- Status: done
- Date: 2026-05-30
- Specs touched: docs/specs/BRAIN_UI_PROTOCOL.md, docs/specs/DASHBOARD.md
Added a read-only GET /api/v1/catalog/{id}/install-plan endpoint (Pattern A sync) that returns everything the install-consent screen needs before the user confirms an install: declared permissions, role-derived scope options, and per-folder per-scope source menus.
Code changes:
internal/api/install_plan.go— new file. DTO types:InstallPlanDTO,InstallPlanPermissions,InstallPlanFolder,FolderSources,SourceMenu(snake_case JSON tags;subfolder_defaultis omitempty). Pure builderbuildInstallPlan(man *manifest.Manifest, isAdmin bool) InstallPlanDTOdoes all mapping with no I/O. HandlerinstallPlanguards withauth.FromContext→ 401, then mapscatalog.Loaderrors:catalog.ErrNotFound→ 404 (no log — a normal client outcome, likegetApp); any other Load error (malformed manifest, missing compose) → 500 with a loudslog.Error(a curated-catalog integrity problem, not a missing app). Small unexported constssourceShared/sourcePersonalfor the source value strings. HelperscopeMenu(isAdmin bool)returns scope options and default: admin → (["household","personal"],"household"), member → (["personal"],"personal").internal/api/api.go— registeredGET /api/v1/catalog/{id}/install-plan(OperationIDget-install-plan) inregister(). Added a comment onresolveOwnerScopepointing atscopeMenuas the shared scope-authorization source so the read (menu) and write (enforcement) paths stay in sync.internal/catalog/catalog.go— new sentinelcatalog.ErrNotFound, returned fromLoadonly when the manifest dir/file is absent (errors.Is(err, fs.ErrNotExist)on the manifest read); parse failures and a missing compose file stay as plain wrapped errors. Lets the API discriminate 404 (missing) from 500 (malformed) instead of collapsing both. Same shape/rationale asstore.ErrNotFound.internal/api/install_plan_test.go— new file. 8 table cases covering: unauthenticated → 401; unknown id → 404; malformed manifest → 500 (guards the ErrNotFound discrimination); admin scope_options + default; member scope_options + default; no-folders manifest (flags false, empty devices/folders); folders-bearing manifest (mode/scope/subfolder_default + household/personal source menus); member still receives household source menu in folder rows (uniform shape). Fixtures are written into the harness's own catalog dir (h.catalogDir, exposed bynewHarness) and read lazily by the live server — no post-listen mutation of the running server's catalog field.web-ui/src/api.ts— addedSourceMenu,FolderSources,InstallPlanFolder,InstallPlanPermissions,InstallPlanTS types alongside existingCatalogEntry/Instance/Job. No view changes — the consent screen is slice 5.docs/specs/BRAIN_UI_PROTOCOL.md— addedGET /api/v1/catalog/:id/install-planto the Pattern A sync list and a full subsection documenting the response shape and its key properties.
Realizes the "read-only endpoint the consent screen calls before showing itself" from the slice 3 plan. The endpoint is Pattern A sync (BRAIN_UI_PROTOCOL.md): short read, no host call, no mutation. Auth guard and 404 shape follow the same conventions as getApp/listApps. The scopeMenu helper is the single source of truth for the install authorization table (DASHBOARD.md # install authorization) on the read path; resolveOwnerScope is the write-path mirror — a comment ties them together.
Option A per-scope source map chosen. Each folder carries sources.{household, personal} — both menus always populated regardless of caller role (household unreachable for members but uniform shape simplifies UI). Alternatives considered: Option B (single flat source_options array, UI filters by elected scope) was rejected because it requires UI-side policy derivation. Option A was the approved choice per the plan.
Structured fields only (no copy). The brain returns mode/scope/subfolder_default and source fields; the UI owns all wording. This matches the rest of the brain's data-not-sentences convention.
No audit. Pure read — CLAUDE.md: "Pure reads … don't audit."
go build ./... fails on the PAM cgo module (unrelated to this slice — a pre-existing CGo/RTLD_NEXT issue in the dev environment). go build ./internal/api/... succeeds cleanly; go test ./internal/api/... ./internal/catalog/... -race passes (8 install-plan cases + the rest of the api suite).
Code-review follow-up. A post-implementation review raised four points; resolved as: (1) Load errors no longer collapse to 404 — catalog.ErrNotFound splits 404 (missing) from 500 (malformed/compose-missing); (2) the failure log moved from slog.Info to slog.Error and only fires on the 500 path (a 404 is a normal client outcome, logged nowhere, like getApp); (3) declined — the TS scope_options: Scope[] was flagged as looser than the Go []string, but the narrower TS type is the better choice (gives the UI exhaustiveness checking; TS types never validate JSON at runtime regardless), and typing the Go side would ripple into store's untyped scope consts for no contract gain; (4) the test no longer mutates the live server's catalog field — fixtures write into h.catalogDir and are read lazily (go test -race was already clean here because the HTTP round-trip synchronizes, but the pattern was fragile).
- Slice 4 —
writeOverride+writeEnvenforce permissions:user:, folder bind mounts from elected source,group_addfor shared, devices, GPU,MALMO_FOLDER_*injection. CallsResolveHome(slice 2). Receives the user's elections fromPOST /api/v1/appsconfig. Decide whetherGET /v1/identity/well-knownbelongs here or can use hard-coded defaults. - Slice 5 — consent + config UI in
web-ui/src/views/StoreView.vueconsumingInstallPlanfrom this slice.