diff --git a/CHANGELOG.md b/CHANGELOG.md index 46b2c2a..2f3e8ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/VERSION b/VERSION index 8b95abd..fef426c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.24.2 +0.24.3 \ No newline at end of file diff --git a/docs/DESIGN-0.24.0.md b/docs/DESIGN-0.24.0.md index 27bfcfe..cfb3572 100644 --- a/docs/DESIGN-0.24.0.md +++ b/docs/DESIGN-0.24.0.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index da8bcc6..6c04bf5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 --- diff --git a/server/auth/session.go b/server/auth/session.go new file mode 100644 index 0000000..52fcea8 --- /dev/null +++ b/server/auth/session.go @@ -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 +} diff --git a/server/database/migrations/021_sessions.sql b/server/database/migrations/021_sessions.sql new file mode 100644 index 0000000..4fd0e54 --- /dev/null +++ b/server/database/migrations/021_sessions.sql @@ -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).'; diff --git a/server/database/migrations/sqlite/021_sessions.sql b/server/database/migrations/sqlite/021_sessions.sql new file mode 100644 index 0000000..dc15682 --- /dev/null +++ b/server/database/migrations/sqlite/021_sessions.sql @@ -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; diff --git a/server/handlers/channels.go b/server/handlers/channels.go index a69fcdc..0861929 100644 --- a/server/handlers/channels.go +++ b/server/handlers/channels.go @@ -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")) diff --git a/server/handlers/completion.go b/server/handlers/completion.go index ca7b0a3..11b8673 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -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) diff --git a/server/handlers/messages.go b/server/handlers/messages.go index 2d3ddeb..da5fd1c 100644 --- a/server/handlers/messages.go +++ b/server/handlers/messages.go @@ -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 != "" { diff --git a/server/main.go b/server/main.go index 770c019..7ffdbaf 100644 --- a/server/main.go +++ b/server/main.go @@ -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 = "/" diff --git a/server/middleware/session_auth.go b/server/middleware/session_auth.go new file mode 100644 index 0000000..8de3e00 --- /dev/null +++ b/server/middleware/session_auth.go @@ -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 "" +} diff --git a/server/models/models.go b/server/models/models.go index f360916..4aa25b9 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -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"` } // ========================================= diff --git a/server/pages/pages.go b/server/pages/pages.go index 67e3f48..5de66f0 100644 --- a/server/pages/pages.go +++ b/server/pages/pages.go @@ -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") diff --git a/server/pages/templates/workflow.html b/server/pages/templates/workflow.html new file mode 100644 index 0000000..6c9055e --- /dev/null +++ b/server/pages/templates/workflow.html @@ -0,0 +1,162 @@ +{{define "workflow.html"}} + + +
+ + +{{.Data.ChannelDescription}}
{{end}} +