feat(webui): version update check with badge and download proxy

This commit is contained in:
m4
2026-08-11 15:07:53 +08:00
parent 141e25c3b0
commit 598b2cfdc4
7 changed files with 702 additions and 1 deletions
@@ -0,0 +1,226 @@
# Version Update Check & Download for OriginEvoScientist
Date: 2026-08-03
Status: Implemented (2026-08-06)
Inspired by: sub2api `UpdateService` + `VersionBadge` (see `~/Projects/EvoSci/sub2api/sub2api`)
Implementation deviations from the original draft:
- "Latest" is the max semver across `/releases?limit=20` **and** `/tags?limit=20`
(not `releases/latest`): Gitea orders `releases/latest` by tag *creation
date*, so v0.2.2 (tagged 2026-07-11) ranked below v0.1.19 (tagged 2026-07-13).
- `release.sh` builds in a temporary `git worktree` at the tag so a dirty
working tree can't leak into artifacts, and supports version == pyproject
(reuses the existing tag) for baseline cuts.
- Backend routes live in `langgraph_dev/http.py` behind the model-registry
`BffAuthenticator` with a new `system:read` scope (added to both WebUI
roles in `src/lib/server/delegation.ts`).
- The Gitea checker extends the existing `EvoScientist/update_check.py`
(PyPI startup check untouched).
- No component render test for VersionBadge — the repo has no jsdom/testing
-library harness (node-env vitest only); verified in-browser via Playwright.
## Context
Port sub2api's "version badge + update prompt" to OriginEvoScientist, using the
self-hosted Gitea instance **git.foksai.com** (verified: Gitea 1.26.4, anonymous
read access, GitHub-compatible release schema) as the release source.
Verified facts (2026-08-03):
- `GET https://git.foksai.com/api/v1/repos/ouyangbo/EvoScientist/releases/latest`
→ 200, `tag_name=v0.1.19`, schema matches GitHub (`tag_name/name/body/html_url/
draft/prerelease/tarball_url/assets`).
- Latest git tag is `v0.1.20` (tag without a release exists).
- `ouyangbo/EvoScientist-WebUI` has **no releases and no tags** (404 from
`releases/latest` — must be treated as "never published", not an error).
- Local `pyproject.toml` is already `0.2.2` → release baseline lags behind the
code; Part 1 must cut a fresh release first or `has_update` can never fire.
- Asset/attachment URLs returned by the API use the `git.foksai.com:8443` host
(instance's HTTPS port); the download allowlist must compare host, ignoring port
or allowing `:8443` explicitly.
Non-goal: **automatic in-place upgrade**. EvoScientist runs from source
(Python venv + Next.js build), so sub2api's atomic-binary-swap does not
translate. We download + verify + present the install command; the operator
applies it.
## Decisions
| Question | Decision |
|---|---|
| What counts as "latest"? | `releases/latest`; if 404, fall back to max semver tag via `/tags?limit=20` |
| Which repo/component? | `ouyangbo/EvoScientist` (backend) only; WebUI deferred until it starts tagging |
| Current version source | `importlib.metadata.version("EvoScientist")` (pyproject is the single source) |
| Release creation | Local `scripts/release.sh` + Gitea API token; Gitea Actions later (instance capability unverified) |
| Auto-apply | No — download to staging dir, show install command |
Env config (backend):
- `EVOSCIENTIST_UPDATE_BASE_URL` — default `https://git.foksai.com`
- `EVOSCIENTIST_UPDATE_REPO` — default `ouyangbo/EvoScientist`
- `EVOSCIENTIST_UPDATE_CHECK_DISABLED` — set to `1` to disable outbound checks
- `GITEA_TOKEN` — only needed by `release.sh` (writes), not by the checker
---
## Part 1 — Publishing new versions (`EvoScientist` repo)
**Task 1.1: `scripts/release.sh`**
Inputs: `scripts/release.sh 0.2.3 "Release notes text"` (version arg required;
script refuses if it doesn't differ from pyproject).
Steps:
1. `sed` bump `version = "X.Y.Z"` in `pyproject.toml`; `uv lock` (if lockfile
tracks version); commit `chore(release): vX.Y.Z`; `git tag vX.Y.Z`;
`git push origin main --tags`.
2. `uv build` → `dist/EvoScientist-X.Y.Z.tar.gz`, `dist/evoscientist-X.Y.Z-*.whl`.
3. `shasum -a 256 dist/* > dist/checksums.txt`.
4. Create release via API:
`POST $BASE/api/v1/repos/ouyangbo/EvoScientist/releases`
with `{tag_name, name: "Release vX.Y.Z", body}`; header
`Authorization: token $GITEA_TOKEN`.
5. Upload each asset:
`POST /repos/.../releases/{id}/assets?name=<fname>` (binary body).
6. Verify: `GET releases/latest` returns the new tag.
**Task 1.2: cut baseline release v0.2.2** — run the script once so the checker
has a meaningful comparison target.
**Task 1.3 (deferred): Gitea Actions** — probe
`GET /api/v1/repos/ouyangbo/EvoScientist/actions/tasks` (or instance admin)
to see if Actions is enabled; if yes, port `.github/workflows/build.yml` into
`.gitea/workflows/release.yml` triggered on `push: tags: ["v*"]` doing steps
2–5. Not required for v1.
---
## Part 2 — Update check
**Task 2.1: backend module `EvoScientist/update_check.py`** (new file)
```python
class UpdateInfo(TypedDict):
current_version: str
latest_version: str
has_update: bool
release_url: str | None
release_notes: str | None
published_at: str | None
cached: bool
warning: str | None
def get_update_info(*, force: bool = False) -> UpdateInfo
```
- Current version: `importlib.metadata.version("EvoScientist")`, fallback
`"0.0.0-dev"` on `PackageNotFoundError`.
- Latest: `GET {base}/api/v1/repos/{repo}/releases/latest` (10s timeout).
- 404 → `GET {base}/api/v1/repos/{repo}/tags?limit=20`, take max semver.
- Other network/HTTP error → return last cached value with `warning` set;
if no cache, `latest = current`, `has_update = False`, `warning` set.
- Version compare: strip leading `v`, split `.`, int-compare 3 segments
(mirror sub2api `compareVersions`, `update_service.go:641`); non-numeric
segments compare as 0.
- Cache: module-level `{"info": ..., "fetched_at": ...}`, TTL 1200s; `force`
bypasses. Mirror sub2api TTL (`update_service.go:32`).
- Disabled switch: `EVOSCIENTIST_UPDATE_CHECK_DISABLED=1` → immediately return
current==latest, no network.
- HTTP client: `httpx` (already a dependency).
**Task 2.2: backend route** in `EvoScientist/langgraph_dev/http.py` (alongside
the existing `/internal/workspace-scopes/*` routes, ~line 878):
- `GET /internal/system/version?force=true` → JSON from `get_update_info`.
**Task 2.3: WebUI BFF** `src/app/api/system/version/route.ts` (new)
- `GET` proxies to `{backend}/internal/system/version`, forwards `force`
query param, returns backend JSON; on backend failure → 502 with the
existing error-shape used by other BFF routes (mirror
`src/app/api/skills/route.ts`).
**Task 2.4: WebUI component `src/app/components/VersionBadge.tsx`** (new)
Modeled on sub2api `VersionBadge.vue` and our `ErrorLogBell.tsx`:
- Button showing `v{current_version}`; when `has_update`: amber background +
ping-dot indicator.
- Dropdown: current version, latest version, "View release notes" link
(`release_url`, opens new tab), refresh button (force=true), and the
download section from Part 3.
- SWR over `/api/system/version`, fetch on mount + on dropdown open; no
aggressive polling (server caches 20 min anyway).
- Mount in `src/app/page.tsx` header next to `<ErrorLogBell />`.
- All strings English.
---
## Part 3 — Update download
**Task 3.1: backend download** — extend `update_check.py`:
```python
def download_update(version: str | None = None) -> DownloadResult
```
1. Resolve target release: `releases/latest` or
`GET /repos/{repo}/releases/tags/{version}` for a specific version.
2. Pick asset, in priority order:
a. `evoscientist-X.Y.Z-py3-none-any.whl` (from `assets[].name`,
`browser_download_url`)
b. sdist `EvoScientist-X.Y.Z.tar.gz`
c. `tarball_url` fallback
3. **Host allowlist**: parsed asset URL host must equal the update base URL's
host (allow port `:8443`); reject everything else (SSRF guard, mirror
`validateDownloadURL` at `update_service.go:447`).
4. Stream-download with 200 MB cap to
`{workspace}/.evoscientist/updates/v{X.Y.Z}/` (create dir; `workspace` =
`EVOSCIENTIST_WORKSPACE_DIR`).
5. If the release has a `checksums.txt` asset, download it and verify sha256
of the chosen file; fail hard on mismatch, delete the file.
6. Return `{version, file, path, suggested_command}` where
`suggested_command = f"uv pip install {path} # then restart the backend"`.
- Route: `POST /internal/system/version/download`, body `{"version": optional}`,
timeout 120s.
**Task 3.2: WebUI** — BFF `src/app/api/system/version/download/route.ts`
(POST proxy), and in `VersionBadge` dropdown a "Download update" button
(disabled while running; shows spinner, then success path + copyable install
command; `errorToast` on failure, mirroring ErrorLogBell patterns).
---
## Tests
Backend (`EvoScientist/tests/` or colocated, pytest):
- semver compare: `v0.1.19 < 0.2.2`, equal, malformed segments
- 404 on `releases/latest` → falls back to tags → still 404 → latest=current
- cache: second call within TTL does not hit network (mock httpx)
- `force=True` bypasses cache
- download: asset priority (wheel over sdist), host allowlist rejects
`evil.com`, size cap aborts, checksum mismatch deletes file
- disabled env var short-circuits
WebUI (vitest, node env — same as existing `route.test.ts` files):
- BFF GET proxies + forwards `force`
- BFF POST download proxies body
- Badge renders amber/dot only when `has_update`
## Rollout order
1. Part 1 script + baseline release v0.2.2 (validates the publishing path)
2. Task 2.1 + 2.2 (backend check) with tests
3. Task 2.3 + 2.4 (WebUI badge), browser-verify with playwright
4. Task 3.1 + 3.2 (download), verify a real v0.2.2 download end-to-end
## Open risks
- Gitea instance may require auth for API reads in the future → checker should
accept optional `EVOSCIENTIST_UPDATE_TOKEN` header from the start.
- Release URLs embed `:8443`; if the instance later moves to 443, allowlist
logic must not hard-code the port.
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const mocks = vi.hoisted(() => ({
requireActor: vi.fn(() => ({ sub: "tester", role: "admin" as const })),
configApiFetch: vi.fn(),
}));
vi.mock("server-only", () => ({}));
vi.mock("@/lib/server/actor", () => ({
requireActor: mocks.requireActor,
ActorError: class ActorError extends Error {
constructor(
message: string,
readonly status: number
) {
super(message);
}
},
}));
vi.mock("@/lib/server/evoscientistConfigClient", () => ({
configApiFetch: mocks.configApiFetch,
ConfigApiError: class ConfigApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly code: string | null = null,
readonly details: unknown[] = []
) {
super(message);
}
},
}));
const { POST } = await import("./route");
const RESULT = {
version: "0.2.2",
file: "evoscientist-0.2.2-py3-none-any.whl",
path: "/ws/.evoscientist/updates/v0.2.2/evoscientist-0.2.2-py3-none-any.whl",
suggested_command: "uv pip install /ws/.evoscientist/updates/v0.2.2/...",
};
function req(body?: unknown): NextRequest {
return new NextRequest(new URL("http://localhost/api/system/version/download"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
}
describe("POST /api/system/version/download", () => {
it("proxies an empty body when none is given", async () => {
mocks.configApiFetch.mockResolvedValue({ status: 200, body: RESULT });
const res = await POST(req());
expect(res.status).toBe(200);
expect(await res.json()).toEqual(RESULT);
expect(mocks.configApiFetch).toHaveBeenCalledWith(
expect.objectContaining({ sub: "tester" }),
"/internal/system/version/download",
{ method: "POST", body: {} }
);
});
it("forwards an explicit version", async () => {
mocks.configApiFetch.mockResolvedValue({ status: 200, body: RESULT });
await POST(req({ version: "0.2.2" }));
expect(mocks.configApiFetch).toHaveBeenCalledWith(
expect.anything(),
"/internal/system/version/download",
{ method: "POST", body: { version: "0.2.2" } }
);
});
});
@@ -0,0 +1,46 @@
import { type NextRequest, NextResponse } from "next/server";
import { requireActor, type Actor } from "@/lib/server/actor";
import { configApiFetch } from "@/lib/server/evoscientistConfigClient";
import { isCrossOrigin } from "@/lib/server/workspace";
import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export interface SystemVersionDownloadResult {
version: string;
file: string;
path: string;
suggested_command: string;
}
/** Stage a release artifact on the backend host; never auto-installs. */
export async function POST(request: NextRequest) {
let actor: Actor | undefined;
try {
if (isCrossOrigin(request)) {
return NextResponse.json(
{ code: "FORBIDDEN", message: "Cross-origin access is not allowed." },
{ status: 403, headers: NO_STORE }
);
}
actor = requireActor(request);
const body: unknown = await request.json().catch(() => null);
const version =
body && typeof body === "object" && "version" in body
? (body as { version: unknown }).version
: undefined;
const { body: result } =
await configApiFetch<SystemVersionDownloadResult>(
actor,
"/internal/system/version/download",
{
method: "POST",
body: typeof version === "string" ? { version } : {},
}
);
return NextResponse.json(result, { headers: NO_STORE });
} catch (error) {
return routeErrorResponse(error, actor);
}
}
+82
View File
@@ -0,0 +1,82 @@
import { describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const mocks = vi.hoisted(() => ({
requireActor: vi.fn(() => ({ sub: "tester", role: "admin" as const })),
configApiFetch: vi.fn(),
}));
vi.mock("server-only", () => ({}));
vi.mock("@/lib/server/actor", () => ({
requireActor: mocks.requireActor,
ActorError: class ActorError extends Error {
constructor(
message: string,
readonly status: number
) {
super(message);
}
},
}));
vi.mock("@/lib/server/evoscientistConfigClient", () => ({
configApiFetch: mocks.configApiFetch,
ConfigApiError: class ConfigApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly code: string | null = null,
readonly details: unknown[] = []
) {
super(message);
}
},
}));
const { GET } = await import("./route");
const { ConfigApiError } = await import("@/lib/server/evoscientistConfigClient");
const INFO = {
current_version: "0.2.2",
latest_version: "0.2.2",
has_update: false,
release_url: null,
release_notes: null,
published_at: null,
cached: false,
warning: null,
};
function req(url: string): NextRequest {
return new NextRequest(new URL(url, "http://localhost"));
}
describe("GET /api/system/version", () => {
it("proxies the backend version endpoint", async () => {
mocks.configApiFetch.mockResolvedValue({ status: 200, body: INFO });
const res = await GET(req("http://localhost/api/system/version"));
expect(res.status).toBe(200);
expect(await res.json()).toEqual(INFO);
expect(mocks.configApiFetch).toHaveBeenCalledWith(
expect.objectContaining({ sub: "tester" }),
"/internal/system/version"
);
});
it("forwards force=true", async () => {
mocks.configApiFetch.mockResolvedValue({ status: 200, body: INFO });
await GET(req("http://localhost/api/system/version?force=true"));
expect(mocks.configApiFetch).toHaveBeenCalledWith(
expect.anything(),
"/internal/system/version?force=true"
);
});
it("surfaces backend failures", async () => {
mocks.configApiFetch.mockRejectedValue(
new ConfigApiError("backend down", 502, "UPSTREAM")
);
const res = await GET(req("http://localhost/api/system/version"));
expect(res.status).toBe(502);
expect(await res.json()).toMatchObject({ code: "UPSTREAM" });
});
});
+43
View File
@@ -0,0 +1,43 @@
import { type NextRequest, NextResponse } from "next/server";
import { requireActor, type Actor } from "@/lib/server/actor";
import { configApiFetch } from "@/lib/server/evoscientistConfigClient";
import { isCrossOrigin } from "@/lib/server/workspace";
import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export interface SystemVersionInfo {
current_version: string;
latest_version: string;
has_update: boolean;
release_url: string | null;
release_notes: string | null;
published_at: string | null;
cached: boolean;
warning: string | null;
}
/** Installed vs latest published EvoScientist version. Any authenticated
* user may call this; the backend enforces the system:read scope. The
* backend caches upstream checks for 20 minutes; `?force=true` bypasses. */
export async function GET(request: NextRequest) {
let actor: Actor | undefined;
try {
if (isCrossOrigin(request)) {
return NextResponse.json(
{ code: "FORBIDDEN", message: "Cross-origin access is not allowed." },
{ status: 403, headers: NO_STORE }
);
}
actor = requireActor(request);
const force = request.nextUrl.searchParams.get("force") === "true";
const { body } = await configApiFetch<SystemVersionInfo>(
actor,
`/internal/system/version${force ? "?force=true" : ""}`
);
return NextResponse.json(body, { headers: NO_STORE });
} catch (error) {
return routeErrorResponse(error, actor);
}
}
+228
View File
@@ -0,0 +1,228 @@
"use client";
import { useState } from "react";
import useSWR from "swr";
import { Copy, Download, ExternalLink, RefreshCw, Tag } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { errorToast } from "@/lib/errorReporter";
import type { SystemVersionInfo } from "@/app/api/system/version/route";
import type { SystemVersionDownloadResult } from "@/app/api/system/version/download/route";
async function fetchVersion(url: string): Promise<SystemVersionInfo> {
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(
body && typeof body.message === "string"
? body.message
: `Version check failed (${res.status}).`
);
}
return (await res.json()) as SystemVersionInfo;
}
export function VersionBadge() {
const [open, setOpen] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [downloading, setDownloading] = useState(false);
const [downloaded, setDownloaded] =
useState<SystemVersionDownloadResult | null>(null);
const { data, error, mutate } = useSWR<SystemVersionInfo>(
"/api/system/version",
fetchVersion,
{ revalidateOnFocus: false, shouldRetryOnError: false }
);
const handleRefresh = async () => {
setRefreshing(true);
try {
const info = await fetchVersion("/api/system/version?force=true");
await mutate(info, { revalidate: false });
} catch (e) {
errorToast(
"version.refresh",
e instanceof Error ? e.message : "Couldn't check for updates."
);
} finally {
setRefreshing(false);
}
};
const handleDownload = async () => {
setDownloading(true);
try {
const res = await fetch("/api/system/version/download", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
const body = await res.json().catch(() => null);
if (!res.ok) {
throw new Error(
body && typeof body.message === "string"
? body.message
: body && typeof body.error === "string"
? body.error
: `Download failed (${res.status}).`
);
}
setDownloaded(body as SystemVersionDownloadResult);
} catch (e) {
errorToast(
"version.download",
e instanceof Error ? e.message : "Couldn't download the update."
);
} finally {
setDownloading(false);
}
};
const handleOpenChange = (next: boolean) => {
setOpen(next);
if (!next) setDownloaded(null);
};
const hasUpdate = data?.has_update === true;
return (
<DropdownMenu modal={false} open={open} onOpenChange={handleOpenChange}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
aria-label="Version"
title={data ? `EvoScientist v${data.current_version}` : "Version"}
className={`relative h-8 gap-1 px-2 text-xs ${
hasUpdate
? "bg-amber-500/15 text-amber-600 hover:bg-amber-500/25 dark:text-amber-400"
: "text-muted-foreground"
}`}
>
<Tag className="size-3.5" aria-hidden="true" />
{data ? `v${data.current_version}` : "…"}
{hasUpdate && (
<span className="absolute -right-0.5 -top-0.5 flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-amber-500 opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-amber-500" />
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 p-0">
<div className="border-b border-border px-3 py-2 text-sm font-medium">
Version
</div>
{error && !data ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
Couldn&apos;t load version info.
</p>
) : !data ? (
<p className="px-3 py-6 text-center text-sm text-muted-foreground">
Loading...
</p>
) : (
<div className="space-y-2 px-3 py-3 text-sm">
<div className="flex items-baseline justify-between gap-2">
<span className="text-muted-foreground">Installed</span>
<span className="font-mono">v{data.current_version}</span>
</div>
<div className="flex items-baseline justify-between gap-2">
<span className="text-muted-foreground">Latest</span>
<span
className={`font-mono ${
hasUpdate ? "font-medium text-amber-600 dark:text-amber-400" : ""
}`}
>
v{data.latest_version}
</span>
</div>
{hasUpdate && (
<p className="rounded-md bg-amber-500/10 px-2 py-1.5 text-xs text-amber-700 dark:text-amber-300">
A new version of EvoScientist is available.
</p>
)}
{data.warning && (
<p className="text-xs text-muted-foreground">
Last check failed: {data.warning}
</p>
)}
{hasUpdate && !downloaded && (
<Button
variant="outline"
size="sm"
className="w-full"
disabled={downloading}
onClick={() => void handleDownload()}
>
<Download
className={`mr-1 size-3.5 ${downloading ? "animate-bounce" : ""}`}
aria-hidden="true"
/>
{downloading
? "Downloading..."
: `Download v${data.latest_version}`}
</Button>
)}
{downloaded && (
<div className="rounded-md border border-border p-2 text-xs">
<p className="break-all font-mono text-muted-foreground">
{downloaded.path}
</p>
<div className="mt-1 flex items-start gap-1">
<code className="flex-1 break-all rounded bg-muted px-1.5 py-1">
{downloaded.suggested_command}
</code>
<Button
variant="ghost"
size="icon"
className="size-6 shrink-0"
aria-label="Copy install command"
title="Copy install command"
onClick={() =>
void navigator.clipboard.writeText(
downloaded.suggested_command
)
}
>
<Copy className="size-3.5" aria-hidden="true" />
</Button>
</div>
<p className="mt-1 text-muted-foreground">
Run this on the backend host, then restart it.
</p>
</div>
)}
</div>
)}
<div className="flex items-center gap-2 border-t border-border p-2">
{data?.release_url && (
<Button variant="ghost" size="sm" className="flex-1" asChild>
<a href={data.release_url} target="_blank" rel="noreferrer">
<ExternalLink className="mr-1 size-3.5" aria-hidden="true" />
Release notes
</a>
</Button>
)}
<Button
variant="ghost"
size="sm"
className="flex-1"
disabled={refreshing}
onClick={() => void handleRefresh()}
>
<RefreshCw
className={`mr-1 size-3.5 ${refreshing ? "animate-spin" : ""}`}
aria-hidden="true"
/>
{refreshing ? "Checking..." : "Check now"}
</Button>
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
+3 -1
View File
@@ -25,6 +25,7 @@ export const SCOPE_MODEL_CONFIG_WRITE = "model_config:write";
export const SCOPE_MODEL_CONFIG_TEST = "model_config:test";
export const SCOPE_MODEL_SELECT = "model:select";
export const SCOPE_RUN_CREATE = "run:create";
export const SCOPE_SYSTEM_READ = "system:read";
const ADMIN_SCOPES = [
SCOPE_MODEL_CONFIG_READ,
@@ -32,9 +33,10 @@ const ADMIN_SCOPES = [
SCOPE_MODEL_CONFIG_TEST,
SCOPE_MODEL_SELECT,
SCOPE_RUN_CREATE,
SCOPE_SYSTEM_READ,
] as const;
const USER_SCOPES = [SCOPE_MODEL_SELECT, SCOPE_RUN_CREATE] as const;
const USER_SCOPES = [SCOPE_MODEL_SELECT, SCOPE_RUN_CREATE, SCOPE_SYSTEM_READ] as const;
/** Fixed role → scopes mapping (design doc 7.2). */
export function scopesForRole(role: UserRole): string[] {