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

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(),
}
}