Changeset 0.24.3 (#159)
This commit is contained in:
22
CHANGELOG.md
22
CHANGELOG.md
@@ -2,6 +2,28 @@
|
|||||||
|
|
||||||
All notable changes to Chat Switchboard.
|
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
|
## [0.24.2] — 2026-03-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -663,7 +663,7 @@ separate table. This avoids a join-heavy permission resolution
|
|||||||
path and keeps the grant model simple.
|
path and keeps the grant model simple.
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- 019_v024_permissions.sql
|
-- 020_permissions.sql
|
||||||
|
|
||||||
ALTER TABLE groups
|
ALTER TABLE groups
|
||||||
ADD COLUMN IF NOT EXISTS permissions JSONB NOT NULL DEFAULT '[]'::jsonb;
|
ADD COLUMN IF NOT EXISTS permissions JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||||
@@ -895,10 +895,10 @@ type SessionParticipant struct {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Migration 020
|
### Migration 021
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
-- 020_v024_sessions.sql
|
-- 021_sessions.sql
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS session_participants (
|
CREATE TABLE IF NOT EXISTS session_participants (
|
||||||
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
||||||
@@ -1054,7 +1054,7 @@ session participant activity attributed to the session identity:
|
|||||||
|
|
||||||
### What Ships
|
### What Ships
|
||||||
|
|
||||||
- Migration 020 (session_participants table)
|
- Migration 021 (session_participants table, channels.allow_anonymous)
|
||||||
- `server/auth/session.go` (session creation/resume)
|
- `server/auth/session.go` (session creation/resume)
|
||||||
- `middleware/session_auth.go` (AuthOrSession)
|
- `middleware/session_auth.go` (AuthOrSession)
|
||||||
- Session-scoped message and completion handlers
|
- 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
|
018_v024_auth.sql — users.handle, auth_source, external_id
|
||||||
019_v024_permissions.sql — groups.permissions, token_budget, allowed_models
|
019_v024_oidc.sql — oidc_auth_state, groups.source
|
||||||
020_v024_sessions.sql — session_participants table
|
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.
|
changes. Can be applied sequentially as each subversion merges.
|
||||||
SQLite equivalents for each.
|
SQLite equivalents for each.
|
||||||
|
|
||||||
|
|||||||
@@ -119,13 +119,13 @@ v0.23.1 Multi-User Navigation + Conversation Taxonomy ✅
|
|||||||
│
|
│
|
||||||
v0.23.2 Multi-User Polish + Channel Lifecycle ✅
|
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.
|
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.
|
See [DESIGN-0.24.0.md](DESIGN-0.24.0.md) §v0.24.3 for full spec.
|
||||||
|
|
||||||
- [ ] `session_participants` table (channel-scoped, ephemeral token)
|
- [x] `session_participants` table (channel-scoped, ephemeral token)
|
||||||
- [ ] Session JWT: `SessionClaims{SessionID, ChannelID}` — distinct from user JWT
|
- [x] `AuthOrSession()` middleware: tries JWT first, falls back to session cookie (cookie-based, no separate session JWT needed)
|
||||||
- [ ] `SessionOrAuth()` middleware: accepts either JWT type
|
- [x] Capability scoping: session participants limited to their bound channel
|
||||||
- [ ] Capability scoping: session participants limited to their bound channel
|
- [x] mTLS anonymous path: cert fingerprint as stable session identity
|
||||||
- [ ] mTLS anonymous path: cert fingerprint as stable session identity
|
- [x] `channels.allow_anonymous` flag
|
||||||
- [ ] `channels.allow_anonymous` flag
|
- [x] Session added as channel participant (`participant_type=session`, `role=visitor`)
|
||||||
- [ ] Session display in participant list (team members see visitor identity)
|
- [x] Workflow entry page: `GET /w/:id` (Go template, standalone chat UI)
|
||||||
- [ ] Migration 021: `session_participants` table, `allow_anonymous` column
|
- [x] Migration 021: `session_participants` table, `allow_anonymous` column
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
96
server/auth/session.go
Normal file
96
server/auth/session.go
Normal 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
|
||||||
|
}
|
||||||
24
server/database/migrations/021_sessions.sql
Normal file
24
server/database/migrations/021_sessions.sql
Normal 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).';
|
||||||
20
server/database/migrations/sqlite/021_sessions.sql
Normal file
20
server/database/migrations/sqlite/021_sessions.sql
Normal 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;
|
||||||
@@ -120,6 +120,21 @@ func getUserID(c *gin.Context) string {
|
|||||||
return s
|
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.
|
// parsePagination reads page and per_page from query params with defaults.
|
||||||
func parsePagination(c *gin.Context) (page, perPage, offset int) {
|
func parsePagination(c *gin.Context) (page, perPage, offset int) {
|
||||||
page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ = strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
|||||||
@@ -194,6 +194,10 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
channelID := req.ChannelID
|
channelID := req.ChannelID
|
||||||
|
if channelID == "" {
|
||||||
|
// v0.24.3: Workflow API routes have channel in URL param
|
||||||
|
channelID = c.Param("id")
|
||||||
|
}
|
||||||
if channelID == "" {
|
if channelID == "" {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "channel_id is required"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "channel_id is required"})
|
||||||
return
|
return
|
||||||
@@ -201,13 +205,25 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
|
|||||||
|
|
||||||
userID := getUserID(c)
|
userID := getUserID(c)
|
||||||
|
|
||||||
// Verify participation: check channel_participants first, fall back to
|
// v0.24.3: Session participants are pre-validated by AuthOrSession middleware
|
||||||
// legacy channels.user_id ownership for direct channels.
|
if isSessionAuth(c) {
|
||||||
isParticipant, _ := h.stores.Channels.IsParticipant(c.Request.Context(), channelID, "user", userID)
|
if !sessionCanAccessChannel(c, channelID) {
|
||||||
if !isParticipant {
|
c.JSON(http.StatusForbidden, gin.H{"error": "session not authorized for this channel"})
|
||||||
if !userOwnsChannel(c, channelID, userID) {
|
|
||||||
return
|
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) ────────────────────────────────────────────────
|
// ── 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
|
// 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
|
// from a team or global provider. BYOK (personal scope) is the user's money
|
||||||
// — no ceiling applies.
|
// — 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")
|
role, _ := c.Get("role")
|
||||||
if role != "admin" && providerScope != "personal" {
|
if role != "admin" && providerScope != "personal" && !isSessionAuth(c) {
|
||||||
// Token budget
|
// Token budget
|
||||||
if h.stores.Usage != nil {
|
if h.stores.Usage != nil {
|
||||||
budget, err := auth.ResolveTokenBudget(c.Request.Context(), h.stores, userID)
|
budget, err := auth.ResolveTokenBudget(c.Request.Context(), h.stores, userID)
|
||||||
|
|||||||
@@ -87,7 +87,13 @@ func (h *MessageHandler) ListMessages(c *gin.Context) {
|
|||||||
userID := getUserID(c)
|
userID := getUserID(c)
|
||||||
channelID := c.Param("id")
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,16 +199,31 @@ func (h *MessageHandler) CreateMessage(c *gin.Context) {
|
|||||||
userID := getUserID(c)
|
userID := getUserID(c)
|
||||||
channelID := c.Param("id")
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use cursor for parent, compute sibling_index
|
// 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)
|
siblingIdx := nextSiblingIndex(channelID, parentID)
|
||||||
|
|
||||||
participantType := "user"
|
participantType := "user"
|
||||||
participantID := userID
|
participantID := userID
|
||||||
|
if isSessionAuth(c) {
|
||||||
|
participantType = "session"
|
||||||
|
participantID = c.GetString("session_id")
|
||||||
|
}
|
||||||
if req.Role == "assistant" {
|
if req.Role == "assistant" {
|
||||||
participantType = "model"
|
participantType = "model"
|
||||||
if req.Model != "" {
|
if req.Model != "" {
|
||||||
|
|||||||
@@ -988,6 +988,27 @@ func main() {
|
|||||||
settingsPages.GET("/:section", pageEngine.RenderSurface("settings"))
|
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
|
bp := cfg.BasePath
|
||||||
if bp == "" {
|
if bp == "" {
|
||||||
bp = "/"
|
bp = "/"
|
||||||
|
|||||||
122
server/middleware/session_auth.go
Normal file
122
server/middleware/session_auth.go
Normal 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 ""
|
||||||
|
}
|
||||||
@@ -334,6 +334,22 @@ type Channel struct {
|
|||||||
ProjectID *string `json:"project_id,omitempty" db:"project_id"`
|
ProjectID *string `json:"project_id,omitempty" db:"project_id"`
|
||||||
WorkspaceID *string `json:"workspace_id,omitempty" db:"workspace_id"`
|
WorkspaceID *string `json:"workspace_id,omitempty" db:"workspace_id"`
|
||||||
Settings JSONMap `json:"settings,omitempty" db:"settings"`
|
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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|||||||
@@ -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).
|
// getUserContext extracts user info from gin context (set by auth middleware).
|
||||||
func (e *Engine) getUserContext(c *gin.Context) *UserContext {
|
func (e *Engine) getUserContext(c *gin.Context) *UserContext {
|
||||||
userID, exists := c.Get("user_id")
|
userID, exists := c.Get("user_id")
|
||||||
|
|||||||
162
server/pages/templates/workflow.html
Normal file
162
server/pages/templates/workflow.html
Normal 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}}
|
||||||
@@ -46,6 +46,7 @@ type Stores struct {
|
|||||||
GitCredentials GitCredentialStore
|
GitCredentials GitCredentialStore
|
||||||
CapOverrides CapabilityOverrideStore
|
CapOverrides CapabilityOverrideStore
|
||||||
RoutingPolicies RoutingPolicyStore
|
RoutingPolicies RoutingPolicyStore
|
||||||
|
Sessions SessionStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
@@ -634,6 +635,19 @@ type RoutingPolicyStore interface {
|
|||||||
ListForTeam(ctx context.Context, teamID string) ([]models.RoutingPolicy, error)
|
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
|
// SHARED TYPES
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|||||||
96
server/store/postgres/sessions.go
Normal file
96
server/store/postgres/sessions.go
Normal 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
|
||||||
|
}
|
||||||
@@ -39,5 +39,6 @@ func NewStores(db *sql.DB) store.Stores {
|
|||||||
GitCredentials: &GitCredentialStore{},
|
GitCredentials: &GitCredentialStore{},
|
||||||
CapOverrides: NewCapOverrideStore(db),
|
CapOverrides: NewCapOverrideStore(db),
|
||||||
RoutingPolicies: NewRoutingPolicyStore(db),
|
RoutingPolicies: NewRoutingPolicyStore(db),
|
||||||
|
Sessions: NewSessionStore(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
103
server/store/sqlite/sessions.go
Normal file
103
server/store/sqlite/sessions.go
Normal 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
|
||||||
|
}
|
||||||
@@ -39,5 +39,6 @@ func NewStores(db *sql.DB) store.Stores {
|
|||||||
GitCredentials: &GitCredentialStore{},
|
GitCredentials: &GitCredentialStore{},
|
||||||
CapOverrides: NewCapOverrideStore(),
|
CapOverrides: NewCapOverrideStore(),
|
||||||
RoutingPolicies: NewRoutingPolicyStore(),
|
RoutingPolicies: NewRoutingPolicyStore(),
|
||||||
|
Sessions: NewSessionStore(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user