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()}
/>
-
- {copied ? (
+
+ {copiedKey ? (
<>
Copied
@@ -184,6 +228,46 @@ export function CreateApiKeyDialog() {
button is blocked by your browser.
+
+
+
+ Connect the cix CLI
+
+
+ {/* Read-only, wrapping command box: one paste registers this
+ server (URL + key as a single entry) and makes it the
+ default, so `cix search …` works right after. */}
+
+ {connectCmd}
+
+
+ {copiedCmd ? (
+ <>
+
+ Copied
+ >
+ ) : (
+ <>
+
+ Copy
+ >
+ )}
+
+
+
+ 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.
+
+
{
From 880346306fd833fa8056aa651f6ff8b7ab446c16 Mon Sep 17 00:00:00 2001
From: dvcdsys
Date: Wed, 3 Jun 2026 11:23:35 +0100
Subject: [PATCH 2/9] fix(dashboard): make the X button close the "API key
created" popup
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The onOpenChange handler returned early whenever a key was revealed
(`if (!next && revealed) return;`), which swallowed the close event from
the top-right X button — the dialog could then only be dismissed by
reloading the page. Accidental dismissal (outside-click, Escape) is
already blocked separately at the DialogContent layer via preventDefault,
so that early-return only ever harmed the explicit X click.
Remove the guard so X (and "I've saved it") close and reset the dialog,
while Escape / outside-click stay blocked at the content layer.
Co-Authored-By: Claude Opus 4.8
---
.../api-keys/components/CreateApiKeyDialog.tsx | 13 ++++++++-----
1 file changed, 8 insertions(+), 5 deletions(-)
diff --git a/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx b/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx
index 633bd85..8ad90fc 100644
--- a/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx
+++ b/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx
@@ -149,11 +149,14 @@ export function CreateApiKeyDialog() {
{
- // Once a key is revealed, only the explicit "I've saved it" button
- // (or close-X) may dismiss the dialog. Outside-click and Escape are
- // blocked at the DialogContent layer below; we still gate state-resets
- // here so a sibling dismiss path can't wipe the key silently.
- if (!next && revealed) return;
+ // Accidental dismissal of the reveal screen (outside-click, Escape) is
+ // blocked at the DialogContent layer below via preventDefault, so it
+ // never reaches here. The only close paths that DO reach onOpenChange
+ // while a key is revealed are explicit user intent — the top-right X
+ // button (the "I've saved it" button sets state directly) — so honor
+ // every close and always reset. (An earlier guard returned here when a
+ // key was revealed, which also swallowed the X click: the dialog could
+ // then only be dismissed by reloading the page.)
setOpen(next);
if (!next) reset();
}}
From 57b107ca2822acd290c3b102971ae05899d73f60 Mon Sep 17 00:00:00 2001
From: dvcdsys
Date: Wed, 3 Jun 2026 11:50:06 +0100
Subject: [PATCH 3/9] perf(users/test): make bcrypt cost cheap under `go test`
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Password hashing uses bcrypt cost 12 (~250ms/hash by design). Because
nearly every server test seeds a user through a fixture, that deliberate
cost dominated the whole `-race` suite: httpapi ~462s, users ~83s,
sessions ~43s, apikeys ~28s — almost entirely bcrypt, serialized and
amplified by CI CPU contention.
Resolve the work factor once at init instead of a hard-coded const:
1. CIX_BCRYPT_COST — explicit override, clamped to [MinCost, MaxCost]
2. testing.Testing() — drop to bcrypt.MinCost under `go test`
3. 12 — production default (unchanged behaviour outside tests)
testing.Testing() is false outside test binaries, and since Go 1.13
importing "testing" no longer registers test flags, so cix-server's
flag.Parse() is unaffected (verified: `cix-server -v` still works).
Also derive the user-not-found anti-enumeration dummy hash from the
active cost (was a hard-coded `$2a$12$…` literal) so the timing
mitigation stays accurate when the cost changes and the not-found path
is cheap under test too.
Measured (local, `go test -race -count=1`):
httpapi 462.3s → 15.8s
users 83.0s → <1s
sessions 43.3s → <1s
apikeys 27.9s → 5.4s
full suite all green, 0 data races, ~23s wall.
chunker (~19s, tree-sitter/CGO) is the remaining slow package and is
inherent to -race; left untouched.
Co-Authored-By: Claude Opus 4.8
---
server/internal/users/users.go | 63 ++++++++++++++++++++++++++++++----
1 file changed, 56 insertions(+), 7 deletions(-)
diff --git a/server/internal/users/users.go b/server/internal/users/users.go
index d0151f7..936b0de 100644
--- a/server/internal/users/users.go
+++ b/server/internal/users/users.go
@@ -9,7 +9,10 @@ import (
"database/sql"
"errors"
"fmt"
+ "os"
+ "strconv"
"strings"
+ "testing"
"time"
"github.com/google/uuid"
@@ -23,10 +26,56 @@ const (
RoleUser = "user"
)
-// BcryptCost is the work factor for password hashing. 12 is the current
-// industry default — tunable here without touching call sites if the
-// hardware moves.
-const BcryptCost = 12
+// defaultBcryptCost is the production work factor for password hashing. 12 is
+// the current industry default — high enough that one hash costs ~250ms, which
+// is what makes offline cracking expensive (and what makes a test suite that
+// mints a user per fixture crawl).
+const defaultBcryptCost = 12
+
+// BcryptCost is the work factor actually used by Create / UpdatePassword. It is
+// resolved once, at package init, from (highest precedence first):
+//
+// 1. CIX_BCRYPT_COST — explicit override, clamped to bcrypt's [MinCost,
+// MaxCost]. An escape hatch; production should leave it unset.
+// 2. testing.Testing() — under `go test` we drop to bcrypt.MinCost. The hash
+// itself is never what the tests assert, yet at cost 12 the deliberate
+// ~250ms/hash dominated the whole server suite (hundreds of fixtures each
+// seed a user → minutes of pure bcrypt under -race). MinCost is ~256×
+// cheaper and keeps the round-trip behaviour identical. Safe in prod
+// because testing.Testing() is false outside test binaries, and importing
+// "testing" no longer registers test flags (Go 1.13+), so cix-server's
+// flag.Parse() is unaffected.
+// 3. defaultBcryptCost (12) — production default.
+var BcryptCost = resolveBcryptCost()
+
+func resolveBcryptCost() int {
+ if v := os.Getenv("CIX_BCRYPT_COST"); v != "" {
+ if c, err := strconv.Atoi(v); err == nil && c >= bcrypt.MinCost && c <= bcrypt.MaxCost {
+ return c
+ }
+ }
+ if testing.Testing() {
+ return bcrypt.MinCost
+ }
+ return defaultBcryptCost
+}
+
+// dummyHash is a real bcrypt hash, computed once at the active BcryptCost. It is
+// fed to CompareHashAndPassword on the user-not-found login path to burn the
+// same CPU a genuine check would, so response timing can't be used to enumerate
+// accounts. Deriving it from BcryptCost — rather than hard-coding a $2a$12$…
+// literal — keeps the mitigation accurate when the cost is overridden and keeps
+// the not-found path cheap under test.
+var dummyHash = mustDummyHash()
+
+func mustDummyHash() []byte {
+ h, err := bcrypt.GenerateFromPassword([]byte("user-enumeration-timing-equaliser"), BcryptCost)
+ if err != nil {
+ // BcryptCost is clamped to bcrypt's valid range, so this is unreachable.
+ panic(fmt.Sprintf("users: precompute dummy bcrypt hash: %v", err))
+ }
+ return h
+}
var (
ErrNotFound = errors.New("user not found")
@@ -225,9 +274,9 @@ func (s *Service) Authenticate(ctx context.Context, email, password string) (Use
if err := row.Scan(&u.ID, &hash, &u.Role, &mcp, &createdAt, &updatedAt, &disabledAt, &emailOut); err != nil {
if errors.Is(err, sql.ErrNoRows) {
// Match the timing of a hash-compare to mitigate user-enumeration
- // via response time. CompareHashAndPassword on a known-bad hash
- // burns the same cost as a real login.
- _ = bcrypt.CompareHashAndPassword([]byte("$2a$12$invalidinvalidinvalidinvalidinvalidinvalidinvalidinvali"), []byte(password))
+ // via response time. dummyHash carries the active cost, so this
+ // burns the same CPU as a real login (see dummyHash).
+ _ = bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
return User{}, ErrInvalidLogin
}
return User{}, fmt.Errorf("scan user: %w", err)
From 27ee2d93b2136cbd31513fbb3e0531608940728c Mon Sep 17 00:00:00 2001
From: dvcdsys
Date: Wed, 3 Jun 2026 11:54:00 +0100
Subject: [PATCH 4/9] feat(dashboard): add "Connect Claude Code to cix"
onboarding card to home
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Add a collapsible step-by-step card on the dashboard home page that walks a
new user from zero to seeing the cix skills in Claude Code:
1. Install the cix CLI (curl installer)
2. Connect the CLI to this server (links to API Keys, shows the live
per-host connect command)
3. Add the plugin marketplace + install in Claude Code
4. (Optional) index a local project — skippable for workspace / external-
project-only users; cix status alone still verifies the connection
5. See the cix slash commands / skills appear
Collapse state persists in localStorage (expanded by default). Commands use
one-click copy buttons with the secure-context clipboard + execCommand
fallback for HTTP deploys.
Extract the per-host alias / connect-command derivation into
lib/cixServer.ts (cixServerAlias, cixConnectCommand) and reuse it in both the
card and the API-key popup so the two never drift. Add a reusable lib/useCopy
hook for the new components.
Co-Authored-By: Claude Opus 4.8
---
server/dashboard/src/lib/cixServer.ts | 31 +++
server/dashboard/src/lib/useCopy.ts | 67 ++++++
.../components/CreateApiKeyDialog.tsx | 31 +--
.../src/modules/home/CommandBlock.tsx | 38 +++
.../modules/home/ConnectClaudeCodeCard.tsx | 216 ++++++++++++++++++
.../dashboard/src/modules/home/HomePage.tsx | 3 +
6 files changed, 363 insertions(+), 23 deletions(-)
create mode 100644 server/dashboard/src/lib/cixServer.ts
create mode 100644 server/dashboard/src/lib/useCopy.ts
create mode 100644 server/dashboard/src/modules/home/CommandBlock.tsx
create mode 100644 server/dashboard/src/modules/home/ConnectClaudeCodeCard.tsx
diff --git a/server/dashboard/src/lib/cixServer.ts b/server/dashboard/src/lib/cixServer.ts
new file mode 100644
index 0000000..f7a8d04
--- /dev/null
+++ b/server/dashboard/src/lib/cixServer.ts
@@ -0,0 +1,31 @@
+// 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".
+//
+// Shared by the API-key "Connect the cix CLI" popup and the home-page
+// onboarding card so the two never drift.
+export function cixServerAlias(host: string): string {
+ const alias = host
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+ return alias || 'default';
+}
+
+// Build the one-paste command that registers a server (URL + key as a single
+// `server.` entry) in the cix CLI config and makes it the default.
+// 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. `key`
+// defaults to a `` placeholder for previews where no secret is revealed.
+export function cixConnectCommand(origin: string, host: string, key = ''): string {
+ const alias = cixServerAlias(host);
+ return (
+ `cix config set server.${alias}.url ${origin} && ` +
+ `cix config set server.${alias}.key ${key} && ` +
+ `cix config set default_server ${alias}`
+ );
+}
diff --git a/server/dashboard/src/lib/useCopy.ts b/server/dashboard/src/lib/useCopy.ts
new file mode 100644
index 0000000..fa9db82
--- /dev/null
+++ b/server/dashboard/src/lib/useCopy.ts
@@ -0,0 +1,67 @@
+import { useCallback, useRef, useState } from 'react';
+import { toast } from 'sonner';
+
+// Last-resort copy when the async Clipboard API isn't available — happens on
+// plain HTTP deploys (non-localhost) and inside some embedded webviews.
+// document.execCommand('copy') is deprecated but universally implemented as of
+// 2026; keeping it as a fallback turns "no way to copy" into "always works".
+function legacyCopy(text: string): boolean {
+ if (typeof document === 'undefined') return false;
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.setAttribute('readonly', '');
+ ta.style.position = 'fixed';
+ ta.style.opacity = '0';
+ document.body.appendChild(ta);
+ ta.focus();
+ ta.select();
+ let ok = false;
+ try {
+ ok = document.execCommand('copy');
+ } catch {
+ ok = false;
+ }
+ document.body.removeChild(ta);
+ return ok;
+}
+
+// useCopy — one-click clipboard with a transient "copied" flag and graceful
+// fallback. navigator.clipboard requires a secure context (HTTPS or localhost);
+// on bare-IP / HTTP deploys it throws, so we fall back to document.execCommand
+// through a transient textarea. On total failure we surface a toast telling the
+// user to copy manually. `copied` flips true for `resetMs` after a success so
+// callers can show a checkmark without re-implementing the timer.
+export function useCopy(resetMs = 2000): {
+ copied: boolean;
+ copy: (text: string) => Promise;
+} {
+ const [copied, setCopied] = useState(false);
+ const timer = useRef(null);
+
+ const copy = useCallback(
+ async (text: string): Promise => {
+ let ok = false;
+ try {
+ if (window.isSecureContext && navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(text);
+ ok = true;
+ } else {
+ ok = legacyCopy(text);
+ if (!ok) throw new Error('legacy copy failed');
+ }
+ } catch {
+ toast.error('Could not copy automatically.', {
+ description: 'Select the text, ⌘A / Ctrl-A, then copy.',
+ });
+ return false;
+ }
+ setCopied(true);
+ if (timer.current) window.clearTimeout(timer.current);
+ timer.current = window.setTimeout(() => setCopied(false), resetMs);
+ return ok;
+ },
+ [resetMs]
+ );
+
+ return { copied, copy };
+}
diff --git a/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx b/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx
index 8ad90fc..0f05d8c 100644
--- a/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx
+++ b/server/dashboard/src/modules/api-keys/components/CreateApiKeyDialog.tsx
@@ -15,6 +15,7 @@ import {
} from '@/ui/dialog';
import { Input } from '@/ui/input';
import { Label } from '@/ui/label';
+import { cixConnectCommand } from '@/lib/cixServer';
import { useCreateApiKey } from '../hooks';
// Last-resort copy when the async Clipboard API isn't available — happens
@@ -41,21 +42,6 @@ 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
@@ -121,14 +107,13 @@ export function CreateApiKeyDialog() {
// 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}`;
+ // window.location.origin is exactly the base URL the CLI must talk to. Shared
+ // with the home-page onboarding card via cixConnectCommand so they never drift.
+ const connectCmd = cixConnectCommand(
+ window.location.origin,
+ window.location.host,
+ revealed ?? ''
+ );
async function copyKey() {
if (!revealed) return;
diff --git a/server/dashboard/src/modules/home/CommandBlock.tsx b/server/dashboard/src/modules/home/CommandBlock.tsx
new file mode 100644
index 0000000..420c7e6
--- /dev/null
+++ b/server/dashboard/src/modules/home/CommandBlock.tsx
@@ -0,0 +1,38 @@
+import { Check, Copy } from 'lucide-react';
+import { Button } from '@/ui/button';
+import { useCopy } from '@/lib/useCopy';
+
+// A read-only, wrapping mono command box with a one-click Copy button — same
+// look as the API-key popup's connect-command block. `command` is the literal
+// text copied to the clipboard; render it verbatim so what the user sees is
+// exactly what they paste.
+export function CommandBlock({ command }: { command: string }) {
+ const { copied, copy } = useCopy();
+ return (
+
+
+ {command}
+
+
void copy(command)}
+ aria-label="Copy command"
+ >
+ {copied ? (
+ <>
+
+ Copied
+ >
+ ) : (
+ <>
+
+ Copy
+ >
+ )}
+
+
+ );
+}
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 ? (
+
+ ) : (
+
+ )}
+
+
+
+ Connect Claude Code to cix
+
+
+ Install the CLI, link this server, add the plugin — then the cix skills show up in
+ Claude Code.
+
+
+
+
+ {!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