From a0ae31fe2a1c62d0f7607f944d2aaf17608d5a00 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Wed, 3 Jun 2026 11:18:18 +0100 Subject: [PATCH 1/9] feat(dashboard): add copyable "connect CLI" command to API-key-created popup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After creating an API key, the success popup now shows a second copyable field: a ready-to-paste command that registers THIS server in the cix CLI and makes it the default, so the flow is create → copy → paste → use. The command writes a single `server.` entry (URL + the new key as one unit — the key belongs to the server), then sets it as default: cix config set server..url && \ cix config set server..key && \ cix config set default_server - comes from window.location.origin (dashboard is served same-origin from cix-server, so it's always the correct base URL). - is derived from window.location.host, sanitized to [a-z0-9-] so it's dot-/whitespace-free as the CLI's parseServerKey requires. Copy logic refactored into a reusable copyText() helper driving two independent button states (key + command), preserving the existing secure-context / execCommand fallback. Frontend-only; no CLI or server change — the command uses existing `cix config set` commands. Co-Authored-By: Claude Opus 4.8 --- .../components/CreateApiKeyDialog.tsx | 114 +++++++++++++++--- 1 file changed, 99 insertions(+), 15 deletions(-) diff --git a/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx b/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx index 4b56de6..633bd85 100644 --- a/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx +++ b/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react'; -import { Check, Copy, Loader2, KeyRound, AlertTriangle } from 'lucide-react'; +import { Check, Copy, Loader2, KeyRound, AlertTriangle, Terminal } from 'lucide-react'; import { toast } from 'sonner'; import { ApiError } from '@/api/client'; import { Alert, AlertDescription, AlertTitle } from '@/ui/alert'; @@ -41,6 +41,21 @@ function legacyCopy(text: string): boolean { return ok; } +// Derive a cix CLI server alias from the browser host. The CLI stores each +// server as one entry under `server.` and parses that config key by +// splitting on dots, so the alias must be dot-free (and whitespace-free, and +// non-empty) — see validateServerName / parseServerKey in the CLI. We fold the +// host (including any non-default port, so distinct ports get distinct aliases) +// to [a-z0-9-]: "cix.example.com" -> "cix-example-com", +// "localhost:21847" -> "localhost-21847". +function cixServerAlias(host: string): string { + const alias = host + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + return alias || 'default'; +} + // Two-stage dialog: collect a name, then reveal the freshly minted key once. // Once revealed, the dialog refuses outside-click and Escape — accidental // dismissal would lose the unrecoverable secret. Only the explicit "I've @@ -49,7 +64,8 @@ export function CreateApiKeyDialog() { const [open, setOpen] = useState(false); const [name, setName] = useState(''); const [revealed, setRevealed] = useState(null); - const [copied, setCopied] = useState(false); + const [copiedKey, setCopiedKey] = useState(false); + const [copiedCmd, setCopiedCmd] = useState(false); const inputRef = useRef(null); const create = useCreateApiKey(); @@ -65,7 +81,8 @@ export function CreateApiKeyDialog() { function reset() { setName(''); setRevealed(null); - setCopied(false); + setCopiedKey(false); + setCopiedCmd(false); create.reset(); } @@ -81,23 +98,50 @@ export function CreateApiKeyDialog() { } } - async function copyToClipboard() { - if (!revealed) return; - // navigator.clipboard requires a secure context (HTTPS or localhost). On - // bare-IP / HTTP deploys it throws — fall back to document.execCommand - // through a transient textarea so users still get one-click copy. + // copyText copies one string to the clipboard, surfacing a toast on failure. + // navigator.clipboard requires a secure context (HTTPS or localhost). On + // bare-IP / HTTP deploys it throws — fall back to document.execCommand + // through a transient textarea so users still get one-click copy. + async function copyText(text: string): Promise { try { if (window.isSecureContext && navigator.clipboard?.writeText) { - await navigator.clipboard.writeText(revealed); + await navigator.clipboard.writeText(text); } else { - if (!legacyCopy(revealed)) throw new Error('legacy copy failed'); + if (!legacyCopy(text)) throw new Error('legacy copy failed'); } - setCopied(true); - window.setTimeout(() => setCopied(false), 2000); + return true; } catch { toast.error('Could not copy automatically.', { - description: 'Click the field, ⌘A / Ctrl-A to select, then copy.', + description: 'Select the text, ⌘A / Ctrl-A, then copy.', }); + return false; + } + } + + // One-paste command that registers THIS server (URL + the freshly minted key, + // as a single `server.` entry) in the cix CLI config and makes it the + // default. The dashboard is served same-origin from cix-server, so + // window.location.origin is exactly the base URL the CLI must talk to. Values + // are shell-safe (a URL, a `cix_` key, an [a-z0-9-] alias), so no quoting + // is needed — matching the CLI README's unquoted examples. + const alias = cixServerAlias(window.location.host); + const connectCmd = + `cix config set server.${alias}.url ${window.location.origin} && ` + + `cix config set server.${alias}.key ${revealed ?? ''} && ` + + `cix config set default_server ${alias}`; + + async function copyKey() { + if (!revealed) return; + if (await copyText(revealed)) { + setCopiedKey(true); + window.setTimeout(() => setCopiedKey(false), 2000); + } + } + + async function copyConnectCmd() { + if (await copyText(connectCmd)) { + setCopiedCmd(true); + window.setTimeout(() => setCopiedCmd(false), 2000); } } @@ -165,8 +209,8 @@ export function CreateApiKeyDialog() { onFocus={(e) => e.currentTarget.select()} onClick={(e) => e.currentTarget.select()} /> - + +

+ Paste this in a terminal with the cix CLI installed. It saves the + server to ~/.cix/config.yaml as the default, then{' '} + cix search "…" works. +

+ + + ); +} diff --git a/server/dashboard/src/modules/home/ConnectClaudeCodeCard.tsx b/server/dashboard/src/modules/home/ConnectClaudeCodeCard.tsx new file mode 100644 index 0000000..bbca91b --- /dev/null +++ b/server/dashboard/src/modules/home/ConnectClaudeCodeCard.tsx @@ -0,0 +1,216 @@ +import { useState, type ReactNode } from 'react'; +import { Link } from 'react-router-dom'; +import { ChevronDown, ChevronRight, KeyRound, Sparkles } from 'lucide-react'; +import { Card } from '@/ui/card'; +import { Badge } from '@/ui/badge'; +import { cn } from '@/lib/cn'; +import { cixConnectCommand } from '@/lib/cixServer'; +import { CommandBlock } from './CommandBlock'; + +// Collapse state lives in localStorage so a returning user who dismissed the +// onboarding card keeps it dismissed. Expanded by default (key absent → false). +const COLLAPSE_KEY = 'cix.home.connect-claude.collapsed'; + +function readCollapsed(): boolean { + if (typeof window === 'undefined') return false; + try { + return window.localStorage.getItem(COLLAPSE_KEY) === '1'; + } catch { + return false; + } +} + +function writeCollapsed(v: boolean): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(COLLAPSE_KEY, v ? '1' : '0'); + } catch { + /* swallow — privacy mode; state just resets on reload */ + } +} + +// The cix CLI one-line installer (README §3 "Install the CLI"). +const INSTALL_CMD = + 'curl -fsSL https://raw.githubusercontent.com/dvcdsys/code-index/main/install.sh | bash'; + +// Claude Code marketplace + plugin install (README "Agent Integration"). +const PLUGIN_CMDS = [ + '/plugin marketplace add dvcdsys/code-index', + '/plugin install cix@code-index', + '/reload-plugins', +].join('\n'); + +// Optional local-project indexing + connection check. +const INDEX_CMDS = ['cd /path/to/your/project', 'cix init', 'cix status # wait for: Status: ✓ Indexed'].join( + '\n' +); + +// User-facing skills/commands the plugin exposes — the "success" checklist. +const SKILL_COMMANDS = [ + '/cix:search', + '/cix:def', + '/cix:refs', + '/cix:init', + '/cix:status', + '/cix:summary', +]; + +export function ConnectClaudeCodeCard() { + const [collapsed, setCollapsed] = useState(readCollapsed); + + function toggle() { + setCollapsed((prev) => { + const next = !prev; + writeCollapsed(next); + return next; + }); + } + + // Live, per-host connect command so the alias + URL match this exact + // deployment — the same derivation the API-key popup uses. The real key is + // shown once in that popup; here we keep the `` placeholder. + const connectCmd = cixConnectCommand(window.location.origin, window.location.host); + + return ( + + + + {!collapsed && ( +
+ + + + + + Open the{' '} + + + API Keys + {' '} + page, create a key, then copy the “Connect the cix CLI” command from the popup. + It looks like this for this server (with your real key filled in): + + } + > + + + + + + + + Optional} + hint={ + <> + Skip this if you only work with{' '} + + workspaces + {' '} + or server-side{' '} + + external projects + + . cix status alone + still confirms the CLI reached the server. + + } + > + + + + +
+ {SKILL_COMMANDS.map((c) => ( + + {c} + + ))} +
+
+
+ )} +
+ ); +} + +function Step({ + n, + title, + hint, + badge, + children, +}: { + n: number; + title: string; + hint: ReactNode; + badge?: ReactNode; + children: ReactNode; +}) { + return ( +
+
+ {n} +
+
+
+

{title}

+ {badge} +
+

{hint}

+ {children} +
+
+ ); +} diff --git a/server/dashboard/src/modules/home/HomePage.tsx b/server/dashboard/src/modules/home/HomePage.tsx index b119798..b20866b 100644 --- a/server/dashboard/src/modules/home/HomePage.tsx +++ b/server/dashboard/src/modules/home/HomePage.tsx @@ -4,6 +4,7 @@ import { useServerStatus } from '@/lib/useServerStatus'; import { Card, CardDescription, CardHeader, CardTitle } from '@/ui/card'; import { cn } from '@/lib/cn'; import { MODULES } from '../registry'; +import { ConnectClaudeCodeCard } from './ConnectClaudeCodeCard'; // One-line pitch per module — kept here (not on the Module type) so the // sidebar stays terse and only the landing page carries the prose. @@ -54,6 +55,8 @@ export default function HomePage() { )} + +

