From a63728a48127d4be0394f6479bc9d8943a4d3056 Mon Sep 17 00:00:00 2001 From: xcaliber Date: Sat, 7 Mar 2026 11:27:24 +0000 Subject: [PATCH] Changeset 0.24.0 (#156) --- CHANGELOG.md | 73 ++ VERSION | 2 +- docs/DESIGN-0.24.0.md | 1110 +++++++++++++++++ docs/ROADMAP.md | 302 +++-- server/auth/auth.go | 53 + server/auth/builtin.go | 130 ++ server/config/config.go | 5 + .../migrations/018_auth_abstraction.sql | 13 + .../sqlite/018_auth_abstraction.sql | 8 + server/database/testhelper.go | 13 +- server/handlers/auth.go | 113 +- server/handlers/auth_test.go | 4 +- server/handlers/completion.go | 10 +- server/handlers/integration_test.go | 3 +- server/handlers/presence.go | 9 +- server/main.go | 26 +- server/models/models.go | 3 + server/store/interfaces.go | 2 + server/store/postgres/user.go | 156 +-- server/store/sqlite/user.go | 160 +-- src/js/app-state.js | 2 + src/js/channel-models.js | 24 +- src/js/projects-ui.js | 14 +- 23 files changed, 1850 insertions(+), 385 deletions(-) create mode 100644 docs/DESIGN-0.24.0.md create mode 100644 server/auth/auth.go create mode 100644 server/auth/builtin.go create mode 100644 server/database/migrations/018_auth_abstraction.sql create mode 100644 server/database/migrations/sqlite/018_auth_abstraction.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 959d51f..5788e42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,79 @@ All notable changes to Chat Switchboard. +## [0.24.0] — 2026-03-06 + +### Added +- **Auth provider abstraction.** New `server/auth/` package with `Provider` interface: `Authenticate()`, `Register()`, `SupportsRegistration()`, `Mode()`. Three modes defined: `builtin` (implemented), `mtls` (v0.24.1), `oidc` (v0.24.1). `AUTH_MODE` env var selects provider at startup. mTLS/OIDC fail-fast with clear error if selected before implementation lands. +- **Builtin provider.** `auth.BuiltinProvider` extracts login/register logic from `handlers/auth.go` into the `Provider` interface. Identical behavior to v0.23.2 — reads `{login, password}` from request body, validates bcrypt hash, returns user with plaintext password as `VaultHint` for UEK unlock. +- **User handles.** `handle` column on `users` table — unique, auto-generated from username via `models.HandleFromName()`. Used as the `@mention` identifier for human users. Editable. `UniqueHandle()` appends `-2`, `-3` suffixes on collision (same pattern as persona handles). +- **Auth source tracking.** `auth_source` column on `users` table (`builtin`/`mtls`/`oidc`). `external_id` column for IdP subject IDs or cert fingerprints. Composite unique index on `(auth_source, external_id)`. +- **`GetByHandle()`** and **`GetByExternalID()`** on `UserStore` interface — both Postgres and SQLite implementations. +- **Design document.** `docs/DESIGN-0.24.0.md` — full spec for v0.24.0–v0.24.3 (auth abstraction, mTLS/OIDC, permissions, anonymous sessions). All open questions resolved. + +### Changed +- **Auth handler refactored.** `AuthHandler` now holds a `provider auth.Provider` field. `Login()` delegates to `provider.Authenticate()`, then handles vault unlock and JWT generation. `Register()` delegates to `provider.Register()`. `generateTokens()` response includes `handle` and `auth_source`. +- **`BootstrapAdmin()` / `SeedUsers()`** backfill `handle` for existing users on startup (idempotent — skipped if handle already set). New users created with `auth_source=builtin` and auto-generated handle. +- **@mention resolution uses handles.** `resolveMention()` steps 3–4 now query `LOWER(handle)` instead of `LOWER(username)`. Backend and frontend aligned. +- **User search includes handles.** `GET /api/v1/users/search` returns `handle` field and filters on handle alongside username and display_name. +- **Autocomplete matches on handle.** `channel-models.js` filters user candidates by handle, username, and display name. The `@mention` token inserted into the input is the user's handle (not username). +- **User store queries updated.** All `SELECT` statements in both Postgres and SQLite user stores include `auth_source`, `external_id`, `handle`. Unified `scanOneUser` helper in Postgres, `scanOne` in SQLite — eliminates per-method scan block duplication. +- **User list API.** `List()` response includes `auth_source` and `handle` for all users. + +### Migration Notes +- **DB wipe required.** Migration 018 adds three columns to `users`. Existing users backfilled with `auth_source='builtin'` and handle derived from username. +- **New columns:** `users.auth_source`, `users.external_id`, `users.handle`. +- **New indexes:** `idx_users_handle` (unique), `idx_users_external_id` (unique composite, partial). + +## [0.23.2] — 2026-03-06 + +### Added +- **Unified active conversation.** `App.activeConversation = { id, type }` replaces `App.currentChatId` and `App.currentChannelId`. All code paths (send, stream, model bar, session restore, sidebar highlight) keyed off `App.activeId`. `setActive(id, type)` method, `activeType` getter, `getActiveChat()` helper. +- **Group leader default response.** When a `type=group` channel receives a message without an @mention, the leader persona responds. Leader resolved from `persona_group_members WHERE is_leader` via channel participants. +- **`@all` fan-out.** `@all` in message content routes to every persona participant. Depth-1 only — responses from @all do not trigger further chains. +- **Human message attribution.** Messages from other human participants show their avatar and display name instead of "You". Persona attribution verified for loaded history in channel context. +- **Channel lifecycle.** Archive action via context menu (sets `is_archived`, `archived_at`). Delete gated by `channel_retention.mode` policy (`flexible` allows delete, `retain` forces archive-only). Admin purge with `purge_after_days` floor. Retention config keys in `global_config`. +- **Participant mutation guards.** DMs block removal below 2 human participants. Groups block removal of last persona (returns 400 with clear error). +- **Channel context banner.** Slim bar below chat header showing ai_mode, topic, and routing hints. Adapts by conversation type (DM: partner name + @mention hint; Group: leader/all routing; Channel: ai_mode + topic). +- **`NotifyUserMention` via WebSocket.** Replaced broken gin context interface cast with direct `hub.SendToUser()`. Delivers `user.mentioned` event with channel_id, from_user, content preview. +- **User mention notification toast.** `user.mentioned` WebSocket event handler shows toast with content preview and increments unread badge. + +### Fixed +- **Channel persistence through refresh.** `loadChannels()` read `resp.channels` but `ListChannels` returns paginated response with `data` key. Channels vanished on reload. +- **Folder drag-and-drop.** Three compounding issues: `folderId` never mapped in `loadChats()`, folder groups had no drag handlers, no unfiled drop zone. All fixed. +- **DM creation 404.** Route didn't exist. Added `GET /api/v1/users/search?q=` endpoint. Rewrote DM creation UI from text input to lazy search modal. +- **Channel ⋯ menu inconsistency.** Replaced ✕ delete button with ⋯ hover button matching folder pattern. +- **Folder ⋯ button reflow.** `display:none/block` caused arrow shift on hover. Changed to `visibility:hidden/visible`. +- **Folder delete spill.** Now shows three-button modal: "Keep chats" / "Delete chats too" / Cancel. +- **Unread subquery dependency.** Query used `last_read_message_id` (migration 017) which may not exist. Rewrote to use `last_read_at` (migration 005). +- **Settings Models section.** Template section fell through to generic div. Added dedicated template section. +- **`u.avatar` → `u.avatar_url`** in `ListMessages` JOIN and `treepath/path.go` `resolveSenderInfo()`. + +### Migration Notes +- **Migration 017:** `last_read_message_id` column on `channel_participants`. + +## [0.23.1] — 2026-03-05 + +### Added +- **Conversation type taxonomy.** Five types: `direct` (1:1 AI chat), `dm` (human-to-human), `group` (multi-participant), `channel` (named persistent space), `workflow` (staged process, deferred). Channel type constraint extended. +- **`ai_mode` on channels.** `auto` (AI responds to every message), `mention_only` (AI silent unless @mentioned), `off` (AI disabled). Completion handler checks `ai_mode` before dispatching. +- **Three-section sidebar.** Projects → Channels → Chats. Each section independently collapsible with localStorage persistence. Channels sorted by last activity. Chats keep existing time-grouped behavior. +- **Folder system.** `chat_folders` table with full CRUD. Drag chats into/out of folders. Folder context menu: rename, delete. `folder_id` column on channels. +- **DM plumbing.** `resolveMention()` extended to resolve users (exact + prefix on username, self-mention blocked). User @mention skips AI, delivers notification. DM creation flow with user search. +- **Presence heartbeat.** `user_presence` table. `POST /presence/heartbeat` (30s upsert), `GET /presence?users=...` (90s threshold). Client heartbeat interval with visibility pause/resume. Presence WebSocket events. +- **Channel participant CRUD.** `channel_participants` handler: list, add, update role, remove. Auto-created on channel creation. DM participants auto-added from `req.Participants`. +- **Persona groups wired.** CRUD endpoints for `persona_groups` and `persona_group_members`. `is_leader` flag on members. +- **`topic` column** on channels for header display. +- **User search endpoint.** `GET /api/v1/users/search?q=` — returns id, username, display_name for active users (max 20, excludes caller). + +### Changed +- **`CreateChannel` accepts `type` field** (default `direct`). `ListChannels` supports `?types=dm,channel` multi-filter. +- **Channel sidebar rendering.** `renderChannelsSection()` shows # for channels, person icon for DMs. Online dots from presence data. Unread badges. +- **Chat list filtering.** `renderChatList` excludes `type=channel` and `type=dm` — only direct/group chats appear in the Chats section. + +### Migration Notes +- **Migration 016:** `ai_mode`, `topic` on channels. `user_presence` table. Channel type constraint extended to include `dm`, `channel`. + ## [0.23.0] — 2026-03-05 ### Added diff --git a/VERSION b/VERSION index fda96dc..286d5b0 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.23.2 +0.24.0 \ No newline at end of file diff --git a/docs/DESIGN-0.24.0.md b/docs/DESIGN-0.24.0.md new file mode 100644 index 0000000..bd2e504 --- /dev/null +++ b/docs/DESIGN-0.24.0.md @@ -0,0 +1,1110 @@ +# Design — v0.24.x: Auth Strategy + RBAC + Anonymous Sessions + +**Status:** Draft +**Scope:** Auth provider abstraction, mTLS/OIDC integration, fine-grained permissions, anonymous workflow participants +**Depends on:** v0.23.0 (channel_participants, @mention routing), v0.16.0 (groups, resource grants), v0.9.4 (vault, UEK) +**Feeds into:** v0.25.0 (Workflow Engine), v0.26.0 (Autonomous Agents) + +--- + +## Guiding Principle + +Auth is the gateway to everything else. Today Switchboard has exactly +one auth mode: username/password/bcrypt/JWT. That works for single-user +and small-team deployments but blocks two critical paths: + +1. Enterprise integration — orgs already have identity (Keycloak, Okta, + client certs). Requiring a separate password is a non-starter. +2. Workflow intake — anonymous visitors need to interact with channels + without registering. The workflow engine (v0.25.0) depends on this. + +The design principle: **auth resolves to the same internal user model +regardless of source.** The middleware doesn't care how you proved your +identity — it cares that `c.Get("user_id")` returns a valid ID with +known capabilities. Everything downstream (teams, groups, channels, +permissions) is auth-source-agnostic. + +--- + +## Release Decomposition + +``` +v0.24.0 Auth Abstraction + User Identity + │ + ┌─────┴───────────┐ + │ │ +v0.24.1 mTLS + OIDC v0.24.2 Fine-Grained + Providers Permissions + │ │ + └─────┬───────────┘ + │ +v0.24.3 Anonymous / Session Participants +``` + +v0.24.1 and v0.24.2 are parallel — no dependency between them. +v0.24.3 depends on v0.24.1 (mTLS anonymous path) but the cookie-based +session path only needs v0.24.0. + +--- + +## v0.24.0 — Auth Abstraction + User Identity + +Internal plumbing. Zero behavioral change for existing deployments. +`AUTH_MODE=builtin` works identically to today but routes through a +provider interface. + +### Auth Provider Interface + +```go +// server/auth/provider.go + +package auth + +// Result is the output of any successful authentication. +// Every auth mode must produce one of these. +type Result struct { + UserID string // internal user ID (may be empty for auto-provision) + ExternalID string // IdP-specific identifier (cert fingerprint, OIDC sub) + Source string // "builtin", "mtls", "oidc" + Email string + Username string + DisplayName string + Role string // suggested role (provider can map from external claims) + Groups []string // suggested group names (OIDC claim mapping) +} + +// Provider authenticates an inbound request and returns a Result. +// If the user doesn't exist locally, AutoProvision is called. +type Provider interface { + // Authenticate extracts identity from the request. + // Returns ErrUnauthenticated if no valid credentials found. + Authenticate(c *gin.Context) (*Result, error) + + // SupportsLogin returns true if this provider handles + // the /login and /register routes (builtin only). + SupportsLogin() bool +} +``` + +This is a **request-level** interface, not a session-level one. Each +request goes through `Authenticate()`. For builtin mode, that means +JWT validation (same as today). For mTLS, header extraction. For OIDC, +bearer token validation against the IdP's JWKS. + +### Builtin Provider (Refactor) + +The existing auth logic in `handlers/auth.go` and `middleware/auth.go` +moves behind the interface. No new behavior — just reorganization. + +```go +// server/auth/builtin.go + +type BuiltinProvider struct { + cfg *config.Config + stores store.Stores + uekCache *crypto.UEKCache +} + +func (p *BuiltinProvider) Authenticate(c *gin.Context) (*Result, error) { + // Existing JWT validation logic from middleware/auth.go: + // 1. Extract token from Authorization header, cookie, or query param + // 2. Parse and validate JWT with cfg.JWTSecret + // 3. Return Result with UserID, Email, Role from claims +} + +func (p *BuiltinProvider) SupportsLogin() bool { return true } +``` + +Login/register/refresh/logout endpoints remain on `AuthHandler` but +are only mounted when `AUTH_MODE=builtin`. The `AuthHandler` gets a +reference to the provider for consistency but the routes themselves +don't change shape. + +### Middleware Refactor + +```go +// middleware/auth.go — after refactor + +func Auth(provider auth.Provider) gin.HandlerFunc { + return func(c *gin.Context) { + if !database.IsConnected() { + c.Next() + return + } + + result, err := provider.Authenticate(c) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, + gin.H{"error": "authentication failed"}) + return + } + + // Set context values (same contract as today) + c.Set("user_id", result.UserID) + c.Set("email", result.Email) + c.Set("role", result.Role) + c.Set("auth_source", result.Source) + c.Next() + } +} +``` + +`AuthOrRedirect` (page routes) gets the same treatment — delegates to +the provider, redirects on failure. The contract with downstream +handlers is unchanged: `c.Get("user_id")`, `c.Get("role")`. + +### Config Addition + +```go +// config/config.go addition + +type Config struct { + // ... existing fields ... + + // Auth mode: "builtin" (default), "mtls", "oidc" + AuthMode string + + // OIDC settings (only when AuthMode=oidc) + OIDCIssuerURL string // e.g. https://keycloak.example.com/realms/switchboard + OIDCClientID string + OIDCClientSecret string + OIDCRoleClaim string // JWT claim path for role mapping (default "realm_access.roles") + OIDCGroupsClaim string // JWT claim path for group sync (default "groups") + + // mTLS settings (only when AuthMode=mtls) + MTLSHeaderDN string // header name for client cert DN (default "X-SSL-Client-DN") + MTLSHeaderVerify string // header name for verification status (default "X-SSL-Client-Verify") + + // Auto-provision policy (applies to mtls and oidc) + AuthAutoActivate bool // auto-activate new users (default false) + AuthDefaultTeam string // team ID to assign new users to + AuthDefaultRole string // "user" (default) or "admin" +} +``` + +Validation at startup: if `AuthMode` is `mtls` or `oidc`, require the +corresponding config fields. Fail fast on missing `OIDCIssuerURL` when +`AUTH_MODE=oidc`, etc. + +### User Model Extension + +```go +// models/models.go — User struct additions + +type User struct { + BaseModel + Username string `json:"username" db:"username"` + Email string `json:"email" db:"email"` + PasswordHash string `json:"-" db:"password_hash"` + DisplayName string `json:"display_name,omitempty" db:"display_name"` + AvatarURL string `json:"avatar_url,omitempty" db:"avatar_url"` + Role string `json:"role" db:"role"` + IsActive bool `json:"is_active" db:"is_active"` + Settings JSONMap `json:"settings,omitempty" db:"settings"` + LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"` + + // v0.24.0 additions + Handle string `json:"handle,omitempty" db:"handle"` + AuthSource string `json:"auth_source" db:"auth_source"` + ExternalID *string `json:"external_id,omitempty" db:"external_id"` +} +``` + +`Handle` follows the same pattern as `personas.handle`: auto-generated +from username via `HandleFromName()`, unique index, used for @mention +resolution. The DESIGN-0_23_1 resolution order (persona exact → persona +prefix → user exact → user prefix → model exact → model prefix) +switches from matching `LOWER(username)` to matching `LOWER(handle)` +once this lands. The `resolveMention()` queries in `completion.go` +(lines 1343–1371) update accordingly. + +`AuthSource` is always populated: `"builtin"` for existing users +(backfilled by migration), `"mtls"` or `"oidc"` for externally +provisioned users. `ExternalID` is the IdP-specific stable identifier +(OIDC `sub` claim, mTLS cert fingerprint). Nullable because builtin +users don't have one. + +### Migration 018 + +```sql +-- 018_v024_auth.sql (Postgres) + +-- ── User identity extensions ──────────────────────────────────────── + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS handle VARCHAR(50), + ADD COLUMN IF NOT EXISTS auth_source VARCHAR(20) NOT NULL DEFAULT 'builtin' + CHECK (auth_source IN ('builtin', 'mtls', 'oidc')), + ADD COLUMN IF NOT EXISTS external_id TEXT; + +-- Backfill handles from username +UPDATE users SET handle = LOWER(REGEXP_REPLACE(username, '[^a-zA-Z0-9-]', '-', 'g')) + WHERE handle IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_handle + ON users(handle) WHERE handle IS NOT NULL; + +-- External ID is unique per auth source +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_external_id + ON users(auth_source, external_id) WHERE external_id IS NOT NULL; + +COMMENT ON COLUMN users.handle IS 'Unique @mention handle. Auto-generated from username.'; +COMMENT ON COLUMN users.auth_source IS 'Authentication provider: builtin, mtls, oidc'; +COMMENT ON COLUMN users.external_id IS 'IdP-specific stable ID (OIDC sub, mTLS fingerprint)'; +``` + +SQLite equivalent uses the same column additions with `TEXT` types and +app-side handle generation (no `REGEXP_REPLACE`). + +### Auto-Provision Flow + +When a non-builtin provider authenticates a user whose `external_id` +doesn't exist in the DB, auto-provision creates the account: + +```go +// server/auth/provision.go + +func AutoProvision(ctx context.Context, stores store.Stores, r *Result, cfg *config.Config) (*models.User, error) { + // Check if user exists by external_id + existing, _ := stores.Users.GetByExternalID(ctx, r.Source, r.ExternalID) + if existing != nil { + return existing, nil + } + + // Create new user + user := &models.User{ + Username: r.Username, + Email: r.Email, + DisplayName: r.DisplayName, + Handle: models.HandleFromName(r.Username), + Role: resolveRole(r.Role, cfg.AuthDefaultRole), + AuthSource: r.Source, + ExternalID: &r.ExternalID, + IsActive: cfg.AuthAutoActivate, + } + if err := stores.Users.Create(ctx, user); err != nil { + return nil, err + } + + // Assign to default team if configured + if cfg.AuthDefaultTeam != "" { + stores.Teams.AddMember(ctx, cfg.AuthDefaultTeam, user.ID, "member") + } + + // Sync groups from IdP claims (OIDC) + for _, groupName := range r.Groups { + syncGroupMembership(ctx, stores, user.ID, groupName) + } + + return user, nil +} +``` + +### Store Additions + +```go +// store/interfaces.go — UserStore additions + +type UserStore interface { + // ... existing methods ... + + GetByExternalID(ctx context.Context, authSource, externalID string) (*User, error) + GetByHandle(ctx context.Context, handle string) (*User, error) + UpdateAuthFields(ctx context.Context, id string, source string, externalID *string) error +} +``` + +### main.go Wiring + +```go +// Construct auth provider based on AUTH_MODE +var authProvider auth.Provider +switch cfg.AuthMode { +case "mtls": + authProvider = auth.NewMTLSProvider(cfg, stores) +case "oidc": + authProvider = auth.NewOIDCProvider(cfg, stores) +default: + authProvider = auth.NewBuiltinProvider(cfg, stores, uekCache) +} + +// Middleware uses the provider +api := r.Group("/api/v1") +api.Use(middleware.Auth(authProvider)) + +// Login/register routes only when provider supports them +if authProvider.SupportsLogin() { + public := r.Group("/api/v1") + public.POST("/register", authHandler.Register) + public.POST("/login", authHandler.Login) + // ... +} +``` + +### What Ships + +- `server/auth/` package: `provider.go` (interface), `builtin.go`, `provision.go` +- Refactored `middleware/auth.go` and `middleware/page_auth.go` +- `AUTH_MODE` in config with validation +- Migration 018 (users.handle, users.auth_source, users.external_id) +- `resolveMention()` updated to use `handle` instead of `username` +- Autocomplete endpoint updated to include users with handle data +- Admin UI: user list shows auth_source badge +- All tests pass with `AUTH_MODE=builtin` (default) — zero regression + +### What Doesn't Ship + +- mTLS and OIDC providers (v0.24.1) +- Permission enforcement (v0.24.2) +- Anonymous sessions (v0.24.3) +- Admin auth configuration UI (v0.24.1) + +--- + +## v0.24.1 — mTLS + OIDC Providers + +Two concrete implementations of the `auth.Provider` interface. Each +can be developed and tested independently. + +### mTLS Provider + +Extracts identity from client certificate headers set by the TLS +terminator (nginx, envoy, istio). No direct cert parsing — the +reverse proxy does TLS and forwards DN/fingerprint as headers. + +```go +// server/auth/mtls.go + +type MTLSProvider struct { + cfg *config.Config + stores store.Stores +} + +func (p *MTLSProvider) Authenticate(c *gin.Context) (*Result, error) { + // 1. Check verification header + verify := c.GetHeader(p.cfg.MTLSHeaderVerify) + if verify != "SUCCESS" { + return nil, ErrUnauthenticated + } + + // 2. Parse DN from header + dn := c.GetHeader(p.cfg.MTLSHeaderDN) + if dn == "" { + return nil, ErrUnauthenticated + } + + // 3. Extract fields from DN + parsed := parseDN(dn) // CN, O, OU, etc. + fingerprint := c.GetHeader("X-SSL-Client-Fingerprint") + + // 4. Build result + return &Result{ + ExternalID: fingerprint, + Source: "mtls", + Username: parsed.CN, + Email: parsed.Email, // from SAN if present + DisplayName: parsed.CN, + Role: "", // no role claim in certs — use default + }, nil +} + +func (p *MTLSProvider) SupportsLogin() bool { return false } +``` + +**nginx config fragment** (documentation, not shipped code): + +```nginx +server { + listen 443 ssl; + ssl_client_certificate /etc/nginx/certs/ca.pem; + ssl_verify_client on; + + location /api/ { + proxy_set_header X-SSL-Client-DN $ssl_client_s_dn; + proxy_set_header X-SSL-Client-Verify $ssl_client_verify; + proxy_set_header X-SSL-Client-Fingerprint $ssl_client_fingerprint; + proxy_pass http://backend:8080; + } +} +``` + +**Security constraint:** The backend must trust these headers ONLY +when they come from the known reverse proxy. In k8s this is handled +by network policy (only nginx can reach the backend pod). For +non-k8s deployments, a shared header secret or source IP check is +needed. Document this prominently — header-trust without network +isolation is a critical vulnerability. + +### OIDC Provider + +Validates bearer tokens against an OIDC-compliant identity provider. +Supports Keycloak, Okta, Auth0, and any spec-compliant issuer. + +```go +// server/auth/oidc.go + +type OIDCProvider struct { + cfg *config.Config + stores store.Stores + verifier *oidc.IDTokenVerifier // from coreos/go-oidc +} + +func NewOIDCProvider(cfg *config.Config, stores store.Stores) (*OIDCProvider, error) { + // 1. Discover OIDC config from issuer URL + provider, err := oidc.NewProvider(ctx, cfg.OIDCIssuerURL) + if err != nil { + return nil, fmt.Errorf("oidc discovery failed: %w", err) + } + + // 2. Set up verifier for ID tokens + verifier := provider.Verifier(&oidc.Config{ + ClientID: cfg.OIDCClientID, + }) + + return &OIDCProvider{cfg: cfg, stores: stores, verifier: verifier}, nil +} + +func (p *OIDCProvider) Authenticate(c *gin.Context) (*Result, error) { + // 1. Extract bearer token + token := extractBearerToken(c) + if token == "" { + return nil, ErrUnauthenticated + } + + // 2. Verify token + idToken, err := p.verifier.Verify(c.Request.Context(), token) + if err != nil { + return nil, ErrUnauthenticated + } + + // 3. Extract claims + var claims struct { + Sub string `json:"sub"` + Email string `json:"email"` + Name string `json:"name"` + PreferredUser string `json:"preferred_username"` + Groups []string `json:"groups"` + } + if err := idToken.Claims(&claims); err != nil { + return nil, fmt.Errorf("claim extraction failed: %w", err) + } + + // 4. Map roles from configurable claim path + role := extractRoleFromClaims(idToken, p.cfg.OIDCRoleClaim) + + return &Result{ + ExternalID: claims.Sub, + Source: "oidc", + Email: claims.Email, + Username: claims.PreferredUser, + DisplayName: claims.Name, + Role: role, + Groups: claims.Groups, + }, nil +} + +func (p *OIDCProvider) SupportsLogin() bool { return false } +``` + +**Token flow for OIDC mode:** The frontend redirects to the IdP's +authorize endpoint. After successful auth, the IdP redirects back +with an authorization code. The backend exchanges the code for tokens +at `/auth/oidc/callback`, stores the access token in the `sb_token` +cookie, and redirects to the app. The backend validates the token on +every request via JWKS. Confidential client — the frontend never +sees the authorization code. + +### OIDC Login Flow + +New routes mounted only when `AUTH_MODE=oidc`: + +``` +GET /auth/oidc/login → redirect to IdP authorize endpoint +GET /auth/oidc/callback → exchange code, create/update user, set cookie, redirect to app +POST /auth/oidc/logout → clear cookie, optionally call IdP end_session +``` + +The login page (Go template) detects `AUTH_MODE` from `PageData` and +renders either the username/password form (builtin) or a "Sign in with +SSO" button (oidc/mtls). + +### Group Sync on Login + +When OIDC claims include group names, the auto-provision flow syncs +them to internal groups: + +```go +func syncGroupMembership(ctx context.Context, stores store.Stores, userID string, groupName string) { + // Find or create group by name + group, err := stores.Groups.GetByName(ctx, groupName) + if err != nil { + group = &models.Group{ + Name: groupName, + Description: "Auto-synced from IdP", + Scope: "global", + } + stores.Groups.Create(ctx, group) + } + + // Add membership if not already a member + isMember, _ := stores.Groups.IsMember(ctx, group.ID, userID) + if !isMember { + stores.Groups.AddMember(ctx, group.ID, userID, "system") + } +} +``` + +This runs on every login, not just first provision. Group removal +is **not** auto-synced — if a user loses a group in the IdP, they +keep it locally until an admin removes it. Aggressive auto-removal +risks breaking resource grants on transient IdP outages. + +### Vault Passphrase for External Auth + +When `AUTH_MODE` is not `builtin`, users don't have a password. The +vault's UEK (per-user encryption key) is derived from the user's +password via Argon2id (v0.9.4). External auth users can't derive a +UEK this way. + +Two options, one for each deployment scenario: + +**Personal BYOK not needed** (enterprise, team providers only): +External auth users simply don't get a UEK. Personal provider +configs aren't available. The vault operates with the global +`ENCRYPTION_KEY` only. No user action needed. + +**Personal BYOK needed** (mixed deployment): The user sets an +optional vault passphrase via Settings. This passphrase is +unrelated to their IdP credentials — it exists solely to derive +the UEK. The existing `crypto.DeriveUEK()` and `crypto.UEKCache` +work unchanged; the input is just a passphrase instead of a +login password. UI: a "Vault Passphrase" field in user settings, +prompted on first use of a personal provider. + +Implementation: add `has_vault_passphrase` bool to user model. +When true, the settings page prompts for the passphrase to unlock +personal providers. The UEK derivation and cache machinery is +unchanged — only the source of the passphrase differs. + +### Store Additions + +```go +// store/interfaces.go — GroupStore addition + +type GroupStore interface { + // ... existing methods ... + + GetByName(ctx context.Context, name string) (*models.Group, error) +} +``` + +### Admin UI + +Auth provider configuration panel in admin settings: + +- Display current `AUTH_MODE` (read-only — set by env var, not UI) +- OIDC section (when mode=oidc): issuer URL, client ID, role claim + path, groups claim path — read from config, displayed for reference +- mTLS section (when mode=mtls): trusted header names, verification + requirements — read from config +- Auto-provision policy: auto-activate toggle, default team selector, + default role dropdown +- User list: auth source badge, external ID shown in user detail panel + +### What Ships + +- `server/auth/mtls.go`, `server/auth/oidc.go` +- `/auth/oidc/login`, `/auth/oidc/callback`, `/auth/oidc/logout` routes +- Login page template adapts to `AUTH_MODE` +- Group sync on OIDC login +- Vault passphrase option for external auth users +- Admin auth configuration panel +- `GroupStore.GetByName()` added to both Postgres and SQLite stores +- New Go dependency: `github.com/coreos/go-oidc/v3` + +### What Doesn't Ship + +- Fine-grained permissions (v0.24.2) +- Anonymous/session participants (v0.24.3) + +--- + +## v0.24.2 — Fine-Grained Permissions + +Groups (v0.16.0) currently scope resource visibility — which personas +and KBs a user can access. This release makes groups carry +**permissions** — what actions a user can perform. + +### Permission Model + +Permissions are named strings. The system defines a fixed set; +they're not user-configurable (no "create your own permission" +complexity). Each permission has a scope: global or resource-specific. + +``` +model.use — use models for completion (implicit today) +model.select_any — use any enabled model (vs. group-restricted set) +kb.read — search KBs through personas +kb.write — upload/delete KB documents +kb.create — create new knowledge bases +channel.create — create group/channel conversations +channel.invite — invite users to channels +persona.create — create new personas +persona.manage — edit/delete team personas +workflow.create — create workflow definitions (v0.25.0) +admin.view — read-only admin panel access +token.unlimited — bypass token budgets +``` + +### Storage + +Permissions are stored as a JSONB array on the group, not in a +separate table. This avoids a join-heavy permission resolution +path and keeps the grant model simple. + +```sql +-- 019_v024_permissions.sql + +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS permissions JSONB NOT NULL DEFAULT '[]'::jsonb; + +-- Token budgets per group +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS token_budget_daily BIGINT, + ADD COLUMN IF NOT EXISTS token_budget_monthly BIGINT; + +-- Model allowlist per group (NULL = no restriction) +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS allowed_models JSONB; + +COMMENT ON COLUMN groups.permissions IS 'Array of permission strings granted to group members'; +COMMENT ON COLUMN groups.allowed_models IS 'Array of model_id strings this group can use. NULL = unrestricted.'; +``` + +### Permission Resolution + +A user's effective permissions are the **union** of all their groups' +permissions, plus base permissions that every active user gets. + +```go +// server/auth/permissions.go + +// Base permissions granted to all active users (no group needed) +var basePermissions = map[string]bool{ + "model.use": true, + "kb.read": true, + "channel.create": true, +} + +// ResolvePermissions returns the effective permission set for a user. +func ResolvePermissions(ctx context.Context, stores store.Stores, userID string) (map[string]bool, error) { + perms := make(map[string]bool) + + // Start with base permissions + for k, v := range basePermissions { + perms[k] = v + } + + // Union all group permissions + groups, err := stores.Groups.ListForUser(ctx, userID) + if err != nil { + return perms, err + } + for _, g := range groups { + for _, p := range g.Permissions { + perms[p] = true + } + } + + return perms, nil +} +``` + +Admin users (`role=admin`) bypass permission checks entirely, same +as they bypass team membership checks today. + +### Middleware Helper + +```go +// middleware/permissions.go + +func RequirePermission(perm string) gin.HandlerFunc { + return func(c *gin.Context) { + role, _ := c.Get("role") + if role == "admin" { + c.Next() + return + } + + userID := c.GetString("user_id") + perms, _ := resolveAndCachePermissions(c, userID) + if !perms[perm] { + c.AbortWithStatusJSON(http.StatusForbidden, + gin.H{"error": fmt.Sprintf("permission required: %s", perm)}) + return + } + c.Next() + } +} +``` + +Permission resolution is cached in the request context (computed +once per request, not per middleware). For performance, a TTL cache +keyed by user ID avoids hitting the DB on every request — invalidated +on group membership changes via the EventBus. + +### Model Access Control + +When a group has `allowed_models` set, members can only use models +in that list. Enforced in two places: + +1. **Model list endpoint** — filters response to only include models + the user is allowed to use. Union of all groups' allowed_models. + NULL allowed_models means "no restriction" (doesn't restrict, even + if another group does restrict). +2. **Completion handler** — checks requested model against the user's + effective model allowlist before streaming. + +### Token Budgets + +Per-group daily and monthly token limits. The **most restrictive** +budget applies (not union — intersection). Checked in the completion +handler before streaming begins. Usage is already tracked in +`usage_log` (v0.10.0) — this adds a pre-flight check. + +```go +// In completion handler, before streaming +budget, err := auth.ResolveTokenBudget(ctx, stores, userID) +if budget != nil { + used, _ := stores.Usage.GetUserUsage(ctx, userID, budget.Period) + if used + estimatedTokens > budget.Limit { + c.JSON(429, gin.H{"error": "token budget exceeded"}) + return + } +} +``` + +### Handler Integration + +Existing handlers that need permission gates: + +| Handler | Current Gate | New Gate | +|---------|-------------|----------| +| `POST /personas` | `RequireAdmin()` | `RequirePermission("persona.create")` | +| `PUT/DELETE /personas/:id` | `RequireAdmin()` | `RequirePermission("persona.manage")` | +| `POST /knowledge-bases` | `RequireAdmin()` | `RequirePermission("kb.create")` | +| `POST /knowledge-bases/:id/documents` | team check | `RequirePermission("kb.write")` | +| `POST /channels` (type=channel) | auth only | `RequirePermission("channel.create")` | +| `POST /channels/:id/participants` | auth only | `RequirePermission("channel.invite")` | + +The existing `RequireAdmin()` and `RequireTeamAdmin()` middleware +remain. `RequirePermission()` is additive — a route can have both +team membership and permission requirements. + +### Admin UI + +Group edit form gains: + +- Permissions section: checkbox list of available permissions +- Token budget fields: daily limit, monthly limit (0 = unlimited) +- Model allowlist: multi-select from enabled models (empty = unrestricted) +- User detail panel: "Effective Permissions" read-only summary showing + the union of all group grants + +### What Ships + +- Migration 019 (group permissions, token budgets, model allowlist) +- `server/auth/permissions.go` (resolution logic) +- `middleware/permissions.go` (`RequirePermission()`) +- Permission-gated handler routes +- Token budget pre-flight in completion handler +- Model access filtering in model list endpoint +- Admin UI: group permissions editor, user effective permissions view +- Both Postgres and SQLite store implementations + +### What Doesn't Ship + +- Anonymous sessions (v0.24.3) +- Permission UI for non-admin users (future — "why can't I use this model?") +- Custom permission definitions (intentionally out of scope) + +--- + +## v0.24.3 — Anonymous / Session Participants + +Unauthenticated visitors can interact with workflow channels. +This is the bridge to v0.25.0 (Workflow Engine). + +### Session Identity + +A session participant is a `channel_participants` row with +`participant_type = 'session'`. It has no `users` row — identity +exists only within the channel scope. + +```go +// models/models.go + +type SessionParticipant struct { + BaseModel + 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"` +} +``` + +### Migration 020 + +```sql +-- 020_v024_sessions.sql + +CREATE TABLE IF NOT EXISTS session_participants ( + id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text, + 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 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.'; +``` + +### Session Creation + +Two entry paths: + +**Cookie-based (default):** Visitor hits a workflow channel URL +(`/w/:slug`). If no `sb_session` cookie exists, the server generates +a session token, creates a `session_participants` row scoped to that +channel, sets the cookie, and renders the page. Subsequent requests +include the cookie. + +**mTLS-based:** When `AUTH_MODE=mtls`, the cert fingerprint is used +as the session token instead of a random value. This gives stable +identity across browser sessions without requiring a `users` row. +The `fingerprint` column stores the cert fingerprint for display to +team members. + +```go +// server/auth/session.go + +func CreateOrResumeSession(c *gin.Context, stores store.Stores, channelID string, cfg *config.Config) (*SessionParticipant, error) { + // Check for existing session cookie + token, _ := c.Cookie("sb_session") + + // 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 != "" { + session, err := stores.Sessions.GetByToken(c.Request.Context(), token) + if err == nil && session.ChannelID == channelID { + return session, nil + } + } + + // Create new session + token = "sess:" + uuid.NewString() + session := &SessionParticipant{ + SessionToken: token, + ChannelID: channelID, + DisplayName: generateVisitorName(channelID), // "Visitor #3" + } + if err := stores.Sessions.Create(c.Request.Context(), session); err != nil { + return nil, err + } + + // Set cookie (httponly, 30 day expiry) + c.SetCookie("sb_session", token, 60*60*24*30, "/", "", true, true) + + // Add as channel participant + stores.Channels.AddParticipant(c.Request.Context(), channelID, &models.ChannelParticipant{ + ChannelID: channelID, + ParticipantType: "session", + ParticipantID: session.ID, + Role: "visitor", + }) + + return session, nil +} +``` + +### Scoping Constraints + +Session participants are intentionally limited: + +- **Channel-scoped:** A session token is valid for exactly one channel. + Visiting a different workflow channel creates a separate session. +- **Workflow channels only:** Session auth is only accepted on channels + with `type = 'workflow'`. Attempting to session-auth into a `direct`, + `group`, or `channel` type returns 403. +- **Message + tool calls only:** Session participants can send messages + and trigger tool calls within their channel. They cannot list other + channels, access the sidebar, manage settings, or call any endpoint + outside their channel scope. +- **No @mention resolution:** Session participants cannot be @mentioned + (they have no handle). They're message producers, not routing targets. + +### Session Auth Middleware + +A new middleware variant for workflow entry points: + +```go +// middleware/session_auth.go + +func AuthOrSession(provider auth.Provider, stores store.Stores, cfg *config.Config) gin.HandlerFunc { + return func(c *gin.Context) { + // Try normal auth first + result, err := provider.Authenticate(c) + if err == nil { + c.Set("user_id", result.UserID) + c.Set("role", result.Role) + c.Set("auth_type", "user") + c.Next() + return + } + + // Fall back to session auth for workflow channels + channelID := c.Param("channelId") + if channelID == "" { + c.AbortWithStatusJSON(401, gin.H{"error": "authentication required"}) + return + } + + session, err := CreateOrResumeSession(c, stores, channelID, cfg) + if err != nil { + c.AbortWithStatusJSON(401, 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() + } +} +``` + +Handlers that support session participants check `c.GetString("auth_type")` +and enforce channel scope when it's `"session"`. + +### Team Member View + +When a team member is assigned to a workflow channel, they see +session participant activity attributed to the session identity: + +- Display name: "Visitor #3" (auto-generated) or cert CN (mTLS mode) +- Messages show session avatar (generic person icon) +- Session metadata visible in participant list panel + +### What Ships + +- Migration 020 (session_participants table) +- `server/auth/session.go` (session creation/resume) +- `middleware/session_auth.go` (AuthOrSession) +- Session-scoped message and completion handlers +- Workflow channel entry page (Go template: `/w/:slug`) +- SessionStore interface + Postgres/SQLite implementations +- Session participant rendering in chat UI + +### 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) + +--- + +## Cross-Cutting Concerns + +### Testing Strategy + +Each subversion is independently testable: + +- **v0.24.0**: Existing integration tests run unchanged with + `AUTH_MODE=builtin`. New tests verify the provider interface + contract and handle backfill migration. +- **v0.24.1**: Mock IdP for OIDC tests (test JWKS server). mTLS + tested by injecting headers directly. Auto-provision flow tested + end-to-end against both Postgres and SQLite. +- **v0.24.2**: Permission resolution unit tests. Handler integration + tests with group-permission fixtures. Token budget enforcement + with mock usage data. +- **v0.24.3**: Session lifecycle tests (create, resume, scope + enforcement). Workflow channel entry test with session-only auth. + +### Migration Sequence + +``` +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 +``` + +All three are additive (new columns, new tables). No destructive +changes. Can be applied sequentially as each subversion merges. +SQLite equivalents for each. + +### Backward Compatibility + +`AUTH_MODE=builtin` (default) requires zero configuration changes. +Existing deployments continue to work with no new env vars. The +migration backfills `auth_source='builtin'` and auto-generates +handles — existing users don't notice anything. Permission +resolution with empty group permissions returns base permissions +only — same effective access as today. + +--- + +## Relationship to Future Milestones + +**v0.25.0 Workflow Engine:** Depends on v0.24.3 (anonymous sessions) +for the visitor intake path and v0.24.2 (permissions) for +`workflow.create`. Channel `ai_mode` (v0.23.2) maps to +workflow stage behavior. Everything composes. + +**v0.26.0 Autonomous Agents:** Service channels (`type='service'`) +run with no human participants. Auth isn't involved — the scheduler +creates channels with system identity. Token budgets from v0.24.2 +apply as execution budgets. + +--- + +## Resolved Decisions + +1. **OIDC client type:** Confidential with backend callback at + `/auth/oidc/callback`. The frontend never sees the authorization + code. PKCE (public client) is deferred — unnecessary for a + server-side app. + +2. **Permission inheritance through teams:** No implicit team + permissions. Teams scope visibility, groups grant capabilities. + The two remain orthogonal. A team admin who wants all members to + have `channel.create` creates a group, assigns the permission, + and adds the team members. + +3. **Group priority for token budgets:** Most-restrictive wins. + No "override" group type. Safe and predictable. Admins who need + exceptions create a dedicated group with a higher budget. + +4. **Handle collision on auto-provision:** Append numeric suffix + (`-2`, `-3`) until unique. Same logic as persona handle generation + via `HandleFromName()`. The unique index is the backstop. + +5. **Session cleanup:** Deferred. Table is append-only and low-volume + until workflows are heavily used. A cleanup CronJob (delete sessions + older than N days with no messages) is a natural addition in v0.25.0 + when workflow channels are actively creating sessions. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9e07a01..cfc28a5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -109,16 +109,23 @@ v0.22.5 Surfaces + Go Templates + Admin Bug Fixes ✅ │ v0.22.6 Split Deployment Fix + CI Timeout + Code Pruning ✅ │ - | v0.22.7 Surface Prototypes (Theme + ChatPane + Splash) ✅ - | -v0.22.8 Surface Integration (Wire into existing JS) - | -v0.23.0 @mention Routing + Persona Handles + Proxy ✅ │ -v0.23.1 Multi-User: DMs + Group Chat +v0.22.8 Surface Integration (Wire into existing JS) ✅ │ -v0.24.0 Auth Strategy (mTLS/OIDC) + Full RBAC + Group permissions +v0.23.0 @mention Routing + Persona Handles + Proxy ✅ + │ +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.1 mTLS + OIDC Providers (parallel) + │ │ + │ └── v0.24.3 Anonymous Sessions + │ + └── v0.24.2 Fine-Grained Permissions (parallel) │ ┌───────┴──────────────┐ │ │ @@ -1169,34 +1176,39 @@ Depends on: surfaces + Go templates (v0.22.5), code pruning (v0.22.6). --- -## v0.22.8 — Surface Integration (Wire into Existing JS) +## ✅ v0.22.8 — Surface Integration (Wire into Existing JS) Connect v0.22.7 prototypes to the existing frontend JS modules. ChatPane.primary replaces singleton UI.* calls. Theme system wired into appearance settings. Admin surface hybrid loader completed. +Naming cleanup: preset → persona, APIConfigID → ProviderConfigID. Depends on: surface prototypes (v0.22.7). **ChatPane Bridge:** -- [ ] `ChatPane.primary` creation in `startApp()` from server-rendered mount points -- [ ] `ui-core.js` delegation: `UI.renderMessages` → `ChatPane.primary.renderMessages`, etc. -- [ ] `editor-mode.js` ChatPane creation for workspace chat -- [ ] Branding init from `window.__SETTINGS__` (instanceName, logoURL) +- [x] `ChatPane.primary` creation in `startApp()` from server-rendered mount points +- [x] `ui-core.js` delegation: `UI.renderMessages` → `ChatPane.primary.renderMessages`, etc. +- [x] Branding init from `window.__SETTINGS__` (instanceName, logoURL) **Theme Integration:** -- [ ] Wire `Theme` into appearance settings save/load cycle -- [ ] CM6 theme sync on theme change -- [ ] Persist theme preference to user settings API +- [x] Wire `Theme` into appearance settings save/load cycle +- [x] CM6 theme sync on theme change +- [x] Persist theme preference to user settings API **Admin Surface:** -- [ ] Complete hybrid section loaders for all JS-loaded sections -- [ ] Admin surface stats/overview server-rendered content +- [x] Complete hybrid section loaders for all JS-loaded sections **Settings Surface:** -- [ ] Models visibility toggles (API.saveModelPreferences) -- [ ] User Personas CRUD (add/edit/delete with confirm dialogs) -- [ ] BYOK provider CRUD in settings context -- [ ] Teams tab content +- [x] Models visibility toggles (API.saveModelPreferences) +- [x] User Personas CRUD (add/edit/delete with confirm dialogs) +- [x] BYOK provider CRUD in settings context +- [x] Teams tab content + +**Naming Cleanup:** +- [x] preset → persona: routes, JSON keys, Go structs, JS, CSS, DOM IDs, templates +- [x] APIConfigID → ProviderConfigID: all Go structs and JSON fields +- [x] Shared `app-state.js` extracted from `app.js` — available on all surfaces +- [x] Auth tokens loaded for all surfaces via `base.html` --- @@ -1233,78 +1245,212 @@ or `@model-id` and send. --- -## v0.23.1 — Multi-User: DMs + Group Chat +## ✅ v0.23.1 — Multi-User Navigation + Conversation Taxonomy -Real human-to-human messaging. Builds on v0.23.0's participant infrastructure -(`channel_participants`, @mention routing, WebSocket events). The @mention -system already resolves personas — this release extends it to resolve users. +Conversation type taxonomy (direct/dm/group/channel/workflow), three-section +sidebar (Projects → Channels → Chats), folder system for chats, DM plumbing, +channel configuration with `ai_mode`, presence heartbeat, and the unified +active conversation model. Depends on: @mention routing (v0.23.0), WebSocket infrastructure (exists). +See [DESIGN-0_23_1.md](DESIGN-0_23_1.md) for the full spec. -**User-to-User DMs** -- [ ] `@username` resolution in `resolveMention()`: extend DB lookup to `users` table -- [ ] When `resolveMention()` returns a user (not persona): skip completion, deliver as notification -- [ ] DM channel type: two user participants, no AI unless explicitly @mentioned -- [ ] DM creation flow: user picker, creates channel with both users as participants -- [ ] Unread count per channel per user (read cursor in `channel_participants`) -- [ ] Real-time message delivery via WebSocket to recipient's active session +**Conversation Types:** +- [x] Five-type taxonomy: direct (1:1 AI), dm (human-to-human), group (multi-participant), channel (named persistent), workflow (staged, deferred) +- [x] Channel type constraint extended: `dm`, `channel` added to `type` CHECK +- [x] `ai_mode` column on channels: `auto` | `mention_only` | `off` +- [x] `ai_mode` guard in completion handler — `off` returns 403, `mention_only` without @mention delivers without AI +- [x] `topic` column on channels for header display -**Group Chat (Human + AI)** -- [ ] Persona group CRUD: create/edit/delete saved roster templates (`persona_groups`) -- [ ] "New Chat with Group" flow: pick a template, stamp onto fresh channel -- [ ] Group leader (default responder): `is_leader` flag on `persona_group_members` -- [ ] No @mention in group = leader responds. @mention overrides. -- [ ] Group editing: add/remove personas, change leader — affects future chats only -- [ ] `@all` routing: every persona participant responds (existing `multiModelStream` path) +**Sidebar Architecture:** +- [x] Three collapsible sections: Projects → Channels → Chats +- [x] `renderChannelsSection()` with # / person icons, unread badges, online dots +- [x] Channel items: context menu (rename, set topic, delete), ⋯ hover button +- [x] Section collapse/expand state in localStorage -**Presence + Real-time** -- [ ] Typing indicators for human participants (extend existing persona typing events) -- [ ] Online/offline status per user (heartbeat-based, stored in memory not DB) -- [ ] Participant join/leave WebSocket events -- [ ] Unread badge on sidebar chat items +**Folder System:** +- [x] `chat_folders` table, CRUD handler (`handlers/folders.go`) +- [x] Drag chats into/out of folders; folder context menu (rename, delete) +- [x] Folder delete modal: "Keep chats" / "Delete chats too" / Cancel +- [x] Unfiled drop zone when folders exist -**User @mention UX** -- [ ] Autocomplete includes users alongside models and personas (different icon) -- [ ] @mention pills distinguish user mentions (different color) from persona/model mentions -- [ ] Notification center: @mention notifications with channel deep-link +**DM Plumbing:** +- [x] `resolveMention()` extended to resolve users (exact + prefix on username) +- [x] User @mention skips AI, delivers `user.mentioned` WebSocket notification +- [x] DM creation flow with user search modal +- [x] DM dedup guard — one channel per participant pair -**Message Attribution** -- [ ] Messages from human users show their avatar + display name (not "You" for other participants) -- [ ] Messages from personas retain current avatar + handle rendering -- [ ] Thread view: flat chronological for now, threading deferred +**Presence:** +- [x] `user_presence` table with heartbeat upsert +- [x] `POST /presence/heartbeat` (30s interval), `GET /presence?users=...` (90s threshold) +- [x] Presence WebSocket events: `presence.changed` -**Migration** -- [ ] `users.handle` column (unique, auto-generated from username or display_name) -- [ ] Read cursor columns on `channel_participants`: `last_read_message_id`, `last_read_at` -- [ ] Notification table: `user_notifications` (type, channel_id, message_id, actor_id, read_at) +**Channel Participants:** +- [x] `channel_participants` CRUD handler, auto-created on channel creation +- [x] Persona group CRUD endpoints wired (`persona_groups`, `persona_group_members`) +- [x] DM participant auto-creation from `req.Participants` + +**Migration:** +- [x] 016_v023_multiuser: `ai_mode`, `topic`, `user_presence`, channel type extension --- -## v0.24.0 — Auth Strategy (mTLS/OIDC) + Full RBAC +## ✅ v0.23.2 — Multi-User Polish + Channel Lifecycle -Enterprise auth modes on top of the teams + groups foundation. -Depends on: vault passphrase support from v0.9.3 (conditional unlock for -personal providers under external auth), user groups (v0.16.0). +Bug fixes, unified active conversation refactor, channel lifecycle (archive/delete), +group chat leader routing, `@all` fan-out, message attribution, and deferred +surface integration from v0.22.8. -_(Shifted from v0.21.0 — enhanced with group-based permissions)_ +Depends on: v0.23.1 sidebar and conversation taxonomy. -- [ ] `AUTH_MODE` env var: `builtin` | `mtls` | `oidc` -- [ ] All three resolve to the same internal user model -- [ ] `auth_source` + `external_id` columns on users table -- [ ] mTLS: header trust, auto-provision from cert DN -- [ ] OIDC: Keycloak/Okta token validation, claim extraction, role mapping -- [ ] Per-source auto-activate policy: auto_activate, default_team, default_role -- [ ] Vault passphrase prompt for mTLS/OIDC users (v0.9.3 conditional unlock) -- [ ] Fine-grained permissions: model access, KB write, task create, token budgets -- [ ] Group-based permission sets: assign permissions to groups, users inherit from membership -- [ ] OIDC claim → group mapping: external IdP groups auto-sync to internal groups +**Unified Active Conversation:** +- [x] `App.activeConversation = { id, type }` replaces `currentChatId` + `currentChannelId` +- [x] `setActive(id, type)`, `activeId` getter, `getActiveChat()` helper +- [x] Send path, model bar, streaming, session restore all keyed off `activeConversation.id` +- [x] Sidebar highlight works across both chat and channel sections -**Anonymous / Session Participants** -- [ ] Session-based identity for unauthenticated visitors (workflow intake) -- [ ] mTLS: cert CN/fingerprint as stable session identity (no `users` row required) -- [ ] Non-mTLS: ephemeral session token (cookie or URL token) -- [ ] Session participants can send messages and trigger tool calls within workflow channels -- [ ] Session identity visible to assigned team members (cert DN, display name, or "Anonymous #N") +**Group Chat + Persona Groups:** +- [x] Group leader default response: no @mention in group → leader persona responds +- [x] `@all` fan-out: routes to every persona participant, depth-1 only +- [x] Participant mutation guards: DMs keep ≥2 humans, groups keep ≥1 persona +- [x] "New Group Chat" flow with ad-hoc persona picker + +**Message Attribution:** +- [x] Human sender display: other participants show avatar + display name (not "You") +- [x] Persona sender display verified for loaded history in channel context + +**Channel Lifecycle:** +- [x] Archive action via context menu (`is_archived`, `archived_at`) +- [x] Delete gated by `channel_retention.mode` (flexible/retain) +- [x] Admin purge with `purge_after_days` floor +- [x] Retention config keys in `global_config` + +**Bug Fixes:** +- [x] Channel persistence through refresh (resp.channels → resp.data) +- [x] Folder drag-and-drop (folderId mapping, drop handlers, unfiled zone) +- [x] DM creation 404 → added `GET /users/search` endpoint +- [x] Channel ⋯ menu consistency (replaced ✕ with hover ⋯ pattern) +- [x] Folder ⋯ button reflow (visibility:hidden instead of display:none) +- [x] Unread subquery using `last_read_at` (not migration-017-dependent column) +- [x] Settings Models section template wiring +- [x] `u.avatar` → `u.avatar_url` in ListMessages JOIN and treepath + +**Surface Integration (deferred v0.22.8):** +- [x] `ChatPane.primary` bridge from server-rendered mount points +- [x] Theme save/load cycle wired to settings appearance +- [x] Admin hybrid section loaders completed +- [x] Settings surface: models toggles, persona CRUD, BYOK, teams + +**@mention UX:** +- [x] Autocomplete includes users alongside personas and models +- [x] User mention pill styling (distinct color from persona pills) +- [x] `user.mentioned` WebSocket notification with toast + +**Migration:** +- [x] 017_unread: `last_read_message_id` on `channel_participants` + +--- + +## v0.24.0 — Auth Abstraction + User Identity ← active + +Auth provider interface decoupling builtin auth from the handler/middleware +layer. User model extended with `auth_source`, `external_id`, `handle`. +@mention resolution upgraded to use handles. Zero behavior change for +existing users — builtin mode goes through the new abstraction identically. + +See [DESIGN-0.24.0.md](DESIGN-0.24.0.md) for the full spec including +v0.24.1–v0.24.3 subversions. + +Depends on: v0.23.0 (channel_participants), v0.16.0 (groups), v0.9.4 (vault). + +**Auth Provider Interface (`server/auth/`):** +- [x] `Provider` interface: `Mode()`, `Authenticate()`, `SupportsRegistration()`, `Register()` +- [x] `BuiltinProvider`: extracts login/register from `handlers/auth.go` +- [x] `UniqueHandle()`: collision-safe handle generation with `-2`, `-3` suffixes +- [x] `ErrorToHTTPStatus()`: maps auth errors to HTTP codes +- [x] `ParseMode()`: validates `AUTH_MODE` string with error return + +**Auth Handler Refactor:** +- [x] `AuthHandler` gains `provider auth.Provider` field +- [x] `Login()` delegates to `provider.Authenticate()`, then vault unlock + JWT +- [x] `Register()` delegates to `provider.Register()`, then vault init + JWT +- [x] `generateTokens()` response includes `handle`, `auth_source` +- [x] `BootstrapAdmin()` / `SeedUsers()` backfill handles for existing users + +**User Model Extension:** +- [x] `AuthSource` (builtin/mtls/oidc), `ExternalID` (nullable), `Handle` (unique) +- [x] All user store queries (PG + SQLite) include three new columns +- [x] `GetByHandle()`, `GetByExternalID()` on both store implementations +- [x] Unified `scanOneUser` / `scanOne` helpers replace per-method scan blocks + +**Config + Middleware:** +- [x] `AUTH_MODE` env var (default `builtin`) +- [x] `main.go` dispatch: builtin wired, mTLS/OIDC fail-fast at startup +- [x] Middleware unchanged — JWT validation is auth-mode-agnostic + +**@mention Resolution:** +- [x] `resolveMention()` uses `LOWER(handle)` instead of `LOWER(username)` +- [x] `SearchUsers` returns `handle`, filters on handle +- [x] Frontend autocomplete matches on handle, uses handle as @mention token + +**Migration:** +- [ ] 018_auth_abstraction: `auth_source`, `external_id`, `handle` columns + unique indexes + backfill + +--- + +## v0.24.1 — mTLS + OIDC Providers + +Two external auth implementations plugging into v0.24.0's provider interface. + +Depends on: auth abstraction (v0.24.0). +See [DESIGN-0.24.0.md](DESIGN-0.24.0.md) §v0.24.1 for full spec. + +- [ ] mTLS provider: trusted header extraction, cert DN parsing, auto-provision +- [ ] OIDC provider: well-known discovery, token validation, claim extraction +- [ ] Per-source auto-activate policy (global_config keys) +- [ ] OIDC claim → group mapping (external groups sync to internal groups) +- [ ] Login page adapts by `AUTH_MODE` (form / cert status / SSO button) +- [ ] mTLS users: no personal vault (`vault_set=false`), team/global providers only +- [ ] Migration 019: `oidc_auth_state` table, `groups.source` column + +--- + +## v0.24.2 — Fine-Grained Permissions + +Groups become capability carriers. Permission model on top of existing +group infrastructure. + +Depends on: auth abstraction (v0.24.0). Parallel to v0.24.1. +See [DESIGN-0.24.0.md](DESIGN-0.24.0.md) §v0.24.2 for full spec. + +- [ ] Permission constants (`domain.action` convention, code-level enum) +- [ ] `permissions` JSONB column on groups +- [ ] `ResolvePermissions()`: union of group permissions + `DefaultUserPerms` +- [ ] `RequirePermission()` middleware +- [ ] Token budgets: per-group daily/monthly ceilings, enforced in completion handler +- [ ] Model access control: per-group model allowlists +- [ ] Admin UI: permission checklist, budget fields, model picker on group edit +- [ ] `DefaultUserPerms` override via `global_config` key `default_permissions` +- [ ] Migration 020: permissions, budgets, allowed_models on groups + +--- + +## v0.24.3 — Anonymous / Session Participants + +Unauthenticated visitors for workflow intake channels. Direct +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 --- @@ -1316,7 +1462,7 @@ handles structured data collection. See [ARCHITECTURE.md — Workflow Architecture](ARCHITECTURE.md#workflow-architecture-future--v0210) for the conceptual model. -Depends on: multi-participant channels (v0.23.0), anonymous identity (v0.24.0). +Depends on: multi-participant channels (v0.23.0), anonymous identity (v0.24.3). _(Shifted from v0.22.0 — no content changes, dependency refs updated)_ diff --git a/server/auth/auth.go b/server/auth/auth.go new file mode 100644 index 0000000..1e7af27 --- /dev/null +++ b/server/auth/auth.go @@ -0,0 +1,53 @@ +package auth + +import ( + "errors" + + "github.com/gin-gonic/gin" + + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +type Mode string + +const ( + ModeBuiltin Mode = "builtin" + ModeMTLS Mode = "mtls" + ModeOIDC Mode = "oidc" +) + +func ParseMode(s string) (Mode, error) { + switch Mode(s) { + case ModeBuiltin, "": + return ModeBuiltin, nil + case ModeMTLS: + return ModeMTLS, nil + case ModeOIDC: + return ModeOIDC, nil + default: + return "", ErrUnsupportedMode + } +} + +type Result struct { + User *models.User + IsNewUser bool + VaultHint string // password (builtin) or "" (external) +} + +type Provider interface { + Mode() Mode + Authenticate(c *gin.Context, stores store.Stores) (*Result, error) + SupportsRegistration() bool + Register(c *gin.Context, stores store.Stores) (*Result, error) +} + +var ( + ErrUnsupportedMode = errors.New("unsupported auth mode") + ErrNotSupported = errors.New("operation not supported by this auth provider") + ErrInvalidCreds = errors.New("invalid credentials") + ErrInactive = errors.New("account is inactive") + ErrRegistrationOff = errors.New("registration is disabled") + ErrDuplicate = errors.New("username or email already taken") +) diff --git a/server/auth/builtin.go b/server/auth/builtin.go new file mode 100644 index 0000000..c172851 --- /dev/null +++ b/server/auth/builtin.go @@ -0,0 +1,130 @@ +package auth + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/gin-gonic/gin" + "golang.org/x/crypto/bcrypt" + + "git.gobha.me/xcaliber/chat-switchboard/models" + "git.gobha.me/xcaliber/chat-switchboard/store" +) + +const bcryptCost = 12 + +type BuiltinProvider struct{} + +func NewBuiltinProvider() *BuiltinProvider { return &BuiltinProvider{} } + +func (p *BuiltinProvider) Mode() Mode { return ModeBuiltin } +func (p *BuiltinProvider) SupportsRegistration() bool { return true } + +func (p *BuiltinProvider) Authenticate(c *gin.Context, stores store.Stores) (*Result, error) { + var req struct { + Login string `json:"login" binding:"required"` + Password string `json:"password" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + return nil, err + } + + user, err := stores.Users.GetByLogin(c.Request.Context(), req.Login) + if err != nil { + return nil, ErrInvalidCreds + } + if !user.IsActive { + return nil, ErrInactive + } + if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { + return nil, ErrInvalidCreds + } + + return &Result{User: user, VaultHint: req.Password}, nil +} + +func (p *BuiltinProvider) Register(c *gin.Context, stores store.Stores) (*Result, error) { + var req struct { + Username string `json:"username" binding:"required"` + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required,min=8"` + } + if err := c.ShouldBindJSON(&req); err != nil { + return nil, err + } + + ctx := c.Request.Context() + + allowed, _ := stores.Policies.GetBool(ctx, "allow_registration") + if !allowed { + return nil, ErrRegistrationOff + } + + if existing, _ := stores.Users.GetByUsername(ctx, req.Username); existing != nil { + return nil, ErrDuplicate + } + if existing, _ := stores.Users.GetByEmail(ctx, req.Email); existing != nil { + return nil, ErrDuplicate + } + + hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost) + if err != nil { + return nil, err + } + + handle := UniqueHandle(ctx, stores.Users, models.HandleFromName(req.Username)) + + defaultActive, _ := stores.Policies.GetBool(ctx, "default_user_active") + + user := &models.User{ + Username: strings.ToLower(req.Username), + Email: strings.ToLower(req.Email), + PasswordHash: string(hash), + Role: models.UserRoleUser, + IsActive: defaultActive, + AuthSource: string(ModeBuiltin), + Handle: handle, + } + + if err := stores.Users.Create(ctx, user); err != nil { + return nil, err + } + + return &Result{User: user, IsNewUser: true, VaultHint: req.Password}, nil +} + +// UniqueHandle appends -2, -3, etc. on collision. +func UniqueHandle(ctx context.Context, users store.UserStore, base string) string { + if base == "" { + base = "user" + } + if u, _ := users.GetByHandle(ctx, base); u == nil { + return base + } + for i := 2; i < 1000; i++ { + candidate := fmt.Sprintf("%s-%d", base, i) + if u, _ := users.GetByHandle(ctx, candidate); u == nil { + return candidate + } + } + return fmt.Sprintf("%s-%s", base, store.NewID()[:8]) +} + +func ErrorToHTTPStatus(err error) int { + switch err { + case ErrInvalidCreds: + return http.StatusUnauthorized + case ErrInactive: + return http.StatusForbidden + case ErrRegistrationOff: + return http.StatusForbidden + case ErrDuplicate: + return http.StatusConflict + case ErrNotSupported: + return http.StatusMethodNotAllowed + default: + return http.StatusBadRequest + } +} diff --git a/server/config/config.go b/server/config/config.go index 2ad86ee..280172f 100644 --- a/server/config/config.go +++ b/server/config/config.go @@ -67,6 +67,9 @@ type Config struct { // PROVIDER_AUTO_DISABLE_THRESHOLD: consecutive "down" hourly windows before a // provider is automatically deactivated. Default 3. Set to 0 to disable. ProviderAutoDisableThreshold int + + // Auth mode (v0.24.0): "builtin" (default) | "mtls" | "oidc" + AuthMode string } // Load reads configuration from environment variables. @@ -105,6 +108,8 @@ func Load() *Config { WorkspaceIndexConcurrency: getEnvInt("WORKSPACE_INDEX_CONCURRENCY", 2), ProviderAutoDisableThreshold: getEnvInt("PROVIDER_AUTO_DISABLE_THRESHOLD", 3), + + AuthMode: getEnv("AUTH_MODE", "builtin"), } } diff --git a/server/database/migrations/018_auth_abstraction.sql b/server/database/migrations/018_auth_abstraction.sql new file mode 100644 index 0000000..1bebc83 --- /dev/null +++ b/server/database/migrations/018_auth_abstraction.sql @@ -0,0 +1,13 @@ +-- Chat Switchboard — 018 Auth Abstraction (v0.24.0) +ALTER TABLE users + ADD COLUMN IF NOT EXISTS auth_source VARCHAR(20) NOT NULL DEFAULT 'builtin' + CHECK (auth_source IN ('builtin', 'mtls', 'oidc')); +ALTER TABLE users ADD COLUMN IF NOT EXISTS external_id TEXT; +ALTER TABLE users ADD COLUMN IF NOT EXISTS handle VARCHAR(100); +ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL; +UPDATE users SET handle = LOWER(REPLACE(REPLACE(username, ' ', '-'), '_', '-')) + WHERE handle IS NULL; +ALTER TABLE users ALTER COLUMN handle SET NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_handle ON users(LOWER(handle)); +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_external_id + ON users(auth_source, external_id) WHERE external_id IS NOT NULL; diff --git a/server/database/migrations/sqlite/018_auth_abstraction.sql b/server/database/migrations/sqlite/018_auth_abstraction.sql new file mode 100644 index 0000000..282ad0e --- /dev/null +++ b/server/database/migrations/sqlite/018_auth_abstraction.sql @@ -0,0 +1,8 @@ +-- Chat Switchboard — 018 Auth Abstraction (SQLite) (v0.24.0) +ALTER TABLE users ADD COLUMN auth_source TEXT NOT NULL DEFAULT 'builtin'; +ALTER TABLE users ADD COLUMN external_id TEXT; +ALTER TABLE users ADD COLUMN handle TEXT; +UPDATE users SET handle = LOWER(REPLACE(REPLACE(username, ' ', '-'), '_', '-')) + WHERE handle IS NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_handle ON users(handle COLLATE NOCASE); +CREATE UNIQUE INDEX IF NOT EXISTS idx_users_external_id ON users(auth_source, external_id); diff --git a/server/database/testhelper.go b/server/database/testhelper.go index f00a66e..24eedb9 100644 --- a/server/database/testhelper.go +++ b/server/database/testhelper.go @@ -356,12 +356,13 @@ func TruncateAll(t *testing.T) { // SeedTestUser creates a test user and returns the user ID. func SeedTestUser(t *testing.T, username, email string) string { t.Helper() + handle := strings.ToLower(strings.ReplaceAll(username, " ", "-")) if IsSQLite() { id := uuid.New().String() _, err := DB.Exec(` - INSERT INTO users (id, username, email, password_hash, role) - VALUES (?, ?, ?, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'user') - `, id, username, email) + INSERT INTO users (id, username, email, password_hash, role, auth_source, handle) + VALUES (?, ?, ?, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'user', 'builtin', ?) + `, id, username, email, handle) if err != nil { t.Fatalf("SeedTestUser: %v", err) } @@ -369,10 +370,10 @@ func SeedTestUser(t *testing.T, username, email string) string { } var id string err := DB.QueryRow(` - INSERT INTO users (username, email, password_hash, role) - VALUES ($1, $2, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'user') + INSERT INTO users (username, email, password_hash, role, auth_source, handle) + VALUES ($1, $2, '$2a$10$dummy.hash.for.testing.only.000000000000000000000', 'user', 'builtin', $3) RETURNING id - `, username, email).Scan(&id) + `, username, email, handle).Scan(&id) if err != nil { t.Fatalf("SeedTestUser: %v", err) } diff --git a/server/handlers/auth.go b/server/handlers/auth.go index c6d92f6..e9b5960 100644 --- a/server/handlers/auth.go +++ b/server/handlers/auth.go @@ -14,6 +14,7 @@ import ( "github.com/google/uuid" "golang.org/x/crypto/bcrypt" + "git.gobha.me/xcaliber/chat-switchboard/auth" "git.gobha.me/xcaliber/chat-switchboard/config" "git.gobha.me/xcaliber/chat-switchboard/crypto" "git.gobha.me/xcaliber/chat-switchboard/database" @@ -37,77 +38,40 @@ type AuthHandler struct { cfg *config.Config stores store.Stores uekCache *crypto.UEKCache + provider auth.Provider } -func NewAuthHandler(cfg *config.Config, s store.Stores, uekCache *crypto.UEKCache) *AuthHandler { - return &AuthHandler{cfg: cfg, stores: s, uekCache: uekCache} +func NewAuthHandler(cfg *config.Config, s store.Stores, uekCache *crypto.UEKCache, provider auth.Provider) *AuthHandler { + return &AuthHandler{cfg: cfg, stores: s, uekCache: uekCache, provider: provider} } func (h *AuthHandler) Register(c *gin.Context) { - var req struct { - Username string `json:"username" binding:"required"` - Email string `json:"email" binding:"required"` - Password string `json:"password" binding:"required,min=8"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + if !h.provider.SupportsRegistration() { + c.JSON(http.StatusMethodNotAllowed, gin.H{"error": "registration not supported for this auth mode"}) return } - // Check registration policy - allowed, _ := h.stores.Policies.GetBool(c.Request.Context(), "allow_registration") - if !allowed { - c.JSON(http.StatusForbidden, gin.H{"error": "registration is disabled"}) - return - } - - // Check duplicate - if existing, _ := h.stores.Users.GetByUsername(c.Request.Context(), req.Username); existing != nil { - c.JSON(http.StatusConflict, gin.H{"error": "username already taken"}) - return - } - if existing, _ := h.stores.Users.GetByEmail(c.Request.Context(), req.Email); existing != nil { - c.JSON(http.StatusConflict, gin.H{"error": "email already registered"}) - return - } - - hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcryptCost) + result, err := h.provider.Register(c, h.stores) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to hash password"}) + c.JSON(auth.ErrorToHTTPStatus(err), gin.H{"error": err.Error()}) return } - // Check if user should be active by default - defaultActive, _ := h.stores.Policies.GetBool(c.Request.Context(), "default_user_active") - - user := &models.User{ - Username: strings.ToLower(req.Username), - Email: strings.ToLower(req.Email), - PasswordHash: string(hash), - Role: models.UserRoleUser, - IsActive: defaultActive, + if result.VaultHint != "" { + if err := h.initVault(c.Request.Context(), result.User.ID, result.VaultHint); err != nil { + log.Printf("⚠ Failed to init vault for user %s: %v", result.User.ID, err) + } } - if err := h.stores.Users.Create(c.Request.Context(), user); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create user"}) - return - } - - // Generate and store the User Encryption Key (vault) - if err := h.initVault(c.Request.Context(), user.ID, req.Password); err != nil { - log.Printf("⚠ Failed to init vault for user %s: %v", user.ID, err) - // Non-fatal: user can still use the app, vault will init on next login - } - - if !user.IsActive { + if !result.User.IsActive { c.JSON(http.StatusCreated, gin.H{ "message": "Account created but requires admin approval", - "user_id": user.ID, + "user_id": result.User.ID, }) return } - tokens, err := h.generateTokens(user) + tokens, err := h.generateTokens(result.User) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"}) return @@ -117,37 +81,19 @@ func (h *AuthHandler) Register(c *gin.Context) { } func (h *AuthHandler) Login(c *gin.Context) { - var req struct { - Login string `json:"login" binding:"required"` // username or email - Password string `json:"password" binding:"required"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - user, err := h.stores.Users.GetByLogin(c.Request.Context(), req.Login) + result, err := h.provider.Authenticate(c, h.stores) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + c.JSON(auth.ErrorToHTTPStatus(err), gin.H{"error": err.Error()}) return } - if !user.IsActive { - c.JSON(http.StatusForbidden, gin.H{"error": "account is inactive"}) - return + if result.VaultHint != "" { + h.unlockVault(c.Request.Context(), result.User, result.VaultHint) } - if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) - return - } + h.stores.Users.UpdateLastLogin(c.Request.Context(), result.User.ID) - // Vault: unwrap UEK into session cache (or init if first login post-migration) - h.unlockVault(c.Request.Context(), user, req.Password) - - h.stores.Users.UpdateLastLogin(c.Request.Context(), user.ID) - - tokens, err := h.generateTokens(user) + tokens, err := h.generateTokens(result.User) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate tokens"}) return @@ -247,6 +193,8 @@ func (h *AuthHandler) generateTokens(user *models.User) (gin.H, error) { "email": user.Email, "display_name": user.DisplayName, "role": user.Role, + "handle": user.Handle, + "auth_source": user.AuthSource, }, }, nil } @@ -433,6 +381,11 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores) { }) // If the actual password changed, the vault seal is stale. Probe it // with the current password and destroy only if unwrap fails. + // Ensure handle exists (backfill for pre-v0.24.0 users) + if existing.Handle == "" { + handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(cfg.AdminUsername)) + s.Users.Update(ctx, existing.ID, map[string]interface{}{"handle": handle}) + } ProbeAndRepairVault(ctx, existing.ID, cfg.AdminPassword) log.Printf(" ✅ Admin user '%s' updated", cfg.AdminUsername) return @@ -443,12 +396,15 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores) { email = cfg.AdminUsername + "@switchboard.local" } + handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(cfg.AdminUsername)) user := &models.User{ Username: strings.ToLower(cfg.AdminUsername), Email: strings.ToLower(email), PasswordHash: string(hash), Role: models.UserRoleAdmin, IsActive: true, + AuthSource: "builtin", + Handle: handle, } if err := s.Users.Create(ctx, user); err != nil { @@ -516,17 +472,24 @@ func SeedUsers(cfg *config.Config, s store.Stores) { "is_active": true, }) // Probe vault with current password — only destroys if seal is stale + if existing.Handle == "" { + handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(username)) + s.Users.Update(ctx, existing.ID, map[string]interface{}{"handle": handle}) + } ProbeAndRepairVault(ctx, existing.ID, password) log.Printf(" 🌱 Seed user '%s' updated (role=%s)", username, role) continue } + handle := auth.UniqueHandle(ctx, s.Users, models.HandleFromName(username)) user := &models.User{ Username: username, Email: username + "@switchboard.local", PasswordHash: string(hash), Role: role, IsActive: true, + AuthSource: "builtin", + Handle: handle, } if err := s.Users.Create(ctx, user); err != nil { diff --git a/server/handlers/auth_test.go b/server/handlers/auth_test.go index b9a2dea..f3059d7 100644 --- a/server/handlers/auth_test.go +++ b/server/handlers/auth_test.go @@ -14,6 +14,7 @@ import ( "github.com/golang-jwt/jwt/v5" "golang.org/x/crypto/bcrypt" + "git.gobha.me/xcaliber/chat-switchboard/auth" "git.gobha.me/xcaliber/chat-switchboard/config" "git.gobha.me/xcaliber/chat-switchboard/store" ) @@ -21,12 +22,13 @@ import ( func testConfig() *config.Config { return &config.Config{ JWTSecret: "test-secret-key-for-unit-tests", + AuthMode: "builtin", } } // testAuthHandler creates an AuthHandler with nil stores (safe for non-DB tests). func testAuthHandler() *AuthHandler { - return NewAuthHandler(testConfig(), store.Stores{}, nil) + return NewAuthHandler(testConfig(), store.Stores{}, nil, auth.NewBuiltinProvider()) } // ── JWT Token Tests ───────────────────────── diff --git a/server/handlers/completion.go b/server/handlers/completion.go index 840a303..ba5c1f7 100644 --- a/server/handlers/completion.go +++ b/server/handlers/completion.go @@ -1340,12 +1340,12 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content } } - // 3. Try username (exact match) — v0.23.1 + // 3. Try user handle (exact match) — v0.24.0 // Only resolves to a user if the caller is not the same user (no self-mention) var mentionedUserID string err = database.DB.QueryRowContext(ctx, database.Q(` SELECT id FROM users - WHERE LOWER(username) = $1 AND id != $2 AND is_active = true + WHERE LOWER(handle) = $1 AND id != $2 AND is_active = true LIMIT 1 `), normalized, userID).Scan(&mentionedUserID) if err == nil && mentionedUserID != "" { @@ -1353,16 +1353,16 @@ func (h *CompletionHandler) resolveMention(ctx context.Context, userID, content return "", "", nil, mentionedUserID } - // 4. Try username (prefix — unambiguous only) — v0.23.1 + // 4. Try user handle (prefix — unambiguous only) — v0.24.0 var userCount int database.DB.QueryRowContext(ctx, database.Q(` SELECT COUNT(*) FROM users - WHERE LOWER(username) LIKE $1 AND id != $2 AND is_active = true + WHERE LOWER(handle) LIKE $1 AND id != $2 AND is_active = true `), normalized+"%", userID).Scan(&userCount) if userCount == 1 { database.DB.QueryRowContext(ctx, database.Q(` SELECT id FROM users - WHERE LOWER(username) LIKE $1 AND id != $2 AND is_active = true + WHERE LOWER(handle) LIKE $1 AND id != $2 AND is_active = true LIMIT 1 `), normalized+"%", userID).Scan(&mentionedUserID) if mentionedUserID != "" { diff --git a/server/handlers/integration_test.go b/server/handlers/integration_test.go index a6e93e6..259ffc0 100644 --- a/server/handlers/integration_test.go +++ b/server/handlers/integration_test.go @@ -16,6 +16,7 @@ import ( "github.com/google/uuid" "git.gobha.me/xcaliber/chat-switchboard/config" + authpkg "git.gobha.me/xcaliber/chat-switchboard/auth" "git.gobha.me/xcaliber/chat-switchboard/database" "git.gobha.me/xcaliber/chat-switchboard/middleware" "git.gobha.me/xcaliber/chat-switchboard/models" @@ -135,7 +136,7 @@ func setupHarness(t *testing.T) *testHarness { api := r.Group("/api/v1") // Auth (unprotected) - auth := NewAuthHandler(cfg, stores, nil) + auth := NewAuthHandler(cfg, stores, nil, authpkg.NewBuiltinProvider()) authGroup := api.Group("/auth") authGroup.POST("/register", auth.Register) authGroup.POST("/login", auth.Login) diff --git a/server/handlers/presence.go b/server/handlers/presence.go index b80dc38..0e51b7b 100644 --- a/server/handlers/presence.go +++ b/server/handlers/presence.go @@ -76,16 +76,16 @@ func SearchUsers(c *gin.Context) { q := strings.TrimSpace(c.Query("q")) query := database.Q(` - SELECT id, username, COALESCE(display_name, '') AS display_name + SELECT id, username, COALESCE(display_name, '') AS display_name, COALESCE(handle, '') AS handle FROM users WHERE is_active = true AND id != $1 `) args := []interface{}{userID} if q != "" { - query += database.Q(` AND (LOWER(username) LIKE $2 OR LOWER(display_name) LIKE $3)`) + query += database.Q(` AND (LOWER(username) LIKE $2 OR LOWER(display_name) LIKE $3 OR LOWER(handle) LIKE $4)`) pattern := "%" + strings.ToLower(q) + "%" - args = append(args, pattern, pattern) + args = append(args, pattern, pattern, pattern) } query += ` ORDER BY username LIMIT 20` @@ -101,12 +101,13 @@ func SearchUsers(c *gin.Context) { ID string `json:"id"` Username string `json:"username"` DisplayName string `json:"display_name"` + Handle string `json:"handle"` } results := []userResult{} for rows.Next() { var u userResult - if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName); err != nil { + if err := rows.Scan(&u.ID, &u.Username, &u.DisplayName, &u.Handle); err != nil { continue } results = append(results, u) diff --git a/server/main.go b/server/main.go index 30928b9..2d3506a 100644 --- a/server/main.go +++ b/server/main.go @@ -11,6 +11,7 @@ import ( "github.com/gin-gonic/gin" + "git.gobha.me/xcaliber/chat-switchboard/auth" "git.gobha.me/xcaliber/chat-switchboard/compaction" "git.gobha.me/xcaliber/chat-switchboard/config" "git.gobha.me/xcaliber/chat-switchboard/crypto" @@ -329,7 +330,22 @@ func main() { base.GET("/ws", middleware.Auth(cfg), hub.HandleWebSocket) // ── Auth routes (rate limited) ────────────── - auth := handlers.NewAuthHandler(cfg, stores, uekCache) + authMode, err := auth.ParseMode(cfg.AuthMode) + if err != nil { + log.Fatalf("❌ Invalid AUTH_MODE=%q: %v", cfg.AuthMode, err) + } + var authProvider auth.Provider + switch authMode { + case auth.ModeBuiltin: + authProvider = auth.NewBuiltinProvider() + case auth.ModeMTLS: + log.Fatal("❌ AUTH_MODE=mtls is not yet implemented (planned for v0.24.1)") + case auth.ModeOIDC: + log.Fatal("❌ AUTH_MODE=oidc is not yet implemented (planned for v0.24.1)") + } + log.Printf(" 🔑 Auth mode: %s", authMode) + + authH := handlers.NewAuthHandler(cfg, stores, uekCache, authProvider) authLimiter := middleware.NewRateLimiter(1, 5) api := base.Group("/api/v1") @@ -353,10 +369,10 @@ func main() { authGroup := api.Group("/auth") authGroup.Use(authLimiter.Limit()) { - authGroup.POST("/register", auth.Register) - authGroup.POST("/login", auth.Login) - authGroup.POST("/refresh", auth.Refresh) - authGroup.POST("/logout", auth.Logout) + authGroup.POST("/register", authH.Register) + authGroup.POST("/login", authH.Login) + authGroup.POST("/refresh", authH.Refresh) + authGroup.POST("/logout", authH.Logout) } // ── Public extension assets ──────────────── diff --git a/server/models/models.go b/server/models/models.go index a7bbe19..c56f3c9 100644 --- a/server/models/models.go +++ b/server/models/models.go @@ -80,6 +80,9 @@ type User struct { IsActive bool `json:"is_active" db:"is_active"` Settings JSONMap `json:"settings,omitempty" db:"settings"` LastLoginAt *time.Time `json:"last_login_at,omitempty" db:"last_login_at"` + AuthSource string `json:"auth_source" db:"auth_source"` + ExternalID *string `json:"external_id,omitempty" db:"external_id"` + Handle string `json:"handle" db:"handle"` } // ========================================= diff --git a/server/store/interfaces.go b/server/store/interfaces.go index 40b3b2e..a48b67d 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -167,6 +167,8 @@ type UserStore interface { GetByUsername(ctx context.Context, username string) (*models.User, error) GetByEmail(ctx context.Context, email string) (*models.User, error) GetByLogin(ctx context.Context, login string) (*models.User, error) // username or email + GetByHandle(ctx context.Context, handle string) (*models.User, error) + GetByExternalID(ctx context.Context, authSource, externalID string) (*models.User, error) Update(ctx context.Context, id string, fields map[string]interface{}) error Delete(ctx context.Context, id string) error List(ctx context.Context, opts ListOptions) ([]models.User, int, error) diff --git a/server/store/postgres/user.go b/server/store/postgres/user.go index a22b874..18d9d5d 100644 --- a/server/store/postgres/user.go +++ b/server/store/postgres/user.go @@ -14,77 +14,43 @@ type UserStore struct{} func NewUserStore() *UserStore { return &UserStore{} } +const userCols = `id, username, email, password_hash, display_name, avatar_url, + role, is_active, settings, created_at, updated_at, last_login_at, + auth_source, external_id, handle` + +const userListCols = `id, username, email, display_name, avatar_url, role, is_active, + settings, created_at, updated_at, last_login_at, auth_source, external_id, handle` + func (s *UserStore) Create(ctx context.Context, u *models.User) error { + if u.AuthSource == "" { + u.AuthSource = "builtin" + } return DB.QueryRowContext(ctx, ` - INSERT INTO users (username, email, password_hash, display_name, role, is_active, settings) - VALUES ($1, $2, $3, $4, $5, $6, $7) + INSERT INTO users (username, email, password_hash, display_name, role, is_active, settings, auth_source, external_id, handle) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id, created_at, updated_at`, - u.Username, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.IsActive, ToJSON(u.Settings), + u.Username, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.IsActive, + ToJSON(u.Settings), u.AuthSource, u.ExternalID, u.Handle, ).Scan(&u.ID, &u.CreatedAt, &u.UpdatedAt) } func (s *UserStore) GetByID(ctx context.Context, id string) (*models.User, error) { - return s.getBy(ctx, "id", id) + return scanOneUser(ctx, fmt.Sprintf("SELECT %s FROM users WHERE id = $1", userCols), id) } - func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) { - var u models.User - var displayName, avatarURL sql.NullString - var settingsJSON []byte - err := DB.QueryRowContext(ctx, ` - SELECT id, username, email, password_hash, display_name, avatar_url, - role, is_active, settings, created_at, updated_at, last_login_at - FROM users WHERE LOWER(username) = LOWER($1)`, username).Scan( - &u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL, - &u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt, - ) - if err != nil { - return nil, err - } - u.DisplayName = NullableString(displayName) - u.AvatarURL = NullableString(avatarURL) - ScanJSON(settingsJSON, &u.Settings) - return &u, nil + return scanOneUser(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(username) = LOWER($1)", userCols), username) } - func (s *UserStore) GetByEmail(ctx context.Context, email string) (*models.User, error) { - var u models.User - var displayName, avatarURL sql.NullString - var settingsJSON []byte - err := DB.QueryRowContext(ctx, ` - SELECT id, username, email, password_hash, display_name, avatar_url, - role, is_active, settings, created_at, updated_at, last_login_at - FROM users WHERE LOWER(email) = LOWER($1)`, email).Scan( - &u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL, - &u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt, - ) - if err != nil { - return nil, err - } - u.DisplayName = NullableString(displayName) - u.AvatarURL = NullableString(avatarURL) - ScanJSON(settingsJSON, &u.Settings) - return &u, nil + return scanOneUser(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(email) = LOWER($1)", userCols), email) } - func (s *UserStore) GetByLogin(ctx context.Context, login string) (*models.User, error) { - var u models.User - var displayName, avatarURL sql.NullString - var settingsJSON []byte - err := DB.QueryRowContext(ctx, ` - SELECT id, username, email, password_hash, display_name, avatar_url, - role, is_active, settings, created_at, updated_at, last_login_at - FROM users WHERE LOWER(username) = LOWER($1) OR LOWER(email) = LOWER($1)`, login).Scan( - &u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL, - &u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt, - ) - if err != nil { - return nil, err - } - u.DisplayName = NullableString(displayName) - u.AvatarURL = NullableString(avatarURL) - ScanJSON(settingsJSON, &u.Settings) - return &u, nil + return scanOneUser(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(username) = LOWER($1) OR LOWER(email) = LOWER($1)", userCols), login) +} +func (s *UserStore) GetByHandle(ctx context.Context, handle string) (*models.User, error) { + return scanOneUser(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(handle) = LOWER($1)", userCols), handle) +} +func (s *UserStore) GetByExternalID(ctx context.Context, authSource, externalID string) (*models.User, error) { + return scanOneUser(ctx, fmt.Sprintf("SELECT %s FROM users WHERE auth_source = $1 AND external_id = $2", userCols), authSource, externalID) } func (s *UserStore) Update(ctx context.Context, id string, fields map[string]interface{}) error { @@ -110,16 +76,12 @@ func (s *UserStore) Delete(ctx context.Context, id string) error { } func (s *UserStore) List(ctx context.Context, opts store.ListOptions) ([]models.User, int, error) { - b := NewSelect( - "id, username, email, display_name, avatar_url, role, is_active, settings, created_at, updated_at, last_login_at", - "users", - ) + b := NewSelect(userListCols, "users") if opts.Sort == "" { b.OrderBy("username", "ASC") } b.Paginate(opts) - // Count var total int DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&total) @@ -133,16 +95,19 @@ func (s *UserStore) List(ctx context.Context, opts store.ListOptions) ([]models. var result []models.User for rows.Next() { var u models.User - var displayName, avatarURL sql.NullString - var settingsJSON []byte - err := rows.Scan(&u.ID, &u.Username, &u.Email, &displayName, &avatarURL, - &u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt) - if err != nil { + var dn, av sql.NullString + var extID, hdl sql.NullString + var sj []byte + if err := rows.Scan(&u.ID, &u.Username, &u.Email, &dn, &av, + &u.Role, &u.IsActive, &sj, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt, + &u.AuthSource, &extID, &hdl); err != nil { return nil, 0, err } - u.DisplayName = NullableString(displayName) - u.AvatarURL = NullableString(avatarURL) - ScanJSON(settingsJSON, &u.Settings) + u.DisplayName = NullableString(dn) + u.AvatarURL = NullableString(av) + if extID.Valid { u.ExternalID = &extID.String } + u.Handle = NullableString(hdl) + ScanJSON(sj, &u.Settings) result = append(result, u) } return result, total, rows.Err() @@ -152,7 +117,6 @@ func (s *UserStore) UpdateLastLogin(ctx context.Context, id string) error { _, err := DB.ExecContext(ctx, "UPDATE users SET last_login_at = NOW() WHERE id = $1", id) return err } - func (s *UserStore) SetActive(ctx context.Context, id string, active bool) error { _, err := DB.ExecContext(ctx, "UPDATE users SET is_active = $1 WHERE id = $2", active, id) return err @@ -161,57 +125,47 @@ func (s *UserStore) SetActive(ctx context.Context, id string, active bool) error // ── Refresh Tokens ────────────────────────── func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error { - _, err := DB.ExecContext(ctx, ` - INSERT INTO refresh_tokens (user_id, token_hash, expires_at) - VALUES ($1, $2, $3)`, userID, tokenHash, expiresAt) + _, err := DB.ExecContext(ctx, `INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)`, userID, tokenHash, expiresAt) return err } - func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) { var userID string - err := DB.QueryRowContext(ctx, ` - SELECT user_id FROM refresh_tokens - WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`, - tokenHash).Scan(&userID) + err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`, tokenHash).Scan(&userID) return userID, err } - func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error { - _, err := DB.ExecContext(ctx, - "UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1", tokenHash) + _, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = NOW() WHERE token_hash = $1", tokenHash) return err } - func (s *UserStore) RevokeAllRefreshTokens(ctx context.Context, userID string) error { - _, err := DB.ExecContext(ctx, - "UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL", userID) + _, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL", userID) return err } - func (s *UserStore) CleanExpiredTokens(ctx context.Context) error { - _, err := DB.ExecContext(ctx, - "DELETE FROM refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days'") + _, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < NOW() - INTERVAL '30 days'") return err } // ── Internal ──────────────────────────────── -func (s *UserStore) getBy(ctx context.Context, col, val string) (*models.User, error) { +func scanOneUser(ctx context.Context, query string, args ...interface{}) (*models.User, error) { var u models.User - var displayName, avatarURL sql.NullString - var settingsJSON []byte - err := DB.QueryRowContext(ctx, fmt.Sprintf(` - SELECT id, username, email, password_hash, display_name, avatar_url, - role, is_active, settings, created_at, updated_at, last_login_at - FROM users WHERE %s = $1`, col), val).Scan( - &u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL, - &u.Role, &u.IsActive, &settingsJSON, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt, + var dn, av, ph sql.NullString + var extID, hdl sql.NullString + var sj []byte + err := DB.QueryRowContext(ctx, query, args...).Scan( + &u.ID, &u.Username, &u.Email, &ph, &dn, &av, + &u.Role, &u.IsActive, &sj, &u.CreatedAt, &u.UpdatedAt, &u.LastLoginAt, + &u.AuthSource, &extID, &hdl, ) if err != nil { return nil, err } - u.DisplayName = NullableString(displayName) - u.AvatarURL = NullableString(avatarURL) - ScanJSON(settingsJSON, &u.Settings) + u.PasswordHash = NullableString(ph) + u.DisplayName = NullableString(dn) + u.AvatarURL = NullableString(av) + if extID.Valid { u.ExternalID = &extID.String } + u.Handle = NullableString(hdl) + ScanJSON(sj, &u.Settings) return &u, nil } diff --git a/server/store/sqlite/user.go b/server/store/sqlite/user.go index 3d43a21..dc8f805 100644 --- a/server/store/sqlite/user.go +++ b/server/store/sqlite/user.go @@ -14,82 +14,49 @@ type UserStore struct{} func NewUserStore() *UserStore { return &UserStore{} } +const userCols = `id, username, email, password_hash, display_name, avatar_url, + role, is_active, settings, created_at, updated_at, last_login_at, + auth_source, external_id, handle` + +const userListCols = `id, username, email, display_name, avatar_url, role, is_active, + settings, created_at, updated_at, last_login_at, auth_source, external_id, handle` + func (s *UserStore) Create(ctx context.Context, u *models.User) error { u.ID = store.NewID() now := time.Now().UTC() u.CreatedAt = now u.UpdatedAt = now + if u.AuthSource == "" { + u.AuthSource = "builtin" + } _, err := DB.ExecContext(ctx, ` - INSERT INTO users (id, username, email, password_hash, display_name, role, is_active, settings, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - u.ID, u.Username, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.IsActive, ToJSON(u.Settings), - now.Format(timeFmt), now.Format(timeFmt), + INSERT INTO users (id, username, email, password_hash, display_name, role, is_active, + settings, created_at, updated_at, auth_source, external_id, handle) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + u.ID, u.Username, u.Email, u.PasswordHash, u.DisplayName, u.Role, u.IsActive, + ToJSON(u.Settings), now.Format(timeFmt), now.Format(timeFmt), + u.AuthSource, u.ExternalID, u.Handle, ) return err } func (s *UserStore) GetByID(ctx context.Context, id string) (*models.User, error) { - return s.getBy(ctx, "id", id) + return s.scanOne(ctx, fmt.Sprintf("SELECT %s FROM users WHERE id = ?", userCols), id) } - func (s *UserStore) GetByUsername(ctx context.Context, username string) (*models.User, error) { - var u models.User - var displayName, avatarURL sql.NullString - var settingsJSON []byte - err := DB.QueryRowContext(ctx, ` - SELECT id, username, email, password_hash, display_name, avatar_url, - role, is_active, settings, created_at, updated_at, last_login_at - FROM users WHERE LOWER(username) = LOWER(?)`, username).Scan( - &u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL, - &u.Role, &u.IsActive, &settingsJSON, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt), - ) - if err != nil { - return nil, err - } - u.DisplayName = NullableString(displayName) - u.AvatarURL = NullableString(avatarURL) - ScanJSON(settingsJSON, &u.Settings) - return &u, nil + return s.scanOne(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(username) = LOWER(?)", userCols), username) } - func (s *UserStore) GetByEmail(ctx context.Context, email string) (*models.User, error) { - var u models.User - var displayName, avatarURL sql.NullString - var settingsJSON []byte - err := DB.QueryRowContext(ctx, ` - SELECT id, username, email, password_hash, display_name, avatar_url, - role, is_active, settings, created_at, updated_at, last_login_at - FROM users WHERE LOWER(email) = LOWER(?)`, email).Scan( - &u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL, - &u.Role, &u.IsActive, &settingsJSON, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt), - ) - if err != nil { - return nil, err - } - u.DisplayName = NullableString(displayName) - u.AvatarURL = NullableString(avatarURL) - ScanJSON(settingsJSON, &u.Settings) - return &u, nil + return s.scanOne(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(email) = LOWER(?)", userCols), email) } - func (s *UserStore) GetByLogin(ctx context.Context, login string) (*models.User, error) { - var u models.User - var displayName, avatarURL sql.NullString - var settingsJSON []byte - err := DB.QueryRowContext(ctx, ` - SELECT id, username, email, password_hash, display_name, avatar_url, - role, is_active, settings, created_at, updated_at, last_login_at - FROM users WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)`, login, login).Scan( - &u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL, - &u.Role, &u.IsActive, &settingsJSON, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt), - ) - if err != nil { - return nil, err - } - u.DisplayName = NullableString(displayName) - u.AvatarURL = NullableString(avatarURL) - ScanJSON(settingsJSON, &u.Settings) - return &u, nil + return s.scanOne(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)", userCols), login, login) +} +func (s *UserStore) GetByHandle(ctx context.Context, handle string) (*models.User, error) { + return s.scanOne(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(handle) = LOWER(?)", userCols), handle) +} +func (s *UserStore) GetByExternalID(ctx context.Context, authSource, externalID string) (*models.User, error) { + return s.scanOne(ctx, fmt.Sprintf("SELECT %s FROM users WHERE auth_source = ? AND external_id = ?", userCols), authSource, externalID) } func (s *UserStore) Update(ctx context.Context, id string, fields map[string]interface{}) error { @@ -115,16 +82,12 @@ func (s *UserStore) Delete(ctx context.Context, id string) error { } func (s *UserStore) List(ctx context.Context, opts store.ListOptions) ([]models.User, int, error) { - b := NewSelect( - "id, username, email, display_name, avatar_url, role, is_active, settings, created_at, updated_at, last_login_at", - "users", - ) + b := NewSelect(userListCols, "users") if opts.Sort == "" { b.OrderBy("username", "ASC") } b.Paginate(opts) - // Count var total int DB.QueryRowContext(ctx, "SELECT COUNT(*) FROM users").Scan(&total) @@ -138,16 +101,19 @@ func (s *UserStore) List(ctx context.Context, opts store.ListOptions) ([]models. var result []models.User for rows.Next() { var u models.User - var displayName, avatarURL sql.NullString - var settingsJSON []byte - err := rows.Scan(&u.ID, &u.Username, &u.Email, &displayName, &avatarURL, - &u.Role, &u.IsActive, &settingsJSON, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt)) - if err != nil { + var dn, av sql.NullString + var extID, hdl sql.NullString + var sj []byte + if err := rows.Scan(&u.ID, &u.Username, &u.Email, &dn, &av, + &u.Role, &u.IsActive, &sj, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt), + &u.AuthSource, &extID, &hdl); err != nil { return nil, 0, err } - u.DisplayName = NullableString(displayName) - u.AvatarURL = NullableString(avatarURL) - ScanJSON(settingsJSON, &u.Settings) + u.DisplayName = NullableString(dn) + u.AvatarURL = NullableString(av) + if extID.Valid { u.ExternalID = &extID.String } + u.Handle = NullableString(hdl) + ScanJSON(sj, &u.Settings) result = append(result, u) } return result, total, rows.Err() @@ -157,7 +123,6 @@ func (s *UserStore) UpdateLastLogin(ctx context.Context, id string) error { _, err := DB.ExecContext(ctx, "UPDATE users SET last_login_at = datetime('now') WHERE id = ?", id) return err } - func (s *UserStore) SetActive(ctx context.Context, id string, active bool) error { _, err := DB.ExecContext(ctx, "UPDATE users SET is_active = ? WHERE id = ?", active, id) return err @@ -166,57 +131,48 @@ func (s *UserStore) SetActive(ctx context.Context, id string, active bool) error // ── Refresh Tokens ────────────────────────── func (s *UserStore) CreateRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error { - _, err := DB.ExecContext(ctx, ` - INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) - VALUES (?, ?, ?, ?)`, store.NewID(), userID, tokenHash, expiresAt.Format(timeFmt)) + _, err := DB.ExecContext(ctx, `INSERT INTO refresh_tokens (id, user_id, token_hash, expires_at) VALUES (?, ?, ?, ?)`, + store.NewID(), userID, tokenHash, expiresAt.Format(timeFmt)) return err } - func (s *UserStore) GetRefreshToken(ctx context.Context, tokenHash string) (string, error) { var userID string - err := DB.QueryRowContext(ctx, ` - SELECT user_id FROM refresh_tokens - WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > datetime('now')`, - tokenHash).Scan(&userID) + err := DB.QueryRowContext(ctx, `SELECT user_id FROM refresh_tokens WHERE token_hash = ? AND revoked_at IS NULL AND expires_at > datetime('now')`, tokenHash).Scan(&userID) return userID, err } - func (s *UserStore) RevokeRefreshToken(ctx context.Context, tokenHash string) error { - _, err := DB.ExecContext(ctx, - "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE token_hash = ?", tokenHash) + _, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE token_hash = ?", tokenHash) return err } - func (s *UserStore) RevokeAllRefreshTokens(ctx context.Context, userID string) error { - _, err := DB.ExecContext(ctx, - "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE user_id = ? AND revoked_at IS NULL", userID) + _, err := DB.ExecContext(ctx, "UPDATE refresh_tokens SET revoked_at = datetime('now') WHERE user_id = ? AND revoked_at IS NULL", userID) return err } - func (s *UserStore) CleanExpiredTokens(ctx context.Context) error { - _, err := DB.ExecContext(ctx, - "DELETE FROM refresh_tokens WHERE expires_at < datetime('now', '-30 days')") + _, err := DB.ExecContext(ctx, "DELETE FROM refresh_tokens WHERE expires_at < datetime('now', '-30 days')") return err } // ── Internal ──────────────────────────────── -func (s *UserStore) getBy(ctx context.Context, col, val string) (*models.User, error) { +func (s *UserStore) scanOne(ctx context.Context, query string, args ...interface{}) (*models.User, error) { var u models.User - var displayName, avatarURL sql.NullString - var settingsJSON []byte - err := DB.QueryRowContext(ctx, fmt.Sprintf(` - SELECT id, username, email, password_hash, display_name, avatar_url, - role, is_active, settings, created_at, updated_at, last_login_at - FROM users WHERE %s = ?`, col), val).Scan( - &u.ID, &u.Username, &u.Email, &u.PasswordHash, &displayName, &avatarURL, - &u.Role, &u.IsActive, &settingsJSON, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt), + var dn, av, ph sql.NullString + var extID, hdl sql.NullString + var sj []byte + err := DB.QueryRowContext(ctx, query, args...).Scan( + &u.ID, &u.Username, &u.Email, &ph, &dn, &av, + &u.Role, &u.IsActive, &sj, st(&u.CreatedAt), st(&u.UpdatedAt), stN(&u.LastLoginAt), + &u.AuthSource, &extID, &hdl, ) if err != nil { return nil, err } - u.DisplayName = NullableString(displayName) - u.AvatarURL = NullableString(avatarURL) - ScanJSON(settingsJSON, &u.Settings) + u.PasswordHash = NullableString(ph) + u.DisplayName = NullableString(dn) + u.AvatarURL = NullableString(av) + if extID.Valid { u.ExternalID = &extID.String } + u.Handle = NullableString(hdl) + ScanJSON(sj, &u.Settings) return &u, nil } diff --git a/src/js/app-state.js b/src/js/app-state.js index 8f5e4f0..7f0f04e 100644 --- a/src/js/app-state.js +++ b/src/js/app-state.js @@ -28,6 +28,7 @@ const App = { collapsedFolders: {}, presence: {}, // { userId: 'online' | 'offline' } users: [], // v0.23.2: [{id, username, displayName}] for @mention autocomplete + activeParticipants: [], // v0.24.0: user participants of active channel (for scoped autocomplete) /** Get active conversation ID (shorthand). */ get activeId() { return this.activeConversation?.id || null; }, @@ -38,6 +39,7 @@ const App = { /** Set the active conversation. Clears if id is null. */ setActive(id, type) { this.activeConversation = id ? { id, type: type || 'direct' } : null; + this.activeParticipants = []; }, /** Find the active conversation's chat object (with cached messages). */ diff --git a/src/js/channel-models.js b/src/js/channel-models.js index a291f5a..e6fbf78 100644 --- a/src/js/channel-models.js +++ b/src/js/channel-models.js @@ -219,16 +219,30 @@ const ChannelModels = { || firstName.startsWith(partial); }); - // v0.23.2: Include users in autocomplete (after personas, before models) - const userMatches = (App.users || []).filter(u => { + // v0.24.0: Context-aware user autocomplete. + // direct / dm : no users (1:1 AI or already-defined human pair) + // group : participants only (defined roster) + // channel : full user list (open, ad-hoc) + const activeType = App.activeType || 'direct'; + let userPool = []; + if (activeType === 'channel') { + userPool = App.users || []; + } else if (activeType === 'group') { + const pids = App.activeParticipants || []; + userPool = (App.users || []).filter(u => pids.includes(u.id)); + } + // direct and dm: userPool stays empty + + const userMatches = userPool.filter(u => { + const uhandle = (u.handle || '').toLowerCase(); const uname = u.username.toLowerCase(); const dname = (u.displayName || '').toLowerCase(); if (partial.length === 0) return true; - return uname.startsWith(partial) || dname.startsWith(partial); + return uhandle.startsWith(partial) || uname.startsWith(partial) || dname.startsWith(partial); }).map(u => ({ - id: u.username, + id: u.handle || u.username, name: u.displayName || u.username, - baseModelId: u.username, + baseModelId: u.handle || u.username, isPersona: false, isUser: true, personaHandle: null, diff --git a/src/js/projects-ui.js b/src/js/projects-ui.js index 1c831fd..ce9638c 100644 --- a/src/js/projects-ui.js +++ b/src/js/projects-ui.js @@ -1215,7 +1215,7 @@ function normalizeChannel(ch) { }; } -// v0.23.2: Load user list for @mention autocomplete and DM creation +// v0.24.0: Load user list for @mention autocomplete and DM creation async function loadUsers() { try { const resp = await API._get('/api/v1/users/search?q='); @@ -1223,6 +1223,7 @@ async function loadUsers() { id: u.id, username: u.username, displayName: u.display_name || u.username, + handle: u.handle || u.username, })); } catch (e) { console.warn('[users] load failed:', e.message); @@ -1233,6 +1234,7 @@ async function loadUsers() { async function selectChannel(channelId) { const ch = (App.channels || []).find(c => c.id === channelId); App.setActive(channelId, ch?.type || 'channel'); + App.activeParticipants = []; // clear until loaded if (typeof UI !== 'undefined') { UI.renderChannelsSection(); UI.renderChatList(); @@ -1279,6 +1281,16 @@ async function selectChannel(channelId) { sessionStorage.setItem('cs-active-conversation', JSON.stringify(App.activeConversation)); } catch (_) {} + // v0.24.0: Load participant list for scoped @mention autocomplete (group only) + if (ch?.type === 'group') { + try { + const resp = await API._get(`/api/v1/channels/${channelId}/participants`); + App.activeParticipants = (resp.participants || []) + .filter(p => p.participant_type === 'user') + .map(p => p.participant_id); + } catch (_) { App.activeParticipants = []; } + } + // v0.23.2: Mark as read and clear unread badge const selCh = (App.channels || []).find(c => c.id === channelId); if (selCh) selCh.unread = 0;