Changeset 0.24.0 (#156)

This commit is contained in:
2026-03-07 11:27:24 +00:00
parent 2dc4514a57
commit a63728a481
23 changed files with 1850 additions and 385 deletions

View File

@@ -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.0v0.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 34 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

View File

@@ -1 +1 @@
0.23.2
0.24.0

1110
docs/DESIGN-0.24.0.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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.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.23.1 Multi-User Navigation + Conversation Taxonomy ✅
v0.24.0 Auth Strategy (mTLS/OIDC) + Full RBAC + Group permissions
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.1v0.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)_

53
server/auth/auth.go Normal file
View File

@@ -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")
)

130
server/auth/builtin.go Normal file
View File

@@ -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
}
}

View File

@@ -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"),
}
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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 ─────────────────────────

View File

@@ -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 != "" {

View File

@@ -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)

View File

@@ -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)

View File

@@ -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 ────────────────

View File

@@ -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"`
}
// =========================================

View File

@@ -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)

View File

@@ -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
return scanOneUser(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(username) = LOWER($1)", userCols), username)
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
return &u, nil
}
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
return scanOneUser(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(email) = LOWER($1)", userCols), email)
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
return &u, nil
}
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
return scanOneUser(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(username) = LOWER($1) OR LOWER(email) = LOWER($1)", userCols), login)
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
return &u, nil
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
}

View File

@@ -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
return s.scanOne(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(username) = LOWER(?)", userCols), username)
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
return &u, nil
}
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
return s.scanOne(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(email) = LOWER(?)", userCols), email)
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
return &u, nil
}
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
return s.scanOne(ctx, fmt.Sprintf("SELECT %s FROM users WHERE LOWER(username) = LOWER(?) OR LOWER(email) = LOWER(?)", userCols), login, login)
}
u.DisplayName = NullableString(displayName)
u.AvatarURL = NullableString(avatarURL)
ScanJSON(settingsJSON, &u.Settings)
return &u, nil
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
}

View File

@@ -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). */

View File

@@ -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,

View File

@@ -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;