Admin-gated Settings: runtime model selection, enforce is_admin (#79)
Moves the agent model out of env-only config into an admin-editable Settings
section, and enforces is_admin for the first time — it has been in the schema
since migration 0001, plumbed to the client, and checked nowhere.
Backend:
- Migration 0010: instance_settings, a single-row (CHECK id=1) table — pansy's
first instance-level state. Holds agent_model ('' = inherit env) and
agent_enabled (NULL = inherit env), version-guarded like every mutable row.
SECRETS STAY IN ENV: OLLAMA_CLOUD_API_KEY is never stored here.
- requireAdmin at the service seam (authoritative) plus a cheap middleware
early-403. Non-admin gets 403, not 404 — settings existence isn't masked.
- EffectiveAgent resolves DB-over-env (model, enabled); key always from env.
- The live Runner is hot-swapped, not built once. agentHolder holds it behind
an atomic.Pointer; the chat routes are now registered UNCONDITIONALLY and
nil-check agent.get(), so a settings change turns the assistant on/off/onto a
new model with no restart and no race against in-flight readers. /capabilities
reads the pointer, so it reports what's live, not what booted.
- internal/agentmodel is a new leaf package holding the one place that knows how
to turn a spec into a model. Both agent (to run) and service (to validate a
spec before storing it) import it; it can't live in agent, which imports
service. Settings PATCH validates the spec via Parse, so a typo is a 400 now
rather than a broken assistant on the next turn.
Frontend:
- /settings route (admin guard), a Settings page (model field, tri-state
enabled, live status), nav link shown only to admins.
- useCapabilities drops staleTime:Infinity — the assistant can now change under
a running page — and the settings save invalidates it.
Contract change: chat routes always exist, so "assistant off" is a runtime 503
+ capabilities:false, not a missing route. Updated the test that asserted the
old shape.
Verified live against the built binary: disable flips capabilities to false and
logs it; re-enable with a new model swaps it back; a bad spec is rejected 400;
the setting persists across a restart. Swap is race-clean under `go test -race`.
Docs: README (precedence + key-stays-in-env), DESIGN (decision + routes),
CLAUDE (don't re-add conditional route registration; key never in the DB).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
@@ -53,6 +53,19 @@ export function AppShell() {
|
||||
{l.label}
|
||||
</Link>
|
||||
))}
|
||||
{/* Settings is admin-only, matching the server's requireAdmin gate.
|
||||
A non-admin who typed /settings still gets a 403 from the API — the
|
||||
hidden link is convenience, not the security boundary. */}
|
||||
{user?.isAdmin && (
|
||||
<Link
|
||||
to="/settings"
|
||||
className={navLinkBase}
|
||||
activeProps={{ className: navLinkActive }}
|
||||
inactiveProps={{ className: navLinkInactive }}
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{user ? (
|
||||
|
||||
+12
-4
@@ -13,13 +13,21 @@ import { historyKey } from './history'
|
||||
|
||||
const capabilitiesSchema = z.object({ agent: z.boolean() })
|
||||
|
||||
/** Whether this instance has the assistant configured. Without it the panel
|
||||
* isn't rendered at all — a dead button is worse than no button. */
|
||||
export const capabilitiesKey = ['capabilities'] as const
|
||||
|
||||
/** Whether the assistant is live RIGHT NOW. Without it the panel isn't rendered
|
||||
* at all — a dead button is worse than no button.
|
||||
*
|
||||
* Not `staleTime: Infinity` any more: an admin can turn the assistant on or off
|
||||
* in Settings (#79), so this must be able to change under a running page. The
|
||||
* settings save invalidates this key directly; the finite staleTime just means
|
||||
* another admin's change is picked up on the next focus/remount rather than
|
||||
* never. */
|
||||
export function useCapabilities() {
|
||||
return useQuery({
|
||||
queryKey: ['capabilities'] as const,
|
||||
queryKey: capabilitiesKey,
|
||||
queryFn: async () => capabilitiesSchema.parse(await api.get('/capabilities')),
|
||||
staleTime: Infinity, // server config; it doesn't change under a running page
|
||||
staleTime: 60_000,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Instance settings data layer (#79): admin-only, instance-wide configuration.
|
||||
//
|
||||
// The GET/PATCH return both the stored settings and a read-only "effective" view
|
||||
// — what's actually in force after layering the DB over the environment — so the
|
||||
// form can say "inheriting ollama-cloud/glm-5.2:cloud from the environment" and
|
||||
// whether the API key is present, without the key ever crossing the wire.
|
||||
|
||||
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { z } from 'zod'
|
||||
import { ApiError, api } from './api'
|
||||
import { capabilitiesKey } from './agent'
|
||||
|
||||
export const instanceSettingsSchema = z.object({
|
||||
// '' means "inherit the PANSY_AGENT_MODEL env var".
|
||||
agentModel: z.string(),
|
||||
// null means "inherit PANSY_AGENT_ENABLED"; true/false is an explicit override.
|
||||
agentEnabled: z.boolean().nullable(),
|
||||
version: z.number(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
export type InstanceSettings = z.infer<typeof instanceSettingsSchema>
|
||||
|
||||
export const effectiveAgentSchema = z.object({
|
||||
model: z.string(),
|
||||
enabled: z.boolean(),
|
||||
hasApiKey: z.boolean(),
|
||||
agentLive: z.boolean(),
|
||||
})
|
||||
export type EffectiveAgent = z.infer<typeof effectiveAgentSchema>
|
||||
|
||||
export const settingsResponseSchema = z.object({
|
||||
settings: instanceSettingsSchema,
|
||||
effective: effectiveAgentSchema,
|
||||
})
|
||||
export type SettingsResponse = z.infer<typeof settingsResponseSchema>
|
||||
|
||||
export const settingsKey = ['settings'] as const
|
||||
|
||||
export const settingsQueryOptions = queryOptions({
|
||||
queryKey: settingsKey,
|
||||
queryFn: async (): Promise<SettingsResponse> =>
|
||||
settingsResponseSchema.parse(await api.get('/settings')),
|
||||
})
|
||||
|
||||
export function useSettings() {
|
||||
return useQuery(settingsQueryOptions)
|
||||
}
|
||||
|
||||
export interface SettingsUpdate {
|
||||
agentModel: string
|
||||
agentEnabled: boolean | null
|
||||
version: number
|
||||
}
|
||||
|
||||
export function useUpdateSettings() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (input: SettingsUpdate): Promise<SettingsResponse> =>
|
||||
settingsResponseSchema.parse(await api.patch('/settings', input)),
|
||||
onSuccess: (res) => {
|
||||
qc.setQueryData(settingsKey, res)
|
||||
// The save may have turned the assistant on or off; the editor keys its
|
||||
// chat tab off /capabilities, so make it re-read rather than trust its
|
||||
// cached answer.
|
||||
qc.invalidateQueries({ queryKey: capabilitiesKey })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** If err is a 409 version conflict, return the fresh settings it carries so a
|
||||
* form can rebase; otherwise null. */
|
||||
export function conflictSettings(err: unknown): InstanceSettings | null {
|
||||
if (err instanceof ApiError && err.isConflict && err.body && typeof err.body === 'object') {
|
||||
const current = (err.body as { current?: unknown }).current
|
||||
const parsed = instanceSettingsSchema.safeParse(current)
|
||||
if (parsed.success) return parsed.data
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { Select } from '@/components/ui/Select'
|
||||
import { TextField } from '@/components/ui/TextField'
|
||||
import { toast } from '@/components/ui/toast'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import {
|
||||
conflictSettings,
|
||||
useSettings,
|
||||
useUpdateSettings,
|
||||
type EffectiveAgent,
|
||||
type InstanceSettings,
|
||||
} from '@/lib/settings'
|
||||
import { usePageTitle } from '@/lib/usePageTitle'
|
||||
|
||||
// agentEnabled is a tri-state on the wire (null = inherit env, true, false); the
|
||||
// form models it as three named choices so "inherit" is a deliberate pick, not
|
||||
// an empty control.
|
||||
type EnabledChoice = 'inherit' | 'on' | 'off'
|
||||
const toChoice = (v: boolean | null): EnabledChoice => (v === null ? 'inherit' : v ? 'on' : 'off')
|
||||
const fromChoice = (c: EnabledChoice): boolean | null => (c === 'inherit' ? null : c === 'on')
|
||||
|
||||
/** Admin-only instance settings (#79). The one that matters today is the agent
|
||||
* model; the API key stays in the environment and is only ever reported as
|
||||
* present/absent, never shown or edited. */
|
||||
export function SettingsPage() {
|
||||
usePageTitle('Settings')
|
||||
const settings = useSettings()
|
||||
const update = useUpdateSettings()
|
||||
|
||||
if (settings.isPending) {
|
||||
return <p className="text-sm text-muted">Loading settings…</p>
|
||||
}
|
||||
if (settings.isError) {
|
||||
return <Alert>{errorMessage(settings.error, "Couldn't load settings.")}</Alert>
|
||||
}
|
||||
return <SettingsForm data={settings.data} update={update} />
|
||||
}
|
||||
|
||||
function SettingsForm({
|
||||
data,
|
||||
update,
|
||||
}: {
|
||||
data: { settings: InstanceSettings; effective: EffectiveAgent }
|
||||
update: ReturnType<typeof useUpdateSettings>
|
||||
}) {
|
||||
const [model, setModel] = useState(data.settings.agentModel)
|
||||
const [enabled, setEnabled] = useState<EnabledChoice>(toChoice(data.settings.agentEnabled))
|
||||
const [version, setVersion] = useState(data.settings.version)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Rebase the form when the query cache updates (e.g. a successful save writes
|
||||
// the new row back), so the version we submit is never stale.
|
||||
useEffect(() => {
|
||||
setVersion(data.settings.version)
|
||||
}, [data.settings.version])
|
||||
|
||||
const eff = data.effective
|
||||
|
||||
const save = () => {
|
||||
setError(null)
|
||||
update.mutate(
|
||||
{ agentModel: model.trim(), agentEnabled: fromChoice(enabled), version },
|
||||
{
|
||||
onSuccess: () => toast.info('Settings saved.'),
|
||||
onError: (err) => {
|
||||
const current = conflictSettings(err)
|
||||
if (current) {
|
||||
// Someone else saved first. Adopt their row so the next attempt is
|
||||
// clean, and say so rather than silently discarding this edit.
|
||||
setModel(current.agentModel)
|
||||
setEnabled(toChoice(current.agentEnabled))
|
||||
setVersion(current.version)
|
||||
setError('Someone else changed these settings just now — reloaded their version. Re-apply your change if you still want it.')
|
||||
return
|
||||
}
|
||||
setError(errorMessage(err, "Couldn't save settings."))
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex max-w-2xl flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold text-fg">Settings</h1>
|
||||
<p className="mt-1 text-sm text-muted">
|
||||
Instance-wide, admin-only. Changes take effect immediately — no restart.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="flex flex-col gap-4 rounded-lg border border-border p-4">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-fg">Garden assistant</h2>
|
||||
<p className="mt-1 text-xs text-muted">
|
||||
The model runs against your Ollama Cloud key, which is set in the environment and never
|
||||
shown here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AgentStatus eff={eff} />
|
||||
|
||||
<TextField
|
||||
label="Model"
|
||||
name="agentModel"
|
||||
placeholder={eff.model || 'ollama-cloud/glm-5.2:cloud'}
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
hint={
|
||||
model.trim() === ''
|
||||
? `Empty — inheriting ${eff.model || 'the built-in default'} from the environment.`
|
||||
: 'A majordomo model spec. A comma-separated list is a failover chain.'
|
||||
}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Enabled"
|
||||
name="agentEnabled"
|
||||
value={enabled}
|
||||
onChange={(e) => setEnabled(e.target.value as EnabledChoice)}
|
||||
options={[
|
||||
{ value: 'inherit', label: 'Inherit from environment' },
|
||||
{ value: 'on', label: 'On' },
|
||||
{ value: 'off', label: 'Off' },
|
||||
]}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{error && <Alert>{error}</Alert>}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={save} disabled={update.isPending}>
|
||||
{update.isPending ? 'Saving…' : 'Save changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// AgentStatus surfaces the gap the capabilities endpoint exists for: enabled +
|
||||
// a key does not guarantee the assistant is actually running (an unresolvable
|
||||
// model leaves it down), and that's precisely what an admin needs to see.
|
||||
function AgentStatus({ eff }: { eff: EffectiveAgent }) {
|
||||
const [tone, text] = eff.agentLive
|
||||
? (['ok', `Live on ${eff.model}`] as const)
|
||||
: !eff.hasApiKey
|
||||
? (['warn', 'No API key set in the environment — the assistant is off.'] as const)
|
||||
: !eff.enabled
|
||||
? (['warn', 'Turned off.'] as const)
|
||||
: (['warn', `Configured but not running — check the model (${eff.model}).`] as const)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
className={
|
||||
'inline-block h-2 w-2 rounded-full ' + (tone === 'ok' ? 'bg-emerald-500' : 'bg-amber-500')
|
||||
}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className={tone === 'ok' ? 'text-fg' : 'text-muted'}>{text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { GardensPage } from '@/pages/GardensPage'
|
||||
import { GardenEditorPage } from '@/pages/GardenEditorPage'
|
||||
import { PublicGardenPage } from '@/pages/PublicGardenPage'
|
||||
import { PlantsPage } from '@/pages/PlantsPage'
|
||||
import { SettingsPage } from '@/pages/SettingsPage'
|
||||
import { meQueryOptions } from '@/lib/auth'
|
||||
import { queryClient } from '@/lib/queryClient'
|
||||
import { safeRedirectPath } from '@/lib/redirect'
|
||||
@@ -48,6 +49,20 @@ async function requireGuest(context: RouterContext, redirectTo: string) {
|
||||
}
|
||||
}
|
||||
|
||||
// requireAdmin: authenticated AND admin. A non-admin who navigates to /settings
|
||||
// is sent to /gardens rather than shown a page whose API calls would 403. This
|
||||
// is convenience routing, not the security boundary — the server's requireAdmin
|
||||
// is authoritative.
|
||||
async function requireAdmin(context: RouterContext, path: string) {
|
||||
const me = await context.queryClient.ensureQueryData(meQueryOptions)
|
||||
if (!me) {
|
||||
throw redirect({ to: '/login', search: { redirect: path } })
|
||||
}
|
||||
if (!me.isAdmin) {
|
||||
throw redirect({ to: '/gardens' })
|
||||
}
|
||||
}
|
||||
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
@@ -109,6 +124,13 @@ const plantsRoute = createRoute({
|
||||
component: PlantsPage,
|
||||
})
|
||||
|
||||
const settingsRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: 'settings',
|
||||
beforeLoad: ({ context, location }) => requireAdmin(context, location.href),
|
||||
component: SettingsPage,
|
||||
})
|
||||
|
||||
// Public read-only garden by share token. Deliberately has NO beforeLoad auth
|
||||
// guard, so a logged-out visitor viewing a shared link is never redirected to
|
||||
// /login or OIDC.
|
||||
@@ -125,6 +147,7 @@ const routeTree = rootRoute.addChildren([
|
||||
gardensRoute,
|
||||
gardenEditorRoute,
|
||||
plantsRoute,
|
||||
settingsRoute,
|
||||
publicGardenRoute,
|
||||
])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user