Changeset 0.24.3 (#159)

This commit is contained in:
2026-03-07 20:49:23 +00:00
parent ea082e2016
commit 937be26578
20 changed files with 836 additions and 29 deletions

View File

@@ -2,6 +2,28 @@
All notable changes to Chat Switchboard.
## [0.24.3] — 2026-03-07
### Added
- **Anonymous session participants.** Unauthenticated visitors can interact with workflow channels via ephemeral session identities. `session_participants` table stores channel-scoped session tokens with auto-generated display names ("Visitor #N"). Two entry paths: cookie-based (default, random token in `sb_session` cookie, 30-day expiry) and mTLS-based (cert fingerprint as stable session identity).
- **`AuthOrSession` middleware.** Tries JWT auth first (Authorization header + `sb_token` cookie), falls back to session cookie for workflow channels. Validates channel is `type=workflow` with `allow_anonymous=true` before creating a session. Sets `auth_type=session` in context with `session_id` and `channel_id`.
- **Session-scoped API routes.** `POST /api/v1/w/:id/completions`, `POST /api/v1/w/:id/messages`, `GET /api/v1/w/:id/messages` — all under `AuthOrSession`. Session participants can send messages and trigger completions within their bound channel only.
- **Workflow entry page.** `GET /w/:id` renders a standalone chat UI via Go template (`workflow.html`). Minimal layout: channel title/description header, message list, input box, session identity footer. No sidebar, no settings, no navigation — purpose-built for anonymous intake.
- **`SessionStore` interface.** `Create`, `GetByToken`, `GetByID`, `ListForChannel`, `CountForChannel`, `Delete`. Both Postgres and SQLite implementations.
- **`SessionParticipant` model.** `ID`, `SessionToken`, `ChannelID`, `DisplayName`, `Fingerprint`, `CreatedAt`. Added as channel participant with `participant_type=session`, `role=visitor`.
- **`channels.allow_anonymous` flag.** Boolean column (default false) gating session access. Middleware enforces: only `type=workflow` channels with `allow_anonymous=true` accept session auth.
- **Session-aware handlers.** `CreateMessage`, `ListMessages`, and `Complete` all check `isSessionAuth()` and enforce channel scope via `sessionCanAccessChannel()`. Session participants bypass user-level budget and model allowlist checks (they use the workflow channel's allocated resources). `Complete` falls back to URL param for `channel_id` on workflow API routes.
- **Handler helpers.** `isSessionAuth(c)` and `sessionCanAccessChannel(c, channelID)` in `handlers/channels.go` for consistent session identity checks.
### Migration Notes
- **Migration 021:** Creates `session_participants` table (UUID PK, unique session_token, FK to channels, display_name, fingerprint). Adds `allow_anonymous` boolean column to `channels`. Both Postgres and SQLite.
- **No new env vars.** mTLS session path reuses existing `AUTH_MODE` and `X-SSL-Client-Fingerprint` header.
### What Doesn't Ship
- Workflow definitions and stage transitions (v0.25.0)
- Session-to-user promotion ("create an account from your session")
- Session expiry and cleanup (future housekeeping job)
## [0.24.2] — 2026-03-07
### Added

View File

@@ -1 +1 @@
0.24.2
0.24.3

View File

@@ -663,7 +663,7 @@ separate table. This avoids a join-heavy permission resolution
path and keeps the grant model simple.
```sql
-- 019_v024_permissions.sql
-- 020_permissions.sql
ALTER TABLE groups
ADD COLUMN IF NOT EXISTS permissions JSONB NOT NULL DEFAULT '[]'::jsonb;
@@ -895,10 +895,10 @@ type SessionParticipant struct {
}
```
### Migration 020
### Migration 021
```sql
-- 020_v024_sessions.sql
-- 021_sessions.sql
CREATE TABLE IF NOT EXISTS session_participants (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
@@ -1054,7 +1054,7 @@ session participant activity attributed to the session identity:
### What Ships
- Migration 020 (session_participants table)
- Migration 021 (session_participants table, channels.allow_anonymous)
- `server/auth/session.go` (session creation/resume)
- `middleware/session_auth.go` (AuthOrSession)
- Session-scoped message and completion handlers
@@ -1092,11 +1092,12 @@ Each subversion is independently testable:
```
018_v024_auth.sql — users.handle, auth_source, external_id
019_v024_permissions.sql — groups.permissions, token_budget, allowed_models
020_v024_sessions.sql — session_participants table
019_v024_oidc.sql — oidc_auth_state, groups.source
020_permissions.sql — groups.permissions, token_budget, allowed_models, Everyone group
021_sessions.sql — session_participants table, channels.allow_anonymous
```
All three are additive (new columns, new tables). No destructive
All four are additive (new columns, new tables). No destructive
changes. Can be applied sequentially as each subversion merges.
SQLite equivalents for each.

View File

@@ -119,13 +119,13 @@ v0.23.1 Multi-User Navigation + Conversation Taxonomy ✅
v0.23.2 Multi-User Polish + Channel Lifecycle ✅
v0.24.0 Auth Abstraction + User Identity ← active
v0.24.0 Auth Abstraction + User Identity
├── v0.24.1 mTLS + OIDC Providers (parallel)
├── v0.24.1 mTLS + OIDC Providers (parallel)
│ │
│ └── v0.24.3 Anonymous Sessions
│ └── v0.24.3 Anonymous Sessions
└── v0.24.2 Fine-Grained Permissions (parallel)
└── v0.24.2 Fine-Grained Permissions (parallel)
┌───────┴──────────────┐
│ │
@@ -1450,14 +1450,14 @@ prerequisite for v0.25.0 (Workflow Engine).
Depends on: mTLS provider (v0.24.1) for cert-based anonymous identity.
See [DESIGN-0.24.0.md](DESIGN-0.24.0.md) §v0.24.3 for full spec.
- [ ] `session_participants` table (channel-scoped, ephemeral token)
- [ ] Session JWT: `SessionClaims{SessionID, ChannelID}` — distinct from user JWT
- [ ] `SessionOrAuth()` middleware: accepts either JWT type
- [ ] Capability scoping: session participants limited to their bound channel
- [ ] mTLS anonymous path: cert fingerprint as stable session identity
- [ ] `channels.allow_anonymous` flag
- [ ] Session display in participant list (team members see visitor identity)
- [ ] Migration 021: `session_participants` table, `allow_anonymous` column
- [x] `session_participants` table (channel-scoped, ephemeral token)
- [x] `AuthOrSession()` middleware: tries JWT first, falls back to session cookie (cookie-based, no separate session JWT needed)
- [x] Capability scoping: session participants limited to their bound channel
- [x] mTLS anonymous path: cert fingerprint as stable session identity
- [x] `channels.allow_anonymous` flag
- [x] Session added as channel participant (`participant_type=session`, `role=visitor`)
- [x] Workflow entry page: `GET /w/:id` (Go template, standalone chat UI)
- [x] Migration 021: `session_participants` table, `allow_anonymous` column
---

96
server/auth/session.go Normal file
View File

@@ -0,0 +1,96 @@
package auth
import (
"context"
"database/sql"
"fmt"
"log"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
const sessionCookieName = "sb_session"
// CreateOrResumeSession looks up or creates a session participant for
// an anonymous visitor to a workflow channel.
//
// Two entry paths:
// - Cookie-based (default): random token stored in sb_session cookie
// - mTLS-based: cert fingerprint used as stable session identity
func CreateOrResumeSession(c *gin.Context, stores store.Stores, channelID string, cfg *config.Config) (*models.SessionParticipant, error) {
// Check for existing session cookie
token, _ := c.Cookie(sessionCookieName)
// mTLS mode: use cert fingerprint as stable token
if cfg.AuthMode == "mtls" {
fp := c.GetHeader("X-SSL-Client-Fingerprint")
if fp != "" {
token = "mtls:" + fp
}
}
// Resume existing session if token matches this channel
if token != "" {
session, err := stores.Sessions.GetByToken(c.Request.Context(), token)
if err == nil && session.ChannelID == channelID {
return session, nil
}
// Token exists but for different channel — fall through to create
if err != nil && err != sql.ErrNoRows {
log.Printf("[auth/session] warn: GetByToken error: %v", err)
}
}
// Create new session
token = "sess:" + uuid.New().String()
displayName, err := generateVisitorName(c.Request.Context(), stores, channelID)
if err != nil {
displayName = "Visitor"
}
session := &models.SessionParticipant{
SessionToken: token,
ChannelID: channelID,
DisplayName: displayName,
}
// mTLS mode: store fingerprint for team member visibility
if cfg.AuthMode == "mtls" {
fp := c.GetHeader("X-SSL-Client-Fingerprint")
if fp != "" {
session.Fingerprint = fp
}
}
if err := stores.Sessions.Create(c.Request.Context(), session); err != nil {
return nil, fmt.Errorf("create session: %w", err)
}
// Add as channel participant
_ = stores.Channels.AddParticipant(c.Request.Context(), &models.ChannelParticipant{
ChannelID: channelID,
ParticipantType: "session",
ParticipantID: session.ID,
Role: "visitor",
})
// Set cookie (httponly, secure, 30 day expiry)
c.SetCookie(sessionCookieName, token, 60*60*24*30, "/", "", true, true)
log.Printf("[auth/session] created session %s for channel %s (%s)", session.ID, channelID, displayName)
return session, nil
}
// generateVisitorName produces "Visitor #N" based on existing session count.
func generateVisitorName(ctx context.Context, stores store.Stores, channelID string) (string, error) {
count, err := stores.Sessions.CountForChannel(ctx, channelID)
if err != nil {
return "", err
}
return fmt.Sprintf("Visitor #%d", count+1), nil
}

View File

@@ -0,0 +1,24 @@
-- 021_sessions.sql
-- Anonymous / session participants for workflow channels (v0.24.3)
CREATE TABLE IF NOT EXISTS session_participants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_token TEXT NOT NULL UNIQUE,
channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
display_name TEXT NOT NULL DEFAULT 'Visitor',
fingerprint TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_session_participants_channel
ON session_participants(channel_id);
CREATE INDEX IF NOT EXISTS idx_session_participants_token
ON session_participants(session_token);
COMMENT ON TABLE session_participants IS 'Ephemeral identities for anonymous workflow channel visitors.';
-- Allow channels to opt in to anonymous session access.
ALTER TABLE channels ADD COLUMN IF NOT EXISTS allow_anonymous BOOLEAN NOT NULL DEFAULT FALSE;
COMMENT ON COLUMN channels.allow_anonymous IS 'When true, unauthenticated visitors can join via session token (workflow channels only).';

View File

@@ -0,0 +1,20 @@
-- 021_sessions.sql (SQLite)
-- Anonymous / session participants for workflow channels (v0.24.3)
CREATE TABLE IF NOT EXISTS session_participants (
id TEXT PRIMARY KEY,
session_token TEXT NOT NULL UNIQUE,
channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
display_name TEXT NOT NULL DEFAULT 'Visitor',
fingerprint TEXT,
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_session_participants_channel
ON session_participants(channel_id);
CREATE INDEX IF NOT EXISTS idx_session_participants_token
ON session_participants(session_token);
-- Allow channels to opt in to anonymous session access.
ALTER TABLE channels ADD COLUMN allow_anonymous INTEGER NOT NULL DEFAULT 0;

View File

@@ -120,6 +120,21 @@ func getUserID(c *gin.Context) string {
return s
}
// isSessionAuth returns true if the request is from a session participant (v0.24.3).
func isSessionAuth(c *gin.Context) bool {
return c.GetString("auth_type") == "session"
}
// sessionCanAccessChannel validates that a session participant is authorized
// for the given channel. The AuthOrSession middleware already verifies this,
// but handlers call this as a defense-in-depth check.
func sessionCanAccessChannel(c *gin.Context, channelID string) bool {
if !isSessionAuth(c) {
return false
}
return c.GetString("channel_id") == channelID
}
// parsePagination reads page and per_page from query params with defaults.
func parsePagination(c *gin.Context) (page, perPage, offset int) {
page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))

View File

@@ -194,6 +194,10 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
channelID := req.ChannelID
if channelID == "" {
// v0.24.3: Workflow API routes have channel in URL param
channelID = c.Param("id")
}
if channelID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "channel_id is required"})
return
@@ -201,13 +205,25 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
userID := getUserID(c)
// Verify participation: check channel_participants first, fall back to
// legacy channels.user_id ownership for direct channels.
isParticipant, _ := h.stores.Channels.IsParticipant(c.Request.Context(), channelID, "user", userID)
if !isParticipant {
if !userOwnsChannel(c, channelID, userID) {
// v0.24.3: Session participants are pre-validated by AuthOrSession middleware
if isSessionAuth(c) {
if !sessionCanAccessChannel(c, channelID) {
c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"})
return
}
// Use session_id as effective identity for cursor/participant tracking.
// persistMessage will record participant_type=user with this ID —
// acceptable for v0.24.3; v0.25.0 can refine to participant_type=session.
userID = c.GetString("session_id")
} else {
// Verify participation: check channel_participants first, fall back to
// legacy channels.user_id ownership for direct channels.
isParticipant, _ := h.stores.Channels.IsParticipant(c.Request.Context(), channelID, "user", userID)
if !isParticipant {
if !userOwnsChannel(c, channelID, userID) {
return
}
}
}
// ── ai_mode guard (v0.23.1) ────────────────────────────────────────────────
@@ -630,8 +646,10 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
// Budget enforcement and model allowlist only apply when the request draws
// from a team or global provider. BYOK (personal scope) is the user's money
// — no ceiling applies.
// v0.24.3: Session participants bypass user-level budget/allowlist checks.
// They use the workflow channel's allocated resources.
role, _ := c.Get("role")
if role != "admin" && providerScope != "personal" {
if role != "admin" && providerScope != "personal" && !isSessionAuth(c) {
// Token budget
if h.stores.Usage != nil {
budget, err := auth.ResolveTokenBudget(c.Request.Context(), h.stores, userID)

View File

@@ -87,7 +87,13 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
if !userOwnsChannel(c, channelID, userID) {
// v0.24.3: Session participants are pre-validated by AuthOrSession middleware
if isSessionAuth(c) {
if !sessionCanAccessChannel(c, channelID) {
c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"})
return
}
} else if !userOwnsChannel(c, channelID, userID) {
return
}
@@ -193,16 +199,31 @@ func (h *MessageHandler) CreateMessage(c *gin.Context) {
userID := getUserID(c)
channelID := c.Param("id")
if !userOwnsChannel(c, channelID, userID) {
// v0.24.3: Session participants are pre-validated by AuthOrSession middleware
if isSessionAuth(c) {
if !sessionCanAccessChannel(c, channelID) {
c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"})
return
}
} else if !userOwnsChannel(c, channelID, userID) {
return
}
// Use cursor for parent, compute sibling_index
parentID, _ := getActiveLeaf(channelID, userID)
// Sessions use the same cursor logic (empty userID = channel-level latest)
effectiveUserID := userID
if isSessionAuth(c) {
effectiveUserID = c.GetString("session_id")
}
parentID, _ := getActiveLeaf(channelID, effectiveUserID)
siblingIdx := nextSiblingIndex(channelID, parentID)
participantType := "user"
participantID := userID
if isSessionAuth(c) {
participantType = "session"
participantID = c.GetString("session_id")
}
if req.Role == "assistant" {
participantType = "model"
if req.Model != "" {

View File

@@ -988,6 +988,27 @@ func main() {
settingsPages.GET("/:section", pageEngine.RenderSurface("settings"))
}
// ── Workflow routes (v0.24.3) ────────────
// Anonymous session entry point for workflow channels.
// AuthOrSession tries JWT first, falls back to session cookie.
wfPages := base.Group("/w")
wfPages.Use(middleware.AuthOrSession(cfg, stores))
{
wfPages.GET("/:id", pageEngine.RenderWorkflow())
}
// Workflow API — session participants can send messages + trigger completions
wfAPI := base.Group("/api/v1/w")
wfAPI.Use(middleware.AuthOrSession(cfg, stores))
{
wfMsgs := handlers.NewMessageHandler(keyResolver, stores, hub, objStore)
wfAPI.POST("/:id/messages", wfMsgs.CreateMessage)
wfAPI.GET("/:id/messages", wfMsgs.ListMessages)
wfComp := handlers.NewCompletionHandler(keyResolver, stores, hub, objStore, kbEmbedder)
wfAPI.POST("/:id/completions", wfComp.Complete)
}
bp := cfg.BasePath
if bp == "" {
bp = "/"

View File

@@ -0,0 +1,122 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"git.gobha.me/xcaliber/chat-switchboard/auth"
"git.gobha.me/xcaliber/chat-switchboard/config"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// AuthOrSession returns middleware that accepts either a normal JWT or a
// session cookie. Authenticated users get the standard context values
// (user_id, email, role, auth_type="user"). Session visitors get
// session_id, channel_id, and auth_type="session".
//
// Session auth is only valid for workflow channels with allow_anonymous=true.
func AuthOrSession(cfg *config.Config, stores store.Stores) gin.HandlerFunc {
return func(c *gin.Context) {
// Skip auth when running without a database
if !database.IsConnected() {
c.Next()
return
}
// ── Try normal JWT auth first ──
tokenString := extractBearerToken(c)
if tokenString != "" {
claims := &Claims{}
token, err := jwt.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(cfg.JWTSecret), nil
})
if err == nil && token.Valid {
c.Set("user_id", claims.UserID)
c.Set("email", claims.Email)
c.Set("role", claims.Role)
c.Set("auth_type", "user")
c.Next()
return
}
}
// ── Try sb_token cookie (page auth pattern) ──
if cookie, err := c.Cookie("sb_token"); err == nil && cookie != "" {
claims := &Claims{}
token, err := jwt.ParseWithClaims(cookie, claims, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
return []byte(cfg.JWTSecret), nil
})
if err == nil && token.Valid {
c.Set("user_id", claims.UserID)
c.Set("email", claims.Email)
c.Set("role", claims.Role)
c.Set("auth_type", "user")
c.Next()
return
}
}
// ── Fall back to session auth ──
channelID := c.Param("id") // channel routes use :id
if channelID == "" {
channelID = c.Param("channelId")
}
if channelID == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
// Verify channel exists, is workflow type, and allows anonymous
var chType string
var allowAnon bool
err := database.DB.QueryRowContext(c.Request.Context(),
database.Q(`SELECT type, allow_anonymous FROM channels WHERE id = $1`),
channelID,
).Scan(&chType, &allowAnon)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
if chType != "workflow" || !allowAnon {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "anonymous access not permitted on this channel"})
return
}
session, err := auth.CreateOrResumeSession(c, stores, channelID, cfg)
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "session creation failed"})
return
}
c.Set("session_id", session.ID)
c.Set("session_token", session.SessionToken)
c.Set("channel_id", channelID)
c.Set("auth_type", "session")
c.Next()
}
}
// extractBearerToken gets the JWT from Authorization header or ?token= query param.
func extractBearerToken(c *gin.Context) string {
header := c.GetHeader("Authorization")
if header == "" {
if qToken := c.Query("token"); qToken != "" {
return qToken
}
return ""
}
if strings.HasPrefix(header, "Bearer ") {
return strings.TrimPrefix(header, "Bearer ")
}
return ""
}

View File

@@ -334,6 +334,22 @@ type Channel struct {
ProjectID *string `json:"project_id,omitempty" db:"project_id"`
WorkspaceID *string `json:"workspace_id,omitempty" db:"workspace_id"`
Settings JSONMap `json:"settings,omitempty" db:"settings"`
AllowAnonymous bool `json:"allow_anonymous" db:"allow_anonymous"` // v0.24.3: session participants can join
}
// =========================================
// SESSION PARTICIPANTS (v0.24.3)
// =========================================
// SessionParticipant is an ephemeral identity for anonymous workflow
// channel visitors. Scoped to a single channel, no users row required.
type SessionParticipant struct {
ID string `json:"id" db:"id"`
SessionToken string `json:"session_token" db:"session_token"`
ChannelID string `json:"channel_id" db:"channel_id"`
DisplayName string `json:"display_name" db:"display_name"`
Fingerprint string `json:"fingerprint,omitempty" db:"fingerprint"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
// =========================================

View File

@@ -217,6 +217,60 @@ func (e *Engine) RenderLogin() gin.HandlerFunc {
}
}
// WorkflowPageData is passed to the workflow.html template via PageData.Data.
type WorkflowPageData struct {
ChannelID string
ChannelTitle string
ChannelDescription string
SessionID string
SessionName string
}
// RenderWorkflow renders the workflow entry page for anonymous session visitors.
// The middleware (AuthOrSession) has already created/resumed the session.
func (e *Engine) RenderWorkflow() gin.HandlerFunc {
return func(c *gin.Context) {
channelID := c.GetString("channel_id")
sessionID := c.GetString("session_id")
// Load channel metadata
var title, description string
if e.stores.Channels != nil && channelID != "" {
ch, err := e.stores.Channels.GetByID(c.Request.Context(), channelID)
if err == nil {
title = ch.Title
description = ch.Description
}
}
if title == "" {
title = "Workflow"
}
// Load session display name
sessionName := "Visitor"
if e.stores.Sessions != nil && sessionID != "" {
sp, err := e.stores.Sessions.GetByID(c.Request.Context(), sessionID)
if err == nil {
sessionName = sp.DisplayName
}
}
instanceName, _, _ := e.loadBranding()
e.Render(c, "workflow.html", PageData{
Surface: "workflow",
InstanceName: instanceName,
Data: WorkflowPageData{
ChannelID: channelID,
ChannelTitle: title,
ChannelDescription: description,
SessionID: sessionID,
SessionName: sessionName,
},
})
}
}
// getUserContext extracts user info from gin context (set by auth middleware).
func (e *Engine) getUserContext(c *gin.Context) *UserContext {
userID, exists := c.Get("user_id")

View File

@@ -0,0 +1,162 @@
{{define "workflow.html"}}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Data.ChannelTitle}} — {{.InstanceName}}</title>
<link rel="icon" type="image/svg+xml" href="{{.BasePath}}/favicon.svg?v={{.Version}}">
<link rel="icon" type="image/x-icon" href="{{.BasePath}}/favicon.ico?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/variables.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/primitives.css?v={{.Version}}">
<link rel="stylesheet" href="{{.BasePath}}/css/chat.css?v={{.Version}}">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: var(--font); background: var(--bg); color: var(--text); }
.wf-shell { display: flex; flex-direction: column; height: 100vh; }
.wf-header {
padding: 16px 24px;
border-bottom: 1px solid var(--border);
background: var(--bg-surface);
}
.wf-header h1 { font-size: 18px; font-weight: 600; }
.wf-header p { font-size: 13px; color: var(--text-2); margin-top: 4px; }
.wf-chat {
flex: 1; overflow-y: auto; padding: 16px 24px;
}
.wf-chat .message {
margin-bottom: 12px; padding: 10px 14px;
border-radius: 8px; max-width: 80%;
}
.wf-chat .message.user { background: var(--accent-dim); margin-left: auto; }
.wf-chat .message.assistant { background: var(--bg-raised); }
.wf-chat .message .meta { font-size: 11px; color: var(--text-3); margin-bottom: 4px; }
.wf-chat .message .content { font-size: 14px; line-height: 1.5; white-space: pre-wrap; }
.wf-input {
padding: 12px 24px; border-top: 1px solid var(--border);
background: var(--bg-surface); display: flex; gap: 8px;
}
.wf-input textarea {
flex: 1; resize: none; background: var(--input-bg); color: var(--text);
border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px;
font-family: var(--font); font-size: 14px; min-height: 42px; max-height: 160px;
}
.wf-input textarea:focus { outline: none; border-color: var(--accent); }
.wf-input button {
background: var(--accent); color: #fff; border: none; border-radius: 8px;
padding: 0 20px; font-weight: 600; cursor: pointer; font-size: 14px;
}
.wf-input button:hover { background: var(--accent-hover); }
.wf-input button:disabled { opacity: 0.5; cursor: default; }
.wf-session-info {
padding: 8px 24px; font-size: 12px; color: var(--text-3);
border-top: 1px solid var(--border); background: var(--bg);
}
</style>
<script>
(function() {
try {
var p = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
var mode = p.theme || 'system';
var resolved = mode;
if (mode === 'system') resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', resolved);
} catch(e) {}
})();
</script>
</head>
<body>
<div class="wf-shell">
<div class="wf-header">
<h1>{{.Data.ChannelTitle}}</h1>
{{if .Data.ChannelDescription}}<p>{{.Data.ChannelDescription}}</p>{{end}}
</div>
<div class="wf-chat" id="chatMessages"></div>
<div class="wf-input">
<textarea id="chatInput" placeholder="Type a message…" rows="1"
onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();sendMessage()}"></textarea>
<button id="sendBtn" onclick="sendMessage()">Send</button>
</div>
<div class="wf-session-info">
Chatting as <strong>{{.Data.SessionName}}</strong>
</div>
</div>
<script>
(function() {
const BASE = '{{.BasePath}}';
const CHAN_ID = '{{.Data.ChannelID}}';
const SESSION_ID = '{{.Data.SessionID}}';
const chatEl = document.getElementById('chatMessages');
const inputEl = document.getElementById('chatInput');
const sendBtn = document.getElementById('sendBtn');
let sending = false;
function addMessage(role, content, name) {
const div = document.createElement('div');
div.className = 'message ' + role;
const meta = name ? '<div class="meta">' + escHtml(name) + '</div>' : '';
div.innerHTML = meta + '<div class="content">' + escHtml(content) + '</div>';
chatEl.appendChild(div);
chatEl.scrollTop = chatEl.scrollHeight;
}
function escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
window.sendMessage = async function() {
const text = inputEl.value.trim();
if (!text || sending) return;
sending = true;
sendBtn.disabled = true;
inputEl.value = '';
addMessage('user', text, '{{.Data.SessionName}}');
// The completion endpoint persists the user message and generates the response
try {
const resp = await fetch(BASE + '/api/v1/w/' + CHAN_ID + '/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ channel_id: CHAN_ID, content: text, stream: false }),
});
if (resp.ok) {
const data = await resp.json();
if (data.content) {
addMessage('assistant', data.content, data.persona_name || 'Assistant');
}
} else {
const err = await resp.json().catch(() => ({}));
addMessage('assistant', 'Error: ' + (err.error || resp.statusText));
}
} catch(e) {
addMessage('assistant', 'Network error: ' + e.message);
}
sending = false;
sendBtn.disabled = false;
inputEl.focus();
};
// Auto-resize textarea
inputEl.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = Math.min(this.scrollHeight, 160) + 'px';
});
})();
</script>
</body>
</html>
{{end}}

