diff --git a/docs/superpowers/specs/2026-08-12-config-import-export-design.md b/docs/superpowers/specs/2026-08-12-config-import-export-design.md new file mode 100644 index 0000000..146b36c --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-config-import-export-design.md @@ -0,0 +1,224 @@ +# Model & Image Config Import/Export — Design + +Date: 2026-08-12 +Status: Approved (brainstorming 2026-08-12, approach A) + +## Goal + +Let an admin move the two whole-document configurations — the **model +registry** (大模型参数) and the **image-generation settings** (图像参数) — +between deployments via JSON files: export to a file, import from a file or +pasted JSON, with replace-or-merge semantics. + +Decisions locked in during brainstorming: + +- **Approach A:** backend adds export endpoints only; import reuses the + existing, already-validated PUT write paths, with merge computed on the + client. +- **Secrets included.** Export files contain plaintext credentials. This + deliberately breaks the backend's "APIs never return secrets" invariant — + contained to two new admin-scoped export routes. +- **Import modes:** both **replace** and **merge**, chosen by the user per + import. +- **No remote/URL import.** Local file upload + paste-JSON only; no + server-side fetching, no SSRF surface. +- **Two separate files**, exported/imported from their respective dialogs — + no combined whole-instance bundle. + +## Feasibility baseline (verified) + +- Both configs are whole-document GET/PUT: `GET/PUT /api/model-registry` + (`RegistryV4`, optimistic-locked by `expected_revision`) and + `GET/PUT /api/image-generation` (`ImageGenerationSettings`). +- Secrets exist server-side in plaintext: registry credentials in the + SQLite `credential_versions` table (`store.py:62`, + `resolve_credential()`), image `api_key` in the image-generation settings + store (`load_image_generation_settings`). +- Existing PUT semantics already help merge: blank image `api_key` keeps + the stored key (`http_api.py` "blank api_key keeps the stored key"); + registry credentials are only touched via explicit `credential_writes`. +- Both backend config families live in one Config API module + (`EvoScientist/EvoScientist/model_registry/http_api.py`). + +## File format + +Two independent `.json` files with an envelope: + +```json +{ + "kind": "evoscientist.model-registry", + "format_version": 1, + "exported_at": "2026-08-12T09:00:00Z", + "payload": { + "registry": { "...": "RegistryV4 document" }, + "credentials": [ + { "credential_id": "openai-main", "secret_value": "sk-..." } + ] + } +} +``` + +```json +{ + "kind": "evoscientist.image-generation", + "format_version": 1, + "exported_at": "2026-08-12T09:00:00Z", + "payload": { + "default_model": "dall-e-3", + "timeout_seconds": 60, + "models": [ + { "id": "...", "provider": "openai", "api_key": "sk-...", "...": "..." } + ] + } +} +``` + +`kind` acts as the file-type guard: importing a file whose `kind` does not +match the dialog's expected kind is rejected before any further validation. +`format_version` allows future evolution; only `1` is accepted. + +## Components + +### 1. Backend export routes (EvoScientist, Python) + +In `model_registry/http_api.py`, two new routes on the existing Config API: + +- `GET /api/model-registry/export` +- `GET /api/image-generation/export` + +Both require a **new scope `config:export`** (delegation JWT scope check, +same mechanism as `model_config:read` etc.; missing scope → 403 via the +existing section 9.5 error payload). + +- Registry export: current registry document plus, for every credential id + referenced by `credential_status`, the latest active `secret_value` from + `credential_versions`. +- Image export: the stored image-generation settings with plaintext + `api_key` values (the store holds them; only the public GET strips them). + +Response body is the envelope JSON described above. Filenames and download +mechanics are a client concern. + +### 2. BFF proxy routes (WebUI, Next.js) + +- `src/app/api/model-registry/export/route.ts` +- `src/app/api/image-generation/export/route.ts` + +Each mirrors the existing sibling routes exactly: `isCrossOrigin` guard → +`requireActor` → `requireAdmin` → `configApiFetch(actor, path)` → passthrough +JSON with `NO_STORE`. + +`src/lib/server/delegation.ts`: add `SCOPE_CONFIG_EXPORT = "config:export"` +to `ADMIN_SCOPES` only (regular users never receive it). + +No new BFF routes for import — the existing PUT routes are the import write +path. + +### 3. Frontend export + +In the model-config dialog (`RegistryEditor`) and the image-models dialog +(`ImageModelsEditor`), an "Export" button in the header area: + +- `fetch("/api/model-registry/export")` (or image equivalent) → `Blob` → + anchor download as `evoscientist-model-registry-.json` / + `evoscientist-image-generation-.json`. +- Static warning text next to the button (i18n): the file contains + plaintext secrets. + +### 4. Frontend import + +One shared `ConfigImportDialog` component, parameterized by `kind`: + +1. **Input:** file picker (`.json`) or a paste textarea; either fills the + same parse step. +2. **Client validation** (pure functions in `src/lib/configTransfer.ts`): + JSON parseable → envelope fields (`kind` exact match, `format_version: + 1`) → structural shape of `payload` (providers/models arrays with the + required fields per the existing `RegistryV4` / `ImageModel` mirrors in + `src/lib/modelRegistry.ts` / `src/lib/imageGeneration.ts`). Failures + render inline; no request is sent. +3. **Preview:** counts (N providers / M models, or M image models) and a + per-entry warning list for entries missing secrets (imported file has no + credential for a provider that requires one; image model with empty + `api_key` and no existing stored key). +4. **Mode choice:** radio "Replace" / "Merge" (i18n, with one-line + explanations). +5. **Write:** + - *Replace:* the imported payload becomes the PUT body directly — + registry: `PUT /api/model-registry` with the **current** + `expected_revision` (freshly GET-ted at import time) and + `credential_writes` from the imported credentials; image: + `PUT /api/image-generation` with the imported models as-is. + - *Merge:* client GETs the current config, merges by id — an imported + provider/model **replaces** the same-id entry wholesale, new ids are + **appended**, unmentioned entries are **kept** — then PUTs the merged + document. Credentials: imported secrets go to `credential_writes`; + providers kept but absent from the import keep their existing + credentials untouched. Image models whose imported `api_key` is empty + send `api_key: ""`, which the backend interprets as "keep stored key". +6. **Revision conflict** (`expected_revision` mismatch on PUT): show + "configuration changed by someone else" and offer reload — no silent + retry. + +Merge logic lives in `src/lib/configTransfer.ts` as pure, unit-tested +functions: `mergeRegistry(current, imported)`, `mergeImageSettings(current, +imported)`, `parseRegistryExport(text)`, `parseImageExport(text)`. + +### 5. i18n + +New keys under a `configTransfer` namespace in both zh/en catalogs +(updated in lockstep): buttons, warnings, import dialog copy (steps, mode +labels, errors, success toasts). + +## Data flow + +``` +Export: UI button → BFF GET /api/*/export (admin, config:export JWT) + → backend reads store + secrets → envelope JSON → file download + +Import: file/paste → client parse+validate → preview → mode choice + → [replace] payload + fresh expected_revision → existing PUT + → [merge] GET current → merge in client → existing PUT + → backend section 9.2 verification + rollback on failure (unchanged) +``` + +## Error handling + +- Malformed/wrong-kind/unsupported-version file: inline dialog error, no + network call. +- Backend PUT validation failure: surface the backend's section 9.5 message + in the dialog. +- Export endpoint for non-admin: 403 (scope enforced at backend; BFF also + gates with `requireAdmin`). +- Secrets-in-file warning shown at both export (static text) and import + (preview notes which secrets will be written). + +## Security posture + +- The plaintext-secrets surface is exactly two GET routes behind a new + admin-only scope; no other route changes its never-secrets behavior. +- Export files are sensitive: the UI says so at the point of download. + Storage/sharing hygiene is the operator's responsibility. +- No remote import → no SSRF. File size: client rejects files over 1 MB + (configs are tens of KB in practice). + +## Testing + +- **Backend (pytest):** export routes return registry/settings with + plaintext secrets; 403 without `config:export` scope; 401 without valid + delegation; empty-credential registry exports an empty `credentials` + array. Import paths need no new backend tests (existing PUT tests stand). +- **WebUI (vitest, node env):** `configTransfer.ts` pure functions — + envelope/kind/version validation failures; merge semantics (override by + id, append new, keep unmentioned, blank-key preservation); replace-mode + body construction. BFF export route tests following the existing + route-test pattern (actor/admin gating, proxying). +- **Manual:** export → wipe → import (replace) round-trip on a dev + deployment; merge behavior; zh/en copy; both dialogs. + +## Out of scope + +- Remote/URL import or export to a server. +- A combined whole-instance bundle (registry + image + other config). +- Secret encryption inside the export file. +- Import/export for other config areas (MCP, channels, system config).