Changeset 0.9.0 (#50)

This commit is contained in:
2026-02-23 01:57:28 +00:00
parent 15be26c516
commit 8264aa6016
94 changed files with 9812 additions and 8574 deletions

View File

@@ -1,13 +1,14 @@
# .gitea/workflows/ci.yaml
# ============================================
# Chat Switchboard - CI/CD Pipeline (v0.6.2)
# Chat Switchboard - CI/CD Pipeline (v0.9.0)
# ============================================
# Cluster deployments use SEPARATE FE + BE images.
# Unified image is for Docker Hub only (docker-compose use).
#
# Pipeline:
# 1. Go test (all PRs and pushes)
# 2. Build + Deploy (depends on test passing)
# 1a. Frontend tests (Node.js — contracts, model logic, policy wiring)
# 1b. Go test (all PRs and pushes)
# 2. Build + Deploy (depends on both test jobs passing)
#
# Deployment mapping (single domain, path-based):
# PR → FE + BE :dev → switchboard.DOMAIN/dev/ (DB wipe + fresh schema)
@@ -34,6 +35,7 @@
# POSTGRES_USER, POSTGRES_PASSWORD
# POSTGRES_ADMIN_USER, POSTGRES_ADMIN_PASSWORD
# SWITCHBOARD_ADMIN_USERNAME, SWITCHBOARD_ADMIN_PASSWORD, SWITCHBOARD_ADMIN_EMAIL
# VENICE_API_KEY (live provider integration tests — optional, tests skip if missing)
# DOCKERHUB_USERNAME, DOCKERHUB_TOKEN (optional)
#
# Global Variables (Gitea org-level):
@@ -65,12 +67,34 @@ env:
DOCKERHUB_IMAGE: ${{ vars.DOCKERHUB_IMAGE || 'gobha/chat-switchboard' }}
jobs:
# ── Stage 1: Go Build & Test ─────────────────
# ── Stage 1a: Frontend Tests ─────────────────
# API contract tests, model processing, policy wiring audits.
# Uses Node.js built-in test runner (node --test), zero npm deps.
test-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Verify Node.js
run: |
echo "Node $(node --version)"
NODE_MAJOR=$(node -v | sed 's/v//' | cut -d. -f1)
if [ "$NODE_MAJOR" -lt 21 ]; then
echo "❌ Node >= 21 required for built-in test runner (have v${NODE_MAJOR})"
exit 1
fi
- name: Run frontend tests
run: node --test src/js/__tests__/*.test.js
# ── Stage 1b: Go Build & Test ────────────────
test:
runs-on: ubuntu-latest
env:
GOPRIVATE: git.gobha.me/*
GONOSUMCHECK: git.gobha.me/*
VENICE_API_KEY: ${{ secrets.VENICE_API_KEY }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -142,7 +166,7 @@ jobs:
# ── Stage 2: Build, Database, Deploy ─────────
build-and-deploy:
runs-on: ubuntu-latest
needs: test
needs: [test, test-frontend]
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -223,9 +247,6 @@ jobs:
fi
# ── Database Bootstrap (admin creds) ───────
# Creates the database and app role if they don't exist.
# Idempotent — safe to run every build. Uses admin creds
# that the backend pods never see.
- name: Bootstrap database
env:
PGHOST: ${{ env.POSTGRES_HOST }}
@@ -240,9 +261,6 @@ jobs:
scripts/db-bootstrap.sh
# ── Dev: Upgrade Test (dev only) ───────────
# The dev DB still has the PREVIOUS PR's schema.
# Apply THIS PR's migrations to test the upgrade path.
# If migration fails → CI fails before build.
- name: "Dev: test migration upgrade"
if: steps.env.outputs.DB_WIPE == 'true'
env:
@@ -254,7 +272,6 @@ jobs:
run: |
echo "━━━ Upgrade test: applying migrations to existing dev DB ━━━"
# Ensure tracking table exists (first run or after manual wipe)
psql -v ON_ERROR_STOP=1 <<'SQL'
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(255) PRIMARY KEY,
@@ -296,8 +313,6 @@ jobs:
fi
# ── Dev: Validate Schema ───────────────────
# After upgrade, verify all expected tables/columns exist.
# Catches migrations that apply cleanly but produce wrong schema.
- name: "Dev: validate schema"
if: steps.env.outputs.DB_WIPE == 'true'
env:
@@ -311,9 +326,6 @@ jobs:
scripts/db-validate.sh
# ── Dev Wipe (fresh install test) ──────────
# Upgrade test passed. Now wipe so the deploy tests a
# full fresh-install migration via the backend binary.
# SAFETY: hard-refuses on any database not ending in _dev.
- name: "Dev: wipe for fresh install test"
if: steps.env.outputs.DB_WIPE == 'true'
env:
@@ -325,7 +337,6 @@ jobs:
run: |
DB="${{ steps.env.outputs.DB_NAME }}"
# ── SAFETY: only wipe databases ending in _dev ──
if [[ "${DB}" != *_dev ]]; then
echo "❌ REFUSING to wipe '${DB}' — only *_dev databases can be wiped"
exit 1
@@ -343,11 +354,6 @@ jobs:
SQL
echo "✓ Dev wipe complete — backend will rebuild schema on startup"
# Dev exercises BOTH paths:
# 1. Upgrade test above: existing schema → new migrations (psql)
# 2. Fresh install on deploy: empty DB → all migrations (backend binary)
# Test/Prod: backend applies only pending migrations on startup.
# ── Build Backend Image ──────────────────────
- name: Build backend image
run: |
@@ -485,7 +491,6 @@ jobs:
HEALTH=$(curl -sf "https://${HOST}${BP}/api/v1/health" 2>/dev/null || echo "unreachable")
echo "Health: ${HEALTH}"
# Extract fields (|| true prevents set -e from killing on no-match)
SCHEMA=$(echo "${HEALTH}" | grep -o '"schema_version":"[^"]*"' | cut -d'"' -f4 || true)
DB_OK=$(echo "${HEALTH}" | grep -o '"database":true' || true)

File diff suppressed because it is too large Load Diff

View File

@@ -1,309 +0,0 @@
# Branding — Volume Mount Contract
**Version:** 0.7.3
**Status:** Spec
---
## Overview
Chat Switchboard supports white-label branding through a volume mount at
`/branding/` on the frontend container. Deployers provide a ConfigMap (or
bind mount) with their identity assets. The app reads a JSON config on
startup, serves static assets at runtime, and falls back gracefully when
no branding is mounted.
Switchboard provides the hooks. Your brand lives in its own repo.
---
## Mount Path
```
/branding/ ← volume mount root (frontend container)
├── branding.json ← config seed (required if mount exists)
├── favicon.png ← tab/bookmark icon
├── logo.png ← splash page hero, optional sidebar
└── custom.css ← style overrides (power-user escape hatch)
```
All files are optional individually, but `branding.json` is expected if the
mount exists. Missing files degrade gracefully — no broken images, no JS errors.
---
## branding.json — Config Seed
```json
{
"org_name": "Gobha.ai",
"tagline": "Something clever here",
"accent_color": "#4a9eff",
"logo": "logo.png",
"favicon": "favicon.png"
}
```
**Fields:**
| Field | Type | Default | Description |
|----------------|--------|----------------------|---------------------------------------------|
| `org_name` | string | `"Chat Switchboard"` | Displayed in splash, header, auth card, `<title>` |
| `tagline` | string | `"Multi-Model AI Chat"` | Splash page subtitle, auth footer |
| `headline` | string | `null` | Splash hero headline (default preserved if null) |
| `accent_color` | string | `"#4a9eff"` | Primary UI accent (hex) |
| `logo` | string | `null` | Filename relative to `/branding/` |
| `favicon` | string | `null` | Filename relative to `/branding/` |
| `pills` | array | (Switchboard defaults) | Feature pills on splash. `[]` = hide. See below. |
**Pills format:**
```json
"pills": [
{ "icon": "⚡", "text": "Fast inference", "style": "accent" },
{ "icon": "🔒", "text": "Zero trust", "style": "purple" },
{ "icon": "🏢", "text": "Enterprise ready" }
]
```
`style` is optional: `"accent"` uses the accent color, `"purple"` uses purple,
omit for the default neutral pill. Set `"pills": []` to hide the section entirely.
Omit the field to keep the stock Switchboard pills.
**Lifecycle:**
1. Frontend entrypoint (`docker-entrypoint-fe.sh`) reads `/branding/branding.json` on container start
2. Values are injected into `index.html` as a `<script>` block: `window.__BRANDING__`
3. The `branding` key is also upserted into `global_settings` via the backend on startup
(admin panel override layer — if admins change values in the UI, those take precedence
until the next cold deploy with a branding mount)
4. Frontend `initBranding()` applies values from `window.__BRANDING__` (fast, no API call)
then overlays any DB overrides from `App.serverSettings.branding` (from public settings)
**Resolution order:** `branding.json` (fast init) → DB `global_settings.branding` (override layer)
---
## favicon.png — Static Asset
Served at `/branding/favicon.png` by nginx. The frontend `<link rel="icon">` is
set dynamically by `initBranding()`:
```js
// If branding favicon exists, use it; otherwise keep built-in default
document.querySelector('link[rel="icon"]').href = '/branding/favicon.png';
```
**Recommendations:**
- PNG format (modern browsers prefer it over ICO)
- 32×32 minimum, 192×192 recommended (covers PWA + high-DPI)
- Transparent background works best with dark themes
---
## logo.png — Static Asset
Served at `/branding/logo.png`. Used in:
- Splash page hero (replaces the 🔀 emoji)
- Sidebar header (optional, depends on size)
**Recommendations:**
- PNG with transparency
- Max 512×512 (larger files are wasteful; displayed at ~80px on splash)
- Aspect ratio: square or landscape (tall logos will be clamped)
---
## custom.css — Style Extension
Loaded *after* the main stylesheet:
```html
<link rel="stylesheet" href="/branding/custom.css" onerror="this.remove()">
```
The `onerror` handler silently removes the tag if the file doesn't exist (no 404
console noise in unbrandeded deployments).
**What you can do:**
- Override `--accent-color` and any other CSS custom property
- Change fonts (`@import` or `@font-face` with files in `/branding/`)
- Add background textures or patterns
- Hide elements you don't want (`display: none`)
- Override specific component styles
**What you shouldn't do:**
- Rely on internal class names that may change between versions
- Override layout properties (`flex`, `grid`) unless you're testing against the current release
- Import external resources (breaks airgapped deployments)
**Example:**
```css
:root {
--accent-color: #e74c3c;
--bg-primary: #1a1a2e;
}
.splash-logo img {
border-radius: 50%;
}
```
---
## Frontend Touchpoints
These are the DOM elements and CSS properties that branding affects:
| Element / Property | Default Value | Branding Source |
|--------------------------|----------------------------------|-----------------------|
| `<title>` | `Chat Switchboard` | `org_name` |
| `.brand-text` | `Chat Switchboard` | `org_name` |
| `.hero-wordmark` | `Chat Switchboard` | `org_name` |
| `.hero-headline` | `One interface. Every AI model.` | `headline` |
| `.hero-sub` | (default description) | `tagline` |
| `.splash-logo` | SVG switchboard icon | `logo.png``<img>` |
| `link[rel="icon"]` | `favicon-32.png` | `favicon.png` |
| `--accent-color` | `#4a9eff` | `accent_color` |
| `.auth-card-header h2` | `Welcome back` | `Welcome to {org_name}` |
| `.auth-card-header p` | `Sign in to continue to your workspace` | `Sign in to continue` |
| `.auth-footer p` | `Self-hosted AI chat...` | `tagline` |
| `.hero-features` | Switchboard feature pills | `pills` array or `[]` to hide |
---
## Nginx Configuration
The frontend entrypoint adds a branding location block. For path-based deployments,
the block is under `BASE_PATH` so Traefik routes requests to the correct pod:
```nginx
# Root deployment (no BASE_PATH):
location /branding/ {
alias /branding/;
expires 1h;
add_header Cache-Control "public";
try_files $uri =404;
}
# Path-based deployment (e.g. /dev, /test):
location ${BASE_PATH}/branding/ {
alias /branding/;
expires 1h;
add_header Cache-Control "public";
try_files $uri =404;
}
```
Frontend JS resolves paths via `window.__BASE__ + '/branding/'` so URLs
automatically include the environment prefix.
Short cache (1h) so branding updates via ConfigMap rollout are picked up
without requiring users to hard-refresh.
---
## K8s Deployment
The frontend deployment mounts the branding ConfigMap:
```yaml
spec:
containers:
- name: frontend
volumeMounts:
- name: branding
mountPath: /branding
readOnly: true
volumes:
- name: branding
configMap:
name: switchboard-branding
optional: true # ← app works without it
```
The `optional: true` is critical — Switchboard deploys cleanly with zero
branding config. The `switchboard-gobha-ai` repo (or any deployer's
equivalent) creates this ConfigMap.
---
## Deployer Repo Structure (Example: switchboard-gobha-ai)
```
switchboard-gobha-ai/
├── branding/
│ ├── branding.json
│ ├── favicon.png
│ ├── logo.png
│ └── custom.css
├── k8s/
│ └── configmap.yaml
├── README.md
└── .gitea/
└── workflows/
└── deploy.yaml
```
**configmap.yaml:**
```yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: switchboard-branding
namespace: ${NAMESPACE}
data:
branding.json: |
{
"org_name": "Gobha.ai",
"tagline": "Your tagline",
"accent_color": "#4a9eff"
}
binaryData:
favicon.png: <base64-encoded>
logo.png: <base64-encoded>
custom.css: <base64-encoded-or-use-data>
```
Note: For binary files in ConfigMaps, use `binaryData` with base64 encoding.
Alternatively, use a script that creates the ConfigMap from files:
```sh
kubectl create configmap switchboard-branding \
--from-file=branding/branding.json \
--from-file=branding/favicon.png \
--from-file=branding/logo.png \
--from-file=branding/custom.css \
--dry-run=client -o yaml | kubectl apply -f -
```
---
## Backend Integration
The backend participates in branding in two ways:
1. **Startup seed** (optional, future): If the backend also mounts `/branding/`,
it can read `branding.json` and upsert into `global_settings` alongside the
admin bootstrap. This enables admin-panel overrides without redeployment.
2. **Public settings**: The `branding` key is added to `publicSettingKeys`,
making it available to non-admin users via `GET /api/v1/settings/public`.
For 0.7.3, the frontend reads branding from the static mount at init time.
Backend DB seeding is a future enhancement for admin-panel editing.
---
## Graceful Degradation
| Condition | Behavior |
|----------------------------------|-----------------------------------------------|
| No `/branding/` mount | All defaults. App looks like stock Switchboard |
| Mount exists, no `branding.json` | Static assets served, no text/color overrides |
| `branding.json` missing fields | Each field falls back to its default |
| `logo` field set, file missing | `<img>` gets 404, `onerror` shows emoji fallback |
| `custom.css` missing | `<link>` tag self-removes via `onerror` |
| `favicon.png` missing | Built-in favicon remains |

View File

@@ -41,6 +41,7 @@ RUN apk add --no-cache bash
# Go backend binary
COPY --from=backend /bin/switchboard /usr/local/bin/switchboard
COPY --from=backend /app/database/migrations /app/database/migrations
# Frontend static files
COPY src/ /usr/share/nginx/html/

View File

@@ -1,636 +0,0 @@
# Chat Switchboard — Extension System Specification
**Version:** 0.1 draft
**Status:** Design
**Applies to:** v0.7.x+
**Companion to:** ARCHITECTURE.md (core services + terminology)
---
## 1. Philosophy
Chat Switchboard is a substrate, not an application. The core provides:
authentication, provider routing, message persistence, an event bus, and a
rendering surface. Everything else — editing, writing, cluster management,
cost tracking, custom renderers — is an extension.
The goal is that someone with a problem and some JS (or Go, or Python) can
solve it without forking the project. The "modes" Jeff envisions — Editor,
Article, Chat, Cluster Manager — are just extensions that register surfaces,
tools, and event handlers. This project doesn't have to build all of them.
It just has to make them possible.
---
## 2. Extension Tiers
| | Tier 0: Browser | Tier 1: Starlark | Tier 2: Sidecar |
|---|---|---|---|
| **Runs in** | User's browser | Go server (embedded) | Separate container |
| **Language** | JavaScript | Starlark (Python subset) | Any |
| **Deployed by** | User or Admin push | Admin | Admin |
| **Latency** | Zero (client-side) | Low (in-process) | Network hop |
| **Can access** | DOM, EventBus, LocalStorage, user context | Message data, DB reads (sandboxed) | Anything (HTTP, filesystem, network) |
| **Cannot access** | Server internals, other users' data | Network, filesystem, raw SQL | N/A (full access) |
| **Trust model** | Same-origin; user-scoped or admin-pushed | Starlark sandbox (no I/O) | Container isolation |
| **Use cases** | UI, rendering, shortcuts, client tools, modes | Routing rules, message transforms, logging | RAG, external APIs, webhooks, heavy compute |
All three tiers communicate through the EventBus. A browser extension
publishes `tool.result.{callId}` the same way a sidecar does — the bus
doesn't care where the event originated.
---
## 3. Manifest Format
Every extension, regardless of tier, is described by a manifest:
```json
{
"id": "cost-tracker",
"name": "Cost Tracker",
"version": "1.0.0",
"tier": "browser",
"author": "jeff",
"description": "Real-time token counting and cost estimation",
"permissions": [
"events:chat.message.*",
"events:model.selected",
"dom:input-area",
"storage:local"
],
"entry": "cost-tracker.js",
"hooks": {
"chat.message.send": { "priority": 10, "async": false },
"chat.message.received": { "priority": 50, "async": true }
},
"tools": [],
"surfaces": [],
"settings": {
"showInline": {
"type": "boolean",
"label": "Show cost inline",
"default": true
}
}
}
```
### 3.1 Fields
- **id**: Unique identifier. Namespaced by convention (`jeff.cost-tracker`).
- **tier**: `browser` | `starlark` | `sidecar`
- **permissions**: What the extension needs access to. The loader enforces
these. Undeclared access is blocked (Tier 0 via proxy, Tier 1 via sandbox,
Tier 2 via API scoping).
- **entry**: For browser: JS file path. For starlark: `.star` file. For
sidecar: endpoint URL or Docker image.
- **hooks**: EventBus events this extension subscribes to, with priority
(lower = runs first) and whether the hook is async.
- **tools**: LLM-callable tools this extension provides (see §5).
- **surfaces**: UI surfaces this extension registers (see §6).
- **settings**: User-configurable options, rendered as a form in the
extension settings UI.
---
## 4. Browser Extensions (Tier 0)
### 4.1 Lifecycle
```
Install → Load → Init → Active → Disable → Unload
```
**Install**: Admin pushes manifest + JS to server, or user adds from
settings. Stored in `extensions` table with `tier = 'browser'`.
**Load**: On page load, after `events.js` but before `app.js`, the
extension loader injects `<script>` tags for all enabled browser extensions.
Load order respects declared dependencies.
**Init**: Each extension's entry script calls `Extensions.register()`:
```js
Extensions.register({
id: 'cost-tracker',
init(ctx) {
// ctx.events — scoped EventBus (only permitted events)
// ctx.storage — scoped localStorage wrapper
// ctx.settings — this extension's settings values
// ctx.ui — DOM injection points
this.ctx = ctx;
ctx.events.on('chat.message.send', (msg) => {
const est = this.estimateTokens(msg.content);
ctx.ui.inject('input-area', this.renderCost(est));
});
ctx.events.on('model.selected', ({ capabilities }) => {
this.pricing = this.lookupPricing(capabilities);
});
},
destroy() {
// Cleanup: remove DOM elements, unsubscribe events
},
estimateTokens(text) { /* ... */ },
renderCost(est) { /* ... */ },
lookupPricing(caps) { /* ... */ }
});
```
### 4.2 Context Object
The `ctx` object is the extension's API surface. It's scoped — an
extension that didn't declare `dom:sidebar` in permissions gets a `ctx.ui`
that throws on `inject('sidebar', ...)`.
```
ctx.events — EventBus subscribe/publish (filtered by permissions)
ctx.storage — localStorage namespace (extensions::{id}::*)
ctx.settings — Read-only settings values from manifest
ctx.ui — DOM injection into declared surfaces
ctx.api — Proxied fetch() to backend (auth headers injected)
ctx.model — Current model ID + resolved capabilities
ctx.user — Current user info (id, username, role)
```
### 4.3 Admin-Pushed vs User-Installed
- **Admin-pushed**: `extensions` table row with `is_system = true`. Loaded
for all users. Users cannot disable. JS served from
`/api/v1/extensions/{id}/assets/`.
- **User-installed**: Stored in user settings JSONB. Users can enable/disable.
JS loaded from same asset path or inline (for small scripts).
- Both use the same runtime. The difference is governance, not execution.
### 4.4 Security Model
Browser extensions run in the same origin. They can't be truly sandboxed
without iframes (which breaks DOM injection). The security model is:
1. **Permission declaration** — extensions declare what they need; the
loader enforces it via the `ctx` proxy.
2. **Admin review** — admin-pushed extensions are implicitly trusted.
User-installed extensions get a "this extension can access: ..." prompt.
3. **Event scoping** — extensions only see events they declared in
`permissions`. The scoped EventBus filters unpermitted subscriptions.
4. **CSP headers** — strict Content-Security-Policy prevents inline script
injection. Extension scripts are served from known paths only.
---
## 5. Browser-Defined Tools (The Bridge)
This is the critical innovation. ai-editor proved that LLM tools work in
browser JS. But in Chat Switchboard, the completion handler runs server-side.
The tool call originates from the LLM response, arrives at the Go backend,
and needs to execute in the user's browser.
### 5.1 The Flow
```
User sends message
→ Backend sends to LLM with tools[] from enabled extensions
→ LLM returns tool_call: { name: "read_file", args: {...} }
→ Backend sees tool_call, checks tool registry
→ Tool is tier:browser → Backend publishes via WebSocket:
event: tool.call.{callId}
data: { tool: "read_file", args: {...}, callId: "uuid" }
→ Browser extension receives event, executes tool
→ Extension publishes result via WebSocket:
event: tool.result.{callId}
data: { callId: "uuid", result: "file contents..." }
→ Backend receives result, feeds back to LLM as tool_result message
→ LLM continues with the result
→ Response streams to user
```
### 5.2 Tool Registration
Extensions declare tools in their manifest and implement them:
```json
{
"tools": [
{
"name": "estimate_cost",
"description": "Estimate the token cost of a given text",
"parameters": {
"type": "object",
"properties": {
"text": { "type": "string", "description": "Text to estimate" }
},
"required": ["text"]
},
"tier": "browser"
}
]
}
```
```js
Extensions.register({
id: 'cost-tracker',
init(ctx) {
ctx.tools.handle('estimate_cost', async (args) => {
const tokens = this.estimateTokens(args.text);
return { tokens, estimated_cost_usd: tokens * this.pricing.outputPerM / 1e6 };
});
}
});
```
### 5.3 Server-Side Tool Router
The completion handler maintains a tool registry. When building the tools[]
array for the LLM request:
```go
func (h *CompletionHandler) collectTools(userID string) []Tool {
var tools []Tool
// Tier 1: Starlark tools (server-side, execute inline)
tools = append(tools, h.starlarkTools()...)
// Tier 2: Sidecar tools (server-side, HTTP call)
tools = append(tools, h.sidecarTools()...)
// Tier 0: Browser tools (client-side, routed via WebSocket)
// Only included if user has WebSocket connected
if hub.IsConnected(userID) {
tools = append(tools, h.browserTools(userID)...)
}
return tools
}
```
When a tool_call arrives and the tool is `tier: browser`:
```go
case "browser":
callId := uuid.New().String()
// Publish to user's WebSocket
bus.Publish(events.Event{
Type: "tool.call." + callId,
Data: toolCallData,
Room: "user:" + userID,
})
// Wait for result (with timeout)
result, err := bus.WaitFor("tool.result." + callId, 30*time.Second)
```
### 5.4 Timeout and Fallback
Browser tools have a 30-second timeout. If the user's browser disconnects
or the tool fails, the backend sends a tool_result with an error message
to the LLM so it can recover gracefully. This is no different from how
ai-editor handles tool failures — the LLM gets an error and adapts.
### 5.5 Why This Matters
This means an extension author can write a tool in 20 lines of JavaScript
that the LLM can call. No Go code. No container. No deployment. The
cluster manager extension defines `kubectl_get`, `ceph_status`,
`node_drain` as browser tools. The editor extension defines `read_file`,
`write_file`, `search_replace`. The article extension defines
`fetch_source`, `check_citation`.
The LLM doesn't know or care where the tool executes. It sees a tool
schema, calls it, gets a result. The bus handles the routing.
---
## 6. Surfaces (Modes)
A "mode" is an extension that registers a **surface** — a UI region that
replaces or augments the default chat area. The core app provides injection
points:
```
┌─────────────────────────────────────────────┐
│ sidebar-top │ surface-header │
│ │ │
│ sidebar-nav │ │
│ (mode selector) │ surface-main │
│ │ (chat, editor, article, │
│ sidebar-content │ cluster, ...) │
│ (context panel) │ │
│ │ │
│ sidebar-bottom │ surface-footer │
│ │ (input area) │
└─────────────────────────────────────────────┘
```
### 6.1 Surface Registration
```json
{
"surfaces": [
{
"id": "editor",
"label": "Editor",
"icon": "code",
"regions": ["surface-main", "surface-footer", "sidebar-content"],
"default": false
}
]
}
```
```js
Extensions.register({
id: 'editor-mode',
init(ctx) {
ctx.surfaces.register('editor', {
activate() {
// Replace surface-main with editor UI
ctx.ui.replace('surface-main', this.renderEditor());
ctx.ui.replace('surface-footer', this.renderEditorInput());
ctx.ui.replace('sidebar-content', this.renderFileTree());
},
deactivate() {
// Restore defaults
ctx.ui.restore('surface-main');
ctx.ui.restore('surface-footer');
ctx.ui.restore('sidebar-content');
}
});
}
});
```
### 6.2 The Core Surfaces
Chat mode is just the default surface. It's not special — it's the surface
that's active when no extension surface is selected. In theory, even chat
mode could be extracted into an extension, but pragmatically it stays in
core because everything depends on it.
### 6.3 Mode Selector
When extensions register surfaces, a mode selector appears in the sidebar
(below the brand, above chat history). Clicking a mode calls `activate()`
on that surface and `deactivate()` on the current one.
The bus event `surface.activated` fires so other extensions can react.
The cost tracker might show different metrics in editor mode vs chat mode.
### 6.4 Jeff's Planned Modes
**Chat Mode** (core)
- What exists today, plus tool calling and richer message types.
- The "default surface" that everything else builds on.
**Editor Mode** (extension)
- Surfaces: file tree in sidebar, code editor in main, AI chat in a
split pane or overlay.
- Tools: `read_file`, `write_file`, `search_replace`, `run_command`,
`git_status`, `git_commit` — all browser tools backed by a git
provider API (GitHub, Gitea).
- This is ai-editor rebuilt properly: the tool definitions are JS,
the LLM calls happen through the standard completion handler, and
the file operations go through a git provider abstraction.
- The key difference from ai-editor: the completion handler is
server-side, so tool calls are logged, token-counted, and auditable.
**Article Mode** (extension)
- Surfaces: outline in sidebar, rich text editor in main, AI assistant
in a panel.
- Tools: `fetch_url`, `summarize_section`, `check_citation`,
`suggest_structure`.
- Think: a writing environment where the AI is a research assistant,
not a chatbot. The conversation is hidden; only the document matters.
**Cluster Manager Mode** (extension — or extensions plural)
- This one is interesting because it's actually 2-3 cooperating
extensions:
- `k8s-manager`: tools for `kubectl` operations, surfaces for
pod/deployment views.
- `ceph-manager`: tools for `ceph status`, OSD management, pool
operations.
- `node-manager`: tools for SSH commands, system metrics,
package management.
- These extensions talk to each other via the EventBus. When
`k8s-manager` detects a node is NotReady, it publishes
`cluster.node.unhealthy`. `node-manager` subscribes and surfaces
diagnostics. `ceph-manager` subscribes and checks if that node
had OSDs.
- The LLM sees ALL the tools from ALL active cluster extensions.
"Drain node-3, ensure Ceph rebalances, then cordon it" becomes
a multi-tool conversation where the LLM calls `kubectl_drain`,
then `ceph_osd_out`, then `kubectl_cordon` — each routed to the
appropriate extension.
- This is what you do manually with Claude Desktop today. The
extension system makes it one coherent interface.
---
## 7. Extension Loader Architecture
### 7.1 Load Order
```html
<!-- Core -->
<script src="js/events.js"></script>
<script src="js/extensions.js"></script> <!-- NEW: loader + registry -->
<!-- Extensions (injected by loader) -->
<script src="/api/v1/extensions/cost-tracker/assets/main.js"></script>
<script src="/api/v1/extensions/editor-mode/assets/main.js"></script>
<!-- App (runs after extensions registered) -->
<script src="js/api.js"></script>
<script src="js/ui.js"></script>
<script src="js/app.js"></script>
```
### 7.2 extensions.js (Core)
The extension loader and registry. ~200 lines. Responsibilities:
- Fetch enabled extensions list from `/api/v1/extensions?tier=browser`
- Inject script tags in dependency order
- Provide `Extensions.register()` API
- Build scoped `ctx` objects per extension (permission enforcement)
- Manage surface activation/deactivation
- Collect browser tool schemas for the completion handler
- Route `tool.call.*` events to the correct handler
- Provide `Extensions.list()`, `Extensions.get()`, `Extensions.settings()`
### 7.3 Backend Support
New tables (migration 006):
```sql
CREATE TABLE extensions (
-- already exists from 001, but needs columns:
tier VARCHAR(20) NOT NULL DEFAULT 'browser', -- browser, starlark, sidecar
manifest JSONB NOT NULL,
assets_path TEXT, -- filesystem path for browser JS
endpoint TEXT, -- URL for sidecar
script TEXT, -- inline Starlark source
installed_by UUID REFERENCES users(id),
is_system BOOLEAN DEFAULT false
);
CREATE TABLE extension_user_settings (
extension_id UUID REFERENCES extensions(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
settings JSONB DEFAULT '{}',
is_enabled BOOLEAN DEFAULT true,
PRIMARY KEY (extension_id, user_id)
);
```
New endpoints:
```
GET /api/v1/extensions — list enabled for current user
GET /api/v1/extensions/:id/manifest — get manifest
GET /api/v1/extensions/:id/assets/*path — serve browser JS
POST /api/v1/extensions/:id/settings — update user settings
POST /api/v1/admin/extensions — install extension
DELETE /api/v1/admin/extensions/:id — uninstall
PUT /api/v1/admin/extensions/:id — update (enable/disable, config)
```
### 7.4 Tool Registry Integration
The completion handler's tool collection expands:
```go
func (h *CompletionHandler) collectTools(userID string, configID string) []ToolSchema {
var tools []ToolSchema
// 1. Model-native tools (if provider supports them)
caps := h.getModelCapabilities(model, configID)
if !caps.ToolCalling {
return nil // Model doesn't support tools, skip everything
}
// 2. Server-side extension tools (Starlark + Sidecar)
tools = append(tools, h.serverTools(userID)...)
// 3. Browser tools (only if WebSocket connected)
if h.hub.IsConnected(userID) {
tools = append(tools, h.browserTools(userID)...)
}
return tools
}
```
---
## 8. EventBus Integration
The routing table from events/types.go expands:
```go
var Routes = map[string]Direction{
// Core
"chat.message.*": DirBoth,
"user.presence": DirToClient,
// Tool execution
"tool.call.*": DirToClient, // Server → specific client
"tool.result.*": DirFromClient, // Client → server
// Surfaces
"surface.activated": DirLocal, // Client-only
"surface.deactivated": DirLocal,
// Extension lifecycle
"extension.loaded": DirLocal,
"extension.error": DirLocal,
// Cross-extension (cluster manager example)
"cluster.node.*": DirLocal, // Between browser extensions
"cluster.alert.*": DirBoth, // Could notify server too
}
```
Browser extensions use `Events.on()` and `Events.emit()` — the same API
the core app uses. Events with `DirBoth` or `DirFromClient` cross the
WebSocket. `DirLocal` events stay in the browser. This means cluster
manager extensions can coordinate locally at zero latency, and only
publish to the server when something needs persistence or notification.
---
## 9. Implementation Roadmap
### Phase A: Foundation
- [ ] `extensions.js` — loader, registry, scoped context
- [ ] `Extensions.register()` API with permission enforcement
- [ ] Manifest format parser and validator
- [ ] Admin endpoints for extension management
- [ ] Asset serving endpoint
- [ ] Extension settings UI in Settings modal
### Phase B: Browser Tools
- [ ] `ctx.tools.handle()` API for browser tool registration
- [ ] Tool schema collection in completion handler
- [ ] `tool.call.*` / `tool.result.*` WebSocket routing
- [ ] Timeout and error handling for browser tools
- [ ] Tool execution in EventBus `WaitFor` pattern
- [ ] Tool use display in chat messages
### Phase C: Surfaces
- [ ] Surface registration and activation API
- [ ] Mode selector in sidebar
- [ ] `ctx.ui.replace()` / `ctx.ui.restore()` for region management
- [ ] `surface.activated` / `surface.deactivated` events
### Phase D: First Extensions
- [ ] Cost tracker (browser, proof of concept)
- [ ] Slash commands (browser, message transforms)
- [ ] Custom renderers (browser, mermaid/latex/etc)
- [ ] Editor mode (browser, with git provider tools)
### Phase E: Server-Side Tiers
- [ ] Starlark runtime integration
- [ ] Sidecar HTTP tool protocol
- [ ] Server-side tool execution in completion handler
- [ ] Web search as sidecar extension
---
## 10. Design Principles
1. **Extensions are first-class.** The system is designed so that a mode
or feature implemented as an extension is indistinguishable from one
built into core. No second-class citizens.
2. **The EventBus is the spine.** Extensions don't import each other.
They publish and subscribe to events. This is how the cluster manager
extensions cooperate without knowing about each other at build time.
3. **Tools are location-transparent.** The LLM sees a tool schema. It
doesn't know if the tool runs in the browser, in a Starlark sandbox,
or in a container in another data center. The routing is the platform's
problem.
4. **Permissions are declared, not discovered.** An extension says what it
needs upfront. The loader enforces it. No ambient authority.
5. **The core stays small.** Auth, provider routing, message persistence,
event bus, extension loader. That's core. Everything else is an
extension — even if it ships with the project.
6. **Progressive capability.** A browser extension with zero tools and
zero surfaces is just an EventBus subscriber. Add a tool and the LLM
can call it. Add a surface and it becomes a mode. The same manifest
format scales from "show token count" to "full IDE."

View File

@@ -1,36 +0,0 @@
# Development Workflow
## Branches
- `main` — stable, deployable
- `feature-*` — feature branches, merge to main via PR
## Issue Format
Title: `[CATEGORY] Description`
Categories: `BACKEND`, `FRONTEND`, `DEVOPS`, `TESTING`, `DOCS`, `BUG`
## Commit Messages
```
[Category] Short description
Longer explanation if needed. Closes #XX
```
## PR Checklist
- [ ] Code follows existing style
- [ ] Tested manually
- [ ] No breaking API changes (or documented)
- [ ] Docs updated if user-facing
- [ ] Screenshots if UI changes
## Current Priorities
1. UX polish (chat search, keyboard shortcuts, PWA)
2. Teams + RBAC (0.8.0)
3. Audit + Usage tracking (0.8.x)
See [ROADMAP.md](ROADMAP.md) for detail.

332
README.md
View File

@@ -1,192 +1,222 @@
# 🔀 Chat Switchboard
# Chat Switchboard
**Multi-Model AI Chat Platform — Self-Hosted, Extensible, Fast**
A self-hosted AI chat interface that routes conversations across multiple LLM providers. Built with a Go backend for performance and a clean vanilla JS frontend inspired by Open WebUI.
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Go Version](https://img.shields.io/badge/Go-1.22+-00ADD8?logo=go)](https://go.dev/)
---
## What It Does
- **Multi-provider routing** — Connect OpenAI, Anthropic, Venice.ai, Ollama, or any OpenAI-compatible API. Switch models per-conversation.
- **Streaming responses** — SSE-based streaming with stop button, thinking block display, and full markdown rendering.
- **Per-user API keys** — Each user manages their own provider keys. Admins can set global keys shared across the instance.
- **Admin panel** — User management, global provider config, model visibility, registration toggle, usage stats.
- **Self-hosted** — Single Docker image, PostgreSQL backend. No external dependencies at runtime.
---
A self-hosted AI chat application for enterprise and government environments. Unified interface for multiple AI providers with admin controls, team management, and security features.
## Quick Start
### Docker (Recommended)
```bash
git clone https://git.gobha.me/xcaliber/chat-switchboard.git
cd chat-switchboard
cp server/.env.example server/.env
# Edit .env: set DATABASE_URL, JWT_SECRET, etc.
docker-compose up -d
# Clone and start
git clone <repo-url> && cd chat-switchboard
docker compose up -d
# Access at http://localhost:3000
# Default admin: admin / admin
```
Access at `http://localhost:3000`. First registered user becomes admin.
## Features
### Manual
```bash
# Backend
cd server
cp .env.example .env
go build -o switchboard .
./switchboard
# Frontend — serve src/ with any HTTP server
cd ../src
python3 -m http.server 8080
```
Point the frontend at the backend by setting the API base URL (defaults to same-origin).
---
- **Multi-Provider**: Anthropic, OpenAI, OpenRouter, Venice AI — with BYOK support
- **Team Management**: Roles (admin/user) for vertical permissions, Teams for horizontal visibility
- **Personas**: Preset configurations (model + system prompt + parameters) at global, team, or personal scope
- **Model Catalog**: Three-state visibility (enabled/disabled/team-only) with admin controls
- **Message Trees**: Edit and regenerate with full conversation forking — navigate sibling branches
- **Notes**: Markdown notes with full-text search, folders, and tags
- **Audit Log**: All admin operations logged with actor, action, and resource details
- **Security**: JWT + refresh tokens, optional mTLS, optional OIDC/Keycloak, environment classification banners
- **Airgapped**: Local vendor files (marked.js, DOMPurify), no CDN required
- **Mobile**: Responsive design, PWA manifest
## Architecture
```
┌──────────────────────────┐
Frontend (Vanilla JS)
4 files + vendor libs
│ No build step required
└───────────┬──────────────┘
│ REST + SSE
┌──────────────────────────┐
│ Go Backend │
│ ├── Auth (JWT + refresh)│
│ ├── Chat CRUD │
│ ├── Completion proxy │
│ ├── Provider management │
│ ├── Admin endpoints │
│ └── Static file server │
└───────────┬──────────────┘
┌─────────────┐ ┌──────────────────────────────┐
Browser │────▶│ nginx (port 80)
(vanilla JS)│◀────│ ├─ /api/* → Go backend:8080
└─────────────┘ │ ├─ /ws → WebSocket proxy
│ └─ /* → static files │
└──────────────────────────────┘
PostgreSQL
┌─────────▼─────────┐
│ Go Backend │
│ ├─ handlers/ │
│ ├─ store/postgres/ │
│ ├─ providers/ │
│ └─ capabilities/ │
└─────────┬─────────┘
┌─────────▼─────────┐
│ PostgreSQL 16 │
└───────────────────┘
```
### Frontend (src/)
**Go backend** (vanilla, no framework beyond Gin) with a store layer abstracting all database access. Providers package handles LLM API calls. Capabilities package resolves model features from catalog data, known model tables, and heuristic inference.
| File | Size | Purpose |
|------|------|---------|
| `api.js` | 240 lines | HTTP client, token management, auto-refresh on 401 |
| `app.js` | 490 lines | State, init flow, auth, chat CRUD, event wiring |
| `ui.js` | 510 lines | DOM rendering, streaming, modals, formatting |
| `debug.js` | 550 lines | Console/network intercept, state inspector (Ctrl+Shift+L) |
| `vendor/` | 62KB | marked.js + DOMPurify (local, CDN fallback) |
No framework. No build step. No node_modules. Works in disconnected environments with vendor libs baked in.
### Backend (server/)
Go 1.22, ~26 source files. Key packages:
- `handlers/` — Auth, chats, messages, completions, API configs, admin
- `middleware/` — JWT auth, admin role check, rate limiting, error handling, logging
- `providers/` — OpenAI and Anthropic adapters with streaming support
- `models/` — Database models
- `database/` — PostgreSQL connection + migration runner
- `config/` — Environment-based configuration
### API Surface
| Route | Method | Auth | Description |
|-------|--------|------|-------------|
| `/health` | GET | — | Health check + version |
| `/api/v1/auth/register` | POST | — | Create account |
| `/api/v1/auth/login` | POST | — | Get JWT tokens |
| `/api/v1/auth/refresh` | POST | — | Rotate access token |
| `/api/v1/chats` | GET/POST | ✓ | List / create chats |
| `/api/v1/chats/:id` | GET/PUT/DELETE | ✓ | Chat CRUD |
| `/api/v1/chats/:id/messages` | GET | ✓ | Message history |
| `/api/v1/chat/completions` | POST | ✓ | Streaming SSE or sync JSON |
| `/api/v1/models/enabled` | GET | ✓ | Available models |
| `/api/v1/api-configs` | GET/POST/DELETE | ✓ | User provider management |
| `/api/v1/profile` | GET/PUT | ✓ | User profile |
| `/api/v1/settings` | GET/PUT | ✓ | User settings (persisted) |
| `/api/v1/admin/*` | various | admin | User/provider/model/settings management |
---
**Frontend** is vanilla JavaScript — no build step, no bundler. Five files: `api.js` (HTTP client), `app.js` (state + logic), `ui.js` (DOM rendering), `events.js` (event bus + WebSocket), `debug.js` (admin debug panel).
## Configuration
All via environment variables (see `server/.env.example`):
All configuration via environment variables. See `server/.env.example` for the full list.
| Variable | Default | Description |
|----------|---------|-------------|
| `PORT` | `8080` | Backend listen port |
| `DATABASE_URL` | — | PostgreSQL connection string |
| `JWT_SECRET` | — | Token signing key |
| `JWT_EXPIRY` | `15m` | Access token TTL |
| `REFRESH_EXPIRY` | `7d` | Refresh token TTL |
| `CORS_ORIGINS` | `*` | Allowed origins |
| `REGISTRATION_ENABLED` | `true` | Allow new signups |
---
| `BASE_PATH` | ` ` | URL prefix (e.g. `/chat`) |
| `DB_HOST` | `localhost` | PostgreSQL host |
| `DB_NAME` | `chat_switchboard` | Database name |
| `JWT_SECRET` | (required) | Token signing key |
| `SWITCHBOARD_ADMIN_USERNAME` | ` ` | Bootstrap admin username |
| `SWITCHBOARD_ADMIN_PASSWORD` | ` ` | Bootstrap admin password |
## Deployment
### Docker Compose (unified)
Three Docker images support different scenarios:
The provided `Dockerfile` is a 3-stage build:
1. `golang:1.22-bookworm` — compiles Go backend
2. `node:20-alpine` — downloads vendor JS libs via `npm pack`
3. `nginx:1-alpine` — serves frontend, proxies `/api/` to Go backend
| Image | Dockerfile | Use Case |
|-------|-----------|----------|
| **Unified** | `Dockerfile` | Dev, docker-compose, single-node |
| **Backend** | `server/Dockerfile` | K8s — scale API pods independently |
| **Frontend** | `Dockerfile.frontend` | K8s — scale FE pods independently |
Vendor libs are baked into the image at build time — no CDN access needed at runtime.
### Docker Compose (development — unified image)
### Reverse Proxy
```bash
docker compose up -d # start all services
docker compose up -d --build # rebuild after code changes
docker compose --profile dev up # include Adminer DB UI
```
If running behind nginx/Caddy, proxy `/api/` and `/health` to the Go backend. Serve `src/` as static files.
### Kubernetes (split images)
---
Build and push both images:
```bash
docker build -f server/Dockerfile -t your-registry/switchboard-api:0.9.0 server/
docker build -f Dockerfile.frontend -t your-registry/switchboard-fe:0.9.0 .
```
**Backend deployment:**
```yaml
containers:
- name: api
image: your-registry/switchboard-api:0.9.0
ports:
- containerPort: 8080
env:
- name: DB_HOST
value: "postgres-service"
- name: BASE_PATH
value: "/chat" # must match ingress path
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: switchboard-secrets
key: jwt-secret
```
**Frontend deployment:**
```yaml
containers:
- name: frontend
image: your-registry/switchboard-fe:0.9.0
ports:
- containerPort: 80
env:
- name: BASE_PATH
value: "/chat" # injected into index.html at startup
volumeMounts:
- name: branding
mountPath: /branding
readOnly: true # optional: custom logo, colors
```
**Ingress** routes `/api/*` and `/ws` to the backend Service, everything else to the frontend Service. The frontend entrypoint generates the nginx config dynamically based on `BASE_PATH`.
### Path-Based Routing
Set `BASE_PATH=/chat` to serve the application under a subpath. The frontend reads `window.__BASE__` injected at container startup, and all API calls are prefixed automatically.
### Airgapped / Disconnected
The Docker build bakes in `marked.js` and `DOMPurify` from npm during the vendor stage. No CDN calls at runtime. The `src/vendor/` directory contains local copies as fallback for development without Docker.
## Database
PostgreSQL 16+ required. The `pgcrypto` extension is used for `gen_random_uuid()`.
### Schema Management
The Go backend auto-migrates on startup using files in `server/database/migrations/`. For manual operations:
```bash
# Bootstrap database (superuser, creates role + DB)
scripts/db-bootstrap.sh
# Manual migration (usually not needed)
scripts/db-migrate.sh
# Validate schema
scripts/db-validate.sh
```
### Key Tables (v0.9)
| Table | Purpose |
|-------|---------|
| `users` | Accounts with role, avatar, settings |
| `provider_configs` | API provider configurations (scope: global/team/personal) |
| `model_catalog` | Synced model list with capabilities and visibility |
| `personas` | Model presets (scope: global/team/personal) |
| `channels` | Conversations with type (direct/group/channel) |
| `messages` | Message tree with parent_id for forking |
| `teams` / `team_members` | Organizational units |
| `notes` | Markdown notes with full-text search |
| `audit_log` | Admin action audit trail |
| `user_model_settings` | Per-user model visibility and sort preferences |
## API
All endpoints under `/api/v1/`. Authentication via `Authorization: Bearer <token>` header.
### Auth
- `POST /auth/register` — Register (if `allow_registration` policy is true)
- `POST /auth/login` — Login, returns access + refresh tokens
- `POST /auth/refresh` — Refresh access token
- `POST /auth/logout` — Revoke refresh token
### Channels & Messages
- `GET/POST /channels` — List/create conversations
- `GET/PUT/DELETE /channels/:id` — Channel CRUD
- `GET/POST /channels/:id/messages` — Message list/create
- `POST /chat/completions` — Stream AI completions (SSE)
- `POST /channels/:id/messages/:msgId/edit` — Edit and fork
- `POST /channels/:id/messages/:msgId/regenerate` — Regenerate response
### Models
- `GET /models/enabled` — Models available to the user
- `GET/PUT /models/preferences` — User model visibility settings
### Admin
- `GET/POST /admin/users` — User management
- `GET/PUT /admin/settings/:key` — Global settings and policies
- `GET/POST /admin/configs` — Global provider configs
- `GET/POST /admin/models/fetch` — Model catalog sync
- `GET /admin/audit` — Audit log
## Development
```bash
# Backend (hot reload with air)
# Backend (requires Go 1.22+)
cd server
go install github.com/air-verse/air@latest
air
cp .env.example .env # edit with your DB credentials
go run .
# Frontend just edit files, hard-refresh browser
# Debug: Ctrl+Shift+L opens debug modal
# Frontend (just serve static files)
# Use any HTTP server pointed at src/
python3 -m http.server 3000 --directory src
```
### Database Migrations
Migrations in `migrations/` run automatically on startup. Current schema:
- `001_full_schema.sql` — users, chats, messages, api_configs
- `002_refresh_tokens.sql` — token rotation
- `003_global_settings.sql` — admin settings table
- `004_model_configs.sql` — per-model enable/disable
---
## Roadmap
See [ROADMAP.md](ROADMAP.md) for the full plan. Next up:
1. **WebSocket hub** — real-time message delivery, typing indicators
2. **Channels** — multi-user + AI chat rooms with @mentions
3. **Plugin system** — Go-native extensions, installable via admin UI
4. **Notes & Knowledge Base** — markdown notes, document upload, RAG via pgvector
---
## License
MIT — build anything, including commercial products.
---
**Repository:** https://git.gobha.me/xcaliber/chat-switchboard
Proprietary. All rights reserved.

View File

@@ -1,460 +1,44 @@
# 🗺️ Chat Switchboard — Roadmap
**See also:**
- [ARCHITECTURE.md](ARCHITECTURE.md) — Core services design (Notes, KBs, Tasks, Channels, Embeddings)
- [EXTENSIONS.md](EXTENSIONS.md) — Extension system spec (Browser/Starlark/Sidecar tiers, tools, surfaces)
**Versioning (pre-1.0):** `0.<major>.<minor>` — hotfixes use quad: `0.x.y.z`
No compatibility guarantees before 1.0. Post-1.0: major = long-lived (break nothing),
minor = add features (deprecate, don't remove), patch = fix something.
---
## Current State: v0.7.4
### ✅ Done
**Backend (Go)**
- [x] PostgreSQL schema + auto-migrations (go:embed, startup)
- [x] JWT auth with refresh token rotation
- [x] **Unified channel model** — chats→channels, "everything is a channel"
- [x] Channel CRUD + message persistence (`/api/v1/channels`)
- [x] Message tree (`parent_id`) with linear backfill
- [x] Participant tracking (`participant_type`/`participant_id` on messages)
- [x] `channel_members` + `channel_models` tables (schema foundation)
- [x] `channel_cursors` for branch tracking (schema foundation)
- [x] Folders + Projects tables for organization
- [x] Environment banner system (global_settings, admin presets, position control)
- [x] Streaming completion proxy (OpenAI + Anthropic + Venice + OpenRouter)
- [x] Per-user + global API config management
- [x] Admin endpoints (users, providers, models, settings, stats)
- [x] Admin bootstrap from env vars (K8s secret → upsert on every restart)
- [x] Registration with pending state (admin approval workflow)
- [x] Registration default state setting (active / pending)
- [x] User providers toggle (admin can restrict to global-only providers)
- [x] Bulk model enable/disable
- [x] Public settings endpoint (non-admin users read safe subset)
- [x] Rate limiting, error middleware, request logging
- [x] Health check endpoint (includes schema_version)
- [x] EventBus with WebSocket hub (rooms, JWT auth, heartbeat, reconnection)
- [x] Provider capability system (known models, heuristic detection, resolution chain)
- [x] Dynamic max_tokens resolution
- [x] User + admin provider model listing with capabilities
- [x] Model presets (named wrappers: global/personal scope, system prompt, temp, max_tokens)
- [x] Preset unwrap in completion handler (transparent to provider)
- [x] Tool framework: registry, type system, execution loop (tool→result→model)
- [x] Note tools: note_create, note_search, note_update, note_list
- [x] Notes API: CRUD, full-text search (tsvector), folder listing
- [x] Provider tool calling: OpenAI + Anthropic function calling in request/response/stream
- [x] Message tree: edit creates sibling, regenerate creates sibling, cursor tracking
- [x] Avatar system: user + preset avatars, server-side resize to 128×128 PNG
**Frontend (Vanilla JS)**
- [x] Professional splash page (split-panel hero + tabbed auth)
- [x] Split "New Chat" button with dropdown (Group Chat, Channel — coming soon)
- [x] Environment banner system (CSS custom props + JS init from settings)
- [x] Collapsible sidebar with time-grouped chat history
- [x] User menu flyout (Settings, Admin, Debug, Sign Out)
- [x] Model selector with capability badges (output, context, tools, vision, thinking)
- [x] Settings modal with tabs (General, Providers, Models)
- [x] Admin modal with tabs (Users, Providers, Models, Settings, Stats)
- [x] Admin settings: registration, user providers, banner config with presets
- [x] Pending user badge + approve workflow in admin Users tab
- [x] Streaming SSE display with smart scroll
- [x] Full markdown rendering (marked.js + DOMPurify, vendor + CDN fallback)
- [x] Thinking block display (`<think>`/`<thinking>` tags)
- [x] Debug modal (console intercept, network log, state inspector)
- [x] EventBus client with exponential backoff + max retries
- [x] Export (Markdown, JSON, Text)
- [x] Model selector with preset grouping (⚡ Presets optgroup)
- [x] Admin Presets tab (create, toggle, delete global presets)
- [x] Preset-aware completion flow (preset_id sent to backend)
- [x] Custom dropdown for model selector (full CSS control, dark theme)
- [x] Admin edit buttons for providers and presets (inline form reuse)
- [x] Appearance settings tab (UI scale, message font size)
- [x] Mobile responsive layout (hamburger menu, sidebar overlay, dvh)
- [x] Model/preset name in message headers (replaces generic "Assistant")
- [x] Notes modal: list/detail views, folder sidebar, create/edit/delete, search
- [x] Message editing + forking: inline edit, regen, branch navigation 1/2
- [x] Conversation path: active-path context assembly, cursor-aware
- [x] White-label branding: volume mount, org name, logo, favicon, accent color, pills
- [x] Avatar system: user profile upload, preset avatars, avatar in messages/sidebar/dropdown
- [x] Chat search / filter in sidebar (real-time title filtering)
- [x] Command palette (Ctrl+K) with fuzzy search, chat jumping, keyboard nav
- [x] PWA manifest + service worker (offline shell, install prompt)
**CI/CD (Gitea Actions)**
- [x] Three-env pipeline (dev/test/prod)
- [x] Dev: upgrade test → schema validate → wipe → fresh install test
- [x] Backend auto-migrates on startup (no CI migration step)
- [x] Admin secret sync (Gitea secrets → K8s secret → env vars)
- [x] Shared PG safety (_dev suffix guard on wipe)
- [x] Post-deploy schema verification via /api/v1/health
**Architecture Design**
- [x] ARCHITECTURE.md: core services spec (13 sections)
- [x] EXTENSIONS.md: three-tier extension system spec
- [x] "Everything is a channel" — unified model with type:'direct'
- [x] Message tree (parent_id for conversation forking)
- [x] Auth strategy design (builtin/mTLS/OIDC)
---
## ~~0.7.0 — Custom Models / Presets~~ ✅
Named wrappers around base models with bundled configuration. Admins create
org-wide presets, users create personal ones (if user providers are enabled).
- [x] `model_presets` table: name, base_model_id, system_prompt, temperature,
max_tokens, tools_enabled (jsonb), created_by, scope (global/team/personal),
team_id (nullable, for future team scoping), is_shared
- [x] Permission gating: personal presets require user_providers_enabled
- [x] Admin preset management UI (create, edit, delete org-wide presets)
- [x] User preset management UI in Settings (personal presets)
- [x] Presets appear as first-class entries in model selector
("Code Reviewer (GPT-4o)", "Research Assistant (Claude Opus)")
- [x] Completion handler unwraps preset → base model + config overrides
## ~~0.7.x — Branding + Polish~~ ✅
White-label support and UX improvements that exploit existing infrastructure.
**Branding**
- [x] Admin branding settings in global_settings: org name, tagline, accent color
- [x] `/branding/` volume mount (K8s ConfigMap, optional) for favicon, logo
- [x] Splash page reads branding config on load, falls back to Switchboard defaults
- [x] CSS accent color override from branding settings
**Profile Pictures / Avatars**
- [x] `avatar_url` column on `users` table (already existed, now wired)
- [x] `avatar` column on `model_presets` (migration 015)
- [x] Avatar upload in user Settings → Profile section (server-side resize to 128×128)
- [x] Admin/user preset avatar upload endpoint
- [x] `avatarHTML()` helper: returns `<img>` if avatar set, emoji fallback otherwise
- [x] Avatar displayed in message headers, stream, typing indicator, sidebar user area
**Message Editing + Forking**
- [x] Edit message → creates sibling (uses existing parent_id tree)
- [x] Regenerate → creates sibling model response
- [x] Branch indicator at fork points (← 1/2 →)
- [x] Context assembly follows active path, not full channel history
**UX Polish**
- [x] Chat search / filter in sidebar
- [x] UI preferences: font size + UI scale (Appearance tab, localStorage)
- [x] Mobile responsive: hamburger menu, sidebar overlay, auto-collapse
- [x] Model name in message headers (preset name or model ID, not "Assistant")
- [x] Keyboard shortcuts (Ctrl+K command palette)
- [x] PWA manifest + offline shell + install prompt
---
## 0.8.0 — Teams + Onboarding
The missing middle tier: scoped administration without system-admin access.
**Schema**
- [x] `teams` table: id, name, description, created_by
- [x] `team_members` table: team_id, user_id, role ('admin' | 'member')
- [x] Add `team_id` (nullable) to: model_presets, channels, notes
- [x] Team admin role: scoped per-team, not system-wide
(one person can be admin of Team A, member of Team B)
**Onboarding**
- [x] Registration approval → assign role + team(s) during approval
(upgrade from current binary approve → assign-and-activate)
- [x] System admin creates teams + assigns first team admin
- [x] Team admin self-manages: add/remove members
(RequireTeamAdmin middleware, scoped /teams/:teamId routes)
- [x] Team admin: create team presets (Settings → Teams tab, self-service UI)
- [ ] Team admin: manage team channels, view team usage
**Private Provider Policy**
- [x] `is_private` flag on provider configs (marks local/self-hosted endpoints)
- [x] `require_private_providers` policy per team (via teams.settings JSONB)
- [x] Completion handler enforces policy — team members restricted to private providers
- [ ] Enables HIPAA/compliance posture: provable data boundary per team
## 0.8.x — Audit + Usage Tracking
Required for enterprise and compliance. Cheap to build, expensive to retrofit.
**Audit Log**
- [x] `audit_log` table: actor_id, action, resource_type, resource_id,
metadata (jsonb), ip_address, timestamp
- [x] Every mutating handler inserts audit entry
(user CRUD, team CRUD, member management, presets, auth)
- [x] Admin audit viewer (filter by action, resource type, paginated)
- [ ] Team admin sees audit entries scoped to their team
**Model Visibility (0.8.3)**
- [x] Three-state visibility: enabled (all users) / team (preset-building only) / disabled
- [x] Migration replaces `is_enabled` boolean with `visibility` varchar
- [x] Admin model list: 3-state cycle button, bulk set all to any state
- [x] `GET /teams/:teamId/models` returns enabled+team models for preset builders
- [x] User-facing `ListEnabledModels` strictly filters `visibility = 'enabled'`
**Preset Form Unification + UX (0.8.4)**
- [x] Single `renderPresetForm(options)` function shared across admin/team contexts
— parameterized: showAvatar, showProviderConfig, onSubmit, onCancel
- [x] Remove icon field from preset forms (not rendered anywhere meaningful)
- [x] Team admin preset form includes avatar upload (reuse admin component)
- [x] Hide Providers tab in Settings when `user_providers_enabled` is false
- [x] Settings Models tab: only show `visibility='enabled'` base models
(team-only models must not leak to user model list; presets filtered out)
- [x] Model provenance labels: badge indicating global vs personal source
— same model_id from both sources shown separately (different API keys)
- [x] Admin presets list: show created_by name + team name for attribution
(e.g. "Code Reviewer · 👥 Engineering · by sarah")
**User Presets + Model Filtering (0.8.5)**
- [x] Users can create personal presets from any enabled base model
(removed user_providers_enabled gate; uses shared renderPresetForm)
- [x] Settings Models tab reworked as "My Models": toggle to hide/show
base models from selector, "My Presets" section with create/delete
- [x] `user_model_preferences` table: user_id, model_id, hidden boolean
— lightweight filter, not access control
- [x] Hidden models filtered from main model selector dropdown
- [x] User personal provider models: simple enable/disable toggle
(same visibility toggle — hide from selector)
**Team Providers (0.8.6)**
- [x] `team_id` column on `api_configs` (nullable FK, indexed)
- [x] Team admins manage team-scoped provider configs
(add/remove API keys, toggle active, same UI as personal providers)
- [x] `allow_team_providers` check: global_settings + team.settings JSONB
- [x] Team provider models available in team preset builder (ListAvailableModels)
— grouped by source: Global Models / Team Provider Models (optgroup)
- [x] Team provider models NOT exposed directly in model selector
— team members access team models ONLY through curated presets
— keeps model selector clean, avoids visibility confusion
- [x] Three-tier provider hierarchy: global → team (presets only) → personal
- [x] ListConfigs excludes team-scoped providers (team_id IS NULL filter)
**Usage / Cost Tracking**
- [ ] Capture from provider responses: prompt_tokens, completion_tokens,
cache_creation_tokens, cache_read_tokens
- [ ] `usage_log` table: channel_id, user_id, model_id, provider_id,
token counts, timestamp
- [ ] Model pricing fields on `model_configs`:
cost_input, cost_output, cost_cache_input, cost_cache_output
(per M tokens, nullable, decimal)
- [ ] `cost_source` field: 'provider' | 'admin' | null
— provider APIs populate on model fetch, admin can override,
source tracking prevents silent overwrites on re-fetch
- [ ] Cost calculated at query time (join usage × pricing)
— never store computed cost; pricing changes and historical
token counts should recalculate against current rates
- [ ] Per-user and per-team usage stats in admin panel
- [ ] Team admins see usage for their team scope
---
## ~~0.9.0 — Tool Execution + Notes~~ ✅ (pulled into 0.7.x)
Pulled forward and shipped ahead of schedule. See TOOLS_IMPL.md and FORKING_IMPL.md.
**Tool Framework**
- [x] Tool calling pipeline in completion handler (OpenAI + Anthropic)
- [x] Tool registry (built-in + future plugin tools)
- [x] Tool execution loop: model requests tool → backend executes → result fed back
- [ ] Tool permission model (which tools enabled per preset/channel)
**Notes**
- [x] `notes` table: title, content, folder, tags, source_channel_id, full-text search
- [x] Notes CRUD endpoints + search
- [x] `note_create`, `note_update`, `note_search`, `note_list` tools
- [x] Full-text search (PostgreSQL `tsvector` + `ts_rank` + `ts_headline`)
- [x] Notes UI: modal with list/detail views, folder sidebar, Markdown editor
- [ ] Team-scoped notes (awaits 0.8.0 teams)
**Conversation Forking UI**
- [x] Edit-and-resubmit creates siblings (tree structure)
- [x] Branch indicator ← 1/2 → at fork points
- [x] Context assembly follows active path
## 0.9.x — Context Management
Usability before compaction exists — long conversations shouldn't silently fail.
- [ ] Token counting on outbound requests (estimate before sending)
- [ ] Truncation strategy (sliding window or drop oldest, configurable)
- [ ] "Conversation is getting long" warning in UI
- [ ] Manual "summarize and continue" action (user-triggered pre-compaction)
- [ ] Groundwork for automated compaction in 0.14.0
---
## 0.10.0 — Web Search + URL Fetch
First external tools, using the tool framework shipped in 0.7.x.
- [ ] `web_search` tool: search provider abstraction (DuckDuckGo, SearXNG, Brave)
- [ ] `url_fetch` tool: retrieve and extract content from URLs
- [ ] Sidecar or direct HTTP from backend (configurable)
- [ ] Results injected into context
- [ ] Paired with Notes: "research X and save findings to my notes"
---
## 0.11.0 — File Handling + Vision
File input into chat — table stakes for serious use. Blobs live in object
storage, metadata and search indexes live in PostgreSQL.
**Storage Backend Abstraction**
- [ ] S3-compatible API as primary interface
(MinIO, Ceph RGW, AWS S3, GCS — anything with S3 API)
- [ ] Local PVC as zero-config fallback (single-node / dev)
- [ ] Admin config: storage backend selection, endpoint, credentials,
bucket/path, per-file and total size limits
- [ ] Reused by 0.13.0 (KB documents) and 0.14.0 (compaction snapshots)
**Metadata + Search Index (PostgreSQL)**
- [ ] `attachments` table (metadata only, never blobs):
id, message_id, channel_id, team_id, filename, mime_type,
size_bytes, storage_backend ('s3' | 'pvc'), storage_path,
checksum_sha256, uploaded_by, created_at,
search_text (tsvector, nullable)
- [ ] Text extraction on upload (PDF, DOCX, TXT, MD → tsvector)
- [ ] Full-text search across attachment content
- [ ] Access control via JOIN: attachments → channels → team_members
**Chat Integration**
- [ ] Image/file upload in chat messages
- [ ] Multimodal message assembly for vision-capable models
- [ ] Document preview in chat (images inline, files as download links)
- [ ] Paste-to-upload (clipboard image support)
*Note: media generation (image gen, video models) is a separate concern —
those are tool-use actions that depend on the tool framework and
produce attachments as output. Tracked under Future.*
---
## 0.12.0 — @mention Routing + Multi-model
The channel schema already supports multiple models. This phase adds the routing logic.
- [ ] @mention parsing in messages (users and AI models)
- [ ] Resolve mentions against `channel_models`
- [ ] Multi-model channels: fire completions per mentioned model
- [ ] "Add a model" UI per channel
- [ ] Enables: editor mode, second opinions, cross-model conversations
---
## 0.13.0 — Embeddings + Knowledge Bases
- [ ] Embedding pipeline: chunking (recursive, semantic), generation (OpenAI, local)
- [ ] `pgvector` storage and similarity search
- [ ] `knowledge_bases` table: name, description, team_id (nullable), created_by
- [ ] KB document storage via 0.11.0 storage backend (same S3/PVC abstraction)
- [ ] KB CRUD endpoints + admin UI
- [ ] `kb_search` tool (uses existing tool framework)
- [ ] Context injection in completion flow
- [ ] Notes get embedded too (once pipeline exists)
- [ ] Team admins manage team KBs (permission layer from 0.8.0)
- [ ] Per-channel KB toggle
---
## 0.14.0 — Compaction
Replaces the manual 0.9.x context management with automated background processing.
- [ ] Auto-compaction service: background job that calls an LLM to summarize
- [ ] Channel-scoped: triggers when channel exceeds context threshold
- [ ] Compaction summaries stored as system messages in the channel
- [ ] Configurable: per-channel opt-in/out, summary model selection
- [ ] Admin controls for resource limits
---
## 0.15.0 — Smart Model Routing
Rules-based routing engine — not ML, just policy.
- [ ] Admin defines routing policies:
"cheapest model with required capabilities",
"prefer private providers, fallback to cloud",
"for this team, use X; for that team, use Y"
- [ ] Model capability system already exists — routing is policy on top
- [ ] Fallback chains: primary provider down → next provider with same model
- [ ] Cost-aware: factor pricing into routing decisions
- [ ] Latency-aware: track response times per provider, prefer faster
---
## 0.16.0 — Tasks / Autonomous Agents
The capstone: everything below it combined into autonomous workflows.
- [ ] Scheduler + task runner
- [ ] `task_create` tool
- [ ] Creates `type: 'service'` channels with no human members
- [ ] Depends on: completion handler, tool execution, notes, web search
- [ ] Admin controls for resource limits, execution budgets
---
## 0.17.0 — Auth Strategy (mTLS/OIDC) + Full RBAC
Enterprise auth modes and fine-grained permissions on top of the teams foundation.
- [ ] `AUTH_MODE` env var: `builtin` | `mtls` | `oidc`
- [ ] All three resolve to the same internal user model
- [ ] `auth_source` + `external_id` columns on users table
- [ ] mTLS: header trust, auto-provision from cert DN
- [ ] OIDC: Keycloak/Okta token validation, claim extraction, role mapping
- [ ] WebSocket auth per mode
- [ ] Per-source auto-activate policy:
auto_activate (bool), default_team, default_role
- [ ] Fine-grained permissions: model access, KB write, task create,
admin delegation, token budgets per user/team
- [ ] SSO/SAML (may fold in or follow as 0.17.x)
---
## Future (post-1.0 candidates)
Items that are real but don't yet have a version assignment. Any of these
could pull left based on need.
**Desktop + Mobile**
- Desktop app (Tauri)
- Full PWA with offline capability
- Mobile-optimized layouts
**Generation + Media**
- Image generation tools (DALL-E, Stable Diffusion endpoints)
- Video model integration
- Audio/TTS tools
**Data + Portability**
- Bulk export/import (account data, conversations, settings)
- ChatGPT/other tool import
- GDPR-style "download my data"
- Backup/restore CronJob manifests + operational docs
**Platform**
- Rate limiting per user/team/tier (token budgets)
- Provider health monitoring + key rotation
- Multi-tenant SaaS mode
- Workflow builder (visual DAG for chaining models + tools)
- Plugin/extension marketplace
- Live collaboration (typing indicators, presence, co-editing)
- Virtual scroll for long conversations
---
## Extension / Plugin Architecture
**Deferred to post-1.0.** The tool execution framework (0.7.x) provides the
internal hook points. A formal plugin API, manifest format, and marketplace
are tracked in the dedicated design documents:
- [EXTENSIONS.md](EXTENSIONS.md) — Three-tier extension system spec
(Browser JS, Starlark sandbox, Sidecar containers)
- [ARCHITECTURE.md](ARCHITECTURE.md) — Core backend services that extensions
build on
# Roadmap — Chat Switchboard
## v0.9.0 (Current)
- [x] Consolidated schema (21 migrations → 1)
- [x] Store layer abstraction (all DB access via typed interfaces)
- [x] Persona-as-trust-boundary model (replaces presets)
- [x] Scope model (global/team/personal) for configs, personas, models
- [x] Capabilities resolver chain (catalog → known → heuristic)
- [x] Three-state model visibility (enabled/disabled/team-only)
- [x] User model preferences (hide, sort)
- [x] Audit log foundation
- [x] Backward-compatible API routes
## v0.9.1 (Next)
- [ ] Chat search (full-text search across messages)
- [ ] Keyboard shortcuts + command palette (Ctrl+K)
- [ ] PWA enhancements (offline indicator, install prompt)
- [ ] API key at-rest encryption
- [ ] v0.8 → v0.9 migration script (002_v08_to_v09.sql)
## v0.10
- [ ] Grant management UI (admin assigns model access per team)
- [ ] Model visibility toggle in admin panel
- [ ] Team/group notes (shared note folders)
- [ ] Conversation export (markdown, JSON)
- [ ] Plugin system (tool registration API)
## v0.11
- [ ] OIDC/Keycloak authentication mode
- [ ] mTLS client certificate authentication
- [ ] SSO integration patterns
- [ ] Rate limiting per user/team (configurable quotas)
## Future
- [ ] SQLite backend option (for single-user / dev deployments)
- [ ] Multi-model conversations (different models per message)
- [ ] Conversation templates (reusable multi-turn starters)
- [ ] Agent mode (multi-step tool use with human-in-the-loop)
- [ ] Model cost tracking and reporting

View File

@@ -1 +1 @@
0.8.6
0.9.0

View File

@@ -7,11 +7,10 @@ services:
container_name: chat-switchboard-db
environment:
POSTGRES_USER: switchboard
POSTGRES_PASSWORD: switchboard_dev
POSTGRES_PASSWORD: ${DB_PASSWORD:-switchboard_dev}
POSTGRES_DB: chat_switchboard
volumes:
- postgres_data:/var/lib/postgresql/data
- ./migrations:/docker-entrypoint-initdb.d:ro
ports:
- "5432:5432"
healthcheck:
@@ -20,7 +19,31 @@ services:
timeout: 5s
retries: 5
# Adminer for database management (optional)
# Chat Switchboard (unified: Go backend + nginx frontend)
switchboard:
build:
context: .
dockerfile: Dockerfile
container_name: chat-switchboard
environment:
PORT: "8080"
BASE_PATH: ""
DB_HOST: postgres
DB_PORT: "5432"
DB_USER: switchboard
DB_PASSWORD: ${DB_PASSWORD:-switchboard_dev}
DB_NAME: chat_switchboard
DB_SSL_MODE: disable
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
SWITCHBOARD_ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
SWITCHBOARD_ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
ports:
- "3000:80"
depends_on:
postgres:
condition: service_healthy
# Adminer for database management (optional, dev only)
adminer:
image: adminer:latest
container_name: chat-switchboard-adminer
@@ -28,31 +51,8 @@ services:
- "8081:8080"
depends_on:
- postgres
# Backend API (uncomment when implemented)
# api:
# build:
# context: ./server
# dockerfile: Dockerfile
# container_name: chat-switchboard-api
# environment:
# DATABASE_URL: postgres://switchboard:switchboard_dev@postgres:5432/chat_switchboard?sslmode=disable
# PORT: 8080
# JWT_SECRET: your-secret-key-change-in-production
# ports:
# - "8080:8080"
# depends_on:
# postgres:
# condition: service_healthy
# Frontend dev server (optional)
frontend:
image: nginx:alpine
container_name: chat-switchboard-frontend
volumes:
- ./standalone:/usr/share/nginx/html:ro
ports:
- "3000:80"
profiles:
- dev
volumes:
postgres_data:

View File

@@ -11,13 +11,14 @@ set -e
echo "🔀 Starting Chat Switchboard backend..."
# Launch Go backend in background
# Launch Go backend in background (from /app so migrations are found)
cd /app
/usr/local/bin/switchboard &
BACKEND_PID=$!
# Wait for backend to be ready (max 10s)
for i in $(seq 1 20); do
if wget -q --spider http://localhost:${PORT:-8080}/health 2>/dev/null; then
if wget -q --spider http://localhost:${PORT:-8080}${BASE_PATH}/health 2>/dev/null; then
echo "✅ Backend ready (PID ${BACKEND_PID})"
exit 0
fi

View File

@@ -1,6 +1,6 @@
#!/bin/bash
# ============================================
# Chat Switchboard - Schema Validation
# Chat Switchboard - Schema Validation (v0.9)
# ============================================
# Verifies the database schema is correct after
# migration. Checks expected tables, key columns,
@@ -48,19 +48,6 @@ check_column() {
fi
}
# ── Helper: check index exists ───────────────
check_index() {
local index="$1"
local exists
exists=$(psql -tAc "SELECT 1 FROM pg_indexes WHERE schemaname='public' AND indexname='${index}';")
if [[ "${exists}" == "1" ]]; then
echo " ✓ index: ${index}"
else
echo " ✗ MISSING index: ${index}"
ERRORS=$((ERRORS + 1))
fi
}
# ── Helper: check extension ──────────────────
check_extension() {
local ext="$1"
@@ -77,7 +64,6 @@ check_extension() {
# ── 1. Extensions ────────────────────────────
echo ""
echo "Extensions:"
check_extension "uuid-ossp"
check_extension "pgcrypto"
# ── 2. Migration tracking ───────────────────
@@ -90,128 +76,112 @@ echo " ✓ ${MIGRATION_COUNT} migrations applied"
LATEST=$(psql -tAc "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1;" 2>/dev/null || echo "none")
echo " ✓ latest: ${LATEST}"
# ── 3. Core tables (001 schema, renamed in 006)
# ── 3. Core tables ─────────────────────────
echo ""
echo "Core tables:"
check_table "users"
check_table "api_configs"
check_table "provider_configs"
check_table "channels"
check_table "messages"
check_table "teams"
check_table "team_members"
check_table "refresh_tokens"
check_table "global_settings"
# Key columns
# ── 4. Users ────────────────────────────────
echo ""
echo "Users:"
check_column "users" "id"
check_column "users" "username"
check_column "users" "email"
check_column "users" "role"
check_column "api_configs" "user_id"
check_column "api_configs" "provider"
check_column "api_configs" "api_key_encrypted"
check_column "users" "is_active"
check_column "users" "avatar_url"
check_column "users" "display_name"
check_column "users" "settings"
# ── 5. Provider Configs (replaces api_configs) ─
echo ""
echo "Provider Configs:"
check_column "provider_configs" "scope"
check_column "provider_configs" "owner_id"
check_column "provider_configs" "provider"
check_column "provider_configs" "endpoint"
check_column "provider_configs" "api_key_enc"
check_column "provider_configs" "headers"
check_column "provider_configs" "settings"
check_column "provider_configs" "is_active"
# ── 6. Model Catalog (replaces model_configs) ─
echo ""
echo "Model Catalog:"
check_table "model_catalog"
check_column "model_catalog" "provider_config_id"
check_column "model_catalog" "model_id"
check_column "model_catalog" "display_name"
check_column "model_catalog" "capabilities"
check_column "model_catalog" "pricing"
check_column "model_catalog" "visibility"
# ── 7. Personas (replaces model_presets) ────
echo ""
echo "Personas:"
check_table "personas"
check_column "personas" "scope"
check_column "personas" "owner_id"
check_column "personas" "name"
check_column "personas" "base_model_id"
check_column "personas" "provider_config_id"
check_column "personas" "system_prompt"
check_column "personas" "created_by"
# ── 8. Channels ─────────────────────────────
echo ""
echo "Channels:"
check_column "channels" "user_id"
check_column "channels" "type"
check_column "channels" "provider_config_id"
check_column "channels" "team_id"
check_column "channels" "settings"
check_table "channel_members"
check_table "channel_models"
check_table "channel_cursors"
# ── 9. Messages ─────────────────────────────
echo ""
echo "Messages:"
check_column "messages" "channel_id"
check_column "messages" "role"
check_column "messages" "parent_id"
# ── 4. Refresh tokens (002) ─────────────────
echo ""
echo "Auth tables:"
check_table "refresh_tokens"
check_column "refresh_tokens" "token_hash"
check_column "refresh_tokens" "user_id"
# ── 5. Global settings (003) ────────────────
echo ""
echo "Settings tables:"
check_table "global_settings"
check_column "global_settings" "key"
check_column "global_settings" "value"
# ── 6. Model configs (004) ──────────────────
echo ""
echo "Model tables:"
check_table "model_configs"
check_column "model_configs" "api_config_id"
check_column "model_configs" "model_id"
check_column "model_configs" "capabilities"
# ── 7. Provider capabilities (005) ──────────
echo ""
echo "Provider capabilities:"
check_column "api_configs" "custom_headers"
check_column "api_configs" "is_global"
check_column "api_configs" "team_id"
check_table "user_model_preferences"
check_column "user_model_preferences" "user_id"
check_column "user_model_preferences" "model_config_id"
check_column "user_model_preferences" "model_id"
check_column "user_model_preferences" "hidden"
# ── 8. Channel unification (006-008) ────────
echo ""
echo "Channel model (006-008):"
check_column "channels" "description"
check_column "messages" "participant_type"
check_column "messages" "participant_id"
check_table "channel_members"
check_column "channel_members" "channel_id"
check_column "channel_members" "user_id"
check_table "channel_models"
check_column "channel_models" "channel_id"
check_column "channel_models" "model_id"
check_table "channel_cursors"
check_column "channel_cursors" "active_leaf_id"
# ── 9. Folders & Projects (009) ─────────────
echo ""
echo "Organization (009):"
check_table "folders"
check_column "folders" "user_id"
check_table "projects"
check_column "projects" "user_id"
check_table "project_channels"
# ── 10. Banners (010) ───────────────────────
echo ""
echo "Banners (010):"
# Banner config is seeded into global_settings; just verify the key exists
BANNER_KEY=$(psql -tAc "SELECT 1 FROM global_settings WHERE key = 'banner';" 2>/dev/null || echo "0")
if [[ "${BANNER_KEY}" == "1" ]]; then
echo " ✓ global_settings: banner"
else
echo " ✗ MISSING global_settings key: banner"
ERRORS=$((ERRORS + 1))
fi
# ── 11. Model Presets (012) ─────────────────
echo ""
echo "Model Presets (012):"
check_table "model_presets"
check_column "model_presets" "name"
check_column "model_presets" "base_model_id"
check_column "model_presets" "api_config_id"
check_column "model_presets" "system_prompt"
check_column "model_presets" "scope"
check_column "model_presets" "created_by"
check_column "model_presets" "is_active"
# ── Migration 013: Message Forking ───────────
echo ""
echo "Migration 013: Message Forking"
check_column "messages" "deleted_at"
check_column "messages" "sibling_index"
check_column "messages" "deleted_at"
check_column "messages" "participant_type"
# ── Migration 014: Notes ─────────────────────
# ── 10. Organization ────────────────────────
echo ""
echo "Migration 014: Notes"
echo "Organization:"
check_table "folders"
check_table "projects"
check_table "notes"
check_column "notes" "user_id"
check_column "notes" "title"
check_column "notes" "content"
check_column "notes" "folder_path"
check_column "notes" "tags"
check_column "notes" "metadata"
check_column "notes" "source_channel_id"
check_column "notes" "search_vector"
# ── 11. User Model Settings ────────────────
echo ""
echo "User Model Settings:"
check_table "user_model_settings"
check_column "user_model_settings" "user_id"
check_column "user_model_settings" "model_id"
check_column "user_model_settings" "hidden"
check_column "user_model_settings" "sort_order"
# ── 12. Audit Log ───────────────────────────
echo ""
echo "Audit Log:"
check_table "audit_log"
check_column "audit_log" "actor_id"
check_column "audit_log" "action"
check_column "audit_log" "resource_type"
# ═══════════════════════════════════════════
# ADD NEW MIGRATION CHECKS ABOVE THIS LINE
# ═══════════════════════════════════════════

View File

@@ -1,12 +1,12 @@
# Chat Switchboard - Server Environment Variables
# Chat Switchboard v0.9 - Server Environment Variables
# Copy this file to .env and fill in the values
# Server settings
# ── Server ───────────────────────────────────
PORT=8080
ENVIRONMENT=development
DEBUG=true
BASE_PATH= # e.g. /chat for path-based routing
# Database settings (PostgreSQL)
# ── Database (PostgreSQL) ────────────────────
DB_HOST=localhost
DB_PORT=5432
DB_USER=chat_switchboard
@@ -15,30 +15,25 @@ DB_NAME=chat_switchboard
DB_SSL_MODE=disable
DB_MAX_CONNS=25
# JWT settings
# ── Auth ─────────────────────────────────────
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_EXPIRATION=24h
JWT_ISSUER=chat-switchboard
# CORS settings (comma-separated for multiple origins)
# ── Bootstrap Admin ──────────────────────────
# Creates or updates admin account on every startup.
# Set via K8s secret or env. Leave blank to skip.
SWITCHBOARD_ADMIN_USERNAME=
SWITCHBOARD_ADMIN_PASSWORD=
# ── CORS ─────────────────────────────────────
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:8080
CORS_ALLOWED_METHODS=GET,POST,PUT,DELETE,OPTIONS,PATCH
CORS_ALLOWED_HEADERS=Content-Type,Authorization,X-Requested-With,Accept,Origin
# Rate limiting
RATE_LIMIT_REQUESTS=100
RATE_LIMIT_WINDOW=1m
# ── Banner (optional) ───────────────────────
# Environment classification banner.
# BANNER_TEXT=DEVELOPMENT
# BANNER_COLOR=#007a33
# BANNER_POSITION=top
# Logging
# ── Logging ──────────────────────────────────
LOG_LEVEL=info
# Optional: Redis configuration (for WebSocket pub/sub and session cache)
# REDIS_HOST=localhost
# REDIS_PORT=6379
# REDIS_DB=0
# REDIS_PASSWORD=
# Optional: External services
# OPENAI_API_KEY=
# ANTHROPIC_API_KEY=
# GOOGLE_API_KEY=

View File

@@ -29,6 +29,9 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /bin/switchboard /usr/local/bin/switchboard
COPY --from=builder /app/database/migrations /app/database/migrations
WORKDIR /app
EXPOSE 8080

View File

@@ -1,6 +1,10 @@
package providers
package capabilities
import "testing"
import (
"testing"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
func TestLookupKnownModel_ExactMatch(t *testing.T) {
caps, ok := LookupKnownModel("gpt-4o")
@@ -79,8 +83,7 @@ func TestInferCapabilities_Reasoning(t *testing.T) {
}
func TestResolveMaxOutput_FromCaps(t *testing.T) {
// Explicit value in caps takes priority
caps := ModelCapabilities{MaxOutputTokens: 32000}
caps := models.ModelCapabilities{MaxOutputTokens: 32000}
got := ResolveMaxOutput("whatever-model", caps)
if got != 32000 {
t.Errorf("got %d, want 32000", got)
@@ -88,8 +91,7 @@ func TestResolveMaxOutput_FromCaps(t *testing.T) {
}
func TestResolveMaxOutput_FromKnownTable(t *testing.T) {
// No value in caps, falls to known table
caps := ModelCapabilities{}
caps := models.ModelCapabilities{}
got := ResolveMaxOutput("claude-opus-4-20250514", caps)
if got != 32000 {
t.Errorf("got %d, want 32000", got)
@@ -97,25 +99,21 @@ func TestResolveMaxOutput_FromKnownTable(t *testing.T) {
}
func TestResolveMaxOutput_FromContext(t *testing.T) {
// Unknown model but has context window
caps := ModelCapabilities{MaxContext: 32768}
caps := models.ModelCapabilities{MaxContext: 32768}
got := ResolveMaxOutput("unknown-model-abc", caps)
// 32768 / 8 = 4096
if got != 4096 {
t.Errorf("got %d, want 4096 (derived from context/8)", got)
}
}
func TestResolveMaxOutput_ContextClamp(t *testing.T) {
// Very large context → clamp derived output to 16384
caps := ModelCapabilities{MaxContext: 1048576}
caps := models.ModelCapabilities{MaxContext: 1048576}
got := ResolveMaxOutput("unknown-large-model", caps)
if got != 16384 {
t.Errorf("got %d, want 16384 (clamped)", got)
}
// Very small context → clamp derived output to 2048
caps = ModelCapabilities{MaxContext: 4096}
caps = models.ModelCapabilities{MaxContext: 4096}
got = ResolveMaxOutput("unknown-tiny-model", caps)
if got != 2048 {
t.Errorf("got %d, want 2048 (clamped)", got)
@@ -123,22 +121,21 @@ func TestResolveMaxOutput_ContextClamp(t *testing.T) {
}
func TestResolveMaxOutput_LastResort(t *testing.T) {
// Totally unknown, no context
caps := ModelCapabilities{}
caps := models.ModelCapabilities{}
got := ResolveMaxOutput("mystery-model", caps)
if got != 4096 {
t.Errorf("got %d, want 4096 (last resort)", got)
}
}
func TestMergeCapabilities(t *testing.T) {
func TestResolveIntrinsic(t *testing.T) {
// Provider reports tool_calling and max_output — these should be authoritative.
// Known table for claude-sonnet-4 has vision, thinking, etc — should fill gaps.
providerCaps := ModelCapabilities{
providerCaps := models.ModelCapabilities{
ToolCalling: true,
MaxOutputTokens: 16384,
}
merged := MergeCapabilities(providerCaps, "claude-sonnet-4-20250514")
merged := ResolveIntrinsic("claude-sonnet-4-20250514", &providerCaps)
if !merged.ToolCalling {
t.Error("tool_calling should be preserved from provider")
@@ -146,57 +143,52 @@ func TestMergeCapabilities(t *testing.T) {
if merged.MaxOutputTokens != 16384 {
t.Errorf("max_output should be 16384 from provider, got %d", merged.MaxOutputTokens)
}
// Vision should be filled from known table (claude-sonnet-4 has it)
if !merged.Vision {
t.Error("vision should be filled from known table")
}
// MaxContext should be filled from known table
if merged.MaxContext == 0 {
t.Error("max_context should be filled from known table")
}
}
func TestMergeCapabilities_ProviderFalseWins(t *testing.T) {
// Provider explicitly reports vision:false — should NOT be overridden by known table
providerCaps := ModelCapabilities{
func TestResolveIntrinsic_ProviderFalseWins(t *testing.T) {
providerCaps := models.ModelCapabilities{
ToolCalling: true,
Vision: false, // provider says no vision
Vision: false,
MaxOutputTokens: 8192,
MaxContext: 65536,
}
// Even though gpt-4o has vision in known table, provider caps are authoritative
// Because HasProviderData() is true, the caller passes these as authoritative
merged := MergeCapabilities(providerCaps, "some-unknown-model")
merged := ResolveIntrinsic("some-unknown-model", &providerCaps)
if merged.Vision {
t.Error("vision should remain false — provider is authoritative")
}
}
func TestMergeCapabilities_EmptyProvider(t *testing.T) {
// Empty provider caps — should fall through entirely to known table
merged := MergeCapabilities(ModelCapabilities{}, "gpt-4o")
func TestResolveIntrinsic_NilProvider(t *testing.T) {
// Nil provider caps — should fall through entirely to known table
merged := ResolveIntrinsic("gpt-4o", nil)
if !merged.ToolCalling {
t.Error("should get tool_calling from known table when provider is empty")
t.Error("should get tool_calling from known table when provider is nil")
}
if !merged.Vision {
t.Error("should get vision from known table when provider is empty")
t.Error("should get vision from known table when provider is nil")
}
}
func TestHasProviderData(t *testing.T) {
empty := ModelCapabilities{}
empty := models.ModelCapabilities{}
if empty.HasProviderData() {
t.Error("empty caps should not have provider data")
}
withTool := ModelCapabilities{ToolCalling: true}
withTool := models.ModelCapabilities{ToolCalling: true}
if !withTool.HasProviderData() {
t.Error("caps with tool_calling should have provider data")
}
withContext := ModelCapabilities{MaxContext: 128000}
withContext := models.ModelCapabilities{MaxContext: 128000}
if !withContext.HasProviderData() {
t.Error("caps with max_context should have provider data")
}

View File

@@ -1,26 +1,14 @@
package providers
package capabilities
import (
"regexp"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// ModelCapabilities describes what a model can do and its limits.
// Zero values mean "unknown / use heuristic".
type ModelCapabilities struct {
Streaming bool `json:"streaming"`
ToolCalling bool `json:"tool_calling"`
Vision bool `json:"vision"`
Thinking bool `json:"thinking"`
Reasoning bool `json:"reasoning"`
CodeOptimized bool `json:"code_optimized"`
WebSearch bool `json:"web_search"`
MaxContext int `json:"max_context"`
MaxOutputTokens int `json:"max_output_tokens"`
}
// ── Known Model Defaults ────────────────────
// Authoritative output limits for models where the provider API
// Authoritative capabilities for models where the provider API
// doesn't report them. Keyed by exact model ID or prefix.
//
// Sources:
@@ -29,7 +17,7 @@ type ModelCapabilities struct {
// Meta: Model cards on Hugging Face
// Google: https://ai.google.dev/gemini-api/docs/models
var knownModels = map[string]ModelCapabilities{
var knownModels = map[string]models.ModelCapabilities{
// ── Anthropic ────────────────────────────
"claude-opus-4": {
Streaming: true, ToolCalling: true, Vision: true, Thinking: true,
@@ -178,14 +166,8 @@ var knownModels = map[string]ModelCapabilities{
}
// LookupKnownModel finds capabilities for a model by exact ID or prefix match.
// Returns the caps and true if found, zero value and false if not.
func LookupKnownModel(modelID string) (ModelCapabilities, bool) {
id := strings.ToLower(modelID)
// Strip provider prefix (e.g. "anthropic/claude-sonnet-4-20250514" → "claude-sonnet-4-20250514")
if idx := strings.Index(id, "/"); idx >= 0 {
id = id[idx+1:]
}
func LookupKnownModel(modelID string) (models.ModelCapabilities, bool) {
id := normalizeModelID(modelID)
// Exact match first
if caps, ok := knownModels[id]; ok {
@@ -194,7 +176,7 @@ func LookupKnownModel(modelID string) (ModelCapabilities, bool) {
// Prefix match: "claude-sonnet-4-20250514" matches "claude-sonnet-4"
var bestKey string
var bestCaps ModelCapabilities
var bestCaps models.ModelCapabilities
for key, caps := range knownModels {
if strings.HasPrefix(id, key) && len(key) > len(bestKey) {
bestKey = key
@@ -205,13 +187,10 @@ func LookupKnownModel(modelID string) (ModelCapabilities, bool) {
return bestCaps, true
}
return ModelCapabilities{}, false
return models.ModelCapabilities{}, false
}
// ── Heuristic Capability Detection ──────────
// Ported from ai-editor's ProviderRegistry.
// Used for Ollama, LM Studio, and other generic endpoints
// that don't report capabilities in their /models response.
var (
toolPatterns = []*regexp.Regexp{
@@ -271,16 +250,10 @@ func matchesAny(id string, patterns []*regexp.Regexp) bool {
}
// InferCapabilities guesses model capabilities from the model ID string.
// This is the fallback when neither the known model table nor the provider
// API provides capability data.
func InferCapabilities(modelID string) ModelCapabilities {
id := strings.ToLower(modelID)
// Strip provider prefix
if idx := strings.Index(id, "/"); idx >= 0 {
id = id[idx+1:]
}
return ModelCapabilities{
// Fallback when neither the known model table nor the provider API has data.
func InferCapabilities(modelID string) models.ModelCapabilities {
id := normalizeModelID(modelID)
return models.ModelCapabilities{
Streaming: true, // virtually everything streams
ToolCalling: matchesAny(id, toolPatterns),
Vision: matchesAny(id, visionPatterns),
@@ -289,27 +262,41 @@ func InferCapabilities(modelID string) ModelCapabilities {
}
}
// ResolveMaxOutput returns the max output tokens for a model.
// ResolveIntrinsic determines the intrinsic capabilities of a model.
// Priority:
// 1. Explicit value in caps (from DB / provider API)
// 2. Known model table
// 3. Derive from context window (context/8, clamped 2048..16384)
// 4. 4096 as absolute last resort
// 1. catalogCaps (from model_catalog DB provider API data)
// 2. Known model table (static, compiled-in)
// 3. Heuristic inference (regex patterns)
//
// This is the ONE place the default lives. Nothing else in the
// codebase should hardcode a max_tokens value.
func ResolveMaxOutput(modelID string, caps ModelCapabilities) int {
// 1. Already set (from model_configs DB or provider API)
// This is the ONLY function that computes intrinsic capabilities.
func ResolveIntrinsic(modelID string, catalogCaps *models.ModelCapabilities) models.ModelCapabilities {
// Start with catalog data if available
var base models.ModelCapabilities
if catalogCaps != nil && catalogCaps.HasProviderData() {
base = *catalogCaps
}
// Fill gaps from known model table
if known, found := LookupKnownModel(modelID); found {
mergeGaps(&base, &known)
return base
}
// Fill gaps from heuristic inference
inferred := InferCapabilities(modelID)
mergeGaps(&base, &inferred)
return base
}
// ResolveMaxOutput returns the max output tokens for a model.
// Priority: explicit caps → known model table → derive from context → 4096 fallback.
func ResolveMaxOutput(modelID string, caps models.ModelCapabilities) int {
if caps.MaxOutputTokens > 0 {
return caps.MaxOutputTokens
}
// 2. Known model table
if known, ok := LookupKnownModel(modelID); ok && known.MaxOutputTokens > 0 {
return known.MaxOutputTokens
}
// 3. Derive from context window
if caps.MaxContext > 0 {
derived := caps.MaxContext / 8
if derived < 2048 {
@@ -320,77 +307,45 @@ func ResolveMaxOutput(modelID string, caps ModelCapabilities) int {
}
return derived
}
// 4. Last resort — the ONLY place 4096 appears as a default
return 4096
}
// MergeCapabilities takes authoritative caps (from provider API or DB) and fills
// gaps from the known model table and heuristic detection. Provider-reported data
// always wins; known table fills missing fields; heuristics are last resort.
func MergeCapabilities(authoritative ModelCapabilities, modelID string) ModelCapabilities {
merged := authoritative
// Fill gaps from known model table
known, found := LookupKnownModel(modelID)
if found {
if !merged.ToolCalling && known.ToolCalling {
merged.ToolCalling = true
// mergeGaps fills zero/false fields in dst from src. Never overrides existing data.
func mergeGaps(dst, src *models.ModelCapabilities) {
if !dst.Streaming && src.Streaming {
dst.Streaming = true
}
if !merged.Vision && known.Vision {
merged.Vision = true
if !dst.ToolCalling && src.ToolCalling {
dst.ToolCalling = true
}
if !merged.Thinking && known.Thinking {
merged.Thinking = true
if !dst.Vision && src.Vision {
dst.Vision = true
}
if !merged.Reasoning && known.Reasoning {
merged.Reasoning = true
if !dst.Thinking && src.Thinking {
dst.Thinking = true
}
if !merged.CodeOptimized && known.CodeOptimized {
merged.CodeOptimized = true
if !dst.Reasoning && src.Reasoning {
dst.Reasoning = true
}
if !merged.WebSearch && known.WebSearch {
merged.WebSearch = true
if !dst.CodeOptimized && src.CodeOptimized {
dst.CodeOptimized = true
}
if merged.MaxContext == 0 && known.MaxContext > 0 {
merged.MaxContext = known.MaxContext
if !dst.WebSearch && src.WebSearch {
dst.WebSearch = true
}
if merged.MaxOutputTokens == 0 && known.MaxOutputTokens > 0 {
merged.MaxOutputTokens = known.MaxOutputTokens
if dst.MaxContext == 0 && src.MaxContext > 0 {
dst.MaxContext = src.MaxContext
}
return merged
if dst.MaxOutputTokens == 0 && src.MaxOutputTokens > 0 {
dst.MaxOutputTokens = src.MaxOutputTokens
}
// No known model — fill gaps from heuristics
inferred := InferCapabilities(modelID)
if !merged.ToolCalling && inferred.ToolCalling {
merged.ToolCalling = true
}
if !merged.Vision && inferred.Vision {
merged.Vision = true
}
if !merged.Thinking && inferred.Thinking {
merged.Thinking = true
}
if !merged.Reasoning && inferred.Reasoning {
merged.Reasoning = true
}
if !merged.CodeOptimized && inferred.CodeOptimized {
merged.CodeOptimized = true
}
if merged.MaxContext == 0 && inferred.MaxContext > 0 {
merged.MaxContext = inferred.MaxContext
}
if merged.MaxOutputTokens == 0 && inferred.MaxOutputTokens > 0 {
merged.MaxOutputTokens = inferred.MaxOutputTokens
}
return merged
}
// HasProviderData returns true if this capability set contains any data that
// was likely reported by a provider (not just zero values).
func (c ModelCapabilities) HasProviderData() bool {
return c.ToolCalling || c.Vision || c.Thinking || c.Reasoning ||
c.CodeOptimized || c.WebSearch || c.MaxContext > 0 || c.MaxOutputTokens > 0
// normalizeModelID strips provider prefix and lowercases.
func normalizeModelID(modelID string) string {
id := strings.ToLower(modelID)
if idx := strings.Index(id, "/"); idx >= 0 {
id = id[idx+1:]
}
return id
}

View File

@@ -0,0 +1,276 @@
package capabilities
import (
"context"
"log"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ModelsForUser returns all models and Personas visible to a user.
//
// Visibility is controlled by the three-state visibility field on each
// catalog entry (enabled / team / disabled), applied per provider scope:
//
// Tier | enabled | team | disabled
// ----------+----------------+----------------------+---------
// Global | All users | Team admin → presets | Hidden
// Team | Team members | Team admin → presets | Hidden
// Personal | Owner direct | N/A | Hidden
//
// Sources aggregated:
// 1. Global catalog: enabled models → all authenticated users
// 2. Team catalog: enabled models → team members
// 3. Personal BYOK: enabled models → owner (if allow_user_byok)
// 4. Personas: global + team-scoped + personal + shared
// 5. User hidden preferences applied last
func ModelsForUser(ctx context.Context, stores store.Stores, userID string) ([]models.UserModel, error) {
result := make([]models.UserModel, 0) // never nil — serializes as [] not null
// Load policies once
policies, err := stores.Policies.GetAll(ctx)
if err != nil {
return nil, err
}
allowBYOK := policies["allow_user_byok"] == "true"
// Load user's hidden preferences
hiddenMap, err := stores.UserSettings.GetHiddenModelIDs(ctx, userID)
if err != nil {
log.Printf("warn: failed to load user model settings: %v", err)
hiddenMap = make(map[string]bool)
}
// Get user's team IDs
teamIDs, err := stores.Teams.GetUserTeamIDs(ctx, userID)
if err != nil {
log.Printf("warn: failed to load user teams: %v", err)
}
// ── 1. Global enabled catalog models → all users ────
globalModels, err := stores.Catalog.ListVisible(ctx)
if err != nil {
return nil, err
}
// Build provider name lookup for global providers
globalProviders, _ := stores.Providers.ListGlobal(ctx)
providerMap := make(map[string]models.ProviderConfig)
for _, p := range globalProviders {
providerMap[p.ID] = p
}
for _, entry := range globalModels {
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities)
prov := providerMap[entry.ProviderConfigID]
result = append(result, models.UserModel{
ID: entry.ModelID,
DisplayName: displayName(entry.DisplayName, entry.ModelID),
ModelID: entry.ModelID,
Source: "catalog",
ProviderConfigID: entry.ProviderConfigID,
ConfigID: entry.ProviderConfigID,
ProviderName: prov.Name,
ProviderType: prov.Provider,
Capabilities: caps,
Pricing: entry.Pricing,
Scope: models.ScopeGlobal,
Hidden: hiddenMap[entry.ModelID],
})
}
// ── 2. Team enabled catalog models → team members ────
for _, teamID := range teamIDs {
teamProviders, err := stores.Providers.ListForTeam(ctx, teamID)
if err != nil {
log.Printf("warn: failed to load team %s providers: %v", teamID, err)
continue
}
for _, prov := range teamProviders {
if !prov.IsActive {
continue
}
entries, err := stores.Catalog.ListEnabledForProvider(ctx, prov.ID)
if err != nil {
continue
}
for _, entry := range entries {
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities)
result = append(result, models.UserModel{
ID: entry.ModelID,
DisplayName: displayName(entry.DisplayName, entry.ModelID),
ModelID: entry.ModelID,
Source: "catalog",
ProviderConfigID: prov.ID,
ConfigID: prov.ID,
ProviderName: prov.Name,
ProviderType: prov.Provider,
Capabilities: caps,
Pricing: entry.Pricing,
Scope: models.ScopeTeam,
OwnerID: prov.OwnerID,
Hidden: hiddenMap[entry.ModelID],
})
}
}
}
// ── 3. Personal BYOK enabled models → owner ────
if allowBYOK {
personalProviders, err := stores.Providers.ListForUser(ctx, userID)
if err != nil {
log.Printf("warn: failed to load personal providers: %v", err)
} else {
for _, prov := range personalProviders {
if !prov.IsActive {
continue
}
entries, err := stores.Catalog.ListEnabledForProvider(ctx, prov.ID)
if err != nil {
continue
}
for _, entry := range entries {
caps := ResolveIntrinsic(entry.ModelID, &entry.Capabilities)
result = append(result, models.UserModel{
ID: entry.ModelID,
DisplayName: displayName(entry.DisplayName, entry.ModelID),
ModelID: entry.ModelID,
Source: "catalog",
ProviderConfigID: prov.ID,
ConfigID: prov.ID,
ProviderName: prov.Name,
ProviderType: prov.Provider,
Capabilities: caps,
Pricing: entry.Pricing,
Scope: models.ScopePersonal,
OwnerID: &userID,
Hidden: hiddenMap[entry.ModelID],
})
}
}
}
}
// ── 4. Personas (always resolved) ────────────────
personas, err := stores.Personas.ListForUser(ctx, userID)
if err != nil {
log.Printf("warn: failed to load personas: %v", err)
} else {
for _, p := range personas {
// Resolve base model capabilities
var catalogCaps *models.ModelCapabilities
if p.ProviderConfigID != nil {
if entry, err := stores.Catalog.GetByModelID(ctx, *p.ProviderConfigID, p.BaseModelID); err == nil {
catalogCaps = &entry.Capabilities
}
}
// Fallback: look up any provider's catalog entry for this model
// (covers auto-resolve presets where provider_config_id is NULL)
if catalogCaps == nil {
if entry, err := stores.Catalog.GetByModelIDAny(ctx, p.BaseModelID); err == nil {
catalogCaps = &entry.Capabilities
}
}
caps := ResolveIntrinsic(p.BaseModelID, catalogCaps)
// Load tool grants
toolGrants, _ := stores.Personas.GetToolGrants(ctx, p.ID)
if len(toolGrants) > 0 {
caps.ToolCalling = true
}
// Look up provider info
var provName, provType string
if p.ProviderConfigID != nil {
if prov, err := stores.Providers.GetByID(ctx, *p.ProviderConfigID); err == nil {
provName = prov.Name
provType = prov.Provider
}
}
result = append(result, models.UserModel{
ID: p.ID,
DisplayName: p.Name,
ModelID: p.BaseModelID,
Source: "persona",
ProviderConfigID: deref(p.ProviderConfigID),
ConfigID: deref(p.ProviderConfigID),
ProviderName: provName,
ProviderType: provType,
Capabilities: caps,
IsPreset: true,
PresetID: p.ID,
PresetScope: p.Scope,
PresetAvatar: p.Avatar,
PresetTeamName: teamName(p, stores, ctx),
PersonaID: p.ID,
Description: p.Description,
Icon: p.Icon,
Avatar: p.Avatar,
SystemPrompt: p.SystemPrompt,
Temperature: p.Temperature,
MaxTokens: p.MaxTokens,
ToolGrants: toolGrants,
Scope: p.Scope,
OwnerID: p.OwnerID,
Hidden: hiddenMap[p.ID],
})
}
}
return result, nil
}
// ResolveForPersona returns effective capabilities for a specific Persona.
// Used at completion time to determine what tools/features are available.
func ResolveForPersona(ctx context.Context, stores store.Stores, persona *models.Persona) (models.ModelCapabilities, []string, error) {
var catalogCaps *models.ModelCapabilities
if persona.ProviderConfigID != nil {
if entry, err := stores.Catalog.GetByModelID(ctx, *persona.ProviderConfigID, persona.BaseModelID); err == nil {
catalogCaps = &entry.Capabilities
}
}
// Fallback: any provider's catalog entry (auto-resolve presets)
if catalogCaps == nil {
if entry, err := stores.Catalog.GetByModelIDAny(ctx, persona.BaseModelID); err == nil {
catalogCaps = &entry.Capabilities
}
}
caps := ResolveIntrinsic(persona.BaseModelID, catalogCaps)
toolGrants, err := stores.Personas.GetToolGrants(ctx, persona.ID)
if err != nil {
return caps, nil, err
}
return caps, toolGrants, nil
}
func displayName(name, modelID string) string {
if name != "" {
return name
}
return modelID
}
func deref(s *string) string {
if s == nil {
return ""
}
return *s
}
// teamName resolves the team_name for a persona's owner (if team-scoped).
func teamName(p models.Persona, stores store.Stores, ctx context.Context) string {
if p.Scope != models.ScopeTeam || p.OwnerID == nil {
return ""
}
team, err := stores.Teams.GetByID(ctx, *p.OwnerID)
if err != nil {
return ""
}
return team.Name
}

View File

@@ -1,148 +1,156 @@
package database
import (
"database/sql"
"embed"
"fmt"
"log"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// schemaVersion tracks the latest applied migration.
var schemaVersion string = "none"
// Migrate checks the database schema state and applies any pending
// migrations. This runs at startup before the HTTP server opens.
//
// Flow:
// 1. Ping DB (health check)
// 2. Ensure schema_migrations table exists
// 3. Load applied versions
// 4. Discover embedded SQL files
// 5. Apply pending migrations in order
//
// All migrations run in individual transactions. A failed migration
// aborts startup — the backend will not serve traffic with an
// inconsistent schema.
// SchemaVersion returns the current schema version string.
func SchemaVersion() string { return schemaVersion }
// Migrate runs all pending migrations. It creates the schema_migrations
// tracking table if it doesn't exist, then applies each .sql file that
// hasn't been applied yet, in order.
func Migrate() error {
if DB == nil {
return fmt.Errorf("database not connected")
}
start := time.Now()
log.Println("📋 Schema migration check...")
// ── 1. Health check ─────────────────────────
if err := DB.Ping(); err != nil {
return fmt.Errorf("database unreachable: %w", err)
}
// ── 2. Ensure tracking table exists ─────────
// Ensure tracking table exists
_, err := DB.Exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(255) PRIMARY KEY,
version TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ DEFAULT NOW()
)
`)
if err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
return fmt.Errorf("create migrations table: %w", err)
}
// ── 3. Load already-applied versions ────────
applied := make(map[string]bool)
rows, err := DB.Query(`SELECT version FROM schema_migrations`)
// Find migration files
migrationsDir := findMigrationsDir()
if migrationsDir == "" {
log.Println("⚠ No migrations directory found — skipping schema migration")
return nil
}
entries, err := os.ReadDir(migrationsDir)
if err != nil {
return fmt.Errorf("read schema_migrations: %w", err)
}
defer rows.Close()
for rows.Next() {
var v string
if err := rows.Scan(&v); err != nil {
return fmt.Errorf("scan version: %w", err)
}
applied[v] = true
}
// ── 4. Discover embedded migration files ────
entries, err := migrationsFS.ReadDir("migrations")
if err != nil {
return fmt.Errorf("read embedded migrations: %w", err)
return fmt.Errorf("read migrations dir: %w", err)
}
// Collect and sort .sql files
var files []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
files = append(files, e.Name())
}
}
sort.Strings(files) // lexicographic = version order (001_, 002_, ...)
sort.Strings(files)
// ── 5. Apply pending ────────────────────────
pending := 0
skipped := 0
for _, name := range files {
if applied[name] {
skipped++
if len(files) == 0 {
log.Println(" No migration files found")
return nil
}
// Compat: rename old numeric-only version entries to full filenames.
// Earlier extractVersion used regex ^(\d+), recording "001" instead of
// "001_v09_schema.sql". Fix them in-place so CI's db-migrate.sh matches.
for _, file := range files {
prefix := strings.SplitN(file, "_", 2)[0] // "001"
DB.Exec("UPDATE schema_migrations SET version = $1 WHERE version = $2 AND version != $1", file, prefix)
}
// Apply pending migrations
applied := 0
for _, file := range files {
version := extractVersion(file)
if version == "" {
continue
}
content, err := migrationsFS.ReadFile("migrations/" + name)
// Check if already applied
var exists bool
DB.QueryRow("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)", version).Scan(&exists)
if exists {
schemaVersion = version
continue
}
// Read and execute
path := filepath.Join(migrationsDir, file)
sql, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read migration %s: %w", name, err)
return fmt.Errorf("read %s: %w", file, err)
}
log.Printf(" ▶ applying: %s", name)
tx, err := DB.Begin()
if err != nil {
return fmt.Errorf("begin tx for %s: %w", name, err)
log.Printf(" Applying migration %s...", file)
if _, err := DB.Exec(string(sql)); err != nil {
return fmt.Errorf("apply %s: %w", file, err)
}
if _, err := tx.Exec(string(content)); err != nil {
tx.Rollback()
return fmt.Errorf("migration %s failed: %w", name, err)
// Record
if _, err := DB.Exec("INSERT INTO schema_migrations (version) VALUES ($1)", version); err != nil {
return fmt.Errorf("record %s: %w", file, err)
}
if _, err := tx.Exec(
`INSERT INTO schema_migrations (version) VALUES ($1)`, name,
); err != nil {
tx.Rollback()
return fmt.Errorf("record migration %s: %w", name, err)
schemaVersion = version
applied++
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", name, err)
}
log.Printf(" ✓ %s applied", name)
pending++
}
elapsed := time.Since(start).Round(time.Millisecond)
if pending > 0 {
log.Printf("✅ Migrations complete: %d applied, %d skipped (%s)", pending, skipped, elapsed)
if applied > 0 {
log.Printf(" ✅ Applied %d migration(s), schema at %s", applied, schemaVersion)
} else {
log.Printf(" Schema up to date (%d migrations, %s)", skipped, elapsed)
log.Printf(" Schema up to date at %s", schemaVersion)
}
return nil
}
// SchemaVersion returns the most recently applied migration version,
// or "" if no migrations have been applied.
func SchemaVersion() string {
if DB == nil {
// extractVersion returns the filename as the version key if it's a valid
// migration file (starts with digit, ends with .sql).
// "001_v09_schema.sql" → "001_v09_schema.sql" (matches db-migrate.sh convention)
func extractVersion(filename string) string {
// Use full filename as version to match db-migrate.sh convention
if !strings.HasSuffix(filename, ".sql") {
return ""
}
var version sql.NullString
err := DB.QueryRow(`
SELECT version FROM schema_migrations
ORDER BY version DESC LIMIT 1
`).Scan(&version)
if err != nil || !version.Valid {
// Must start with a digit (e.g., 001_v09_schema.sql)
if len(filename) == 0 || filename[0] < '0' || filename[0] > '9' {
return ""
}
return version.String
return filename
}
// findMigrationsDir locates the migrations directory.
// Checks relative to the binary, then relative to the source file.
func findMigrationsDir() string {
candidates := []string{
"database/migrations",
"server/database/migrations",
"../database/migrations",
}
// Also check relative to this source file (for tests)
_, thisFile, _, ok := runtime.Caller(0)
if ok {
dir := filepath.Dir(thisFile)
candidates = append(candidates, filepath.Join(dir, "migrations"))
}
for _, c := range candidates {
if info, err := os.Stat(c); err == nil && info.IsDir() {
return c
}
}
return ""
}

View File

@@ -1,408 +0,0 @@
-- ==========================================
-- Chat Switchboard - PostgreSQL Schema
-- ==========================================
-- Enable extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE EXTENSION IF NOT EXISTS "vector"; -- For embeddings (pgvector)
-- ==========================================
-- Core Tables
-- ==========================================
-- Users
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name VARCHAR(100),
avatar_url TEXT,
role VARCHAR(20) DEFAULT 'user', -- user, admin, moderator
is_active BOOLEAN DEFAULT true,
settings JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_login_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_email ON users(email);
-- API Configurations (user's API keys)
CREATE TABLE api_configs (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL, -- "OpenAI", "Claude", "Local Ollama"
provider VARCHAR(50) NOT NULL, -- openai, anthropic, ollama, openrouter
endpoint TEXT NOT NULL,
api_key_encrypted TEXT, -- Encrypted at rest
model_default VARCHAR(100),
config JSONB DEFAULT '{}'::jsonb, -- Custom settings per provider
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_api_configs_user ON api_configs(user_id);
-- ==========================================
-- Feature 1: CHATS (User to LLM)
-- ==========================================
CREATE TABLE chats (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
model VARCHAR(100), -- Current model for this chat
api_config_id UUID REFERENCES api_configs(id) ON DELETE SET NULL,
system_prompt TEXT,
settings JSONB DEFAULT '{}'::jsonb, -- temperature, max_tokens, etc
is_archived BOOLEAN DEFAULT false,
is_pinned BOOLEAN DEFAULT false,
folder VARCHAR(100), -- For organization
tags TEXT[], -- For filtering
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_chats_user ON chats(user_id);
CREATE INDEX idx_chats_updated ON chats(updated_at DESC);
CREATE INDEX idx_chats_tags ON chats USING GIN(tags);
CREATE TABLE chat_messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
chat_id UUID REFERENCES chats(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL, -- user, assistant, system, tool
content TEXT NOT NULL,
model VARCHAR(100), -- Which model generated this (for assistant)
tokens_used INTEGER,
tool_calls JSONB, -- Function calls made
metadata JSONB DEFAULT '{}'::jsonb, -- thinking_blocks, attachments, etc
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_chat_messages_chat ON chat_messages(chat_id, created_at);
-- Model routing history (for analytics/debugging)
CREATE TABLE model_routing_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
chat_id UUID REFERENCES chats(id) ON DELETE CASCADE,
message_id UUID REFERENCES chat_messages(id) ON DELETE CASCADE,
requested_model VARCHAR(100),
routed_model VARCHAR(100),
reason TEXT, -- "cost_optimization", "context_length", "manual", "fallback"
latency_ms INTEGER,
cost_usd NUMERIC(10, 6),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_routing_log_chat ON model_routing_log(chat_id);
-- ==========================================
-- Feature 2: CHANNELS (User to User + AI)
-- ==========================================
CREATE TABLE channels (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
type VARCHAR(20) DEFAULT 'public', -- public, private, dm
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
settings JSONB DEFAULT '{}'::jsonb, -- ai_participants, webhooks, etc
is_archived BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_channels_name ON channels(name);
CREATE INDEX idx_channels_type ON channels(type);
-- Channel memberships
CREATE TABLE channel_members (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) DEFAULT 'member', -- owner, admin, member
joined_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_read_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(channel_id, user_id)
);
CREATE INDEX idx_channel_members_channel ON channel_members(channel_id);
CREATE INDEX idx_channel_members_user ON channel_members(user_id);
-- Channel messages
CREATE TABLE channel_messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL, -- NULL for AI messages
content TEXT NOT NULL,
mentions JSONB, -- {users: [uuid], models: [name]}
thread_id UUID REFERENCES channel_messages(id) ON DELETE CASCADE, -- For threading
attachments JSONB, -- Files, images, etc
reactions JSONB DEFAULT '{}'::jsonb, -- {emoji: [user_ids]}
is_ai_message BOOLEAN DEFAULT false,
ai_model VARCHAR(100), -- If AI generated
edited_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_channel_messages_channel ON channel_messages(channel_id, created_at DESC);
CREATE INDEX idx_channel_messages_thread ON channel_messages(thread_id);
CREATE INDEX idx_channel_messages_mentions ON channel_messages USING GIN(mentions);
-- ==========================================
-- Feature 3: NOTES & KNOWLEDGE BASES
-- ==========================================
CREATE TABLE notes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
content TEXT NOT NULL,
content_type VARCHAR(20) DEFAULT 'markdown', -- markdown, html, plain
folder VARCHAR(100),
tags TEXT[],
is_pinned BOOLEAN DEFAULT false,
is_shared BOOLEAN DEFAULT false,
share_token UUID UNIQUE DEFAULT uuid_generate_v4(),
parent_note_id UUID REFERENCES notes(id) ON DELETE SET NULL, -- For hierarchies
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_notes_user ON notes(user_id);
CREATE INDEX idx_notes_updated ON notes(updated_at DESC);
CREATE INDEX idx_notes_tags ON notes USING GIN(tags);
CREATE INDEX idx_notes_share_token ON notes(share_token) WHERE is_shared = true;
-- Knowledge Bases (Collections of documents)
CREATE TABLE knowledge_bases (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
description TEXT,
embedding_model VARCHAR(100) DEFAULT 'text-embedding-ada-002',
settings JSONB DEFAULT '{}'::jsonb, -- chunk_size, overlap, etc
is_public BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_knowledge_bases_user ON knowledge_bases(user_id);
-- Documents in knowledge bases
CREATE TABLE kb_documents (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
kb_id UUID REFERENCES knowledge_bases(id) ON DELETE CASCADE,
filename VARCHAR(500) NOT NULL,
content TEXT NOT NULL,
content_type VARCHAR(50), -- application/pdf, text/markdown, etc
file_size INTEGER,
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_kb_documents_kb ON kb_documents(kb_id);
-- Document chunks (for RAG)
CREATE TABLE kb_chunks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
document_id UUID REFERENCES kb_documents(id) ON DELETE CASCADE,
kb_id UUID REFERENCES knowledge_bases(id) ON DELETE CASCADE,
content TEXT NOT NULL,
embedding vector(1536), -- For pgvector similarity search
chunk_index INTEGER,
metadata JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_kb_chunks_document ON kb_chunks(document_id);
CREATE INDEX idx_kb_chunks_embedding ON kb_chunks USING ivfflat (embedding vector_cosine_ops);
-- ==========================================
-- Feature 4: PLUGIN ORCHESTRATION
-- ==========================================
-- Installed extensions/plugins
CREATE TABLE extensions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(100) UNIQUE NOT NULL,
version VARCHAR(20) NOT NULL,
author VARCHAR(100),
description TEXT,
runtime VARCHAR(20), -- python, go, node, rust
entry_point TEXT NOT NULL,
port INTEGER,
manifest JSONB NOT NULL, -- Full extension.json
is_enabled BOOLEAN DEFAULT true,
is_system BOOLEAN DEFAULT false, -- Core extensions
install_source VARCHAR(200), -- URL or marketplace ID
installed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_extensions_enabled ON extensions(is_enabled);
-- Extension tools/functions
CREATE TABLE extension_tools (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
extension_id UUID REFERENCES extensions(id) ON DELETE CASCADE,
name VARCHAR(100) NOT NULL,
description TEXT,
parameters_schema JSONB NOT NULL, -- JSON Schema
response_schema JSONB,
is_enabled BOOLEAN DEFAULT true,
UNIQUE(extension_id, name)
);
CREATE INDEX idx_extension_tools_extension ON extension_tools(extension_id);
-- Tool usage log (for analytics)
CREATE TABLE tool_usage_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
tool_id UUID REFERENCES extension_tools(id) ON DELETE SET NULL,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
chat_id UUID REFERENCES chats(id) ON DELETE SET NULL,
channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
input_params JSONB,
output_result JSONB,
execution_time_ms INTEGER,
success BOOLEAN,
error_message TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_tool_usage_tool ON tool_usage_log(tool_id);
CREATE INDEX idx_tool_usage_user ON tool_usage_log(user_id);
-- ==========================================
-- Feature 5: WORKFLOWS (Unique Feature!)
-- ==========================================
-- Workflow definitions (DAG of AI operations)
CREATE TABLE workflows (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
description TEXT,
graph JSONB NOT NULL, -- Node-edge DAG structure
input_schema JSONB, -- Expected inputs
output_schema JSONB, -- Expected outputs
is_public BOOLEAN DEFAULT false,
is_template BOOLEAN DEFAULT false,
tags TEXT[],
usage_count INTEGER DEFAULT 0,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_workflows_user ON workflows(user_id);
CREATE INDEX idx_workflows_public ON workflows(is_public) WHERE is_public = true;
CREATE INDEX idx_workflows_tags ON workflows USING GIN(tags);
-- Workflow executions
CREATE TABLE workflow_executions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
workflow_id UUID REFERENCES workflows(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
input_data JSONB,
output_data JSONB,
status VARCHAR(20), -- running, completed, failed
steps JSONB, -- Execution trace
total_cost_usd NUMERIC(10, 6),
total_time_ms INTEGER,
error_message TEXT,
started_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
completed_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_workflow_executions_workflow ON workflow_executions(workflow_id);
CREATE INDEX idx_workflow_executions_user ON workflow_executions(user_id);
-- ==========================================
-- Shared/Utility Tables
-- ==========================================
-- File uploads
CREATE TABLE files (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
filename VARCHAR(500) NOT NULL,
content_type VARCHAR(100),
file_size INTEGER,
storage_path TEXT NOT NULL, -- S3/local path
is_public BOOLEAN DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_files_user ON files(user_id);
-- Webhooks
CREATE TABLE webhooks (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(100),
url TEXT NOT NULL,
events TEXT[] NOT NULL, -- chat.message, channel.message, etc
secret VARCHAR(100),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_webhooks_user ON webhooks(user_id);
-- Audit log
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(100) NOT NULL,
resource_type VARCHAR(50),
resource_id UUID,
metadata JSONB,
ip_address INET,
user_agent TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_audit_log_user ON audit_log(user_id);
CREATE INDEX idx_audit_log_created ON audit_log(created_at DESC);
-- ==========================================
-- Functions & Triggers
-- ==========================================
-- Auto-update updated_at timestamps
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER chats_updated_at BEFORE UPDATE ON chats
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER channels_updated_at BEFORE UPDATE ON channels
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER notes_updated_at BEFORE UPDATE ON notes
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER knowledge_bases_updated_at BEFORE UPDATE ON knowledge_bases
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER workflows_updated_at BEFORE UPDATE ON workflows
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Increment workflow usage count
CREATE OR REPLACE FUNCTION increment_workflow_usage()
RETURNS TRIGGER AS $$
BEGIN
UPDATE workflows SET usage_count = usage_count + 1 WHERE id = NEW.workflow_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER workflow_executions_insert AFTER INSERT ON workflow_executions
FOR EACH ROW EXECUTE FUNCTION increment_workflow_usage();

View File

@@ -0,0 +1,585 @@
-- ==========================================
-- Chat Switchboard — v0.9.0 Consolidated Schema
-- ==========================================
-- Clean-slate schema. Replaces all 001021 migrations.
-- Drop DB and re-create before applying.
--
-- Design principles:
-- • Explicit scope enums over nullable column tri-states
-- • Personas as trust boundaries with extensible grants
-- • Secure by default (models hidden until admin enables)
-- • Only tables with active handlers — no placeholder tables
-- ==========================================
-- ── Extensions ──────────────────────────────
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid()
-- ── Utility: auto-update updated_at ─────────
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- =========================================
-- 1. USERS
-- =========================================
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(50) UNIQUE NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name VARCHAR(100),
avatar_url TEXT,
role VARCHAR(20) DEFAULT 'user'
CHECK (role IN ('user', 'admin', 'moderator')),
is_active BOOLEAN DEFAULT true,
settings JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
last_login_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
DROP TRIGGER IF EXISTS users_updated_at ON users;
CREATE TRIGGER users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- =========================================
-- 2. AUTH
-- =========================================
CREATE TABLE IF NOT EXISTS refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
revoked_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash) WHERE revoked_at IS NULL;
-- =========================================
-- 3. TEAMS
-- =========================================
CREATE TABLE IF NOT EXISTS teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL UNIQUE,
description TEXT DEFAULT '',
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
is_active BOOLEAN DEFAULT true,
settings JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_teams_active ON teams(is_active) WHERE is_active = true;
DROP TRIGGER IF EXISTS teams_updated_at ON teams;
CREATE TRIGGER teams_updated_at BEFORE UPDATE ON teams
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
COMMENT ON COLUMN teams.settings IS 'Team policies: {"require_private_providers": false}';
CREATE TABLE IF NOT EXISTS team_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL DEFAULT 'member'
CHECK (role IN ('admin', 'member')),
joined_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(team_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_team_members_team ON team_members(team_id);
CREATE INDEX IF NOT EXISTS idx_team_members_user ON team_members(user_id);
-- =========================================
-- 4. PROVIDER CONFIGS (replaces api_configs)
-- =========================================
-- Explicit scope enum replaces nullable column tri-state.
-- scope='global': admin-managed, visible to all users (owner_id IS NULL)
-- scope='team': team admin-managed (owner_id = teams.id)
-- scope='personal': user's own keys (owner_id = users.id)
CREATE TABLE IF NOT EXISTS provider_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scope VARCHAR(10) NOT NULL
CHECK (scope IN ('global', 'team', 'personal')),
owner_id UUID,
name VARCHAR(100) NOT NULL,
provider VARCHAR(50) NOT NULL,
endpoint TEXT NOT NULL,
api_key_enc TEXT,
model_default VARCHAR(100),
config JSONB DEFAULT '{}'::jsonb,
headers JSONB DEFAULT '{}'::jsonb,
settings JSONB DEFAULT '{}'::jsonb,
is_active BOOLEAN DEFAULT true,
is_private BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_provider_configs_scope ON provider_configs(scope);
CREATE INDEX IF NOT EXISTS idx_provider_configs_owner ON provider_configs(owner_id) WHERE owner_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_provider_configs_active ON provider_configs(is_active) WHERE is_active = true;
DROP TRIGGER IF EXISTS provider_configs_updated_at ON provider_configs;
CREATE TRIGGER provider_configs_updated_at BEFORE UPDATE ON provider_configs
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
COMMENT ON COLUMN provider_configs.scope IS 'global=admin-managed, team=team-scoped, personal=user BYOK';
COMMENT ON COLUMN provider_configs.owner_id IS 'NULL for global; teams.id for team scope; users.id for personal scope';
COMMENT ON COLUMN provider_configs.headers IS 'Custom HTTP headers (e.g. OpenRouter HTTP-Referer)';
COMMENT ON COLUMN provider_configs.settings IS 'Provider-specific params (e.g. Venice web_search, OpenRouter route)';
COMMENT ON COLUMN provider_configs.is_private IS 'Data stays on-prem (local/self-hosted provider)';
-- =========================================
-- 5. MODEL CATALOG (replaces model_configs)
-- =========================================
-- Intrinsic capabilities for models the system knows about.
-- Hidden by default — admin must enable.
CREATE TABLE IF NOT EXISTS model_catalog (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
provider_config_id UUID NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
model_id TEXT NOT NULL,
display_name TEXT,
capabilities JSONB NOT NULL DEFAULT '{}',
pricing JSONB,
visibility VARCHAR(10) DEFAULT 'disabled'
CHECK (visibility IN ('enabled', 'disabled', 'team')),
last_synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(provider_config_id, model_id)
);
CREATE INDEX IF NOT EXISTS idx_model_catalog_provider ON model_catalog(provider_config_id);
CREATE INDEX IF NOT EXISTS idx_model_catalog_enabled ON model_catalog(visibility) WHERE visibility = 'enabled';
-- Fix CHECK constraint and default for existing databases (idempotent)
DO $$ BEGIN
ALTER TABLE model_catalog DROP CONSTRAINT IF EXISTS model_catalog_visibility_check;
-- Remap old values before adding new constraint
UPDATE model_catalog SET visibility = 'enabled' WHERE visibility = 'visible';
UPDATE model_catalog SET visibility = 'disabled' WHERE visibility = 'hidden';
ALTER TABLE model_catalog ADD CONSTRAINT model_catalog_visibility_check
CHECK (visibility IN ('enabled', 'disabled', 'team'));
ALTER TABLE model_catalog ALTER COLUMN visibility SET DEFAULT 'disabled';
EXCEPTION WHEN OTHERS THEN NULL;
END $$;
DROP TRIGGER IF EXISTS model_catalog_updated_at ON model_catalog;
CREATE TRIGGER model_catalog_updated_at BEFORE UPDATE ON model_catalog
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
COMMENT ON COLUMN model_catalog.visibility IS 'hidden by default — admin must enable for global models';
COMMENT ON COLUMN model_catalog.capabilities IS 'Intrinsic: {"streaming","tool_calling","vision","thinking","reasoning","code_optimized","web_search","max_context","max_output_tokens"}';
-- =========================================
-- 6. PERSONAS (replaces model_presets)
-- =========================================
CREATE TABLE IF NOT EXISTS personas (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(100) NOT NULL,
description TEXT DEFAULT '',
icon VARCHAR(10) DEFAULT '',
avatar TEXT DEFAULT '',
-- Base model binding
base_model_id TEXT NOT NULL,
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
-- Behavioral configuration
system_prompt TEXT DEFAULT '',
temperature FLOAT,
max_tokens INT,
thinking_budget INT,
top_p FLOAT,
-- Scope & ownership
scope VARCHAR(10) NOT NULL
CHECK (scope IN ('global', 'team', 'personal')),
owner_id UUID,
created_by UUID NOT NULL REFERENCES users(id),
-- State
is_active BOOLEAN DEFAULT true,
is_shared BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_personas_scope ON personas(scope);
CREATE INDEX IF NOT EXISTS idx_personas_owner ON personas(owner_id) WHERE owner_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_personas_active ON personas(is_active) WHERE is_active = true;
DROP TRIGGER IF EXISTS personas_updated_at ON personas;
CREATE TRIGGER personas_updated_at BEFORE UPDATE ON personas
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
COMMENT ON COLUMN personas.scope IS 'global=all users, team=team members, personal=creator only';
COMMENT ON COLUMN personas.owner_id IS 'NULL for global; teams.id for team scope; users.id for personal';
COMMENT ON COLUMN personas.is_shared IS 'Personal Personas shared with others (read-only)';
-- =========================================
-- 7. PERSONA GRANTS (extensible resource binding)
-- =========================================
CREATE TABLE IF NOT EXISTS persona_grants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
persona_id UUID NOT NULL REFERENCES personas(id) ON DELETE CASCADE,
grant_type VARCHAR(30) NOT NULL,
grant_ref TEXT NOT NULL,
config JSONB DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(persona_id, grant_type, grant_ref)
);
CREATE INDEX IF NOT EXISTS idx_persona_grants_persona ON persona_grants(persona_id);
CREATE INDEX IF NOT EXISTS idx_persona_grants_type ON persona_grants(grant_type);
COMMENT ON COLUMN persona_grants.grant_type IS 'Extensible: tool, knowledge_base (future), api_endpoint (future)';
COMMENT ON COLUMN persona_grants.grant_ref IS 'tool: function name. knowledge_base: UUID. api_endpoint: identifier.';
COMMENT ON COLUMN persona_grants.config IS 'Type-specific config, e.g. {"read_only": true} for KB grants';
-- =========================================
-- 8. PLATFORM POLICIES (replaces scattered global_settings checks)
-- =========================================
CREATE TABLE IF NOT EXISTS platform_policies (
key VARCHAR(50) PRIMARY KEY,
value TEXT NOT NULL,
updated_by UUID REFERENCES users(id),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Secure by default
INSERT INTO platform_policies (key, value) VALUES
('allow_user_byok', 'false'),
('allow_user_personas', 'false'),
('allow_raw_model_access', 'true'),
('allow_registration', 'true'),
('default_user_active', 'false'),
('allow_team_providers', 'true')
ON CONFLICT (key) DO NOTHING;
COMMENT ON TABLE platform_policies IS 'Global admin switches controlling platform behavior';
-- =========================================
-- 9. GLOBAL SETTINGS (non-policy config)
-- =========================================
-- Retained for banner config, site branding, etc.
-- Policy-like keys move to platform_policies.
CREATE TABLE IF NOT EXISTS global_settings (
key VARCHAR(100) PRIMARY KEY,
value JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMPTZ DEFAULT NOW(),
updated_by UUID REFERENCES users(id)
);
-- Seed defaults
INSERT INTO global_settings (key, value) VALUES
('registration', '{"enabled": true}'::jsonb),
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'::jsonb),
('banner', '{
"enabled": false,
"text": "",
"position": "both",
"bg": "#007a33",
"fg": "#ffffff"
}'::jsonb),
('banner_presets', '{
"development": { "text": "DEVELOPMENT", "bg": "#007a33", "fg": "#ffffff" },
"testing": { "text": "TESTING", "bg": "#502b85", "fg": "#ffffff" },
"staging": { "text": "STAGING", "bg": "#0033a0", "fg": "#ffffff" },
"production": { "text": "PRODUCTION", "bg": "#c8102e", "fg": "#ffffff" },
"training": { "text": "TRAINING", "bg": "#ff8c00", "fg": "#000000" },
"demo": { "text": "DEMO", "bg": "#fce83a", "fg": "#000000" }
}'::jsonb)
ON CONFLICT (key) DO NOTHING;
-- =========================================
-- 10. USER MODEL SETTINGS (replaces user_model_preferences)
-- =========================================
CREATE TABLE IF NOT EXISTS user_model_settings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
model_id TEXT NOT NULL,
hidden BOOLEAN DEFAULT false,
preferred_temperature FLOAT,
preferred_max_tokens INT,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, model_id)
);
CREATE INDEX IF NOT EXISTS idx_user_model_settings_user ON user_model_settings(user_id);
DROP TRIGGER IF EXISTS user_model_settings_updated_at ON user_model_settings;
CREATE TRIGGER user_model_settings_updated_at BEFORE UPDATE ON user_model_settings
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- =========================================
-- 11. CHANNELS (unified chats)
-- =========================================
CREATE TABLE IF NOT EXISTS channels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
description TEXT,
type VARCHAR(20) DEFAULT 'direct'
CHECK (type IN ('direct', 'group', 'channel')),
model VARCHAR(100),
system_prompt TEXT,
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
is_archived BOOLEAN DEFAULT false,
is_pinned BOOLEAN DEFAULT false,
folder_id UUID, -- FK added after folders table
folder TEXT, -- backward compat: simple text folder name
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
settings JSONB DEFAULT '{}'::jsonb,
tags TEXT[],
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_channels_user ON channels(user_id);
CREATE INDEX IF NOT EXISTS idx_channels_updated ON channels(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_channels_type ON channels(type);
CREATE INDEX IF NOT EXISTS idx_channels_team ON channels(team_id) WHERE team_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_channels_tags ON channels USING GIN(tags);
DROP TRIGGER IF EXISTS channels_updated_at ON channels;
CREATE TRIGGER channels_updated_at BEFORE UPDATE ON channels
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
COMMENT ON COLUMN channels.type IS 'direct=1:1 AI chat, group=multi-model, channel=named persistent';
-- =========================================
-- 12. MESSAGES (with tree/forking support)
-- =========================================
CREATE TABLE IF NOT EXISTS messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL
CHECK (role IN ('user', 'assistant', 'system', 'tool')),
content TEXT NOT NULL,
model VARCHAR(100),
tokens_used INTEGER,
tool_calls JSONB,
metadata JSONB DEFAULT '{}'::jsonb,
parent_id UUID REFERENCES messages(id) ON DELETE SET NULL,
sibling_index INTEGER DEFAULT 0,
participant_type VARCHAR(10) DEFAULT 'user',
participant_id VARCHAR(255),
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, created_at);
CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_id);
CREATE INDEX IF NOT EXISTS idx_messages_alive ON messages(channel_id, created_at)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_messages_parent_alive ON messages(parent_id, sibling_index)
WHERE deleted_at IS NULL;
COMMENT ON COLUMN messages.parent_id IS 'Tree parent for conversation forking';
COMMENT ON COLUMN messages.sibling_index IS 'Position among siblings (0-indexed)';
COMMENT ON COLUMN messages.participant_type IS 'user or model';
COMMENT ON COLUMN messages.participant_id IS 'user UUID or model identifier string';
-- =========================================
-- 13. CHANNEL MEMBERS & MODELS
-- =========================================
CREATE TABLE IF NOT EXISTS channel_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) DEFAULT 'member',
joined_at TIMESTAMPTZ DEFAULT NOW(),
last_read_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(channel_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_channel_members_channel ON channel_members(channel_id);
CREATE INDEX IF NOT EXISTS idx_channel_members_user ON channel_members(user_id);
CREATE TABLE IF NOT EXISTS channel_models (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
model_id VARCHAR(255) NOT NULL,
provider_config_id UUID REFERENCES provider_configs(id) ON DELETE SET NULL,
display_name VARCHAR(100),
system_prompt TEXT,
settings JSONB DEFAULT '{}'::jsonb,
is_default BOOLEAN DEFAULT false,
added_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(channel_id, model_id)
);
CREATE INDEX IF NOT EXISTS idx_channel_models_channel ON channel_models(channel_id);
-- =========================================
-- 14. CHANNEL CURSORS (forking navigation)
-- =========================================
CREATE TABLE IF NOT EXISTS channel_cursors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
active_leaf_id UUID REFERENCES messages(id) ON DELETE SET NULL,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(channel_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_channel_cursors_channel ON channel_cursors(channel_id);
CREATE INDEX IF NOT EXISTS idx_channel_cursors_user ON channel_cursors(user_id);
-- =========================================
-- 15. FOLDERS & PROJECTS
-- =========================================
CREATE TABLE IF NOT EXISTS folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, name, parent_id)
);
CREATE INDEX IF NOT EXISTS idx_folders_user ON folders(user_id);
CREATE INDEX IF NOT EXISTS idx_folders_parent ON folders(parent_id);
DROP TRIGGER IF EXISTS folders_updated_at ON folders;
CREATE TRIGGER folders_updated_at BEFORE UPDATE ON folders
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Now add the FK from channels
DO $$ BEGIN
ALTER TABLE channels ADD CONSTRAINT fk_channels_folder
FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE SET NULL;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id) WHERE folder_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
description TEXT,
color VARCHAR(7),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_projects_user ON projects(user_id);
DROP TRIGGER IF EXISTS projects_updated_at ON projects;
CREATE TRIGGER projects_updated_at BEFORE UPDATE ON projects
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TABLE IF NOT EXISTS project_channels (
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
added_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (project_id, channel_id)
);
CREATE INDEX IF NOT EXISTS idx_project_channels_channel ON project_channels(channel_id);
-- =========================================
-- 16. NOTES
-- =========================================
CREATE TABLE IF NOT EXISTS notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
content TEXT DEFAULT '',
folder_path TEXT DEFAULT '/',
tags TEXT[] DEFAULT '{}',
metadata JSONB DEFAULT '{}',
source_channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
team_id UUID REFERENCES teams(id) ON DELETE SET NULL,
search_vector TSVECTOR,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_notes_user ON notes(user_id);
CREATE INDEX IF NOT EXISTS idx_notes_search ON notes USING GIN(search_vector);
CREATE INDEX IF NOT EXISTS idx_notes_folder ON notes(user_id, folder_path);
CREATE INDEX IF NOT EXISTS idx_notes_tags ON notes USING GIN(tags);
CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(user_id, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_notes_team ON notes(team_id) WHERE team_id IS NOT NULL;
CREATE OR REPLACE FUNCTION notes_search_update_fn()
RETURNS TRIGGER AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('pg_catalog.english', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('pg_catalog.english', COALESCE(NEW.content, '')), 'B');
NEW.updated_at := NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS notes_search_update ON notes;
CREATE TRIGGER notes_search_update
BEFORE INSERT OR UPDATE OF title, content ON notes
FOR EACH ROW EXECUTE FUNCTION notes_search_update_fn();
-- =========================================
-- 17. AUDIT LOG
-- =========================================
CREATE TABLE IF NOT EXISTS audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(100) NOT NULL,
resource_type VARCHAR(50) NOT NULL,
resource_id VARCHAR(255),
metadata JSONB DEFAULT '{}'::jsonb,
ip_address VARCHAR(45),
user_agent TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_audit_log_created ON audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id) WHERE actor_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_audit_log_resource ON audit_log(resource_type, resource_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);
COMMENT ON TABLE audit_log IS 'Immutable audit trail of all mutating operations';

View File

@@ -1,20 +0,0 @@
-- ==========================================
-- Chat Switchboard - Refresh Tokens
-- ==========================================
-- Supports JWT refresh token rotation.
-- Old tokens are revoked on each refresh.
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
revoked_at TIMESTAMP WITH TIME ZONE
);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash) WHERE revoked_at IS NULL;
-- Cleanup: auto-delete expired tokens older than 30 days
-- (run periodically or via pg_cron if available)

View File

@@ -1,15 +0,0 @@
-- Migration 003: Global Settings
-- Stores application-wide configuration managed by admins.
CREATE TABLE IF NOT EXISTS global_settings (
key VARCHAR(100) PRIMARY KEY,
value JSONB NOT NULL DEFAULT '{}'::jsonb,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_by UUID REFERENCES users(id)
);
-- Seed defaults
INSERT INTO global_settings (key, value) VALUES
('registration', '{"enabled": true}'::jsonb),
('site', '{"name": "Chat Switchboard", "tagline": "Multi-Model AI Chat"}'::jsonb)
ON CONFLICT (key) DO NOTHING;

View File

@@ -1,12 +0,0 @@
-- Model configurations: admin-curated list of available models
CREATE TABLE IF NOT EXISTS model_configs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
api_config_id UUID REFERENCES api_configs(id) ON DELETE CASCADE,
model_id TEXT NOT NULL,
display_name TEXT,
is_enabled BOOLEAN DEFAULT true,
capabilities JSONB DEFAULT '{"tool": false, "thinking": false, "vision": false, "code": false}',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(api_config_id, model_id)
);

View File

@@ -1,60 +0,0 @@
-- Migration 005: Provider Capabilities & Custom Headers
--
-- Adds provider-level configuration (custom headers, provider-specific settings)
-- and expands model capabilities to include output token limits.
-- This eliminates hardcoded max_tokens defaults throughout the system.
-- ── api_configs: custom headers and global flag ──
ALTER TABLE api_configs
ADD COLUMN IF NOT EXISTS custom_headers JSONB DEFAULT '{}'::jsonb,
ADD COLUMN IF NOT EXISTS provider_settings JSONB DEFAULT '{}'::jsonb,
ADD COLUMN IF NOT EXISTS is_global BOOLEAN DEFAULT false;
COMMENT ON COLUMN api_configs.custom_headers IS 'Extra HTTP headers sent with every request (e.g. OpenRouter HTTP-Referer)';
COMMENT ON COLUMN api_configs.provider_settings IS 'Provider-specific params (e.g. Venice web_search, OpenRouter route)';
COMMENT ON COLUMN api_configs.is_global IS 'Admin-managed configs visible to all users';
-- Backfill: existing admin-created configs (user_id IS NULL) are global
UPDATE api_configs SET is_global = true WHERE user_id IS NULL;
-- ── model_configs: richer capabilities ──
-- The existing capabilities JSONB gets richer fields.
-- No schema change needed (it's JSONB), but let's update existing rows
-- that have the old minimal shape to include the new fields.
-- New canonical shape:
-- {
-- "streaming": true,
-- "tool_calling": false,
-- "vision": false,
-- "thinking": false,
-- "reasoning": false,
-- "code_optimized": false,
-- "max_context": 0,
-- "max_output_tokens": 0,
-- "web_search": false
-- }
-- max_output_tokens = 0 means "not set, use provider/heuristic default"
-- Migrate old "tool" key to "tool_calling" for consistency
UPDATE model_configs
SET capabilities = capabilities - 'tool' || jsonb_build_object('tool_calling', COALESCE(capabilities->>'tool', 'false')::boolean)
WHERE capabilities ? 'tool' AND NOT capabilities ? 'tool_calling';
-- Migrate old "code" key to "code_optimized"
UPDATE model_configs
SET capabilities = capabilities - 'code' || jsonb_build_object('code_optimized', COALESCE(capabilities->>'code', 'false')::boolean)
WHERE capabilities ? 'code' AND NOT capabilities ? 'code_optimized';
-- ── user_model_preferences ──
-- Users can enable/disable models from global providers for personal use.
CREATE TABLE IF NOT EXISTS user_model_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
model_config_id UUID NOT NULL REFERENCES model_configs(id) ON DELETE CASCADE,
is_enabled BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, model_config_id)
);
CREATE INDEX IF NOT EXISTS idx_user_model_prefs_user ON user_model_preferences(user_id);

View File

@@ -1,114 +0,0 @@
-- ==========================================
-- Migration 006: Unify Chats → Channels
-- ==========================================
-- "Everything is a channel." Merges the separate chats/channels
-- schema into a single unified channel model.
--
-- The old channels/channel_members/channel_messages tables (from 001)
-- were never populated — safe to drop and rebuild on top of chats.
-- ==========================================
-- ── 1. Drop unused legacy channel tables ────
-- These were placeholders from 001; no data, no handlers.
-- Order matters: drop dependents first.
-- Remove FK references in tool_usage_log before dropping
ALTER TABLE tool_usage_log DROP CONSTRAINT IF EXISTS tool_usage_log_channel_id_fkey;
ALTER TABLE tool_usage_log DROP COLUMN IF EXISTS channel_id;
DROP TABLE IF EXISTS channel_messages CASCADE;
DROP TABLE IF EXISTS channel_members CASCADE;
DROP TABLE IF EXISTS channels CASCADE;
-- Drop the old trigger (will re-create after rename)
DROP TRIGGER IF EXISTS channels_updated_at ON channels;
-- ── 2. Rename chats → channels ──────────────
ALTER TABLE chats RENAME TO channels;
ALTER TABLE chat_messages RENAME TO messages;
-- Rename columns to match new schema
ALTER TABLE messages RENAME COLUMN chat_id TO channel_id;
-- Rename indexes
ALTER INDEX IF EXISTS idx_chats_user RENAME TO idx_channels_user;
ALTER INDEX IF EXISTS idx_chats_updated RENAME TO idx_channels_updated;
ALTER INDEX IF EXISTS idx_chats_tags RENAME TO idx_channels_tags;
ALTER INDEX IF EXISTS idx_chat_messages_chat RENAME TO idx_messages_channel;
-- Rename constraints (PK and FK auto-renamed with table on some PG versions,
-- but let's be explicit for the FK)
-- Note: PG auto-renames PKs but not FKs or check constraints
-- Rename the updated_at trigger
DROP TRIGGER IF EXISTS chats_updated_at ON channels;
CREATE TRIGGER channels_updated_at BEFORE UPDATE ON channels
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- ── 3. Add channel type + description ───────
ALTER TABLE channels ADD COLUMN IF NOT EXISTS type VARCHAR(20) DEFAULT 'direct';
ALTER TABLE channels ADD COLUMN IF NOT EXISTS description TEXT;
COMMENT ON COLUMN channels.type IS 'direct=1:1 AI chat, group=multi-model, channel=named persistent';
-- Backfill: all existing rows are 1:1 AI chats
UPDATE channels SET type = 'direct' WHERE type IS NULL;
-- Index on type for filtered queries
CREATE INDEX IF NOT EXISTS idx_channels_type ON channels(type);
-- ── 4. Add message tree (parent_id) ─────────
ALTER TABLE messages ADD COLUMN IF NOT EXISTS parent_id UUID REFERENCES messages(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_messages_parent ON messages(parent_id);
-- Backfill linear parent chains on existing messages.
-- Each message's parent is the previous message in the same channel (by created_at).
WITH ordered AS (
SELECT id, channel_id, created_at,
LAG(id) OVER (PARTITION BY channel_id ORDER BY created_at) AS prev_id
FROM messages
)
UPDATE messages m
SET parent_id = o.prev_id
FROM ordered o
WHERE m.id = o.id AND o.prev_id IS NOT NULL AND m.parent_id IS NULL;
-- ── 5. Add participant columns on messages ──
-- Decouples messages from user-only: AI models are participants too.
ALTER TABLE messages ADD COLUMN IF NOT EXISTS participant_type VARCHAR(10) DEFAULT 'user';
ALTER TABLE messages ADD COLUMN IF NOT EXISTS participant_id VARCHAR(255);
COMMENT ON COLUMN messages.participant_type IS 'user or model';
COMMENT ON COLUMN messages.participant_id IS 'user UUID or model identifier string';
-- Backfill: user messages get the channel owner's user_id;
-- assistant messages get the model name as participant_id.
UPDATE messages m
SET participant_type = CASE WHEN m.role = 'assistant' THEN 'model' ELSE 'user' END,
participant_id = CASE
WHEN m.role = 'assistant' THEN COALESCE(m.model, 'unknown')
ELSE (SELECT c.user_id::text FROM channels c WHERE c.id = m.channel_id)
END
WHERE m.participant_id IS NULL;
-- ── 6. Update model_routing_log FK ──────────
-- The FK column was named chat_id — rename it
ALTER TABLE model_routing_log RENAME COLUMN chat_id TO channel_id;
ALTER INDEX IF EXISTS idx_routing_log_chat RENAME TO idx_routing_log_channel;
-- message_id FK still valid (messages table was renamed, FK follows)
-- ── 7. Update tool_usage_log ────────────────
-- We already dropped the old channel_id column above.
-- Re-add it pointing to the unified channels table.
-- Also rename the old chat_id column.
ALTER TABLE tool_usage_log RENAME COLUMN chat_id TO channel_id;
-- The FK auto-follows the table rename, but let's be safe:
-- (chat_id FK pointed to chats(id), which is now channels(id) — PG handles this)

View File

@@ -1,56 +0,0 @@
-- ==========================================
-- Migration 007: Channel Members & Models
-- ==========================================
-- Membership and model assignment tables for
-- multi-user and multi-model channels.
-- ==========================================
-- ── Channel Members ─────────────────────────
-- Who is in this channel (human participants).
CREATE TABLE IF NOT EXISTS channel_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) DEFAULT 'member', -- owner, admin, member
joined_at TIMESTAMPTZ DEFAULT NOW(),
last_read_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(channel_id, user_id)
);
CREATE INDEX idx_channel_members_channel ON channel_members(channel_id);
CREATE INDEX idx_channel_members_user ON channel_members(user_id);
-- Backfill: existing channels are 1:1, so the channel owner is the sole member.
INSERT INTO channel_members (channel_id, user_id, role)
SELECT id, user_id, 'owner'
FROM channels
WHERE user_id IS NOT NULL
ON CONFLICT (channel_id, user_id) DO NOTHING;
-- ── Channel Models ──────────────────────────
-- Which AI models are assigned to this channel.
-- For direct chats this is one model; for group/channel
-- there can be multiple with @mention routing.
CREATE TABLE IF NOT EXISTS channel_models (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
model_id VARCHAR(255) NOT NULL, -- e.g. 'claude-sonnet-4'
api_config_id UUID REFERENCES api_configs(id) ON DELETE SET NULL,
display_name VARCHAR(100), -- optional alias in this channel
system_prompt TEXT, -- per-model system prompt override
settings JSONB DEFAULT '{}'::jsonb, -- temperature, max_tokens overrides
is_default BOOLEAN DEFAULT false, -- auto-complete (no @mention needed)
added_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(channel_id, model_id)
);
CREATE INDEX idx_channel_models_channel ON channel_models(channel_id);
-- Backfill: existing channels have a model in the channels.model column.
INSERT INTO channel_models (channel_id, model_id, api_config_id, is_default)
SELECT id, model, api_config_id, true
FROM channels
WHERE model IS NOT NULL AND model != ''
ON CONFLICT (channel_id, model_id) DO NOTHING;

View File

@@ -1,29 +0,0 @@
-- ==========================================
-- Migration 008: Channel Cursors
-- ==========================================
-- Tracks each user's active branch position
-- per channel. Essential for conversation forking.
-- ==========================================
CREATE TABLE IF NOT EXISTS channel_cursors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
active_leaf_id UUID REFERENCES messages(id) ON DELETE SET NULL,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(channel_id, user_id)
);
CREATE INDEX idx_channel_cursors_channel ON channel_cursors(channel_id);
CREATE INDEX idx_channel_cursors_user ON channel_cursors(user_id);
-- Backfill: set cursor to the last message in each channel for the owner.
INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id)
SELECT c.id, c.user_id, (
SELECT m.id FROM messages m
WHERE m.channel_id = c.id
ORDER BY m.created_at DESC LIMIT 1
)
FROM channels c
WHERE c.user_id IS NOT NULL
ON CONFLICT (channel_id, user_id) DO NOTHING;

View File

@@ -1,57 +0,0 @@
-- ==========================================
-- Migration 009: Folders & Projects
-- ==========================================
-- Organizational structures for channels.
-- Folders are simple containers; projects are
-- tagged collections that can span folders.
-- ==========================================
-- ── Folders ─────────────────────────────────
CREATE TABLE IF NOT EXISTS folders (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, name, parent_id)
);
CREATE INDEX idx_folders_user ON folders(user_id);
CREATE INDEX idx_folders_parent ON folders(parent_id);
CREATE TRIGGER folders_updated_at BEFORE UPDATE ON folders
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Add folder_id to channels (replaces the old text folder column)
ALTER TABLE channels ADD COLUMN IF NOT EXISTS folder_id UUID REFERENCES folders(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_channels_folder ON channels(folder_id);
-- ── Projects ────────────────────────────────
CREATE TABLE IF NOT EXISTS projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(200) NOT NULL,
description TEXT,
color VARCHAR(7), -- hex color for UI badge
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_projects_user ON projects(user_id);
CREATE TRIGGER projects_updated_at BEFORE UPDATE ON projects
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Junction: channels can belong to multiple projects
CREATE TABLE IF NOT EXISTS project_channels (
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
added_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (project_id, channel_id)
);
CREATE INDEX idx_project_channels_channel ON project_channels(channel_id);

View File

@@ -1,32 +0,0 @@
-- ==========================================
-- Migration 010: Environment Banners
-- ==========================================
-- Environment banners for deployment context
-- (dev, staging, production, etc). Stored in
-- global_settings with a dedicated key.
-- ==========================================
-- Seed default banner config (disabled).
-- Schema: { enabled, text, position, bg, fg }
INSERT INTO global_settings (key, value) VALUES
('banner', '{
"enabled": false,
"text": "",
"position": "both",
"bg": "#007a33",
"fg": "#ffffff"
}'::jsonb)
ON CONFLICT (key) DO NOTHING;
-- Banner presets for quick selection in admin UI.
-- Generic environment labels — admins can set custom text.
INSERT INTO global_settings (key, value) VALUES
('banner_presets', '{
"development": { "text": "DEVELOPMENT", "bg": "#007a33", "fg": "#ffffff" },
"testing": { "text": "TESTING", "bg": "#502b85", "fg": "#ffffff" },
"staging": { "text": "STAGING", "bg": "#0033a0", "fg": "#ffffff" },
"production": { "text": "PRODUCTION", "bg": "#c8102e", "fg": "#ffffff" },
"training": { "text": "TRAINING", "bg": "#ff8c00", "fg": "#000000" },
"demo": { "text": "DEMO", "bg": "#fce83a", "fg": "#000000" }
}'::jsonb)
ON CONFLICT (key) DO NOTHING;

View File

@@ -1,18 +0,0 @@
-- ==========================================
-- Migration 011: Replace banner presets
-- ==========================================
-- Replaces legacy presets with
-- generic environment labels. Existing databases
-- that ran 010 have the old presets; this overwrites.
-- ==========================================
UPDATE global_settings
SET value = '{
"development": { "text": "DEVELOPMENT", "bg": "#007a33", "fg": "#ffffff" },
"testing": { "text": "TESTING", "bg": "#502b85", "fg": "#ffffff" },
"staging": { "text": "STAGING", "bg": "#0033a0", "fg": "#ffffff" },
"production": { "text": "PRODUCTION", "bg": "#c8102e", "fg": "#ffffff" },
"training": { "text": "TRAINING", "bg": "#ff8c00", "fg": "#000000" },
"demo": { "text": "DEMO", "bg": "#fce83a", "fg": "#000000" }
}'::jsonb
WHERE key = 'banner_presets';

View File

@@ -1,33 +0,0 @@
-- Model Presets: named wrappers around base models with bundled config.
-- Admins create org-wide presets, users create personal ones.
CREATE TABLE IF NOT EXISTS model_presets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL,
description TEXT DEFAULT '',
base_model_id TEXT NOT NULL, -- e.g. "gpt-4o", "claude-sonnet-4-20250514"
api_config_id UUID REFERENCES api_configs(id) ON DELETE CASCADE,
system_prompt TEXT DEFAULT '',
temperature REAL, -- NULL = use model default
max_tokens INTEGER, -- NULL = use model default
tools_enabled JSONB DEFAULT '[]'::jsonb, -- reserved for future tool framework
scope VARCHAR(20) NOT NULL DEFAULT 'personal'
CHECK (scope IN ('global', 'team', 'personal')),
team_id UUID, -- nullable; for future team scoping
created_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
is_shared BOOLEAN DEFAULT false, -- personal presets visible to others
is_active BOOLEAN DEFAULT true,
icon VARCHAR(10) DEFAULT '', -- emoji or short icon code
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_model_presets_scope ON model_presets(scope);
CREATE INDEX IF NOT EXISTS idx_model_presets_created_by ON model_presets(created_by);
CREATE INDEX IF NOT EXISTS idx_model_presets_team ON model_presets(team_id) WHERE team_id IS NOT NULL;
COMMENT ON TABLE model_presets IS 'Named model configurations: org-wide or personal wrappers around base models';
COMMENT ON COLUMN model_presets.base_model_id IS 'The underlying model_id (matches model_configs.model_id)';
COMMENT ON COLUMN model_presets.api_config_id IS 'Which provider config to use (NULL = resolve at completion time)';
COMMENT ON COLUMN model_presets.scope IS 'global = admin-created for all users; team = team-scoped; personal = user-created';
COMMENT ON COLUMN model_presets.tools_enabled IS 'JSON array of tool names enabled for this preset (future use)';

View File

@@ -1,28 +0,0 @@
-- ==========================================
-- Migration 013: Message Forking Support
-- ==========================================
-- Adds soft delete and sibling ordering to support
-- conversation forking (edit, regenerate, branch).
-- See ARCHITECTURE.md §8 for the full tree model.
-- ==========================================
-- Soft delete: pruned branches keep their structure for undo
ALTER TABLE messages ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ;
-- Sibling ordering: explicit position among children of same parent
-- First child = 0, second = 1, etc. Set at insert time.
ALTER TABLE messages ADD COLUMN IF NOT EXISTS sibling_index INTEGER DEFAULT 0;
-- Partial index: fast lookup of live messages only
CREATE INDEX IF NOT EXISTS idx_messages_alive
ON messages(channel_id, created_at)
WHERE deleted_at IS NULL;
-- Children of a parent (for sibling queries)
CREATE INDEX IF NOT EXISTS idx_messages_parent_alive
ON messages(parent_id, sibling_index)
WHERE deleted_at IS NULL;
-- Backfill sibling_index for existing messages.
-- In linear conversations every message is the sole child of its parent,
-- so all get sibling_index = 0 (already the default). No update needed.

View File

@@ -1,50 +0,0 @@
-- 014_notes.sql — Notes table with full-text search
--
-- Notes are user-scoped persistent documents. The LLM can create, search,
-- and update them via built-in tools (note_create, note_search, etc.).
-- Full-text search uses PostgreSQL tsvector — zero additional infrastructure.
--
-- DROP first: if a previous deployment created an incomplete version of
-- this table (e.g. missing search_vector), IF NOT EXISTS would skip and
-- the indexes/trigger would fail. Safe because 014 was never recorded
-- in schema_migrations — any existing data is from a failed attempt.
DROP TABLE IF EXISTS notes CASCADE;
CREATE TABLE notes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title VARCHAR(500) NOT NULL,
content TEXT DEFAULT '',
folder_path TEXT DEFAULT '/',
tags TEXT[] DEFAULT '{}',
metadata JSONB DEFAULT '{}',
source_channel_id UUID REFERENCES channels(id) ON DELETE SET NULL,
search_vector TSVECTOR,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_notes_user ON notes(user_id);
CREATE INDEX idx_notes_search ON notes USING GIN(search_vector);
CREATE INDEX idx_notes_folder ON notes(user_id, folder_path);
CREATE INDEX idx_notes_tags ON notes USING GIN(tags);
CREATE INDEX idx_notes_updated ON notes(user_id, updated_at DESC);
-- Auto-update search vector from title + content
CREATE OR REPLACE FUNCTION notes_search_update_fn()
RETURNS TRIGGER AS $$
BEGIN
NEW.search_vector :=
setweight(to_tsvector('pg_catalog.english', COALESCE(NEW.title, '')), 'A') ||
setweight(to_tsvector('pg_catalog.english', COALESCE(NEW.content, '')), 'B');
NEW.updated_at := NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Drop first to allow re-run
DROP TRIGGER IF EXISTS notes_search_update ON notes;
CREATE TRIGGER notes_search_update
BEFORE INSERT OR UPDATE OF title, content ON notes
FOR EACH ROW EXECUTE FUNCTION notes_search_update_fn();

View File

@@ -1,6 +0,0 @@
-- Avatar support for model presets.
-- Users table already has avatar_url from 001_full_schema.sql.
ALTER TABLE model_presets ADD COLUMN IF NOT EXISTS avatar TEXT DEFAULT '';
COMMENT ON COLUMN model_presets.avatar IS 'Base64 data URI of preset avatar image (128x128 PNG), empty = use icon emoji';

View File

@@ -1,76 +0,0 @@
-- ==========================================
-- Migration 016: Teams
-- ==========================================
-- Teams are the middle tier between system admin and individual users.
-- A team admin can manage members, create team-scoped presets,
-- and enforce provider policies — without system-wide access.
-- ==========================================
-- ── 1. Teams table ──────────────────────────
CREATE TABLE IF NOT EXISTS teams (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name VARCHAR(200) NOT NULL UNIQUE,
description TEXT DEFAULT '',
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
is_active BOOLEAN DEFAULT true,
settings JSONB DEFAULT '{}'::jsonb, -- team-level policies
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_teams_active ON teams(is_active) WHERE is_active = true;
CREATE TRIGGER teams_updated_at BEFORE UPDATE ON teams
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
COMMENT ON TABLE teams IS 'Organizational teams with scoped administration';
COMMENT ON COLUMN teams.settings IS 'Team policies: {"require_private_providers": false}';
-- ── 2. Team Members table ───────────────────
CREATE TABLE IF NOT EXISTS team_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL DEFAULT 'member'
CHECK (role IN ('admin', 'member')),
joined_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(team_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_team_members_team ON team_members(team_id);
CREATE INDEX IF NOT EXISTS idx_team_members_user ON team_members(user_id);
COMMENT ON TABLE team_members IS 'Team membership with per-team roles (admin or member)';
COMMENT ON COLUMN team_members.role IS 'admin = manages team; member = uses team resources';
-- ── 3. Wire model_presets.team_id FK ────────
-- Column already exists (migration 012), just add the FK constraint.
DO $$ BEGIN
ALTER TABLE model_presets
ADD CONSTRAINT fk_presets_team
FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE SET NULL;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
-- ── 4. Add team_id to channels ──────────────
-- Team channels are visible to all team members.
ALTER TABLE channels ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_channels_team ON channels(team_id) WHERE team_id IS NOT NULL;
-- ── 5. Private provider flag ────────────────
-- Marks a provider as local/self-hosted (data stays on-prem).
ALTER TABLE api_configs ADD COLUMN IF NOT EXISTS is_private BOOLEAN DEFAULT false;
COMMENT ON COLUMN api_configs.is_private IS 'Private/self-hosted provider — data does not leave network';
-- ── 6. Add team_id to notes ─────────────────
-- Team notes are shared within the team.
ALTER TABLE notes ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_notes_team ON notes(team_id) WHERE team_id IS NOT NULL;

View File

@@ -1,37 +0,0 @@
-- ==========================================
-- Migration 017: Audit Log
-- ==========================================
-- Immutable append-only log of all mutating actions.
-- Required for enterprise compliance (SOC2, FedRAMP, HIPAA).
-- ==========================================
-- Drop stale table if left from a prior partial run
DROP TABLE IF EXISTS audit_log CASCADE;
CREATE TABLE audit_log (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
action VARCHAR(100) NOT NULL, -- e.g. 'user.create', 'team.add_member'
resource_type VARCHAR(50) NOT NULL, -- e.g. 'user', 'team', 'preset', 'channel'
resource_id VARCHAR(255), -- UUID or identifier of affected resource
metadata JSONB DEFAULT '{}'::jsonb, -- action-specific details
ip_address VARCHAR(45), -- IPv4 or IPv6
user_agent TEXT DEFAULT '',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Time-range queries (admin viewer, compliance exports)
CREATE INDEX idx_audit_log_created ON audit_log(created_at DESC);
-- Filter by actor
CREATE INDEX idx_audit_log_actor ON audit_log(actor_id) WHERE actor_id IS NOT NULL;
-- Filter by resource
CREATE INDEX idx_audit_log_resource ON audit_log(resource_type, resource_id);
-- Filter by action
CREATE INDEX idx_audit_log_action ON audit_log(action);
COMMENT ON TABLE audit_log IS 'Immutable audit trail of all mutating operations';
COMMENT ON COLUMN audit_log.action IS 'Dotted action name: resource.verb (e.g. user.create, team.add_member)';
COMMENT ON COLUMN audit_log.metadata IS 'Action-specific context: old/new values, affected fields, etc.';

View File

@@ -1,36 +0,0 @@
-- ==========================================
-- Migration 018: Model Visibility
-- ==========================================
-- Replace binary is_enabled with three-state visibility:
-- 'enabled' — visible to all users in model selector
-- 'disabled' — hidden from everyone
-- 'team' — only available to team admins for building presets
-- ==========================================
-- Add new column (idempotent)
ALTER TABLE model_configs ADD COLUMN IF NOT EXISTS visibility VARCHAR(10) DEFAULT 'disabled';
-- Backfill from is_enabled if it still exists
DO $$ BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'model_configs' AND column_name = 'is_enabled'
) THEN
UPDATE model_configs SET visibility = CASE
WHEN is_enabled = true THEN 'enabled'
ELSE 'disabled'
END;
ALTER TABLE model_configs DROP COLUMN is_enabled;
END IF;
END $$;
-- Ensure NOT NULL (safe: DEFAULT already covers new rows)
UPDATE model_configs SET visibility = 'disabled' WHERE visibility IS NULL;
ALTER TABLE model_configs ALTER COLUMN visibility SET NOT NULL;
-- Constraint (drop first for idempotency)
ALTER TABLE model_configs DROP CONSTRAINT IF EXISTS chk_model_visibility;
ALTER TABLE model_configs ADD CONSTRAINT chk_model_visibility
CHECK (visibility IN ('enabled', 'disabled', 'team'));
COMMENT ON COLUMN model_configs.visibility IS 'enabled=all users, team=team admin presets only, disabled=hidden';

View File

@@ -1,8 +0,0 @@
-- ==========================================
-- Migration 019: (superseded by 020)
-- ==========================================
-- Original CREATE TABLE IF NOT EXISTS was a no-op because
-- user_model_preferences already existed from migration 005.
-- The actual rework is in 020_user_model_preferences_rework.sql.
-- ==========================================
SELECT 1;

View File

@@ -1,24 +0,0 @@
-- ==========================================
-- Migration 019: User Model Preferences (rework)
-- ==========================================
-- The user_model_preferences table was created in 005 with:
-- id UUID PK, user_id UUID, model_config_id UUID FK, is_enabled BOOL
-- That schema ties preferences to model_configs rows (global only).
-- We need string-based model_id to support personal provider models too,
-- plus a 'hidden' column with clearer semantics.
--
-- Strategy: add new columns, add unique constraint for UPSERT.
-- Old columns (model_config_id, is_enabled) remain for backward compat.
-- ==========================================
-- Add new columns if they don't exist
ALTER TABLE user_model_preferences ADD COLUMN IF NOT EXISTS model_id VARCHAR(255);
ALTER TABLE user_model_preferences ADD COLUMN IF NOT EXISTS hidden BOOLEAN DEFAULT false;
ALTER TABLE user_model_preferences ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ DEFAULT NOW();
-- Make model_config_id nullable (new rows use model_id instead)
ALTER TABLE user_model_preferences ALTER COLUMN model_config_id DROP NOT NULL;
-- Unique constraint for UPSERT — NULLs are distinct in PG so old rows won't conflict
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_model_pref_user_model
ON user_model_preferences (user_id, model_id);

View File

@@ -1,16 +0,0 @@
-- ==========================================
-- Migration 021: Team Providers
-- ==========================================
-- Adds team_id to api_configs, enabling teams to have their own
-- provider configs managed by team admins.
--
-- Provider hierarchy: global (user_id IS NULL, is_global=true)
-- → team (team_id IS NOT NULL)
-- → personal (user_id IS NOT NULL)
-- ==========================================
-- Add team_id FK to api_configs
ALTER TABLE api_configs ADD COLUMN IF NOT EXISTS team_id UUID REFERENCES teams(id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_api_configs_team ON api_configs(team_id) WHERE team_id IS NOT NULL;
COMMENT ON COLUMN api_configs.team_id IS 'Team-scoped provider — managed by team admins, visible to team members';

View File

@@ -68,8 +68,6 @@ func SetupTestDB() func() {
createdByUs := false
adminDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", testDBName))
if _, err := adminDB.Exec(fmt.Sprintf("CREATE DATABASE %s", testDBName)); err != nil {
// Permission denied is expected in CI — the bootstrap step
// already created the DB with admin creds. Just proceed.
log.Printf("⚠ Cannot CREATE DATABASE %s (will try to connect to existing): %v", testDBName, err)
} else {
createdByUs = true
@@ -161,14 +159,19 @@ func TruncateAll(t *testing.T) {
// Order matters due to foreign keys — truncate with CASCADE
tables := []string{
"notes",
"audit_log",
"channel_cursors",
"messages",
"channel_models",
"channel_members",
"channels",
"model_configs",
"model_presets",
"api_configs",
"user_model_settings",
"model_catalog",
"persona_grants",
"personas",
"provider_configs",
"team_members",
"teams",
"refresh_tokens",
"users",
}
@@ -197,16 +200,13 @@ func SeedTestChannel(t *testing.T, userID, title string) string {
t.Helper()
var id string
err := DB.QueryRow(`
INSERT INTO channels (title, created_by)
INSERT INTO channels (user_id, title)
VALUES ($1, $2)
RETURNING id
`, title, userID).Scan(&id)
`, userID, title).Scan(&id)
if err != nil {
t.Fatalf("SeedTestChannel: %v", err)
}
// Add ownership
DB.Exec(`INSERT INTO channel_members (channel_id, user_id, role) VALUES ($1, $2, 'owner')`,
id, userID)
return id
}
@@ -233,7 +233,6 @@ func replaceDBName(dsn, newDB string) string {
}
// Handle URL format: postgres://user:pass@host/olddb?...
if strings.Contains(dsn, "://") {
// Find the last / before ? and replace the path
idx := strings.LastIndex(dsn, "/")
qIdx := strings.Index(dsn, "?")
if qIdx > idx {

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +0,0 @@
package handlers
import (
"testing"
)
func TestNewSettingsHandler(t *testing.T) {
h := NewSettingsHandler()
if h == nil {
t.Fatal("NewSettingsHandler returned nil")
}
}
func TestNewAdminHandler(t *testing.T) {
h := NewAdminHandler()
if h == nil {
t.Fatal("NewAdminHandler returned nil")
}
}
func TestIsRegistrationEnabledDefaultsTrue(t *testing.T) {
// Without a database connection, should default to true
enabled := IsRegistrationEnabled()
if !enabled {
t.Error("Expected registration enabled by default when no DB")
}
}

View File

@@ -1,646 +1,243 @@
package handlers
import (
"database/sql"
"encoding/json"
"log"
"math"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── Request / Response Types ────────────────
type createAPIConfigRequest struct {
Name string `json:"name" binding:"required,max=100"`
Provider string `json:"provider" binding:"required"`
Endpoint string `json:"endpoint" binding:"required"`
APIKey string `json:"api_key"`
ModelDefault string `json:"model_default,omitempty"`
Config map[string]interface{} `json:"config,omitempty"`
IsPrivate bool `json:"is_private,omitempty"`
// ProviderConfigHandler handles user-facing provider config endpoints.
type ProviderConfigHandler struct {
stores store.Stores
}
type updateAPIConfigRequest struct {
Name *string `json:"name,omitempty"`
Endpoint *string `json:"endpoint,omitempty"`
APIKey *string `json:"api_key,omitempty"`
ModelDefault *string `json:"model_default,omitempty"`
Config map[string]interface{} `json:"config,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
IsPrivate *bool `json:"is_private,omitempty"`
func NewProviderConfigHandler(s store.Stores) *ProviderConfigHandler {
return &ProviderConfigHandler{stores: s}
}
type apiConfigResponse struct {
ID string `json:"id"`
UserID *string `json:"user_id"`
Name string `json:"name"`
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
HasKey bool `json:"has_key"` // Never expose the actual key
ModelDefault *string `json:"model_default"`
Config map[string]interface{} `json:"config"`
IsActive bool `json:"is_active"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// APIConfigHandler holds dependencies.
type APIConfigHandler struct{}
// NewAPIConfigHandler creates a new handler.
func NewAPIConfigHandler() *APIConfigHandler {
return &APIConfigHandler{}
}
// ── List API Configs ────────────────────────
func (h *APIConfigHandler) ListConfigs(c *gin.Context) {
// ListConfigs returns configs accessible to the user (global + personal + team).
func (h *ProviderConfigHandler) ListConfigs(c *gin.Context) {
userID := getUserID(c)
page, perPage, offset := parsePagination(c)
// Count: user's configs + global configs (exclude team-scoped)
var total int
err := database.DB.QueryRow(
`SELECT COUNT(*) FROM api_configs WHERE (user_id = $1 OR user_id IS NULL) AND team_id IS NULL`,
userID,
).Scan(&total)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to count configs"})
return
}
rows, err := database.DB.Query(`
SELECT id, user_id, name, provider, endpoint, api_key_encrypted,
model_default, config, is_active, created_at, updated_at
FROM api_configs
WHERE (user_id = $1 OR user_id IS NULL) AND team_id IS NULL
ORDER BY user_id NULLS LAST, name ASC
LIMIT $2 OFFSET $3
`, userID, perPage, offset)
// User settings → Providers shows only personal (BYOK) configs.
// Global/team providers are managed by admins and surfaced via the model list.
cfgs, err := h.stores.Providers.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
return
}
defer rows.Close()
configs := make([]apiConfigResponse, 0)
for rows.Next() {
cfg, err := scanAPIConfig(rows)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to scan config"})
return
}
configs = append(configs, cfg)
// Mask API keys
type safeConfig struct {
ID string `json:"id"`
Name string `json:"name"`
Provider string `json:"provider"`
Endpoint string `json:"endpoint"`
HasKey bool `json:"has_key"`
ModelDefault string `json:"model_default,omitempty"`
Scope string `json:"scope"`
IsActive bool `json:"is_active"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
c.JSON(http.StatusOK, paginatedResponse{
Data: configs,
Page: page,
PerPage: perPage,
Total: total,
TotalPages: int(math.Ceil(float64(total) / float64(perPage))),
})
out := make([]safeConfig, len(cfgs))
for i, cfg := range cfgs {
out[i] = safeConfig{
ID: cfg.ID,
Name: cfg.Name,
Provider: cfg.Provider,
Endpoint: cfg.Endpoint,
HasKey: cfg.APIKeyEnc != "",
ModelDefault: cfg.ModelDefault,
Scope: cfg.Scope,
IsActive: cfg.IsActive,
CreatedAt: cfg.CreatedAt.Format("2006-01-02T15:04:05Z"),
UpdatedAt: cfg.UpdatedAt.Format("2006-01-02T15:04:05Z"),
}
}
c.JSON(http.StatusOK, gin.H{"configs": out})
}
// ── Create API Config ───────────────────────
func (h *APIConfigHandler) CreateConfig(c *gin.Context) {
// GetConfig returns a single config by ID (if user has access).
func (h *ProviderConfigHandler) GetConfig(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
var req createAPIConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Validate provider exists
if _, err := providers.Get(req.Provider); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "unsupported provider: " + req.Provider,
"supported_providers": providers.List(),
})
return
}
configJSON := "{}"
if req.Config != nil {
b, _ := json.Marshal(req.Config)
configJSON = string(b)
}
var cfg apiConfigResponse
var apiKeyEnc *string
var configRaw string
err := database.DB.QueryRow(`
INSERT INTO api_configs (user_id, name, provider, endpoint, api_key_encrypted, model_default, config)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
RETURNING id, user_id, name, provider, endpoint, api_key_encrypted,
model_default, config::text, is_active, created_at, updated_at
`, userID, req.Name, req.Provider, req.Endpoint, req.APIKey, req.ModelDefault, configJSON,
).Scan(
&cfg.ID, &cfg.UserID, &cfg.Name, &cfg.Provider, &cfg.Endpoint, &apiKeyEnc,
&cfg.ModelDefault, &configRaw, &cfg.IsActive, &cfg.CreatedAt, &cfg.UpdatedAt,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"})
return
}
cfg.HasKey = apiKeyEnc != nil && *apiKeyEnc != ""
cfg.Config = parseJSONBConfig(configRaw)
c.JSON(http.StatusCreated, cfg)
}
// ── Get API Config ──────────────────────────
func (h *APIConfigHandler) GetConfig(c *gin.Context) {
userID := getUserID(c)
configID := c.Param("id")
row := database.DB.QueryRow(`
SELECT id, user_id, name, provider, endpoint, api_key_encrypted,
model_default, config::text, is_active, created_at, updated_at
FROM api_configs
WHERE id = $1 AND (user_id = $2 OR user_id IS NULL) AND team_id IS NULL
`, configID, userID)
var cfg apiConfigResponse
var apiKeyEnc *string
var configRaw string
err := row.Scan(
&cfg.ID, &cfg.UserID, &cfg.Name, &cfg.Provider, &cfg.Endpoint, &apiKeyEnc,
&cfg.ModelDefault, &configRaw, &cfg.IsActive, &cfg.CreatedAt, &cfg.UpdatedAt,
)
if err == sql.ErrNoRows {
if ok, _ := h.stores.Providers.UserCanAccess(c.Request.Context(), userID, id); !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "config not found"})
return
}
cfg, err := h.stores.Providers.GetByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get config"})
c.JSON(http.StatusNotFound, gin.H{"error": "config not found"})
return
}
cfg.HasKey = apiKeyEnc != nil && *apiKeyEnc != ""
cfg.Config = parseJSONBConfig(configRaw)
c.JSON(http.StatusOK, cfg)
}
// ── Update API Config ───────────────────────
func (h *APIConfigHandler) UpdateConfig(c *gin.Context) {
// CreateConfig creates a personal provider config (BYOK).
// After creation, automatically fetches models from the provider API and enables them.
func (h *ProviderConfigHandler) CreateConfig(c *gin.Context) {
userID := getUserID(c)
configID := c.Param("id")
var req updateAPIConfigRequest
// Check policy
allowed, _ := h.stores.Policies.GetBool(c.Request.Context(), "allow_user_byok")
if !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "personal API keys not allowed"})
return
}
var req struct {
Name string `json:"name" binding:"required"`
Provider string `json:"provider" binding:"required"`
Endpoint string `json:"endpoint" binding:"required"`
APIKey string `json:"api_key"`
ModelDefault string `json:"model_default,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Verify ownership (only owner can update, not global)
var ownerID *string
err := database.DB.QueryRow(`SELECT user_id FROM api_configs WHERE id = $1`, configID).Scan(&ownerID)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "config not found"})
return
cfg := &models.ProviderConfig{
Name: req.Name,
Provider: req.Provider,
Endpoint: req.Endpoint,
APIKeyEnc: req.APIKey, // TODO: encrypt
ModelDefault: req.ModelDefault,
Scope: models.ScopePersonal,
OwnerID: &userID,
IsActive: true,
}
if ownerID == nil || *ownerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify global or other user's config"})
if err := h.stores.Providers.Create(c.Request.Context(), cfg); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create config"})
return
}
// Dynamic update
setClauses := []string{}
args := []interface{}{}
argN := 1
addClause := func(col string, val interface{}) {
setClauses = append(setClauses, col+" = $"+strconv.Itoa(argN))
args = append(args, val)
argN++
// Auto-fetch models from the provider API and enable them.
// The user added this key to USE it — don't make them hunt for a fetch button.
resp := gin.H{
"id": cfg.ID,
"name": cfg.Name,
"provider": cfg.Provider,
"endpoint": cfg.Endpoint,
"scope": cfg.Scope,
}
if req.Name != nil {
addClause("name", *req.Name)
}
if req.Endpoint != nil {
addClause("endpoint", *req.Endpoint)
}
if req.APIKey != nil {
addClause("api_key_encrypted", *req.APIKey)
}
if req.ModelDefault != nil {
addClause("model_default", *req.ModelDefault)
}
if req.Config != nil {
b, _ := json.Marshal(req.Config)
addClause("config", string(b))
}
if req.IsActive != nil {
addClause("is_active", *req.IsActive)
}
if len(setClauses) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
query := "UPDATE api_configs SET updated_at = NOW(), "
for i, clause := range setClauses {
if i > 0 {
query += ", "
}
query += clause
}
query += " WHERE id = $" + strconv.Itoa(argN)
args = append(args, configID)
_, err = database.DB.Exec(query, args...)
result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg)
if err != nil {
// Provider created successfully but model fetch failed.
// Return 201 (provider exists) with a warning so the frontend can show it.
resp["warning"] = "Provider created but model fetch failed: " + err.Error()
resp["models_fetched"] = 0
log.Printf("warn: BYOK auto-fetch for %s (%s) failed: %v", cfg.ID, cfg.Provider, err)
} else {
resp["models_fetched"] = result.Total
}
c.JSON(http.StatusCreated, resp)
}
// UpdateConfig updates a personal provider config.
func (h *ProviderConfigHandler) UpdateConfig(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
existing, err := h.stores.Providers.GetByID(c.Request.Context(), id)
if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "can only update your own configs"})
return
}
// Bind standard fields
var req struct {
models.ProviderConfigPatch
APIKey *string `json:"api_key,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
patch := req.ProviderConfigPatch
// Transfer api_key → APIKeyEnc (json:"-" prevents auto-binding)
if req.APIKey != nil && *req.APIKey != "" {
patch.APIKeyEnc = req.APIKey // TODO: encrypt
}
if err := h.stores.Providers.Update(c.Request.Context(), id, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update config"})
return
}
h.GetConfig(c)
c.JSON(http.StatusOK, gin.H{"message": "config updated"})
}
// ── Delete API Config ───────────────────────
func (h *APIConfigHandler) DeleteConfig(c *gin.Context) {
// DeleteConfig deletes a personal provider config.
func (h *ProviderConfigHandler) DeleteConfig(c *gin.Context) {
userID := getUserID(c)
configID := c.Param("id")
id := c.Param("id")
// Only allow deleting own configs
result, err := database.DB.Exec(
`DELETE FROM api_configs WHERE id = $1 AND user_id = $2`,
configID, userID,
)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"})
existing, err := h.stores.Providers.GetByID(c.Request.Context(), id)
if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "can only delete your own configs"})
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "config not found or cannot delete global config"})
h.stores.Catalog.DeleteForProvider(c.Request.Context(), id)
if err := h.stores.Providers.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete config"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "config deleted"})
}
// ── List Models from a Config ───────────────
// ListModels returns models for a specific provider config.
func (h *ProviderConfigHandler) ListModels(c *gin.Context) {
id := c.Param("id")
func (h *APIConfigHandler) ListModels(c *gin.Context) {
entries, err := h.stores.Catalog.ListForProvider(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list models"})
return
}
c.JSON(http.StatusOK, gin.H{"models": entries})
}
// FetchModels fetches models from the provider API and auto-enables them.
// This is the user-facing equivalent of admin POST /models/fetch.
// Allows refreshing models for existing BYOK providers.
func (h *ProviderConfigHandler) FetchModels(c *gin.Context) {
userID := getUserID(c)
configID := c.Param("id")
id := c.Param("id")
// Load config including API key
var providerID, endpoint string
var apiKey *string
err := database.DB.QueryRow(`
SELECT provider, endpoint, api_key_encrypted
FROM api_configs
WHERE id = $1 AND (user_id = $2 OR user_id IS NULL) AND team_id IS NULL AND is_active = true
`, configID, userID).Scan(&providerID, &endpoint, &apiKey)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "config not found"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load config"})
cfg, err := h.stores.Providers.GetByID(c.Request.Context(), id)
if err != nil || cfg.Scope != models.ScopePersonal || cfg.OwnerID == nil || *cfg.OwnerID != userID {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
return
}
provider, err := providers.Get(providerID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
key := ""
if apiKey != nil {
key = *apiKey
}
models, err := provider.ListModels(c.Request.Context(), providers.ProviderConfig{
Endpoint: endpoint,
APIKey: key,
})
result, err := syncAndEnableProviderModels(c.Request.Context(), h.stores, cfg)
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to fetch models: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"config_id": configID,
"provider": providerID,
"models": models,
"message": "models synced",
"added": result.Added,
"updated": result.Updated,
"total": result.Total,
})
}
// ── List All Available Models (aggregate) ───
func (h *APIConfigHandler) ListAllModels(c *gin.Context) {
userID := getUserID(c)
rows, err := database.DB.Query(`
SELECT id, name, provider, endpoint, api_key_encrypted
FROM api_configs
WHERE (user_id = $1 OR user_id IS NULL) AND is_active = true AND team_id IS NULL
`, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list configs"})
return
}
defer rows.Close()
type modelEntry struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
OwnedBy string `json:"owned_by,omitempty"`
ConfigID string `json:"config_id"`
Provider string `json:"provider"`
Capabilities providers.ModelCapabilities `json:"capabilities"`
}
allModels := make([]modelEntry, 0)
for rows.Next() {
var cfgID, name, providerID, endpoint string
var apiKey *string
if err := rows.Scan(&cfgID, &name, &providerID, &endpoint, &apiKey); err != nil {
continue
}
provider, err := providers.Get(providerID)
if err != nil {
continue
}
key := ""
if apiKey != nil {
key = *apiKey
}
models, err := provider.ListModels(c.Request.Context(), providers.ProviderConfig{
Endpoint: endpoint,
APIKey: key,
})
if err != nil {
continue // Skip configs that fail
}
for _, m := range models {
// Provider caps are authoritative; fill gaps from known table
caps := providers.MergeCapabilities(m.Capabilities, m.ID)
caps.MaxOutputTokens = providers.ResolveMaxOutput(m.ID, caps)
allModels = append(allModels, modelEntry{
ID: m.ID,
Name: m.Name,
OwnedBy: m.OwnedBy,
ConfigID: cfgID,
Provider: providerID,
Capabilities: caps,
})
}
}
c.JSON(http.StatusOK, gin.H{"models": allModels})
}
// ── List Enabled Models (from model_configs) ─
// enabledModel is the unified model entry returned by ListEnabledModels.
// Used across apiconfigs, capabilities, and preset resolution.
type enabledModel struct {
ID string `json:"id"`
ModelID string `json:"model_id"`
DisplayName *string `json:"display_name"`
Provider string `json:"provider"`
ProviderName string `json:"provider_name"`
ConfigID string `json:"config_id"`
Capabilities providers.ModelCapabilities `json:"capabilities"`
Pricing *providers.ModelPricing `json:"pricing,omitempty"`
Source string `json:"source,omitempty"`
TeamName string `json:"team_name,omitempty"`
IsPreset bool `json:"is_preset,omitempty"`
PresetID string `json:"preset_id,omitempty"`
PresetScope string `json:"preset_scope,omitempty"`
PresetAvatar string `json:"preset_avatar,omitempty"`
PresetTeamName string `json:"preset_team_name,omitempty"`
}
func (h *APIConfigHandler) ListEnabledModels(c *gin.Context) {
userID := getUserID(c)
models := make([]enabledModel, 0)
// ── 1. Admin model_configs (pre-synced via FetchModels) ──
rows, err := database.DB.Query(`
SELECT mc.id, mc.model_id, mc.display_name, ac.provider, ac.name, mc.api_config_id, mc.capabilities
FROM model_configs mc
JOIN api_configs ac ON mc.api_config_id = ac.id
WHERE mc.visibility = 'enabled' AND ac.is_active = true AND ac.is_global = true
ORDER BY ac.name, mc.model_id
`)
if err == nil {
defer rows.Close()
for rows.Next() {
var m enabledModel
var capsJSON []byte
if err := rows.Scan(&m.ID, &m.ModelID, &m.DisplayName, &m.Provider, &m.ProviderName, &m.ConfigID, &capsJSON); err != nil {
continue
}
// Parse DB capabilities (from provider at sync time)
var dbCaps providers.ModelCapabilities
_ = json.Unmarshal(capsJSON, &dbCaps)
// Provider-reported caps are authoritative; fill gaps from known table/heuristics
if dbCaps.HasProviderData() {
m.Capabilities = providers.MergeCapabilities(dbCaps, m.ModelID)
} else {
// No provider data — use known table or heuristics as base
knownCaps, found := providers.LookupKnownModel(m.ModelID)
if !found {
knownCaps = providers.InferCapabilities(m.ModelID)
}
m.Capabilities = knownCaps
}
m.Capabilities.MaxOutputTokens = providers.ResolveMaxOutput(m.ModelID, m.Capabilities)
m.Source = "global"
models = append(models, m)
}
}
// ── 2. User provider models (live query) ──
// NOTE: Team provider models are NOT listed here. They are only
// available to team admins for building presets (via ListAvailableModels).
// Team members access team models through curated presets only.
userRows, err := database.DB.Query(`
SELECT id, name, provider, endpoint, api_key_encrypted, custom_headers
FROM api_configs
WHERE user_id = $1 AND is_active = true AND team_id IS NULL
`, userID)
if err == nil {
defer userRows.Close()
for userRows.Next() {
var cfgID, name, providerID, endpoint string
var apiKey *string
var headersJSON []byte
if err := userRows.Scan(&cfgID, &name, &providerID, &endpoint, &apiKey, &headersJSON); err != nil {
continue
}
provider, err := providers.Get(providerID)
if err != nil {
continue
}
key := ""
if apiKey != nil {
key = *apiKey
}
var customHeaders map[string]string
_ = json.Unmarshal(headersJSON, &customHeaders)
provModels, err := provider.ListModels(c.Request.Context(), providers.ProviderConfig{
Endpoint: endpoint,
APIKey: key,
CustomHeaders: customHeaders,
})
if err != nil {
log.Printf("[models] user provider %q (%s) list failed: %v", name, providerID, err)
continue
}
for _, pm := range provModels {
caps := pm.Capabilities
// Provider-reported caps are authoritative; fill gaps
caps = providers.MergeCapabilities(caps, pm.ID)
caps.MaxOutputTokens = providers.ResolveMaxOutput(pm.ID, caps)
models = append(models, enabledModel{
ID: pm.ID,
ModelID: pm.ID,
Provider: providerID,
ProviderName: name,
ConfigID: cfgID,
Capabilities: caps,
Pricing: pm.Pricing,
Source: "personal",
})
}
}
}
// ── 3. Active presets (global + user's team + user's personal + shared) ──
presetRows, err := database.DB.Query(`
SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id,
mp.icon, mp.avatar, mp.scope, mp.temperature, mp.max_tokens,
COALESCE(ac.provider, '') as provider, COALESCE(ac.name, '') as provider_name,
COALESCE(t.name, '') as team_name
FROM model_presets mp
LEFT JOIN api_configs ac ON mp.api_config_id = ac.id
LEFT JOIN teams t ON mp.team_id = t.id
WHERE mp.is_active = true
AND (
mp.scope = 'global'
OR (mp.scope = 'personal' AND mp.created_by = $1)
OR (mp.scope = 'personal' AND mp.is_shared = true)
OR (mp.scope = 'team' AND mp.team_id IN (
SELECT team_id FROM team_members WHERE user_id = $1
))
)
ORDER BY mp.scope ASC, mp.name ASC
`, userID)
if err == nil {
defer presetRows.Close()
for presetRows.Next() {
var presetID, name, description, baseModelID, icon, avatar, scope, provID, provName, teamName string
var apiConfigID *string
var temp *float64
var maxTok *int
if err := presetRows.Scan(&presetID, &name, &description, &baseModelID, &apiConfigID,
&icon, &avatar, &scope, &temp, &maxTok, &provID, &provName, &teamName); err != nil {
continue
}
// Inherit capabilities from base model via shared resolver
cfgID := ""
if apiConfigID != nil {
cfgID = *apiConfigID
}
caps := ResolveModelCapsFromLoaded(c, baseModelID, cfgID, models)
// Build display name: "icon Name (base-model)"
displayName := name
if icon != "" {
displayName = icon + " " + name
}
models = append(models, enabledModel{
ID: presetID,
ModelID: baseModelID,
DisplayName: &displayName,
Provider: provID,
ProviderName: provName,
ConfigID: cfgID,
Capabilities: caps,
IsPreset: true,
PresetID: presetID,
PresetScope: scope,
PresetAvatar: avatar,
PresetTeamName: teamName,
})
}
}
c.JSON(http.StatusOK, gin.H{"models": models})
}
// ── Helpers ─────────────────────────────────
type scannable interface {
Scan(dest ...interface{}) error
}
func scanAPIConfig(row scannable) (apiConfigResponse, error) {
var cfg apiConfigResponse
var apiKeyEnc *string
var configRaw string
err := row.Scan(
&cfg.ID, &cfg.UserID, &cfg.Name, &cfg.Provider, &cfg.Endpoint, &apiKeyEnc,
&cfg.ModelDefault, &configRaw, &cfg.IsActive, &cfg.CreatedAt, &cfg.UpdatedAt,
)
if err != nil {
return cfg, err
}
cfg.HasKey = apiKeyEnc != nil && *apiKeyEnc != ""
cfg.Config = parseJSONBConfig(configRaw)
return cfg, nil
}
func parseJSONBConfig(raw string) map[string]interface{} {
result := make(map[string]interface{})
_ = json.Unmarshal([]byte(raw), &result)
return result
}

View File

@@ -1,126 +0,0 @@
package handlers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/providers"
)
func TestCreateConfigMissingFields(t *testing.T) {
h := NewAPIConfigHandler()
r := gin.New()
r.POST("/api-configs", func(c *gin.Context) {
c.Set("user_id", "test-user")
h.CreateConfig(c)
})
tests := []struct {
name string
body string
}{
{"missing name", `{"provider":"openai","endpoint":"http://x"}`},
{"missing provider", `{"name":"Test","endpoint":"http://x"}`},
{"missing endpoint", `{"name":"Test","provider":"openai"}`},
{"empty body", `{}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api-configs",
bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400, got %d (body: %s)", w.Code, w.Body.String())
}
})
}
}
func TestCreateConfigInvalidProvider(t *testing.T) {
h := NewAPIConfigHandler()
r := gin.New()
r.POST("/api-configs", func(c *gin.Context) {
c.Set("user_id", "test-user")
h.CreateConfig(c)
})
body := `{"name":"Test","provider":"nonexistent","endpoint":"http://x"}`
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api-configs",
bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400 for invalid provider, got %d", w.Code)
}
var resp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &resp)
if _, ok := resp["supported_providers"]; !ok {
t.Error("Expected supported_providers in error response")
}
}
func TestCompletionHandlerMissingFields(t *testing.T) {
h := NewCompletionHandler()
r := gin.New()
r.POST("/chat/completions", func(c *gin.Context) {
c.Set("user_id", "test-user")
h.Complete(c)
})
tests := []struct {
name string
body string
}{
{"missing channel_id", `{"content":"hello"}`},
{"missing content", `{"channel_id":"abc"}`},
{"empty body", `{}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/chat/completions",
bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400, got %d (body: %s)", w.Code, w.Body.String())
}
})
}
}
func TestSupportedProviders(t *testing.T) {
ids := providers.List()
expected := map[string]bool{
"openai": false,
"anthropic": false,
}
for _, id := range ids {
if _, ok := expected[id]; ok {
expected[id] = true
}
}
for name, found := range expected {
if !found {
t.Errorf("Expected provider %s to be registered", name)
}
}
}

View File

@@ -1,32 +1,28 @@
package handlers
import (
"crypto/rand"
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
const (
accessTokenDuration = 15 * time.Minute
refreshTokenDuration = 7 * 24 * time.Hour
bcryptCost = 12
)
// Claims represents the JWT payload.
//
// bcryptCost is shared across auth.go, settings.go, admin.go
const bcryptCost = 12
// Claims is the JWT access token payload.
type Claims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
@@ -34,424 +30,261 @@ type Claims struct {
jwt.RegisteredClaims
}
// ── Request / Response types ────────────────
type registerRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=8,max=128"`
}
type loginRequest struct {
Login string `json:"login" binding:"required"` // email or username
Password string `json:"password" binding:"required"`
}
type refreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
type authResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"` // seconds
User userResponse `json:"user"`
}
type userResponse struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
DisplayName *string `json:"display_name"`
Role string `json:"role"`
Avatar *string `json:"avatar,omitempty"`
}
// AuthHandler holds dependencies for auth endpoints.
type AuthHandler struct {
cfg *config.Config
stores store.Stores
}
// NewAuthHandler creates a new auth handler.
func NewAuthHandler(cfg *config.Config) *AuthHandler {
return &AuthHandler{cfg: cfg}
func NewAuthHandler(cfg *config.Config, s store.Stores) *AuthHandler {
return &AuthHandler{cfg: cfg, stores: s}
}
// ── Register ────────────────────────────────
func (h *AuthHandler) Register(c *gin.Context) {
var req registerRequest
var req struct {
Username string `json:"username" binding:"required"`
Email string `json:"email" binding:"required"`
Password string `json:"password" binding:"required,min=8"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
req.Username = strings.TrimSpace(req.Username)
// Check if this is the first user (will become admin)
var userCount int
_ = database.DB.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&userCount)
isFirstUser := userCount == 0
// First-user-becomes-admin only when no env admin is configured
envAdminSet := os.Getenv("SWITCHBOARD_ADMIN_USERNAME") != ""
promoteFirst := isFirstUser && !envAdminSet
// If not first user (or env admin handles bootstrap), check registration
if !promoteFirst {
if !IsRegistrationEnabled() {
// Check registration policy
allowed, _ := h.stores.Policies.GetBool(c.Request.Context(), "allow_registration")
if !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "registration is disabled"})
return
}
// Check duplicate
if existing, _ := h.stores.Users.GetByUsername(c.Request.Context(), req.Username); existing != nil {
c.JSON(http.StatusConflict, gin.H{"error": "username already taken"})
return
}
if existing, _ := h.stores.Users.GetByEmail(c.Request.Context(), req.Email); existing != nil {
c.JSON(http.StatusConflict, gin.H{"error": "email already registered"})
return
}
// Hash password
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"})
return
}
// Determine role and active state
role := "user"
isActive := true
if promoteFirst {
role = "admin"
} else {
// Apply registration default state
if GetRegistrationDefaultState() == "pending" {
isActive = false
}
// Check if user should be active by default
defaultActive, _ := h.stores.Policies.GetBool(c.Request.Context(), "default_user_active")
user := &models.User{
Username: req.Username,
Email: req.Email,
PasswordHash: string(hash),
Role: models.UserRoleUser,
IsActive: defaultActive,
}
// Insert user
var user userResponse
err = database.DB.QueryRow(`
INSERT INTO users (username, email, password_hash, role, is_active)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, username, email, display_name, role, avatar_url
`, req.Username, req.Email, string(hash), role, isActive).Scan(
&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role, &user.Avatar,
)
if err != nil {
if strings.Contains(err.Error(), "duplicate key") {
field := "email"
if strings.Contains(err.Error(), "username") {
field = "username"
}
c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("%s already taken", field)})
return
}
if err := h.stores.Users.Create(c.Request.Context(), user); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"})
return
}
// If account is pending, don't generate tokens
if !isActive {
if !user.IsActive {
c.JSON(http.StatusCreated, gin.H{
"message": "Account created and pending admin approval",
"pending": true,
})
AuditLogWithActor(user.ID, c, "user.register", "user", user.ID, map[string]interface{}{
"username": req.Username, "pending": true,
"message": "Account created but requires admin approval",
"user_id": user.ID,
})
return
}
// Generate tokens
resp, err := h.generateTokenPair(user)
tokens, err := h.generateTokens(user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
}
c.JSON(http.StatusCreated, resp)
AuditLogWithActor(user.ID, c, "user.register", "user", user.ID, map[string]interface{}{
"username": req.Username, "pending": false,
})
c.JSON(http.StatusCreated, tokens)
}
// IsRegistrationEnabled checks the global_settings table.
// Returns true if the table doesn't exist (pre-migration), DB is nil, or setting is enabled.
func IsRegistrationEnabled() bool {
if database.DB == nil {
return true
func (h *AuthHandler) Login(c *gin.Context) {
var req struct {
Login string `json:"login" binding:"required"` // username or email
Password string `json:"password" binding:"required"`
}
var enabled bool
err := database.DB.QueryRow(`
SELECT COALESCE((value->>'value')::boolean, true)
FROM global_settings WHERE key = 'registration_enabled'
`).Scan(&enabled)
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
user, err := h.stores.Users.GetByLogin(c.Request.Context(), req.Login)
if err != nil {
return true // Default to open if setting missing
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
return enabled
if !user.IsActive {
c.JSON(http.StatusForbidden, gin.H{"error": "account is inactive"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
h.stores.Users.UpdateLastLogin(c.Request.Context(), user.ID)
tokens, err := h.generateTokens(user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
}
c.JSON(http.StatusOK, tokens)
}
// GetRegistrationDefaultState returns "active" or "pending".
func GetRegistrationDefaultState() string {
if database.DB == nil {
return "active"
func (h *AuthHandler) Refresh(c *gin.Context) {
var req struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
var state string
err := database.DB.QueryRow(`
SELECT COALESCE(value->>'value', 'active')
FROM global_settings WHERE key = 'registration_default_state'
`).Scan(&state)
if err != nil || state == "" {
return "active"
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
return state
tokenHash := hashToken(req.RefreshToken)
userID, err := h.stores.Users.GetRefreshToken(c.Request.Context(), tokenHash)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
return
}
// Revoke the used token (rotate)
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
user, err := h.stores.Users.GetByID(c.Request.Context(), userID)
if err != nil || !user.IsActive {
c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found or inactive"})
return
}
tokens, err := h.generateTokens(user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
}
c.JSON(http.StatusOK, tokens)
}
// BootstrapAdmin creates or updates the admin user from environment variables.
// This runs on every startup, so changing the K8s secret + restarting resets the password.
// Handles both username and email conflicts (e.g. admin username changed between deploys).
func BootstrapAdmin(cfg *config.Config) {
func (h *AuthHandler) Logout(c *gin.Context) {
var req struct {
RefreshToken string `json:"refresh_token"`
}
c.ShouldBindJSON(&req)
if req.RefreshToken != "" {
tokenHash := hashToken(req.RefreshToken)
h.stores.Users.RevokeRefreshToken(c.Request.Context(), tokenHash)
}
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
}
func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) {
// Access token (15 min)
accessClaims := Claims{
UserID: user.ID,
Email: user.Email,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
ID: uuid.New().String(),
},
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
accessString, err := accessToken.SignedString([]byte(h.cfg.JWTSecret))
if err != nil {
return nil, err
}
// Refresh token (7 days)
refreshRaw := uuid.New().String()
refreshHash := hashToken(refreshRaw)
expiresAt := time.Now().Add(7 * 24 * time.Hour)
if err := h.stores.Users.CreateRefreshToken(context.Background(), user.ID, refreshHash, expiresAt); err != nil {
log.Printf("warn: failed to store refresh token: %v", err)
}
return gin.H{
"access_token": accessString,
"refresh_token": refreshRaw,
"token_type": "Bearer",
"expires_in": 900,
"user": gin.H{
"id": user.ID,
"username": user.Username,
"email": user.Email,
"display_name": user.DisplayName,
"role": user.Role,
},
}, nil
}
func hashToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}
// BootstrapAdmin creates/updates the admin user from env vars (K8s secret).
func BootstrapAdmin(cfg *config.Config, s store.Stores) {
if cfg.AdminUsername == "" || cfg.AdminPassword == "" {
return
}
if database.DB == nil {
return
}
email := cfg.AdminEmail
if email == "" {
email = cfg.AdminUsername + "@localhost"
}
ctx := context.Background()
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.AdminPassword), bcryptCost)
if err != nil {
log.Printf("⚠ Failed to hash admin password: %v", err)
return
}
// Try upsert by username (common case: same username, new password)
_, err = database.DB.Exec(`
INSERT INTO users (username, email, password_hash, role, is_active)
VALUES ($1, $2, $3, 'admin', true)
ON CONFLICT (username) DO UPDATE SET
password_hash = EXCLUDED.password_hash,
email = EXCLUDED.email,
role = 'admin',
is_active = true
`, cfg.AdminUsername, email, string(hash))
if err != nil && strings.Contains(err.Error(), "duplicate key") {
// Email conflict — admin username was changed in config but email
// already belongs to old admin row. Update that row instead.
_, err = database.DB.Exec(`
UPDATE users SET
username = $1,
password_hash = $3,
role = 'admin',
is_active = true
WHERE email = $2
`, cfg.AdminUsername, email, string(hash))
existing, _ := s.Users.GetByUsername(ctx, cfg.AdminUsername)
if existing != nil {
// Update password and ensure admin role
s.Users.Update(ctx, existing.ID, map[string]interface{}{
"password_hash": string(hash),
"role": models.UserRoleAdmin,
"is_active": true,
})
log.Printf(" ✅ Admin user '%s' updated", cfg.AdminUsername)
return
}
if err != nil {
log.Printf("⚠ Admin bootstrap failed: %v", err)
} else {
log.Printf("✅ Admin user '%s' bootstrapped from environment", cfg.AdminUsername)
email := cfg.AdminEmail
if email == "" {
email = cfg.AdminUsername + "@switchboard.local"
}
user := &models.User{
Username: cfg.AdminUsername,
Email: email,
PasswordHash: string(hash),
Role: models.UserRoleAdmin,
IsActive: true,
}
if err := s.Users.Create(ctx, user); err != nil {
log.Printf("⚠ Failed to create admin user: %v", err)
return
}
log.Printf(" ✅ Admin user '%s' created", cfg.AdminUsername)
}
// ── Login ───────────────────────────────────
func (h *AuthHandler) Login(c *gin.Context) {
var req loginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Login = strings.TrimSpace(req.Login)
// Look up user by email or username
var user userResponse
var passwordHash string
var isActive bool
err := database.DB.QueryRow(`
SELECT id, username, email, display_name, role, avatar_url, password_hash, is_active
FROM users
WHERE email = $1 OR username = $1
`, req.Login).Scan(
&user.ID, &user.Username, &user.Email, &user.DisplayName,
&user.Role, &user.Avatar, &passwordHash, &isActive,
)
if err == sql.ErrNoRows {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "authentication failed"})
return
}
if !isActive {
c.JSON(http.StatusForbidden, gin.H{"error": "account is pending admin approval"})
return
}
// Verify password
if err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(req.Password)); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
// Update last_login_at
_, _ = database.DB.Exec(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, user.ID)
// Generate tokens
resp, err := h.generateTokenPair(user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
}
c.JSON(http.StatusOK, resp)
AuditLogWithActor(user.ID, c, "user.login", "user", user.ID, nil)
}
// ── Refresh ─────────────────────────────────
func (h *AuthHandler) Refresh(c *gin.Context) {
var req refreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tokenHash := hashToken(req.RefreshToken)
// Find and validate the refresh token
var tokenID, userID string
var expiresAt time.Time
err := database.DB.QueryRow(`
SELECT rt.id, rt.user_id, rt.expires_at
FROM refresh_tokens rt
WHERE rt.token_hash = $1 AND rt.revoked_at IS NULL
`, tokenHash).Scan(&tokenID, &userID, &expiresAt)
if err == sql.ErrNoRows {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid refresh token"})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "token validation failed"})
return
}
if time.Now().After(expiresAt) {
// Revoke expired token
_, _ = database.DB.Exec(`UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = $1`, tokenID)
c.JSON(http.StatusUnauthorized, gin.H{"error": "refresh token expired"})
return
}
// Revoke the old token (rotation)
_, _ = database.DB.Exec(`UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = $1`, tokenID)
// Look up user
var user userResponse
var isActive bool
err = database.DB.QueryRow(`
SELECT id, username, email, display_name, role, avatar_url, is_active
FROM users WHERE id = $1
`, userID).Scan(
&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Role, &user.Avatar, &isActive,
)
if err != nil || !isActive {
c.JSON(http.StatusUnauthorized, gin.H{"error": "account unavailable"})
return
}
// Issue new pair
resp, err := h.generateTokenPair(user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"})
return
}
c.JSON(http.StatusOK, resp)
}
// ── Logout ──────────────────────────────────
func (h *AuthHandler) Logout(c *gin.Context) {
var req refreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
// No refresh token provided — just acknowledge
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
return
}
tokenHash := hashToken(req.RefreshToken)
// Revoke the refresh token
_, _ = database.DB.Exec(`
UPDATE refresh_tokens SET revoked_at = NOW()
WHERE token_hash = $1 AND revoked_at IS NULL
`, tokenHash)
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
}
// ── Token Generation ────────────────────────
func (h *AuthHandler) generateTokenPair(user userResponse) (*authResponse, error) {
now := time.Now()
// Access token (JWT)
claims := Claims{
UserID: user.ID,
Email: user.Email,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenDuration)),
Issuer: "chat-switchboard",
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
accessToken, err := token.SignedString([]byte(h.cfg.JWTSecret))
if err != nil {
return nil, fmt.Errorf("sign access token: %w", err)
}
// Refresh token (opaque random string, stored hashed)
refreshBytes := make([]byte, 32)
if _, err := rand.Read(refreshBytes); err != nil {
return nil, fmt.Errorf("generate refresh token: %w", err)
}
refreshToken := hex.EncodeToString(refreshBytes)
refreshHash := hashToken(refreshToken)
// Store refresh token
_, err = database.DB.Exec(`
INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)
`, user.ID, refreshHash, now.Add(refreshTokenDuration))
if err != nil {
return nil, fmt.Errorf("store refresh token: %w", err)
}
return &authResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresIn: int(accessTokenDuration.Seconds()),
User: user,
}, nil
}
// hashToken returns a SHA-256 hex digest of a token string.
// Refresh tokens are stored hashed so a DB leak doesn't
// compromise active sessions.
func hashToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
// IsRegistrationEnabled checks the platform policy.
func IsRegistrationEnabled(s store.Stores) bool {
val, _ := s.Policies.GetBool(context.Background(), "allow_registration")
return val
}

View File

@@ -15,6 +15,7 @@ import (
"golang.org/x/crypto/bcrypt"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
func testConfig() *config.Config {
@@ -23,12 +24,16 @@ func testConfig() *config.Config {
}
}
// testAuthHandler creates an AuthHandler with nil stores (safe for non-DB tests).
func testAuthHandler() *AuthHandler {
return NewAuthHandler(testConfig(), store.Stores{})
}
// ── JWT Token Tests ─────────────────────────
func TestJWTGeneration(t *testing.T) {
cfg := testConfig()
// Create a token the same way the handler does
now := time.Now()
claims := Claims{
UserID: "550e8400-e29b-41d4-a716-446655440000",
@@ -130,13 +135,11 @@ func TestBcryptHash(t *testing.T) {
t.Fatalf("Failed to hash: %v", err)
}
// Correct password should match
err = bcrypt.CompareHashAndPassword(hash, []byte(password))
if err != nil {
t.Error("Correct password should match hash")
}
// Wrong password should not match
err = bcrypt.CompareHashAndPassword(hash, []byte("wrongPassword"))
if err == nil {
t.Error("Wrong password should not match hash")
@@ -153,7 +156,6 @@ func TestBcryptDifferentHashesForSamePassword(t *testing.T) {
t.Error("Same password should produce different hashes (salt)")
}
// Both should still verify
if bcrypt.CompareHashAndPassword(hash1, []byte(password)) != nil {
t.Error("hash1 should verify")
}
@@ -168,22 +170,18 @@ func TestTokenHash(t *testing.T) {
token := "abc123refreshtoken"
hash := hashToken(token)
// Should be deterministic
if hashToken(token) != hash {
t.Error("hashToken should be deterministic")
}
// Different token should produce different hash
if hashToken("different") == hash {
t.Error("Different tokens should produce different hashes")
}
// Should be hex-encoded SHA-256 (64 chars)
if len(hash) != 64 {
t.Errorf("Expected 64 char hex hash, got %d chars", len(hash))
}
// Verify it's actually SHA-256
expected := sha256.Sum256([]byte(token))
expectedHex := hex.EncodeToString(expected[:])
if hash != expectedHex {
@@ -194,7 +192,7 @@ func TestTokenHash(t *testing.T) {
// ── Request Validation Tests ────────────────
func TestRegisterValidation(t *testing.T) {
h := NewAuthHandler(testConfig())
h := testAuthHandler()
tests := []struct {
name string
@@ -211,21 +209,11 @@ func TestRegisterValidation(t *testing.T) {
body: `{"username":"test","email":"test@example.com"}`,
wantCode: http.StatusBadRequest,
},
{
name: "invalid email",
body: `{"username":"test","email":"notanemail","password":"12345678"}`,
wantCode: http.StatusBadRequest,
},
{
name: "password too short",
body: `{"username":"test","email":"test@example.com","password":"short"}`,
wantCode: http.StatusBadRequest,
},
{
name: "username too short",
body: `{"username":"ab","email":"test@example.com","password":"12345678"}`,
wantCode: http.StatusBadRequest,
},
}
for _, tt := range tests {
@@ -247,7 +235,7 @@ func TestRegisterValidation(t *testing.T) {
}
func TestLoginValidation(t *testing.T) {
h := NewAuthHandler(testConfig())
h := testAuthHandler()
tests := []struct {
name string
@@ -289,7 +277,7 @@ func TestLoginValidation(t *testing.T) {
}
func TestRefreshValidation(t *testing.T) {
h := NewAuthHandler(testConfig())
h := testAuthHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
@@ -305,7 +293,7 @@ func TestRefreshValidation(t *testing.T) {
}
func TestLogoutWithoutToken(t *testing.T) {
h := NewAuthHandler(testConfig())
h := testAuthHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)

View File

@@ -207,7 +207,7 @@ func UploadPresetAvatar(c *gin.Context) {
dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
result, err := database.DB.Exec(
`UPDATE model_presets SET avatar = $1, updated_at = NOW() WHERE id = $2`,
`UPDATE personas SET avatar = $1, updated_at = NOW() WHERE id = $2`,
dataURI, presetID,
)
if err != nil {
@@ -228,7 +228,7 @@ func DeletePresetAvatar(c *gin.Context) {
presetID := c.Param("id")
result, err := database.DB.Exec(
`UPDATE model_presets SET avatar = '', updated_at = NOW() WHERE id = $1`,
`UPDATE personas SET avatar = '', updated_at = NOW() WHERE id = $1`,
presetID,
)
if err != nil {

View File

@@ -3,109 +3,130 @@ package handlers
import (
"encoding/json"
"log"
"net/http"
"github.com/gin-gonic/gin"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ModelHandler provides the unified models endpoint.
type ModelHandler struct {
stores store.Stores
}
func NewModelHandler(s store.Stores) *ModelHandler {
return &ModelHandler{stores: s}
}
// ListEnabledModels returns all models the user can access (catalog + personas),
// with user preferences (hidden, sort order) applied.
func (h *ModelHandler) ListEnabledModels(c *gin.Context) {
userID := getUserID(c)
userModels, err := capspkg.ModelsForUser(c.Request.Context(), h.stores, userID)
if err != nil {
log.Printf("error: ModelsForUser(%s): %v", userID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve models"})
return
}
c.JSON(http.StatusOK, gin.H{"models": userModels})
}
// ResolveModelCaps is the canonical capability resolver for any model.
// It walks a priority chain and returns the best capabilities available:
//
// 1. model_configs DB — exact match (model_id + api_config_id)
// 2. model_configs DB — any provider (same model, different config)
// Priority chain:
// 1. model_catalog DB — exact match (model_id + provider_config_id)
// 2. model_catalog DB — any provider (same model, different config)
// 3. Known model table (static, curated)
// 4. Heuristic inference (name-based fallback)
//
// configID is optional — pass "" to skip the exact-match step.
func ResolveModelCaps(c *gin.Context, modelID, configID string) providers.ModelCapabilities {
// ── 1. Exact match: model_id + api_config_id ──
func ResolveModelCaps(c *gin.Context, modelID, configID string) models.ModelCapabilities {
// 1. Exact match: model_id + provider_config_id
if configID != "" {
caps, ok := capsFromModelConfigs(modelID, configID)
caps, ok := capsFromCatalog(modelID, configID)
if ok {
caps.MaxOutputTokens = providers.ResolveMaxOutput(modelID, caps)
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps)
return caps
}
}
// ── 2. Any provider: same model_id, any config ──
caps, ok := capsFromModelConfigs(modelID, "")
// 2. Any provider: same model_id, any config
caps, ok := capsFromCatalog(modelID, "")
if ok {
caps.MaxOutputTokens = providers.ResolveMaxOutput(modelID, caps)
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps)
return caps
}
// ── 3. Known model table (static, curated) ──
caps, found := providers.LookupKnownModel(modelID)
// 3. Known model table (static, curated)
caps, found := capspkg.LookupKnownModel(modelID)
if found {
caps.MaxOutputTokens = providers.ResolveMaxOutput(modelID, caps)
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps)
return caps
}
// ── 4. Heuristic inference ──
caps = providers.InferCapabilities(modelID)
caps.MaxOutputTokens = providers.ResolveMaxOutput(modelID, caps)
// 4. Heuristic inference
caps = capspkg.InferCapabilities(modelID)
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(modelID, caps)
return caps
}
// capsFromModelConfigs looks up capabilities from the model_configs table.
// If configID is non-empty, it matches exactly; otherwise it finds any entry
// for the model_id (capabilities for the same model are provider-agnostic).
func capsFromModelConfigs(modelID, configID string) (providers.ModelCapabilities, bool) {
// capsFromCatalog looks up capabilities from the model_catalog table.
func capsFromCatalog(modelID, configID string) (models.ModelCapabilities, bool) {
if database.DB == nil {
return models.ModelCapabilities{}, false
}
var capsJSON []byte
var err error
if configID != "" {
err = database.DB.QueryRow(`
SELECT capabilities FROM model_configs
WHERE model_id = $1 AND api_config_id = $2
SELECT capabilities FROM model_catalog
WHERE model_id = $1 AND provider_config_id = $2
`, modelID, configID).Scan(&capsJSON)
} else {
err = database.DB.QueryRow(`
SELECT capabilities FROM model_configs
WHERE model_id = $1 ORDER BY updated_at DESC LIMIT 1
SELECT capabilities FROM model_catalog
WHERE model_id = $1 ORDER BY last_synced_at DESC NULLS LAST LIMIT 1
`, modelID).Scan(&capsJSON)
}
if err != nil || len(capsJSON) == 0 {
return providers.ModelCapabilities{}, false
return models.ModelCapabilities{}, false
}
var caps providers.ModelCapabilities
if json.Unmarshal(capsJSON, &caps) != nil || !caps.HasProviderData() {
return providers.ModelCapabilities{}, false
}
return providers.MergeCapabilities(caps, modelID), true
}
// ResolveModelCapsFromLoaded checks an existing slice of models first (avoids
// redundant DB/network calls when we already have models in memory).
func ResolveModelCapsFromLoaded(c *gin.Context, modelID, configID string, loaded []enabledModel) providers.ModelCapabilities {
// Check already-loaded models first
for _, m := range loaded {
if m.ModelID == modelID {
return m.Capabilities
var caps models.ModelCapabilities
if json.Unmarshal(capsJSON, &caps) != nil || !caps.HasProviderData() {
return models.ModelCapabilities{}, false
}
}
// Fall through to canonical resolver
return ResolveModelCaps(c, modelID, configID)
// Merge with known data to fill gaps
resolved := capspkg.ResolveIntrinsic(modelID, &caps)
return resolved, true
}
// liveQueryModelCaps queries a provider API to get capabilities for a specific model.
// Used for team provider presets whose base model isn't in model_configs.
func liveQueryModelCaps(c *gin.Context, configID, modelID string) (providers.ModelCapabilities, bool) {
func liveQueryModelCaps(c *gin.Context, configID, modelID string) (models.ModelCapabilities, bool) {
if database.DB == nil {
return models.ModelCapabilities{}, false
}
var providerID, endpoint string
var apiKey *string
var headersJSON []byte
err := database.DB.QueryRow(`
SELECT provider, endpoint, api_key_encrypted, custom_headers
FROM api_configs WHERE id = $1 AND is_active = true
SELECT provider, endpoint, api_key_enc, headers
FROM provider_configs WHERE id = $1 AND is_active = true
`, configID).Scan(&providerID, &endpoint, &apiKey, &headersJSON)
if err != nil {
return providers.ModelCapabilities{}, false
return models.ModelCapabilities{}, false
}
provider, err := providers.Get(providerID)
if err != nil {
return providers.ModelCapabilities{}, false
return models.ModelCapabilities{}, false
}
key := ""
@@ -123,15 +144,15 @@ func liveQueryModelCaps(c *gin.Context, configID, modelID string) (providers.Mod
})
if err != nil {
log.Printf("[caps] live query for %s via config %s failed: %v", modelID, configID, err)
return providers.ModelCapabilities{}, false
return models.ModelCapabilities{}, false
}
for _, m := range modelList {
if m.ID == modelID {
caps := providers.MergeCapabilities(m.Capabilities, modelID)
return caps, true
resolved := capspkg.ResolveIntrinsic(modelID, &m.Capabilities)
return resolved, true
}
}
return providers.ModelCapabilities{}, false
return models.ModelCapabilities{}, false
}

View File

@@ -21,7 +21,7 @@ type createChannelRequest struct {
Description string `json:"description,omitempty"`
Model string `json:"model,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
APIConfigID *string `json:"api_config_id,omitempty"`
APIConfigID *string `json:"provider_config_id,omitempty"`
Folder string `json:"folder,omitempty"`
Tags []string `json:"tags,omitempty"`
}
@@ -31,7 +31,7 @@ type updateChannelRequest struct {
Description *string `json:"description,omitempty"`
Model *string `json:"model,omitempty"`
SystemPrompt *string `json:"system_prompt,omitempty"`
APIConfigID *string `json:"api_config_id,omitempty"`
APIConfigID *string `json:"provider_config_id,omitempty"`
IsArchived *bool `json:"is_archived,omitempty"`
IsPinned *bool `json:"is_pinned,omitempty"`
Folder *string `json:"folder,omitempty"`
@@ -45,7 +45,7 @@ type channelResponse struct {
Type string `json:"type"`
Description *string `json:"description"`
Model *string `json:"model"`
APIConfigID *string `json:"api_config_id"`
APIConfigID *string `json:"provider_config_id"`
SystemPrompt *string `json:"system_prompt"`
IsArchived bool `json:"is_archived"`
IsPinned bool `json:"is_pinned"`
@@ -140,7 +140,7 @@ func (h *ChannelHandler) ListChannels(c *gin.Context) {
// Fetch channels with message count
query := `
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.api_config_id,
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags,
COALESCE(mc.cnt, 0) AS message_count,
c.created_at, c.updated_at
@@ -233,9 +233,9 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
var ch channelResponse
var tags []string
err := database.DB.QueryRow(`
INSERT INTO channels (user_id, title, type, description, model, system_prompt, api_config_id, folder, tags)
INSERT INTO channels (user_id, title, type, description, model, system_prompt, provider_config_id, folder, tags)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, user_id, title, type, description, model, api_config_id, system_prompt,
RETURNING id, user_id, title, type, description, model, provider_config_id, system_prompt,
is_archived, is_pinned, folder, tags, created_at, updated_at
`, userID, req.Title, channelType, req.Description, req.Model, req.SystemPrompt, req.APIConfigID,
req.Folder, pq.Array(req.Tags),
@@ -265,7 +265,7 @@ func (h *ChannelHandler) CreateChannel(c *gin.Context) {
// Auto-create channel_model if model specified
if req.Model != "" {
_, _ = database.DB.Exec(`
INSERT INTO channel_models (channel_id, model_id, api_config_id, is_default)
INSERT INTO channel_models (channel_id, model_id, provider_config_id, is_default)
VALUES ($1, $2, $3, true)
ON CONFLICT DO NOTHING
`, ch.ID, req.Model, req.APIConfigID)
@@ -283,7 +283,7 @@ func (h *ChannelHandler) GetChannel(c *gin.Context) {
var ch channelResponse
var tags []string
err := database.DB.QueryRow(`
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.api_config_id,
SELECT c.id, c.user_id, c.title, c.type, c.description, c.model, c.provider_config_id,
c.system_prompt, c.is_archived, c.is_pinned, c.folder, c.tags,
COALESCE(mc.cnt, 0) AS message_count,
c.created_at, c.updated_at
@@ -369,7 +369,7 @@ func (h *ChannelHandler) UpdateChannel(c *gin.Context) {
addClause("system_prompt", *req.SystemPrompt)
}
if req.APIConfigID != nil {
addClause("api_config_id", *req.APIConfigID)
addClause("provider_config_id", *req.APIConfigID)
}
if req.IsArchived != nil {
addClause("is_archived", *req.IsArchived)

View File

@@ -1,342 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
// ── Channel Request Validation ─────────────────
func TestCreateChannelMissingTitle(t *testing.T) {
h := NewChannelHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/v1/channels",
strings.NewReader(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
h.CreateChannel(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestCreateChannelTitleTooLong(t *testing.T) {
h := NewChannelHandler()
longTitle := strings.Repeat("x", 501)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/api/v1/channels",
strings.NewReader(`{"title":"`+longTitle+`"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.CreateChannel(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400 for title > 500 chars, got %d", w.Code)
}
}
func TestUpdateChannelEmptyBody(t *testing.T) {
h := NewChannelHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", "test-user-id")
c.Params = gin.Params{{Key: "id", Value: "test-channel-id"}}
c.Request = httptest.NewRequest("PUT", "/api/v1/channels/test-channel-id",
strings.NewReader(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
// Without a DB connection, UpdateChannel will fail at ownership check.
// Integration tests with a real DB validate the "no fields" path.
// Here we just confirm it doesn't return 400 for valid JSON.
h.UpdateChannel(c)
if w.Code == http.StatusBadRequest {
t.Error("Empty JSON body should not be a parse error")
}
}
// ── Message Request Validation ──────────────
func TestCreateMessageMissingRole(t *testing.T) {
h := NewMessageHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "test-channel"}}
c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages",
strings.NewReader(`{"content":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.CreateMessage(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400 for missing role, got %d: %s", w.Code, w.Body.String())
}
}
func TestCreateMessageInvalidRole(t *testing.T) {
h := NewMessageHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "test-channel"}}
c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages",
strings.NewReader(`{"role":"invalid","content":"hello"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.CreateMessage(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400 for invalid role, got %d", w.Code)
}
}
func TestCreateMessageMissingContent(t *testing.T) {
h := NewMessageHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Params = gin.Params{{Key: "id", Value: "test-channel"}}
c.Request = httptest.NewRequest("POST", "/api/v1/channels/test-channel/messages",
strings.NewReader(`{"role":"user"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.CreateMessage(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400 for missing content, got %d", w.Code)
}
}
// ── Integration: Message CRUD with Real DB ──────
func TestCreateMessageValidRoles(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "roletester", "role@test.com")
channelID := database.SeedTestChannel(t, userID, "Role Test")
h := NewMessageHandler()
for _, role := range []string{"user", "assistant", "system"} {
t.Run(role, func(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", userID)
c.Params = gin.Params{{Key: "id", Value: channelID}}
c.Request = httptest.NewRequest("POST", "/api/v1/channels/"+channelID+"/messages",
strings.NewReader(`{"role":"`+role+`","content":"hello from `+role+`"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.CreateMessage(c)
if w.Code != http.StatusCreated {
t.Errorf("role=%s: expected 201, got %d: %s", role, w.Code, w.Body.String())
}
})
}
}
func TestChannelCRUDIntegration(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "cruduser", "crud@test.com")
h := NewChannelHandler()
r := gin.New()
r.Use(func(c *gin.Context) { c.Set("user_id", userID); c.Next() })
r.POST("/channels", h.CreateChannel)
r.GET("/channels", h.ListChannels)
r.GET("/channels/:id", h.GetChannel)
r.PUT("/channels/:id", h.UpdateChannel)
r.DELETE("/channels/:id", h.DeleteChannel)
// Create
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/channels",
strings.NewReader(`{"title":"Integration Test Channel"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("Create: expected 201, got %d: %s", w.Code, w.Body.String())
}
var created map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &created)
channelID := created["id"].(string)
// Get
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/channels/"+channelID, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Get: expected 200, got %d", w.Code)
}
// List
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/channels", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("List: expected 200, got %d", w.Code)
}
var listResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &listResp)
if listResp["total"].(float64) < 1 {
t.Error("List: expected at least 1 channel")
}
// Update
w = httptest.NewRecorder()
req, _ = http.NewRequest("PUT", "/channels/"+channelID,
strings.NewReader(`{"title":"Updated Title"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Update: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Delete
w = httptest.NewRecorder()
req, _ = http.NewRequest("DELETE", "/channels/"+channelID, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Delete: expected 200, got %d", w.Code)
}
// Verify gone
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/channels/"+channelID, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("Get after delete: expected 404, got %d", w.Code)
}
}
func TestRegeneratePassesOwnershipCheck(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "regenuser", "regen@test.com")
channelID := database.SeedTestChannel(t, userID, "Regen Test")
// Seed an assistant message to regenerate
var msgID string
err := database.DB.QueryRow(`
INSERT INTO messages (channel_id, role, content, participant_type, participant_id)
VALUES ($1, 'assistant', 'original response', 'model', 'test-model')
RETURNING id
`, channelID).Scan(&msgID)
if err != nil {
t.Fatalf("seed message: %v", err)
}
h := NewMessageHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", userID)
c.Params = gin.Params{
{Key: "id", Value: channelID},
{Key: "msgId", Value: msgID},
}
c.Request = httptest.NewRequest("POST",
"/api/v1/channels/"+channelID+"/messages/"+msgID+"/regenerate",
strings.NewReader(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
h.Regenerate(c)
// Should NOT be 404 — ownership check passed. Will be 400 or 500
// because no API config is set up, which is expected.
if w.Code == http.StatusNotFound {
t.Errorf("Expected to pass ownership check, got 404: %s", w.Body.String())
}
}
// ── Pagination Helpers ──────────────────────
func TestParsePaginationDefaults(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/channels", nil)
page, perPage, offset := parsePagination(c)
if page != 1 {
t.Errorf("Default page should be 1, got %d", page)
}
if perPage != 50 {
t.Errorf("Default per_page should be 50, got %d", perPage)
}
if offset != 0 {
t.Errorf("Default offset should be 0, got %d", offset)
}
}
func TestParsePaginationCustom(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/channels?page=3&per_page=10", nil)
page, perPage, offset := parsePagination(c)
if page != 3 {
t.Errorf("Page should be 3, got %d", page)
}
if perPage != 10 {
t.Errorf("Per page should be 10, got %d", perPage)
}
if offset != 20 {
t.Errorf("Offset should be 20, got %d", offset)
}
}
func TestParsePaginationClampMax(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/channels?per_page=500", nil)
_, perPage, _ := parsePagination(c)
if perPage != 100 {
t.Errorf("Per page should be clamped to 100, got %d", perPage)
}
}
// ── getUserID ───────────────────────────────
func TestGetUserID(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", "abc-123")
uid := getUserID(c)
if uid != "abc-123" {
t.Errorf("Expected abc-123, got %s", uid)
}
}
func TestGetUserIDMissing(t *testing.T) {
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
uid := getUserID(c)
if uid != "" {
t.Errorf("Expected empty string, got %s", uid)
}
}

View File

@@ -11,7 +11,9 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/tools"
)
@@ -23,7 +25,7 @@ type completionRequest struct {
Content string `json:"content" binding:"required"`
Model string `json:"model,omitempty"`
PresetID string `json:"preset_id,omitempty"` // if set, unwraps preset → base model + config
APIConfigID string `json:"api_config_id,omitempty"`
APIConfigID string `json:"provider_config_id,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
@@ -88,8 +90,8 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
if req.Model == "" {
req.Model = preset.BaseModelID
}
if req.APIConfigID == "" && preset.APIConfigID != nil {
req.APIConfigID = *preset.APIConfigID
if req.APIConfigID == "" && preset.ProviderConfigID != nil {
req.APIConfigID = *preset.ProviderConfigID
}
if req.Temperature == nil && preset.Temperature != nil {
req.Temperature = preset.Temperature
@@ -152,7 +154,7 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
provReq.MaxTokens = req.MaxTokens
} else {
// ResolveMaxOutput checks: caps → known models → context/8 → 4096
provReq.MaxTokens = providers.ResolveMaxOutput(model, caps)
provReq.MaxTokens = capspkg.ResolveMaxOutput(model, caps)
}
if req.Temperature != nil {
@@ -477,14 +479,14 @@ func escapeJSON(s string) string {
return s
}
// getModelCapabilities looks up capabilities from model_configs DB,
// getModelCapabilities looks up capabilities from model_catalog DB,
// then overlays with known model defaults and heuristic detection.
func (h *CompletionHandler) getModelCapabilities(c *gin.Context, model, apiConfigID string) providers.ModelCapabilities {
func (h *CompletionHandler) getModelCapabilities(c *gin.Context, model, apiConfigID string) models.ModelCapabilities {
return ResolveModelCaps(c, model, apiConfigID)
}
// ── Config Resolution ───────────────────────
// Priority: request.api_config_id → chat.api_config_id → user's first active config
// Priority: request.provider_config_id → chat.provider_config_id → user's first active config
func (h *CompletionHandler) resolveConfig(userID string, channelID string, req completionRequest) (providers.ProviderConfig, string, string, string, error) {
var configID string
@@ -498,7 +500,7 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
if configID == "" {
var channelConfigID *string
err := database.DB.QueryRow(
`SELECT api_config_id FROM channels WHERE id = $1`, channelID,
`SELECT provider_config_id FROM channels WHERE id = $1`, channelID,
).Scan(&channelConfigID)
if err == nil && channelConfigID != nil {
configID = *channelConfigID
@@ -508,9 +510,12 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
// 3. User's first active config (personal first, then global — excludes team providers)
if configID == "" {
err := database.DB.QueryRow(`
SELECT id FROM api_configs
WHERE (user_id = $1 OR is_global = true) AND is_active = true AND team_id IS NULL
ORDER BY user_id NULLS LAST, created_at ASC
SELECT id FROM provider_configs
WHERE is_active = true AND (
(scope = 'personal' AND owner_id = $1)
OR scope = 'global'
)
ORDER BY scope ASC, created_at ASC
LIMIT 1
`, userID).Scan(&configID)
if err != nil {
@@ -523,11 +528,12 @@ func (h *CompletionHandler) resolveConfig(userID string, channelID string, req c
var apiKey, modelDefault *string
var customHeadersJSON, providerSettingsJSON []byte
err := database.DB.QueryRow(`
SELECT provider, endpoint, api_key_encrypted, model_default, custom_headers, provider_settings
FROM api_configs
SELECT provider, endpoint, api_key_enc, model_default, headers, settings
FROM provider_configs
WHERE id = $1 AND is_active = true
AND (user_id = $2 OR is_global = true
OR team_id IN (SELECT team_id FROM team_members WHERE user_id = $2))
AND (scope = 'global'
OR (scope = 'personal' AND owner_id = $2)
OR (scope = 'team' AND owner_id IN (SELECT team_id FROM team_members WHERE user_id = $2)))
`, configID, userID).Scan(&providerID, &endpoint, &apiKey, &modelDefault, &customHeadersJSON, &providerSettingsJSON)
if err == sql.ErrNoRows {

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,457 @@
package handlers
import (
"fmt"
"net/http"
"os"
"testing"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
// ═══════════════════════════════════════════
// Live Provider Integration Tests
// ═══════════════════════════════════════════
// These tests require:
// - TEST_DATABASE_URL or PGHOST+PGUSER
// - VENICE_API_KEY secret
//
// They exercise the full flow: create provider →
// fetch models → enable model → resolve → complete.
// ═══════════════════════════════════════════
func requireVeniceKey(t *testing.T) string {
t.Helper()
key := os.Getenv("VENICE_API_KEY")
if key == "" {
t.Skip("VENICE_API_KEY not set — skipping live provider test")
}
return key
}
// TestLive_VeniceProviderFullFlow exercises the complete admin workflow:
// create provider → fetch models → enable a model → user sees it → chat completion
func TestLive_VeniceProviderFullFlow(t *testing.T) {
h := setupHarness(t)
veniceKey := requireVeniceKey(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// ── 1. Create Venice provider config ────
t.Log("Step 1: Creating Venice provider config")
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": "Venice Live Test",
"provider": "venice",
"endpoint": "https://api.venice.ai/api/v1",
"api_key": veniceKey,
})
if w.Code != http.StatusCreated {
t.Fatalf("create venice config: want 201, got %d: %s", w.Code, w.Body.String())
}
var configResp map[string]interface{}
decode(w, &configResp)
configID := configResp["id"].(string)
t.Logf(" Created config: %s", configID)
// ── 2. Fetch models from Venice ─────────
t.Log("Step 2: Fetching models from Venice API")
w = h.request("POST", "/api/v1/admin/models/fetch", adminToken, map[string]interface{}{
"provider_config_id": configID,
})
if w.Code != http.StatusOK {
t.Fatalf("fetch models: want 200, got %d: %s", w.Code, w.Body.String())
}
var fetchResp map[string]interface{}
decode(w, &fetchResp)
totalFetched := fetchResp["total"].(float64)
if totalFetched < 1 {
t.Fatalf("Venice should return at least 1 model, got %.0f", totalFetched)
}
t.Logf(" Fetched %.0f models from Venice", totalFetched)
// ── 3. List catalog models (all disabled by default) ──
t.Log("Step 3: Listing catalog models")
w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("list models: want 200, got %d: %s", w.Code, w.Body.String())
}
var modelsResp map[string]interface{}
decode(w, &modelsResp)
catalogModels := modelsResp["models"].([]interface{})
if len(catalogModels) < 1 {
t.Fatal("catalog should have models after fetch")
}
// Find a text model to enable (prefer a small/fast one)
var enableID string
var enableModelID string
for _, raw := range catalogModels {
m := raw.(map[string]interface{})
modelID := m["model_id"].(string)
vis := m["visibility"].(string)
if vis == "disabled" {
enableID = m["id"].(string)
enableModelID = modelID
break
}
}
if enableID == "" {
t.Fatal("no disabled model found to enable")
}
t.Logf(" Will enable: %s (catalog ID: %s)", enableModelID, enableID)
// ── 4. Enable the model ─────────────────
t.Log("Step 4: Enabling model")
w = h.request("PUT", "/api/v1/admin/models/"+enableID, adminToken,
map[string]interface{}{"visibility": "enabled"})
if w.Code != http.StatusOK {
t.Fatalf("enable model: want 200, got %d: %s", w.Code, w.Body.String())
}
// ── 5. Verify models/enabled returns it (admin) ──
t.Log("Step 5: Verifying models/enabled (admin)")
w = h.request("GET", "/api/v1/models/enabled", adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("models/enabled: want 200, got %d: %s", w.Code, w.Body.String())
}
var enabledResp map[string]interface{}
decode(w, &enabledResp)
enabledModels := enabledResp["models"].([]interface{})
if len(enabledModels) < 1 {
t.Fatal("models/enabled should return at least 1 model after enabling")
}
// Verify our model is in the list
found := false
for _, raw := range enabledModels {
m := raw.(map[string]interface{})
if m["model_id"] == enableModelID {
found = true
t.Logf(" ✓ Found %s in enabled models", enableModelID)
// Verify it has the required fields for the frontend
if m["config_id"] == nil || m["config_id"] == "" {
t.Error("enabled model must have config_id for composite ID")
}
if m["provider_name"] == nil || m["provider_name"] == "" {
t.Error("enabled model must have provider_name for display")
}
break
}
}
if !found {
t.Errorf("enabled model %s not found in models/enabled response", enableModelID)
}
// ── 6. Verify a REGULAR USER also sees the model ──
t.Log("Step 6: Verifying models/enabled (regular user)")
userID := database.SeedTestUser(t, "liveuser", "liveuser@test.com")
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
userToken := makeToken(userID, "liveuser@test.com", "user")
w = h.request("GET", "/api/v1/models/enabled", userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("user models/enabled: want 200, got %d: %s", w.Code, w.Body.String())
}
var userResp map[string]interface{}
decode(w, &userResp)
userModels := userResp["models"].([]interface{})
if len(userModels) < 1 {
t.Fatalf("regular user should see at least 1 enabled model, got %d — "+
"admin can see models but regular user cannot; check ListVisible query",
len(userModels))
}
userFound := false
for _, raw := range userModels {
m := raw.(map[string]interface{})
if m["model_id"] == enableModelID {
userFound = true
t.Logf(" ✓ Regular user can see %s", enableModelID)
// Verify same fields available for regular user
if m["config_id"] == nil || m["config_id"] == "" {
t.Error("user: enabled model must have config_id")
}
if m["provider_name"] == nil || m["provider_name"] == "" {
t.Error("user: enabled model must have provider_name")
}
break
}
}
if !userFound {
t.Errorf("regular user cannot see %s — admin→user visibility broken", enableModelID)
}
}
// TestLive_VeniceFetchModelsCapabilities verifies that Venice model
// capabilities are correctly parsed into the catalog.
func TestLive_VeniceFetchModelsCapabilities(t *testing.T) {
h := setupHarness(t)
veniceKey := requireVeniceKey(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// Create provider + fetch
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": "Venice Caps Test", "provider": "venice",
"endpoint": "https://api.venice.ai/api/v1", "api_key": veniceKey,
})
var cfg map[string]interface{}
decode(w, &cfg)
configID := cfg["id"].(string)
h.request("POST", "/api/v1/admin/models/fetch", adminToken,
map[string]interface{}{"provider_config_id": configID})
// Read catalog and check capabilities
w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
var resp map[string]interface{}
decode(w, &resp)
for _, raw := range resp["models"].([]interface{}) {
m := raw.(map[string]interface{})
caps, ok := m["capabilities"].(map[string]interface{})
if !ok {
t.Errorf("model %s: capabilities must be an object", m["model_id"])
continue
}
// streaming should always be true for Venice
if caps["streaming"] != true {
t.Errorf("model %s: streaming should be true", m["model_id"])
}
// Verify capabilities are actual booleans (not strings)
for _, key := range []string{"streaming", "vision", "tool_calling", "reasoning"} {
if v, exists := caps[key]; exists {
if _, ok := v.(bool); !ok {
t.Errorf("model %s: capability %s should be bool, got %T", m["model_id"], key, v)
}
}
}
}
}
// TestLive_VeniceChatCompletion sends an actual chat completion.
func TestLive_VeniceChatCompletion(t *testing.T) {
h := setupHarness(t)
veniceKey := requireVeniceKey(t)
adminID, adminToken := h.createAdminUser("admin", "admin@test.com")
_ = adminID
// Create provider
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": "Venice Chat Test", "provider": "venice",
"endpoint": "https://api.venice.ai/api/v1", "api_key": veniceKey,
})
var cfg map[string]interface{}
decode(w, &cfg)
configID := cfg["id"].(string)
// Fetch + enable a fast model
h.request("POST", "/api/v1/admin/models/fetch", adminToken,
map[string]interface{}{"provider_config_id": configID})
w = h.request("GET", "/api/v1/admin/models", adminToken, nil)
var modelsResp map[string]interface{}
decode(w, &modelsResp)
// Find and enable a small model (prefer llama or qwen for speed)
var targetModelID, targetCatalogID string
for _, raw := range modelsResp["models"].([]interface{}) {
m := raw.(map[string]interface{})
mid := m["model_id"].(string)
// Pick any available model - first disabled one
if m["visibility"].(string) == "disabled" {
targetModelID = mid
targetCatalogID = m["id"].(string)
break
}
}
if targetModelID == "" {
t.Skip("no model available to test chat completion")
}
h.request("PUT", "/api/v1/admin/models/"+targetCatalogID, adminToken,
map[string]interface{}{"visibility": "enabled"})
// Set as default model and send completion
// First get enabled model's composite ID
w = h.request("GET", "/api/v1/models/enabled", adminToken, nil)
var enabled map[string]interface{}
decode(w, &enabled)
if len(enabled["models"].([]interface{})) == 0 {
t.Fatal("no enabled models for completion test")
}
firstModel := enabled["models"].([]interface{})[0].(map[string]interface{})
modelForChat := firstModel["model_id"].(string)
configForChat := firstModel["config_id"].(string)
t.Logf("Sending completion to %s via config %s", modelForChat, configForChat)
// Create a channel first
w = h.request("POST", "/api/v1/channels", adminToken, map[string]interface{}{
"title": "Test Chat", "type": "direct",
})
if w.Code != http.StatusCreated {
// Some channel handlers may use database.DB directly
t.Skipf("channel creation failed (may need database.DB global): %d %s", w.Code, w.Body.String())
}
var ch map[string]interface{}
decode(w, &ch)
channelID := ch["id"].(string)
// Send completion
w = h.request("POST", "/api/v1/chat/completions", adminToken, map[string]interface{}{
"channel_id": channelID,
"model": modelForChat,
"config_id": configForChat,
"stream": false,
"messages": []map[string]string{
{"role": "user", "content": "Say hello in exactly 3 words."},
},
})
if w.Code != http.StatusOK {
t.Logf("completion response: %s", w.Body.String())
t.Skipf("chat completion failed with %d (may need full router wiring)", w.Code)
}
t.Logf(" ✓ Completion succeeded: %s", w.Body.String()[:min(200, w.Body.Len())])
}
// TestLive_VeniceModelDeletion tests cleanup: delete provider removes catalog entries
func TestLive_VeniceModelDeletion(t *testing.T) {
h := setupHarness(t)
veniceKey := requireVeniceKey(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// Create + fetch
w := h.request("POST", "/api/v1/admin/configs", adminToken, map[string]interface{}{
"name": "Venice Delete Test", "provider": "venice",
"endpoint": "https://api.venice.ai/api/v1", "api_key": veniceKey,
})
var cfg map[string]interface{}
decode(w, &cfg)
configID := cfg["id"].(string)
h.request("POST", "/api/v1/admin/models/fetch", adminToken,
map[string]interface{}{"provider_config_id": configID})
// Verify models exist
var count int
database.TestDB.QueryRow("SELECT COUNT(*) FROM model_catalog WHERE provider_config_id = $1", configID).Scan(&count)
if count == 0 {
t.Fatal("catalog should have models after fetch")
}
t.Logf(" %d models in catalog before delete", count)
// Delete the provider
w = h.request("DELETE", "/api/v1/admin/configs/"+configID, adminToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("delete config: want 200, got %d: %s", w.Code, w.Body.String())
}
// Verify cascade: catalog entries should be gone
database.TestDB.QueryRow("SELECT COUNT(*) FROM model_catalog WHERE provider_config_id = $1", configID).Scan(&count)
if count != 0 {
t.Errorf("catalog should be empty after provider delete, got %d entries", count)
}
t.Log(" ✓ Cascade delete cleaned up catalog entries")
}
// TestLive_VeniceBYOK_AutoFetch exercises the ACTUAL user experience:
// user creates a BYOK provider → auto-fetch triggers → models appear in /models/enabled
//
// This is the definitive test. No simulated data. Real Venice API.
func TestLive_VeniceBYOK_AutoFetch(t *testing.T) {
h := setupHarness(t)
veniceKey := requireVeniceKey(t)
_, adminToken := h.createAdminUser("admin", "admin@test.com")
// Enable BYOK policy
h.request("PUT", "/api/v1/admin/settings/allow_user_byok", adminToken,
map[string]interface{}{"value": "true"})
// Create regular user
userID := database.SeedTestUser(t, "byokuser", "byokuser@test.com")
database.TestDB.Exec("UPDATE users SET is_active = true WHERE id = $1", userID)
userToken := makeToken(userID, "byokuser@test.com", "user")
// ── Step 1: User creates BYOK provider (the ONLY user action) ──
t.Log("Step 1: User creates BYOK Venice provider")
w := h.request("POST", "/api/v1/api-configs", userToken, map[string]interface{}{
"name": "My Venice",
"provider": "venice",
"endpoint": "https://api.venice.ai/api/v1",
"api_key": veniceKey,
})
if w.Code != http.StatusCreated {
t.Fatalf("create BYOK provider: want 201, got %d: %s", w.Code, w.Body.String())
}
var created map[string]interface{}
decode(w, &created)
cfgID := created["id"].(string)
t.Logf(" Created provider: %s", cfgID)
// ── Step 2: Verify auto-fetch happened ──
if created["warning"] != nil {
t.Fatalf("auto-fetch should succeed with real Venice key, got warning: %v", created["warning"])
}
modelsFetched := created["models_fetched"]
if modelsFetched == nil || modelsFetched.(float64) < 1 {
t.Fatalf("auto-fetch should return models_fetched > 0, got: %v", modelsFetched)
}
t.Logf(" Auto-fetched %.0f models", modelsFetched.(float64))
// ── Step 3: User's models/enabled shows personal models ──
t.Log("Step 3: Verify user sees BYOK models in /models/enabled")
w = h.request("GET", "/api/v1/models/enabled", userToken, nil)
if w.Code != http.StatusOK {
t.Fatalf("models/enabled: %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
decode(w, &resp)
userModels := resp["models"].([]interface{})
personalCount := 0
for _, raw := range userModels {
m := raw.(map[string]interface{})
if m["scope"] == "personal" {
personalCount++
}
}
if personalCount < 1 {
t.Fatalf("user should see personal BYOK models, got %d personal out of %d total\n"+
" model IDs: %v",
personalCount, len(userModels), func() []string {
ids := make([]string, 0)
for _, raw := range userModels {
m := raw.(map[string]interface{})
ids = append(ids, fmt.Sprintf("%s(scope=%s)", m["model_id"], m["scope"]))
}
return ids
}())
}
t.Logf(" ✓ User sees %d personal BYOK models (out of %d total)", personalCount, len(userModels))
// ── Step 4: Verify model fields for frontend ──
t.Log("Step 4: Verify frontend-required fields on BYOK models")
for _, raw := range userModels {
m := raw.(map[string]interface{})
if m["scope"] != "personal" {
continue
}
if m["config_id"] == nil || m["config_id"] == "" {
t.Errorf("personal model %s missing config_id", m["model_id"])
}
if m["provider_name"] == nil || m["provider_name"] == "" {
t.Errorf("personal model %s missing provider_name", m["model_id"])
}
if m["model_id"] == nil || m["model_id"] == "" {
t.Errorf("personal model missing model_id")
}
break // check first personal model only
}
// ── Cleanup ──
h.request("DELETE", fmt.Sprintf("/api/v1/api-configs/%s", cfgID), userToken, nil)
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/gin-gonic/gin"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/providers"
)
@@ -43,7 +44,7 @@ type editRequest struct {
type regenerateRequest struct {
Model string `json:"model,omitempty"`
PresetID string `json:"preset_id,omitempty"`
APIConfigID string `json:"api_config_id,omitempty"`
APIConfigID string `json:"provider_config_id,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
}
@@ -369,8 +370,8 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
if model == "" {
model = preset.BaseModelID
}
if apiConfigID == "" && preset.APIConfigID != nil {
apiConfigID = *preset.APIConfigID
if apiConfigID == "" && preset.ProviderConfigID != nil {
apiConfigID = *preset.ProviderConfigID
}
if temperature == nil && preset.Temperature != nil {
temperature = preset.Temperature
@@ -436,7 +437,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
if maxTokens > 0 {
provReq.MaxTokens = maxTokens
} else {
provReq.MaxTokens = providers.ResolveMaxOutput(model, caps)
provReq.MaxTokens = capspkg.ResolveMaxOutput(model, caps)
}
if temperature != nil {
provReq.Temperature = temperature

View File

@@ -1,77 +1,69 @@
package handlers
import (
"log"
"net/http"
"git.gobha.me/xcaliber/chat-switchboard/database"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// GetModelPreferences returns the user's hidden model list.
// GET /api/v1/models/preferences
func GetModelPreferences(c *gin.Context) {
// ModelPrefsHandler handles user model preference endpoints.
type ModelPrefsHandler struct {
stores store.Stores
}
func NewModelPrefsHandler(s store.Stores) *ModelPrefsHandler {
return &ModelPrefsHandler{stores: s}
}
// GetPreferences returns the user's model preferences.
func (h *ModelPrefsHandler) GetPreferences(c *gin.Context) {
userID := getUserID(c)
rows, err := database.DB.Query(`
SELECT model_id, hidden FROM user_model_preferences
WHERE user_id = $1
`, userID)
prefs, err := h.stores.UserSettings.GetForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query preferences"})
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get preferences"})
return
}
defer rows.Close()
type pref struct {
ModelID string `json:"model_id"`
Hidden bool `json:"hidden"`
}
prefs := make([]pref, 0)
for rows.Next() {
var p pref
if err := rows.Scan(&p.ModelID, &p.Hidden); err != nil {
continue
}
prefs = append(prefs, p)
}
c.JSON(http.StatusOK, gin.H{"preferences": prefs})
}
// SetModelPreference sets hidden state for a single model.
// PUT /api/v1/models/preferences
func SetModelPreference(c *gin.Context) {
// SetPreference upserts a single model preference.
func (h *ModelPrefsHandler) SetPreference(c *gin.Context) {
userID := getUserID(c)
var req struct {
ModelID string `json:"model_id" binding:"required"`
Hidden bool `json:"hidden"`
Hidden *bool `json:"hidden,omitempty"`
PreferredTemperature *float64 `json:"preferred_temperature,omitempty"`
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
_, err := database.DB.Exec(`
INSERT INTO user_model_preferences (user_id, model_id, hidden, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (user_id, model_id)
DO UPDATE SET hidden = EXCLUDED.hidden, updated_at = NOW()
`, userID, req.ModelID, req.Hidden)
if err != nil {
log.Printf("[WARN] Failed to save model preference for user %s, model %s: %v", userID, req.ModelID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save preference: " + err.Error()})
patch := models.UserModelSettingPatch{
Hidden: req.Hidden,
PreferredTemperature: req.PreferredTemperature,
PreferredMaxTokens: req.PreferredMaxTokens,
SortOrder: req.SortOrder,
}
if err := h.stores.UserSettings.Set(c.Request.Context(), userID, req.ModelID, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to set preference"})
return
}
c.JSON(http.StatusOK, gin.H{"model_id": req.ModelID, "hidden": req.Hidden})
c.JSON(http.StatusOK, gin.H{"message": "preference updated"})
}
// BulkSetModelPreferences sets hidden state for multiple models at once.
// POST /api/v1/models/preferences/bulk
func BulkSetModelPreferences(c *gin.Context) {
// BulkSetPreferences sets hidden state for multiple models at once.
func (h *ModelPrefsHandler) BulkSetPreferences(c *gin.Context) {
userID := getUserID(c)
var req struct {
@@ -83,43 +75,10 @@ func BulkSetModelPreferences(c *gin.Context) {
return
}
if len(req.ModelIDs) == 0 {
c.JSON(http.StatusOK, gin.H{"updated": 0})
if err := h.stores.UserSettings.BulkSetHidden(c.Request.Context(), userID, req.ModelIDs, req.Hidden); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to bulk update"})
return
}
tx, err := database.DB.Begin()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to begin transaction"})
return
}
defer tx.Rollback()
stmt, err := tx.Prepare(`
INSERT INTO user_model_preferences (user_id, model_id, hidden, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (user_id, model_id)
DO UPDATE SET hidden = EXCLUDED.hidden, updated_at = NOW()
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare statement"})
return
}
defer stmt.Close()
updated := 0
for _, modelID := range req.ModelIDs {
if _, err := stmt.Exec(userID, modelID, req.Hidden); err != nil {
log.Printf("[WARN] Failed to save preference for model %s: %v", modelID, err)
continue
}
updated++
}
if err := tx.Commit(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to commit"})
return
}
c.JSON(http.StatusOK, gin.H{"updated": updated})
c.JSON(http.StatusOK, gin.H{"message": "preferences updated", "count": len(req.ModelIDs)})
}

View File

@@ -0,0 +1,97 @@
package handlers
import (
"context"
"encoding/json"
"fmt"
"log"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// syncResult holds the outcome of a model sync operation.
type syncResult struct {
Added int `json:"added"`
Updated int `json:"updated"`
Total int `json:"total"`
}
// syncProviderModels fetches models from a provider's API and syncs them into the catalog.
// New models default to 'disabled' visibility (admin must explicitly enable for global providers).
func syncProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig) (syncResult, error) {
prov, err := providers.Get(cfg.Provider)
if err != nil {
return syncResult{}, fmt.Errorf("unknown provider type: %s", cfg.Provider)
}
provCfg := providers.ProviderConfig{
Endpoint: cfg.Endpoint,
APIKey: cfg.APIKeyEnc,
}
if cfg.Headers != nil {
customHeaders := make(map[string]string)
for k, v := range cfg.Headers {
if s, ok := v.(string); ok {
customHeaders[k] = s
}
}
provCfg.CustomHeaders = customHeaders
}
// Parse config for any extra settings the provider needs
if cfg.Config != nil {
raw, _ := json.Marshal(cfg.Config)
var extra map[string]string
if json.Unmarshal(raw, &extra) == nil {
if provCfg.CustomHeaders == nil {
provCfg.CustomHeaders = make(map[string]string)
}
for k, v := range extra {
provCfg.CustomHeaders[k] = v
}
}
}
provModels, err := prov.ListModels(ctx, provCfg)
if err != nil {
return syncResult{}, err
}
syncEntries := make([]store.CatalogSyncEntry, len(provModels))
for i, m := range provModels {
syncEntries[i] = store.CatalogSyncEntry{
ModelID: m.ID,
DisplayName: m.Name,
Capabilities: m.Capabilities,
Pricing: m.Pricing,
}
}
added, updated, err := stores.Catalog.UpsertFromSync(ctx, cfg.ID, syncEntries)
if err != nil {
return syncResult{}, fmt.Errorf("failed to sync: %w", err)
}
return syncResult{Added: added, Updated: updated, Total: len(provModels)}, nil
}
// syncAndEnableProviderModels fetches models and auto-enables them all.
// Used for personal (BYOK) providers — the user explicitly added this provider to use it.
func syncAndEnableProviderModels(ctx context.Context, stores store.Stores, cfg *models.ProviderConfig) (syncResult, error) {
result, err := syncProviderModels(ctx, stores, cfg)
if err != nil {
return result, err
}
// Auto-enable: user added this key to USE it, not to stare at disabled models
if result.Total > 0 {
if err := stores.Catalog.BulkSetVisibility(ctx, cfg.ID, "enabled"); err != nil {
log.Printf("warn: auto-enable models for provider %s failed: %v", cfg.ID, err)
}
}
return result, nil
}

View File

@@ -1,329 +0,0 @@
package handlers
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
// ── Notes: Validation (no DB needed) ────────
func TestCreateNoteMissingTitle(t *testing.T) {
h := NewNoteHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", "test-user")
c.Request = httptest.NewRequest("POST", "/api/v1/notes",
strings.NewReader(`{"content":"body only"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400 for missing title, got %d: %s", w.Code, w.Body.String())
}
}
func TestCreateNoteMissingContent(t *testing.T) {
h := NewNoteHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", "test-user")
c.Request = httptest.NewRequest("POST", "/api/v1/notes",
strings.NewReader(`{"title":"title only"}`))
c.Request.Header.Set("Content-Type", "application/json")
h.Create(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400 for missing content, got %d: %s", w.Code, w.Body.String())
}
}
// ── Notes: Full CRUD Integration ────────────
func TestNoteCRUDIntegration(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "noteuser", "note@test.com")
h := NewNoteHandler()
r := gin.New()
r.Use(func(c *gin.Context) { c.Set("user_id", userID); c.Next() })
r.POST("/notes", h.Create)
r.GET("/notes", h.List)
r.GET("/notes/search", h.Search)
r.GET("/notes/folders", h.ListFolders)
r.GET("/notes/:id", h.Get)
r.PUT("/notes/:id", h.Update)
r.DELETE("/notes/:id", h.Delete)
// ── Create ──
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/notes",
strings.NewReader(`{
"title": "Meeting Notes",
"content": "Discussed project timeline and deliverables",
"folder_path": "/work/meetings",
"tags": ["project", "planning"]
}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("Create: expected 201, got %d: %s", w.Code, w.Body.String())
}
var created map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &created)
noteID, ok := created["id"].(string)
if !ok || noteID == "" {
t.Fatal("Create: missing or empty id in response")
}
if created["title"] != "Meeting Notes" {
t.Errorf("Create: title mismatch: %v", created["title"])
}
if created["folder_path"] != "/work/meetings/" {
t.Errorf("Create: folder_path should be normalized, got %v", created["folder_path"])
}
// ── Create second note for search/list tests ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("POST", "/notes",
strings.NewReader(`{
"title": "Recipe Ideas",
"content": "Try making sourdough bread with rosemary",
"folder_path": "/personal",
"tags": ["food", "recipes"]
}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("Create 2nd note: expected 201, got %d", w.Code)
}
// ── Get ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes/"+noteID, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Get: expected 200, got %d", w.Code)
}
var got map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &got)
if got["content"] != "Discussed project timeline and deliverables" {
t.Errorf("Get: wrong content: %v", got["content"])
}
// ── List (all) ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("List: expected 200, got %d", w.Code)
}
var listResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &listResp)
total := listResp["total"].(float64)
if total != 2 {
t.Errorf("List: expected total=2, got %.0f", total)
}
// ── List (filtered by folder) ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes?folder=/work/meetings", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("List by folder: expected 200, got %d", w.Code)
}
json.Unmarshal(w.Body.Bytes(), &listResp)
total = listResp["total"].(float64)
if total != 1 {
t.Errorf("List by folder: expected total=1, got %.0f", total)
}
// ── List (filtered by tag) ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes?tag=food", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("List by tag: expected 200, got %d", w.Code)
}
json.Unmarshal(w.Body.Bytes(), &listResp)
total = listResp["total"].(float64)
if total != 1 {
t.Errorf("List by tag: expected total=1, got %.0f", total)
}
// ── Search ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes/search?q=sourdough", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Search: expected 200, got %d: %s", w.Code, w.Body.String())
}
var searchResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &searchResp)
count := searchResp["count"].(float64)
if count != 1 {
t.Errorf("Search 'sourdough': expected count=1, got %.0f", count)
}
// ── Folders ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes/folders", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Folders: expected 200, got %d", w.Code)
}
var folders []interface{}
json.Unmarshal(w.Body.Bytes(), &folders)
if len(folders) != 2 {
t.Errorf("Folders: expected 2 folders, got %d", len(folders))
}
// ── Update (replace) ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("PUT", "/notes/"+noteID,
strings.NewReader(`{"title":"Updated Meeting Notes","content":"New content","mode":"replace"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Update: expected 200, got %d: %s", w.Code, w.Body.String())
}
// Verify update
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes/"+noteID, nil)
r.ServeHTTP(w, req)
json.Unmarshal(w.Body.Bytes(), &got)
if got["title"] != "Updated Meeting Notes" {
t.Errorf("Update title: got %v", got["title"])
}
if got["content"] != "New content" {
t.Errorf("Update content: got %v", got["content"])
}
// ── Update (append) ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("PUT", "/notes/"+noteID,
strings.NewReader(`{"content":"\nAppended line","mode":"append"}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Append: expected 200, got %d: %s", w.Code, w.Body.String())
}
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes/"+noteID, nil)
r.ServeHTTP(w, req)
json.Unmarshal(w.Body.Bytes(), &got)
content := got["content"].(string)
if !strings.Contains(content, "New content") || !strings.Contains(content, "Appended line") {
t.Errorf("Append: expected both parts in content, got %q", content)
}
// ── Delete ──
w = httptest.NewRecorder()
req, _ = http.NewRequest("DELETE", "/notes/"+noteID, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Delete: expected 200, got %d", w.Code)
}
// Verify gone
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes/"+noteID, nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("Get after delete: expected 404, got %d", w.Code)
}
}
// ── Notes: Search empty query ───────────────
func TestNoteSearchEmptyQuery(t *testing.T) {
database.RequireTestDB(t)
h := NewNoteHandler()
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Set("user_id", "test-user")
c.Request = httptest.NewRequest("GET", "/api/v1/notes/search", nil)
h.Search(c)
if w.Code != http.StatusBadRequest {
t.Errorf("Search with no query: expected 400, got %d", w.Code)
}
}
// ── Notes: Cross-user isolation ─────────────
func TestNoteIsolationBetweenUsers(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userA := database.SeedTestUser(t, "alice", "alice@test.com")
userB := database.SeedTestUser(t, "bob", "bob@test.com")
h := NewNoteHandler()
// Alice creates a note
rA := gin.New()
rA.Use(func(c *gin.Context) { c.Set("user_id", userA); c.Next() })
rA.POST("/notes", h.Create)
rA.GET("/notes", h.List)
w := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/notes",
strings.NewReader(`{"title":"Alice Secret","content":"private stuff"}`))
req.Header.Set("Content-Type", "application/json")
rA.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("Alice create: expected 201, got %d", w.Code)
}
// Bob lists — should see zero
rB := gin.New()
rB.Use(func(c *gin.Context) { c.Set("user_id", userB); c.Next() })
rB.GET("/notes", h.List)
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes", nil)
rB.ServeHTTP(w, req)
var listResp map[string]interface{}
json.Unmarshal(w.Body.Bytes(), &listResp)
total := listResp["total"].(float64)
if total != 0 {
t.Errorf("Bob should see 0 notes, got %.0f", total)
}
// Alice lists — should see one
w = httptest.NewRecorder()
req, _ = http.NewRequest("GET", "/notes", nil)
rA.ServeHTTP(w, req)
json.Unmarshal(w.Body.Bytes(), &listResp)
total = listResp["total"].(float64)
if total != 1 {
t.Errorf("Alice should see 1 note, got %.0f", total)
}
}

View File

@@ -1,646 +1,286 @@
package handlers
import (
"database/sql"
"log"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// PresetHandler handles model preset CRUD operations.
type PresetHandler struct{}
// NewPresetHandler creates a new handler.
func NewPresetHandler() *PresetHandler {
return &PresetHandler{}
// PersonaHandler handles persona (formerly preset) endpoints.
type PersonaHandler struct {
stores store.Stores
}
// ── Request/Response Types ─────────────────
func NewPersonaHandler(s store.Stores) *PersonaHandler {
return &PersonaHandler{stores: s}
}
type createPresetRequest struct {
// ── User Personas (personal scope) ──────────
func (h *PersonaHandler) ListUserPersonas(c *gin.Context) {
userID := getUserID(c)
personas, err := h.stores.Personas.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list personas"})
return
}
c.JSON(http.StatusOK, gin.H{"personas": personas, "presets": personas})
}
func (h *PersonaHandler) CreateUserPersona(c *gin.Context) {
userID := getUserID(c)
// Check policy
allowed, _ := h.stores.Policies.GetBool(c.Request.Context(), "allow_user_personas")
if !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "custom personas not allowed"})
return
}
var req personaRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
persona := req.toPersona()
persona.Scope = models.ScopePersonal
persona.OwnerID = &userID
persona.CreatedBy = userID
if err := h.stores.Personas.Create(c.Request.Context(), persona); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create persona"})
return
}
c.JSON(http.StatusCreated, persona)
}
func (h *PersonaHandler) UpdateUserPersona(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
existing, err := h.stores.Personas.GetByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "persona not found"})
return
}
// Users can only edit their own personal personas
if existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot edit this persona"})
return
}
var patch models.PersonaPatch
if err := c.ShouldBindJSON(&patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Personas.Update(c.Request.Context(), id, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "persona updated"})
}
func (h *PersonaHandler) DeleteUserPersona(c *gin.Context) {
userID := getUserID(c)
id := c.Param("id")
existing, err := h.stores.Personas.GetByID(c.Request.Context(), id)
if err != nil || existing.Scope != models.ScopePersonal || existing.OwnerID == nil || *existing.OwnerID != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "cannot delete this persona"})
return
}
if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete persona"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "persona deleted"})
}
// ── Team Personas ───────────────────────────
func (h *PersonaHandler) ListTeamPersonas(c *gin.Context) {
teamID := c.Param("teamId")
personas, err := h.stores.Personas.ListForTeam(c.Request.Context(), teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team personas"})
return
}
c.JSON(http.StatusOK, gin.H{"personas": personas, "presets": personas})
}
func (h *PersonaHandler) CreateTeamPersona(c *gin.Context) {
userID := getUserID(c)
teamID := c.Param("teamId")
var req personaRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
persona := req.toPersona()
persona.Scope = models.ScopeTeam
persona.OwnerID = &teamID
persona.CreatedBy = userID
if err := h.stores.Personas.Create(c.Request.Context(), persona); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create team persona"})
return
}
c.JSON(http.StatusCreated, persona)
}
func (h *PersonaHandler) DeleteTeamPersona(c *gin.Context) {
id := c.Param("id")
if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete persona"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "persona deleted"})
}
// ── Admin Personas (global scope) ───────────
func (h *PersonaHandler) ListAdminPersonas(c *gin.Context) {
personas, err := h.stores.Personas.ListGlobal(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list personas"})
return
}
c.JSON(http.StatusOK, gin.H{"personas": personas, "presets": personas})
}
func (h *PersonaHandler) CreateAdminPersona(c *gin.Context) {
userID := getUserID(c)
var req personaRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
persona := req.toPersona()
persona.Scope = models.ScopeGlobal
persona.CreatedBy = userID
if err := h.stores.Personas.Create(c.Request.Context(), persona); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create persona"})
return
}
c.JSON(http.StatusCreated, persona)
}
func (h *PersonaHandler) UpdateAdminPersona(c *gin.Context) {
id := c.Param("id")
var patch models.PersonaPatch
if err := c.ShouldBindJSON(&patch); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.stores.Personas.Update(c.Request.Context(), id, patch); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update persona"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "persona updated"})
}
func (h *PersonaHandler) DeleteAdminPersona(c *gin.Context) {
id := c.Param("id")
if err := h.stores.Personas.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete persona"})
return
}
c.JSON(http.StatusOK, gin.H{"message": "persona deleted"})
}
// ── Request Types ───────────────────────────
type personaRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
BaseModelID string `json:"base_model_id" binding:"required"`
APIConfigID *string `json:"api_config_id,omitempty"`
SystemPrompt string `json:"system_prompt"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
ToolsEnabled string `json:"tools_enabled,omitempty"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
IsShared bool `json:"is_shared"`
}
type updatePresetRequest struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
BaseModelID *string `json:"base_model_id,omitempty"`
APIConfigID *string `json:"api_config_id,omitempty"`
SystemPrompt *string `json:"system_prompt,omitempty"`
BaseModelID string `json:"base_model_id,omitempty"`
ProviderConfigID *string `json:"provider_config_id,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
ToolsEnabled *string `json:"tools_enabled,omitempty"`
Icon *string `json:"icon,omitempty"`
IsShared *bool `json:"is_shared,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
ThinkingBudget *int `json:"thinking_budget,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
IsShared bool `json:"is_shared,omitempty"`
}
type presetResponse struct {
models.ModelPreset
ProviderName string `json:"provider_name,omitempty"`
BaseModelName string `json:"base_model_name,omitempty"`
func (r *personaRequest) toPersona() *models.Persona {
p := &models.Persona{
Name: r.Name,
Description: r.Description,
Icon: r.Icon,
BaseModelID: r.BaseModelID,
ProviderConfigID: r.ProviderConfigID,
SystemPrompt: r.SystemPrompt,
Temperature: r.Temperature,
MaxTokens: r.MaxTokens,
ThinkingBudget: r.ThinkingBudget,
TopP: r.TopP,
IsActive: true,
IsShared: r.IsShared,
}
return p
}
// ── User Preset Endpoints ──────────────────
// These require user_providers_enabled for personal presets.
// ListUserPresets returns all presets visible to the user:
// their own personal presets + all global presets + shared presets + team presets.
// GET /api/v1/presets
func (h *PresetHandler) ListUserPresets(c *gin.Context) {
userID := getUserID(c)
rows, err := database.DB.Query(`
SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id,
mp.system_prompt, mp.temperature, mp.max_tokens, mp.tools_enabled,
mp.scope, mp.team_id, mp.created_by, mp.is_shared, mp.is_active,
mp.icon, mp.avatar, mp.created_at, mp.updated_at,
COALESCE(ac.name, '') as provider_name
FROM model_presets mp
LEFT JOIN api_configs ac ON mp.api_config_id = ac.id
WHERE mp.is_active = true
AND (
mp.scope = 'global'
OR (mp.scope = 'personal' AND mp.created_by = $1)
OR (mp.scope = 'personal' AND mp.is_shared = true)
OR (mp.scope = 'team' AND mp.team_id IN (
SELECT team_id FROM team_members WHERE user_id = $1
))
)
ORDER BY mp.scope ASC, mp.name ASC
`, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list presets"})
return
}
defer rows.Close()
presets := make([]presetResponse, 0)
for rows.Next() {
var p presetResponse
if err := rows.Scan(
&p.ID, &p.Name, &p.Description, &p.BaseModelID, &p.APIConfigID,
&p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled,
&p.Scope, &p.TeamID, &p.CreatedBy, &p.IsShared, &p.IsActive,
&p.Icon, &p.Avatar, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName,
); err != nil {
continue
}
presets = append(presets, p)
}
c.JSON(http.StatusOK, gin.H{"presets": presets})
}
// CreateUserPreset creates a personal preset for the current user.
// POST /api/v1/presets
func (h *PresetHandler) CreateUserPreset(c *gin.Context) {
userID := getUserID(c)
var req createPresetRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
// Validate api_config_id belongs to user if provided
if req.APIConfigID != nil && *req.APIConfigID != "" {
var count int
// ResolvePreset loads a persona by ID and returns it if the user has access.
// Returns nil if not found, inactive, or not accessible.
// Used by completion.go and messages.go for preset unwrapping.
func ResolvePreset(presetID, userID string) *models.Persona {
var p models.Persona
var providerConfigID *string
err := database.DB.QueryRow(`
SELECT COUNT(*) FROM api_configs
WHERE id = $1 AND (user_id = $2 OR is_global = true) AND is_active = true AND team_id IS NULL
`, *req.APIConfigID, userID).Scan(&count)
if err != nil || count == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid or inaccessible API config"})
return
}
}
toolsJSON := req.ToolsEnabled
if toolsJSON == "" {
toolsJSON = "[]"
}
var id string
err := database.DB.QueryRow(`
INSERT INTO model_presets (name, description, base_model_id, api_config_id,
system_prompt, temperature, max_tokens, tools_enabled,
scope, created_by, is_shared, icon)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, 'personal', $9, $10, $11)
RETURNING id
`, req.Name, req.Description, req.BaseModelID, req.APIConfigID,
req.SystemPrompt, req.Temperature, req.MaxTokens, toolsJSON,
userID, req.IsShared, req.Icon,
).Scan(&id)
if err != nil {
log.Printf("[WARN] Failed to create user preset: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create preset: " + err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"id": id})
}
// UpdateUserPreset updates a personal preset owned by the current user.
// PUT /api/v1/presets/:id
func (h *PresetHandler) UpdateUserPreset(c *gin.Context) {
userID := getUserID(c)
presetID := c.Param("id")
// Verify ownership
var createdBy, scope string
err := database.DB.QueryRow(
`SELECT created_by, scope FROM model_presets WHERE id = $1`, presetID,
).Scan(&createdBy, &scope)
if err == sql.ErrNoRows {
c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"})
return
}
if scope != models.PresetScopePersonal || createdBy != userID {
c.JSON(http.StatusForbidden, gin.H{"error": "can only edit your own personal presets"})
return
}
var req updatePresetRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Build dynamic SET clause
sets := []string{}
args := []interface{}{}
argN := 1
addField := func(col string, val interface{}) {
sets = append(sets, col+" = $"+itoa(argN))
args = append(args, val)
argN++
}
if req.Name != nil {
addField("name", strings.TrimSpace(*req.Name))
}
if req.Description != nil {
addField("description", *req.Description)
}
if req.BaseModelID != nil {
addField("base_model_id", *req.BaseModelID)
}
if req.APIConfigID != nil {
addField("api_config_id", *req.APIConfigID)
}
if req.SystemPrompt != nil {
addField("system_prompt", *req.SystemPrompt)
}
if req.Temperature != nil {
addField("temperature", *req.Temperature)
}
if req.MaxTokens != nil {
addField("max_tokens", *req.MaxTokens)
}
if req.Icon != nil {
addField("icon", *req.Icon)
}
if req.IsShared != nil {
addField("is_shared", *req.IsShared)
}
if req.IsActive != nil {
addField("is_active", *req.IsActive)
}
if len(sets) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
sets = append(sets, "updated_at = NOW()")
args = append(args, presetID)
query := "UPDATE model_presets SET " + strings.Join(sets, ", ") + " WHERE id = $" + itoa(argN)
_, err = database.DB.Exec(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update preset"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "updated"})
}
// DeleteUserPreset deletes a personal preset owned by the current user.
// DELETE /api/v1/presets/:id
func (h *PresetHandler) DeleteUserPreset(c *gin.Context) {
userID := getUserID(c)
presetID := c.Param("id")
result, err := database.DB.Exec(`
DELETE FROM model_presets WHERE id = $1 AND created_by = $2 AND scope = 'personal'
`, presetID, userID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete preset"})
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "preset not found or not yours"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
}
// ── Admin Preset Endpoints ─────────────────
// ListAdminPresets returns all presets (any scope) for admin management.
// GET /api/v1/admin/presets
func (h *PresetHandler) ListAdminPresets(c *gin.Context) {
rows, err := database.DB.Query(`
SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id,
mp.system_prompt, mp.temperature, mp.max_tokens, mp.tools_enabled,
mp.scope, mp.team_id, mp.created_by, mp.is_shared, mp.is_active,
mp.icon, mp.avatar, mp.created_at, mp.updated_at,
COALESCE(ac.name, '') as provider_name,
COALESCE(u.username, '') as creator_name,
COALESCE(t.name, '') as team_name
FROM model_presets mp
LEFT JOIN api_configs ac ON mp.api_config_id = ac.id
LEFT JOIN users u ON mp.created_by = u.id
LEFT JOIN teams t ON mp.team_id = t.id
ORDER BY mp.scope ASC, mp.name ASC
`)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list presets"})
return
}
defer rows.Close()
type adminPreset struct {
presetResponse
CreatorName string `json:"creator_name"`
TeamName string `json:"team_name"`
}
presets := make([]adminPreset, 0)
for rows.Next() {
var p adminPreset
if err := rows.Scan(
&p.ID, &p.Name, &p.Description, &p.BaseModelID, &p.APIConfigID,
&p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled,
&p.Scope, &p.TeamID, &p.CreatedBy, &p.IsShared, &p.IsActive,
&p.Icon, &p.Avatar, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName,
&p.CreatorName, &p.TeamName,
); err != nil {
continue
}
presets = append(presets, p)
}
c.JSON(http.StatusOK, gin.H{"presets": presets})
}
// CreateAdminPreset creates a global preset (admin-managed, visible to all users).
// POST /api/v1/admin/presets
func (h *PresetHandler) CreateAdminPreset(c *gin.Context) {
userID := getUserID(c)
var req createPresetRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
// Validate api_config_id is a global config
if req.APIConfigID != nil && *req.APIConfigID != "" {
var count int
err := database.DB.QueryRow(`
SELECT COUNT(*) FROM api_configs
WHERE id = $1 AND is_global = true AND is_active = true AND team_id IS NULL
`, *req.APIConfigID).Scan(&count)
if err != nil || count == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "global presets must use a global API config"})
return
}
}
toolsJSON := req.ToolsEnabled
if toolsJSON == "" {
toolsJSON = "[]"
}
var id string
err := database.DB.QueryRow(`
INSERT INTO model_presets (name, description, base_model_id, api_config_id,
system_prompt, temperature, max_tokens, tools_enabled,
scope, created_by, is_shared, icon)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, 'global', $9, true, $10)
RETURNING id
`, req.Name, req.Description, req.BaseModelID, req.APIConfigID,
req.SystemPrompt, req.Temperature, req.MaxTokens, toolsJSON,
userID, req.Icon,
).Scan(&id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create preset: " + err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"id": id})
AuditLog(c, "preset.create", "preset", id, map[string]interface{}{
"name": req.Name, "scope": "global", "base_model": req.BaseModelID,
})
}
// UpdateAdminPreset updates any preset (admin can edit global and personal).
// PUT /api/v1/admin/presets/:id
func (h *PresetHandler) UpdateAdminPreset(c *gin.Context) {
presetID := c.Param("id")
var req updatePresetRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
sets := []string{}
args := []interface{}{}
argN := 1
addField := func(col string, val interface{}) {
sets = append(sets, col+" = $"+itoa(argN))
args = append(args, val)
argN++
}
if req.Name != nil {
addField("name", strings.TrimSpace(*req.Name))
}
if req.Description != nil {
addField("description", *req.Description)
}
if req.BaseModelID != nil {
addField("base_model_id", *req.BaseModelID)
}
if req.APIConfigID != nil {
addField("api_config_id", *req.APIConfigID)
}
if req.SystemPrompt != nil {
addField("system_prompt", *req.SystemPrompt)
}
if req.Temperature != nil {
addField("temperature", *req.Temperature)
}
if req.MaxTokens != nil {
addField("max_tokens", *req.MaxTokens)
}
if req.Icon != nil {
addField("icon", *req.Icon)
}
if req.IsShared != nil {
addField("is_shared", *req.IsShared)
}
if req.IsActive != nil {
addField("is_active", *req.IsActive)
}
if len(sets) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
sets = append(sets, "updated_at = NOW()")
args = append(args, presetID)
query := "UPDATE model_presets SET " + strings.Join(sets, ", ") + " WHERE id = $" + itoa(argN)
result, err := database.DB.Exec(query, args...)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update preset"})
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "updated"})
}
// DeleteAdminPreset deletes any preset.
// DELETE /api/v1/admin/presets/:id
func (h *PresetHandler) DeleteAdminPreset(c *gin.Context) {
presetID := c.Param("id")
result, err := database.DB.Exec(`DELETE FROM model_presets WHERE id = $1`, presetID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete preset"})
return
}
rows, _ := result.RowsAffected()
if rows == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "deleted"})
}
// ── Helpers ────────────────────────────────
// isUserProvidersEnabled checks the global setting.
func isUserProvidersEnabled() bool {
var val string
err := database.DB.QueryRow(
`SELECT value FROM global_settings WHERE key = 'user_providers_enabled'`,
).Scan(&val)
if err != nil {
return true // default: enabled
}
return val == "true"
}
// itoa is a minimal int-to-string for building SQL arg placeholders.
func itoa(n int) string {
if n < 10 {
return string(rune('0' + n))
}
return itoa(n/10) + string(rune('0'+n%10))
}
// ── Preset Resolution for Completion ───────
// ResolvePreset loads a preset by ID and returns its config overrides.
// Returns nil if preset not found or inactive.
func ResolvePreset(presetID, userID string) *models.ModelPreset {
var p models.ModelPreset
err := database.DB.QueryRow(`
SELECT id, name, base_model_id, api_config_id, system_prompt,
temperature, max_tokens, scope, created_by, is_active
FROM model_presets
SELECT id, name, base_model_id, provider_config_id, system_prompt,
temperature, max_tokens, thinking_budget, top_p,
scope, owner_id, created_by, is_active
FROM personas
WHERE id = $1 AND is_active = true
AND (
scope = 'global'
OR (scope = 'personal' AND created_by = $2)
OR (scope = 'personal' AND is_shared = true)
OR (scope = 'team' AND team_id IN (
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = $2
))
)
`, presetID, userID).Scan(
&p.ID, &p.Name, &p.BaseModelID, &p.APIConfigID, &p.SystemPrompt,
&p.Temperature, &p.MaxTokens, &p.Scope, &p.CreatedBy, &p.IsActive,
&p.ID, &p.Name, &p.BaseModelID, &providerConfigID, &p.SystemPrompt,
&p.Temperature, &p.MaxTokens, &p.ThinkingBudget, &p.TopP,
&p.Scope, &p.OwnerID, &p.CreatedBy, &p.IsActive,
)
if err != nil {
return nil
}
p.ProviderConfigID = providerConfigID
return &p
}
// ── Team Preset Endpoints ─────────────────
// Team admins can create/manage presets scoped to their team.
type createTeamPresetRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
BaseModelID string `json:"base_model_id" binding:"required"`
APIConfigID *string `json:"api_config_id,omitempty"`
SystemPrompt string `json:"system_prompt"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
Icon string `json:"icon,omitempty"`
}
// ListTeamPresets returns presets scoped to a team.
// GET /api/v1/teams/:teamId/presets
func (h *PresetHandler) ListTeamPresets(c *gin.Context) {
teamID := getTeamID(c)
rows, err := database.DB.Query(`
SELECT mp.id, mp.name, mp.description, mp.base_model_id, mp.api_config_id,
mp.system_prompt, mp.temperature, mp.max_tokens, mp.tools_enabled,
mp.scope, mp.team_id, mp.created_by, mp.is_shared, mp.is_active,
mp.icon, mp.avatar, mp.created_at, mp.updated_at,
COALESCE(ac.name, '') as provider_name
FROM model_presets mp
LEFT JOIN api_configs ac ON mp.api_config_id = ac.id
WHERE mp.team_id = $1 AND mp.scope = 'team'
ORDER BY mp.name ASC
`, teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list team presets"})
return
}
defer rows.Close()
presets := make([]presetResponse, 0)
for rows.Next() {
var p presetResponse
if err := rows.Scan(
&p.ID, &p.Name, &p.Description, &p.BaseModelID, &p.APIConfigID,
&p.SystemPrompt, &p.Temperature, &p.MaxTokens, &p.ToolsEnabled,
&p.Scope, &p.TeamID, &p.CreatedBy, &p.IsShared, &p.IsActive,
&p.Icon, &p.Avatar, &p.CreatedAt, &p.UpdatedAt, &p.ProviderName,
); err != nil {
continue
}
presets = append(presets, p)
}
c.JSON(http.StatusOK, gin.H{"presets": presets})
}
// CreateTeamPreset creates a preset scoped to a team.
// POST /api/v1/teams/:teamId/presets
func (h *PresetHandler) CreateTeamPreset(c *gin.Context) {
teamID := getTeamID(c)
userID := getUserID(c)
var req createTeamPresetRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
toolsJSON := "[]"
var id string
err := database.DB.QueryRow(`
INSERT INTO model_presets (name, description, base_model_id, api_config_id,
system_prompt, temperature, max_tokens, tools_enabled,
scope, team_id, created_by, is_shared, icon)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, 'team', $9, $10, true, $11)
RETURNING id
`, req.Name, req.Description, req.BaseModelID, req.APIConfigID,
req.SystemPrompt, req.Temperature, req.MaxTokens, toolsJSON,
teamID, userID, req.Icon,
).Scan(&id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create team preset: " + err.Error()})
return
}
c.JSON(http.StatusCreated, gin.H{"id": id})
AuditLog(c, "preset.create", "preset", id, map[string]interface{}{
"name": req.Name, "scope": "team", "team_id": teamID, "base_model": req.BaseModelID,
})
}
// DeleteTeamPreset deletes a team-scoped preset.
// DELETE /api/v1/teams/:teamId/presets/:id
func (h *PresetHandler) DeleteTeamPreset(c *gin.Context) {
teamID := getTeamID(c)
presetID := c.Param("id")
res, err := database.DB.Exec(`
DELETE FROM model_presets WHERE id = $1 AND team_id = $2 AND scope = 'team'
`, presetID, teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "delete failed"})
return
}
if n, _ := res.RowsAffected(); n == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "preset not found"})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
AuditLog(c, "preset.delete", "preset", presetID, map[string]interface{}{
"scope": "team", "team_id": teamID,
})
}

View File

@@ -8,22 +8,23 @@ import (
"github.com/gin-gonic/gin"
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
)
// ── Team Provider Handlers ──────────────────
// ListTeamProviders returns API configs scoped to a team.
// GET /api/v1/teams/:teamId/providers
func (h *TeamHandler) ListTeamProviders(c *gin.Context) {
teamID := getTeamID(c)
rows, err := database.DB.Query(`
SELECT id, name, provider, endpoint, api_key_encrypted,
SELECT id, name, provider, endpoint, api_key_enc,
model_default, config::text, is_active, is_private, created_at, updated_at
FROM api_configs
WHERE team_id = $1
FROM provider_configs
WHERE scope = 'team' AND owner_id = $1
ORDER BY name ASC
`, teamID)
if err != nil {
@@ -67,17 +68,23 @@ func (h *TeamHandler) ListTeamProviders(c *gin.Context) {
}
// CreateTeamProvider creates an API config scoped to a team.
// POST /api/v1/teams/:teamId/providers
func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
teamID := getTeamID(c)
// Check allow_team_providers setting
if !isTeamProvidersAllowed(teamID) {
c.JSON(http.StatusForbidden, gin.H{"error": "team providers are not enabled for this team"})
return
}
var req createAPIConfigRequest
var req struct {
Name string `json:"name" binding:"required,max=100"`
Provider string `json:"provider" binding:"required"`
Endpoint string `json:"endpoint" binding:"required"`
APIKey string `json:"api_key"`
ModelDefault string `json:"model_default,omitempty"`
Config map[string]interface{} `json:"config,omitempty"`
IsPrivate bool `json:"is_private,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -99,8 +106,8 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
var id string
err := database.DB.QueryRow(`
INSERT INTO api_configs (team_id, name, provider, endpoint, api_key_encrypted, model_default, config, is_private)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8)
INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint, api_key_enc, model_default, config, is_private)
VALUES ('team', $1, $2, $3, $4, $5, $6, $7::jsonb, $8)
RETURNING id
`, teamID, req.Name, req.Provider, req.Endpoint, req.APIKey, req.ModelDefault, configJSON, req.IsPrivate,
).Scan(&id)
@@ -114,12 +121,19 @@ func (h *TeamHandler) CreateTeamProvider(c *gin.Context) {
}
// UpdateTeamProvider updates a team-scoped API config.
// PUT /api/v1/teams/:teamId/providers/:id
func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
teamID := getTeamID(c)
providerID := c.Param("id")
var req updateAPIConfigRequest
var req struct {
Name *string `json:"name,omitempty"`
Endpoint *string `json:"endpoint,omitempty"`
APIKey *string `json:"api_key,omitempty"`
ModelDefault *string `json:"model_default,omitempty"`
Config map[string]interface{} `json:"config,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
IsPrivate *bool `json:"is_private,omitempty"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
@@ -127,14 +141,14 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
// Verify provider belongs to this team
var count int
database.DB.QueryRow(`SELECT COUNT(*) FROM api_configs WHERE id = $1 AND team_id = $2`, providerID, teamID).Scan(&count)
database.DB.QueryRow(`SELECT COUNT(*) FROM provider_configs WHERE id = $1 AND scope = 'team' AND owner_id = $2`, providerID, teamID).Scan(&count)
if count == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found in this team"})
return
}
// Build dynamic update
query := "UPDATE api_configs SET updated_at = NOW()"
query := "UPDATE provider_configs SET updated_at = NOW()"
args := []interface{}{}
argN := 1
@@ -149,7 +163,7 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
argN++
}
if req.APIKey != nil {
query += ", api_key_encrypted = $" + strconv.Itoa(argN)
query += ", api_key_enc = $" + strconv.Itoa(argN)
args = append(args, *req.APIKey)
argN++
}
@@ -175,7 +189,7 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
argN++
}
query += " WHERE id = $" + strconv.Itoa(argN) + " AND team_id = $" + strconv.Itoa(argN+1)
query += " WHERE id = $" + strconv.Itoa(argN) + " AND scope = 'team' AND owner_id = $" + strconv.Itoa(argN+1)
args = append(args, providerID, teamID)
_, err := database.DB.Exec(query, args...)
@@ -188,13 +202,12 @@ func (h *TeamHandler) UpdateTeamProvider(c *gin.Context) {
}
// DeleteTeamProvider removes a team-scoped API config.
// DELETE /api/v1/teams/:teamId/providers/:id
func (h *TeamHandler) DeleteTeamProvider(c *gin.Context) {
teamID := getTeamID(c)
providerID := c.Param("id")
result, err := database.DB.Exec(`
DELETE FROM api_configs WHERE id = $1 AND team_id = $2
DELETE FROM provider_configs WHERE id = $1 AND scope = 'team' AND owner_id = $2
`, providerID, teamID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete provider"})
@@ -211,7 +224,6 @@ func (h *TeamHandler) DeleteTeamProvider(c *gin.Context) {
}
// ListTeamProviderModels lists models available from a team provider (live query).
// GET /api/v1/teams/:teamId/providers/:id/models
func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
teamID := getTeamID(c)
providerID := c.Param("id")
@@ -220,9 +232,9 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
var apiKey *string
var headersJSON []byte
err := database.DB.QueryRow(`
SELECT name, provider, endpoint, api_key_encrypted, custom_headers
FROM api_configs
WHERE id = $1 AND team_id = $2 AND is_active = true
SELECT name, provider, endpoint, api_key_enc, headers
FROM provider_configs
WHERE id = $1 AND scope = 'team' AND owner_id = $2 AND is_active = true
`, providerID, teamID).Scan(&name, &providerType, &endpoint, &apiKey, &headersJSON)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "provider not found"})
@@ -255,23 +267,37 @@ func (h *TeamHandler) ListTeamProviderModels(c *gin.Context) {
type modelInfo struct {
ID string `json:"id"`
Capabilities providers.ModelCapabilities `json:"capabilities"`
Capabilities models.ModelCapabilities `json:"capabilities"`
}
models := make([]modelInfo, 0, len(modelList))
out := make([]modelInfo, 0, len(modelList))
for _, m := range modelList {
caps := providers.MergeCapabilities(m.Capabilities, m.ID)
caps.MaxOutputTokens = providers.ResolveMaxOutput(m.ID, caps)
models = append(models, modelInfo{ID: m.ID, Capabilities: caps})
caps := capspkg.ResolveIntrinsic(m.ID, &m.Capabilities)
caps.MaxOutputTokens = capspkg.ResolveMaxOutput(m.ID, caps)
out = append(out, modelInfo{ID: m.ID, Capabilities: caps})
}
c.JSON(http.StatusOK, gin.H{"models": models, "provider": name})
c.JSON(http.StatusOK, gin.H{"models": out, "provider": name})
}
// isTeamProvidersAllowed checks if team providers are enabled for a team.
// First checks the global allow_team_providers setting, then team.settings JSONB.
// parseJSONBConfig parses a JSONB text string into a map.
func parseJSONBConfig(raw string) map[string]interface{} {
if raw == "" || raw == "{}" || raw == "null" {
return map[string]interface{}{}
}
var m map[string]interface{}
if err := json.Unmarshal([]byte(raw), &m); err != nil {
return map[string]interface{}{}
}
return m
}
// isTeamProvidersAllowed checks if team providers are enabled.
func isTeamProvidersAllowed(teamID string) bool {
// Check global setting
if database.DB == nil {
return false
}
var globalVal string
err := database.DB.QueryRow(`
SELECT value FROM global_settings WHERE key = 'allow_team_providers'
@@ -279,15 +305,11 @@ func isTeamProvidersAllowed(teamID string) bool {
if err == nil && globalVal == "false" {
return false
}
// Default to true if not set
// Check team-level override
var settingsJSON []byte
err = database.DB.QueryRow(`
SELECT settings FROM teams WHERE id = $1
`, teamID).Scan(&settingsJSON)
err = database.DB.QueryRow(`SELECT settings FROM teams WHERE id = $1`, teamID).Scan(&settingsJSON)
if err != nil {
return true // default allow
return true
}
var settings map[string]interface{}

View File

@@ -478,14 +478,14 @@ func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
models := make([]availableModel, 0)
// ── 1. Global admin models (synced in model_configs) ──
// ── 1. Global admin models (synced in model_catalog) ──
rows, err := database.DB.Query(`
SELECT mc.id, mc.model_id, mc.display_name, mc.visibility,
ac.provider, ac.name as provider_name
FROM model_configs mc
JOIN api_configs ac ON mc.api_config_id = ac.id
FROM model_catalog mc
JOIN provider_configs ac ON mc.provider_config_id = ac.id
WHERE mc.visibility IN ('enabled', 'team')
AND ac.is_active = true AND ac.is_global = true
AND ac.is_active = true AND ac.scope = 'global'
ORDER BY ac.name, mc.model_id
`)
if err != nil {
@@ -506,9 +506,9 @@ func (h *TeamHandler) ListAvailableModels(c *gin.Context) {
// ── 2. Team provider models (live query) ──
teamRows, err := database.DB.Query(`
SELECT id, name, provider, endpoint, api_key_encrypted, custom_headers
FROM api_configs
WHERE team_id = $1 AND is_active = true
SELECT id, name, provider, endpoint, api_key_enc, headers
FROM provider_configs
WHERE scope = 'team' AND owner_id = $1 AND is_active = true
`, teamID)
if err == nil {
defer teamRows.Close()
@@ -621,7 +621,7 @@ func enforcePrivateProviderPolicy(userID, configID string) error {
// User is in a restricted team — verify the config is private
var isPrivate bool
err = database.DB.QueryRow(`
SELECT COALESCE(is_private, false) FROM api_configs WHERE id = $1
SELECT COALESCE(is_private, false) FROM provider_configs WHERE id = $1
`, configID).Scan(&isPrivate)
if err != nil {
return nil // config lookup failed, allow (fail open)

View File

@@ -11,6 +11,8 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/handlers"
"git.gobha.me/xcaliber/chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store"
postgres "git.gobha.me/xcaliber/chat-switchboard/store/postgres"
_ "git.gobha.me/xcaliber/chat-switchboard/tools" // registers built-in tools via init()
)
@@ -20,6 +22,8 @@ func main() {
// Register LLM providers
providers.Init()
var stores store.Stores
if err := database.Connect(cfg); err != nil {
log.Printf("⚠ Database unavailable: %v", err)
log.Println(" Running in unmanaged mode (no persistence)")
@@ -28,8 +32,12 @@ func main() {
if err := database.Migrate(); err != nil {
log.Fatalf("❌ Schema migration failed: %v", err)
}
// Initialize store layer
stores = postgres.NewStores(database.DB)
// Bootstrap admin from env (K8s secret) — upserts on every restart
handlers.BootstrapAdmin(cfg)
handlers.BootstrapAdmin(cfg, stores)
}
defer database.Close()
@@ -37,7 +45,6 @@ func main() {
r.Use(middleware.CORS())
// ── Base path group ──────────────────────
// All routes live under cfg.BasePath (e.g. "/dev", "/test", or "")
base := r.Group(cfg.BasePath)
// ── EventBus + WebSocket Hub ─────────────
@@ -54,11 +61,11 @@ func main() {
})
})
// WebSocket endpoint — auth via ?token= query param
// WebSocket endpoint
base.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket)
// ── Auth routes (rate limited) ──────────────
auth := handlers.NewAuthHandler(cfg)
auth := handlers.NewAuthHandler(cfg, stores)
authLimiter := middleware.NewRateLimiter(1, 5)
api := base.Group("/api/v1")
@@ -72,9 +79,8 @@ func main() {
"database": database.IsConnected(),
"providers": providers.List(),
}
// Include registration status for frontend
if database.IsConnected() {
info["registration_enabled"] = isRegistrationOpen()
info["registration_enabled"] = handlers.IsRegistrationEnabled(stores)
}
c.JSON(200, info)
})
@@ -92,7 +98,7 @@ func main() {
protected := api.Group("")
protected.Use(middleware.Auth(cfg))
{
// Channels (unified: replaces /chats)
// Channels
channels := handlers.NewChannelHandler()
protected.GET("/channels", channels.ListChannels)
protected.POST("/channels", channels.CreateChannel)
@@ -116,21 +122,26 @@ func main() {
comp := handlers.NewCompletionHandler()
protected.POST("/chat/completions", comp.Complete)
// API Configs
apiCfg := handlers.NewAPIConfigHandler()
protected.GET("/api-configs", apiCfg.ListConfigs)
protected.POST("/api-configs", apiCfg.CreateConfig)
protected.GET("/api-configs/:id", apiCfg.GetConfig)
protected.PUT("/api-configs/:id", apiCfg.UpdateConfig)
protected.DELETE("/api-configs/:id", apiCfg.DeleteConfig)
// Provider Configs (user-facing — replaces /api-configs)
provCfg := handlers.NewProviderConfigHandler(stores)
protected.GET("/api-configs", provCfg.ListConfigs) // backward compat
protected.POST("/api-configs", provCfg.CreateConfig)
protected.GET("/api-configs/:id", provCfg.GetConfig)
protected.PUT("/api-configs/:id", provCfg.UpdateConfig)
protected.DELETE("/api-configs/:id", provCfg.DeleteConfig)
protected.GET("/api-configs/:id/models", provCfg.ListModels)
protected.POST("/api-configs/:id/models/fetch", provCfg.FetchModels)
// Models (per-config and aggregate)
protected.GET("/api-configs/:id/models", apiCfg.ListModels)
protected.GET("/models", apiCfg.ListAllModels)
protected.GET("/models/enabled", apiCfg.ListEnabledModels)
protected.GET("/models/preferences", handlers.GetModelPreferences)
protected.PUT("/models/preferences", handlers.SetModelPreference)
protected.POST("/models/preferences/bulk", handlers.BulkSetModelPreferences)
// Models (unified resolver — replaces scattered endpoints)
modelH := handlers.NewModelHandler(stores)
protected.GET("/models/enabled", modelH.ListEnabledModels)
protected.GET("/models", modelH.ListEnabledModels) // alias
// Model Preferences
modelPrefs := handlers.NewModelPrefsHandler(stores)
protected.GET("/models/preferences", modelPrefs.GetPreferences)
protected.PUT("/models/preferences", modelPrefs.SetPreference)
protected.POST("/models/preferences/bulk", modelPrefs.BulkSetPreferences)
// User Settings & Profile
settings := handlers.NewSettingsHandler()
@@ -142,24 +153,31 @@ func main() {
protected.GET("/settings", settings.GetSettings)
protected.PUT("/settings", settings.UpdateSettings)
// Model Presets (user)
presets := handlers.NewPresetHandler()
protected.GET("/presets", presets.ListUserPresets)
protected.POST("/presets", presets.CreateUserPreset)
protected.PUT("/presets/:id", presets.UpdateUserPreset)
protected.DELETE("/presets/:id", presets.DeleteUserPreset)
// Personas (replaces /presets)
personas := handlers.NewPersonaHandler(stores)
protected.GET("/presets", personas.ListUserPersonas) // backward compat
protected.POST("/presets", personas.CreateUserPersona)
protected.PUT("/presets/:id", personas.UpdateUserPersona)
protected.DELETE("/presets/:id", personas.DeleteUserPersona)
protected.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
protected.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
// Notes
notes := handlers.NewNoteHandler()
protected.GET("/notes", notes.List)
protected.POST("/notes", notes.Create)
protected.GET("/notes/search", notes.Search)
protected.GET("/notes/folders", notes.ListFolders)
protected.POST("/notes/bulk-delete", notes.BulkDelete)
protected.GET("/notes/:id", notes.Get)
protected.PUT("/notes/:id", notes.Update)
protected.DELETE("/notes/:id", notes.Delete)
// Teams (user: my teams)
teams := handlers.NewTeamHandler()
protected.GET("/teams/mine", teams.MyTeams)
// Team admin self-service (requires team admin role, not sys-admin)
// Team admin self-service
teamScoped := protected.Group("/teams/:teamId")
teamScoped.Use(middleware.RequireTeamAdmin())
{
@@ -176,22 +194,15 @@ func main() {
teamScoped.DELETE("/providers/:id", teams.DeleteTeamProvider)
teamScoped.GET("/providers/:id/models", teams.ListTeamProviderModels)
// Team presets
teamPresets := handlers.NewPresetHandler()
teamScoped.GET("/presets", teamPresets.ListTeamPresets)
teamScoped.POST("/presets", teamPresets.CreateTeamPreset)
teamScoped.DELETE("/presets/:id", teamPresets.DeleteTeamPreset)
// Team personas
teamPersonas := handlers.NewPersonaHandler(stores)
teamScoped.GET("/presets", teamPersonas.ListTeamPersonas)
teamScoped.POST("/presets", teamPersonas.CreateTeamPersona)
teamScoped.DELETE("/presets/:id", teamPersonas.DeleteTeamPersona)
}
protected.POST("/notes", notes.Create)
protected.GET("/notes/search", notes.Search)
protected.GET("/notes/folders", notes.ListFolders)
protected.POST("/notes/bulk-delete", notes.BulkDelete)
protected.GET("/notes/:id", notes.Get)
protected.PUT("/notes/:id", notes.Update)
protected.DELETE("/notes/:id", notes.Delete)
// Public global settings (non-admin users can read safe subset)
adm := handlers.NewAdminHandler()
adm := handlers.NewAdminHandler(stores)
protected.GET("/settings/public", adm.PublicSettings)
}
@@ -200,7 +211,7 @@ func main() {
admin.Use(middleware.Auth(cfg))
admin.Use(middleware.RequireAdmin())
{
adm := handlers.NewAdminHandler()
adm := handlers.NewAdminHandler(stores)
// User management
admin.GET("/users", adm.ListUsers)
@@ -218,35 +229,31 @@ func main() {
// Stats
admin.GET("/stats", adm.GetStats)
// Global API Configs
// Global Provider Configs
admin.GET("/configs", adm.ListGlobalConfigs)
admin.POST("/configs", adm.CreateGlobalConfig)
admin.PUT("/configs/:id", adm.UpdateGlobalConfig)
admin.DELETE("/configs/:id", adm.DeleteGlobalConfig)
// Model Configs
// Model Catalog
admin.GET("/models", adm.ListModelConfigs)
admin.POST("/models/fetch", adm.FetchModels)
admin.PUT("/models/bulk", adm.BulkUpdateModels)
admin.PUT("/models/:id", adm.UpdateModelConfig)
admin.DELETE("/models/:id", adm.DeleteModelConfig)
// Model Presets (admin)
presetAdm := handlers.NewPresetHandler()
admin.GET("/presets", presetAdm.ListAdminPresets)
admin.POST("/presets", presetAdm.CreateAdminPreset)
admin.PUT("/presets/:id", presetAdm.UpdateAdminPreset)
admin.DELETE("/presets/:id", presetAdm.DeleteAdminPreset)
// Personas (admin global)
personaAdm := handlers.NewPersonaHandler(stores)
admin.GET("/presets", personaAdm.ListAdminPersonas)
admin.POST("/presets", personaAdm.CreateAdminPersona)
admin.PUT("/presets/:id", personaAdm.UpdateAdminPersona)
admin.DELETE("/presets/:id", personaAdm.DeleteAdminPersona)
admin.POST("/presets/:id/avatar", handlers.UploadPresetAvatar)
admin.DELETE("/presets/:id/avatar", handlers.DeletePresetAvatar)
// Teams (admin)
teamAdm := handlers.NewTeamHandler()
admin.GET("/teams", teamAdm.ListTeams)
// Audit log
admin.GET("/audit", adm.ListAuditLog)
admin.GET("/audit/actions", adm.ListAuditActions)
admin.POST("/teams", teamAdm.CreateTeam)
admin.GET("/teams/:id", teamAdm.GetTeam)
admin.PUT("/teams/:id", teamAdm.UpdateTeam)
@@ -255,6 +262,10 @@ func main() {
admin.POST("/teams/:id/members", teamAdm.AddMember)
admin.PUT("/teams/:id/members/:memberId", teamAdm.UpdateMember)
admin.DELETE("/teams/:id/members/:memberId", teamAdm.RemoveMember)
// Audit log
admin.GET("/audit", adm.ListAuditLog)
admin.GET("/audit/actions", adm.ListAuditActions)
}
}
@@ -271,7 +282,3 @@ func main() {
log.Fatalf("Failed to start server: %v", err)
}
}
func isRegistrationOpen() bool {
return handlers.IsRegistrationEnabled()
}

View File

@@ -1,308 +0,0 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/handlers"
"git.gobha.me/xcaliber/chat-switchboard/middleware"
"git.gobha.me/xcaliber/chat-switchboard/providers"
)
func init() {
gin.SetMode(gin.TestMode)
providers.Init()
}
func TestHealthEndpoint(t *testing.T) {
r := gin.New()
r.GET("/health", func(c *gin.Context) {
c.JSON(200, gin.H{"status": "ok", "version": "test", "database": false})
})
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/health", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected %d, got %d", http.StatusOK, w.Code)
}
}
func TestCORSHeaders(t *testing.T) {
r := gin.New()
r.Use(middleware.CORS())
r.GET("/test", func(c *gin.Context) { c.Status(200) })
w := httptest.NewRecorder()
req, _ := http.NewRequest("OPTIONS", "/test", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("Expected %d for OPTIONS, got %d", http.StatusNoContent, w.Code)
}
if w.Header().Get("Access-Control-Allow-Origin") != "*" {
t.Error("Missing CORS header")
}
}
func TestAllRoutesRegistered(t *testing.T) {
cfg := &config.Config{JWTSecret: "test"}
auth := handlers.NewAuthHandler(cfg)
channels := handlers.NewChannelHandler()
msgs := handlers.NewMessageHandler()
comp := handlers.NewCompletionHandler()
apiCfg := handlers.NewAPIConfigHandler()
settings := handlers.NewSettingsHandler()
presets := handlers.NewPresetHandler()
notes := handlers.NewNoteHandler()
adm := handlers.NewAdminHandler()
r := gin.New()
api := r.Group("/api/v1")
authGroup := api.Group("/auth")
{
authGroup.POST("/register", auth.Register)
authGroup.POST("/login", auth.Login)
authGroup.POST("/refresh", auth.Refresh)
authGroup.POST("/logout", auth.Logout)
}
protected := api.Group("")
{
// Channels
protected.GET("/channels", channels.ListChannels)
protected.POST("/channels", channels.CreateChannel)
protected.GET("/channels/:id", channels.GetChannel)
protected.PUT("/channels/:id", channels.UpdateChannel)
protected.DELETE("/channels/:id", channels.DeleteChannel)
// Messages
protected.GET("/channels/:id/messages", msgs.ListMessages)
protected.POST("/channels/:id/messages", msgs.CreateMessage)
// Message tree (forking)
protected.GET("/channels/:id/path", msgs.GetActivePath)
protected.PUT("/channels/:id/cursor", msgs.UpdateCursor)
protected.POST("/channels/:id/messages/:msgId/edit", msgs.EditMessage)
protected.POST("/channels/:id/messages/:msgId/regenerate", msgs.Regenerate)
protected.GET("/channels/:id/messages/:msgId/siblings", msgs.ListSiblings)
// Completion Engine
protected.POST("/chat/completions", comp.Complete)
// API Configs
protected.GET("/api-configs", apiCfg.ListConfigs)
protected.POST("/api-configs", apiCfg.CreateConfig)
protected.GET("/api-configs/:id", apiCfg.GetConfig)
protected.PUT("/api-configs/:id", apiCfg.UpdateConfig)
protected.DELETE("/api-configs/:id", apiCfg.DeleteConfig)
protected.GET("/api-configs/:id/models", apiCfg.ListModels)
protected.GET("/models", apiCfg.ListAllModels)
protected.GET("/models/enabled", apiCfg.ListEnabledModels)
// User Settings & Profile
protected.GET("/profile", settings.GetProfile)
protected.PUT("/profile", settings.UpdateProfile)
protected.POST("/profile/password", settings.ChangePassword)
protected.GET("/settings", settings.GetSettings)
protected.PUT("/settings", settings.UpdateSettings)
// Model Presets
protected.GET("/presets", presets.ListUserPresets)
protected.POST("/presets", presets.CreateUserPreset)
protected.PUT("/presets/:id", presets.UpdateUserPreset)
protected.DELETE("/presets/:id", presets.DeleteUserPreset)
// Notes
protected.GET("/notes", notes.List)
protected.POST("/notes", notes.Create)
protected.GET("/notes/search", notes.Search)
protected.GET("/notes/folders", notes.ListFolders)
protected.POST("/notes/bulk-delete", notes.BulkDelete)
protected.GET("/notes/:id", notes.Get)
protected.PUT("/notes/:id", notes.Update)
protected.DELETE("/notes/:id", notes.Delete)
}
// Admin routes
admin := api.Group("/admin")
{
admin.GET("/users", adm.ListUsers)
admin.POST("/users", adm.CreateUser)
admin.PUT("/users/:id/role", adm.UpdateUserRole)
admin.PUT("/users/:id/active", adm.ToggleUserActive)
admin.POST("/users/:id/reset-password", adm.ResetPassword)
admin.DELETE("/users/:id", adm.DeleteUser)
admin.GET("/settings", adm.ListGlobalSettings)
admin.GET("/settings/:key", adm.GetGlobalSetting)
admin.PUT("/settings/:key", adm.UpdateGlobalSetting)
admin.GET("/stats", adm.GetStats)
admin.GET("/configs", adm.ListGlobalConfigs)
admin.POST("/configs", adm.CreateGlobalConfig)
admin.DELETE("/configs/:id", adm.DeleteGlobalConfig)
admin.GET("/models", adm.ListModelConfigs)
admin.POST("/models/fetch", adm.FetchModels)
admin.PUT("/models/:id", adm.UpdateModelConfig)
admin.DELETE("/models/:id", adm.DeleteModelConfig)
}
routes := r.Routes()
routePaths := make(map[string]bool)
for _, route := range routes {
routePaths[route.Method+" "+route.Path] = true
}
expected := []string{
// Auth
"POST /api/v1/auth/register",
"POST /api/v1/auth/login",
"POST /api/v1/auth/refresh",
"POST /api/v1/auth/logout",
// Channels
"GET /api/v1/channels",
"POST /api/v1/channels",
"GET /api/v1/channels/:id",
"PUT /api/v1/channels/:id",
"DELETE /api/v1/channels/:id",
// Messages
"GET /api/v1/channels/:id/messages",
"POST /api/v1/channels/:id/messages",
// Message tree (forking)
"GET /api/v1/channels/:id/path",
"PUT /api/v1/channels/:id/cursor",
"POST /api/v1/channels/:id/messages/:msgId/edit",
"POST /api/v1/channels/:id/messages/:msgId/regenerate",
"GET /api/v1/channels/:id/messages/:msgId/siblings",
// Completion Engine
"POST /api/v1/chat/completions",
// API Configs
"GET /api/v1/api-configs",
"POST /api/v1/api-configs",
"GET /api/v1/api-configs/:id",
"PUT /api/v1/api-configs/:id",
"DELETE /api/v1/api-configs/:id",
"GET /api/v1/api-configs/:id/models",
// Models
"GET /api/v1/models",
"GET /api/v1/models/enabled",
// Profile & Settings
"GET /api/v1/profile",
"PUT /api/v1/profile",
"POST /api/v1/profile/password",
"GET /api/v1/settings",
"PUT /api/v1/settings",
// Presets
"GET /api/v1/presets",
"POST /api/v1/presets",
"PUT /api/v1/presets/:id",
"DELETE /api/v1/presets/:id",
// Notes
"GET /api/v1/notes",
"POST /api/v1/notes",
"GET /api/v1/notes/search",
"GET /api/v1/notes/folders",
"POST /api/v1/notes/bulk-delete",
"GET /api/v1/notes/:id",
"PUT /api/v1/notes/:id",
"DELETE /api/v1/notes/:id",
// Admin
"GET /api/v1/admin/users",
"POST /api/v1/admin/users",
"PUT /api/v1/admin/users/:id/role",
"PUT /api/v1/admin/users/:id/active",
"POST /api/v1/admin/users/:id/reset-password",
"DELETE /api/v1/admin/users/:id",
"GET /api/v1/admin/settings",
"GET /api/v1/admin/settings/:key",
"PUT /api/v1/admin/settings/:key",
"GET /api/v1/admin/stats",
"GET /api/v1/admin/configs",
"POST /api/v1/admin/configs",
"DELETE /api/v1/admin/configs/:id",
"GET /api/v1/admin/models",
"POST /api/v1/admin/models/fetch",
"PUT /api/v1/admin/models/:id",
"DELETE /api/v1/admin/models/:id",
}
for _, e := range expected {
if !routePaths[e] {
t.Errorf("Missing route: %s", e)
}
}
}
func TestProviderRegistry(t *testing.T) {
ids := providers.List()
if len(ids) < 2 {
t.Errorf("Expected at least 2 providers, got %d", len(ids))
}
// OpenAI should be registered
p, err := providers.Get("openai")
if err != nil {
t.Errorf("OpenAI provider not found: %v", err)
}
if p.ID() != "openai" {
t.Errorf("Expected ID 'openai', got '%s'", p.ID())
}
// Anthropic should be registered
p, err = providers.Get("anthropic")
if err != nil {
t.Errorf("Anthropic provider not found: %v", err)
}
if p.ID() != "anthropic" {
t.Errorf("Expected ID 'anthropic', got '%s'", p.ID())
}
// Unknown should fail
_, err = providers.Get("nonexistent")
if err == nil {
t.Error("Expected error for unknown provider")
}
}
func TestAnthropicStaticModels(t *testing.T) {
p := &providers.AnthropicProvider{}
models, err := p.ListModels(nil, providers.ProviderConfig{})
if err != nil {
t.Errorf("ListModels should not fail: %v", err)
}
if len(models) == 0 {
t.Error("Expected some static models")
}
// Check that known models are present
found := false
for _, m := range models {
if m.ID == "claude-sonnet-4-20250514" {
found = true
break
}
}
if !found {
t.Error("Expected claude-sonnet-4 in static model list")
}
}
func TestAuthMiddlewareNoDatabase(t *testing.T) {
cfg := &config.Config{JWTSecret: "test"}
r := gin.New()
r.Use(middleware.Auth(cfg))
r.GET("/protected", func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
w := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/protected", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("Expected pass-through with no DB, got %d", w.Code)
}
}

View File

@@ -1,42 +1,281 @@
package models
import (
"database/sql"
"encoding/json"
"time"
)
// BaseModel contains fields common to all models
// ── Base ────────────────────────────────────
type BaseModel struct {
ID string `json:"id" db:"id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// User represents a user in the system
type User struct {
BaseModel
Email string `json:"email" db:"email"`
PasswordHash string `json:"-" db:"password_hash"`
Name string `json:"name" db:"name"`
Role string `json:"role" db:"role"`
LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"`
IsActive bool `json:"is_active" db:"is_active"`
}
// ── Scope Constants ─────────────────────────
const (
ScopeGlobal = "global"
ScopeTeam = "team"
ScopePersonal = "personal"
)
// ── Visibility Constants ────────────────────
const (
VisibilityVisible = "visible"
VisibilityHidden = "hidden"
)
// ── Role Constants ──────────────────────────
// UserRole constants
const (
UserRoleUser = "user"
UserRoleAdmin = "admin"
TeamRoleAdmin = "admin"
TeamRoleMember = "member"
)
// ── Channel Types ───────────────────────────
// ── Grant Type Constants ────────────────────
const (
ChannelTypeDirect = "direct" // 1:1 AI chat (legacy "chat")
ChannelTypeGroup = "group" // multi-model conversation
ChannelTypeChannel = "channel" // named, persistent, membered
GrantTypeTool = "tool"
GrantTypeKnowledgeBase = "knowledge_base"
GrantTypeAPIEndpoint = "api_endpoint"
)
// Channel represents a conversation channel (unified: chats + channels).
// ── Channel Type Constants ──────────────────
const (
ChannelTypeDirect = "direct"
ChannelTypeGroup = "group"
ChannelTypeChannel = "channel"
)
// ── Message Role Constants ──────────────────
const (
MessageRoleUser = "user"
MessageRoleAssistant = "assistant"
MessageRoleSystem = "system"
MessageRoleTool = "tool"
)
// =========================================
// USERS
// =========================================
type User struct {
BaseModel
Username string `json:"username" db:"username"`
Email string `json:"email" db:"email"`
PasswordHash string `json:"-" db:"password_hash"`
DisplayName string `json:"display_name,omitempty" db:"display_name"`
AvatarURL string `json:"avatar_url,omitempty" db:"avatar_url"`
Role string `json:"role" db:"role"`
IsActive bool `json:"is_active" db:"is_active"`
Settings JSONMap `json:"settings,omitempty" db:"settings"`
LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"`
}
// =========================================
// TEAMS
// =========================================
type Team struct {
BaseModel
Name string `json:"name" db:"name"`
Description string `json:"description,omitempty" db:"description"`
CreatedBy string `json:"created_by" db:"created_by"`
IsActive bool `json:"is_active" db:"is_active"`
Settings JSONMap `json:"settings,omitempty" db:"settings"`
MemberCount int `json:"member_count,omitempty"` // computed
}
type TeamMember struct {
ID string `json:"id" db:"id"`
TeamID string `json:"team_id" db:"team_id"`
UserID string `json:"user_id" db:"user_id"`
Role string `json:"role" db:"role"`
JoinedAt string `json:"joined_at" db:"joined_at"`
// Joined fields from users table
Email string `json:"email,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Username string `json:"username,omitempty"`
UserRole string `json:"user_role,omitempty"`
}
// =========================================
// PROVIDER CONFIGS (replaces APIConfig)
// =========================================
type ProviderConfig struct {
BaseModel
Scope string `json:"scope" db:"scope"`
OwnerID *string `json:"owner_id,omitempty" db:"owner_id"`
Name string `json:"name" db:"name"`
Provider string `json:"provider" db:"provider"`
Endpoint string `json:"endpoint" db:"endpoint"`
APIKeyEnc string `json:"-" db:"api_key_enc"`
ModelDefault string `json:"model_default,omitempty" db:"model_default"`
Config JSONMap `json:"config,omitempty" db:"config"`
Headers JSONMap `json:"headers,omitempty" db:"headers"`
Settings JSONMap `json:"settings,omitempty" db:"settings"`
IsActive bool `json:"is_active" db:"is_active"`
IsPrivate bool `json:"is_private" db:"is_private"`
}
type ProviderConfigPatch struct {
Name *string `json:"name,omitempty"`
Endpoint *string `json:"endpoint,omitempty"`
APIKeyEnc *string `json:"-"`
ModelDefault *string `json:"model_default,omitempty"`
Config JSONMap `json:"config,omitempty"`
Headers JSONMap `json:"headers,omitempty"`
Settings JSONMap `json:"settings,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
IsPrivate *bool `json:"is_private,omitempty"`
}
// =========================================
// MODEL CATALOG (replaces model_configs)
// =========================================
type CatalogEntry struct {
BaseModel
ProviderConfigID string `json:"provider_config_id" db:"provider_config_id"`
ModelID string `json:"model_id" db:"model_id"`
DisplayName string `json:"display_name,omitempty" db:"display_name"`
Capabilities ModelCapabilities `json:"capabilities" db:"capabilities"`
Pricing *ModelPricing `json:"pricing,omitempty" db:"pricing"`
Visibility string `json:"visibility" db:"visibility"`
LastSyncedAt *time.Time `json:"last_synced_at,omitempty" db:"last_synced_at"`
}
type ModelCapabilities struct {
Streaming bool `json:"streaming"`
ToolCalling bool `json:"tool_calling"`
Vision bool `json:"vision"`
Thinking bool `json:"thinking"`
Reasoning bool `json:"reasoning"`
CodeOptimized bool `json:"code_optimized"`
WebSearch bool `json:"web_search"`
MaxContext int `json:"max_context"`
MaxOutputTokens int `json:"max_output_tokens"`
}
func (c ModelCapabilities) HasProviderData() bool {
return c.ToolCalling || c.Vision || c.Thinking || c.Reasoning ||
c.CodeOptimized || c.WebSearch || c.MaxContext > 0 || c.MaxOutputTokens > 0
}
type ModelPricing struct {
InputPerM float64 `json:"input_per_m,omitempty"`
OutputPerM float64 `json:"output_per_m,omitempty"`
Currency string `json:"currency,omitempty"`
}
// =========================================
// PERSONAS (replaces ModelPreset)
// =========================================
type Persona struct {
BaseModel
Name string `json:"name" db:"name"`
Description string `json:"description,omitempty" db:"description"`
Icon string `json:"icon,omitempty" db:"icon"`
Avatar string `json:"avatar,omitempty" db:"avatar"`
BaseModelID string `json:"base_model_id" db:"base_model_id"`
ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
Temperature *float64 `json:"temperature,omitempty" db:"temperature"`
MaxTokens *int `json:"max_tokens,omitempty" db:"max_tokens"`
ThinkingBudget *int `json:"thinking_budget,omitempty" db:"thinking_budget"`
TopP *float64 `json:"top_p,omitempty" db:"top_p"`
Scope string `json:"scope" db:"scope"`
OwnerID *string `json:"owner_id,omitempty" db:"owner_id"`
CreatedBy string `json:"created_by" db:"created_by"`
IsActive bool `json:"is_active" db:"is_active"`
IsShared bool `json:"is_shared" db:"is_shared"`
// Loaded from persona_grants, not stored in personas table
Grants []Grant `json:"grants,omitempty" db:"-"`
}
type PersonaPatch struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Icon *string `json:"icon,omitempty"`
Avatar *string `json:"avatar,omitempty"`
BaseModelID *string `json:"base_model_id,omitempty"`
ProviderConfigID *string `json:"provider_config_id,omitempty"`
SystemPrompt *string `json:"system_prompt,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
ThinkingBudget *int `json:"thinking_budget,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
IsShared *bool `json:"is_shared,omitempty"`
}
// =========================================
// GRANTS
// =========================================
type Grant struct {
ID string `json:"id" db:"id"`
PersonaID string `json:"persona_id" db:"persona_id"`
GrantType string `json:"grant_type" db:"grant_type"`
GrantRef string `json:"grant_ref" db:"grant_ref"`
Config JSONMap `json:"config,omitempty" db:"config"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// =========================================
// PLATFORM POLICIES
// =========================================
var PolicyDefaults = map[string]string{
"allow_user_byok": "false",
"allow_user_personas": "false",
"allow_raw_model_access": "false",
"allow_registration": "true",
"default_user_active": "false",
"allow_team_providers": "true",
}
// =========================================
// USER MODEL SETTINGS
// =========================================
type UserModelSetting struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
ModelID string `json:"model_id" db:"model_id"`
Hidden bool `json:"hidden" db:"hidden"`
PreferredTemperature *float64 `json:"preferred_temperature,omitempty" db:"preferred_temperature"`
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty" db:"preferred_max_tokens"`
SortOrder int `json:"sort_order" db:"sort_order"`
}
type UserModelSettingPatch struct {
Hidden *bool `json:"hidden,omitempty"`
PreferredTemperature *float64 `json:"preferred_temperature,omitempty"`
PreferredMaxTokens *int `json:"preferred_max_tokens,omitempty"`
SortOrder *int `json:"sort_order,omitempty"`
}
// =========================================
// CHANNELS
// =========================================
type Channel struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
@@ -45,33 +284,37 @@ type Channel struct {
Type string `json:"type" db:"type"`
Model string `json:"model,omitempty" db:"model"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
APIConfigID *string `json:"api_config_id,omitempty" db:"api_config_id"`
ProviderConfigID *string `json:"provider_config_id,omitempty" db:"provider_config_id"`
IsArchived bool `json:"is_archived" db:"is_archived"`
IsPinned bool `json:"is_pinned" db:"is_pinned"`
FolderID *string `json:"folder_id,omitempty" db:"folder_id"`
TeamID *string `json:"team_id,omitempty" db:"team_id"`
Settings JSONMap `json:"settings,omitempty" db:"settings"`
}
// Message represents a message in a channel
// =========================================
// MESSAGES
// =========================================
type Message struct {
BaseModel
ChannelID string `json:"channel_id" db:"channel_id"`
Role string `json:"role" db:"role"`
Content string `json:"content" db:"content"`
Tokens int `json:"tokens,omitempty" db:"tokens"`
Model string `json:"model,omitempty" db:"model"`
FinishReason string `json:"finish_reason,omitempty" db:"finish_reason"`
TokensUsed int `json:"tokens_used,omitempty" db:"tokens_used"`
ToolCalls JSONMap `json:"tool_calls,omitempty" db:"tool_calls"`
Metadata JSONMap `json:"metadata,omitempty" db:"metadata"`
ParentID *string `json:"parent_id,omitempty" db:"parent_id"`
SiblingIndex int `json:"sibling_index" db:"sibling_index"`
ParticipantType string `json:"participant_type,omitempty" db:"participant_type"`
ParticipantID string `json:"participant_id,omitempty" db:"participant_id"`
DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"`
}
const (
MessageRoleUser = "user"
MessageRoleAssistant = "assistant"
MessageRoleSystem = "system"
)
// ── Channel Members & Models ────────────────
// =========================================
// CHANNEL MEMBERS, MODELS, CURSORS
// =========================================
type ChannelMember struct {
ID string `json:"id" db:"id"`
@@ -86,7 +329,7 @@ type ChannelModel struct {
ID string `json:"id" db:"id"`
ChannelID string `json:"channel_id" db:"channel_id"`
ModelID string `json:"model_id" db:"model_id"`
APIConfigID string `json:"api_config_id,omitempty" db:"api_config_id"`
ProviderConfigID string `json:"provider_config_id,omitempty" db:"provider_config_id"`
DisplayName string `json:"display_name,omitempty" db:"display_name"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
IsDefault bool `json:"is_default" db:"is_default"`
@@ -100,13 +343,16 @@ type ChannelCursor struct {
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// ── Organization ────────────────────────────
// =========================================
// ORGANIZATION
// =========================================
type Folder struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"`
ParentID *string `json:"parent_id,omitempty" db:"parent_id"`
SortOrder int `json:"sort_order" db:"sort_order"`
}
type Project struct {
@@ -117,114 +363,134 @@ type Project struct {
Color string `json:"color,omitempty" db:"color"`
}
// ── API Config ──────────────────────────────
type APIConfig struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"`
Provider string `json:"provider" db:"provider"`
APIKey string `json:"-" db:"api_key"`
BaseURL string `json:"base_url,omitempty" db:"base_url"`
Model string `json:"model" db:"model"`
IsDefault bool `json:"is_default" db:"is_default"`
}
// ── Notes (future Phase 2) ──────────────────
// =========================================
// NOTES
// =========================================
type Note struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Title string `json:"title" db:"title"`
Content string `json:"content" db:"content"`
FolderPath string `json:"folder_path" db:"folder_path"`
Tags []string `json:"tags,omitempty" db:"tags"`
FolderID string `json:"folder_id,omitempty" db:"folder_id"`
IsShared bool `json:"is_shared" db:"is_shared"`
}
type KnowledgeBase struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"`
Source string `json:"source" db:"source"`
Settings string `json:"settings,omitempty" db:"settings"`
}
// ── Model Presets ──────────────────────────
const (
PresetScopeGlobal = "global"
PresetScopeTeam = "team"
PresetScopePersonal = "personal"
)
type ModelPreset struct {
BaseModel
Name string `json:"name" db:"name"`
Description string `json:"description" db:"description"`
BaseModelID string `json:"base_model_id" db:"base_model_id"`
APIConfigID *string `json:"api_config_id,omitempty" db:"api_config_id"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
Temperature *float64 `json:"temperature,omitempty" db:"temperature"`
MaxTokens *int `json:"max_tokens,omitempty" db:"max_tokens"`
ToolsEnabled string `json:"tools_enabled,omitempty" db:"tools_enabled"` // JSON array
Scope string `json:"scope" db:"scope"`
Metadata JSONMap `json:"metadata,omitempty" db:"metadata"`
SourceChannelID *string `json:"source_channel_id,omitempty" db:"source_channel_id"`
TeamID *string `json:"team_id,omitempty" db:"team_id"`
CreatedBy string `json:"created_by" db:"created_by"`
IsShared bool `json:"is_shared" db:"is_shared"`
IsActive bool `json:"is_active" db:"is_active"`
Icon string `json:"icon,omitempty" db:"icon"`
Avatar string `json:"avatar,omitempty" db:"avatar"`
}
// ── Teams ───────────────────────────────────
// =========================================
// AUDIT LOG
// =========================================
const (
TeamRoleAdmin = "admin"
TeamRoleMember = "member"
)
type Team struct {
BaseModel
Name string `json:"name" db:"name"`
Description string `json:"description,omitempty" db:"description"`
CreatedBy string `json:"created_by" db:"created_by"`
IsActive bool `json:"is_active" db:"is_active"`
Settings string `json:"settings,omitempty" db:"settings"` // JSON
MemberCount int `json:"member_count,omitempty"` // computed, not stored
}
type TeamMember struct {
type AuditEntry struct {
ID string `json:"id" db:"id"`
TeamID string `json:"team_id" db:"team_id"`
UserID string `json:"user_id" db:"user_id"`
Role string `json:"role" db:"role"`
JoinedAt string `json:"joined_at" db:"joined_at"`
// Joined fields (from user)
Email string `json:"email,omitempty"`
DisplayName string `json:"display_name,omitempty"`
UserRole string `json:"user_role,omitempty"` // system role (user/admin)
ActorID *string `json:"actor_id,omitempty" db:"actor_id"`
Action string `json:"action" db:"action"`
ResourceType string `json:"resource_type" db:"resource_type"`
ResourceID string `json:"resource_id,omitempty" db:"resource_id"`
Metadata JSONMap `json:"metadata,omitempty" db:"metadata"`
IPAddress string `json:"ip_address,omitempty" db:"ip_address"`
UserAgent string `json:"user_agent,omitempty" db:"user_agent"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// ── Settings ────────────────────────────────
// =========================================
// VIEW MODELS (computed, not stored)
// =========================================
type Settings struct {
UserID string `json:"user_id" db:"user_id"`
Theme string `json:"theme" db:"theme"`
Language string `json:"language" db:"language"`
Model string `json:"model" db:"model"`
SystemPrompt string `json:"system_prompt,omitempty" db:"system_prompt"`
MaxTokens int `json:"max_tokens" db:"max_tokens"`
Temperature float64 `json:"temperature" db:"temperature"`
DefaultAPIConfigID string `json:"default_api_config_id,omitempty" db:"default_api_config_id"`
// UserModel is the view model returned by the capability resolver.
// Combines catalog entries + Personas for the frontend.
type UserModel struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
ModelID string `json:"model_id"`
Source string `json:"source"` // "catalog", "persona", "live"
ProviderConfigID string `json:"provider_config_id"`
ConfigID string `json:"config_id"` // Alias of ProviderConfigID for frontend compat
ProviderName string `json:"provider_name"`
ProviderType string `json:"provider_type"`
Capabilities ModelCapabilities `json:"capabilities"`
// Preset fields — always emitted so frontend can branch on is_preset.
IsPreset bool `json:"is_preset"`
PresetID string `json:"preset_id,omitempty"`
PresetScope string `json:"preset_scope,omitempty"`
PresetAvatar string `json:"preset_avatar,omitempty"`
PresetTeamName string `json:"preset_team_name,omitempty"`
PersonaID string `json:"persona_id,omitempty"`
Description string `json:"description,omitempty"`
Icon string `json:"icon,omitempty"`
Avatar string `json:"avatar,omitempty"`
SystemPrompt string `json:"system_prompt,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
ToolGrants []string `json:"tool_grants,omitempty"`
Pricing *ModelPricing `json:"pricing,omitempty"`
Scope string `json:"scope"`
OwnerID *string `json:"owner_id,omitempty"`
TeamName string `json:"team_name,omitempty"`
Hidden bool `json:"hidden"`
SortOrder int `json:"sort_order"`
}
type APIToken struct {
BaseModel
UserID string `json:"user_id" db:"user_id"`
Name string `json:"name" db:"name"`
Token string `json:"-" db:"token"`
ExpiresAt time.Time `json:"expires_at,omitempty" db:"expires_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty" db:"last_used_at"`
IsActive bool `json:"is_active" db:"is_active"`
// =========================================
// JSON HELPERS
// =========================================
// JSONMap scans from/to JSONB columns.
type JSONMap map[string]interface{}
func (m *JSONMap) Scan(src interface{}) error {
if src == nil {
*m = nil
return nil
}
var source []byte
switch v := src.(type) {
case []byte:
source = v
case string:
source = []byte(v)
default:
return nil
}
result := make(JSONMap)
if err := json.Unmarshal(source, &result); err != nil {
return err
}
*m = result
return nil
}
func NullString(s *string) sql.NullString {
if s == nil {
return sql.NullString{}
}
return sql.NullString{String: *s, Valid: true}
}
func NullFloat(f *float64) sql.NullFloat64 {
if f == nil {
return sql.NullFloat64{}
}
return sql.NullFloat64{Float64: *f, Valid: true}
}
func NullInt(i *int) sql.NullInt64 {
if i == nil {
return sql.NullInt64{}
}
return sql.NullInt64{Int64: int64(*i), Valid: true}
}
func StringPtr(s string) *string { return &s }
func Float64Ptr(f float64) *float64 { return &f }
func IntPtr(i int) *int { return &i }
func BoolPtr(b bool) *bool { return &b }

View File

@@ -1,6 +1,8 @@
package providers
import (
"git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/models"
"bufio"
"bytes"
"context"
@@ -166,17 +168,17 @@ func (p *AnthropicProvider) ListModels(_ context.Context, _ ProviderConfig) ([]M
{"claude-3-5-haiku-20241022", "Claude 3.5 Haiku"},
}
models := make([]Model, 0, len(modelIDs))
out := make([]Model, 0, len(modelIDs))
for _, m := range modelIDs {
caps, _ := LookupKnownModel(m.id)
models = append(models, Model{
caps, _ := capabilities.LookupKnownModel(m.id)
out = append(out, Model{
ID: m.id,
Name: m.name,
OwnedBy: "anthropic",
Capabilities: caps,
})
}
return models, nil
return out, nil
}
// ── HTTP Layer ──────────────────────────────
@@ -255,7 +257,7 @@ func (p *AnthropicProvider) doRequest(ctx context.Context, cfg ProviderConfig, r
antReq.System = system
}
if antReq.MaxTokens == 0 {
antReq.MaxTokens = ResolveMaxOutput(req.Model, ModelCapabilities{})
antReq.MaxTokens = capabilities.ResolveMaxOutput(req.Model, models.ModelCapabilities{})
}
if req.Temperature != nil {
antReq.Temperature = req.Temperature

View File

@@ -1,6 +1,7 @@
package providers
import (
"git.gobha.me/xcaliber/chat-switchboard/capabilities"
"bufio"
"bytes"
"context"
@@ -190,25 +191,25 @@ func (p *OpenAIProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
return nil, fmt.Errorf("decode models: %w", err)
}
models := make([]Model, 0, len(result.Data))
out := make([]Model, 0, len(result.Data))
for _, m := range result.Data {
// Try known table first, then heuristic
caps, found := LookupKnownModel(m.ID)
caps, found := capabilities.LookupKnownModel(m.ID)
if !found {
caps = InferCapabilities(m.ID)
caps = capabilities.InferCapabilities(m.ID)
}
// Use context_length from API if available and we don't have it
if m.ContextLength > 0 && caps.MaxContext == 0 {
caps.MaxContext = m.ContextLength
}
models = append(models, Model{
out = append(out, Model{
ID: m.ID,
OwnedBy: m.OwnedBy,
Capabilities: caps,
})
}
return models, nil
return out, nil
}
// ── HTTP Layer ──────────────────────────────

View File

@@ -1,6 +1,8 @@
package providers
import (
"git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/models"
"context"
"encoding/json"
"fmt"
@@ -65,12 +67,12 @@ func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig)
return nil, fmt.Errorf("openrouter decode models: %w", err)
}
models := make([]Model, 0, len(result.Data))
out := make([]Model, 0, len(result.Data))
for _, m := range result.Data {
// Start with known table, fall back to heuristic
caps, found := LookupKnownModel(m.ID)
caps, found := capabilities.LookupKnownModel(m.ID)
if !found {
caps = InferCapabilities(m.ID)
caps = capabilities.InferCapabilities(m.ID)
}
// Overlay context length from OpenRouter metadata
@@ -93,12 +95,12 @@ func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig)
caps.Streaming = true
// Parse pricing (OpenRouter uses per-token strings, convert to per-1M)
var pricing *ModelPricing
var pricing *models.ModelPricing
if m.Pricing.Prompt != "" {
inputPerToken, _ := strconv.ParseFloat(m.Pricing.Prompt, 64)
outputPerToken, _ := strconv.ParseFloat(m.Pricing.Completion, 64)
if inputPerToken > 0 || outputPerToken > 0 {
pricing = &ModelPricing{
pricing = &models.ModelPricing{
InputPerM: inputPerToken * 1_000_000,
OutputPerM: outputPerToken * 1_000_000,
}
@@ -116,7 +118,7 @@ func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig)
name = m.ID
}
models = append(models, Model{
out = append(out, Model{
ID: m.ID,
Name: name,
OwnedBy: ownedBy,
@@ -124,7 +126,7 @@ func (p *OpenRouterProvider) ListModels(ctx context.Context, cfg ProviderConfig)
Pricing: pricing,
})
}
return models, nil
return out, nil
}
// ── OpenRouter Wire Types ───────────────────

View File

@@ -3,6 +3,8 @@ package providers
import (
"context"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// ── Provider Interface ──────────────────────
@@ -26,12 +28,12 @@ type Provider interface {
// ── Configuration ───────────────────────────
// ProviderConfig holds credentials and endpoint for a configured provider.
// Populated from the api_configs table at call time.
// Populated from the provider_configs table at call time.
type ProviderConfig struct {
Endpoint string
APIKey string
CustomHeaders map[string]string // Extra HTTP headers (e.g. OpenRouter HTTP-Referer)
Settings map[string]interface{} // Provider-specific settings from provider_settings JSONB
Settings map[string]interface{} // Provider-specific settings from config JSONB
}
// ── Request / Response Types ────────────────
@@ -96,24 +98,11 @@ type CompletionResponse struct {
// StreamEvent is a single chunk from a streaming response.
type StreamEvent struct {
// Delta is the incremental text content (empty for non-content events).
Delta string `json:"delta,omitempty"`
// Done is true when the stream is complete.
Done bool `json:"done,omitempty"`
// FinishReason is set on the final event.
// "stop" = normal, "tool_calls" = LLM wants to call tools.
FinishReason string `json:"finish_reason,omitempty"`
// ToolCalls accumulates tool call data during streaming.
// Fully populated on the final event when FinishReason is "tool_calls".
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
// Model echoes back the model used.
Model string `json:"model,omitempty"`
// Error is set if the stream encountered an error.
Error error `json:"-"`
}
@@ -122,12 +111,6 @@ type Model struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
OwnedBy string `json:"owned_by,omitempty"`
Capabilities ModelCapabilities `json:"capabilities"`
Pricing *ModelPricing `json:"pricing,omitempty"`
}
// ModelPricing holds per-million-token costs.
type ModelPricing struct {
InputPerM float64 `json:"input_per_m,omitempty"`
OutputPerM float64 `json:"output_per_m,omitempty"`
Capabilities models.ModelCapabilities `json:"capabilities"`
Pricing *models.ModelPricing `json:"pricing,omitempty"`
}

View File

@@ -1,6 +1,8 @@
package providers
import (
"git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/models"
"context"
"encoding/json"
"fmt"
@@ -62,29 +64,30 @@ func (p *VeniceProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
return nil, fmt.Errorf("venice decode models: %w", err)
}
models := make([]Model, 0, len(result.Data))
out := make([]Model, 0, len(result.Data))
for _, m := range result.Data {
spec := m.ModelSpec
vcaps := spec.Capabilities
caps := ModelCapabilities{
caps := models.ModelCapabilities{
Streaming: true,
ToolCalling: vcaps.SupportsFunctionCalling,
Vision: vcaps.SupportsVision,
Reasoning: vcaps.SupportsReasoning,
WebSearch: vcaps.SupportsWebSearch,
CodeOptimized: vcaps.OptimizedForCode,
MaxContext: spec.AvailableContextTokens,
}
// Venice doesn't report max output tokens directly.
// Try known table, then derive from context.
if known, ok := LookupKnownModel(m.ID); ok && known.MaxOutputTokens > 0 {
if known, ok := capabilities.LookupKnownModel(m.ID); ok && known.MaxOutputTokens > 0 {
caps.MaxOutputTokens = known.MaxOutputTokens
}
var pricing *ModelPricing
var pricing *models.ModelPricing
if spec.Pricing.Input.USD > 0 {
pricing = &ModelPricing{
pricing = &models.ModelPricing{
InputPerM: spec.Pricing.Input.USD,
OutputPerM: spec.Pricing.Output.USD,
}
@@ -95,7 +98,7 @@ func (p *VeniceProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
name = m.ID
}
models = append(models, Model{
out = append(out, Model{
ID: m.ID,
Name: name,
OwnedBy: "venice",
@@ -103,7 +106,7 @@ func (p *VeniceProvider) ListModels(ctx context.Context, cfg ProviderConfig) ([]
Pricing: pricing,
})
}
return models, nil
return out, nil
}
// ── Venice Wire Types ───────────────────────
@@ -137,6 +140,7 @@ type veniceCapabilities struct {
SupportsResponseSchema bool `json:"supportsResponseSchema"`
SupportsAudioInput bool `json:"supportsAudioInput"`
SupportsLogProbs bool `json:"supportsLogProbs"`
OptimizedForCode bool `json:"optimizedForCode"`
}
type venicePricing struct {

296
server/store/interfaces.go Normal file
View File

@@ -0,0 +1,296 @@
package store
import (
"context"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// =========================================
// STORES — Data Access Layer
// =========================================
// Every database operation goes through these interfaces.
// Handlers never touch SQL directly. This makes the DB
// portable (Postgres today, SQLite/MySQL later) and
// handlers testable with in-memory implementations.
// =========================================
// Stores bundles all store interfaces for dependency injection.
type Stores struct {
Providers ProviderStore
Catalog CatalogStore
Personas PersonaStore
Policies PolicyStore
UserSettings UserModelSettingsStore
Users UserStore
Teams TeamStore
Channels ChannelStore
Messages MessageStore
Audit AuditStore
Notes NoteStore
GlobalConfig GlobalConfigStore
}
// =========================================
// PROVIDER STORE
// =========================================
type ProviderStore interface {
Create(ctx context.Context, cfg *models.ProviderConfig) error
GetByID(ctx context.Context, id string) (*models.ProviderConfig, error)
Update(ctx context.Context, id string, patch models.ProviderConfigPatch) error
Delete(ctx context.Context, id string) error
// Scoped queries
ListGlobal(ctx context.Context) ([]models.ProviderConfig, error)
ListForTeam(ctx context.Context, teamID string) ([]models.ProviderConfig, error)
ListForUser(ctx context.Context, userID string) ([]models.ProviderConfig, error) // personal scope
ListAccessible(ctx context.Context, userID string) ([]models.ProviderConfig, error) // all user can access
// Access check
UserCanAccess(ctx context.Context, userID, configID string) (bool, error)
}
// =========================================
// CATALOG STORE
// =========================================
type CatalogStore interface {
// Sync from provider API
UpsertFromSync(ctx context.Context, providerConfigID string, entries []CatalogSyncEntry) (added, updated int, err error)
// Queries
GetByID(ctx context.Context, id string) (*models.CatalogEntry, error)
GetByModelID(ctx context.Context, providerConfigID, modelID string) (*models.CatalogEntry, error)
GetByModelIDAny(ctx context.Context, modelID string) (*models.CatalogEntry, error) // any provider, most recently synced
ListVisible(ctx context.Context) ([]models.CatalogEntry, error)
ListEnabledForProvider(ctx context.Context, providerConfigID string) ([]models.CatalogEntry, error)
ListForProvider(ctx context.Context, providerConfigID string) ([]models.CatalogEntry, error)
ListAll(ctx context.Context) ([]models.CatalogEntry, error) // admin view
// Visibility management
SetVisibility(ctx context.Context, id string, visibility string) error
BulkSetVisibility(ctx context.Context, providerConfigID string, visibility string) error
BulkSetVisibilityAll(ctx context.Context, visibility string) error
// Delete
Delete(ctx context.Context, id string) error
DeleteForProvider(ctx context.Context, providerConfigID string) error
}
// CatalogSyncEntry is the input format from provider FetchModels.
type CatalogSyncEntry struct {
ModelID string
DisplayName string
Capabilities models.ModelCapabilities
Pricing *models.ModelPricing
}
// =========================================
// PERSONA STORE
// =========================================
type PersonaStore interface {
Create(ctx context.Context, p *models.Persona) error
GetByID(ctx context.Context, id string) (*models.Persona, error)
Update(ctx context.Context, id string, patch models.PersonaPatch) error
Delete(ctx context.Context, id string) error
// Scoped queries
ListForUser(ctx context.Context, userID string) ([]models.Persona, error) // all visible to user
ListForTeam(ctx context.Context, teamID string) ([]models.Persona, error)
ListGlobal(ctx context.Context) ([]models.Persona, error)
ListPersonal(ctx context.Context, userID string) ([]models.Persona, error) // user's own
// Grants
SetGrants(ctx context.Context, personaID string, grants []models.Grant) error
GetGrants(ctx context.Context, personaID string) ([]models.Grant, error)
GetToolGrants(ctx context.Context, personaID string) ([]string, error)
// Access check
UserCanAccess(ctx context.Context, userID, personaID string) (bool, error)
}
// =========================================
// POLICY STORE
// =========================================
type PolicyStore interface {
Get(ctx context.Context, key string) (string, error)
GetBool(ctx context.Context, key string) (bool, error)
Set(ctx context.Context, key, value string, updatedBy string) error
GetAll(ctx context.Context) (map[string]string, error)
}
// =========================================
// USER MODEL SETTINGS STORE
// =========================================
type UserModelSettingsStore interface {
GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error)
GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error)
Set(ctx context.Context, userID, modelID string, patch models.UserModelSettingPatch) error
BulkSetHidden(ctx context.Context, userID string, modelIDs []string, hidden bool) error
}
// =========================================
// USER STORE
// =========================================
type UserStore interface {
Create(ctx context.Context, u *models.User) error
GetByID(ctx context.Context, id string) (*models.User, error)
GetByUsername(ctx context.Context, username string) (*models.User, error)
GetByEmail(ctx context.Context, email string) (*models.User, error)
GetByLogin(ctx context.Context, login string) (*models.User, error) // username or email
Update(ctx context.Context, id string, fields map[string]interface{}) error
Delete(ctx context.Context, id string) error
List(ctx context.Context, opts ListOptions) ([]models.User, int, error)
UpdateLastLogin(ctx context.Context, id string) error
SetActive(ctx context.Context, id string, active bool) error
// Refresh tokens
CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error
GetRefreshToken(ctx context.Context, tokenHash string) (userID string, err error)
RevokeRefreshToken(ctx context.Context, tokenHash string) error
RevokeAllRefreshTokens(ctx context.Context, userID string) error
CleanExpiredTokens(ctx context.Context) error
}
// =========================================
// TEAM STORE
// =========================================
type TeamStore interface {
Create(ctx context.Context, t *models.Team) error
GetByID(ctx context.Context, id string) (*models.Team, error)
Update(ctx context.Context, id string, fields map[string]interface{}) error
Delete(ctx context.Context, id string) error
List(ctx context.Context) ([]models.Team, error)
// Members
AddMember(ctx context.Context, teamID, userID, role string) error
RemoveMember(ctx context.Context, teamID, userID string) error
UpdateMemberRole(ctx context.Context, teamID, userID, role string) error
ListMembers(ctx context.Context, teamID string) ([]models.TeamMember, error)
GetMember(ctx context.Context, teamID, userID string) (*models.TeamMember, error)
GetUserTeamIDs(ctx context.Context, userID string) ([]string, error)
IsTeamAdmin(ctx context.Context, teamID, userID string) (bool, error)
IsMember(ctx context.Context, teamID, userID string) (bool, error)
}
// =========================================
// CHANNEL STORE
// =========================================
type ChannelStore interface {
Create(ctx context.Context, ch *models.Channel) error
GetByID(ctx context.Context, id string) (*models.Channel, error)
Update(ctx context.Context, id string, fields map[string]interface{}) error
Delete(ctx context.Context, id string) error
ListForUser(ctx context.Context, userID string, opts ListOptions) ([]models.Channel, int, error)
Search(ctx context.Context, userID, query string, opts ListOptions) ([]models.Channel, int, error)
// Cursor management (conversation forking)
GetCursor(ctx context.Context, channelID, userID string) (*models.ChannelCursor, error)
SetCursor(ctx context.Context, channelID, userID, leafID string) error
// Channel models
SetModel(ctx context.Context, cm *models.ChannelModel) error
GetModels(ctx context.Context, channelID string) ([]models.ChannelModel, error)
// Ownership check
UserOwns(ctx context.Context, channelID, userID string) (bool, error)
}
// =========================================
// MESSAGE STORE
// =========================================
type MessageStore interface {
Create(ctx context.Context, m *models.Message) error
GetByID(ctx context.Context, id string) (*models.Message, error)
Update(ctx context.Context, id string, fields map[string]interface{}) error
Delete(ctx context.Context, id string) error // soft delete
ListForChannel(ctx context.Context, channelID string, opts ListOptions) ([]models.Message, error)
// Tree operations
GetChildren(ctx context.Context, parentID string) ([]models.Message, error)
GetSiblings(ctx context.Context, messageID string) ([]models.Message, error)
GetPathToRoot(ctx context.Context, messageID string) ([]models.Message, error)
GetNextSiblingIndex(ctx context.Context, parentID string) (int, error)
// Count
CountForChannel(ctx context.Context, channelID string) (int, error)
}
// =========================================
// AUDIT STORE
// =========================================
type AuditStore interface {
Log(ctx context.Context, entry *models.AuditEntry) error
List(ctx context.Context, opts AuditListOptions) ([]models.AuditEntry, int, error)
}
type AuditListOptions struct {
ListOptions
ActorID string
Action string
ResourceType string
ResourceID string
Since *time.Time
Until *time.Time
}
// =========================================
// NOTE STORE
// =========================================
type NoteStore interface {
Create(ctx context.Context, n *models.Note) error
GetByID(ctx context.Context, id string) (*models.Note, error)
Update(ctx context.Context, id string, fields map[string]interface{}) error
Delete(ctx context.Context, id string) error
ListForUser(ctx context.Context, userID string, opts NoteListOptions) ([]models.Note, int, error)
Search(ctx context.Context, userID, query string, opts ListOptions) ([]models.Note, int, error)
BulkDelete(ctx context.Context, ids []string, userID string) (int, error)
}
type NoteListOptions struct {
ListOptions
FolderPath string
Tag string
TeamID string
}
// =========================================
// GLOBAL CONFIG STORE
// =========================================
type GlobalConfigStore interface {
Get(ctx context.Context, key string) (models.JSONMap, error)
Set(ctx context.Context, key string, value models.JSONMap, updatedBy string) error
GetAll(ctx context.Context) (map[string]models.JSONMap, error)
}
// =========================================
// SHARED TYPES
// =========================================
// ListOptions provides standard pagination/sort for list queries.
type ListOptions struct {
Limit int
Offset int
Sort string // column name
Order string // "asc" or "desc"
}
// DefaultListOptions returns sensible defaults.
func DefaultListOptions() ListOptions {
return ListOptions{
Limit: 50,
Order: "desc",
}
}

View File

@@ -0,0 +1,82 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type AuditStore struct{}
func NewAuditStore() *AuditStore { return &AuditStore{} }
func (s *AuditStore) Log(ctx context.Context, entry *models.AuditEntry) error {
metadataJSON := ToJSON(entry.Metadata)
return DB.QueryRowContext(ctx, `
INSERT INTO audit_log (actor_id, action, resource_type, resource_id, metadata, ip_address, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created_at`,
models.NullString(entry.ActorID), entry.Action, entry.ResourceType,
entry.ResourceID, metadataJSON, entry.IPAddress, entry.UserAgent,
).Scan(&entry.ID, &entry.CreatedAt)
}
func (s *AuditStore) List(ctx context.Context, opts store.AuditListOptions) ([]models.AuditEntry, int, error) {
b := NewSelect("id, actor_id, action, resource_type, resource_id, metadata, ip_address, user_agent, created_at", "audit_log")
if opts.ActorID != "" {
b.Where("actor_id = ?", opts.ActorID)
}
if opts.Action != "" {
b.Where("action = ?", opts.Action)
}
if opts.ResourceType != "" {
b.Where("resource_type = ?", opts.ResourceType)
}
if opts.ResourceID != "" {
b.Where("resource_id = ?", opts.ResourceID)
}
if opts.Since != nil {
b.Where("created_at >= ?", *opts.Since)
}
if opts.Until != nil {
b.Where("created_at <= ?", *opts.Until)
}
// Count
countQ, countArgs := b.CountBuild()
var total int
DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total)
// Results
if opts.Sort == "" {
b.OrderBy("created_at", "DESC")
}
b.Paginate(opts.ListOptions)
q, args := b.Build()
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var result []models.AuditEntry
for rows.Next() {
var e models.AuditEntry
var actorID sql.NullString
var metadataJSON []byte
err := rows.Scan(&e.ID, &actorID, &e.Action, &e.ResourceType, &e.ResourceID,
&metadataJSON, &e.IPAddress, &e.UserAgent, &e.CreatedAt)
if err != nil {
return nil, 0, err
}
e.ActorID = NullableStringPtr(actorID)
json.Unmarshal(metadataJSON, &e.Metadata)
result = append(result, e)
}
return result, total, rows.Err()
}

View File

@@ -0,0 +1,224 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type CatalogStore struct{}
func NewCatalogStore() *CatalogStore { return &CatalogStore{} }
const catalogCols = `id, provider_config_id, model_id, display_name,
capabilities, pricing, visibility, last_synced_at, created_at, updated_at`
// catalogColsMC is catalogCols with mc. prefix for use in JOINs
// where id/created_at/updated_at are ambiguous.
const catalogColsMC = `mc.id, mc.provider_config_id, mc.model_id, mc.display_name,
mc.capabilities, mc.pricing, mc.visibility, mc.last_synced_at, mc.created_at, mc.updated_at`
// UpsertFromSync bulk-inserts or updates catalog entries from a provider API fetch.
// New models default to 'disabled' visibility (secure by default).
func (s *CatalogStore) UpsertFromSync(ctx context.Context, providerConfigID string, entries []store.CatalogSyncEntry) (added, updated int, err error) {
now := time.Now()
for _, e := range entries {
capsJSON := ToJSON(e.Capabilities)
var pricingJSON []byte
if e.Pricing != nil {
pricingJSON = ToJSON(e.Pricing)
}
var existingID string
err := DB.QueryRowContext(ctx,
"SELECT id FROM model_catalog WHERE provider_config_id = $1 AND model_id = $2",
providerConfigID, e.ModelID,
).Scan(&existingID)
if err == sql.ErrNoRows {
// Insert new (disabled by default)
_, err = DB.ExecContext(ctx, `
INSERT INTO model_catalog (provider_config_id, model_id, display_name,
capabilities, pricing, visibility, last_synced_at)
VALUES ($1, $2, $3, $4, $5, 'disabled', $6)`,
providerConfigID, e.ModelID, e.DisplayName, capsJSON, pricingJSON, now)
if err != nil {
return added, updated, fmt.Errorf("insert %s: %w", e.ModelID, err)
}
added++
} else if err == nil {
// Update existing (preserve visibility)
_, err = DB.ExecContext(ctx, `
UPDATE model_catalog SET display_name = $1, capabilities = $2,
pricing = $3, last_synced_at = $4
WHERE id = $5`,
e.DisplayName, capsJSON, pricingJSON, now, existingID)
if err != nil {
return added, updated, fmt.Errorf("update %s: %w", e.ModelID, err)
}
updated++
} else {
return added, updated, fmt.Errorf("check %s: %w", e.ModelID, err)
}
}
return added, updated, nil
}
func (s *CatalogStore) GetByID(ctx context.Context, id string) (*models.CatalogEntry, error) {
row := DB.QueryRowContext(ctx,
fmt.Sprintf("SELECT %s FROM model_catalog WHERE id = $1", catalogCols), id)
return scanCatalogEntry(row)
}
func (s *CatalogStore) GetByModelID(ctx context.Context, providerConfigID, modelID string) (*models.CatalogEntry, error) {
row := DB.QueryRowContext(ctx,
fmt.Sprintf("SELECT %s FROM model_catalog WHERE provider_config_id = $1 AND model_id = $2", catalogCols),
providerConfigID, modelID)
return scanCatalogEntry(row)
}
// GetByModelIDAny returns the most recently synced catalog entry for a model_id
// across any provider. Used to resolve capabilities for presets with auto-resolve
// (no specific provider_config_id).
func (s *CatalogStore) GetByModelIDAny(ctx context.Context, modelID string) (*models.CatalogEntry, error) {
row := DB.QueryRowContext(ctx,
fmt.Sprintf("SELECT %s FROM model_catalog WHERE model_id = $1 ORDER BY last_synced_at DESC NULLS LAST LIMIT 1", catalogCols),
modelID)
return scanCatalogEntry(row)
}
func (s *CatalogStore) ListVisible(ctx context.Context) ([]models.CatalogEntry, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf(`SELECT %s FROM model_catalog mc
JOIN provider_configs pc ON pc.id = mc.provider_config_id
WHERE mc.visibility = 'enabled' AND pc.scope = 'global' AND pc.is_active = true
ORDER BY mc.model_id`, catalogColsMC))
if err != nil {
return nil, err
}
defer rows.Close()
return scanCatalogEntries(rows)
}
func (s *CatalogStore) ListForProvider(ctx context.Context, providerConfigID string) ([]models.CatalogEntry, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM model_catalog WHERE provider_config_id = $1 ORDER BY model_id", catalogCols),
providerConfigID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanCatalogEntries(rows)
}
func (s *CatalogStore) ListEnabledForProvider(ctx context.Context, providerConfigID string) ([]models.CatalogEntry, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM model_catalog WHERE provider_config_id = $1 AND visibility = 'enabled' ORDER BY model_id", catalogCols),
providerConfigID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanCatalogEntries(rows)
}
func (s *CatalogStore) ListAll(ctx context.Context) ([]models.CatalogEntry, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM model_catalog ORDER BY model_id", catalogCols))
if err != nil {
return nil, err
}
defer rows.Close()
return scanCatalogEntries(rows)
}
func (s *CatalogStore) SetVisibility(ctx context.Context, id string, visibility string) error {
_, err := DB.ExecContext(ctx,
"UPDATE model_catalog SET visibility = $1 WHERE id = $2", visibility, id)
return err
}
func (s *CatalogStore) BulkSetVisibility(ctx context.Context, providerConfigID string, visibility string) error {
_, err := DB.ExecContext(ctx,
"UPDATE model_catalog SET visibility = $1 WHERE provider_config_id = $2",
visibility, providerConfigID)
return err
}
func (s *CatalogStore) BulkSetVisibilityAll(ctx context.Context, visibility string) error {
_, err := DB.ExecContext(ctx,
"UPDATE model_catalog SET visibility = $1", visibility)
return err
}
func (s *CatalogStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM model_catalog WHERE id = $1", id)
return err
}
func (s *CatalogStore) DeleteForProvider(ctx context.Context, providerConfigID string) error {
_, err := DB.ExecContext(ctx,
"DELETE FROM model_catalog WHERE provider_config_id = $1", providerConfigID)
return err
}
// ── Scanners ────────────────────────────────
func scanCatalogEntry(row *sql.Row) (*models.CatalogEntry, error) {
var e models.CatalogEntry
var capsJSON, pricingJSON []byte
var displayName sql.NullString
var lastSynced sql.NullTime
err := row.Scan(
&e.ID, &e.ProviderConfigID, &e.ModelID, &displayName,
&capsJSON, &pricingJSON, &e.Visibility, &lastSynced,
&e.CreatedAt, &e.UpdatedAt,
)
if err != nil {
return nil, err
}
e.DisplayName = NullableString(displayName)
json.Unmarshal(capsJSON, &e.Capabilities)
if len(pricingJSON) > 0 {
e.Pricing = &models.ModelPricing{}
json.Unmarshal(pricingJSON, e.Pricing)
}
if lastSynced.Valid {
e.LastSyncedAt = &lastSynced.Time
}
return &e, nil
}
func scanCatalogEntries(rows *sql.Rows) ([]models.CatalogEntry, error) {
result := make([]models.CatalogEntry, 0) // never nil — serializes as [] not null
for rows.Next() {
var e models.CatalogEntry
var capsJSON, pricingJSON []byte
var displayName sql.NullString
var lastSynced sql.NullTime
err := rows.Scan(
&e.ID, &e.ProviderConfigID, &e.ModelID, &displayName,
&capsJSON, &pricingJSON, &e.Visibility, &lastSynced,
&e.CreatedAt, &e.UpdatedAt,
)
if err != nil {
return nil, err
}
e.DisplayName = NullableString(displayName)
json.Unmarshal(capsJSON, &e.Capabilities)
if len(pricingJSON) > 0 {
e.Pricing = &models.ModelPricing{}
json.Unmarshal(pricingJSON, e.Pricing)
}
if lastSynced.Valid {
e.LastSyncedAt = &lastSynced.Time
}
result = append(result, e)
}
return result, rows.Err()
}

View File

@@ -0,0 +1,221 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type ChannelStore struct{}
func NewChannelStore() *ChannelStore { return &ChannelStore{} }
func (s *ChannelStore) Create(ctx context.Context, ch *models.Channel) error {
return DB.QueryRowContext(ctx, `
INSERT INTO channels (user_id, title, description, type, model, system_prompt,
provider_config_id, is_archived, is_pinned, folder_id, team_id, settings)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12)
RETURNING id, created_at, updated_at`,
ch.UserID, ch.Title, ch.Description, ch.Type, ch.Model, ch.SystemPrompt,
models.NullString(ch.ProviderConfigID), ch.IsArchived, ch.IsPinned,
models.NullString(ch.FolderID), models.NullString(ch.TeamID), ToJSON(ch.Settings),
).Scan(&ch.ID, &ch.CreatedAt, &ch.UpdatedAt)
}
func (s *ChannelStore) GetByID(ctx context.Context, id string) (*models.Channel, error) {
var ch models.Channel
var providerConfigID, folderID, teamID sql.NullString
var desc sql.NullString
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, user_id, title, description, type, model, system_prompt,
provider_config_id, is_archived, is_pinned, folder_id, team_id, settings,
created_at, updated_at
FROM channels WHERE id = $1`, id).Scan(
&ch.ID, &ch.UserID, &ch.Title, &desc, &ch.Type, &ch.Model, &ch.SystemPrompt,
&providerConfigID, &ch.IsArchived, &ch.IsPinned, &folderID, &teamID, &settingsJSON,
&ch.CreatedAt, &ch.UpdatedAt,
)
if err != nil {
return nil, err
}
ch.Description = NullableString(desc)
ch.ProviderConfigID = NullableStringPtr(providerConfigID)
ch.FolderID = NullableStringPtr(folderID)
ch.TeamID = NullableStringPtr(teamID)
json.Unmarshal(settingsJSON, &ch.Settings)
return &ch, nil
}
func (s *ChannelStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
b := NewUpdate("channels")
for k, v := range fields {
if k == "settings" || k == "tags" {
b.SetJSON(k, v)
} else {
b.Set(k, v)
}
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
_, err := b.Exec(DB)
return err
}
func (s *ChannelStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM channels WHERE id = $1", id)
return err
}
func (s *ChannelStore) ListForUser(ctx context.Context, userID string, opts store.ListOptions) ([]models.Channel, int, error) {
// Count
var total int
DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM channels WHERE user_id = $1", userID).Scan(&total)
b := NewSelect(
"id, user_id, title, description, type, model, system_prompt, provider_config_id, is_archived, is_pinned, folder_id, team_id, settings, created_at, updated_at",
"channels",
).Where("user_id = ?", userID)
if opts.Sort == "" {
b.OrderBy("updated_at", "DESC")
}
b.Paginate(opts)
q, args := b.Build()
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var result []models.Channel
for rows.Next() {
var ch models.Channel
var providerConfigID, folderID, teamID, desc sql.NullString
var settingsJSON []byte
err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &desc, &ch.Type, &ch.Model,
&ch.SystemPrompt, &providerConfigID, &ch.IsArchived, &ch.IsPinned,
&folderID, &teamID, &settingsJSON, &ch.CreatedAt, &ch.UpdatedAt)
if err != nil {
return nil, 0, err
}
ch.Description = NullableString(desc)
ch.ProviderConfigID = NullableStringPtr(providerConfigID)
ch.FolderID = NullableStringPtr(folderID)
ch.TeamID = NullableStringPtr(teamID)
json.Unmarshal(settingsJSON, &ch.Settings)
result = append(result, ch)
}
return result, total, rows.Err()
}
func (s *ChannelStore) Search(ctx context.Context, userID, query string, opts store.ListOptions) ([]models.Channel, int, error) {
// Simple title search for now — will add full-text when search feature lands
b := NewSelect(
"id, user_id, title, description, type, model, system_prompt, provider_config_id, is_archived, is_pinned, folder_id, team_id, settings, created_at, updated_at",
"channels",
).Where("user_id = ?", userID).Where("title ILIKE ?", "%"+query+"%")
b.OrderBy("updated_at", "DESC")
b.Paginate(opts)
var total int
DB.QueryRowContext(ctx,
"SELECT COUNT(*) FROM channels WHERE user_id = $1 AND title ILIKE $2",
userID, "%"+query+"%").Scan(&total)
q, args := b.Build()
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var result []models.Channel
for rows.Next() {
var ch models.Channel
var providerConfigID, folderID, teamID, desc sql.NullString
var settingsJSON []byte
err := rows.Scan(&ch.ID, &ch.UserID, &ch.Title, &desc, &ch.Type, &ch.Model,
&ch.SystemPrompt, &providerConfigID, &ch.IsArchived, &ch.IsPinned,
&folderID, &teamID, &settingsJSON, &ch.CreatedAt, &ch.UpdatedAt)
if err != nil {
return nil, 0, err
}
ch.Description = NullableString(desc)
ch.ProviderConfigID = NullableStringPtr(providerConfigID)
ch.FolderID = NullableStringPtr(folderID)
ch.TeamID = NullableStringPtr(teamID)
json.Unmarshal(settingsJSON, &ch.Settings)
result = append(result, ch)
}
return result, total, rows.Err()
}
func (s *ChannelStore) GetCursor(ctx context.Context, channelID, userID string) (*models.ChannelCursor, error) {
var c models.ChannelCursor
var leafID sql.NullString
err := DB.QueryRowContext(ctx, `
SELECT id, channel_id, user_id, active_leaf_id, updated_at
FROM channel_cursors WHERE channel_id = $1 AND user_id = $2`,
channelID, userID).Scan(&c.ID, &c.ChannelID, &c.UserID, &leafID, &c.UpdatedAt)
if err != nil {
return nil, err
}
c.ActiveLeafID = NullableStringPtr(leafID)
return &c, nil
}
func (s *ChannelStore) SetCursor(ctx context.Context, channelID, userID, leafID string) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO channel_cursors (channel_id, user_id, active_leaf_id)
VALUES ($1, $2, $3)
ON CONFLICT (channel_id, user_id) DO UPDATE SET active_leaf_id = $3, updated_at = NOW()`,
channelID, userID, leafID)
return err
}
func (s *ChannelStore) SetModel(ctx context.Context, cm *models.ChannelModel) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO channel_models (channel_id, model_id, provider_config_id, display_name, system_prompt, settings, is_default)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (channel_id, model_id) DO UPDATE SET
provider_config_id = $3, display_name = $4, system_prompt = $5, settings = $6, is_default = $7`,
cm.ChannelID, cm.ModelID, cm.ProviderConfigID, cm.DisplayName, cm.SystemPrompt, "{}", cm.IsDefault)
return err
}
func (s *ChannelStore) GetModels(ctx context.Context, channelID string) ([]models.ChannelModel, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, channel_id, model_id, COALESCE(provider_config_id::text, ''),
COALESCE(display_name, ''), COALESCE(system_prompt, ''), is_default
FROM channel_models WHERE channel_id = $1`, channelID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.ChannelModel
for rows.Next() {
var cm models.ChannelModel
if err := rows.Scan(&cm.ID, &cm.ChannelID, &cm.ModelID, &cm.ProviderConfigID,
&cm.DisplayName, &cm.SystemPrompt, &cm.IsDefault); err != nil {
return nil, err
}
result = append(result, cm)
}
return result, rows.Err()
}
func (s *ChannelStore) UserOwns(ctx context.Context, channelID, userID string) (bool, error) {
var exists bool
err := DB.QueryRowContext(ctx,
"SELECT EXISTS(SELECT 1 FROM channels WHERE id = $1 AND user_id = $2)",
channelID, userID).Scan(&exists)
return exists, err
}

View File

@@ -0,0 +1,59 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type GlobalConfigStore struct{}
func NewGlobalConfigStore() *GlobalConfigStore { return &GlobalConfigStore{} }
func (s *GlobalConfigStore) Get(ctx context.Context, key string) (models.JSONMap, error) {
var valueJSON []byte
err := DB.QueryRowContext(ctx,
"SELECT value FROM global_settings WHERE key = $1", key).Scan(&valueJSON)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
var result models.JSONMap
json.Unmarshal(valueJSON, &result)
return result, nil
}
func (s *GlobalConfigStore) Set(ctx context.Context, key string, value models.JSONMap, updatedBy string) error {
valueJSON := ToJSON(value)
_, err := DB.ExecContext(ctx, `
INSERT INTO global_settings (key, value, updated_by, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_by = $3, updated_at = NOW()`,
key, valueJSON, updatedBy)
return err
}
func (s *GlobalConfigStore) GetAll(ctx context.Context) (map[string]models.JSONMap, error) {
rows, err := DB.QueryContext(ctx, "SELECT key, value FROM global_settings")
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]models.JSONMap)
for rows.Next() {
var key string
var valueJSON []byte
if err := rows.Scan(&key, &valueJSON); err != nil {
continue
}
var m models.JSONMap
json.Unmarshal(valueJSON, &m)
result[key] = m
}
return result, rows.Err()
}

View File

@@ -0,0 +1,253 @@
package postgres
import (
"database/sql"
"encoding/json"
"fmt"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// DB is the shared database connection pool.
// Set during initialization via SetDB.
var DB *sql.DB
// SetDB configures the shared database connection for all stores.
func SetDB(db *sql.DB) {
DB = db
}
// ── Dynamic SQL Builder ─────────────────────
// Replaces the copy-pasted addClause/addField pattern
// found in admin.go, presets.go, team_providers.go, apiconfigs.go.
// UpdateBuilder constructs a dynamic UPDATE statement.
type UpdateBuilder struct {
table string
sets []string
args []interface{}
where []string
argIdx int
}
// NewUpdate creates an UpdateBuilder for the given table.
func NewUpdate(table string) *UpdateBuilder {
return &UpdateBuilder{table: table}
}
// Set adds a column=value pair to the UPDATE.
func (b *UpdateBuilder) Set(col string, val interface{}) *UpdateBuilder {
b.argIdx++
b.sets = append(b.sets, fmt.Sprintf("%s = $%d", col, b.argIdx))
b.args = append(b.args, val)
return b
}
// SetJSON adds a JSONB column from a map.
func (b *UpdateBuilder) SetJSON(col string, val interface{}) *UpdateBuilder {
data, err := json.Marshal(val)
if err != nil {
data = []byte("{}")
}
return b.Set(col, string(data))
}
// SetIf conditionally adds a column if the pointer is non-nil.
func (b *UpdateBuilder) SetIf(col string, val interface{}, set bool) *UpdateBuilder {
if set {
return b.Set(col, val)
}
return b
}
// Where adds a WHERE condition.
func (b *UpdateBuilder) Where(col string, val interface{}) *UpdateBuilder {
b.argIdx++
b.where = append(b.where, fmt.Sprintf("%s = $%d", col, b.argIdx))
b.args = append(b.args, val)
return b
}
// HasSets returns true if any SET clauses were added.
func (b *UpdateBuilder) HasSets() bool {
return len(b.sets) > 0
}
// Build returns the SQL string and args.
func (b *UpdateBuilder) Build() (string, []interface{}) {
sql := fmt.Sprintf("UPDATE %s SET %s", b.table, strings.Join(b.sets, ", "))
if len(b.where) > 0 {
sql += " WHERE " + strings.Join(b.where, " AND ")
}
return sql, b.args
}
// Exec executes the built UPDATE.
func (b *UpdateBuilder) Exec(db *sql.DB) (sql.Result, error) {
q, args := b.Build()
return db.Exec(q, args...)
}
// ── Query Builder ───────────────────────────
// SelectBuilder constructs a dynamic SELECT statement.
type SelectBuilder struct {
cols string
table string
joins []string
where []string
args []interface{}
orderBy string
limit int
offset int
argIdx int
}
// NewSelect creates a SelectBuilder.
func NewSelect(cols, table string) *SelectBuilder {
return &SelectBuilder{cols: cols, table: table}
}
// Join adds a JOIN clause.
func (b *SelectBuilder) Join(join string) *SelectBuilder {
b.joins = append(b.joins, join)
return b
}
// Where adds a WHERE condition with a parameter.
func (b *SelectBuilder) Where(clause string, args ...interface{}) *SelectBuilder {
for _, arg := range args {
b.argIdx++
clause = strings.Replace(clause, "?", fmt.Sprintf("$%d", b.argIdx), 1)
b.args = append(b.args, arg)
}
b.where = append(b.where, clause)
return b
}
// WhereRaw adds a WHERE condition without parameters.
func (b *SelectBuilder) WhereRaw(clause string) *SelectBuilder {
b.where = append(b.where, clause)
return b
}
// OrderBy sets the ORDER BY clause.
func (b *SelectBuilder) OrderBy(col, order string) *SelectBuilder {
if order == "" {
order = "DESC"
}
b.orderBy = fmt.Sprintf("%s %s", col, strings.ToUpper(order))
return b
}
// Paginate sets LIMIT and OFFSET from ListOptions.
func (b *SelectBuilder) Paginate(opts store.ListOptions) *SelectBuilder {
if opts.Limit > 0 {
b.limit = opts.Limit
}
if opts.Offset > 0 {
b.offset = opts.Offset
}
if opts.Sort != "" {
b.OrderBy(opts.Sort, opts.Order)
}
return b
}
// Build returns the SQL string and args.
func (b *SelectBuilder) Build() (string, []interface{}) {
q := fmt.Sprintf("SELECT %s FROM %s", b.cols, b.table)
for _, j := range b.joins {
q += " " + j
}
if len(b.where) > 0 {
q += " WHERE " + strings.Join(b.where, " AND ")
}
if b.orderBy != "" {
q += " ORDER BY " + b.orderBy
}
if b.limit > 0 {
q += fmt.Sprintf(" LIMIT %d", b.limit)
}
if b.offset > 0 {
q += fmt.Sprintf(" OFFSET %d", b.offset)
}
return q, b.args
}
// CountBuild returns a SELECT COUNT(*) version of the query (no order/limit).
func (b *SelectBuilder) CountBuild() (string, []interface{}) {
q := fmt.Sprintf("SELECT COUNT(*) FROM %s", b.table)
for _, j := range b.joins {
q += " " + j
}
if len(b.where) > 0 {
q += " WHERE " + strings.Join(b.where, " AND ")
}
return q, b.args
}
// ── JSONB Helpers ───────────────────────────
// ToJSON marshals a value to JSON bytes for JSONB columns.
func ToJSON(v interface{}) []byte {
if v == nil {
return []byte("{}")
}
b, err := json.Marshal(v)
if err != nil {
return []byte("{}")
}
return b
}
// ScanJSON scans a JSONB column into a target.
func ScanJSON(src interface{}, dst interface{}) error {
if src == nil {
return nil
}
var data []byte
switch v := src.(type) {
case []byte:
data = v
case string:
data = []byte(v)
default:
return fmt.Errorf("unsupported JSONB type: %T", src)
}
return json.Unmarshal(data, dst)
}
// NullableString returns the string value or empty string from sql.NullString.
func NullableString(ns sql.NullString) string {
if ns.Valid {
return ns.String
}
return ""
}
// NullableStringPtr returns a *string from sql.NullString (nil if not valid).
func NullableStringPtr(ns sql.NullString) *string {
if ns.Valid {
return &ns.String
}
return nil
}
// NullableFloat64Ptr returns a *float64 from sql.NullFloat64.
func NullableFloat64Ptr(nf sql.NullFloat64) *float64 {
if nf.Valid {
return &nf.Float64
}
return nil
}
// NullableIntPtr returns an *int from sql.NullInt64.
func NullableIntPtr(ni sql.NullInt64) *int {
if ni.Valid {
v := int(ni.Int64)
return &v
}
return nil
}

View File

@@ -0,0 +1,191 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type MessageStore struct{}
func NewMessageStore() *MessageStore { return &MessageStore{} }
func (s *MessageStore) Create(ctx context.Context, m *models.Message) error {
toolCallsJSON := ToJSON(m.ToolCalls)
metadataJSON := ToJSON(m.Metadata)
return DB.QueryRowContext(ctx, `
INSERT INTO messages (channel_id, role, content, model, tokens_used, tool_calls,
metadata, parent_id, sibling_index, participant_type, participant_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
RETURNING id, created_at`,
m.ChannelID, m.Role, m.Content, m.Model, m.TokensUsed,
toolCallsJSON, metadataJSON,
models.NullString(m.ParentID), m.SiblingIndex,
m.ParticipantType, m.ParticipantID,
).Scan(&m.ID, &m.CreatedAt)
}
func (s *MessageStore) GetByID(ctx context.Context, id string) (*models.Message, error) {
var m models.Message
var parentID sql.NullString
var toolCallsJSON, metadataJSON []byte
var deletedAt sql.NullTime
err := DB.QueryRowContext(ctx, `
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
FROM messages WHERE id = $1`, id).Scan(
&m.ID, &m.ChannelID, &m.Role, &m.Content, &m.Model, &m.TokensUsed,
&toolCallsJSON, &metadataJSON,
&parentID, &m.SiblingIndex, &m.ParticipantType, &m.ParticipantID,
&deletedAt, &m.CreatedAt,
)
if err != nil {
return nil, err
}
m.ParentID = NullableStringPtr(parentID)
json.Unmarshal(toolCallsJSON, &m.ToolCalls)
json.Unmarshal(metadataJSON, &m.Metadata)
if deletedAt.Valid {
m.DeletedAt = &deletedAt.Time
}
return &m, nil
}
func (s *MessageStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
b := NewUpdate("messages")
for k, v := range fields {
if k == "tool_calls" || k == "metadata" {
b.SetJSON(k, v)
} else {
b.Set(k, v)
}
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
_, err := b.Exec(DB)
return err
}
func (s *MessageStore) Delete(ctx context.Context, id string) error {
now := time.Now()
_, err := DB.ExecContext(ctx, "UPDATE messages SET deleted_at = $1 WHERE id = $2", now, id)
return err
}
func (s *MessageStore) ListForChannel(ctx context.Context, channelID string, opts store.ListOptions) ([]models.Message, error) {
b := NewSelect(
"id, channel_id, role, content, model, tokens_used, tool_calls, metadata, parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at",
"messages",
).Where("channel_id = ?", channelID).WhereRaw("deleted_at IS NULL")
if opts.Sort == "" {
b.OrderBy("created_at", "ASC")
}
b.Paginate(opts)
q, args := b.Build()
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMessages(rows)
}
func (s *MessageStore) GetChildren(ctx context.Context, parentID string) ([]models.Message, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
FROM messages WHERE parent_id = $1 AND deleted_at IS NULL
ORDER BY sibling_index`, parentID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMessages(rows)
}
func (s *MessageStore) GetSiblings(ctx context.Context, messageID string) ([]models.Message, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
FROM messages
WHERE parent_id = (SELECT parent_id FROM messages WHERE id = $1)
AND deleted_at IS NULL
ORDER BY sibling_index`, messageID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMessages(rows)
}
func (s *MessageStore) GetPathToRoot(ctx context.Context, messageID string) ([]models.Message, error) {
rows, err := DB.QueryContext(ctx, `
WITH RECURSIVE path AS (
SELECT id, channel_id, role, content, model, tokens_used, tool_calls, metadata,
parent_id, sibling_index, participant_type, participant_id, deleted_at, created_at
FROM messages WHERE id = $1
UNION ALL
SELECT m.id, m.channel_id, m.role, m.content, m.model, m.tokens_used, m.tool_calls, m.metadata,
m.parent_id, m.sibling_index, m.participant_type, m.participant_id, m.deleted_at, m.created_at
FROM messages m JOIN path p ON m.id = p.parent_id
)
SELECT * FROM path ORDER BY created_at ASC`, messageID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanMessages(rows)
}
func (s *MessageStore) GetNextSiblingIndex(ctx context.Context, parentID string) (int, error) {
var maxIdx sql.NullInt64
err := DB.QueryRowContext(ctx,
"SELECT MAX(sibling_index) FROM messages WHERE parent_id = $1 AND deleted_at IS NULL",
parentID).Scan(&maxIdx)
if err != nil || !maxIdx.Valid {
return 0, err
}
return int(maxIdx.Int64) + 1, nil
}
func (s *MessageStore) CountForChannel(ctx context.Context, channelID string) (int, error) {
var count int
err := DB.QueryRowContext(ctx,
"SELECT COUNT(*) FROM messages WHERE channel_id = $1 AND deleted_at IS NULL",
channelID).Scan(&count)
return count, err
}
func scanMessages(rows *sql.Rows) ([]models.Message, error) {
var result []models.Message
for rows.Next() {
var m models.Message
var parentID sql.NullString
var toolCallsJSON, metadataJSON []byte
var deletedAt sql.NullTime
err := rows.Scan(
&m.ID, &m.ChannelID, &m.Role, &m.Content, &m.Model, &m.TokensUsed,
&toolCallsJSON, &metadataJSON,
&parentID, &m.SiblingIndex, &m.ParticipantType, &m.ParticipantID,
&deletedAt, &m.CreatedAt,
)
if err != nil {
return nil, err
}
m.ParentID = NullableStringPtr(parentID)
json.Unmarshal(toolCallsJSON, &m.ToolCalls)
json.Unmarshal(metadataJSON, &m.Metadata)
if deletedAt.Valid {
m.DeletedAt = &deletedAt.Time
}
result = append(result, m)
}
return result, rows.Err()
}

View File

@@ -0,0 +1,191 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
"github.com/lib/pq"
)
type NoteStore struct{}
func NewNoteStore() *NoteStore { return &NoteStore{} }
func (s *NoteStore) Create(ctx context.Context, n *models.Note) error {
return DB.QueryRowContext(ctx, `
INSERT INTO notes (user_id, title, content, folder_path, tags, metadata, source_channel_id, team_id)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8)
RETURNING id, created_at, updated_at`,
n.UserID, n.Title, n.Content, n.FolderPath,
pq.Array(n.Tags), ToJSON(n.Metadata),
models.NullString(n.SourceChannelID), models.NullString(n.TeamID),
).Scan(&n.ID, &n.CreatedAt, &n.UpdatedAt)
}
func (s *NoteStore) GetByID(ctx context.Context, id string) (*models.Note, error) {
var n models.Note
var sourceChannelID, teamID sql.NullString
var metadataJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, user_id, title, content, folder_path, tags, metadata,
source_channel_id, team_id, created_at, updated_at
FROM notes WHERE id = $1`, id).Scan(
&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
pq.Array(&n.Tags), &metadataJSON,
&sourceChannelID, &teamID, &n.CreatedAt, &n.UpdatedAt,
)
if err != nil {
return nil, err
}
n.SourceChannelID = NullableStringPtr(sourceChannelID)
n.TeamID = NullableStringPtr(teamID)
json.Unmarshal(metadataJSON, &n.Metadata)
return &n, nil
}
func (s *NoteStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
b := NewUpdate("notes")
for k, v := range fields {
if k == "metadata" {
b.SetJSON(k, v)
} else if k == "tags" {
if tags, ok := v.([]string); ok {
b.Set(k, pq.Array(tags))
}
} else {
b.Set(k, v)
}
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
_, err := b.Exec(DB)
return err
}
func (s *NoteStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM notes WHERE id = $1", id)
return err
}
func (s *NoteStore) ListForUser(ctx context.Context, userID string, opts store.NoteListOptions) ([]models.Note, int, error) {
b := NewSelect(
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, team_id, created_at, updated_at",
"notes",
).Where("user_id = ?", userID)
if opts.FolderPath != "" {
b.Where("folder_path = ?", opts.FolderPath)
}
if opts.Tag != "" {
b.Where("? = ANY(tags)", opts.Tag)
}
if opts.TeamID != "" {
b.Where("team_id = ?", opts.TeamID)
}
// Count
countQ, countArgs := b.CountBuild()
var total int
DB.QueryRowContext(ctx, countQ, countArgs...).Scan(&total)
if opts.Sort == "" {
b.OrderBy("updated_at", "DESC")
}
b.Paginate(opts.ListOptions)
q, args := b.Build()
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var result []models.Note
for rows.Next() {
var n models.Note
var sourceChannelID, teamID sql.NullString
var metadataJSON []byte
err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
pq.Array(&n.Tags), &metadataJSON,
&sourceChannelID, &teamID, &n.CreatedAt, &n.UpdatedAt)
if err != nil {
return nil, 0, err
}
n.SourceChannelID = NullableStringPtr(sourceChannelID)
n.TeamID = NullableStringPtr(teamID)
json.Unmarshal(metadataJSON, &n.Metadata)
result = append(result, n)
}
return result, total, rows.Err()
}
func (s *NoteStore) Search(ctx context.Context, userID, query string, opts store.ListOptions) ([]models.Note, int, error) {
tsQuery := strings.Join(strings.Fields(query), " & ")
b := NewSelect(
"id, user_id, title, content, folder_path, tags, metadata, source_channel_id, team_id, created_at, updated_at",
"notes",
).Where("user_id = ?", userID).Where("search_vector @@ to_tsquery('english', ?)", tsQuery)
b.OrderBy("ts_rank(search_vector, to_tsquery('english', '"+tsQuery+"'))", "DESC")
b.Paginate(opts)
var total int
DB.QueryRowContext(ctx,
"SELECT COUNT(*) FROM notes WHERE user_id = $1 AND search_vector @@ to_tsquery('english', $2)",
userID, tsQuery).Scan(&total)
q, args := b.Build()
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var result []models.Note
for rows.Next() {
var n models.Note
var sourceChannelID, teamID sql.NullString
var metadataJSON []byte
err := rows.Scan(&n.ID, &n.UserID, &n.Title, &n.Content, &n.FolderPath,
pq.Array(&n.Tags), &metadataJSON,
&sourceChannelID, &teamID, &n.CreatedAt, &n.UpdatedAt)
if err != nil {
return nil, 0, err
}
n.SourceChannelID = NullableStringPtr(sourceChannelID)
n.TeamID = NullableStringPtr(teamID)
json.Unmarshal(metadataJSON, &n.Metadata)
result = append(result, n)
}
return result, total, rows.Err()
}
func (s *NoteStore) BulkDelete(ctx context.Context, ids []string, userID string) (int, error) {
if len(ids) == 0 {
return 0, nil
}
placeholders := make([]string, len(ids))
args := make([]interface{}, 0, len(ids)+1)
args = append(args, userID)
for i, id := range ids {
placeholders[i] = fmt.Sprintf("$%d", i+2)
args = append(args, id)
}
result, err := DB.ExecContext(ctx,
fmt.Sprintf("DELETE FROM notes WHERE user_id = $1 AND id IN (%s)",
strings.Join(placeholders, ",")),
args...)
if err != nil {
return 0, err
}
n, _ := result.RowsAffected()
return int(n), nil
}

View File

@@ -0,0 +1,295 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type PersonaStore struct{}
func NewPersonaStore() *PersonaStore { return &PersonaStore{} }
const personaCols = `id, name, description, icon, avatar, base_model_id, provider_config_id,
system_prompt, temperature, max_tokens, thinking_budget, top_p,
scope, owner_id, created_by, is_active, is_shared, created_at, updated_at`
func (s *PersonaStore) Create(ctx context.Context, p *models.Persona) error {
return DB.QueryRowContext(ctx, `
INSERT INTO personas (name, description, icon, avatar, base_model_id, provider_config_id,
system_prompt, temperature, max_tokens, thinking_budget, top_p,
scope, owner_id, created_by, is_active, is_shared)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)
RETURNING id, created_at, updated_at`,
p.Name, p.Description, p.Icon, p.Avatar, p.BaseModelID,
models.NullString(p.ProviderConfigID),
p.SystemPrompt, models.NullFloat(p.Temperature), models.NullInt(p.MaxTokens),
models.NullInt(p.ThinkingBudget), models.NullFloat(p.TopP),
p.Scope, models.NullString(p.OwnerID), p.CreatedBy, p.IsActive, p.IsShared,
).Scan(&p.ID, &p.CreatedAt, &p.UpdatedAt)
}
func (s *PersonaStore) GetByID(ctx context.Context, id string) (*models.Persona, error) {
row := DB.QueryRowContext(ctx,
fmt.Sprintf("SELECT %s FROM personas WHERE id = $1", personaCols), id)
p, err := scanPersona(row)
if err != nil {
return nil, err
}
// Load grants
grants, _ := s.GetGrants(ctx, id)
p.Grants = grants
return p, nil
}
func (s *PersonaStore) Update(ctx context.Context, id string, patch models.PersonaPatch) error {
b := NewUpdate("personas")
if patch.Name != nil {
b.Set("name", *patch.Name)
}
if patch.Description != nil {
b.Set("description", *patch.Description)
}
if patch.Icon != nil {
b.Set("icon", *patch.Icon)
}
if patch.Avatar != nil {
b.Set("avatar", *patch.Avatar)
}
if patch.BaseModelID != nil {
b.Set("base_model_id", *patch.BaseModelID)
}
if patch.ProviderConfigID != nil {
b.Set("provider_config_id", models.NullString(patch.ProviderConfigID))
}
if patch.SystemPrompt != nil {
b.Set("system_prompt", *patch.SystemPrompt)
}
if patch.Temperature != nil {
b.Set("temperature", models.NullFloat(patch.Temperature))
}
if patch.MaxTokens != nil {
b.Set("max_tokens", models.NullInt(patch.MaxTokens))
}
if patch.ThinkingBudget != nil {
b.Set("thinking_budget", models.NullInt(patch.ThinkingBudget))
}
if patch.TopP != nil {
b.Set("top_p", models.NullFloat(patch.TopP))
}
if patch.IsActive != nil {
b.Set("is_active", *patch.IsActive)
}
if patch.IsShared != nil {
b.Set("is_shared", *patch.IsShared)
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
_, err := b.Exec(DB)
return err
}
func (s *PersonaStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM personas WHERE id = $1", id)
return err
}
// ListForUser returns all Personas visible to a user:
// global active + team-scoped (for user's teams) + personal + shared.
func (s *PersonaStore) ListForUser(ctx context.Context, userID string) ([]models.Persona, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf(`SELECT %s FROM personas WHERE is_active = true AND (
scope = 'global'
OR (scope = 'personal' AND created_by = $1)
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = $1
))
OR (scope = 'personal' AND is_shared = true)
) ORDER BY scope, name`, personaCols), userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPersonas(rows)
}
func (s *PersonaStore) ListForTeam(ctx context.Context, teamID string) ([]models.Persona, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM personas WHERE scope = 'team' AND owner_id = $1 AND is_active = true ORDER BY name", personaCols),
teamID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPersonas(rows)
}
func (s *PersonaStore) ListGlobal(ctx context.Context) ([]models.Persona, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM personas WHERE scope = 'global' ORDER BY name", personaCols))
if err != nil {
return nil, err
}
defer rows.Close()
return scanPersonas(rows)
}
func (s *PersonaStore) ListPersonal(ctx context.Context, userID string) ([]models.Persona, error) {
rows, err := DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM personas WHERE scope = 'personal' AND created_by = $1 ORDER BY name", personaCols),
userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPersonas(rows)
}
// ── Grants ──────────────────────────────────
// SetGrants replaces all grants for a Persona (delete + re-insert).
func (s *PersonaStore) SetGrants(ctx context.Context, personaID string, grants []models.Grant) error {
tx, err := DB.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.ExecContext(ctx, "DELETE FROM persona_grants WHERE persona_id = $1", personaID)
if err != nil {
return err
}
for _, g := range grants {
configJSON := ToJSON(g.Config)
_, err = tx.ExecContext(ctx, `
INSERT INTO persona_grants (persona_id, grant_type, grant_ref, config)
VALUES ($1, $2, $3, $4)`,
personaID, g.GrantType, g.GrantRef, configJSON)
if err != nil {
return fmt.Errorf("grant %s/%s: %w", g.GrantType, g.GrantRef, err)
}
}
return tx.Commit()
}
func (s *PersonaStore) GetGrants(ctx context.Context, personaID string) ([]models.Grant, error) {
rows, err := DB.QueryContext(ctx,
"SELECT id, persona_id, grant_type, grant_ref, config, created_at FROM persona_grants WHERE persona_id = $1 ORDER BY grant_type, grant_ref",
personaID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.Grant
for rows.Next() {
var g models.Grant
var configJSON []byte
err := rows.Scan(&g.ID, &g.PersonaID, &g.GrantType, &g.GrantRef, &configJSON, &g.CreatedAt)
if err != nil {
return nil, err
}
json.Unmarshal(configJSON, &g.Config)
result = append(result, g)
}
return result, rows.Err()
}
// GetToolGrants returns just the tool names for a Persona.
func (s *PersonaStore) GetToolGrants(ctx context.Context, personaID string) ([]string, error) {
rows, err := DB.QueryContext(ctx,
"SELECT grant_ref FROM persona_grants WHERE persona_id = $1 AND grant_type = 'tool' ORDER BY grant_ref",
personaID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
result = append(result, name)
}
return result, rows.Err()
}
// UserCanAccess checks if a user can see/use a specific Persona.
func (s *PersonaStore) UserCanAccess(ctx context.Context, userID, personaID string) (bool, error) {
var exists bool
err := DB.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM personas WHERE id = $2 AND is_active = true AND (
scope = 'global'
OR (scope = 'personal' AND created_by = $1)
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = $1
))
OR (scope = 'personal' AND is_shared = true)
)
)`, userID, personaID).Scan(&exists)
return exists, err
}
// ── Scanners ────────────────────────────────
func scanPersona(row *sql.Row) (*models.Persona, error) {
var p models.Persona
var providerConfigID, ownerID sql.NullString
var temp, topP sql.NullFloat64
var maxTokens, thinkingBudget sql.NullInt64
err := row.Scan(
&p.ID, &p.Name, &p.Description, &p.Icon, &p.Avatar,
&p.BaseModelID, &providerConfigID,
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
&p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
return nil, err
}
p.ProviderConfigID = NullableStringPtr(providerConfigID)
p.OwnerID = NullableStringPtr(ownerID)
p.Temperature = NullableFloat64Ptr(temp)
p.MaxTokens = NullableIntPtr(maxTokens)
p.ThinkingBudget = NullableIntPtr(thinkingBudget)
p.TopP = NullableFloat64Ptr(topP)
return &p, nil
}
func scanPersonas(rows *sql.Rows) ([]models.Persona, error) {
var result []models.Persona
for rows.Next() {
var p models.Persona
var providerConfigID, ownerID sql.NullString
var temp, topP sql.NullFloat64
var maxTokens, thinkingBudget sql.NullInt64
err := rows.Scan(
&p.ID, &p.Name, &p.Description, &p.Icon, &p.Avatar,
&p.BaseModelID, &providerConfigID,
&p.SystemPrompt, &temp, &maxTokens, &thinkingBudget, &topP,
&p.Scope, &ownerID, &p.CreatedBy, &p.IsActive, &p.IsShared,
&p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
return nil, err
}
p.ProviderConfigID = NullableStringPtr(providerConfigID)
p.OwnerID = NullableStringPtr(ownerID)
p.Temperature = NullableFloat64Ptr(temp)
p.MaxTokens = NullableIntPtr(maxTokens)
p.ThinkingBudget = NullableIntPtr(thinkingBudget)
p.TopP = NullableFloat64Ptr(topP)
result = append(result, p)
}
return result, rows.Err()
}

View File

@@ -0,0 +1,68 @@
package postgres
import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type PolicyStore struct{}
func NewPolicyStore() *PolicyStore { return &PolicyStore{} }
// Get returns a policy value by key. Falls back to PolicyDefaults if not in DB.
func (s *PolicyStore) Get(ctx context.Context, key string) (string, error) {
var value string
err := DB.QueryRowContext(ctx,
"SELECT value FROM platform_policies WHERE key = $1", key).Scan(&value)
if err == sql.ErrNoRows {
if def, ok := models.PolicyDefaults[key]; ok {
return def, nil
}
return "", nil
}
return value, err
}
// GetBool returns a policy value as a boolean.
func (s *PolicyStore) GetBool(ctx context.Context, key string) (bool, error) {
val, err := s.Get(ctx, key)
if err != nil {
return false, err
}
return val == "true", nil
}
// Set upserts a policy value.
func (s *PolicyStore) Set(ctx context.Context, key, value, updatedBy string) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO platform_policies (key, value, updated_by, updated_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (key) DO UPDATE SET value = $2, updated_by = $3, updated_at = NOW()`,
key, value, updatedBy)
return err
}
// GetAll returns all platform policies, merged with defaults.
func (s *PolicyStore) GetAll(ctx context.Context) (map[string]string, error) {
result := make(map[string]string)
// Start with defaults
for k, v := range models.PolicyDefaults {
result[k] = v
}
// Override with DB values
rows, err := DB.QueryContext(ctx, "SELECT key, value FROM platform_policies")
if err != nil {
return result, err // return defaults on error
}
defer rows.Close()
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
continue
}
result[k] = v
}
return result, rows.Err()
}

View File

@@ -0,0 +1,194 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type ProviderStore struct{}
func NewProviderStore() *ProviderStore { return &ProviderStore{} }
const providerCols = `id, scope, owner_id, name, provider, endpoint, api_key_enc,
model_default, config, headers, settings, is_active, is_private, created_at, updated_at`
func (s *ProviderStore) Create(ctx context.Context, cfg *models.ProviderConfig) error {
return DB.QueryRowContext(ctx, `
INSERT INTO provider_configs (scope, owner_id, name, provider, endpoint, api_key_enc,
model_default, config, headers, settings, is_active, is_private)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id, created_at, updated_at`,
cfg.Scope, models.NullString(cfg.OwnerID), cfg.Name, cfg.Provider, cfg.Endpoint,
cfg.APIKeyEnc, cfg.ModelDefault,
ToJSON(cfg.Config), ToJSON(cfg.Headers), ToJSON(cfg.Settings),
cfg.IsActive, cfg.IsPrivate,
).Scan(&cfg.ID, &cfg.CreatedAt, &cfg.UpdatedAt)
}
func (s *ProviderStore) GetByID(ctx context.Context, id string) (*models.ProviderConfig, error) {
row := DB.QueryRowContext(ctx,
fmt.Sprintf("SELECT %s FROM provider_configs WHERE id = $1", providerCols), id)
return scanProvider(row)
}
func (s *ProviderStore) Update(ctx context.Context, id string, patch models.ProviderConfigPatch) error {
b := NewUpdate("provider_configs")
if patch.Name != nil {
b.Set("name", *patch.Name)
}
if patch.Endpoint != nil {
b.Set("endpoint", *patch.Endpoint)
}
if patch.APIKeyEnc != nil {
b.Set("api_key_enc", *patch.APIKeyEnc)
}
if patch.ModelDefault != nil {
b.Set("model_default", *patch.ModelDefault)
}
if patch.Config != nil {
b.SetJSON("config", patch.Config)
}
if patch.Headers != nil {
b.SetJSON("headers", patch.Headers)
}
if patch.Settings != nil {
b.SetJSON("settings", patch.Settings)
}
if patch.IsActive != nil {
b.Set("is_active", *patch.IsActive)
}
if patch.IsPrivate != nil {
b.Set("is_private", *patch.IsPrivate)
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
_, err := b.Exec(DB)
return err
}
func (s *ProviderStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM provider_configs WHERE id = $1", id)
return err
}
func (s *ProviderStore) ListGlobal(ctx context.Context) ([]models.ProviderConfig, error) {
return s.listByScope(ctx, models.ScopeGlobal, "")
}
func (s *ProviderStore) ListForTeam(ctx context.Context, teamID string) ([]models.ProviderConfig, error) {
return s.listByScope(ctx, models.ScopeTeam, teamID)
}
func (s *ProviderStore) ListForUser(ctx context.Context, userID string) ([]models.ProviderConfig, error) {
return s.listByScope(ctx, models.ScopePersonal, userID)
}
// ListAccessible returns all provider configs a user can access:
// global + team (for teams they belong to) + personal.
func (s *ProviderStore) ListAccessible(ctx context.Context, userID string) ([]models.ProviderConfig, error) {
rows, err := DB.QueryContext(ctx, fmt.Sprintf(`
SELECT %s FROM provider_configs
WHERE is_active = true AND (
scope = 'global'
OR (scope = 'personal' AND owner_id = $1)
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = $1
))
)
ORDER BY scope, name`, providerCols), userID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanProviders(rows)
}
// UserCanAccess checks if a user can use a specific provider config.
func (s *ProviderStore) UserCanAccess(ctx context.Context, userID, configID string) (bool, error) {
var exists bool
err := DB.QueryRowContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM provider_configs
WHERE id = $2 AND is_active = true AND (
scope = 'global'
OR (scope = 'personal' AND owner_id = $1)
OR (scope = 'team' AND owner_id IN (
SELECT team_id FROM team_members WHERE user_id = $1
))
)
)`, userID, configID).Scan(&exists)
return exists, err
}
// ── Internal helpers ────────────────────────
func (s *ProviderStore) listByScope(ctx context.Context, scope, ownerID string) ([]models.ProviderConfig, error) {
var rows *sql.Rows
var err error
if scope == models.ScopeGlobal {
rows, err = DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM provider_configs WHERE scope = $1 AND is_active = true ORDER BY name", providerCols),
scope)
} else {
rows, err = DB.QueryContext(ctx,
fmt.Sprintf("SELECT %s FROM provider_configs WHERE scope = $1 AND owner_id = $2 AND is_active = true ORDER BY name", providerCols),
scope, ownerID)
}
if err != nil {
return nil, err
}
defer rows.Close()
return scanProviders(rows)
}
func scanProvider(row *sql.Row) (*models.ProviderConfig, error) {
var p models.ProviderConfig
var ownerID, modelDefault sql.NullString
var configJSON, headersJSON, settingsJSON []byte
err := row.Scan(
&p.ID, &p.Scope, &ownerID, &p.Name, &p.Provider, &p.Endpoint,
&p.APIKeyEnc, &modelDefault,
&configJSON, &headersJSON, &settingsJSON,
&p.IsActive, &p.IsPrivate, &p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
return nil, err
}
p.OwnerID = NullableStringPtr(ownerID)
p.ModelDefault = modelDefault.String
json.Unmarshal(configJSON, &p.Config)
json.Unmarshal(headersJSON, &p.Headers)
json.Unmarshal(settingsJSON, &p.Settings)
return &p, nil
}
func scanProviders(rows *sql.Rows) ([]models.ProviderConfig, error) {
var result []models.ProviderConfig
for rows.Next() {
var p models.ProviderConfig
var ownerID, modelDefault sql.NullString
var configJSON, headersJSON, settingsJSON []byte
err := rows.Scan(
&p.ID, &p.Scope, &ownerID, &p.Name, &p.Provider, &p.Endpoint,
&p.APIKeyEnc, &modelDefault,
&configJSON, &headersJSON, &settingsJSON,
&p.IsActive, &p.IsPrivate, &p.CreatedAt, &p.UpdatedAt,
)
if err != nil {
return nil, err
}
p.OwnerID = NullableStringPtr(ownerID)
p.ModelDefault = modelDefault.String
json.Unmarshal(configJSON, &p.Config)
json.Unmarshal(headersJSON, &p.Headers)
json.Unmarshal(settingsJSON, &p.Settings)
result = append(result, p)
}
return result, rows.Err()
}

View File

@@ -0,0 +1,27 @@
package postgres
import (
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// NewStores creates all Postgres store implementations and wires them
// into the Stores bundle. Call this at startup after database.Connect().
func NewStores(db *sql.DB) store.Stores {
SetDB(db)
return store.Stores{
Providers: NewProviderStore(),
Catalog: NewCatalogStore(),
Personas: NewPersonaStore(),
Policies: NewPolicyStore(),
UserSettings: NewUserModelSettingsStore(),
Users: NewUserStore(),
Teams: NewTeamStore(),
Channels: NewChannelStore(),
Messages: NewMessageStore(),
Audit: NewAuditStore(),
Notes: NewNoteStore(),
GlobalConfig: NewGlobalConfigStore(),
}
}

View File

@@ -0,0 +1,198 @@
package postgres
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type TeamStore struct{}
func NewTeamStore() *TeamStore { return &TeamStore{} }
func (s *TeamStore) Create(ctx context.Context, t *models.Team) error {
settingsJSON := ToJSON(t.Settings)
return DB.QueryRowContext(ctx, `
INSERT INTO teams (name, description, created_by, is_active, settings)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at, updated_at`,
t.Name, t.Description, t.CreatedBy, t.IsActive, settingsJSON,
).Scan(&t.ID, &t.CreatedAt, &t.UpdatedAt)
}
func (s *TeamStore) GetByID(ctx context.Context, id string) (*models.Team, error) {
var t models.Team
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, name, description, created_by, is_active, settings, created_at, updated_at,
(SELECT COUNT(*) FROM team_members WHERE team_id = teams.id) as member_count
FROM teams WHERE id = $1`, id).Scan(
&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
&settingsJSON, &t.CreatedAt, &t.UpdatedAt, &t.MemberCount,
)
if err != nil {
return nil, err
}
json.Unmarshal(settingsJSON, &t.Settings)
return &t, nil
}
func (s *TeamStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
b := NewUpdate("teams")
for k, v := range fields {
if k == "settings" {
b.SetJSON(k, v)
} else {
b.Set(k, v)
}
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
_, err := b.Exec(DB)
return err
}
func (s *TeamStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM teams WHERE id = $1", id)
return err
}
func (s *TeamStore) List(ctx context.Context) ([]models.Team, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, name, description, created_by, is_active, settings, created_at, updated_at,
(SELECT COUNT(*) FROM team_members WHERE team_id = teams.id) as member_count
FROM teams ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.Team
for rows.Next() {
var t models.Team
var settingsJSON []byte
err := rows.Scan(&t.ID, &t.Name, &t.Description, &t.CreatedBy, &t.IsActive,
&settingsJSON, &t.CreatedAt, &t.UpdatedAt, &t.MemberCount)
if err != nil {
return nil, err
}
json.Unmarshal(settingsJSON, &t.Settings)
result = append(result, t)
}
return result, rows.Err()
}
// ── Members ─────────────────────────────────
func (s *TeamStore) AddMember(ctx context.Context, teamID, userID, role string) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO team_members (team_id, user_id, role) VALUES ($1, $2, $3)
ON CONFLICT (team_id, user_id) DO UPDATE SET role = $3`,
teamID, userID, role)
return err
}
func (s *TeamStore) RemoveMember(ctx context.Context, teamID, userID string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM team_members WHERE team_id = $1 AND user_id = $2",
teamID, userID)
return err
}
func (s *TeamStore) UpdateMemberRole(ctx context.Context, teamID, userID, role string) error {
_, err := DB.ExecContext(ctx,
"UPDATE team_members SET role = $1 WHERE team_id = $2 AND user_id = $3",
role, teamID, userID)
return err
}
func (s *TeamStore) ListMembers(ctx context.Context, teamID string) ([]models.TeamMember, error) {
rows, err := DB.QueryContext(ctx, `
SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.joined_at,
u.email, COALESCE(u.display_name, ''), u.username, u.role as user_role
FROM team_members tm
JOIN users u ON u.id = tm.user_id
WHERE tm.team_id = $1
ORDER BY tm.role DESC, u.username`, teamID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.TeamMember
for rows.Next() {
var m models.TeamMember
err := rows.Scan(&m.ID, &m.TeamID, &m.UserID, &m.Role, &m.JoinedAt,
&m.Email, &m.DisplayName, &m.Username, &m.UserRole)
if err != nil {
return nil, err
}
result = append(result, m)
}
return result, rows.Err()
}
func (s *TeamStore) GetMember(ctx context.Context, teamID, userID string) (*models.TeamMember, error) {
var m models.TeamMember
err := DB.QueryRowContext(ctx, `
SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.joined_at,
u.email, COALESCE(u.display_name, ''), u.username, u.role as user_role
FROM team_members tm
JOIN users u ON u.id = tm.user_id
WHERE tm.team_id = $1 AND tm.user_id = $2`, teamID, userID).Scan(
&m.ID, &m.TeamID, &m.UserID, &m.Role, &m.JoinedAt,
&m.Email, &m.DisplayName, &m.Username, &m.UserRole)
if err != nil {
return nil, err
}
return &m, nil
}
// GetUserTeamIDs returns all team IDs a user belongs to.
func (s *TeamStore) GetUserTeamIDs(ctx context.Context, userID string) ([]string, error) {
rows, err := DB.QueryContext(ctx,
"SELECT team_id FROM team_members WHERE user_id = $1", userID)
if err != nil {
return nil, err
}
defer rows.Close()
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func (s *TeamStore) IsTeamAdmin(ctx context.Context, teamID, userID string) (bool, error) {
var role string
err := DB.QueryRowContext(ctx,
"SELECT role FROM team_members WHERE team_id = $1 AND user_id = $2",
teamID, userID).Scan(&role)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return role == models.TeamRoleAdmin, nil
}
func (s *TeamStore) IsMember(ctx context.Context, teamID, userID string) (bool, error) {
var exists bool
err := DB.QueryRowContext(ctx,
"SELECT EXISTS(SELECT 1 FROM team_members WHERE team_id = $1 AND user_id = $2)",
teamID, userID).Scan(&exists)
return exists, err
}
// unused but keeping for reference
var _ = fmt.Sprintf

View File

@@ -0,0 +1,185 @@
package postgres
import (
"context"
"database/sql"
"fmt"
"time"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
type UserStore struct{}
func NewUserStore() *UserStore { return &UserStore{} }
func (s *UserStore) Create(ctx context.Context, u *models.User) error {
return DB.QueryRowContext(ctx, `
INSERT INTO users (username, email, password_hash, display_name, role, is_active, settings)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, created_at, updated_at`,
u.Username, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.IsActive, ToJSON(u.Settings),
).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt)
}
func (s *UserStore) GetByID(ctx context.Context, id string) (*models.User, error) {
return s.getBy(ctx, "id", id)
}
func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) {
return s.getBy(ctx, "username", username)
}
func (s *UserStore) GetByEmail(ctx context.Context, email string) (*models.User, error) {
return s.getBy(ctx, "email", email)
}
func (s *UserStore) GetByLogin(ctx context.Context, login string) (*models.User, error) {
var u models.User
var displayName, avatarURL sql.NullString
var settingsJSON []byte
err := DB.QueryRowContext(ctx, `
SELECT id, username, email, password_hash, display_name, avatar_url,
role, is_active, settings, created_at, updated_at, last_login_at
FROM users WHERE username = $1 OR email = $1`, login).Scan(
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
&u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
)
if err != nil {
return nil, err
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
return &u, nil
}
func (s *UserStore) Update(ctx context.Context, id string, fields map[string]interface{}) error {
b := NewUpdate("users")
for k, v := range fields {
if k == "settings" {
b.SetJSON(k, v)
} else {
b.Set(k, v)
}
}
if !b.HasSets() {
return nil
}
b.Where("id", id)
_, err := b.Exec(DB)
return err
}
func (s *UserStore) Delete(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "DELETE FROM users WHERE id = $1", id)
return err
}
func (s *UserStore) List(ctx context.Context, opts store.ListOptions) ([]models.User, int, error) {
b := NewSelect(
"id, username, email, display_name, avatar_url, role, is_active, settings, created_at, updated_at, last_login_at",
"users",
)
if opts.Sort == "" {
b.OrderBy("username", "ASC")
}
b.Paginate(opts)
// Count
var total int
DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&total)
q, args := b.Build()
rows, err := DB.QueryContext(ctx, q, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var result []models.User
for rows.Next() {
var u models.User
var displayName, avatarURL sql.NullString
var settingsJSON []byte
err := rows.Scan(&u.ID, &u.Username, &u.Email, &displayName, &avatarURL,
&u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt)
if err != nil {
return nil, 0, err
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
result = append(result, u)
}
return result, total, rows.Err()
}
func (s *UserStore) UpdateLastLogin(ctx context.Context, id string) error {
_, err := DB.ExecContext(ctx, "UPDATE users SET last_login_at = NOW() WHERE id = $1", id)
return err
}
func (s *UserStore) SetActive(ctx context.Context, id string, active bool) error {
_, err := DB.ExecContext(ctx, "UPDATE users SET is_active = $1 WHERE id = $2", active, id)
return err
}
// ── Refresh Tokens ──────────────────────────
func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error {
_, err := DB.ExecContext(ctx, `
INSERT INTO refresh_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)`, userID, tokenHash, expiresAt)
return err
}
func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) {
var userID string
err := DB.QueryRowContext(ctx, `
SELECT user_id FROM refresh_tokens
WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`,
tokenHash).Scan(&userID)
return userID, err
}
func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error {
_, err := DB.ExecContext(ctx,
"UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1", tokenHash)
return err
}
func (s *UserStore) RevokeAllRefreshTokens(ctx context.Context, userID string) error {
_, err := DB.ExecContext(ctx,
"UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL", userID)
return err
}
func (s *UserStore) CleanExpiredTokens(ctx context.Context) error {
_, err := DB.ExecContext(ctx,
"DELETE FROM refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days'")
return err
}
// ── Internal ────────────────────────────────
func (s *UserStore) getBy(ctx context.Context, col, val string) (*models.User, error) {
var u models.User
var displayName, avatarURL sql.NullString
var settingsJSON []byte
err := DB.QueryRowContext(ctx, fmt.Sprintf(`
SELECT id, username, email, password_hash, display_name, avatar_url,
role, is_active, settings, created_at, updated_at, last_login_at
FROM users WHERE %s = $1`, col), val).Scan(
&u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL,
&u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt,
)
if err != nil {
return nil, err
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
return &u, nil
}

View File

@@ -0,0 +1,163 @@
package postgres
import (
"context"
"fmt"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
type UserModelSettingsStore struct{}
func NewUserModelSettingsStore() *UserModelSettingsStore { return &UserModelSettingsStore{} }
func (s *UserModelSettingsStore) GetForUser(ctx context.Context, userID string) ([]models.UserModelSetting, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, user_id, model_id, hidden, preferred_temperature, preferred_max_tokens,
sort_order, created_at, updated_at
FROM user_model_settings WHERE user_id = $1 ORDER BY sort_order, model_id`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.UserModelSetting
for rows.Next() {
var s models.UserModelSetting
var prefTemp, prefMaxTokens interface{}
err := rows.Scan(
&s.ID, &s.UserID, &s.ModelID, &s.Hidden,
&prefTemp, &prefMaxTokens,
&s.SortOrder, &s.CreatedAt, &s.UpdatedAt,
)
if err != nil {
return nil, err
}
if f, ok := prefTemp.(float64); ok {
s.PreferredTemperature = &f
}
if n, ok := prefMaxTokens.(int64); ok {
v := int(n)
s.PreferredMaxTokens = &v
}
result = append(result, s)
}
return result, rows.Err()
}
// GetHiddenModelIDs returns a map of model_id → true for all hidden models.
func (s *UserModelSettingsStore) GetHiddenModelIDs(ctx context.Context, userID string) (map[string]bool, error) {
rows, err := DB.QueryContext(ctx,
"SELECT model_id FROM user_model_settings WHERE user_id = $1 AND hidden = true",
userID)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]bool)
for rows.Next() {
var modelID string
if err := rows.Scan(&modelID); err != nil {
return nil, err
}
result[modelID] = true
}
return result, rows.Err()
}
// Set upserts a single user model setting.
func (s *UserModelSettingsStore) Set(ctx context.Context, userID, modelID string, patch models.UserModelSettingPatch) error {
b := NewUpdate("user_model_settings")
if patch.Hidden != nil {
b.Set("hidden", *patch.Hidden)
}
if patch.PreferredTemperature != nil {
b.Set("preferred_temperature", models.NullFloat(patch.PreferredTemperature))
}
if patch.PreferredMaxTokens != nil {
b.Set("preferred_max_tokens", models.NullInt(patch.PreferredMaxTokens))
}
if patch.SortOrder != nil {
b.Set("sort_order", *patch.SortOrder)
}
if !b.HasSets() {
return nil
}
// Use upsert: insert if not exists, update if exists
// Build a custom upsert since the update builder doesn't handle INSERT ON CONFLICT
_, err := DB.ExecContext(ctx, `
INSERT INTO user_model_settings (user_id, model_id, hidden, preferred_temperature, preferred_max_tokens, sort_order)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (user_id, model_id)
DO UPDATE SET
hidden = COALESCE($3, user_model_settings.hidden),
preferred_temperature = COALESCE($4, user_model_settings.preferred_temperature),
preferred_max_tokens = COALESCE($5, user_model_settings.preferred_max_tokens),
sort_order = COALESCE($6, user_model_settings.sort_order)`,
userID, modelID,
patchBoolOrNil(patch.Hidden),
patchFloat64OrNil(patch.PreferredTemperature),
patchIntOrNil(patch.PreferredMaxTokens),
patchIntOrNil(patch.SortOrder),
)
return err
}
// BulkSetHidden sets the hidden state for multiple models at once.
func (s *UserModelSettingsStore) BulkSetHidden(ctx context.Context, userID string, modelIDs []string, hidden bool) error {
if len(modelIDs) == 0 {
return nil
}
// Build parameterized IN clause
placeholders := make([]string, len(modelIDs))
args := make([]interface{}, 0, len(modelIDs)+2)
args = append(args, userID, hidden)
for i, id := range modelIDs {
placeholders[i] = fmt.Sprintf("$%d", i+3)
args = append(args, id)
}
// Upsert each: some might not have rows yet
for _, modelID := range modelIDs {
_, err := DB.ExecContext(ctx, `
INSERT INTO user_model_settings (user_id, model_id, hidden)
VALUES ($1, $2, $3)
ON CONFLICT (user_id, model_id) DO UPDATE SET hidden = $3`,
userID, modelID, hidden)
if err != nil {
return fmt.Errorf("set hidden for %s: %w", modelID, err)
}
}
return nil
}
// ── Helpers ─────────────────────────────────
func patchBoolOrNil(b *bool) interface{} {
if b == nil {
return nil
}
return *b
}
func patchFloat64OrNil(f *float64) interface{} {
if f == nil {
return nil
}
return *f
}
func patchIntOrNil(i *int) interface{} {
if i == nil {
return nil
}
return *i
}
// unused but keeping for reference - will be used in ListOptions-based queries
var _ = strings.Join

View File

@@ -1,203 +0,0 @@
package tools
import (
"context"
"encoding/json"
"os"
"testing"
"git.gobha.me/xcaliber/chat-switchboard/database"
)
func TestMain(m *testing.M) {
teardown := database.SetupTestDB()
code := m.Run()
teardown()
os.Exit(code)
}
// ── Note Tool Execution (requires DB) ───────
func TestNoteCreateExecute(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "tooluser", "tool@test.com")
channelID := database.SeedTestChannel(t, userID, "Tool Test")
ctx := context.Background()
execCtx := ExecutionContext{UserID: userID, ChannelID: channelID}
tool := Get("note_create")
if tool == nil {
t.Fatal("note_create not registered")
}
args := `{"title":"Test Note","content":"Created via tool","folder":"/tools","tags":["test","ci"]}`
result, err := tool.Execute(ctx, execCtx, args)
if err != nil {
t.Fatalf("Execute: %v", err)
}
var resp map[string]interface{}
if err := json.Unmarshal([]byte(result), &resp); err != nil {
t.Fatalf("Invalid JSON result: %v\nRaw: %s", err, result)
}
if resp["id"] == nil || resp["id"] == "" {
t.Error("Expected id in result")
}
if resp["title"] != "Test Note" {
t.Errorf("Expected title='Test Note', got %v", resp["title"])
}
}
func TestNoteListExecute(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "listuser", "list@test.com")
channelID := database.SeedTestChannel(t, userID, "List Test")
ctx := context.Background()
execCtx := ExecutionContext{UserID: userID, ChannelID: channelID}
// Create two notes first
createTool := Get("note_create")
createTool.Execute(ctx, execCtx, `{"title":"Note A","content":"Alpha","folder":"/a"}`)
createTool.Execute(ctx, execCtx, `{"title":"Note B","content":"Beta","folder":"/b"}`)
// List all
listTool := Get("note_list")
result, err := listTool.Execute(ctx, execCtx, `{}`)
if err != nil {
t.Fatalf("Execute: %v", err)
}
var resp map[string]interface{}
json.Unmarshal([]byte(result), &resp)
count := resp["count"].(float64)
if count != 2 {
t.Errorf("Expected count=2, got %.0f", count)
}
// List filtered by folder
result, err = listTool.Execute(ctx, execCtx, `{"folder":"/a"}`)
if err != nil {
t.Fatalf("Execute with folder: %v", err)
}
json.Unmarshal([]byte(result), &resp)
count = resp["count"].(float64)
if count != 1 {
t.Errorf("Expected count=1 for folder /a, got %.0f", count)
}
}
func TestNoteSearchExecute(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "searchuser", "search@test.com")
channelID := database.SeedTestChannel(t, userID, "Search Test")
ctx := context.Background()
execCtx := ExecutionContext{UserID: userID, ChannelID: channelID}
createTool := Get("note_create")
createTool.Execute(ctx, execCtx, `{"title":"Kubernetes Guide","content":"How to deploy pods and services"}`)
createTool.Execute(ctx, execCtx, `{"title":"Cooking Tips","content":"Season your cast iron pan properly"}`)
searchTool := Get("note_search")
result, err := searchTool.Execute(ctx, execCtx, `{"query":"kubernetes pods"}`)
if err != nil {
t.Fatalf("Execute: %v", err)
}
var resp map[string]interface{}
json.Unmarshal([]byte(result), &resp)
count := resp["count"].(float64)
if count != 1 {
t.Errorf("Expected 1 search result for 'kubernetes pods', got %.0f", count)
}
}
func TestNoteUpdateExecute(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "updateuser", "update@test.com")
channelID := database.SeedTestChannel(t, userID, "Update Test")
ctx := context.Background()
execCtx := ExecutionContext{UserID: userID, ChannelID: channelID}
// Create a note
createTool := Get("note_create")
result, _ := createTool.Execute(ctx, execCtx, `{"title":"Original","content":"Original content"}`)
var created map[string]interface{}
json.Unmarshal([]byte(result), &created)
noteID := created["id"].(string)
// Update title
updateTool := Get("note_update")
result, err := updateTool.Execute(ctx, execCtx, `{"note_id":"`+noteID+`","title":"Renamed"}`)
if err != nil {
t.Fatalf("Update title: %v", err)
}
var updated map[string]interface{}
json.Unmarshal([]byte(result), &updated)
if updated["title"] != "Renamed" {
t.Errorf("Expected title='Renamed', got %v", updated["title"])
}
// Append content
result, err = updateTool.Execute(ctx, execCtx, `{"note_id":"`+noteID+`","content":"\nNew line","mode":"append"}`)
if err != nil {
t.Fatalf("Append: %v", err)
}
// Verify via direct DB read
var row string
database.DB.QueryRow("SELECT content FROM notes WHERE id = $1", noteID).Scan(&row)
if row != "Original content\nNew line" {
t.Errorf("Append: expected concatenated content, got %q", row)
}
}
func TestNoteCreateMissingRequiredField(t *testing.T) {
database.RequireTestDB(t)
ctx := context.Background()
execCtx := ExecutionContext{UserID: "test", ChannelID: "test"}
tool := Get("note_create")
// Missing content
result, err := tool.Execute(ctx, execCtx, `{"title":"No Content"}`)
if err == nil {
// Tools return errors in content, not as Go errors
var resp map[string]interface{}
json.Unmarshal([]byte(result), &resp)
if resp["error"] == nil {
t.Error("Expected error for missing content")
}
}
}
func TestNoteUpdateNonexistent(t *testing.T) {
database.RequireTestDB(t)
database.TruncateAll(t)
userID := database.SeedTestUser(t, "ghostuser", "ghost@test.com")
ctx := context.Background()
execCtx := ExecutionContext{UserID: userID, ChannelID: "test"}
tool := Get("note_update")
_, err := tool.Execute(ctx, execCtx, `{"note_id":"00000000-0000-0000-0000-000000000000","title":"Nope"}`)
if err == nil {
t.Log("Update of nonexistent note should return error or empty result")
}
}

View File

@@ -510,6 +510,11 @@
<label class="checkbox-label"><input type="checkbox" id="adminUserProvidersToggle" checked> Allow users to configure their own API providers</label>
<p class="section-hint">When disabled, users can only use admin-configured global providers.</p>
</section>
<section class="settings-section">
<h3>User Presets</h3>
<label class="checkbox-label"><input type="checkbox" id="adminUserPresetsToggle" checked> Allow users to create personal presets</label>
<p class="section-hint">When disabled, users can only use admin-created global presets.</p>
</section>
<section class="settings-section">
<h3>Environment Banner</h3>
<label class="checkbox-label"><input type="checkbox" id="adminBannerEnabled"> Show environment banner</label>

View File

@@ -0,0 +1,448 @@
// ==========================================
// API Response Contract Tests
// ==========================================
// These tests validate that frontend code
// correctly handles the actual backend
// response shapes. Every bug from the v0.9.0
// rollout was a contract mismatch.
//
// Run: node --test src/js/__tests__/api-contracts.test.js
// ==========================================
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
// ── Admin /users response ────────────────────
// Bug: Frontend read resp.data, backend sends resp.users
// Fix: Read resp.users || resp.data
describe('GET /admin/users response contract', () => {
// This is the ACTUAL backend response shape from ListUsers handler:
// c.JSON(200, gin.H{"users": users, "total": total})
const backendResponse = {
users: [
{ id: 'u1', username: 'alice', email: 'alice@test.com', role: 'user', is_active: true },
{ id: 'u2', username: 'admin', email: 'admin@test.com', role: 'admin', is_active: true },
],
total: 2,
};
it('response has "users" key (not "data")', () => {
assert.ok(Array.isArray(backendResponse.users), 'response.users must be an array');
assert.equal(backendResponse.data, undefined, 'response.data must NOT exist');
});
it('frontend extraction reads .users with .data fallback', () => {
// This mirrors the fixed loadMemberUserDropdown logic
const users = backendResponse.users || backendResponse.data || [];
assert.equal(users.length, 2);
assert.equal(users[0].id, 'u1');
});
it('each user has required fields for member dropdown', () => {
for (const u of backendResponse.users) {
assert.ok(u.id, 'user must have id');
assert.ok(u.username || u.email, 'user must have username or email');
assert.equal(typeof u.is_active, 'boolean', 'is_active must be boolean');
}
});
it('total is numeric', () => {
assert.equal(typeof backendResponse.total, 'number');
assert.ok(backendResponse.total >= backendResponse.users.length);
});
});
// ── /models/enabled response ─────────────────
// Bug: 500 from ambiguous SQL columns in JOIN
// After fix: returns {models: [...]}
describe('GET /models/enabled response contract', () => {
// ACTUAL backend UserModel struct sends BOTH provider_config_id AND config_id.
// Mock uses provider_config_id as primary (Go struct canonical name)
// and config_id as the alias (populated by resolver for frontend compat).
const backendResponse = {
models: [
{
id: 'cfg1:gpt-4o',
model_id: 'gpt-4o',
display_name: 'GPT-4o',
provider_config_id: 'cfg1',
config_id: 'cfg1', // Alias — MUST match provider_config_id
provider_name: 'OpenAI',
provider_type: 'openai',
source: 'catalog',
scope: 'global',
capabilities: { streaming: true, vision: true, tool_calling: true },
pricing: {},
},
],
};
it('response has "models" array', () => {
assert.ok(Array.isArray(backendResponse.models));
});
it('each model has required fields for selector', () => {
for (const m of backendResponse.models) {
assert.ok(m.model_id || m.id, 'must have model_id or id');
assert.ok(m.display_name || m.model_id, 'must have display_name or model_id');
}
});
it('catalog models have provider_config_id (backend canonical)', () => {
const catalogModels = backendResponse.models.filter(m => m.source === 'catalog');
for (const m of catalogModels) {
assert.ok(m.provider_config_id,
'catalog model must have provider_config_id (Go struct canonical name)');
}
});
it('catalog models have config_id alias matching provider_config_id', () => {
const catalogModels = backendResponse.models.filter(m => m.source === 'catalog');
for (const m of catalogModels) {
assert.ok(m.config_id,
'catalog model must have config_id (frontend alias)');
assert.equal(m.config_id, m.provider_config_id,
'config_id alias must equal provider_config_id');
}
});
it('capabilities is an object (not null)', () => {
for (const m of backendResponse.models) {
assert.equal(typeof m.capabilities, 'object');
assert.notEqual(m.capabilities, null);
}
});
});
// ── /models/enabled with preset response ─────
describe('GET /models/enabled preset contract', () => {
// ACTUAL backend UserModel for a persona includes both canonical and alias fields.
const presetModel = {
id: 'preset-uuid-123',
model_id: 'gpt-4o',
display_name: 'Code Helper',
source: 'persona',
// Backend canonical fields
provider_config_id: 'cfg1',
persona_id: 'preset-uuid-123',
scope: 'global',
avatar: null,
// Frontend alias fields (populated by resolver)
is_preset: true,
preset_id: 'preset-uuid-123',
preset_scope: 'global',
preset_avatar: null,
config_id: 'cfg1',
provider_name: 'OpenAI',
capabilities: { streaming: true },
};
it('preset has is_preset = true', () => {
assert.equal(presetModel.is_preset, true);
});
it('preset has preset_id matching persona_id', () => {
assert.ok(presetModel.preset_id);
assert.equal(presetModel.preset_id, presetModel.persona_id,
'preset_id alias must match persona_id');
});
it('preset has preset_scope matching scope', () => {
assert.ok(['global', 'team', 'personal'].includes(presetModel.preset_scope));
assert.equal(presetModel.preset_scope, presetModel.scope,
'preset_scope alias must match scope');
});
it('preset ID uses preset_id not composite', () => {
// Frontend: id = isPreset ? (m.preset_id || m.persona_id || m.id) : composite
const id = presetModel.is_preset
? (presetModel.preset_id || presetModel.persona_id || presetModel.id)
: `${presetModel.config_id || presetModel.provider_config_id}:${presetModel.model_id}`;
assert.equal(id, 'preset-uuid-123');
});
it('frontend works with ONLY canonical fields (no aliases)', () => {
// If someone breaks the alias population in resolver.go,
// the frontend fallback chain must still produce correct IDs.
const rawBackend = {
id: 'preset-uuid-123',
model_id: 'gpt-4o',
source: 'persona',
provider_config_id: 'cfg1',
persona_id: 'preset-uuid-123',
scope: 'global',
// NO is_preset, NO preset_id, NO config_id aliases
};
// is_preset would be undefined/falsy → treated as catalog model
// This test documents the FAILURE MODE if aliases break
const isPreset = !!rawBackend.is_preset; // false!
assert.equal(isPreset, false,
'Without is_preset alias, persona is misidentified as catalog model');
});
});
// ── /settings/public response ────────────────
// Bug: allow_user_personas not exposed → no toggle in UI
describe('GET /settings/public response contract', () => {
const backendResponse = {
banner: { enabled: false },
branding: null,
policies: {
allow_registration: 'true',
allow_user_byok: 'true',
allow_user_personas: 'false',
},
};
it('response has policies object', () => {
assert.equal(typeof backendResponse.policies, 'object');
assert.notEqual(backendResponse.policies, null);
});
it('policies includes allow_user_byok', () => {
assert.ok('allow_user_byok' in backendResponse.policies,
'MISSING: allow_user_byok — user provider tab will break');
});
it('policies includes allow_user_personas', () => {
assert.ok('allow_user_personas' in backendResponse.policies,
'MISSING: allow_user_personas — preset creation gating will break');
});
it('policies includes allow_registration', () => {
assert.ok('allow_registration' in backendResponse.policies);
});
it('policy values are string "true"/"false" (not boolean)', () => {
for (const [key, val] of Object.entries(backendResponse.policies)) {
assert.equal(typeof val, 'string',
`policy ${key} must be string, got ${typeof val}`);
assert.ok(['true', 'false'].includes(val),
`policy ${key} must be "true" or "false", got "${val}"`);
}
});
});
// ── /admin/settings response ─────────────────
describe('GET /admin/settings response contract', () => {
const backendResponse = {
settings: {
banner: { enabled: false, text: '', position: 'both', bg: '#007a33', fg: '#ffffff' },
},
policies: {
allow_registration: 'true',
default_user_active: 'false',
allow_user_byok: 'false',
allow_user_personas: 'false',
allow_team_providers: 'true',
},
};
it('response has settings and policies', () => {
assert.ok(backendResponse.settings, 'must have settings');
assert.ok(backendResponse.policies, 'must have policies');
});
const requiredPolicies = [
'allow_registration',
'default_user_active',
'allow_user_byok',
'allow_user_personas',
];
for (const key of requiredPolicies) {
it(`policies includes ${key}`, () => {
assert.ok(key in backendResponse.policies,
`MISSING policy: ${key} — admin settings UI will be incomplete`);
});
}
});
// ── /api-configs (user) response ─────────────
// Bug: ListConfigs returned global + personal; should be personal only
describe('GET /api-configs response contract (user scope)', () => {
const backendResponse = [
{
id: 'cfg-personal-1',
name: 'My OpenAI Key',
provider: 'openai',
endpoint: 'https://api.openai.com/v1',
scope: 'personal',
is_active: true,
},
];
it('all configs are personal scope', () => {
for (const cfg of backendResponse) {
assert.equal(cfg.scope, 'personal',
`user /api-configs must only return personal scope, got: ${cfg.scope}`);
}
});
it('no global configs leak to user list', () => {
const global = backendResponse.filter(c => c.scope === 'global');
assert.equal(global.length, 0,
'SECURITY: global configs must NOT appear in user /api-configs');
});
it('each config has required fields', () => {
for (const cfg of backendResponse) {
assert.ok(cfg.id, 'must have id');
assert.ok(cfg.name, 'must have name');
assert.ok(cfg.provider, 'must have provider');
}
});
});
// ── /teams/:id/members response ──────────────
describe('GET /teams/:id/members response contract', () => {
const backendResponse = {
data: [
{
id: 'tm1', user_id: 'u1', role: 'admin',
email: 'alice@test.com', display_name: 'Alice', joined_at: '2026-01-01T00:00:00Z',
},
],
};
it('response has "data" array', () => {
assert.ok(Array.isArray(backendResponse.data));
});
it('each member has user_id for exclusion filtering', () => {
for (const m of backendResponse.data) {
assert.ok(m.user_id, 'member must have user_id');
assert.ok(m.role, 'member must have role');
}
});
});
// ── /admin/models response ──────────────────
// Returns CatalogEntry objects (not UserModel).
// All models regardless of visibility.
describe('GET /admin/models response contract', () => {
// This is the ACTUAL CatalogEntry shape from ListAll → ListModelConfigs handler.
const backendResponse = {
models: [
{
id: 'catalog-uuid-1',
provider_config_id: 'provider-uuid',
model_id: 'gpt-4o',
display_name: 'GPT-4o',
capabilities: { streaming: true, tool_calling: true },
pricing: null,
visibility: 'disabled',
last_synced_at: '2026-02-22T00:00:00Z',
created_at: '2026-02-22T00:00:00Z',
updated_at: '2026-02-22T00:00:00Z',
},
],
};
it('response has "models" array (not null)', () => {
assert.ok(Array.isArray(backendResponse.models),
'CRITICAL: models must be [] not null — null causes frontend fallback chain to pick wrong object');
});
it('each model has id and model_id', () => {
for (const m of backendResponse.models) {
assert.ok(m.id, 'must have id (UUID) for visibility toggle onclick');
assert.ok(m.model_id, 'must have model_id for display');
}
});
it('each model has visibility', () => {
for (const m of backendResponse.models) {
assert.ok(['enabled', 'disabled', 'team'].includes(m.visibility),
`visibility must be enabled/disabled/team, got: ${m.visibility}`);
}
});
it('frontend extraction handles both null and empty array', () => {
// This is the exact fallback chain from loadAdminModels
const nullResp = { models: null };
const list1 = nullResp.models || nullResp.data || nullResp || [];
const arr1 = Array.isArray(list1) ? list1 : [];
assert.equal(arr1.length, 0, 'null models must produce empty array');
const emptyResp = { models: [] };
const list2 = emptyResp.models || emptyResp.data || emptyResp || [];
const arr2 = Array.isArray(list2) ? list2 : [];
assert.equal(arr2.length, 0, 'empty models must produce empty array');
});
});
// ── /admin/models/fetch response ────────────
describe('POST /admin/models/fetch response contract', () => {
const successResponse = {
message: 'models synced',
added: 15,
updated: 3,
total: 18,
};
const errorResponse = {
message: 'models synced',
added: 0,
updated: 0,
total: 0,
errors: ['venice: HTTP 401: invalid API key'],
};
it('success response has counts', () => {
assert.equal(typeof successResponse.added, 'number');
assert.equal(typeof successResponse.updated, 'number');
assert.equal(typeof successResponse.total, 'number');
});
it('error response has errors array', () => {
assert.ok(Array.isArray(errorResponse.errors));
assert.ok(errorResponse.errors.length > 0);
});
it('frontend must check errors array (not just HTTP status)', () => {
// The handler returns 200 even with errors — frontend must inspect body
const errs = errorResponse.errors || [];
assert.ok(errs.length > 0,
'CRITICAL: fetch can return 200 with errors — frontend must surface them');
});
});
// ── Cross-endpoint consistency ───────────────
describe('Response shape consistency', () => {
it('admin/users uses {users:[]} not {data:[]}', () => {
// This is the contract the team member dropdown depends on
const shape = { users: [], total: 0 };
assert.ok('users' in shape, 'admin/users must use "users" key');
});
it('teams/members uses {data:[]}', () => {
const shape = { data: [] };
assert.ok('data' in shape, 'teams/members uses "data" key');
});
it('models/enabled uses {models:[]}', () => {
const shape = { models: [] };
assert.ok('models' in shape);
});
it('admin/models uses {models:[]}', () => {
const shape = { models: [] };
assert.ok('models' in shape, 'admin/models must use "models" key');
});
it('presets uses {presets:[]}', () => {
const shape = { presets: [] };
assert.ok('presets' in shape);
});
});

160
src/js/__tests__/helpers.js Normal file
View File

@@ -0,0 +1,160 @@
// ==========================================
// Test Helper — Mock Browser Context
// ==========================================
// Loads the vanilla JS source files into a
// simulated browser environment so tests run
// against the ACTUAL frontend code.
// ==========================================
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const SRC = path.join(__dirname, '..');
/**
* Creates a minimal browser-like context with stubs for DOM, localStorage, etc.
* Returns the context sandbox so tests can inspect globals like API, App, UI.
*/
function createBrowserContext(overrides = {}) {
const storage = {};
const elements = {};
const sandbox = {
// Window / globals
window: {},
document: {
getElementById(id) {
return elements[id] || null;
},
querySelector() { return null; },
querySelectorAll() { return []; },
addEventListener() {},
documentElement: { style: { setProperty() {} } },
createElement(tag) {
return {
tagName: tag.toUpperCase(),
style: {}, classList: { add() {}, remove() {}, toggle() {}, contains() { return false; } },
setAttribute() {}, getAttribute() { return null; },
appendChild() {}, removeChild() {}, replaceChildren() {},
addEventListener() {},
innerHTML: '', textContent: '', value: '',
children: [], childNodes: [],
querySelector() { return null; },
querySelectorAll() { return []; },
};
},
},
localStorage: {
_data: storage,
getItem(k) { return storage[k] || null; },
setItem(k, v) { storage[k] = String(v); },
removeItem(k) { delete storage[k]; },
},
console,
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
setInterval: globalThis.setInterval,
clearInterval: globalThis.clearInterval,
requestAnimationFrame(cb) { cb(); },
fetch: async () => ({ ok: true, status: 200, json: async () => ({}) }),
alert() {},
confirm() { return true; },
prompt() { return ''; },
navigator: { userAgent: 'test', clipboard: { writeText() {} } },
location: { href: 'http://localhost/dev/', pathname: '/dev/' },
URL: globalThis.URL,
Blob: globalThis.Blob,
FileReader: class { readAsDataURL() {} readAsText() {} },
WebSocket: class { addEventListener() {} send() {} close() {} },
Image: class { set src(v) {} },
MutationObserver: class { observe() {} disconnect() {} },
IntersectionObserver: class { observe() {} disconnect() {} },
// DOMPurify stub
DOMPurify: { sanitize: (s) => s },
// marked stub
marked: { parse: (s) => s, setOptions() {} },
// hljs stub
hljs: { highlightElement() {}, getLanguage() { return null; } },
__BASE__: '/dev',
...overrides,
};
// Self-references
sandbox.window = sandbox;
sandbox.globalThis = sandbox;
sandbox.self = sandbox;
// Register mock elements
sandbox._setElement = (id, props = {}) => {
const el = sandbox.document.createElement('div');
Object.assign(el, props);
el.id = id;
elements[id] = el;
return el;
};
return sandbox;
}
/**
* Loads a source JS file into the sandbox context.
* Returns the sandbox for inspection.
*/
function loadSource(sandbox, filename) {
const code = fs.readFileSync(path.join(SRC, filename), 'utf-8');
const ctx = vm.createContext(sandbox);
vm.runInContext(code, ctx, { filename });
return sandbox;
}
/**
* Loads API + App modules into a fresh context.
* Returns { API, App, sandbox }.
*/
function loadAppModules(overrides = {}) {
const sandbox = createBrowserContext(overrides);
loadSource(sandbox, 'api.js');
loadSource(sandbox, 'app.js');
return { API: sandbox.API, App: sandbox.App, sandbox };
}
/**
* Extract the model-processing transform from fetchModels.
* This is the core mapping logic that converts API response → App.models.
*/
function processModelsResponse(data, hiddenModels = new Set()) {
return (data.models || []).map(m => {
const isPreset = !!m.is_preset;
const baseModelId = m.model_id || m.id;
const cfgId = m.config_id || m.provider_config_id;
const id = isPreset
? (m.preset_id || m.persona_id || m.id)
: (cfgId ? `${cfgId}:${baseModelId}` : baseModelId);
return {
id,
baseModelId,
name: m.display_name || baseModelId,
provider: m.provider_name || m.provider || '',
configId: cfgId || null,
isPreset,
presetId: m.preset_id || m.persona_id || null,
presetScope: m.preset_scope || (isPreset ? m.scope : null) || null,
presetAvatar: m.preset_avatar || (isPreset ? m.avatar : null) || null,
presetTeamName: m.preset_team_name || (isPreset ? m.team_name : null) || null,
source: m.source || (isPreset ? 'preset' : 'global'),
teamName: m.preset_team_name || (isPreset ? m.team_name : null) || null,
hidden: !isPreset && hiddenModels.has(baseModelId),
};
});
}
module.exports = {
createBrowserContext,
loadSource,
loadAppModules,
processModelsResponse,
SRC,
};

View File

@@ -0,0 +1,317 @@
// ==========================================
// Model Processing Tests
// ==========================================
// Tests the data transforms in fetchModels()
// that convert backend responses into the
// App.models array used throughout the UI.
//
// Run: node --test src/js/__tests__/model-processing.test.js
// ==========================================
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { processModelsResponse } = require('./helpers');
// ── Basic model transform ────────────────────
describe('processModelsResponse — catalog models', () => {
// Mock uses BOTH canonical (provider_config_id) and alias (config_id)
// to match the actual UserModel Go struct serialization.
const apiResponse = {
models: [
{
id: 'entry-uuid',
model_id: 'gpt-4o',
display_name: 'GPT-4o',
provider_config_id: 'cfg-uuid',
config_id: 'cfg-uuid',
provider_name: 'OpenAI Production',
provider_type: 'openai',
source: 'catalog',
scope: 'global',
capabilities: { streaming: true, vision: true, tool_calling: true },
},
],
};
it('produces composite ID for catalog models', () => {
const models = processModelsResponse(apiResponse);
assert.equal(models[0].id, 'cfg-uuid:gpt-4o',
'catalog model ID must be config_id:model_id to avoid collisions');
});
it('produces composite ID with ONLY provider_config_id (no config_id alias)', () => {
// This is the critical regression test: if the config_id alias
// is removed from the Go struct, the frontend must still work.
const resp = {
models: [{
model_id: 'gpt-4o',
provider_config_id: 'cfg-uuid',
// NO config_id — simulates broken alias
provider_name: 'OpenAI',
source: 'catalog',
}],
};
const models = processModelsResponse(resp);
assert.equal(models[0].id, 'cfg-uuid:gpt-4o',
'must fall back to provider_config_id when config_id is missing');
});
it('preserves baseModelId', () => {
const models = processModelsResponse(apiResponse);
assert.equal(models[0].baseModelId, 'gpt-4o');
});
it('uses display_name for name', () => {
const models = processModelsResponse(apiResponse);
assert.equal(models[0].name, 'GPT-4o');
});
it('falls back to model_id when display_name empty', () => {
const resp = {
models: [{ model_id: 'claude-3-opus', provider_config_id: 'c1', provider_type: 'anthropic' }],
};
const models = processModelsResponse(resp);
assert.equal(models[0].name, 'claude-3-opus');
});
it('marks catalog models as NOT presets', () => {
const models = processModelsResponse(apiResponse);
assert.equal(models[0].isPreset, false);
});
it('preserves configId from config_id || provider_config_id', () => {
const models = processModelsResponse(apiResponse);
assert.equal(models[0].configId, 'cfg-uuid');
});
it('uses provider_name for display', () => {
const models = processModelsResponse(apiResponse);
assert.equal(models[0].provider, 'OpenAI Production');
});
});
// ── Preset transform ─────────────────────────
describe('processModelsResponse — presets', () => {
const apiResponse = {
models: [
{
id: 'preset-uuid',
model_id: 'gpt-4o',
display_name: 'Code Helper',
// Backend canonical fields
provider_config_id: 'cfg-uuid',
persona_id: 'preset-uuid',
scope: 'global',
avatar: '/avatars/code.png',
source: 'persona',
// Frontend alias fields
is_preset: true,
preset_id: 'preset-uuid',
preset_scope: 'global',
preset_avatar: '/avatars/code.png',
preset_team_name: null,
config_id: 'cfg-uuid',
provider_name: 'OpenAI',
},
],
};
it('uses preset_id as ID (not composite)', () => {
const models = processModelsResponse(apiResponse);
assert.equal(models[0].id, 'preset-uuid',
'preset ID must use preset_id, not config_id:model_id');
});
it('falls back to persona_id when preset_id is missing', () => {
const resp = {
models: [{
id: 'p-uuid', model_id: 'gpt-4o',
is_preset: true, persona_id: 'p-uuid',
// NO preset_id — simulates broken alias
source: 'persona',
}],
};
const models = processModelsResponse(resp);
assert.equal(models[0].id, 'p-uuid',
'must fall back to persona_id when preset_id is missing');
});
it('marks as preset', () => {
const models = processModelsResponse(apiResponse);
assert.equal(models[0].isPreset, true);
});
it('preserves preset metadata', () => {
const models = processModelsResponse(apiResponse);
assert.equal(models[0].presetScope, 'global');
assert.equal(models[0].presetAvatar, '/avatars/code.png');
assert.equal(models[0].presetId, 'preset-uuid');
});
it('baseModelId is the underlying model', () => {
const models = processModelsResponse(apiResponse);
assert.equal(models[0].baseModelId, 'gpt-4o');
});
});
// ── Same model from multiple providers ───────
describe('processModelsResponse — multi-provider dedup', () => {
const apiResponse = {
models: [
{
model_id: 'gpt-4o', display_name: 'GPT-4o',
provider_config_id: 'global-cfg', config_id: 'global-cfg',
provider_name: 'Global OpenAI',
source: 'catalog', scope: 'global',
},
{
model_id: 'gpt-4o', display_name: 'GPT-4o (BYOK)',
provider_config_id: 'personal-cfg', config_id: 'personal-cfg',
provider_name: 'My Key',
source: 'catalog', scope: 'personal',
},
],
};
it('same model_id with different config_ids produces unique IDs', () => {
const models = processModelsResponse(apiResponse);
assert.equal(models.length, 2);
assert.notEqual(models[0].id, models[1].id,
'same model from different providers MUST have unique IDs');
assert.equal(models[0].id, 'global-cfg:gpt-4o');
assert.equal(models[1].id, 'personal-cfg:gpt-4o');
});
it('works with only provider_config_id (no config_id alias)', () => {
const resp = {
models: [
{ model_id: 'gpt-4o', provider_config_id: 'cfg-a', source: 'catalog' },
{ model_id: 'gpt-4o', provider_config_id: 'cfg-b', source: 'catalog' },
],
};
const models = processModelsResponse(resp);
assert.equal(models[0].id, 'cfg-a:gpt-4o');
assert.equal(models[1].id, 'cfg-b:gpt-4o');
});
});
// ── Hidden models ────────────────────────────
describe('processModelsResponse — hidden models', () => {
const apiResponse = {
models: [
{ model_id: 'gpt-4o', provider_config_id: 'c1', config_id: 'c1', display_name: 'GPT-4o' },
{ model_id: 'claude-3', provider_config_id: 'c2', config_id: 'c2', display_name: 'Claude 3' },
],
};
it('marks models as hidden from user prefs', () => {
const hidden = new Set(['gpt-4o']);
const models = processModelsResponse(apiResponse, hidden);
assert.equal(models[0].hidden, true, 'gpt-4o should be hidden');
assert.equal(models[1].hidden, false, 'claude-3 should NOT be hidden');
});
it('presets are never hidden via model ID', () => {
const resp = {
models: [
{ model_id: 'gpt-4o', is_preset: true, preset_id: 'p1', display_name: 'Preset' },
],
};
const hidden = new Set(['gpt-4o']);
const models = processModelsResponse(resp, hidden);
assert.equal(models[0].hidden, false,
'presets must NOT be hidden by base model hidden pref');
});
});
// ── Empty / edge cases ───────────────────────
describe('processModelsResponse — edge cases', () => {
it('handles empty models array', () => {
const models = processModelsResponse({ models: [] });
assert.equal(models.length, 0);
});
it('handles missing models key', () => {
const models = processModelsResponse({});
assert.equal(models.length, 0);
});
it('handles null response gracefully', () => {
const models = processModelsResponse({ models: null });
assert.equal(models.length, 0);
});
it('model without config_id uses bare model_id as ID', () => {
const resp = {
models: [{ model_id: 'test-model', display_name: 'Test' }],
};
const models = processModelsResponse(resp);
assert.equal(models[0].id, 'test-model');
});
it('model without display_name or model_id uses id', () => {
const resp = {
models: [{ id: 'fallback-id' }],
};
const models = processModelsResponse(resp);
assert.equal(models[0].id, 'fallback-id');
assert.equal(models[0].name, 'fallback-id');
});
});
// ── Model sorting ────────────────────────────
describe('Model sorting', () => {
function sortModels(models) {
const scopeOrder = { global: 0, team: 1, personal: 2 };
return [...models].sort((a, b) => {
if (a.isPreset && !b.isPreset) return -1;
if (!a.isPreset && b.isPreset) return 1;
if (a.isPreset && b.isPreset) {
const sa = scopeOrder[a.presetScope] ?? 9;
const sb = scopeOrder[b.presetScope] ?? 9;
if (sa !== sb) return sa - sb;
}
return a.name.localeCompare(b.name);
});
}
it('presets sort before regular models', () => {
const models = [
{ name: 'GPT-4o', isPreset: false },
{ name: 'Code Helper', isPreset: true, presetScope: 'global' },
];
const sorted = sortModels(models);
assert.equal(sorted[0].name, 'Code Helper');
assert.equal(sorted[1].name, 'GPT-4o');
});
it('global presets sort before team presets', () => {
const models = [
{ name: 'Team Bot', isPreset: true, presetScope: 'team' },
{ name: 'Global Bot', isPreset: true, presetScope: 'global' },
{ name: 'My Bot', isPreset: true, presetScope: 'personal' },
];
const sorted = sortModels(models);
assert.equal(sorted[0].presetScope, 'global');
assert.equal(sorted[1].presetScope, 'team');
assert.equal(sorted[2].presetScope, 'personal');
});
it('regular models sort alphabetically', () => {
const models = [
{ name: 'Zephyr', isPreset: false },
{ name: 'Claude', isPreset: false },
{ name: 'GPT-4o', isPreset: false },
];
const sorted = sortModels(models);
assert.equal(sorted[0].name, 'Claude');
assert.equal(sorted[1].name, 'GPT-4o');
assert.equal(sorted[2].name, 'Zephyr');
});
});

View File

@@ -0,0 +1,247 @@
// ==========================================
// Policy Gating & UI Logic Tests
// ==========================================
// Validates that admin policies correctly
// gate user-facing features. These catch
// the exact class of bugs where a backend
// policy exists but the frontend doesn't
// check it.
//
// Run: node --test src/js/__tests__/policy-gating.test.js
// ==========================================
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const SRC = path.join(__dirname, '..');
// ── Source code audits ───────────────────────
// These tests read the actual source files and verify that required
// wiring exists. If someone removes a policy check, CI breaks.
describe('Policy wiring audit — source code', () => {
const uiSrc = fs.readFileSync(path.join(SRC, 'ui.js'), 'utf-8');
const appSrc = fs.readFileSync(path.join(SRC, 'app.js'), 'utf-8');
const indexSrc = fs.readFileSync(path.join(SRC, '..', 'index.html'), 'utf-8');
// ── allow_user_byok ──
it('ui.js has checkUserProvidersAllowed function', () => {
assert.ok(uiSrc.includes('checkUserProvidersAllowed'),
'MISSING: checkUserProvidersAllowed — provider tab will show when policy is off');
});
it('checkUserProvidersAllowed reads App.policies.allow_user_byok', () => {
assert.ok(uiSrc.includes('allow_user_byok'),
'MISSING: allow_user_byok check in UI');
});
// ── allow_user_personas ──
it('ui.js has checkUserPresetsAllowed function', () => {
assert.ok(uiSrc.includes('checkUserPresetsAllowed'),
'MISSING: checkUserPresetsAllowed — preset button will show when policy is off');
});
it('checkUserPresetsAllowed reads App.policies.allow_user_personas', () => {
assert.ok(uiSrc.includes('allow_user_personas'),
'MISSING: allow_user_personas check in UI');
});
it('admin settings UI has adminUserPresetsToggle', () => {
assert.ok(indexSrc.includes('adminUserPresetsToggle'),
'MISSING: preset toggle in admin settings HTML');
});
it('admin settings load reads allow_user_personas', () => {
assert.ok(uiSrc.includes("adminUserPresetsToggle"),
'MISSING: loadAdminSettings must read allow_user_personas into toggle');
});
it('admin settings save writes allow_user_personas', () => {
assert.ok(appSrc.includes("allow_user_personas"),
'MISSING: handleSaveAdminSettings must write allow_user_personas');
});
// ── Settings live-apply ──
it('admin save calls initBanners for policy refresh', () => {
assert.ok(appSrc.includes('await initBanners()'),
'MISSING: initBanners call after admin save — policies won\'t refresh');
});
it('admin save calls checkUserProvidersAllowed', () => {
// Find in handleSaveAdminSettings
const saveFunc = appSrc.slice(appSrc.indexOf('handleSaveAdminSettings'));
assert.ok(saveFunc.includes('checkUserProvidersAllowed'),
'MISSING: checkUserProvidersAllowed call after admin save');
});
it('admin save calls checkUserPresetsAllowed', () => {
const saveFunc = appSrc.slice(appSrc.indexOf('handleSaveAdminSettings'));
assert.ok(saveFunc.includes('checkUserPresetsAllowed'),
'MISSING: checkUserPresetsAllowed call after admin save');
});
it('admin save calls fetchModels to refresh model list', () => {
const saveFunc = appSrc.slice(appSrc.indexOf('handleSaveAdminSettings'));
assert.ok(saveFunc.includes('fetchModels'),
'MISSING: fetchModels call after admin save — model list stale after settings change');
});
// ── Models tab calls preset check ──
it('models tab switch calls checkUserPresetsAllowed', () => {
assert.ok(uiSrc.includes('checkUserPresetsAllowed'),
'MISSING: checkUserPresetsAllowed call on models tab switch');
});
});
// ── Policy evaluation logic ──────────────────
describe('Policy evaluation logic', () => {
function isPolicyEnabled(policies, key) {
return policies?.[key] === 'true';
}
it('policy "true" (string) → enabled', () => {
assert.equal(isPolicyEnabled({ allow_user_byok: 'true' }, 'allow_user_byok'), true);
});
it('policy "false" (string) → disabled', () => {
assert.equal(isPolicyEnabled({ allow_user_byok: 'false' }, 'allow_user_byok'), false);
});
it('policy boolean true → NOT enabled (must be string)', () => {
assert.equal(isPolicyEnabled({ allow_user_byok: true }, 'allow_user_byok'), false,
'DANGER: policies are string "true"/"false", not boolean');
});
it('missing policy → disabled', () => {
assert.equal(isPolicyEnabled({}, 'allow_user_byok'), false);
});
it('null policies → disabled', () => {
assert.equal(isPolicyEnabled(null, 'allow_user_byok'), false);
});
it('undefined policies → disabled', () => {
assert.equal(isPolicyEnabled(undefined, 'allow_user_byok'), false);
});
});
// ── Team member dropdown logic ───────────────
// Bug: Empty dropdown because resp.data used instead of resp.users
describe('Team member dropdown population', () => {
function getAvailableUsers(usersResp, membersResp) {
const users = usersResp.users || usersResp.data || [];
const existingIds = new Set((membersResp.data || []).map(m => m.user_id));
return users.filter(u => u.is_active && !existingIds.has(u.id));
}
it('extracts users from {users:[]} response', () => {
const usersResp = { users: [
{ id: 'u1', username: 'alice', is_active: true },
{ id: 'u2', username: 'bob', is_active: true },
]};
const membersResp = { data: [] };
const available = getAvailableUsers(usersResp, membersResp);
assert.equal(available.length, 2);
});
it('falls back to {data:[]} response (backward compat)', () => {
const usersResp = { data: [
{ id: 'u1', username: 'alice', is_active: true },
]};
const membersResp = { data: [] };
const available = getAvailableUsers(usersResp, membersResp);
assert.equal(available.length, 1);
});
it('excludes existing team members', () => {
const usersResp = { users: [
{ id: 'u1', username: 'alice', is_active: true },
{ id: 'u2', username: 'bob', is_active: true },
]};
const membersResp = { data: [{ user_id: 'u1', role: 'admin' }] };
const available = getAvailableUsers(usersResp, membersResp);
assert.equal(available.length, 1);
assert.equal(available[0].id, 'u2');
});
it('excludes inactive users', () => {
const usersResp = { users: [
{ id: 'u1', username: 'alice', is_active: true },
{ id: 'u2', username: 'disabled', is_active: false },
]};
const membersResp = { data: [] };
const available = getAvailableUsers(usersResp, membersResp);
assert.equal(available.length, 1);
assert.equal(available[0].id, 'u1');
});
it('returns empty for empty users response', () => {
const available = getAvailableUsers({ users: [] }, { data: [] });
assert.equal(available.length, 0);
});
it('handles completely missing response fields', () => {
const available = getAvailableUsers({}, {});
assert.equal(available.length, 0);
});
});
// ── Admin settings field mapping ─────────────
describe('Admin settings field mapping', () => {
// Maps what the frontend sends to what the backend expects
const settingsFieldMap = {
'adminRegToggle': 'allow_registration',
'adminRegDefaultState': 'default_user_active',
'adminUserProvidersToggle': 'allow_user_byok',
'adminUserPresetsToggle': 'allow_user_personas',
'adminBannerEnabled': 'banner',
};
const appSrc = fs.readFileSync(path.join(SRC, 'app.js'), 'utf-8');
const indexSrc = fs.readFileSync(path.join(SRC, '..', 'index.html'), 'utf-8');
for (const [elementId, settingKey] of Object.entries(settingsFieldMap)) {
it(`HTML has element #${elementId}`, () => {
assert.ok(indexSrc.includes(`id="${elementId}"`),
`MISSING: #${elementId} in index.html — admin settings incomplete`);
});
it(`app.js writes setting "${settingKey}"`, () => {
assert.ok(appSrc.includes(settingKey),
`MISSING: "${settingKey}" in handleSaveAdminSettings`);
});
}
});
// ── HTML element existence checks ────────────
describe('Critical HTML elements exist', () => {
const indexSrc = fs.readFileSync(path.join(SRC, '..', 'index.html'), 'utf-8');
const requiredElements = [
'adminMemberUser', // Team member user dropdown
'userPresetList', // User preset list container
'userAddPresetBtn', // New preset button (policy-gated)
'userAddPresetForm', // Preset form container
'userProvidersDisabled', // BYOK disabled notice
'providerShowAddBtn', // Add provider button (policy-gated)
'adminUserProvidersToggle', // Admin toggle for BYOK
'adminUserPresetsToggle', // Admin toggle for presets
];
for (const id of requiredElements) {
it(`#${id} exists in index.html`, () => {
assert.ok(indexSrc.includes(`id="${id}"`),
`MISSING element: #${id} — UI feature will break`);
});
}
});

View File

@@ -0,0 +1,426 @@
// ==========================================
// User Journey Model Tests — E2E Frontend
// ==========================================
// These tests feed the ACTUAL processModelsResponse()
// (same code as fetchModels() in app.js) with backend
// response data shaped EXACTLY like GET /models/enabled
// for every user permutation.
//
// If these pass but the UI is broken, the bug is in
// the DOM rendering — not the data pipeline.
//
// Run: node --test src/js/__tests__/user-journey-models.test.js
// ==========================================
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const { processModelsResponse } = require('./helpers');
// ── Backend response factories ──────────────────
// These match the EXACT JSON shape from GET /models/enabled
// (Go struct: models.UserModel serialized as JSON)
function globalModel(modelId, configId, providerName) {
return {
id: modelId,
model_id: modelId,
display_name: modelId,
provider_config_id: configId,
config_id: configId,
provider_name: providerName,
provider_type: 'openai',
source: 'catalog',
scope: 'global',
is_preset: false,
capabilities: { streaming: true },
hidden: false,
};
}
function teamModel(modelId, configId, providerName, teamName) {
return {
id: modelId,
model_id: modelId,
display_name: modelId,
provider_config_id: configId,
config_id: configId,
provider_name: providerName,
provider_type: 'venice',
source: 'catalog',
scope: 'team',
is_preset: false,
capabilities: { streaming: true },
hidden: false,
team_name: teamName,
};
}
function personalModel(modelId, configId, providerName) {
return {
id: modelId,
model_id: modelId,
display_name: modelId,
provider_config_id: configId,
config_id: configId,
provider_name: providerName,
provider_type: 'venice',
source: 'catalog',
scope: 'personal',
is_preset: false,
capabilities: { streaming: true },
hidden: false,
};
}
function presetModel(presetId, baseModelId, scope, teamName) {
return {
id: presetId,
model_id: baseModelId,
display_name: `Preset: ${baseModelId}`,
preset_id: presetId,
preset_scope: scope,
preset_team_name: teamName || '',
is_preset: true,
source: 'persona',
scope: scope,
capabilities: { streaming: true },
};
}
// ── Shared fixtures ──────────────────────────────
const GLOBAL_CONFIG_ID = 'cfg-global-openai';
const TEAM_CONFIG_ID = 'cfg-team-venice';
const BYOK_CONFIG_ID = 'cfg-personal-venice';
const GLOBAL_MODELS = [
globalModel('gpt-4o', GLOBAL_CONFIG_ID, 'OpenAI Production'),
globalModel('gpt-4o-mini', GLOBAL_CONFIG_ID, 'OpenAI Production'),
];
const TEAM_MODELS = [
teamModel('llama-3.3-70b', TEAM_CONFIG_ID, 'Team Venice', 'Engineering'),
];
const BYOK_MODELS = [
personalModel('llama-3.3-70b', BYOK_CONFIG_ID, 'My Venice Key'),
personalModel('deepseek-r1', BYOK_CONFIG_ID, 'My Venice Key'),
];
// ═══════════════════════════════════════════════
// USER PERMUTATION TESTS
// ═══════════════════════════════════════════════
describe('User Journey: Platform Admin (no team, no BYOK)', () => {
// Backend returns: 2 global enabled models
const apiResponse = { models: [...GLOBAL_MODELS] };
const models = processModelsResponse(apiResponse);
it('sees exactly 2 models', () => {
assert.equal(models.length, 2);
});
it('all models have composite IDs (configId:modelId)', () => {
assert.equal(models[0].id, `${GLOBAL_CONFIG_ID}:gpt-4o`);
assert.equal(models[1].id, `${GLOBAL_CONFIG_ID}:gpt-4o-mini`);
});
it('all models have configId for routing', () => {
for (const m of models) {
assert.ok(m.configId, `model ${m.baseModelId} must have configId`);
assert.equal(m.configId, GLOBAL_CONFIG_ID);
}
});
it('all models have provider name for display', () => {
for (const m of models) {
assert.equal(m.provider, 'OpenAI Production');
}
});
it('no models are hidden by default', () => {
for (const m of models) {
assert.equal(m.hidden, false);
}
});
it('source is catalog (not preset)', () => {
for (const m of models) {
assert.equal(m.isPreset, false);
assert.equal(m.source, 'catalog');
}
});
});
describe('User Journey: Team Member (global + team)', () => {
// Backend returns: 2 global + 1 team model
const apiResponse = { models: [...GLOBAL_MODELS, ...TEAM_MODELS] };
const models = processModelsResponse(apiResponse);
it('sees exactly 3 models', () => {
assert.equal(models.length, 3);
});
it('team model has different configId from global', () => {
const teamModel = models.find(m => m.baseModelId === 'llama-3.3-70b');
assert.ok(teamModel, 'llama-3.3-70b should be present');
assert.equal(teamModel.configId, TEAM_CONFIG_ID);
assert.notEqual(teamModel.configId, GLOBAL_CONFIG_ID);
});
it('team model composite ID is unique from global with same model_id', () => {
// If both global AND team have "llama-3.3-70b", they must have different composite IDs
const globalLlama = globalModel('llama-3.3-70b', GLOBAL_CONFIG_ID, 'OpenAI');
const resp = { models: [globalLlama, ...TEAM_MODELS] };
const result = processModelsResponse(resp);
const ids = result.map(m => m.id);
const uniqueIds = new Set(ids);
assert.equal(ids.length, uniqueIds.size,
`IDs must be unique: ${JSON.stringify(ids)}`);
});
it('team model has correct source', () => {
const teamModel = models.find(m => m.baseModelId === 'llama-3.3-70b');
// source comes from backend — "catalog" for both global and team
assert.equal(teamModel.source, 'catalog');
});
});
describe('User Journey: BYOK User (global + personal)', () => {
// Backend returns: 2 global + 2 personal models
const apiResponse = { models: [...GLOBAL_MODELS, ...BYOK_MODELS] };
const models = processModelsResponse(apiResponse);
it('sees exactly 4 models', () => {
assert.equal(models.length, 4);
});
it('personal models have unique composite IDs', () => {
const personal = models.filter(m => m.configId === BYOK_CONFIG_ID);
assert.equal(personal.length, 2, 'should have 2 personal models');
assert.equal(personal[0].id, `${BYOK_CONFIG_ID}:llama-3.3-70b`);
assert.equal(personal[1].id, `${BYOK_CONFIG_ID}:deepseek-r1`);
});
it('personal and global models with same model_id have different composite IDs', () => {
// Both global and personal might have "llama-3.3-70b" — must not collide
const globalLlama = globalModel('llama-3.3-70b', GLOBAL_CONFIG_ID, 'OpenAI');
const personalLlama = personalModel('llama-3.3-70b', BYOK_CONFIG_ID, 'My Key');
const resp = { models: [globalLlama, personalLlama] };
const result = processModelsResponse(resp);
assert.equal(result[0].id, `${GLOBAL_CONFIG_ID}:llama-3.3-70b`);
assert.equal(result[1].id, `${BYOK_CONFIG_ID}:llama-3.3-70b`);
assert.notEqual(result[0].id, result[1].id);
});
it('personal model provider name shows user key name, not global', () => {
const personal = models.find(m => m.configId === BYOK_CONFIG_ID);
assert.equal(personal.provider, 'My Venice Key');
});
});
describe('User Journey: Team Member + BYOK (global + team + personal)', () => {
// Backend returns: 2 global + 1 team + 2 personal
const apiResponse = { models: [...GLOBAL_MODELS, ...TEAM_MODELS, ...BYOK_MODELS] };
const models = processModelsResponse(apiResponse);
it('sees exactly 5 models', () => {
assert.equal(models.length, 5);
});
it('all three config IDs are represented', () => {
const configIds = new Set(models.map(m => m.configId));
assert.ok(configIds.has(GLOBAL_CONFIG_ID), 'missing global');
assert.ok(configIds.has(TEAM_CONFIG_ID), 'missing team');
assert.ok(configIds.has(BYOK_CONFIG_ID), 'missing personal');
});
it('no ID collisions across scopes', () => {
const ids = models.map(m => m.id);
const uniqueIds = new Set(ids);
assert.equal(ids.length, uniqueIds.size,
`composite IDs must be unique across scopes: ${JSON.stringify(ids)}`);
});
});
describe('User Journey: Outsider (global only, no team, no BYOK)', () => {
const apiResponse = { models: [...GLOBAL_MODELS] };
const models = processModelsResponse(apiResponse);
it('sees exactly 2 global models', () => {
assert.equal(models.length, 2);
});
it('no team or personal models leak in', () => {
for (const m of models) {
assert.equal(m.configId, GLOBAL_CONFIG_ID);
}
});
});
// ═══════════════════════════════════════════════
// EDGE CASES FROM REAL BUGS
// ═══════════════════════════════════════════════
describe('Edge: Empty model list', () => {
it('handles empty array', () => {
const models = processModelsResponse({ models: [] });
assert.equal(models.length, 0);
});
it('handles null models', () => {
const models = processModelsResponse({ models: null });
assert.equal(models.length, 0);
});
it('handles missing models key', () => {
const models = processModelsResponse({});
assert.equal(models.length, 0);
});
});
describe('Edge: Missing provider_config_id', () => {
it('falls back to model_id only (no composite ID)', () => {
const resp = { models: [{
model_id: 'gpt-4o',
display_name: 'GPT-4o',
// NO provider_config_id, NO config_id
source: 'catalog',
scope: 'global',
}]};
const models = processModelsResponse(resp);
assert.equal(models[0].id, 'gpt-4o',
'without configId, ID must fall back to bare model_id');
assert.equal(models[0].configId, null);
});
});
describe('Edge: provider_config_id only (no config_id alias)', () => {
it('uses provider_config_id for composite ID', () => {
const resp = { models: [{
model_id: 'gpt-4o',
provider_config_id: 'cfg-uuid',
// NO config_id alias
provider_name: 'OpenAI',
source: 'catalog',
}]};
const models = processModelsResponse(resp);
assert.equal(models[0].id, 'cfg-uuid:gpt-4o');
assert.equal(models[0].configId, 'cfg-uuid');
});
});
describe('Edge: Hidden model preferences', () => {
it('marks hidden models correctly', () => {
const hidden = new Set(['gpt-4o-mini']);
const models = processModelsResponse({ models: GLOBAL_MODELS }, hidden);
const mini = models.find(m => m.baseModelId === 'gpt-4o-mini');
const main = models.find(m => m.baseModelId === 'gpt-4o');
assert.equal(mini.hidden, true, 'gpt-4o-mini should be hidden');
assert.equal(main.hidden, false, 'gpt-4o should not be hidden');
});
it('presets are never hidden', () => {
const preset = presetModel('preset-1', 'gpt-4o', 'global');
const hidden = new Set(['gpt-4o']);
const models = processModelsResponse({ models: [preset] }, hidden);
assert.equal(models[0].hidden, false, 'presets must never be hidden');
});
});
describe('Edge: Presets mixed with catalog models', () => {
const apiResponse = {
models: [
...GLOBAL_MODELS,
presetModel('preset-code', 'gpt-4o', 'global'),
presetModel('preset-team', 'llama-3.3-70b', 'team', 'Engineering'),
],
};
const models = processModelsResponse(apiResponse);
it('presets use preset_id as ID, not composite', () => {
const preset = models.find(m => m.presetId === 'preset-code');
assert.ok(preset, 'preset-code should be present');
assert.equal(preset.id, 'preset-code');
assert.equal(preset.isPreset, true);
});
it('preset and catalog model with same model_id have different IDs', () => {
const catalog = models.find(m => !m.isPreset && m.baseModelId === 'gpt-4o');
const preset = models.find(m => m.isPreset && m.baseModelId === 'gpt-4o');
assert.ok(catalog, 'catalog gpt-4o should exist');
assert.ok(preset, 'preset gpt-4o should exist');
assert.notEqual(catalog.id, preset.id);
});
it('preset team name flows through', () => {
const teamPreset = models.find(m => m.presetId === 'preset-team');
assert.equal(teamPreset.presetTeamName, 'Engineering');
});
});
// ═══════════════════════════════════════════════
// FULL MATRIX: 4 actors × expected model counts
// ═══════════════════════════════════════════════
// This matches the backend TestUserJourney_FullMatrix
// and proves the frontend handles the same data correctly.
describe('Full Matrix: 4 actors × backend response → frontend model count', () => {
const matrix = [
{
actor: 'platformAdmin',
models: [...GLOBAL_MODELS],
expectedCount: 2,
desc: 'global only',
},
{
actor: 'teamAdmin',
models: [...GLOBAL_MODELS, ...TEAM_MODELS],
expectedCount: 3,
desc: 'global + team',
},
{
actor: 'teamMember',
models: [...GLOBAL_MODELS, ...TEAM_MODELS, ...BYOK_MODELS],
expectedCount: 5,
desc: 'global + team + personal',
},
{
actor: 'outsider',
models: [...GLOBAL_MODELS,
personalModel('llama-outsider-byok', 'cfg-outsider', 'Outsider Key')],
expectedCount: 3,
desc: 'global + own BYOK',
},
];
for (const tc of matrix) {
it(`${tc.actor} (${tc.desc}): ${tc.expectedCount} models`, () => {
const result = processModelsResponse({ models: tc.models });
assert.equal(result.length, tc.expectedCount,
`${tc.actor} should see ${tc.expectedCount}, got ${result.length}: ` +
result.map(m => `${m.baseModelId}(${m.configId})`).join(', '));
});
it(`${tc.actor}: all IDs unique`, () => {
const result = processModelsResponse({ models: tc.models });
const ids = result.map(m => m.id);
const unique = new Set(ids);
assert.equal(ids.length, unique.size,
`${tc.actor}: duplicate IDs: ${JSON.stringify(ids)}`);
});
it(`${tc.actor}: all models have configId or are presets`, () => {
const result = processModelsResponse({ models: tc.models });
for (const m of result) {
if (!m.isPreset) {
assert.ok(m.configId,
`${tc.actor}: model ${m.baseModelId} missing configId — ` +
`frontend will fail to route completions`);
}
}
});
}
});

View File

@@ -136,7 +136,7 @@ const API = {
const body = {};
if (model) body.model = model;
if (presetId) body.preset_id = presetId;
if (apiConfigId) body.api_config_id = apiConfigId;
if (apiConfigId) body.provider_config_id = apiConfigId;
let resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
method: 'POST',
@@ -182,7 +182,7 @@ const API = {
} else {
if (model) body.model = model;
}
if (apiConfigId) body.api_config_id = apiConfigId;
if (apiConfigId) body.provider_config_id = apiConfigId;
// Only send max_tokens if user explicitly set it (non-zero = override)
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
@@ -228,12 +228,16 @@ const API = {
// ── API Configs (user providers) ─────────
listConfigs() { return this._get('/api/v1/api-configs'); },
getConfig(id) { return this._get(`/api/v1/api-configs/${id}`); },
createConfig(name, provider, endpoint, apiKey, modelDefault) {
return this._post('/api/v1/api-configs', {
name, provider, endpoint, api_key: apiKey, model_default: modelDefault
});
},
deleteConfig(id) { return this._del(`/api/v1/api-configs/${id}`); },
updateConfig(id, patch) { return this._put(`/api/v1/api-configs/${id}`, patch); },
listProviderModels(id) { return this._get(`/api/v1/api-configs/${id}/models`); },
fetchProviderModels(id) { return this._post(`/api/v1/api-configs/${id}/models/fetch`); },
// ── Profile & Settings ───────────────────

View File

@@ -10,6 +10,7 @@ const App = {
isGenerating: false,
abortController: null,
serverSettings: {},
policies: {},
// Find model by composite ID, with fallback to bare model_id match
findModel(id) {
@@ -226,19 +227,19 @@ async function fetchModels() {
// collisions when the same model exists across team/personal/global providers
const id = isPreset
? (m.preset_id || m.id)
: (m.config_id ? `${m.config_id}:${baseModelId}` : baseModelId);
: ((m.config_id || m.provider_config_id) ? `${m.config_id || m.provider_config_id}:${baseModelId}` : baseModelId);
return {
id,
baseModelId,
name: m.display_name || baseModelId,
provider: m.provider_name || m.provider || '',
configId: m.config_id || null,
configId: m.config_id || m.provider_config_id || null,
capabilities: resolveCapabilities(m.capabilities, baseModelId),
isPreset,
presetId: m.preset_id || null,
presetScope: m.preset_scope || null,
presetAvatar: m.preset_avatar || null,
presetTeamName: m.preset_team_name || null,
presetId: m.preset_id || m.persona_id || null,
presetScope: m.preset_scope || (isPreset ? m.scope : null) || null,
presetAvatar: m.preset_avatar || (isPreset ? m.avatar : null) || null,
presetTeamName: m.preset_team_name || (isPreset ? m.team_name : null) || null,
source: m.source || (isPreset ? 'preset' : 'global'),
teamName: m.preset_team_name || null,
hidden: !isPreset && App.hiddenModels.has(baseModelId),
@@ -1511,14 +1512,39 @@ async function handleCreateProvider() {
const endpoint = document.getElementById('providerEndpoint').value.trim();
const apiKey = document.getElementById('providerApiKey').value.trim();
const model = document.getElementById('providerDefaultModel').value.trim();
if (!name || !endpoint || !apiKey) return UI.toast('Fill in required fields', 'warning');
const editId = UI._editingProviderId;
if (editId) {
// Update mode — api_key optional (blank = keep existing)
if (!name || !endpoint) return UI.toast('Fill in required fields', 'warning');
const patch = { name, provider, endpoint, model_default: model };
if (apiKey) patch.api_key = apiKey;
try {
await API.createConfig(name, provider, endpoint, apiKey, model);
UI.toast('Provider added', 'success');
await API.updateConfig(editId, patch);
UI.toast('Provider updated', 'success');
UI._editingProviderId = null;
document.getElementById('providerApiKey').placeholder = 'API key';
UI.hideProviderForm();
UI.loadProviderList();
fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
} else {
// Create mode
if (!name || !endpoint || !apiKey) return UI.toast('Fill in required fields', 'warning');
try {
const result = await API.createConfig(name, provider, endpoint, apiKey, model);
if (result.warning) {
UI.toast(`Provider added — ${result.warning}`, 'warning');
} else {
const count = result.models_fetched || 0;
UI.toast(`Provider added — ${count} model${count !== 1 ? 's' : ''} synced`, 'success');
}
UI.hideProviderForm();
UI.loadProviderList();
fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
}
async function deleteProvider(id, name) {
@@ -1531,21 +1557,53 @@ async function deleteProvider(id, name) {
} catch (e) { UI.toast(e.message, 'error'); }
}
async function refreshProviderModels(id, name) {
try {
UI.toast(`Fetching models for ${name}...`, 'info');
const result = await API.fetchProviderModels(id);
const total = result.total || 0;
UI.toast(`${name}: ${total} model${total !== 1 ? 's' : ''} synced`, 'success');
fetchModels(); // refresh the model selector
} catch (e) {
UI.toast(`Failed to fetch models: ${e.message}`, 'error');
}
}
async function editProvider(id) {
try {
const cfg = await API.getConfig(id);
// Populate the add form with existing values
document.getElementById('providerName').value = cfg.name || '';
document.getElementById('providerType').value = cfg.provider || 'openai';
document.getElementById('providerEndpoint').value = cfg.endpoint || '';
document.getElementById('providerApiKey').value = ''; // Don't expose key
document.getElementById('providerApiKey').placeholder = cfg.has_key ? '(unchanged — leave blank to keep)' : 'API key';
document.getElementById('providerDefaultModel').value = cfg.model_default || '';
// Show form and switch to edit mode
UI.showProviderForm();
UI._editingProviderId = id;
} catch (e) { UI.toast(e.message, 'error'); }
}
async function handleSaveAdminSettings() {
try {
// Registration
// Policies — send as string "true"/"false" so backend routes to platform_policies
const reg = document.getElementById('adminRegToggle').checked;
await API.adminUpdateSetting('registration_enabled', { value: reg });
await API.adminUpdateSetting('allow_registration', { value: reg ? 'true' : 'false' });
// Registration default state
// Registration default state → default_user_active policy
const regState = document.getElementById('adminRegDefaultState').value;
await API.adminUpdateSetting('registration_default_state', { value: regState });
await API.adminUpdateSetting('default_user_active', { value: regState === 'active' ? 'true' : 'false' });
// User providers
// User BYOK providers → allow_user_byok policy
const userProviders = document.getElementById('adminUserProvidersToggle').checked;
await API.adminUpdateSetting('user_providers_enabled', { value: userProviders });
await API.adminUpdateSetting('allow_user_byok', { value: userProviders ? 'true' : 'false' });
// Banner
// User presets → allow_user_personas policy
const userPresets = document.getElementById('adminUserPresetsToggle').checked;
await API.adminUpdateSetting('allow_user_personas', { value: userPresets ? 'true' : 'false' });
// Banner → global_settings (JSON value)
const banner = {
enabled: document.getElementById('adminBannerEnabled').checked,
text: document.getElementById('adminBannerText').value,
@@ -1556,8 +1614,11 @@ async function handleSaveAdminSettings() {
await API.adminUpdateSetting('banner', { value: banner });
UI.toast('Settings saved', 'success');
// Live-apply banner
initBanners();
// Live-apply: refresh policies and dependent UI
await initBanners();
UI.checkUserProvidersAllowed();
UI.checkUserPresetsAllowed();
fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
@@ -1695,7 +1756,17 @@ async function createGlobalProvider() {
async function fetchAdminModels() {
const hint = document.getElementById('adminModelsHint');
hint.textContent = 'Fetching...';
try { await API.adminFetchModels(); UI.toast('Models synced', 'success'); hint.textContent = ''; await UI.loadAdminModels(); }
try {
const resp = await API.adminFetchModels();
const errs = resp.errors || [];
if (errs.length > 0) {
UI.toast('Fetch errors: ' + errs.join('; '), 'error');
} else {
UI.toast(`Models synced — ${resp.added || 0} added, ${resp.updated || 0} updated`, 'success');
}
hint.textContent = '';
await UI.loadAdminModels();
}
catch (e) { UI.toast(e.message, 'error'); hint.textContent = 'Failed'; }
}
async function cycleModelVisibility(id, current) {
@@ -1860,7 +1931,7 @@ async function createAdminPreset(vals) {
description: vals.description || '',
system_prompt: vals.system_prompt || '',
};
if (vals.api_config_id) preset.api_config_id = vals.api_config_id;
if (vals.provider_config_id) preset.provider_config_id = vals.provider_config_id;
if (vals.temperature != null) preset.temperature = vals.temperature;
if (vals.max_tokens != null) preset.max_tokens = vals.max_tokens;
@@ -2425,19 +2496,18 @@ function initBranding() {
async function initBanners() {
try {
const data = await API.getPublicSettings?.() || [];
const settings = data.settings || data || [];
const arr = Array.isArray(settings) ? settings : [];
const data = await API.getPublicSettings?.() || {};
// Store globally for user-facing checks — unwrap {value: X} wrapper
// Store policies for user-facing checks (allow_user_byok, etc.)
App.policies = data.policies || {};
// Also flatten into serverSettings for backward compat
App.serverSettings = {};
arr.forEach(s => {
const v = s.value;
App.serverSettings[s.key] = (v && typeof v === 'object' && 'value' in v) ? v.value : v;
});
if (data.banner) App.serverSettings.banner = data.banner;
if (data.branding) App.serverSettings.branding = data.branding;
const root = document.documentElement;
const banner = App.serverSettings.banner;
const banner = data.banner;
// Clear previous banner state
['bannerTop', 'bannerBottom'].forEach(id => {

View File

@@ -98,7 +98,7 @@ function renderPresetForm(containerEl, options = {}) {
};
if (showConfig) {
const cfgId = document.getElementById(`${pfx}_config`)?.value;
if (cfgId) v.api_config_id = cfgId;
if (cfgId) v.provider_config_id = cfgId;
}
const temp = parseFloat(document.getElementById(`${pfx}_temp`)?.value);
if (!isNaN(temp)) v.temperature = temp;
@@ -117,7 +117,7 @@ function renderPresetForm(containerEl, options = {}) {
if (el('temp')) el('temp').value = p.temperature != null ? p.temperature : '';
if (el('maxTokens')) el('maxTokens').value = p.max_tokens != null ? p.max_tokens : '';
if (el('model')) el('model').value = p.base_model_id || '';
if (showConfig && el('config')) el('config').value = p.api_config_id || '';
if (showConfig && el('config')) el('config').value = p.provider_config_id || '';
if (showAvatar) form.updateAvatarPreview(p.avatar || null);
},
clearForm() {
@@ -786,7 +786,7 @@ const UI = {
UI.loadProviderList();
UI.checkUserProvidersAllowed();
}
if (tab === 'models') { UI.loadUserModels(); UI.loadUserPresets(); }
if (tab === 'models') { UI.loadUserModels(); UI.loadUserPresets(); UI.checkUserPresetsAllowed(); }
if (tab === 'appearance') UI.loadAppearanceSettings();
if (tab === 'teams') UI.loadTeamsTab();
},
@@ -843,12 +843,20 @@ const UI = {
const addBtn = document.getElementById('providerShowAddBtn');
const tabBtn = document.getElementById('settingsProvidersTabBtn');
if (!notice) return;
const allowed = App.serverSettings?.user_providers_enabled !== false;
const allowed = App.policies?.allow_user_byok === 'true';
notice.style.display = allowed ? 'none' : '';
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
if (tabBtn) tabBtn.style.display = allowed ? '' : 'none';
},
checkUserPresetsAllowed() {
const addBtn = document.getElementById('userAddPresetBtn');
const addForm = document.getElementById('userAddPresetForm');
const allowed = App.policies?.allow_user_personas === 'true';
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
if (addForm && !allowed) addForm.style.display = 'none';
},
async loadProfileIntoSettings() {
try {
const p = await API.getProfile();
@@ -1075,9 +1083,9 @@ const UI = {
</div>
<div class="provider-actions">
${c.has_key ? '🔑' : '⚠️'}
${c.user_id
? `<button class="btn-small btn-danger" onclick="deleteProvider('${c.id}','${esc(c.name)}')">Remove</button>`
: '<span class="badge-global">global</span>'}
<button class="btn-small" onclick="refreshProviderModels('${c.id}','${esc(c.name)}')">Refresh Models</button>
<button class="btn-small" onclick="editProvider('${c.id}')">Edit</button>
<button class="btn-small btn-danger" onclick="deleteProvider('${c.id}','${esc(c.name)}')">Remove</button>
</div>
</div>`).join('');
} catch (e) {
@@ -1086,7 +1094,11 @@ const UI = {
},
showProviderForm() { document.getElementById('providerAddForm').style.display = ''; },
hideProviderForm() { document.getElementById('providerAddForm').style.display = 'none'; },
hideProviderForm() {
document.getElementById('providerAddForm').style.display = 'none';
UI._editingProviderId = null;
document.getElementById('providerApiKey').placeholder = 'API key';
},
// ── Admin Modal ──────────────────────────
@@ -1114,7 +1126,7 @@ const UI = {
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.adminListUsers();
const users = resp.data || [];
const users = resp.users || resp.data || [];
el.innerHTML = users.map(u => {
const teamBadges = (u.teams || []).map(t =>
`<span class="badge-team" title="${esc(t.role)}">${esc(t.team_name)}</span>`
@@ -1153,7 +1165,7 @@ const UI = {
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const s = await API.adminGetStats();
const labels = { total_users: 'Users', active_users: 'Active Users', api_configs: 'Providers', total_channels: 'Channels', total_messages: 'Messages' };
const labels = { users: 'Users', channels: 'Channels', messages: 'Messages' };
el.innerHTML = '<div class="stats-grid">' +
Object.entries(s).map(([k, v]) => `
<div class="stat-card">
@@ -1456,7 +1468,7 @@ const UI = {
sel.innerHTML = '<option value="">Loading...</option>';
try {
const resp = await API.adminListUsers(1, 200);
const users = resp.data || [];
const users = resp.users || resp.data || [];
// Also get existing members to exclude
const memberResp = await API.adminListMembers(teamId);
const existingIds = new Set((memberResp.data || []).map(m => m.user_id));
@@ -1469,28 +1481,33 @@ const UI = {
async loadAdminSettings() {
try {
const data = await API.adminGetSettings();
const settings = data.settings || data || [];
const arr = Array.isArray(settings) ? settings : [];
// v0.9: { settings: { banner: {...}, ... }, policies: { allow_registration: "true", ... } }
const settings = data.settings || {};
const policies = data.policies || {};
// Unwrap {value: X} wrapper from backend storage
const get = (key, fallback) => {
const s = arr.find(s => s.key === key);
if (!s) return fallback;
const v = s.value;
// Helper to read from settings map (JSONB values)
const getSetting = (key, fallback) => {
const v = settings[key];
if (v === undefined || v === null) return fallback;
// Unwrap {value: X} wrapper if present
return (v && typeof v === 'object' && 'value' in v) ? v.value : v;
};
// Registration
document.getElementById('adminRegToggle').checked = get('registration_enabled', true) !== false;
// Registration (policy: allow_registration)
document.getElementById('adminRegToggle').checked = policies.allow_registration === 'true';
// Registration default state
document.getElementById('adminRegDefaultState').value = get('registration_default_state', 'active') || 'active';
// Registration default state (policy: default_user_active → 'true' means auto-active)
const defaultActive = policies.default_user_active === 'true';
document.getElementById('adminRegDefaultState').value = defaultActive ? 'active' : 'pending';
// User providers
document.getElementById('adminUserProvidersToggle').checked = get('user_providers_enabled', true) !== false;
// User providers / BYOK (policy: allow_user_byok)
document.getElementById('adminUserProvidersToggle').checked = policies.allow_user_byok === 'true';
// Banner
const banner = get('banner', {}) || {};
// User presets / personas (policy: allow_user_personas)
document.getElementById('adminUserPresetsToggle').checked = policies.allow_user_personas === 'true';
// Banner (global_settings)
const banner = getSetting('banner', {}) || {};
document.getElementById('adminBannerEnabled').checked = !!banner.enabled;
document.getElementById('adminBannerText').value = banner.text || '';
document.getElementById('adminBannerPosition').value = banner.position || 'both';
@@ -1501,8 +1518,8 @@ const UI = {
document.getElementById('bannerConfigFields').style.display = banner.enabled ? '' : 'none';
UI.updateBannerPreview();
// Load presets
const presets = get('banner_presets', {}) || {};
// Load banner presets (global_settings)
const presets = getSetting('banner_presets', {}) || {};
const sel = document.getElementById('adminBannerPreset');
sel.innerHTML = '<option value="">Custom</option>';
Object.entries(presets).forEach(([key, p]) => {