View File

@@ -46,6 +46,7 @@ type Stores struct {
GitCredentials GitCredentialStore
CapOverrides CapabilityOverrideStore
RoutingPolicies RoutingPolicyStore
Sessions SessionStore
}
// =========================================
@@ -634,6 +635,19 @@ type RoutingPolicyStore interface {
ListForTeam(ctx context.Context, teamID string) ([]models.RoutingPolicy, error)
}
// =========================================
// SESSION STORE (v0.24.3)
// =========================================
type SessionStore interface {
Create(ctx context.Context, s *models.SessionParticipant) error
GetByToken(ctx context.Context, token string) (*models.SessionParticipant, error)
GetByID(ctx context.Context, id string) (*models.SessionParticipant, error)
ListForChannel(ctx context.Context, channelID string) ([]models.SessionParticipant, error)
CountForChannel(ctx context.Context, channelID string) (int, error)
Delete(ctx context.Context, id string) error
}
// =========================================
// SHARED TYPES
// =========================================

View File

@@ -0,0 +1,96 @@
package postgres
import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/models"
)
// ── SessionStore ───────────────────────────
type SessionStore struct{}
func NewSessionStore() *SessionStore { return &SessionStore{} }
func (s *SessionStore) Create(ctx context.Context, sp *models.SessionParticipant) error {
return DB.QueryRowContext(ctx, `
INSERT INTO session_participants (session_token, channel_id, display_name, fingerprint)
VALUES ($1, $2, $3, $4)
RETURNING id, created_at`,
sp.SessionToken, sp.ChannelID, sp.DisplayName, nullText(sp.Fingerprint),
).Scan(&sp.ID, &sp.CreatedAt)
}
func (s *SessionStore) GetByToken(ctx context.Context, token string) (*models.SessionParticipant, error) {
sp := &models.SessionParticipant{}
err := DB.QueryRowContext(ctx, `
SELECT id, session_token, channel_id, display_name, COALESCE(fingerprint, ''), created_at
FROM session_participants WHERE session_token = $1`, token,
).Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, &sp.CreatedAt)
if err != nil {
return nil, err
}
return sp, nil
}
func (s *SessionStore) GetByID(ctx context.Context, id string) (*models.SessionParticipant, error) {
sp := &models.SessionParticipant{}
err := DB.QueryRowContext(ctx, `
SELECT id, session_token, channel_id, display_name, COALESCE(fingerprint, ''), created_at
FROM session_participants WHERE id = $1`, id,
).Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, &sp.CreatedAt)
if err != nil {
return nil, err
}
return sp, nil
}
func (s *SessionStore) ListForChannel(ctx context.Context, channelID string) ([]models.SessionParticipant, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, session_token, channel_id, display_name, COALESCE(fingerprint, ''), created_at
FROM session_participants WHERE channel_id = $1
ORDER BY created_at ASC`, channelID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.SessionParticipant
for rows.Next() {
var sp models.SessionParticipant
if err := rows.Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, &sp.CreatedAt); err != nil {
return nil, err
}
result = append(result, sp)
}
return result, rows.Err()
}
func (s *SessionStore) CountForChannel(ctx context.Context, channelID string) (int, error) {
var count int
err := DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM session_participants WHERE channel_id = $1`, channelID,
).Scan(&count)
return count, err
}
func (s *SessionStore) Delete(ctx context.Context, id string) error {
res, err := DB.ExecContext(ctx, `DELETE FROM session_participants WHERE id = $1`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
// nullText returns nil for empty strings.
func nullText(s string) interface{} {
if s == "" {
return nil
}
return s
}

View File

@@ -39,5 +39,6 @@ func NewStores(db *sql.DB) store.Stores {
GitCredentials: &GitCredentialStore{},
CapOverrides: NewCapOverrideStore(db),
RoutingPolicies: NewRoutingPolicyStore(db),
Sessions: NewSessionStore(),
}
}

View File

@@ -0,0 +1,103 @@
package sqlite
import (
"context"
"database/sql"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── SessionStore ───────────────────────────
type SessionStore struct{}
func NewSessionStore() *SessionStore { return &SessionStore{} }
func (s *SessionStore) Create(ctx context.Context, sp *models.SessionParticipant) error {
if sp.ID == "" {
sp.ID = store.NewID()
}
_, err := DB.ExecContext(ctx, `
INSERT INTO session_participants (id, session_token, channel_id, display_name, fingerprint)
VALUES (?, ?, ?, ?, ?)`,
sp.ID, sp.SessionToken, sp.ChannelID, sp.DisplayName, nullText(sp.Fingerprint),
)
if err != nil {
return err
}
return DB.QueryRowContext(ctx, `SELECT created_at FROM session_participants WHERE id = ?`, sp.ID).
Scan(&sp.CreatedAt)
}
func (s *SessionStore) GetByToken(ctx context.Context, token string) (*models.SessionParticipant, error) {
sp := &models.SessionParticipant{}
err := DB.QueryRowContext(ctx, `
SELECT id, session_token, channel_id, display_name, COALESCE(fingerprint, ''), created_at
FROM session_participants WHERE session_token = ?`, token,
).Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, &sp.CreatedAt)
if err != nil {
return nil, err
}
return sp, nil
}
func (s *SessionStore) GetByID(ctx context.Context, id string) (*models.SessionParticipant, error) {
sp := &models.SessionParticipant{}
err := DB.QueryRowContext(ctx, `
SELECT id, session_token, channel_id, display_name, COALESCE(fingerprint, ''), created_at
FROM session_participants WHERE id = ?`, id,
).Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, &sp.CreatedAt)
if err != nil {
return nil, err
}
return sp, nil
}
func (s *SessionStore) ListForChannel(ctx context.Context, channelID string) ([]models.SessionParticipant, error) {
rows, err := DB.QueryContext(ctx, `
SELECT id, session_token, channel_id, display_name, COALESCE(fingerprint, ''), created_at
FROM session_participants WHERE channel_id = ?
ORDER BY created_at ASC`, channelID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []models.SessionParticipant
for rows.Next() {
var sp models.SessionParticipant
if err := rows.Scan(&sp.ID, &sp.SessionToken, &sp.ChannelID, &sp.DisplayName, &sp.Fingerprint, &sp.CreatedAt); err != nil {
return nil, err
}
result = append(result, sp)
}
return result, rows.Err()
}
func (s *SessionStore) CountForChannel(ctx context.Context, channelID string) (int, error) {
var count int
err := DB.QueryRowContext(ctx, `
SELECT COUNT(*) FROM session_participants WHERE channel_id = ?`, channelID,
).Scan(&count)
return count, err
}
func (s *SessionStore) Delete(ctx context.Context, id string) error {
res, err := DB.ExecContext(ctx, `DELETE FROM session_participants WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return sql.ErrNoRows
}
return nil
}
func nullText(s string) interface{} {
if s == "" {
return nil
}
return s
}

View File

@@ -39,5 +39,6 @@ func NewStores(db *sql.DB) store.Stores {
GitCredentials: &GitCredentialStore{},
CapOverrides: NewCapOverrideStore(),
RoutingPolicies: NewRoutingPolicyStore(),
Sessions: NewSessionStore(),
}
}