Changeset 0.24.1 (#157)

This commit is contained in:
2026-03-07 17:11:32 +00:00
parent a63728a481
commit b6cc4df6e7
27 changed files with 2094 additions and 453 deletions

View File

@@ -1351,7 +1351,7 @@ Depends on: v0.23.1 sidebar and conversation taxonomy.
---
## v0.24.0 — Auth Abstraction + User Identity ← active
## v0.24.0 — Auth Abstraction + User Identity
Auth provider interface decoupling builtin auth from the handler/middleware
layer. User model extended with `auth_source`, `external_id`, `handle`.
@@ -1394,24 +1394,31 @@ Depends on: v0.23.0 (channel_participants), v0.16.0 (groups), v0.9.4 (vault).
- [x] Frontend autocomplete matches on handle, uses handle as @mention token
**Migration:**
- [ ] 018_auth_abstraction: `auth_source`, `external_id`, `handle` columns + unique indexes + backfill
- [x] 018_auth_abstraction: `auth_source`, `external_id`, `handle` columns + unique indexes + backfill
---
## v0.24.1 — mTLS + OIDC Providers
## v0.24.1 — mTLS + OIDC Providers
Two external auth implementations plugging into v0.24.0's provider interface.
Keycloak integration test environment via docker-compose overlay.
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
- [x] mTLS provider: trusted header extraction, cert DN parsing, auto-provision
- [x] OIDC provider: well-known discovery, JWKS caching, token validation, claim extraction
- [x] OIDC authorization code flow: login redirect, code exchange, token handoff via fragment
- [x] Split-horizon issuer support (`OIDC_EXTERNAL_ISSUER_URL` for Docker/K8s)
- [x] OIDC claim → group mapping (external groups sync to internal groups with `source=oidc`)
- [x] Login page adapts by `AUTH_MODE` (form / cert status / SSO button)
- [x] Keycloak docker-compose overlay with pre-configured realm (`ci/keycloak-realm.json`)
- [x] `QArgs()` dialect adapter: handles Postgres `$N` reuse for SQLite arg expansion
- [x] `database.QueryRow/Query/Exec` wrappers with automatic arg expansion
- [x] Migration 019: `oidc_auth_state` table, `groups.source` column
- [x] Unit tests: mTLS (ParseDN, config), OIDC (discovery, auth URL, mode), QArgs (5 tests)
- [x] Regression test: `TestIntegration_ChannelListWithTypeFilter`
- [x] SQLite local dev fixes: `_time_format` DSN, store wiring, nginx resolver, extension assets
---

View File

@@ -1,399 +0,0 @@
# TODO — v0.23.2
Working checklist. Covers remaining v0.23.1 multi-user scope plus
deferred v0.22.8 surface integration and the channel persistence bug.
ROADMAP and CHANGELOG updated when work is complete.
---
## Already Shipped (in 0.23.0 / 0.23.1 changesets)
Backend foundation is in place. These are done and should not be
re-implemented.
- [x] Migration 016: `ai_mode`, `topic` on channels; `user_presence`
table; channel type constraint extended to `dm`, `channel`
- [x] `ai_mode` guard in `completion.go``off` returns 403,
`mention_only` without @mention returns `{status: delivered}`
- [x] `resolveMention()` resolves users (steps 34: exact + prefix
on `users.username`, self-mention blocked)
- [x] User @mention in completion handler — skips AI, attempts
`NotifyUserMention` via interface cast (hub method not yet
implemented — silently no-ops)
- [x] Presence endpoints: `POST /presence/heartbeat` (upsert),
`GET /presence?users=...` (90s threshold query)
- [x] Folders: full CRUD handler (`handlers/folders.go`), wired routes
- [x] `CreateChannel` accepts `type` field (default `direct`)
- [x] `ListChannels` supports `?types=dm,channel` multi-filter
- [x] Three-section sidebar in `chat.html` (Projects / Channels / Chats)
- [x] `renderChannelsSection()` in `ui-core.js` (# / person icons,
unread badges, online dots, active state)
- [x] `selectChannel()`, `newChannelOrDM()`, `loadChannels()` in
`projects-ui.js`
- [x] Folder UI: create, rename, delete, context menu, drag chats
- [x] Chat list filters out `type=channel` and `type=dm`
- [x] API methods: `createChannel(title,model,sp,type)`,
`listSidebarChannels()`, `presenceHeartbeat()`
- [x] `persona_groups` + `persona_group_members` tables (migration 004)
- [x] `PersonaGroup` / `PersonaGroupMember` model structs
- [x] Channel participants: CRUD handler, auto-created on channel create
---
## Bug Fixes
- [x] **Channel persistence through refresh.** `loadChannels()` read
`resp.channels` but `ListChannels` returns a `paginatedResponse`
with `data` key. Channels created in-session appeared (pushed to
`App.channels` client-side) but vanished on reload because the
API response was always parsed as empty.
Fix: `resp.channels``resp.data` in `projects-ui.js`.
- [x] **Folder drag-and-drop completely broken.** Three compounding
issues:
(a) `loadChats()` never mapped `c.folder``folderId` — all
chats rendered as unfiled regardless of DB state.
(b) Folder groups rendered "Drop chats here" but had zero drag
event handlers (`ondragover`/`ondrop` missing).
(c) No drop zone existed for unfiling a chat or removing from
a project — the Chats section body had no drag handlers.
Fixes: added `folderId: c.folder || null` to `loadChats()` map;
added `ondragover`/`ondragleave`/`ondrop` to `.sb-folder-group`
divs in `renderChatList()`; added `onChatSectionDrop` handler to
`#sbBodyChats` (handles both unfile-from-folder and remove-from-
project); added `onFolderDrop` and `moveChatToFolder` functions;
added "Move to folder" / "Remove from folder" to chat context
menu; added drag-over CSS for folders and section body.
- [x] **DM creation 404 on `/api/v1/users`.** Route didn't exist.
Added `GET /api/v1/users/search?q=` endpoint returning id,
username, display_name for approved users (max 20, excludes
caller). Registered on `protected` group (any authed user).
Rewrote DM creation UI from text input to lazy search modal —
shows all users on open, filters as you type with 200ms debounce,
click to select and create DM.
- [x] **No unfiled drop zone when folders exist.** When folders were
present, folder groups consumed all space in `#sbBodyChats`,
leaving no bare target to drag a chat out of a folder. Added
`.sb-unfiled-zone` wrapper around unfiled chats with its own
drag handlers. Shows "Drop here to unfile" hint when zone is
empty. `onUnfiledDrop` handles both unfile-from-folder and
remove-from-project.
- [x] **No channel CRUD.** Channel sidebar items had no context menu
or management affordances. Added: right-click context menu with
Rename / Set topic (channels only) / Delete. Inline ✕ delete
button on hover (same pattern as chat items). `renameChannel()`
uses `_showCreationDialog` with pre-filled value. `deleteChannel()`
with confirm dialog removes from `App.channels` + `App.chats`,
clears active if deleted channel was selected.
- [x] **Drag-over highlight persists.** `onChatDragEnd` only cleared
`.project-group.drag-over` — missed folders, unfiled zone, and
section body. Fixed to `document.querySelectorAll('.drag-over')`
so all drop targets are cleaned up when a drag ends anywhere.
- [x] **Channel ⋯ menu inconsistency.** Channels used ✕ delete button
+ right-click only, while folders used ⋯ hover button. Replaced
channel ✕ with `.sb-ch-menu` ⋯ button matching the folder
pattern (hidden, shown on hover, triggers context menu on click).
Both channels and folders now have ⋯ hover + right-click.
- [x] **Folder delete silently spills chats.** `deleteFolder()` always
unfiled contained chats with no user input. Replaced with a
three-button modal when the folder has chats: "Keep chats"
(unfile and preserve), "Delete chats too" (hard-delete all
contained chats), or Cancel. Empty folders get a simple confirm.
Delete-all path iterates chats, calls `API.deleteChannel()` for
each, clears active state if needed, then removes the folder.
- [x] **Folder ⋯ button shifts collapse arrow.** The ⋯ button used
`display:none/block` which caused reflow on hover, shifting the
arrow left. Fixed: swapped DOM order (⋯ before arrow), changed
to `visibility:hidden/visible` so space is always reserved.
Applied same fix to channel ⋯ button. Arrow gets fixed width
with `flex-shrink:0`.
- [x] **Channels STILL not surviving refresh (round 2).** The unread
count subquery in `ListChannels` referenced
`cp.last_read_message_id` — a column added by migration 017.
If 017 hasn't been applied (fresh DB only has through 016), the
query fails and returns zero channels. Additionally, the `$1`
parameter was reused in Postgres (subquery + WHERE) which would
break SQLite's positional `?` binding.
Fix: rewrote unread subquery to use `last_read_at` (exists since
migration 005). Split `$1` reuse into `$1` (subquery) and `$2`
(WHERE), with `args` providing `userID` twice. `MarkRead` also
fixed to use `last_read_at` as primary, `last_read_message_id`
as best-effort (silently fails if 017 not applied).
- [x] **Settings Models section empty.** Template `models` section
fell through to the generic `settingsDynamic` div, but
`loadUserModels()` wrote to `#userModelList` which didn't exist.
Added dedicated template sections for `models` and `teams`.
- [x] **CI: TestIntegration_Messages_CRUD 500.** `ListMessages` JOIN
used `u.avatar` but the column is `avatar_url` in the users
table. Query failed on SQLite (Postgres would fail too). Same
bug in `treepath/path.go` `resolveSenderInfo()`.
Fix: `u.avatar``u.avatar_url` in both locations.
---
## Design Decisions (resolved)
**Unified active conversation.** `App.currentChatId` and
`App.currentChannelId` merge into `App.activeConversation = { id, type }`
where type is `direct|dm|group|channel`. The send path, model bar,
input state, streaming, session restore, and URL sync all key off
`activeConversation.id`. The type discriminator drives ai_mode checks,
context banner visibility, and participant rendering. Refactor touches
`selectChat()`, `selectChannel()`, `sendMessage()`, session restore in
`app.js`, and active-highlight in sidebar renderers.
**DM deduplication.** One channel per participant pair. `CreateChannel`
for `type=dm` checks for an existing DM with the same two participants
before creating. Display name is the other party's name (from the
caller's perspective). For future 3+ human DMs: comma-join up to 2
names + "and N others".
**No LLMs in DMs.** DMs are human-to-human by definition. `ai_mode`
is `mention_only` — you can @mention a persona for a one-off response,
but personas are not participants. If you want persistent AI in a
conversation with another human, that's a Group Chat (`type=group`).
The taxonomy stays clean.
**Post-creation participant mutation.** Users and personas can be added
or removed from any channel/group after creation. Participant CRUD
endpoints already exist. Constraints: DMs cannot drop below 2 human
participants; groups cannot drop their last persona (becomes a DM at
that point — block with error, not silent type coercion).
**Channel deletion vs archival.** Governed by `channel_retention.mode`:
- `flexible` (default): delete or archive freely, user's choice.
- `retain`: archive only. Delete button becomes "Archive" everywhere.
`DELETE /channels/:id` returns 403 for non-admins. Admins purge
from the admin panel, subject to retention age floor.
See "Retention Policy" section below for the full lifecycle.
---
## 1 · Unified Active Conversation
Prerequisite refactor. Do this first — everything else builds on it.
- [x] **`App.activeConversation` object.** Replace `App.currentChatId`
and `App.currentChannelId` with `App.activeConversation = { id,
type }` (null when nothing selected). Update `app-state.js`.
Added `activeId` getter, `activeType` getter, `setActive(id,type)`
method, and `getActiveChat()` helper.
- [x] **`selectChat()` migration.** Sets
`App.activeConversation = { id: chatId, type: chat.type }`.
Session restore reads/writes `cs-active-conversation` (JSON).
- [x] **`selectChannel()` migration.** Sets
`App.activeConversation = { id, type: ch.type }`. Same session
storage key. Now also ensures conversation object in `App.chats`
for message caching and maps messages into standard shape.
- [x] **Send path.** `sendMessage()` reads `App.activeId`
for the channel ID. No behavior change — the backend already
handles all channel types on the same completion endpoint.
- [x] **Model bar, input state, streaming.** All `currentChatId`
references → `App.activeId`. All `App.chats.find(...)` for
active chat → `App.getActiveChat()`. 12 files updated.
- [x] **Sidebar active highlight.** Both `renderChatList()` and
`renderChannelsSection()` check `App.activeId` for the
active class. Selecting either clears the other.
- [x] **URL sync.** `history.replaceState` uses
`/chat/${activeConversation.id}` regardless of type. The Go
route handler already loads any channel by ID.
- [x] **Session restore.** Reads `cs-active-conversation` JSON,
checks both `App.chats` and `App.channels` arrays, calls
`selectChat()` or `selectChannel()` as appropriate.
- [x] **`newChannelOrDM()` integration.** Now pushes to both
`App.channels` (sidebar) and `App.chats` (message cache),
and sets the new channel as active.
---
## 2 · Channel + DM Plumbing
- [x] **NotifyUserMention on events hub.** Replaced broken gin context
interface cast with direct `h.hub.SendToUser()`. Delivers
`user.mentioned` event with channel_id, from_user, content
preview. Added `user.mentioned` to event route table as
`DirToClient`. Added `truncateContent()` helper.
- [x] **DM creation flow.** Sidebar "+" shows choice dialog (New Channel
/ New DM). DM flow uses `_showCreationDialog` with username input,
looks up user by username, creates `type=dm` channel with
`participants: [targetId]`. Sets `ai_mode=mention_only` by default.
- [x] **DM dedup guard.** `CreateChannel` for `type=dm` queries
`channel_participants` for existing DM between the two users.
Returns existing channel (200) instead of creating duplicate (201).
Self-DM blocked with 400.
- [x] **Channel context banner.** `#channelContextBanner` div between
chat header and messages. `UI.updateContextBanner()` shows/hides
based on `App.activeConversation.type`. DM shows partner name +
@mention hint. Group shows leader/all routing hint. Channel shows
ai_mode + topic. CSS in `chat.css`.
- [x] **ai_mode and topic in API response.** Added `AiMode` and `Topic`
fields to `channelResponse`. `ListChannels` SELECT and Scan
updated. `CreateChannel` INSERTs `ai_mode` (defaults to
`mention_only` for DMs, `auto` otherwise).
- [x] **DM participant auto-creation.** `CreateChannel` adds all
`req.Participants` as member participants alongside the creator
(owner). Works for both Postgres and SQLite.
- [x] **Unread counts.** Add `last_read_message_id` and `last_read_at`
columns to `channel_participants`. Backend computes unread count
per channel per user. `listSidebarChannels` response includes
`unread_count`. Mark-read on select.
---
## 3 · @mention UX for Users
- [x] **Autocomplete includes users.** `channel-models.js` `onInput()`
builds candidates from `App.models` only. Add `App.users` list
(fetched once on startup from `GET /api/v1/users` or lightweight
endpoint). Merge into candidates with a person icon and different
accent color. Resolution order in autocomplete: personas first,
then users, then models (mirrors backend `resolveMention`).
- [x] **User mention pill styling.** `@username` pills render with a
different background color (e.g. blue/teal vs persona accent)
and person icon to distinguish human from AI mentions.
- [x] **Notification on mention.** `user.mentioned` WS event handler
in `chat.js` increments unread badge on channel sidebar item
and shows a toast with content preview.
---
## 4 · Presence
- [x] **Client heartbeat.** 30s `setInterval` calling
`API.presenceHeartbeat()`. Start on app init, pause on
`visibilitychange` hidden, resume on visible.
- [x] **Presence on channel load.** When rendering the Channels section,
collect DM partner IDs and call `GET /api/v1/presence?users=...`
to populate `App.presence`. Wire into `renderChannelsSection()`
online dot.
- [x] **Presence WebSocket events.** Hub broadcasts
`presence.changed { userId, status }` to sessions in shared
channels. Frontend updates `App.presence` and re-renders the
relevant sidebar item (not full re-render).
---
## 5 · Persona Groups + Group Chat
- [x] **Persona group CRUD endpoints.** Wire `persona_groups` and
`persona_group_members` to handlers. Routes:
`GET/POST /api/v1/persona-groups`,
`GET/PUT/DELETE /api/v1/persona-groups/:id`,
`POST/DELETE /api/v1/persona-groups/:id/members`.
`is_leader` flag on members.
- [x] **"New Group Chat" flow.** UI in sidebar "+" or separate button.
Option A: pick from saved persona group template.
Option B: ad-hoc persona picker (checkboxes).
Creates `type=group` channel, adds persona participants, sets
leader via `is_leader`. Humans and personas can be added
post-creation via participant CRUD.
- [x] **Group leader default response.** In `completion.go`, when
channel `type=group` and no @mention in content, resolve the
leader persona from `persona_group_members WHERE is_leader`
(via `channel_participants` → group membership). Use leader's
model/config/system prompt for the completion.
- [x] **`@all` fan-out.** When `@all` is detected in message content,
route to every persona participant. Reuse existing
`multiModelStream` path or sequential chain. Depth-1 only
(responses from @all do not trigger further chains).
- [x] **Participant mutation guards.** DMs: block removal if it would
drop below 2 human participants. Groups: block removal of last
persona (return 400 with clear error message, not silent type
coercion).
---
## 6 · Message Attribution
- [x] **Human sender display.** Messages from other human participants
show their avatar + display name instead of "You". Requires
the message response to include `sender_name`, `sender_avatar`
(or `participant_type=user` + `participant_id` that the frontend
resolves from a user cache).
- [x] **Persona sender display.** Already works for streaming via
avatar resolution. Verify it works for loaded history in
channel context (not just direct chats).
---
## 7 · Channel Lifecycle
- [x] **Archive action.** Context menu on channel/DM sidebar items:
"Archive". Sets `is_archived = true, archived_at = NOW()` via
`PUT /channels/:id`. Archived channels move to a collapsed
"Archived" sub-section at the bottom of the Channels list
(or hidden entirely with a "Show archived" toggle).
- [x] **Delete action (gated).** When `channel_retention.mode` is
`flexible` (default): context menu shows "Delete". Hard-deletes
the channel and all messages. When `retain`: context menu shows
"Archive" only. No delete option for non-admins.
- [x] **Admin purge.** Admin panel gets a "Channels" section or row
in an existing section showing archived channels with age.
"Purge" button hard-deletes. When `purge_after_days` is set,
purge is blocked for channels archived less than N days ago.
- [x] **`channel_retention` config keys.** Add to `global_config`:
`channel_retention.mode` (`flexible`|`retain`, default `flexible`),
`channel_retention.purge_after_days` (int|null, default null),
`channel_retention.archive_after_days` (int|null, default null).
Admin settings UI: "Retention" section under System.
---
## 8 · Surface Integration (deferred v0.22.8)
These were on the v0.22.8 checklist and haven't been wired yet.
- [x] **ChatPane.primary bridge.** `ChatPane.create()` in `startApp()`
from server-rendered mount points. `UI.renderMessages` delegates
to `ChatPane.primary.renderMessages`. Currently `ChatPane.primary`
is defined but never assigned.
- [x] **Theme save/load cycle.** Wire `Theme` (from
`ui-primitives-additions.js`) into settings appearance save/load.
Persist preference to user settings API. CM6 theme sync on
change.
- [x] **Admin hybrid section loaders.** Complete all JS-loaded admin
sections (some still show empty panels).
- [x] **Settings surface completeness.** Models visibility toggles,
User Personas CRUD, BYOK provider CRUD, Teams tab content.
---
## Forward: Retention Policy Automation (post-0.23.2)
Not in scope for 0.23.2 but documents the planned direction.
**Lifecycle:** Active → Archived → Purged. Each transition is either
user-initiated or policy-driven.
**Auto-archive scanner.** Background job (same pattern as compaction
scanner). When `channel_retention.archive_after_days` is set, queries
`channels WHERE is_archived = false AND updated_at < NOW() - interval`
and sets `is_archived = true`. Runs on a configurable schedule (hourly
default). Workflow channels auto-archive when their final stage
completes — the scanner catches anything the workflow engine misses.
**Auto-purge scanner.** When `channel_retention.purge_after_days` is
set, queries `channels WHERE is_archived = true AND archived_at <
NOW() - interval` and hard-deletes in batches. This is also the floor
for manual admin purge — admins cannot purge channels archived less
than N days ago.
**Audit trail.** Every archive and purge action (manual or automatic)
gets an audit log entry: `action = "channel.archived"` or
`action = "channel.purged"` with actor (user ID or "system"), channel
metadata snapshot, and timestamp.
Slots naturally into v0.24.x alongside RBAC (retention policy is
inherently an admin/compliance concern) or as a standalone 0.23.x
minor if it lands before auth work starts.