Modules

From 5ecffbd204fb8c80b8fa8585b04d637a39fc5086 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Wed, 3 Jun 2026 12:09:46 +0100 Subject: [PATCH 5/9] dashboard(home): use `claude plugin` console commands for plugin step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home "Connect Claude Code to cix" guide's step 3 instructed users to type in-session slash commands (/plugin marketplace add, /plugin install, /reload-plugins). Rewrite them as `claude plugin` CLI commands run in a terminal, so the whole onboarding flow stays in the shell next to the other CLI steps. Installing via the CLI applies on the next `claude` session start, so the /reload-plugins step is dropped. Also update the now-stale hint text in step 3 and step 5 that referenced /reload-plugins. The "Connect the CLI to this server" command (step 2) and the API-key popup already derive the host dynamically from window.location via the shared cixConnectCommand helper — verified, no change needed. Co-Authored-By: Claude Opus 4.8 --- .../src/modules/home/ConnectClaudeCodeCard.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/server/dashboard/src/modules/home/ConnectClaudeCodeCard.tsx b/server/dashboard/src/modules/home/ConnectClaudeCodeCard.tsx index bbca91b..d51ad5d 100644 --- a/server/dashboard/src/modules/home/ConnectClaudeCodeCard.tsx +++ b/server/dashboard/src/modules/home/ConnectClaudeCodeCard.tsx @@ -33,11 +33,15 @@ function writeCollapsed(v: boolean): void { const INSTALL_CMD = 'curl -fsSL https://raw.githubusercontent.com/dvcdsys/code-index/main/install.sh | bash'; -// Claude Code marketplace + plugin install (README "Agent Integration"). +// Claude Code marketplace + plugin install, as `claude plugin` console +// commands (run in a terminal) rather than the in-session `/plugin` slash +// commands — so the whole onboarding flow stays in the shell next to the CLI +// steps. `marketplace add` takes a GitHub owner/repo shorthand; `install` +// takes plugin@marketplace. Installing via the CLI applies on the next +// `claude` session start, so there's no `/reload-plugins` equivalent to run. const PLUGIN_CMDS = [ - '/plugin marketplace add dvcdsys/code-index', - '/plugin install cix@code-index', - '/reload-plugins', + 'claude plugin marketplace add dvcdsys/code-index', + 'claude plugin install cix@code-index', ].join('\n'); // Optional local-project indexing + connection check. @@ -130,7 +134,7 @@ export function ConnectClaudeCodeCard() { @@ -160,7 +164,7 @@ export function ConnectClaudeCodeCard() {
{SKILL_COMMANDS.map((c) => ( From 679945d1af5716bee5bf5bac034054b24f01ebef Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Wed, 3 Jun 2026 12:38:40 +0100 Subject: [PATCH 6/9] feat(server): per-user local_project_disabled flag (block create + index) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an admin-controlled, per-user switch that forbids a user from creating local projects and from indexing/reindexing, while keeping search of their already-indexed projects and workspace creation available. Admins are always exempt. - DB: users.local_project_disabled INTEGER NOT NULL DEFAULT 0 + idempotent migration #14. Default 0 backfills existing and new users to "allowed" (backward compatible). - Carry the column through every users.User builder, including the two hand-rolled auth-path JOINs that populate ac.User: apikeys.Authenticate (CLI/API-key path) and sessions.Get (dashboard path) — missing either is a silent enforcement bypass. - Enforcement: requireLocalProjectActions guard (access.go) gates CreateProject and index begin/files/finish with 403; admins and CIX_AUTH_DISABLED exempt. index/cancel (cleanup) and index/status + search (read) stay open. - Admin API: local_project_disabled on userPayload (/auth/me + /admin/users) and as a PATCH /admin/users/{id} field; SetLocalProjectDisabled setter. - OpenAPI: User + UpdateUserRequest schema fields, 403 docs; regen dashboard types; admin Users table gains a per-user toggle ("Always" for admins). - Tests: migration backfill + setter round-trip; httpapi gating (403 on create/index for a restricted user, read/workspace stay allowed, admin exempt, re-enable restores). docs/AUTH_REVIEW.md updated. Co-Authored-By: Claude Opus 4.8 --- doc/openapi.yaml | 34 +++- .../components/UserLocalProjectToggle.tsx | 50 ++++++ .../modules/users/components/UsersTable.tsx | 14 ++ server/internal/apikeys/apikeys.go | 38 ++--- server/internal/db/db.go | 23 +++ server/internal/db/db_test.go | 63 ++++++++ server/internal/db/schema.go | 7 +- server/internal/httpapi/access.go | 27 ++++ server/internal/httpapi/auth.go | 44 +++--- server/internal/httpapi/auth_test.go | 145 +++++++++++++++++- server/internal/httpapi/server.go | 12 ++ server/internal/sessions/sessions.go | 14 +- server/internal/users/users.go | 65 ++++++-- server/internal/users/users_test.go | 51 ++++++ 14 files changed, 524 insertions(+), 63 deletions(-) create mode 100644 server/dashboard/src/modules/users/components/UserLocalProjectToggle.tsx diff --git a/doc/openapi.yaml b/doc/openapi.yaml index e9943b0..1f87ebc 100644 --- a/doc/openapi.yaml +++ b/doc/openapi.yaml @@ -739,6 +739,14 @@ paths: $ref: "#/components/schemas/Project" "401": $ref: "#/components/responses/Unauthorized" + "403": + description: | + The caller's account has `local_project_disabled=true` + (cannot create local projects). Admins are exempt. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "409": description: A project with this `host_path` already exists content: @@ -1091,6 +1099,15 @@ paths: $ref: "#/components/schemas/IndexBeginResponse" "401": $ref: "#/components/responses/Unauthorized" + "403": + description: | + The caller's account has `local_project_disabled=true` + (cannot index/reindex), or the caller can read the project but + is not its owner. Admins are exempt from the former. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "404": $ref: "#/components/responses/NotFound" "409": @@ -2907,7 +2924,7 @@ components: User: type: object - required: [id, email, role, must_change_password, created_at, updated_at, disabled] + required: [id, email, role, must_change_password, created_at, updated_at, disabled, local_project_disabled] properties: id: type: string @@ -2934,6 +2951,15 @@ components: type: string format: date-time nullable: true + local_project_disabled: + type: boolean + description: | + When true, the user may not create local projects or + index/reindex any project (both return 403). Search of + already-indexed projects and workspace creation remain + allowed. Admins are always exempt regardless of this flag. + Defaults to false; existing users created before this field + was introduced are false (allowed) for backward compatibility. UserWithStats: allOf: @@ -3051,6 +3077,12 @@ components: description: | When true, the user can no longer authenticate. Refused for the last enabled admin when set to true. + local_project_disabled: + type: boolean + description: | + When true, the user may not create local projects or + index/reindex any project. Search and workspace creation stay + allowed; admins are always exempt. Has no last-admin guard. RuntimeConfig: type: object diff --git a/server/dashboard/src/modules/users/components/UserLocalProjectToggle.tsx b/server/dashboard/src/modules/users/components/UserLocalProjectToggle.tsx new file mode 100644 index 0000000..7a58222 --- /dev/null +++ b/server/dashboard/src/modules/users/components/UserLocalProjectToggle.tsx @@ -0,0 +1,50 @@ +import { toast } from 'sonner'; +import { ApiError } from '@/api/client'; +import { Switch } from '@/ui/switch'; +import { useUpdateUser } from '../hooks'; + +// Inline toggle for the per-user "can create local projects / index" permission. +// The stored flag is `local_project_disabled` (true = forbidden); the switch is +// presented in the positive sense ("Allowed") so it reads naturally, so checked +// === !local_project_disabled. The server is the source of truth — on error we +// surface a toast and the next refetch resets the control. +export function UserLocalProjectToggle({ + userId, + localProjectDisabled, + disabled = false, +}: { + userId: string; + localProjectDisabled: boolean; + disabled?: boolean; +}) { + const update = useUpdateUser(); + const allowed = !localProjectDisabled; + + async function onChange(nextAllowed: boolean) { + const nextDisabled = !nextAllowed; + if (nextDisabled === localProjectDisabled) return; + try { + await update.mutateAsync({ + id: userId, + body: { local_project_disabled: nextDisabled }, + }); + toast.success('Permission updated', { + description: nextDisabled + ? 'Local project creation & indexing disabled' + : 'Local project creation & indexing enabled', + }); + } catch (err) { + const detail = err instanceof ApiError ? err.detail : String(err); + toast.error('Could not update permission', { description: detail }); + } + } + + return ( + void onChange(v)} + disabled={disabled || update.isPending} + aria-label="Allow creating local projects and indexing" + /> + ); +} diff --git a/server/dashboard/src/modules/users/components/UsersTable.tsx b/server/dashboard/src/modules/users/components/UsersTable.tsx index 592ef00..521dd6c 100644 --- a/server/dashboard/src/modules/users/components/UsersTable.tsx +++ b/server/dashboard/src/modules/users/components/UsersTable.tsx @@ -13,6 +13,7 @@ import { formatDateTime, formatRelative } from '@/lib/formatDate'; import { DeleteUserDialog } from './DeleteUserDialog'; import { DisableUserButton } from './DisableUserButton'; import { UserRoleSelect } from './UserRoleSelect'; +import { UserLocalProjectToggle } from './UserLocalProjectToggle'; export function UsersTable({ users, @@ -28,6 +29,9 @@ export function UsersTable({ Email Role + + Local projects + Created Last login Sessions @@ -50,6 +54,16 @@ export function UsersTable({ + + {u.role === 'admin' ? ( + Always + ) : ( + + )} + ?), (SELECT COUNT(1) FROM api_keys WHERE owner_user_id = users.id AND revoked_at IS NULL) @@ -221,18 +225,20 @@ func (s *Service) ListWithStats(ctx context.Context) ([]UserWithStats, error) { var ( u User mcp int + localProjectDisabled int createdAt, updatedAt string disabledAt sql.NullString lastLogin sql.NullString activeSessions, apiKeys int ) if err := rows.Scan( - &u.ID, &u.Email, &u.Role, &mcp, &createdAt, &updatedAt, &disabledAt, + &u.ID, &u.Email, &u.Role, &mcp, &createdAt, &updatedAt, &disabledAt, &localProjectDisabled, &lastLogin, &activeSessions, &apiKeys, ); err != nil { return nil, err } u.MustChangePassword = mcp == 1 + u.LocalProjectDisabled = localProjectDisabled == 1 u.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) u.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt) if disabledAt.Valid { @@ -259,19 +265,20 @@ func (s *Service) ListWithStats(ctx context.Context) ([]UserWithStats, error) { func (s *Service) Authenticate(ctx context.Context, email, password string) (User, error) { email = normalizeEmail(email) row := s.DB.QueryRowContext(ctx, - `SELECT id, password_hash, role, must_change_password, created_at, updated_at, disabled_at, email + `SELECT id, password_hash, role, must_change_password, created_at, updated_at, disabled_at, local_project_disabled, email FROM users WHERE email = ? COLLATE NOCASE`, email) var ( - u User - hash string - mcp int - disabledAt sql.NullString - createdAt string - updatedAt string - emailOut string + u User + hash string + mcp int + localProjectDisabled int + disabledAt sql.NullString + createdAt string + updatedAt string + emailOut string ) - if err := row.Scan(&u.ID, &hash, &u.Role, &mcp, &createdAt, &updatedAt, &disabledAt, &emailOut); err != nil { + if err := row.Scan(&u.ID, &hash, &u.Role, &mcp, &createdAt, &updatedAt, &disabledAt, &localProjectDisabled, &emailOut); err != nil { if errors.Is(err, sql.ErrNoRows) { // Match the timing of a hash-compare to mitigate user-enumeration // via response time. dummyHash carries the active cost, so this @@ -289,6 +296,7 @@ func (s *Service) Authenticate(ctx context.Context, email, password string) (Use } u.Email = emailOut u.MustChangePassword = mcp == 1 + u.LocalProjectDisabled = localProjectDisabled == 1 u.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) u.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt) if disabledAt.Valid { @@ -372,6 +380,27 @@ func (s *Service) SetDisabled(ctx context.Context, id string, disabled bool) err return nil } +// SetLocalProjectDisabled toggles the per-user restriction on creating local +// projects and indexing/reindexing. No last-admin guard: admins are exempt +// from the restriction at enforcement time, so the flag is meaningful only +// for regular users and never locks anyone out of administration. +func (s *Service) SetLocalProjectDisabled(ctx context.Context, id string, disabled bool) error { + v := 0 + if disabled { + v = 1 + } + now := time.Now().UTC().Format(time.RFC3339Nano) + res, err := s.DB.ExecContext(ctx, + `UPDATE users SET local_project_disabled = ?, updated_at = ? WHERE id = ?`, v, now, id) + if err != nil { + return fmt.Errorf("update local_project_disabled: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + // Delete removes a user (cascades to sessions + api_keys via FK). // Refuses to delete the last active admin. func (s *Service) Delete(ctx context.Context, id string) error { @@ -412,7 +441,7 @@ func (s *Service) guardLastAdmin(ctx context.Context, id string) error { // --- helpers --- -const listSelect = `SELECT id, email, role, must_change_password, created_at, updated_at, disabled_at FROM users` +const listSelect = `SELECT id, email, role, must_change_password, created_at, updated_at, disabled_at, local_project_disabled FROM users` func (s *Service) scanOne(ctx context.Context, where string, args ...any) (User, error) { row := s.DB.QueryRowContext(ctx, listSelect+" "+where, args...) @@ -429,15 +458,17 @@ type rowScanner interface { func scanUserRow(r rowScanner) (User, error) { var ( - u User - mcp int - createdAt, updatedAt string - disabledAt sql.NullString + u User + mcp int + localProjectDisabled int + createdAt, updatedAt string + disabledAt sql.NullString ) - if err := r.Scan(&u.ID, &u.Email, &u.Role, &mcp, &createdAt, &updatedAt, &disabledAt); err != nil { + if err := r.Scan(&u.ID, &u.Email, &u.Role, &mcp, &createdAt, &updatedAt, &disabledAt, &localProjectDisabled); err != nil { return User{}, err } u.MustChangePassword = mcp == 1 + u.LocalProjectDisabled = localProjectDisabled == 1 u.CreatedAt, _ = time.Parse(time.RFC3339Nano, createdAt) u.UpdatedAt, _ = time.Parse(time.RFC3339Nano, updatedAt) if disabledAt.Valid { diff --git a/server/internal/users/users_test.go b/server/internal/users/users_test.go index 7768cf3..44bc212 100644 --- a/server/internal/users/users_test.go +++ b/server/internal/users/users_test.go @@ -118,6 +118,57 @@ func TestDelete_LastAdminBlock(t *testing.T) { } } +// TestLocalProjectDisabled_DefaultAndRoundTrip verifies the new per-user flag: +// new users default to allowed (false), the setter toggles it, and the value +// survives every User builder (GetByID, List, Authenticate). +func TestLocalProjectDisabled_DefaultAndRoundTrip(t *testing.T) { + ctx := context.Background() + s := newTestService(t) + + u, err := s.Create(ctx, "a@b.com", "password1234", RoleUser, false) + if err != nil { + t.Fatalf("Create: %v", err) + } + if u.LocalProjectDisabled { + t.Fatalf("new user should default to allowed (LocalProjectDisabled=false)") + } + + // Disable, then confirm it is visible through GetByID. + if err := s.SetLocalProjectDisabled(ctx, u.ID, true); err != nil { + t.Fatalf("SetLocalProjectDisabled(true): %v", err) + } + got, err := s.GetByID(ctx, u.ID) + if err != nil { + t.Fatalf("GetByID: %v", err) + } + if !got.LocalProjectDisabled { + t.Errorf("GetByID: LocalProjectDisabled = false, want true") + } + + // Authenticate must also carry the flag (it builds User from its own SELECT). + auth, err := s.Authenticate(ctx, "a@b.com", "password1234") + if err != nil { + t.Fatalf("Authenticate: %v", err) + } + if !auth.LocalProjectDisabled { + t.Errorf("Authenticate: LocalProjectDisabled = false, want true") + } + + // Re-enable round-trips back to false. + if err := s.SetLocalProjectDisabled(ctx, u.ID, false); err != nil { + t.Fatalf("SetLocalProjectDisabled(false): %v", err) + } + got, _ = s.GetByID(ctx, u.ID) + if got.LocalProjectDisabled { + t.Errorf("after re-enable: LocalProjectDisabled = true, want false") + } + + // Unknown id → ErrNotFound. + if err := s.SetLocalProjectDisabled(ctx, "no-such-id", true); !errors.Is(err, ErrNotFound) { + t.Errorf("SetLocalProjectDisabled(unknown) err = %v, want ErrNotFound", err) + } +} + func TestInvalidRole(t *testing.T) { s := newTestService(t) _, err := s.Create(context.Background(), "a@b.com", "password1", "superadmin", false) From 61d13ae7461c45ea16e5c28917cc20d7ec6ac6cb Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Wed, 3 Jun 2026 12:45:40 +0100 Subject: [PATCH 7/9] docs(skills): make cix-workspace multi-server aware + align install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cix-workspace skill and its investigator sub-agent assumed a single cix backend. The cix CLI supports several named servers (--server , CIX_SERVER), and a workspace + all its repos live on exactly one server — so a cross-project workflow that mixes servers silently returns empty or wrong-repo results. - cix-workspace SKILL: new "which server hosts the workspace?" section; thread --server through Step 0/1/2, the sub-agent fan-out prompt, the quick reference, and the TL;DR. Replace the raw curl per-project search (which hardcodes one server's URL/key) with `cix search -n --server `, which resolves the right backend from config. - cix-workspace-investigator: every cix call must carry --server when the workspace is on a non-default server (tools list, hard rule 1, the "where your project lives" preamble). - skills/README.md: align the plugin install snippet with the dashboard onboarding card — `claude plugin …` console commands, drop the obsolete /reload-plugins. - Sync canonical skills into the plugin bundle (sync-skills.sh); plugin bats suite green (15/15). The cix skill already documented multi-server; no change needed there. Co-Authored-By: Claude Opus 4.8 --- .../cix/agents/cix-workspace-investigator.md | 37 ++++++-- plugins/cix/skills/cix-workspace/SKILL.md | 84 ++++++++++++++++--- skills/README.md | 11 +-- skills/cix-workspace/SKILL.md | 84 ++++++++++++++++--- .../agents/cix-workspace-investigator.md | 37 ++++++-- 5 files changed, 210 insertions(+), 43 deletions(-) diff --git a/plugins/cix/agents/cix-workspace-investigator.md b/plugins/cix/agents/cix-workspace-investigator.md index 6295327..561f959 100644 --- a/plugins/cix/agents/cix-workspace-investigator.md +++ b/plugins/cix/agents/cix-workspace-investigator.md @@ -27,11 +27,24 @@ of two shapes. Behave very differently depending on which: nothing useful — there's nothing to read locally. The cix server has the files, chunks, and symbols; you reach them only through the `cix` CLI. -**How to tell which you have:** run `cix list` once, then `grep` for the -exact identifier the main agent gave you. +**If the main agent gave you a server alias, use it on EVERY cix call.** +The `cix` CLI can have several named servers configured, and a workspace +(plus all its repos) lives on exactly one of them. The main agent will +tell you which — e.g. "this project is on server `corporate`". When it +does, add the global `--server ` flag to *every* `cix` command +below, alongside `-n `. Without it, cix talks to the +*default* server, where your assigned project doesn't exist, and every +call comes back empty (which looks like "nothing found" but is really +"wrong server"). If no alias was given, you're on the default server — +don't invent one. + +**How to tell which shape your project is:** run `cix list` once (on the +right server), then `grep` for the exact identifier the main agent gave +you. ```bash -cix list | grep -F "" +cix list --server | grep -F "" +# (drop --server if the main agent didn't name one — default server) ``` - A line starting with `[✓] /` → local working tree. @@ -47,6 +60,11 @@ the code. You have a read-only toolkit for code investigation inside the assigned project: +> **Server flag.** If the main agent named a server (`--server `), +> append it to *every* command in this list, e.g. +> `cix search "" -n --server `. The examples +> below omit it for brevity; add it whenever you were given an alias. + - **`cix search "" -n `** — semantic / hybrid lookups *inside the assigned project*. **Always pass `-n `** (the identifier from `cix list`); without it, cix searches whatever project @@ -72,11 +90,14 @@ re-index. ## Hard rules — non-negotiable -1. **Stay inside the assigned project.** Every `cix` invocation MUST carry - `-n `. Without it, cix searches the cwd's project — that's - the main session's repo, not yours. Don't read or query other workspace - repos. If a finding requires looking elsewhere, surface it as an - uncertainty for the main agent to fan out further. +1. **Stay inside the assigned project — and on the assigned server.** + Every `cix` invocation MUST carry `-n `, plus + `--server ` if the main agent named one. Without `-n`, cix + searches the cwd's project (the main session's repo, not yours); + without the right `--server`, it queries the wrong backend and returns + empty. Don't read or query other workspace repos. If a finding requires + looking elsewhere, surface it as an uncertainty for the main agent to + fan out further. 2. **Never hunt the filesystem for a remote-only project.** No `find /`, no `locate`, no `ls -R ~`, no recursive Grep across `/`. If `cix list` shows the project as `github.com/…@…`, the files do diff --git a/plugins/cix/skills/cix-workspace/SKILL.md b/plugins/cix/skills/cix-workspace/SKILL.md index 2eb983d..f894d3e 100644 --- a/plugins/cix/skills/cix-workspace/SKILL.md +++ b/plugins/cix/skills/cix-workspace/SKILL.md @@ -31,6 +31,36 @@ to implementation before you can answer all three with evidence. --- +## First: which server hosts the workspace? + +The `cix` CLI can be configured with **several named servers** (a local +box, a remote corporate backend, …). Each server hosts its **own** set of +workspaces and projects — a workspace named `platform` on one server is +unrelated to anything on another. Every `cix` command targets the +**default** server unless you pass the global `--server ` flag (or +set `CIX_SERVER`). + +```bash +cix config show # lists configured servers; * marks the default +``` + +**A workspace and all its repos live on exactly one server.** So before +you run the workflow, decide which server you're on, then be **consistent**: +pass the *same* `--server ` to *every* command in the flow — +`cix ws`, workspace search, per-project drill-down, and the sub-agent +fan-out. Mixing servers mid-workflow (orient on server A, drill down on +the default) silently returns empty or wrong-repo results, because the +project simply doesn't exist on the other server. + +**Agent rule:** use the default server (no flag) unless the user names a +specific server, or the primary project's workspace isn't on the default. +Never guess an alias — run `cix config show` to see the configured names. +Once you know the alias, thread it through the whole workflow. The +examples below omit `--server` for readability; add it to **every** command +when the target workspace is on a non-default server. + +--- + ## When to reach for workspace search | Signal in the user's request | What to do | @@ -57,10 +87,16 @@ The goal-driven loop. Don't shortcut it. Each step is fast. ### Step 0 — orient ```bash -cix ws # list workspaces; find the one your primary is in +cix config show # which servers exist? which is default? +cix ws # list workspaces on the (default) server cix ws # describe — confirm repos are indexed (✓ count) ``` +If `cix ws` doesn't list the workspace your task is about, it may live on +a different server — re-run with `--server ` (the alias from +`cix config show`) and keep that flag on every later command. Lock in the +server here, before searching. + If the workspace shows `stale_fts_repos` in any search response later, trust the dense ranking less — see the troubleshooting section. @@ -107,21 +143,28 @@ deeper read, not as the full answer. For repos other than the primary, you have two options: **A. Quick scan (≤ 2 repos to investigate):** use single-project -search directly. +search directly via the CLI, scoped with `-n` to the project. Pass the +`project_path` from the workspace-search `projects[]` panel verbatim: ```bash -# Search inside one specific project -curl -G -H "Authorization: Bearer $CIX_KEY" \ - --data-urlencode "q=rate limit middleware handler" \ - --data-urlencode "min_score=0" \ - "$CIX_URL/api/v1/projects/$(project_hash)/search" +# Search inside one specific project (-n = exact project ID from cix list / +# the workspace projects[] panel). --server keeps it on the same backend +# the workspace lives on — REQUIRED when that's not the default server. +cix search "rate limit middleware handler" -n --min-score 0 +cix search "rate limit middleware handler" -n --server corporate --min-score 0 ``` The per-project default `min_score` is `0.2` — light floor that keeps abstract NL queries non-empty. For drill-down on a natural- -language question ("how does X work end-to-end"), pass `min_score=0` +language question ("how does X work end-to-end"), pass `--min-score 0` explicitly to be safe. For strict code-symbol matching, pass `0.4+`. +> Prefer the CLI over a raw `curl … /api/v1/projects/{hash}/search`: the +> CLI resolves the right server (and its key) from your config, so you +> can't accidentally point `$CIX_URL`/`$CIX_KEY` at a *different* server +> than the workspace. If you do hand-roll curl, make sure the URL + key +> belong to the server that hosts this workspace. + **B. Fan-out to sub-agents (≥ 3 repos, or you need a thorough read):** spawn one `cix-workspace-investigator` sub-agent per relevant repo, in parallel. See the dedicated [Sub-agent fan-out pattern](#sub-agent-fan-out-pattern) @@ -366,6 +409,14 @@ entry in `projects[]`. Paste that string verbatim into the sub-agent prompt, and tell the sub-agent explicitly which shape it is so it doesn't waste calls grepping a tree that isn't there. One repo per spawn. +**Pass the server alias too.** If the workspace is on a non-default server, +the sub-agent's `cix search -n ` calls must carry the *same* +`--server ` — otherwise they hit the default server, where the +project doesn't exist, and come back empty. State it plainly in the prompt: +"This project lives on server `corporate`; add `--server corporate` to every +`cix` call." If you're on the default server, say so (or omit it) so the +sub-agent doesn't invent a flag. + #### 3. Seed chunks **with your commentary** This is the part most often done badly. Don't just paste raw chunk @@ -404,6 +455,8 @@ Seed chunks from workspace search: shared utilities. Panel-level notes: +- Server: this project is on `corporate` — pass `--server corporate` on + every cix call (it does NOT exist on the default server). - Workspace ranked this project #1 with a clear lead (project_score 1.000 vs next 0.860). High confidence this is the right repo. - bm25_score=8.5, dense_score=0.54 — strong on both signals, not a @@ -590,6 +643,11 @@ language to see what stack it actually uses, then refine. ## Quick command reference ```bash +# Servers (run first if more than one backend is configured) +cix config show # list servers; * marks the default +cix ws --server corporate # any command takes the global --server +# (CIX_SERVER=corporate also selects it; --server wins over the env var) + # List workspaces cix ws cix ws list --json @@ -628,12 +686,16 @@ Flags: When the user's task plausibly spans more than one repo: +0. `cix config show` → if more than one server, decide which one hosts + the workspace and thread the **same** `--server ` through every + command below (default server → no flag needed). 1. `cix ws` → find the workspace, then `cix ws ` describe it. 2. Workspace search with a **short, term-rich** query. 3. Read `projects[]` → that's your scope (Q1 answered). -4. For each repo in scope, either single-project search or spawn a - `cix-workspace-investigator` sub-agent — in parallel, with seed - chunks AND your interpretive commentary on what to trust. +4. For each repo in scope, either single-project search (`cix search + -n `) or spawn a `cix-workspace-investigator` sub-agent + — in parallel, with seed chunks, the server alias, AND your + interpretive commentary on what to trust. 5. Synthesize the sub-agent reports → plan changes per repo, with order constraints (Q2 + Q3 answered). 6. Ask the user to confirm the scope and plan before implementing. diff --git a/skills/README.md b/skills/README.md index 47950b2..0bf9efa 100644 --- a/skills/README.md +++ b/skills/README.md @@ -61,12 +61,13 @@ follows instructions and reports. ### Install Easiest path is the **`cix` Claude Code plugin** (v0.2.0+) — both the -skill and the sub-agent are bundled and installed together: +skill and the sub-agent are bundled and installed together. Run these in +a terminal (not inside a Claude Code session); the install applies the +next time you start `claude`, so there's no reload step: -``` -/plugin marketplace add dvcdsys/code-index -/plugin install cix@code-index -/reload-plugins +```bash +claude plugin marketplace add dvcdsys/code-index +claude plugin install cix@code-index ``` Or manually: diff --git a/skills/cix-workspace/SKILL.md b/skills/cix-workspace/SKILL.md index 2eb983d..f894d3e 100644 --- a/skills/cix-workspace/SKILL.md +++ b/skills/cix-workspace/SKILL.md @@ -31,6 +31,36 @@ to implementation before you can answer all three with evidence. --- +## First: which server hosts the workspace? + +The `cix` CLI can be configured with **several named servers** (a local +box, a remote corporate backend, …). Each server hosts its **own** set of +workspaces and projects — a workspace named `platform` on one server is +unrelated to anything on another. Every `cix` command targets the +**default** server unless you pass the global `--server ` flag (or +set `CIX_SERVER`). + +```bash +cix config show # lists configured servers; * marks the default +``` + +**A workspace and all its repos live on exactly one server.** So before +you run the workflow, decide which server you're on, then be **consistent**: +pass the *same* `--server ` to *every* command in the flow — +`cix ws`, workspace search, per-project drill-down, and the sub-agent +fan-out. Mixing servers mid-workflow (orient on server A, drill down on +the default) silently returns empty or wrong-repo results, because the +project simply doesn't exist on the other server. + +**Agent rule:** use the default server (no flag) unless the user names a +specific server, or the primary project's workspace isn't on the default. +Never guess an alias — run `cix config show` to see the configured names. +Once you know the alias, thread it through the whole workflow. The +examples below omit `--server` for readability; add it to **every** command +when the target workspace is on a non-default server. + +--- + ## When to reach for workspace search | Signal in the user's request | What to do | @@ -57,10 +87,16 @@ The goal-driven loop. Don't shortcut it. Each step is fast. ### Step 0 — orient ```bash -cix ws # list workspaces; find the one your primary is in +cix config show # which servers exist? which is default? +cix ws # list workspaces on the (default) server cix ws # describe — confirm repos are indexed (✓ count) ``` +If `cix ws` doesn't list the workspace your task is about, it may live on +a different server — re-run with `--server ` (the alias from +`cix config show`) and keep that flag on every later command. Lock in the +server here, before searching. + If the workspace shows `stale_fts_repos` in any search response later, trust the dense ranking less — see the troubleshooting section. @@ -107,21 +143,28 @@ deeper read, not as the full answer. For repos other than the primary, you have two options: **A. Quick scan (≤ 2 repos to investigate):** use single-project -search directly. +search directly via the CLI, scoped with `-n` to the project. Pass the +`project_path` from the workspace-search `projects[]` panel verbatim: ```bash -# Search inside one specific project -curl -G -H "Authorization: Bearer $CIX_KEY" \ - --data-urlencode "q=rate limit middleware handler" \ - --data-urlencode "min_score=0" \ - "$CIX_URL/api/v1/projects/$(project_hash)/search" +# Search inside one specific project (-n = exact project ID from cix list / +# the workspace projects[] panel). --server keeps it on the same backend +# the workspace lives on — REQUIRED when that's not the default server. +cix search "rate limit middleware handler" -n --min-score 0 +cix search "rate limit middleware handler" -n --server corporate --min-score 0 ``` The per-project default `min_score` is `0.2` — light floor that keeps abstract NL queries non-empty. For drill-down on a natural- -language question ("how does X work end-to-end"), pass `min_score=0` +language question ("how does X work end-to-end"), pass `--min-score 0` explicitly to be safe. For strict code-symbol matching, pass `0.4+`. +> Prefer the CLI over a raw `curl … /api/v1/projects/{hash}/search`: the +> CLI resolves the right server (and its key) from your config, so you +> can't accidentally point `$CIX_URL`/`$CIX_KEY` at a *different* server +> than the workspace. If you do hand-roll curl, make sure the URL + key +> belong to the server that hosts this workspace. + **B. Fan-out to sub-agents (≥ 3 repos, or you need a thorough read):** spawn one `cix-workspace-investigator` sub-agent per relevant repo, in parallel. See the dedicated [Sub-agent fan-out pattern](#sub-agent-fan-out-pattern) @@ -366,6 +409,14 @@ entry in `projects[]`. Paste that string verbatim into the sub-agent prompt, and tell the sub-agent explicitly which shape it is so it doesn't waste calls grepping a tree that isn't there. One repo per spawn. +**Pass the server alias too.** If the workspace is on a non-default server, +the sub-agent's `cix search -n ` calls must carry the *same* +`--server ` — otherwise they hit the default server, where the +project doesn't exist, and come back empty. State it plainly in the prompt: +"This project lives on server `corporate`; add `--server corporate` to every +`cix` call." If you're on the default server, say so (or omit it) so the +sub-agent doesn't invent a flag. + #### 3. Seed chunks **with your commentary** This is the part most often done badly. Don't just paste raw chunk @@ -404,6 +455,8 @@ Seed chunks from workspace search: shared utilities. Panel-level notes: +- Server: this project is on `corporate` — pass `--server corporate` on + every cix call (it does NOT exist on the default server). - Workspace ranked this project #1 with a clear lead (project_score 1.000 vs next 0.860). High confidence this is the right repo. - bm25_score=8.5, dense_score=0.54 — strong on both signals, not a @@ -590,6 +643,11 @@ language to see what stack it actually uses, then refine. ## Quick command reference ```bash +# Servers (run first if more than one backend is configured) +cix config show # list servers; * marks the default +cix ws --server corporate # any command takes the global --server +# (CIX_SERVER=corporate also selects it; --server wins over the env var) + # List workspaces cix ws cix ws list --json @@ -628,12 +686,16 @@ Flags: When the user's task plausibly spans more than one repo: +0. `cix config show` → if more than one server, decide which one hosts + the workspace and thread the **same** `--server ` through every + command below (default server → no flag needed). 1. `cix ws` → find the workspace, then `cix ws ` describe it. 2. Workspace search with a **short, term-rich** query. 3. Read `projects[]` → that's your scope (Q1 answered). -4. For each repo in scope, either single-project search or spawn a - `cix-workspace-investigator` sub-agent — in parallel, with seed - chunks AND your interpretive commentary on what to trust. +4. For each repo in scope, either single-project search (`cix search + -n `) or spawn a `cix-workspace-investigator` sub-agent + — in parallel, with seed chunks, the server alias, AND your + interpretive commentary on what to trust. 5. Synthesize the sub-agent reports → plan changes per repo, with order constraints (Q2 + Q3 answered). 6. Ask the user to confirm the scope and plan before implementing. diff --git a/skills/cix-workspace/agents/cix-workspace-investigator.md b/skills/cix-workspace/agents/cix-workspace-investigator.md index 6295327..561f959 100644 --- a/skills/cix-workspace/agents/cix-workspace-investigator.md +++ b/skills/cix-workspace/agents/cix-workspace-investigator.md @@ -27,11 +27,24 @@ of two shapes. Behave very differently depending on which: nothing useful — there's nothing to read locally. The cix server has the files, chunks, and symbols; you reach them only through the `cix` CLI. -**How to tell which you have:** run `cix list` once, then `grep` for the -exact identifier the main agent gave you. +**If the main agent gave you a server alias, use it on EVERY cix call.** +The `cix` CLI can have several named servers configured, and a workspace +(plus all its repos) lives on exactly one of them. The main agent will +tell you which — e.g. "this project is on server `corporate`". When it +does, add the global `--server ` flag to *every* `cix` command +below, alongside `-n `. Without it, cix talks to the +*default* server, where your assigned project doesn't exist, and every +call comes back empty (which looks like "nothing found" but is really +"wrong server"). If no alias was given, you're on the default server — +don't invent one. + +**How to tell which shape your project is:** run `cix list` once (on the +right server), then `grep` for the exact identifier the main agent gave +you. ```bash -cix list | grep -F "" +cix list --server | grep -F "" +# (drop --server if the main agent didn't name one — default server) ``` - A line starting with `[✓] /` → local working tree. @@ -47,6 +60,11 @@ the code. You have a read-only toolkit for code investigation inside the assigned project: +> **Server flag.** If the main agent named a server (`--server `), +> append it to *every* command in this list, e.g. +> `cix search "" -n --server `. The examples +> below omit it for brevity; add it whenever you were given an alias. + - **`cix search "" -n `** — semantic / hybrid lookups *inside the assigned project*. **Always pass `-n `** (the identifier from `cix list`); without it, cix searches whatever project @@ -72,11 +90,14 @@ re-index. ## Hard rules — non-negotiable -1. **Stay inside the assigned project.** Every `cix` invocation MUST carry - `-n `. Without it, cix searches the cwd's project — that's - the main session's repo, not yours. Don't read or query other workspace - repos. If a finding requires looking elsewhere, surface it as an - uncertainty for the main agent to fan out further. +1. **Stay inside the assigned project — and on the assigned server.** + Every `cix` invocation MUST carry `-n `, plus + `--server ` if the main agent named one. Without `-n`, cix + searches the cwd's project (the main session's repo, not yours); + without the right `--server`, it queries the wrong backend and returns + empty. Don't read or query other workspace repos. If a finding requires + looking elsewhere, surface it as an uncertainty for the main agent to + fan out further. 2. **Never hunt the filesystem for a remote-only project.** No `find /`, no `locate`, no `ls -R ~`, no recursive Grep across `/`. If `cix list` shows the project as `github.com/…@…`, the files do From ae8254cf82cabd5dee0c9e8b7e0b8936f43c30a0 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Wed, 3 Jun 2026 12:54:36 +0100 Subject: [PATCH 8/9] test(server): cover index/files 403 + document local_project_disabled edges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback on the per-user restriction: - Add an index/files 403 case to the gating test (the guard is identical to begin/finish, but explicit coverage closes the gap). - Document the role-independent persistence edge: the flag is not cleared on promote-to-admin (admins just ignore it) and re-activates on demotion — intentional. Noted at SetLocalProjectDisabled and the dashboard "Always" cell. - Document the mid-indexing edge: flipping the flag during an in-flight index session strands it; index/cancel stays open and the session TTLs out. Noted at requireLocalProjectActions. Co-Authored-By: Claude Opus 4.8 --- .../dashboard/src/modules/users/components/UsersTable.tsx | 4 ++++ server/internal/httpapi/access.go | 8 ++++++++ server/internal/httpapi/auth_test.go | 7 +++++++ server/internal/users/users.go | 8 ++++++++ 4 files changed, 27 insertions(+) diff --git a/server/dashboard/src/modules/users/components/UsersTable.tsx b/server/dashboard/src/modules/users/components/UsersTable.tsx index 521dd6c..733c1df 100644 --- a/server/dashboard/src/modules/users/components/UsersTable.tsx +++ b/server/dashboard/src/modules/users/components/UsersTable.tsx @@ -55,6 +55,10 @@ export function UsersTable({ + {/* Admins ignore local_project_disabled (always allowed), so + we show "Always" instead of a toggle. The stored flag is + left untouched — if this admin is later demoted to user it + takes effect again. */} {u.role === 'admin' ? ( Always ) : ( diff --git a/server/internal/httpapi/access.go b/server/internal/httpapi/access.go index c2887f8..baf77d9 100644 --- a/server/internal/httpapi/access.go +++ b/server/internal/httpapi/access.go @@ -32,6 +32,14 @@ func (s *Server) callerIdentity(r *http.Request) (userID string, isAdmin bool) { // rejection it writes the response and returns false: // // if !s.requireLocalProjectActions(w, r) { return } +// +// Mid-indexing edge: the flag is checked per request, so flipping it on while +// a user has an in-flight index session (begin succeeded) makes the next +// index/files or index/finish 403 and strands that session. This is benign — +// index/cancel is deliberately NOT gated (see IndexCancel) so the user/CLI can +// still release the run lock, and an abandoned session expires on its own TTL. +// We accept the rare stranded-session window rather than special-casing +// in-flight runs. func (s *Server) requireLocalProjectActions(w http.ResponseWriter, r *http.Request) bool { if s.Deps.AuthDisabled { return true diff --git a/server/internal/httpapi/auth_test.go b/server/internal/httpapi/auth_test.go index 43c7bb3..0e65431 100644 --- a/server/internal/httpapi/auth_test.go +++ b/server/internal/httpapi/auth_test.go @@ -620,6 +620,13 @@ func TestLocalProjectDisabled_GatesCreateAndIndex(t *testing.T) { t.Errorf("restricted index/begin = %d, want 403", code) } }) + t.Run("restricted: index/files is 403", func(t *testing.T) { + // Same guard as begin/finish — the gate runs before the body is read, + // so an empty body still 403s. Covers the mid-protocol upload step. + if code := do(aliceCookie, http.MethodPost, "/api/v1/projects/"+aliceProj+"/index/files", []byte("{}")); code != http.StatusForbidden { + t.Errorf("restricted index/files = %d, want 403", code) + } + }) t.Run("restricted: index/finish is 403", func(t *testing.T) { if code := do(aliceCookie, http.MethodPost, "/api/v1/projects/"+aliceProj+"/index/finish", []byte(`{"run_id":"x"}`)); code != http.StatusForbidden { t.Errorf("restricted index/finish = %d, want 403", code) diff --git a/server/internal/users/users.go b/server/internal/users/users.go index 4e22753..79ac35d 100644 --- a/server/internal/users/users.go +++ b/server/internal/users/users.go @@ -384,6 +384,14 @@ func (s *Service) SetDisabled(ctx context.Context, id string, disabled bool) err // projects and indexing/reindexing. No last-admin guard: admins are exempt // from the restriction at enforcement time, so the flag is meaningful only // for regular users and never locks anyone out of administration. +// +// The column is independent of role: promoting a restricted user to admin +// does NOT clear it (admins simply ignore it via the enforcement guard), so a +// later demotion back to user re-activates the stored restriction. This is +// intentional — the flag records an explicit admin decision about that +// account, and a round-trip through admin shouldn't silently erase it. The +// dashboard renders "Always" for admin rows to reflect the exemption without +// implying the stored value changed. func (s *Service) SetLocalProjectDisabled(ctx context.Context, id string, disabled bool) error { v := 0 if disabled { From 45c1f218200d8a42b70569b6cc9d9e12b12270b0 Mon Sep 17 00:00:00 2001 From: dvcdsys Date: Wed, 3 Jun 2026 13:16:25 +0100 Subject: [PATCH 9/9] fix(server): bump go directive to 1.25.11 to clear stdlib vulns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit govulncheck (Security workflow) flagged two Go standard-library vulnerabilities, both fixed in go1.25.11: - GO-2026-5039 (net/textproto) — reached via githubapi.DeleteWebhook → io.ReadAll - GO-2026-5037 (crypto/x509) — reached via tunnels.Installer.Install → io.Copy CI installs Go from `server/go.mod` (go-version-file), so bumping the directive to 1.25.11 is what moves the build onto the patched stdlib. Verified locally with the 1.25.11 toolchain: `govulncheck ./...` now reports 0 affecting vulnerabilities; build passes. Co-Authored-By: Claude Opus 4.8 --- server/go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/go.mod b/server/go.mod index d3cf3be..3d0a21d 100644 --- a/server/go.mod +++ b/server/go.mod @@ -1,6 +1,6 @@ module github.com/dvcdsys/code-index/server -go 1.25.10 +go 1.25.11 require ( github.com/getkin/kin-openapi v0.135.0