1350 lines
51 KiB
Markdown
1350 lines
51 KiB
Markdown
# Chat Switchboard — Core Services Architecture
|
|
|
|
**Version:** 0.1 draft
|
|
**Status:** Design
|
|
**Companion to:** EXTENSIONS.md (extension system spec)
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
The extension system (EXTENSIONS.md) defines _how_ new capabilities plug
|
|
in. This document defines _what the core provides_ — the backend services
|
|
that exist before any extension is loaded. These are the organs; the
|
|
extension system is the nervous system.
|
|
|
|
Every service listed here is a **primitive** that multiple features
|
|
consume. Embeddings serve Knowledge Bases, Notes, and future RAG. Tasks
|
|
consume completions, tools, and web search. Channels extend the message
|
|
persistence layer. Nothing here is a leaf feature — everything is
|
|
foundational.
|
|
|
|
---
|
|
|
|
## Terminology
|
|
|
|
These terms have specific meanings throughout this document, EXTENSIONS.md,
|
|
ROADMAP.md, and the codebase. Using them consistently prevents overloading.
|
|
|
|
### Core Primitives
|
|
|
|
**Channel** — The universal conversation primitive. Every interaction is
|
|
a channel: 1:1 chat, group discussion, editor session, task execution.
|
|
A channel has human members, configured models, and a message stream.
|
|
What was previously called a "chat" is a channel with `type: 'direct'`.
|
|
|
|
**Message** — A single entry in a channel's stream. Has a participant
|
|
type (`user`, `model`, `system`) and a participant ID. Replaces the
|
|
previous `role: 'user'|'assistant'` model to support multiple humans
|
|
and multiple models unambiguously.
|
|
|
|
**Provider** — An external LLM API service (OpenAI, Anthropic, Venice,
|
|
OpenRouter, Ollama). Configured via `api_configs` with endpoint, API key,
|
|
and provider-specific settings.
|
|
|
|
**Model** — A specific LLM available through a provider (e.g.,
|
|
`claude-sonnet-4-20250514` via Anthropic). Has capabilities (tool
|
|
calling, vision, thinking) and limits (max output tokens, context window).
|
|
|
|
**Extension** — A plugin that adds capabilities. Three tiers: Browser
|
|
(client JS), Starlark (server sandbox), Sidecar (container). See
|
|
EXTENSIONS.md.
|
|
|
|
**Surface** — A UI mode registered by an extension. Chat mode is the
|
|
default surface. Editor mode, Article mode, and Cluster Manager mode
|
|
are extension-provided surfaces. See EXTENSIONS.md §6.
|
|
|
|
**Tool** — A function the LLM can call. Built-in tools (web_search,
|
|
note_create, kb_search) ship with core. Extension tools are registered
|
|
by extensions at any tier. The LLM doesn't know where a tool executes.
|
|
|
|
### People & Access
|
|
|
|
**User** — An authenticated human account. Has a username, email,
|
|
and one or more Roles. Belongs to zero or more Teams.
|
|
|
|
**Role** — What a user is allowed to do. Admin-controlled RBAC.
|
|
Governs _permissions_: which models to use, which KBs to read/write,
|
|
whether to create tasks, token spending limits, admin delegation.
|
|
Roles are _vertical_ — about privilege level. A user can have
|
|
multiple roles (permissions are additive).
|
|
|
|
Examples:
|
|
- `admin` — full system access, provider management, user management
|
|
- `developer` — all models, KB read/write, task creation
|
|
- `viewer` — read-only access to shared channels and KBs
|
|
- Custom roles defined by admin
|
|
|
|
**Team** — Who a user works with. Organizational scoping. Governs
|
|
_shared context_: which channels are visible, which KBs are shared,
|
|
which projects are collaborative. Teams are _horizontal_ — about
|
|
visibility and collaboration scope. A user can belong to multiple teams.
|
|
|
|
Examples:
|
|
- `infrastructure` — sees infra channels, cluster KBs, ops projects
|
|
- `frontend` — sees frontend channels, design KBs, UI projects
|
|
- `switchboard` — the project team, cross-cutting
|
|
|
|
**The distinction:** A Role says "you're allowed to use GPT-4o."
|
|
A Team says "you share these channels and this knowledge base with
|
|
these people." Two developers on different teams have the same
|
|
permissions but different visibility.
|
|
|
|
```
|
|
Jeff
|
|
├── Roles: [admin, developer] ← what he CAN do
|
|
└── Teams: [switchboard, infrastructure] ← who he works WITH
|
|
|
|
Alice
|
|
├── Roles: [developer] ← same model access, no admin
|
|
└── Teams: [switchboard, frontend] ← different project scope
|
|
```
|
|
|
|
| Resource | Role controls | Team controls |
|
|
|-----------|-----------------------------|--------------------------------|
|
|
| Models | Which models you can use | — |
|
|
| Channels | — | Which channels you see |
|
|
| KBs | Read/write capability | Which KBs are shared with you |
|
|
| Projects | — | Which projects you're part of |
|
|
| Tasks | Whether you can create them | Which task outputs you see |
|
|
| Admin | Admin panel, delegation | — |
|
|
| Budgets | Per-role token limits | — |
|
|
|
|
### Content & Organization
|
|
|
|
**Project** — A workspace that carries configuration: system prompt,
|
|
default model, attached KBs, team access. Contains channels.
|
|
Non-nestable. A project is _context_ — it defines how work happens.
|
|
|
|
**Folder** — A hierarchical organizer for channels. Nestable. Pure
|
|
organization with no configuration. A folder is _structure_ — it
|
|
defines where things live.
|
|
|
|
**Note** — A user-created document with full-text search, folder
|
|
organization, and LLM tool integration. Embedded for RAG retrieval.
|
|
|
|
**Knowledge Base (KB)** — A named collection of documents that are
|
|
chunked, embedded, and queryable via `kb_search`. Attachable to
|
|
channels and projects.
|
|
|
|
**Task** — A scheduled prompt that runs autonomously. Combines a
|
|
cron schedule, a model, tools, and an output target. Executes in
|
|
a `type: 'service'` channel with zero human members.
|
|
|
|
### Infrastructure
|
|
|
|
**EventBus** — Pub/sub message system. Carries events between
|
|
browser, server, and WebSocket. Extensions subscribe to events.
|
|
Channels are bus rooms.
|
|
|
|
**Capability** — A model's feature set: tool calling, vision,
|
|
thinking, reasoning, max output tokens, context window. Resolved
|
|
from provider API data, known model table, and heuristic detection.
|
|
|
|
**Compaction** — Automatic context summarization. When a channel's
|
|
history exceeds the model's context window, a utility LLM condenses
|
|
older messages. The full history stays in the database.
|
|
|
|
**Message Tree** — The actual structure of conversation history.
|
|
Messages form a tree via `parent_id`, not a flat list. A linear
|
|
conversation is a tree with no branches. Edits, regenerations, and
|
|
forks create branches. See §8.
|
|
|
|
**Branch** — A path through the message tree from root to a leaf.
|
|
Created by edit-and-resubmit, regeneration, or explicit fork.
|
|
All branches persist. The UI shows one branch at a time.
|
|
|
|
**Active Path** — The branch currently being viewed and used for
|
|
context assembly. Tracked per-user per-channel via `channel_cursors`.
|
|
The completion handler sends only the active path to the LLM.
|
|
|
|
**Cursor** — A per-user pointer to the leaf message of their
|
|
active branch in a channel. Stored in `channel_cursors`.
|
|
|
|
### Deployment & Auth
|
|
|
|
**Auth Mode** — How the backend resolves user identity. Three
|
|
strategies, selected by `AUTH_MODE` environment variable:
|
|
`builtin` (app-owned JWT), `mtls` (proxy-injected cert headers),
|
|
`oidc` (Keycloak/external IdP). The internal user model is the
|
|
same regardless of auth mode.
|
|
|
|
**Identity Header** — In mTLS mode, the reverse proxy validates
|
|
the client certificate and injects identity via HTTP headers
|
|
(e.g., `X-SSL-Client-S-DN`, `X-Forwarded-Client-Cert`). The
|
|
backend trusts these headers and extracts user identity from them.
|
|
|
|
**OIDC (OpenID Connect)** — Standard protocol for delegated
|
|
authentication. Keycloak, Okta, Azure AD, etc. The backend is a
|
|
relying party — it validates tokens against the IdP's JWKS
|
|
endpoint and extracts claims (email, groups, roles).
|
|
|
|
**Classification Banner** — Thin header/footer bar indicating the
|
|
environment designation. Content area is
|
|
`100vh - banner_top - banner_bottom`. When no banners are
|
|
configured, the full viewport is available. Banner text and color
|
|
are admin-configurable.
|
|
|
|
---
|
|
|
|
## Layered Architecture
|
|
|
|
```
|
|
┌─────────────────────────────────────────────┐
|
|
│ Extensions (Browser / Starlark / Sidecar) │
|
|
│ Editor, Article, Cluster, custom tools │
|
|
├─────────────────────────────────────────────┤
|
|
│ Built-in Tools │
|
|
│ web_search, url_fetch, notes, kb_search, │
|
|
│ task_create │
|
|
├─────────────────────────────────────────────┤
|
|
│ Core Services │
|
|
│ Tasks, Channels, Notes, Knowledge Bases, │
|
|
│ Embeddings, Folders/Projects │
|
|
├─────────────────────────────────────────────┤
|
|
│ Core Infrastructure │
|
|
│ Auth, Provider Router, Completion Handler, │
|
|
│ EventBus, Extension Loader, Persistence │
|
|
├─────────────────────────────────────────────┤
|
|
│ Storage │
|
|
│ PostgreSQL + pgvector │
|
|
└─────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## 1. Summarize / Compaction
|
|
|
|
**What:** Utility LLM service that condenses long conversations to
|
|
preserve context within model context windows.
|
|
|
|
**Why core:** Every mode needs it. Chat, editor, article — any
|
|
conversation that exceeds the context window needs automatic compaction.
|
|
Tasks that run overnight accumulate context that must be compressed.
|
|
|
|
**Design:**
|
|
- Backend service, not a user feature. Like garbage collection — it just
|
|
happens.
|
|
- Admin configures the compaction model (cheap/fast: Haiku, Flash,
|
|
Gemini Flash, or a local model via Ollama).
|
|
- Auto-triggers when conversation token count exceeds a configurable
|
|
threshold (e.g., 80% of model's max_context).
|
|
- Produces a compacted summary that replaces older messages in the
|
|
context window while preserving the full history in the database.
|
|
- Users can optionally trigger manual compaction.
|
|
- Admin can delegate compaction controls to users (opt-in via
|
|
admin setting).
|
|
|
|
**Data model:**
|
|
```sql
|
|
-- Compaction results stored per-chat
|
|
ALTER TABLE chats ADD COLUMN compaction_summary TEXT;
|
|
ALTER TABLE chats ADD COLUMN compacted_at TIMESTAMP;
|
|
ALTER TABLE chats ADD COLUMN compaction_token_count INTEGER;
|
|
```
|
|
|
|
**Admin settings:**
|
|
- `compaction_model` — which model handles compaction
|
|
- `compaction_threshold` — % of context window that triggers auto-compact
|
|
- `compaction_user_enabled` — whether users can trigger manual compaction
|
|
|
|
---
|
|
|
|
## 2. Channels (Everything Is a Channel)
|
|
|
|
**What:** The universal conversation primitive. Every interaction —
|
|
1:1 chat, group discussion, editor session, task execution — is a
|
|
channel with participants and a message stream.
|
|
|
|
**Why core:** A "chat" is a channel with one human and one model.
|
|
A "group chat" is a channel with multiple humans and models.
|
|
An "editor session" is a channel where edit events are messages.
|
|
A "task run" is a channel with zero humans and one model.
|
|
Building these as separate primitives means building context assembly,
|
|
message persistence, tool routing, and compaction multiple times.
|
|
Building them as one primitive means building it once.
|
|
|
|
**The Master Rule:**
|
|
> If a channel has exactly 1 human and 1 model: behaves as chat —
|
|
> no @mention required, every user message triggers a completion.
|
|
> Once additional participants are added (human or model): @mention
|
|
> required for LLM to respond.
|
|
|
|
This is one `if` in the completion handler. The user never configures
|
|
it. Create a chat → it works like a chat. Add a participant → it
|
|
works like a channel. Remove them → back to chat behavior. The
|
|
transition is invisible.
|
|
|
|
```go
|
|
func shouldAutoComplete(channel Channel) bool {
|
|
return len(channel.Models) == 1 && len(channel.HumanMembers) == 1
|
|
}
|
|
```
|
|
|
|
**Design:**
|
|
|
|
- **Channel types:**
|
|
- `direct` — 1:1, what "chat" is today. Created by "New Chat."
|
|
- `group` — multi-participant. Created explicitly or by adding
|
|
participants to a direct channel.
|
|
- `service` — zero humans. Task runner, scheduled jobs, automations.
|
|
|
|
- **Participants are humans. Models are configured resources.**
|
|
Humans are members with roles and permissions. Models are configured
|
|
per-channel with system prompts, display names, KB access, and tool
|
|
permissions. A model is not a "member" — it's a resource that
|
|
members can invoke.
|
|
|
|
- **@mention routing:**
|
|
- `@user` → notification (highlight, badge, push).
|
|
- `@model-display-name` → completion trigger. Backend collects
|
|
channel history, prepends the model's channel-specific system
|
|
prompt, fires completion. Response posts as a message from that
|
|
model.
|
|
- `@editor` and `@reviewer` can both be Claude Sonnet 4 with
|
|
different system prompts. The display name is the identity,
|
|
not the model.
|
|
|
|
- **Context assembly is channel-scoped.** The completion handler
|
|
loads messages from the channel, applies the resolved model's
|
|
system prompt and KB context, and fires. Same handler for 1:1
|
|
and group. The only difference is the auto-complete check.
|
|
|
|
**Backward compatibility:** The current `/api/v1/chats/*` endpoints
|
|
become aliases for channel operations filtered to `type = 'direct'
|
|
AND created_by = $user`. Existing frontend code works unchanged.
|
|
New channel-aware UI layers on top.
|
|
|
|
**Data model:**
|
|
|
|
```sql
|
|
-- Replaces the current "chats" table
|
|
CREATE TABLE channels (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
title VARCHAR(500),
|
|
type VARCHAR(20) DEFAULT 'direct', -- direct, group, service
|
|
created_by UUID REFERENCES users(id),
|
|
system_prompt TEXT,
|
|
default_model VARCHAR(255),
|
|
settings JSONB DEFAULT '{}',
|
|
folder_id UUID REFERENCES folders(id),
|
|
project_id UUID REFERENCES projects(id),
|
|
compaction_summary TEXT,
|
|
compacted_at TIMESTAMP,
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
updated_at TIMESTAMP DEFAULT NOW()
|
|
);
|
|
|
|
-- Human participants
|
|
CREATE TABLE channel_members (
|
|
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 DEFAULT NOW(),
|
|
PRIMARY KEY (channel_id, user_id)
|
|
);
|
|
|
|
-- Model configurations per channel
|
|
CREATE TABLE channel_models (
|
|
channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
|
|
model_id VARCHAR(255) NOT NULL,
|
|
api_config_id UUID REFERENCES api_configs(id),
|
|
display_name VARCHAR(100), -- "@editor", "@reviewer"
|
|
system_prompt TEXT, -- overrides channel default
|
|
kb_ids UUID[] DEFAULT '{}', -- KBs attached to this model
|
|
tools TEXT[] DEFAULT '{}', -- enabled tools for this model
|
|
attention VARCHAR(20) DEFAULT 'mention', -- mention, passive, auto
|
|
settings JSONB DEFAULT '{}', -- temperature, max_tokens, etc.
|
|
PRIMARY KEY (channel_id, model_id, COALESCE(display_name, ''))
|
|
);
|
|
|
|
-- Messages: tree structure with participant attribution
|
|
CREATE TABLE messages (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
|
|
parent_id UUID REFERENCES messages(id), -- tree structure (null = root)
|
|
participant_type VARCHAR(20) NOT NULL, -- 'user', 'model', 'system'
|
|
participant_id TEXT NOT NULL, -- user UUID or model config ref
|
|
content TEXT,
|
|
metadata JSONB DEFAULT '{}', -- tool calls, token counts, etc.
|
|
deleted_at TIMESTAMP, -- soft delete for branch pruning
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_messages_channel ON messages(channel_id, created_at);
|
|
CREATE INDEX idx_messages_parent ON messages(parent_id);
|
|
CREATE INDEX idx_channel_members ON channel_members(user_id);
|
|
```
|
|
|
|
Each channel tracks which branch the user is currently viewing:
|
|
|
|
```sql
|
|
-- Per-user active branch tracking
|
|
CREATE TABLE channel_cursors (
|
|
channel_id UUID REFERENCES channels(id) ON DELETE CASCADE,
|
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
|
active_leaf_id UUID REFERENCES messages(id),
|
|
updated_at TIMESTAMP DEFAULT NOW(),
|
|
PRIMARY KEY (channel_id, user_id)
|
|
);
|
|
```
|
|
|
|
See §8 (Conversation Forking) for the full tree model and operations.
|
|
```
|
|
|
|
**Migration from current schema:**
|
|
|
|
```sql
|
|
-- chats → channels
|
|
ALTER TABLE chats RENAME TO channels;
|
|
ALTER TABLE channels ADD COLUMN type VARCHAR(20) DEFAULT 'direct';
|
|
ALTER TABLE channels RENAME COLUMN user_id TO created_by;
|
|
|
|
-- Every existing chat gets a channel_members row
|
|
INSERT INTO channel_members (channel_id, user_id, role)
|
|
SELECT id, created_by, 'owner' FROM channels;
|
|
|
|
-- Every existing chat gets a channel_models row from its model field
|
|
INSERT INTO channel_models (channel_id, model_id, attention)
|
|
SELECT id, model, 'auto' FROM channels WHERE model IS NOT NULL;
|
|
|
|
-- Messages: add participant columns and tree structure, backfill from role
|
|
ALTER TABLE messages RENAME COLUMN chat_id TO channel_id;
|
|
ALTER TABLE messages ADD COLUMN parent_id UUID REFERENCES messages(id);
|
|
ALTER TABLE messages ADD COLUMN participant_type VARCHAR(20);
|
|
ALTER TABLE messages ADD COLUMN participant_id TEXT;
|
|
ALTER TABLE messages ADD COLUMN deleted_at TIMESTAMP;
|
|
UPDATE messages SET
|
|
participant_type = CASE WHEN role = 'user' THEN 'user'
|
|
WHEN role = 'assistant' THEN 'model'
|
|
ELSE 'system' END,
|
|
participant_id = CASE WHEN role = 'user' THEN
|
|
(SELECT created_by::text FROM channels WHERE id = messages.channel_id)
|
|
ELSE COALESCE(model, 'unknown') END;
|
|
|
|
-- Backfill parent_id: chain messages linearly by timestamp
|
|
WITH ordered AS (
|
|
SELECT id, channel_id,
|
|
LAG(id) OVER (PARTITION BY channel_id ORDER BY created_at) AS prev_id
|
|
FROM messages
|
|
)
|
|
UPDATE messages SET parent_id = ordered.prev_id
|
|
FROM ordered WHERE messages.id = ordered.id;
|
|
|
|
CREATE INDEX idx_messages_parent ON messages(parent_id);
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Notes
|
|
|
|
**What:** User-created documents with full-text search, folder
|
|
organization, and LLM tool integration.
|
|
|
|
**Why core:** Every mode needs persistent, searchable, user-scoped
|
|
storage. The editor extension stores files. The article extension
|
|
stores drafts. The chat mode stores insights. Notes are the universal
|
|
"user's persistent data" primitive.
|
|
|
|
**Design:**
|
|
- CRUD with folder hierarchy and tags.
|
|
- Full-text search via PostgreSQL `tsvector` (zero additional infra).
|
|
- Markdown content with optional structured metadata (JSONB).
|
|
- Bidirectional links to chats ("this note was created from chat X").
|
|
- Embedded for RAG retrieval (uses the embedding infrastructure from §5).
|
|
|
|
**LLM Tools (built-in):**
|
|
- `note_create` — create a note with title, content, folder, tags
|
|
- `note_search` — full-text search across user's notes
|
|
- `note_update` — update note content (append, replace, or patch)
|
|
- `note_list` — list notes by folder or tag
|
|
|
|
These are built-in tools available to any model in any mode. The LLM
|
|
decides when to use them. "Save this as a note" in chat, "Update my
|
|
project notes" in editor mode, "Add this source to my research" in
|
|
article mode — all the same tools.
|
|
|
|
**Data model:**
|
|
```sql
|
|
CREATE TABLE notes (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
|
title VARCHAR(500) NOT NULL,
|
|
content TEXT,
|
|
folder_path TEXT DEFAULT '/', -- /projects/switchboard/
|
|
tags TEXT[] DEFAULT '{}',
|
|
metadata JSONB DEFAULT '{}',
|
|
source_chat_id UUID REFERENCES chats(id),
|
|
search_vector TSVECTOR,
|
|
embedding_id UUID, -- links to embeddings table
|
|
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);
|
|
|
|
-- Auto-update search vector
|
|
CREATE TRIGGER notes_search_update
|
|
BEFORE INSERT OR UPDATE ON notes
|
|
FOR EACH ROW EXECUTE FUNCTION
|
|
tsvector_update_trigger(search_vector, 'pg_catalog.english', title, content);
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Knowledge Bases
|
|
|
|
**What:** Named collections of documents that get embedded and become
|
|
queryable by the LLM via RAG.
|
|
|
|
**Why core:** The embedding and retrieval infrastructure isn't just for
|
|
KBs — notes get embedded, chat history can be embedded, future features
|
|
will need vector search. KBs are the first _consumer_ of the embedding
|
|
infrastructure but not the only one.
|
|
|
|
**Design:**
|
|
- A KB is a named collection with an owner (user or shared).
|
|
- Documents are uploaded (PDF, DOCX, TXT, MD, HTML), chunked, and
|
|
embedded.
|
|
- Admin configures the embedding model globally (OpenAI
|
|
text-embedding-3-small, a local model, etc.).
|
|
- Chunking strategy is configurable (fixed-size, recursive,
|
|
semantic boundaries).
|
|
- KBs can be attached to chats, channels, or projects — when attached,
|
|
`kb_search` automatically includes that KB's context.
|
|
|
|
**LLM Tool (built-in):**
|
|
- `kb_search` — semantic search across one or more knowledge bases.
|
|
Returns top-k chunks with source attribution.
|
|
|
|
**Data model:**
|
|
```sql
|
|
CREATE TABLE knowledge_bases (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
name VARCHAR(255) NOT NULL,
|
|
description TEXT,
|
|
owner_id UUID REFERENCES users(id),
|
|
is_shared BOOLEAN DEFAULT false,
|
|
chunk_strategy VARCHAR(50) DEFAULT 'recursive',
|
|
chunk_size INTEGER DEFAULT 512,
|
|
chunk_overlap INTEGER DEFAULT 50,
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
updated_at TIMESTAMP DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE kb_documents (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
kb_id UUID REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
|
filename VARCHAR(500),
|
|
content_type VARCHAR(100),
|
|
size_bytes BIGINT,
|
|
status VARCHAR(20) DEFAULT 'pending', -- pending, processing, ready, error
|
|
chunk_count INTEGER DEFAULT 0,
|
|
uploaded_at TIMESTAMP DEFAULT NOW()
|
|
);
|
|
|
|
-- See §5 for the embeddings table (shared with Notes)
|
|
```
|
|
|
|
---
|
|
|
|
## 5. Embeddings Infrastructure
|
|
|
|
**What:** The shared embedding pipeline that KBs, Notes, and future
|
|
features all use.
|
|
|
|
**Why core:** Embedding is a horizontal capability. Restricting it to
|
|
just KBs would mean reimplementing it for notes, chat history search,
|
|
and anything else that needs semantic retrieval.
|
|
|
|
**Design:**
|
|
- `embed(text) → vector` — calls the admin-configured embedding model.
|
|
- `store(vector, source_type, source_id, metadata)` — stores in pgvector.
|
|
- `search(query_vector, filters) → ranked chunks` — similarity search
|
|
with source-type filtering.
|
|
- Admin configures: embedding model, vector dimensions, distance metric.
|
|
- Async pipeline: embeddings are generated in the background via a
|
|
worker queue (not blocking the request).
|
|
|
|
**Data model:**
|
|
```sql
|
|
-- Requires: CREATE EXTENSION vector;
|
|
CREATE TABLE embeddings (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
source_type VARCHAR(50) NOT NULL, -- 'kb_chunk', 'note', 'chat_summary'
|
|
source_id UUID NOT NULL, -- references the source record
|
|
chunk_index INTEGER DEFAULT 0, -- position within source
|
|
content TEXT NOT NULL, -- the text that was embedded
|
|
embedding vector(1536), -- dimension matches model
|
|
metadata JSONB DEFAULT '{}', -- source filename, page, etc.
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
);
|
|
|
|
CREATE INDEX idx_embeddings_source ON embeddings(source_type, source_id);
|
|
CREATE INDEX idx_embeddings_vector ON embeddings
|
|
USING ivfflat (embedding vector_cosine_ops)
|
|
WITH (lists = 100);
|
|
```
|
|
|
|
**Admin settings:**
|
|
- `embedding_model` — which model to use (default: text-embedding-3-small)
|
|
- `embedding_dimensions` — vector size (default: 1536)
|
|
- `embedding_api_config_id` — which provider config to use for embedding calls
|
|
|
|
---
|
|
|
|
## 6. Folders and Projects
|
|
|
|
**What:** Organizational containers for conversations and related
|
|
resources.
|
|
|
|
**Why core:** Every mode needs organization. Without it, users drown in
|
|
flat chat lists. Two types serve different needs:
|
|
|
|
- **Folders** — hierarchical, nestable. Pure organization. A folder
|
|
contains chats (and other folders). Like filesystem directories.
|
|
- **Projects** — flat (non-nestable), but carry configuration. A project
|
|
has a system prompt, default model, attached KBs, and member access.
|
|
A project is a _workspace_ that happens to contain conversations.
|
|
|
|
**Design:**
|
|
- Folders are lightweight: just a name, parent_id, and user_id.
|
|
- Projects carry context: system_prompt, model, KBs, team members.
|
|
- Chats belong to at most one folder OR one project (not both).
|
|
- Projects can have shared access (team feature, future).
|
|
|
|
**Data model:**
|
|
```sql
|
|
CREATE TABLE folders (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
|
parent_id UUID REFERENCES folders(id) ON DELETE CASCADE,
|
|
name VARCHAR(255) NOT NULL,
|
|
sort_order INTEGER DEFAULT 0,
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE projects (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
owner_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
|
name VARCHAR(255) NOT NULL,
|
|
description TEXT,
|
|
system_prompt TEXT,
|
|
default_model VARCHAR(255),
|
|
settings JSONB DEFAULT '{}',
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
updated_at TIMESTAMP DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE project_knowledge_bases (
|
|
project_id UUID REFERENCES projects(id) ON DELETE CASCADE,
|
|
kb_id UUID REFERENCES knowledge_bases(id) ON DELETE CASCADE,
|
|
PRIMARY KEY (project_id, kb_id)
|
|
);
|
|
|
|
-- Chats gain optional organization
|
|
ALTER TABLE chats ADD COLUMN folder_id UUID REFERENCES folders(id);
|
|
ALTER TABLE chats ADD COLUMN project_id UUID REFERENCES projects(id);
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Tasks
|
|
|
|
**What:** Scheduled prompts that run autonomously — a prompt + a
|
|
schedule + a model + tools + a destination.
|
|
|
|
**Why core:** This is what transforms Switchboard from a chat app into
|
|
an autonomous agent platform. Tasks consume every other core service:
|
|
completions, tools (web search, KB search, notes), channels
|
|
(posting results), and embeddings (updating knowledge).
|
|
|
|
**Design:**
|
|
- A task is: cron schedule + prompt + model + tool set + output target.
|
|
- Output targets: note (create/update), channel (post message),
|
|
webhook (HTTP POST), chat (append to existing conversation).
|
|
- Tasks run in the backend via a scheduler goroutine.
|
|
- Each run calls the completion handler with the task's prompt, model,
|
|
and enabled tools — identical to a user sending a message, but
|
|
automated.
|
|
- Task history: every run logged with input, output, tokens used,
|
|
duration, status.
|
|
- Tasks can be paused, resumed, edited, deleted.
|
|
- Admin controls: max concurrent tasks, per-user task limits,
|
|
allowed models for tasks.
|
|
|
|
**LLM Tool (built-in):**
|
|
- `task_create` — the LLM can schedule follow-ups: "I'll check on
|
|
this tomorrow" becomes a real task. Parameters: prompt, schedule,
|
|
model, output target.
|
|
|
|
**Examples:**
|
|
- "Check these 5 news sources for AI policy changes every morning,
|
|
write a summary, post to #ai-news channel."
|
|
- "Review k8s cluster health every hour, alert to #ops if anomalous."
|
|
- "Refresh the 'competitor analysis' knowledge base weekly from
|
|
these URLs."
|
|
- "Every Friday, summarize this week's chat conversations into
|
|
project notes."
|
|
|
|
**Data model:**
|
|
```sql
|
|
CREATE TABLE tasks (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
|
name VARCHAR(255) NOT NULL,
|
|
description TEXT,
|
|
prompt TEXT NOT NULL,
|
|
model VARCHAR(255) NOT NULL,
|
|
schedule VARCHAR(100) NOT NULL, -- cron expression
|
|
tools TEXT[] DEFAULT '{}', -- enabled tool names
|
|
output_type VARCHAR(50) NOT NULL, -- note, channel, webhook, chat
|
|
output_target TEXT NOT NULL, -- note_id, channel_id, URL, chat_id
|
|
settings JSONB DEFAULT '{}', -- max_tokens, temperature, etc.
|
|
is_active BOOLEAN DEFAULT true,
|
|
last_run_at TIMESTAMP,
|
|
next_run_at TIMESTAMP,
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
updated_at TIMESTAMP DEFAULT NOW()
|
|
);
|
|
|
|
CREATE TABLE task_runs (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
task_id UUID REFERENCES tasks(id) ON DELETE CASCADE,
|
|
started_at TIMESTAMP DEFAULT NOW(),
|
|
completed_at TIMESTAMP,
|
|
status VARCHAR(20) DEFAULT 'running', -- running, success, error
|
|
input_tokens INTEGER,
|
|
output_tokens INTEGER,
|
|
result TEXT,
|
|
error TEXT,
|
|
duration_ms INTEGER
|
|
);
|
|
|
|
CREATE INDEX idx_tasks_user ON tasks(user_id, is_active);
|
|
CREATE INDEX idx_tasks_schedule ON tasks(next_run_at) WHERE is_active = true;
|
|
CREATE INDEX idx_task_runs ON task_runs(task_id, started_at DESC);
|
|
```
|
|
|
|
---
|
|
|
|
## 8. Conversation Forking
|
|
|
|
**What:** Message history is a tree, not a list. Every edit-and-resubmit,
|
|
every regeneration, every "let me try a different direction" creates a
|
|
branch. All branches persist. The UI shows one branch at a time.
|
|
|
|
**Why core:** Every LLM interaction involves exploration. Users
|
|
backtrack, retry, rephrase. The current model (linear message list)
|
|
forces a choice: keep the old response or destroy it. That's data loss.
|
|
The tree model preserves everything and makes exploration a first-class
|
|
operation.
|
|
|
|
**The model:**
|
|
|
|
```
|
|
msg1 (user: "explain TCP")
|
|
└── msg2 (model: "TCP is a connection-oriented...")
|
|
├── msg3a (user: "go deeper on handshake") ← branch A
|
|
│ └── msg4a (model: "the three-way handshake...")
|
|
└── msg3b (user: "actually explain UDP instead") ← branch B (edit)
|
|
└── msg4b (model: "UDP is a connectionless...")
|
|
└── msg5b (user: "compare their headers")
|
|
├── msg6b-v1 (model: "TCP headers have...") ← regen 1
|
|
└── msg6b-v2 (model: "the key difference...") ← regen 2
|
|
```
|
|
|
|
A linear conversation is just a tree with no branches — every node
|
|
has exactly one child. No special cases.
|
|
|
|
**Schema:** (defined in §2 Channels)
|
|
|
|
```sql
|
|
-- Already in the messages table:
|
|
parent_id UUID REFERENCES messages(id) -- null = root message
|
|
deleted_at TIMESTAMP -- soft delete
|
|
|
|
-- Per-user cursor (which branch they're viewing):
|
|
channel_cursors (channel_id, user_id, active_leaf_id)
|
|
```
|
|
|
|
**Operations:**
|
|
|
|
**Edit & resubmit** — User edits message N. The frontend creates a new
|
|
message with the same `parent_id` as message N (a sibling). Fires a
|
|
completion from the new message. The old branch remains in the tree.
|
|
The cursor moves to the new branch's leaf.
|
|
|
|
**Regenerate** — Creates a new model message with the same `parent_id`
|
|
as the current model response (sibling). Both responses persist. The
|
|
user picks which to continue from.
|
|
|
|
**Fork** — Explicitly branch from any point. "I want to try a different
|
|
direction from message #5." Creates a new child of message #5. Both
|
|
branches coexist. This is just "edit & resubmit" but at an arbitrary
|
|
point in the tree.
|
|
|
|
**Delete a chain** — Soft delete: `SET deleted_at = NOW()` on a message
|
|
and all its descendants. The tree structure stays intact for undo. The
|
|
UI hides soft-deleted messages. Hard prune is a background job that
|
|
removes orphaned subtrees after a retention period.
|
|
|
|
**Switch branch** — User navigates to a different branch at a fork
|
|
point. The frontend updates `channel_cursors.active_leaf_id` and
|
|
redraws the path from root to the new leaf.
|
|
|
|
**Context assembly:**
|
|
|
|
The completion handler sends the **active path**, not all messages in
|
|
the channel. The path is the chain from root to current leaf, following
|
|
`parent_id` pointers:
|
|
|
|
```go
|
|
func getActivePath(channelID, leafID string) []Message {
|
|
var path []Message
|
|
current := leafID
|
|
for current != "" {
|
|
msg := loadMessage(current)
|
|
if msg.DeletedAt != nil {
|
|
break // stop at soft-deleted boundary
|
|
}
|
|
path = append([]Message{msg}, path...)
|
|
current = msg.ParentID
|
|
}
|
|
return path
|
|
}
|
|
```
|
|
|
|
Branch A's messages never pollute Branch B's context. If you branched
|
|
because the conversation went wrong, the wrong turn doesn't follow
|
|
you.
|
|
|
|
**Compaction** is per-path, not per-channel. Each active branch can
|
|
be compacted independently. Unused branches keep their full history.
|
|
|
|
**Frontend requirements:**
|
|
|
|
- **Branch indicator** — When a message has siblings (same parent_id),
|
|
show navigation: `← 2/3 →`. Displayed at the fork point, not on
|
|
every message.
|
|
|
|
- **Tree minimap** (optional, power user) — A collapsible outline
|
|
showing the full branch structure. Click a branch to switch to it.
|
|
Shows branch depth, message count per branch, and which branches
|
|
have model responses.
|
|
|
|
- **Active path highlight** — The current branch is visually
|
|
distinguished. Sibling branches are accessible but dimmed/collapsed.
|
|
|
|
**What to implement when:**
|
|
|
|
Phase 1 (v0.6.x): Add `parent_id` to messages table. Backfill
|
|
existing messages with linear parent chains. All new messages created
|
|
with proper `parent_id`. Frontend still renders linearly — no branch
|
|
UI yet. This is the "do it now while it's cheap" step.
|
|
|
|
Phase 2 (v0.7.x+): Edit-and-resubmit creates siblings instead of
|
|
replacing. Regenerate creates sibling model responses. Branch indicator
|
|
`← 1/2 →` in the UI. `channel_cursors` table for tracking active
|
|
branch.
|
|
|
|
Phase 3 (future): Full tree minimap. Explicit fork button. Branch
|
|
comparison view (diff two branches). Branch merging (take the best
|
|
parts of two branches into a new path).
|
|
|
|
---
|
|
|
|
## 9. Built-in Tools
|
|
|
|
These are the tools that ship with core because multiple modes and
|
|
services depend on them. They are always available when the model
|
|
supports tool calling.
|
|
|
|
| Tool | Tier | Description |
|
|
|---|---|---|
|
|
| `web_search` | Sidecar | Search provider abstraction (SearXNG, Brave, DuckDuckGo). Returns ranked results with snippets. |
|
|
| `url_fetch` | Server | Retrieve and extract content from a URL. Used by web_search follow-up, article mode, KB ingestion, tasks. |
|
|
| `note_create` | Server | Create a note with title, content, folder, tags. |
|
|
| `note_search` | Server | Full-text + semantic search across user's notes. |
|
|
| `note_update` | Server | Update note content (append, replace, or structured patch). |
|
|
| `kb_search` | Server | Semantic search across attached knowledge bases. Returns top-k chunks with source attribution. |
|
|
| `task_create` | Server | Schedule a new task. The LLM can create its own follow-ups. |
|
|
|
|
**Extension-provided tools** (not core, but expected early extensions):
|
|
- `read_file`, `write_file`, `search_replace` (Editor mode)
|
|
- `kubectl_get`, `ceph_status`, `node_ssh` (Cluster manager)
|
|
- `git_commit`, `git_diff`, `git_push` (Editor + Git)
|
|
- `fetch_source`, `check_citation` (Article mode)
|
|
|
|
---
|
|
|
|
|
|
## 10. Implementation Sequence
|
|
|
|
Priority is based on: dependency depth (what blocks other things),
|
|
solo-user value (Jeff is the first user), and complexity.
|
|
|
|
### Phase 1: Channel Foundation + Organization (v0.6.x)
|
|
**chats → channels migration + message tree + Folders + Projects + banners**
|
|
- Rename `chats` → `channels`, add `type`, `channel_members`,
|
|
`channel_models`, `participant_type`/`participant_id` on messages.
|
|
- Add `parent_id` to messages. Backfill existing messages with
|
|
linear parent chains. All new messages created with proper
|
|
`parent_id`. This is the "do it now while it's cheap" step —
|
|
the tree structure exists in the schema even before the branch
|
|
UI is built.
|
|
- Add `channel_cursors` table for per-user active branch tracking.
|
|
- Backward-compatible: `/api/v1/chats/*` aliases still work.
|
|
- 1:1 behavior unchanged (auto-complete rule).
|
|
- Folders + Projects for organization.
|
|
- Classification banners: CSS custom properties, banner settings
|
|
in admin, `/api/v1/settings/banners` endpoint, `initBanners()`
|
|
in frontend. Small, zero-risk, and required before any
|
|
enterprise deployment.
|
|
- This is the schema foundation everything else builds on.
|
|
Do it now while there's one user and a handful of chats.
|
|
|
|
### Phase 2: Notes + Built-in Tools (v0.7.x)
|
|
**Notes CRUD + note_* tools + tool execution framework**
|
|
- Enables the LLM to be genuinely useful beyond ephemeral chat.
|
|
- Requires: tool execution in completion handler (the plumbing for
|
|
_all_ tools).
|
|
- This phase builds the tool calling infrastructure that everything
|
|
else uses.
|
|
- **Conversation forking UI (v0.7.x):** Edit-and-resubmit creates
|
|
siblings instead of replacing. Regenerate creates sibling model
|
|
responses. Branch indicator `← 1/2 →` at fork points. Context
|
|
assembly uses active path, not full channel history.
|
|
|
|
### Phase 3: Web Search + URL Fetch (v0.7.x)
|
|
**web_search + url_fetch tools**
|
|
- Sidecar or direct HTTP from backend.
|
|
- Paired with Notes: "research X and save findings to my notes."
|
|
- Paired with Tasks (future): automated research.
|
|
|
|
### Phase 4: @mention Routing + Multi-participant (v0.7.x)
|
|
**@mention parsing + multi-model channels**
|
|
- The channel schema exists from Phase 1. This phase adds the
|
|
routing logic: scan messages for @mentions, resolve against
|
|
`channel_models`, fire completions.
|
|
- "Add a model" UI: configure a second model in any channel.
|
|
- Enables: editor mode (multiple model roles), second opinions,
|
|
cross-model conversations.
|
|
|
|
### Phase 5: Embeddings + Knowledge Bases (v0.8.x)
|
|
**Embedding pipeline + pgvector + KB CRUD + kb_search tool**
|
|
- Depends on: tool execution framework (Phase 2).
|
|
- Notes get embedded too (once pipeline exists).
|
|
- Admin configures embedding model.
|
|
- KBs attach to channels via `channel_models.kb_ids`.
|
|
|
|
### Phase 6: Compaction (v0.8.x)
|
|
**Auto-compaction service**
|
|
- Depends on: utility LLM calling (same as task runner).
|
|
- Channel-scoped: compacts any channel that exceeds context threshold.
|
|
- Can be built alongside or after KB (similar backend pattern:
|
|
background job that calls an LLM).
|
|
|
|
### Phase 7: Tasks (v0.9.x)
|
|
**Scheduler + task runner + task_create tool**
|
|
- Creates `type: 'service'` channels with no human members.
|
|
- Depends on: completion handler, tool execution, notes, web search.
|
|
- The capstone: everything below it combined into autonomous agents.
|
|
- Admin controls for resource limits.
|
|
|
|
### Phase 8: Auth Strategy + Roles/Teams + Permissions (v0.10.x)
|
|
**Enterprise auth modes + RBAC beyond admin/user**
|
|
- Auth middleware strategy pattern: `AUTH_MODE` env var selects
|
|
`builtin`, `mtls`, or `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.
|
|
- Roles: permission grants (model access, KB write, task create,
|
|
admin delegation, token budgets).
|
|
- Teams: organizational scoping (channel visibility, project
|
|
membership, shared KBs).
|
|
- Multi-user collaboration features on top of the channel foundation.
|
|
|
|
---
|
|
|
|
## 11. Admin Control Surface
|
|
|
|
Each core service adds admin settings. The admin settings table
|
|
(`global_settings`) is already in place. New settings per service:
|
|
|
|
```
|
|
compaction_model — model for auto-compaction
|
|
compaction_threshold — context % trigger (default: 80)
|
|
compaction_user_enabled — let users trigger manual compaction
|
|
|
|
embedding_model — model for embeddings
|
|
embedding_api_config_id — which provider config for embeddings
|
|
embedding_dimensions — vector size (default: 1536)
|
|
|
|
task_max_concurrent — max simultaneous task runs
|
|
task_per_user_limit — max tasks per user
|
|
task_allowed_models — which models can be used in tasks
|
|
|
|
channel_max_members — max users per channel
|
|
channel_ai_models — which models can be @mentioned
|
|
|
|
websearch_provider — searxng, brave, duckduckgo
|
|
websearch_endpoint — SearXNG instance URL
|
|
websearch_api_key — Brave/DDG API key
|
|
|
|
auth_mode — builtin, mtls, oidc
|
|
oidc_issuer_url — Keycloak realm URL
|
|
oidc_client_id — OIDC client ID
|
|
mtls_identity_header — header containing client DN
|
|
mtls_auto_provision — create users on first cert auth
|
|
|
|
banner_top_text — top banner text (empty = hidden)
|
|
banner_top_color — background color (#007A33, #0033A0, etc.)
|
|
banner_top_text_color — text color (#FFFFFF)
|
|
banner_bottom_text — bottom banner text (empty = hidden)
|
|
banner_bottom_color — background color
|
|
banner_bottom_text_color — text color
|
|
```
|
|
|
|
---
|
|
|
|
## 12. Authentication Architecture
|
|
|
|
**Design goal:** The application consumes identity — it does not own
|
|
the auth flow in every deployment. The auth mode is selected by the
|
|
`AUTH_MODE` environment variable. The backend's internal model is the
|
|
same regardless: a user ID, email, display name, and set of roles.
|
|
|
|
### Mode: `builtin` (default)
|
|
|
|
What exists today. The app owns the full auth lifecycle.
|
|
|
|
- Login form → `POST /api/v1/auth/login` → issues JWT access + refresh tokens.
|
|
- Access token: short-lived (15 min), stateless, validated per-request.
|
|
- Refresh token: long-lived (7 days), stored in DB, rotated on use.
|
|
- Password hashing: bcrypt.
|
|
- User management: admin creates users via admin panel.
|
|
|
|
No changes needed. This is the personal deployment mode.
|
|
|
|
### Mode: `mtls`
|
|
|
|
The reverse proxy (Istio sidecar, HAProxy, nginx) terminates mTLS,
|
|
validates the client certificate against a trusted CA chain, and
|
|
injects identity headers. The backend trusts these headers
|
|
unconditionally — the proxy is the trust boundary, not the app.
|
|
|
|
**Flow:**
|
|
```
|
|
Client (CAC/PIV cert) → Proxy (mTLS termination) → Backend
|
|
│
|
|
├── X-SSL-Client-S-DN: CN=jeff.smith.1234567890,OU=...
|
|
├── X-SSL-Client-Verify: SUCCESS
|
|
└── X-SSL-Client-Cert: (optional, full cert PEM)
|
|
```
|
|
|
|
**Backend behavior:**
|
|
1. Auth middleware reads the identity header (`AUTH_MTLS_HEADER`,
|
|
default: `X-SSL-Client-S-DN`).
|
|
2. Parses the DN to extract CN (common name) or a configurable field.
|
|
3. Looks up user by `external_id` (the DN or CN).
|
|
4. If not found and `AUTH_MTLS_AUTO_PROVISION=true`: creates the user
|
|
with `auth_source: 'mtls'`, DN as `external_id`, CN as display name.
|
|
5. If not found and auto-provision disabled: 403.
|
|
6. Sets `user_id` in request context. All downstream handlers are
|
|
unchanged.
|
|
|
|
**No JWTs issued.** Every request carries the cert. The proxy
|
|
validates it every time. The backend has no session state to manage.
|
|
No login page, no refresh tokens, no logout.
|
|
|
|
**Config:**
|
|
```
|
|
AUTH_MODE=mtls
|
|
AUTH_MTLS_HEADER=X-SSL-Client-S-DN
|
|
AUTH_MTLS_VERIFY_HEADER=X-SSL-Client-Verify
|
|
AUTH_MTLS_AUTO_PROVISION=true
|
|
AUTH_MTLS_DEFAULT_ROLE=developer
|
|
```
|
|
|
|
**Security:** The identity headers MUST only be trusted if the
|
|
request came through the proxy. The backend should reject direct
|
|
connections that bypass the proxy — either via network policy
|
|
(pod only accepts traffic from proxy sidecar) or by requiring the
|
|
verify header.
|
|
|
|
### Mode: `oidc`
|
|
|
|
Keycloak, Okta, Azure AD, or any OIDC-compliant IdP. The user
|
|
authenticates with the IdP. The app is a relying party.
|
|
|
|
**Flow (Authorization Code):**
|
|
```
|
|
Browser → /auth/login → redirect to IdP
|
|
IdP → authenticates user → redirect back with code
|
|
Browser → /auth/callback?code=... → Backend exchanges code for tokens
|
|
Backend → validates ID token via JWKS → extracts claims → issues session
|
|
```
|
|
|
|
**Flow (Bearer Token / API):**
|
|
```
|
|
Client → Authorization: Bearer <access_token>
|
|
Backend → validates token against IdP JWKS endpoint
|
|
Backend → extracts claims (sub, email, groups, roles)
|
|
```
|
|
|
|
**Backend behavior:**
|
|
1. Auth middleware reads the `Authorization: Bearer` header.
|
|
2. Validates the token signature against the IdP's JWKS endpoint
|
|
(cached, refreshed periodically).
|
|
3. Extracts claims: `sub` (subject), `email`, `preferred_username`,
|
|
`realm_access.roles` (Keycloak-specific), or configurable claim
|
|
paths.
|
|
4. Looks up user by `external_id` (the `sub` claim).
|
|
5. If not found and auto-provision enabled: creates user with
|
|
`auth_source: 'oidc'`, IdP roles mapped to local roles.
|
|
6. Sets `user_id` in request context.
|
|
|
|
**Role mapping:** The IdP's roles/groups can be mapped to local
|
|
Roles via config. For example:
|
|
```
|
|
AUTH_OIDC_ROLE_MAP=idp-admin:admin,idp-developer:developer,idp-viewer:viewer
|
|
```
|
|
|
|
Unmapped IdP roles get the default role.
|
|
|
|
**Config:**
|
|
```
|
|
AUTH_MODE=oidc
|
|
AUTH_OIDC_ISSUER=https://keycloak.example.com/realms/switchboard
|
|
AUTH_OIDC_CLIENT_ID=chat-switchboard
|
|
AUTH_OIDC_CLIENT_SECRET=<secret>
|
|
AUTH_OIDC_REDIRECT_URI=https://switchboard.example.com/auth/callback
|
|
AUTH_OIDC_SCOPES=openid,profile,email
|
|
AUTH_OIDC_AUTO_PROVISION=true
|
|
AUTH_OIDC_DEFAULT_ROLE=viewer
|
|
AUTH_OIDC_ROLE_MAP=admin:admin,developer:developer
|
|
AUTH_OIDC_ROLE_CLAIM=realm_access.roles
|
|
```
|
|
|
|
### Auth Middleware Strategy Pattern
|
|
|
|
```go
|
|
func AuthMiddleware(cfg config.Config) gin.HandlerFunc {
|
|
switch cfg.AuthMode {
|
|
case "mtls":
|
|
return mtlsAuth(cfg)
|
|
case "oidc":
|
|
return oidcAuth(cfg)
|
|
default:
|
|
return builtinJWTAuth(cfg)
|
|
}
|
|
}
|
|
```
|
|
|
|
Each strategy resolves to the same result: `c.Set("user_id", userID)`.
|
|
Everything downstream — handlers, completion, channels, permissions —
|
|
is auth-mode agnostic.
|
|
|
|
### User Table Changes
|
|
|
|
```sql
|
|
ALTER TABLE users ADD COLUMN auth_source VARCHAR(20) DEFAULT 'builtin';
|
|
-- 'builtin', 'mtls', 'oidc'
|
|
ALTER TABLE users ADD COLUMN external_id TEXT;
|
|
-- DN for mTLS, 'sub' claim for OIDC
|
|
ALTER TABLE users ADD COLUMN external_metadata JSONB DEFAULT '{}';
|
|
-- full cert DN fields, IdP claims, etc.
|
|
CREATE UNIQUE INDEX idx_users_external ON users(auth_source, external_id)
|
|
WHERE external_id IS NOT NULL;
|
|
```
|
|
|
|
Users can exist with `auth_source: 'builtin'` and `external_id: NULL`
|
|
(current state). Migration is additive — existing users unaffected.
|
|
|
|
### WebSocket Auth Per Mode
|
|
|
|
- **builtin:** current `?token=<jwt>` query param approach.
|
|
- **mtls:** proxy already validated the cert for the WebSocket upgrade
|
|
request. Backend reads the same identity headers.
|
|
- **oidc:** `?token=<access_token>` query param, validated against JWKS
|
|
(same as HTTP bearer validation).
|
|
|
|
The WebSocket hub doesn't care which mode resolved the identity — it
|
|
gets `user_id` from the auth middleware like every other handler.
|
|
|
|
---
|
|
|
|
## 13. Classification Banners & Layout
|
|
|
|
**What:** Configurable header and footer banners that indicate the
|
|
environment designation. When present, the content
|
|
area shrinks to accommodate them. When absent, full viewport.
|
|
|
|
**Why this matters:** In certain deployment environments, every page
|
|
of every application must display an environment designation banner.
|
|
This is often a policy requirement. The banners must be consistent,
|
|
always visible (no scroll), and not interfere with the application's
|
|
layout. Getting this wrong blocks deployment.
|
|
|
|
### Design
|
|
|
|
Banners are thin, fixed-position bars. The content area is everything
|
|
between them. The layout uses CSS custom properties so the math is
|
|
always correct:
|
|
|
|
```css
|
|
:root {
|
|
--banner-top-h: 0px;
|
|
--banner-bottom-h: 0px;
|
|
}
|
|
|
|
/* When banners exist, JS sets the actual heights */
|
|
.banner {
|
|
position: fixed;
|
|
left: 0;
|
|
right: 0;
|
|
height: 28px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.5px;
|
|
text-transform: uppercase;
|
|
z-index: 9999;
|
|
user-select: none;
|
|
}
|
|
|
|
.banner-top {
|
|
top: 0;
|
|
}
|
|
|
|
.banner-bottom {
|
|
bottom: 0;
|
|
}
|
|
|
|
/* Content area respects banners */
|
|
.app-container {
|
|
position: fixed;
|
|
top: var(--banner-top-h);
|
|
bottom: var(--banner-bottom-h);
|
|
left: 0;
|
|
right: 0;
|
|
overflow: hidden;
|
|
}
|
|
```
|
|
|
|
**No banners configured:** `--banner-top-h` and `--banner-bottom-h`
|
|
stay at `0px`. The `.app-container` fills the full viewport. The
|
|
banner DOM elements don't exist. Zero visual or layout impact.
|
|
|
|
**Banners configured:** Backend serves banner config via
|
|
`GET /api/v1/settings/banners` (public, no auth). The frontend
|
|
injects the banner elements, sets the CSS variables, and the
|
|
content area shrinks automatically.
|
|
|
|
### Standard Banner Presets
|
|
|
|
Admin configures text, background color, and text color. Organizations
|
|
typically define their own color standards for environment banners.
|
|
The app doesn't know or care about banner semantics — it just
|
|
renders the text in the color the admin configured.
|
|
|
|
### Banner API
|
|
|
|
```json
|
|
GET /api/v1/settings/banners
|
|
|
|
{
|
|
"top": {
|
|
"text": "ENVIRONMENT LABEL",
|
|
"background": "#007A33",
|
|
"color": "#FFFFFF"
|
|
},
|
|
"bottom": {
|
|
"text": "ENVIRONMENT LABEL",
|
|
"background": "#007A33",
|
|
"color": "#FFFFFF"
|
|
}
|
|
}
|
|
```
|
|
|
|
Empty response or `null` top/bottom = no banner.
|
|
|
|
### Frontend Init
|
|
|
|
```javascript
|
|
async function initBanners() {
|
|
try {
|
|
const resp = await fetch('/api/v1/settings/banners');
|
|
const data = await resp.json();
|
|
if (data.top) injectBanner('top', data.top);
|
|
if (data.bottom) injectBanner('bottom', data.bottom);
|
|
} catch (e) {
|
|
// No banners — silent. Don't block app load.
|
|
}
|
|
}
|
|
|
|
function injectBanner(position, config) {
|
|
const el = document.createElement('div');
|
|
el.className = `banner banner-${position}`;
|
|
el.textContent = config.text;
|
|
el.style.backgroundColor = config.background;
|
|
el.style.color = config.color;
|
|
document.body.prepend(el);
|
|
|
|
const h = el.offsetHeight + 'px';
|
|
document.documentElement.style.setProperty(
|
|
`--banner-${position}-h`, h
|
|
);
|
|
}
|
|
```
|
|
|
|
Banners load before the app initializes. The CSS variable update
|
|
causes a single layout reflow — the content area adjusts instantly.
|
|
|
|
### Admin Settings
|
|
|
|
```
|
|
banner_top_text — empty = no banner
|
|
banner_top_color — background color
|
|
banner_top_text_color — text color
|
|
banner_bottom_text — empty = no banner
|
|
banner_bottom_color — background color
|
|
banner_bottom_text_color — text color
|
|
```
|
|
|
|
Setting these to empty strings removes the banners. No restart
|
|
required — next page load picks up the new config. The banner
|
|
endpoint is public (no auth required) so it loads before login
|
|
in OIDC/mTLS flows where the login redirect hasn't happened yet.
|
|
|
|
### Layout Impact on All Surfaces
|
|
|
|
Every surface/mode must use `.app-container` as its root. This
|
|
ensures banners work automatically across chat mode, editor mode,
|
|
article mode, or any future extension surface. Extensions don't
|
|
need to know about banners — they render inside the container,
|
|
the container respects the CSS variables.
|
|
|
|
```
|
|
┌──────────────────────────────────────┐
|
|
│ ██ ENVIRONMENT LABEL ███████████████ │ ← banner-top (28px)
|
|
├──────────────────────────────────────┤
|
|
│ │
|
|
│ .app-container │
|
|
│ (all surfaces render here) │
|
|
│ │
|
|
│ │
|
|
├──────────────────────────────────────┤
|
|
│ ██ ENVIRONMENT LABEL ███████████████ │ ← banner-bottom (28px)
|
|
└──────────────────────────────────────┘
|
|
```
|
|
|