Merge branch 'master' into feat/issue-638-webui-app-shell-phase1
Resolve conflict remediation for PR #818 (Closes #638) against master
caaae9b6. Two conflicts in webui/app.py, both resolved as unions since
the Phase 1 shell work (#638) and the merged master changes touch
disjoint concerns:
- Imports: keep the new webui.nav (NAV_GROUPS, STUB_PAGES) import from
#638 alongside master's expanded project_registry / project_views API
(ProjectRegistry, RegistryError, known_project_ids,
project_detail_to_dict, render_registry_error).
- Route table: keep master's read-only /api/console/security-model route
alongside #638's read-only Phase 1 stub routes (STUB_PAGES).
No behavior change beyond union; console stays read-only. docs/webui-local-dev.md
auto-merged. Full webui suite green (272 passed, 310 subtests).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
# Web console authorization, RBAC, redaction, and audit model (#633)
|
||||
|
||||
**Phase 1. Read-only. This document defines the model that future console
|
||||
writes must pass through; it enables none of them.**
|
||||
|
||||
The MVP deployment boundary ([`webui-deployment.md`](webui-deployment.md), #435)
|
||||
documents internal-only serving and states plainly that MVP authentication is
|
||||
*none* — protection comes from network placement. That is adequate while every
|
||||
route is a GET, and inadequate the moment a gated write ships. This document
|
||||
and the three modules it describes land **before** any write exists, so no
|
||||
Phase 2 action can be added without an authority to check it against.
|
||||
|
||||
| Concern | Module |
|
||||
|---------|--------|
|
||||
| Identity, roles, authorization decision | `webui/console_authz.py` |
|
||||
| Secret redaction for every surface | `webui/console_redaction.py` |
|
||||
| Audit event schema, retention, sink | `webui/console_audit.py` |
|
||||
| Machine-readable publication | `GET /api/console/security-model` |
|
||||
|
||||
Two invariants hold everywhere and are non-negotiable for every child of #631:
|
||||
|
||||
1. **No secrets reach the browser.** Credentials are resolved server-side and
|
||||
redacted before any payload, page, log line, or audit record leaves.
|
||||
2. **No ungated mutations.** Authorization is necessary but never sufficient;
|
||||
execution stays disabled until the Phase 2 framework ships.
|
||||
|
||||
## Identity sources
|
||||
|
||||
The console performs *authorization*. Authentication is delegated, because a
|
||||
console that mints its own sessions is a credential store, and this one must
|
||||
not be.
|
||||
|
||||
| Source | Mode value | Authenticated | Shared host | Phase |
|
||||
|--------|-----------|---------------|-------------|-------|
|
||||
| None | `none` (default) | No — anonymous, capped at `viewer` | No | 1 |
|
||||
| Local dev | `local-dev` / `local_dev` | Yes, **asserted not verified** | No | 1 |
|
||||
| Access proxy | `access-proxy` / `access_proxy` | Yes, asserted by trusted proxy | Yes | 2 |
|
||||
|
||||
Selected by `WEBUI_AUTH_MODE`. An unrecognised value falls back to `none`
|
||||
rather than erroring open.
|
||||
|
||||
**Access-proxy mode** reads the subject from the
|
||||
`Cf-Access-Authenticated-User-Email` header, set by Cloudflare Access, WARP, or
|
||||
an equivalent org portal that terminates authentication in front of the
|
||||
console. If the header is absent the request did not traverse the proxy, so the
|
||||
principal degrades to anonymous — it is never trusted by default.
|
||||
|
||||
The **role is always server-side configuration**, never a client assertion. It
|
||||
comes from `WEBUI_ROLE_MAP`, a JSON object of subject → role:
|
||||
|
||||
```json
|
||||
{"[email protected]": "operator", "[email protected]": "controller"}
|
||||
```
|
||||
|
||||
An unmapped subject gets `viewer`. Malformed JSON yields an empty map, so
|
||||
everyone gets `viewer` — a parse failure loses authority rather than granting
|
||||
it.
|
||||
|
||||
Full SSO is explicitly a non-goal of this issue.
|
||||
|
||||
## Role matrix
|
||||
|
||||
Four roles, ordered least to most authority. Each role inherits every lower
|
||||
role's actions; the table states the *minimum* rank required.
|
||||
|
||||
| Role | Authority |
|
||||
|------|-----------|
|
||||
| `viewer` | Read every console view. No write, ever, in any phase. |
|
||||
| `operator` | Viewer, plus author-class work: claim, comment, open a PR. |
|
||||
| `controller` | Operator, plus reviewer/merger-class decisions on a PR. |
|
||||
| `admin` | Controller, plus destructive and policy-editing actions. |
|
||||
|
||||
`viewer` holds the empty write set by construction, and a test asserts it stays
|
||||
empty.
|
||||
|
||||
## Privileged actions
|
||||
|
||||
Every console action maps to a `task_key` in `task_capability_map.py`, the same
|
||||
single source of truth `gitea_resolve_task_capability` and the MCP tool gates
|
||||
use. The console therefore cannot invent an authority the MCP layer does not
|
||||
already define, and a regression test asserts each mapping matches.
|
||||
|
||||
| Action | Minimum role | Class | MCP permission | Confirm | Dual control | Break-glass | Phase |
|
||||
|--------|--------------|-------|----------------|---------|--------------|-------------|-------|
|
||||
| `claim_issue` | operator | gated_write | `gitea.issue.comment` | Yes | No | No | 2 |
|
||||
| `comment_issue` | operator | gated_write | `gitea.issue.comment` | Yes | No | No | 2 |
|
||||
| `create_issue` | operator | gated_write | `gitea.issue.create` | Yes | No | No | 2 |
|
||||
| `comment_pr` | operator | gated_write | `gitea.pr.comment` | Yes | No | No | 2 |
|
||||
| `create_pr` | operator | gated_write | `gitea.pr.create` | Yes | No | No | 2 |
|
||||
| `review_pr` | controller | privileged | `gitea.pr.review` | Yes | No | No | 3 |
|
||||
| `close_pr` | controller | privileged | `gitea.pr.close` | Yes | No | No | 3 |
|
||||
| `merge_pr` | controller | privileged | `gitea.pr.merge` | Yes | **Yes** | **Yes** | 3 |
|
||||
| `delete_branch` | admin | destructive | `gitea.branch.delete` | Yes | **Yes** | **Yes** | 3 |
|
||||
|
||||
**Dual control** means the acting principal may not be the sole authority: a
|
||||
second distinct principal must confirm. **Break-glass** means the action is
|
||||
expected to be unavailable in normal operation and its use is retained for two
|
||||
years. Both are declared here and enforced by the Phase 2 framework; Phase 1
|
||||
records the requirement on every decision so the framework cannot ship without
|
||||
honouring it.
|
||||
|
||||
`delete_branch` is admin-only rather than controller because it is the one
|
||||
irreversible action in the set.
|
||||
|
||||
### Authorization decision
|
||||
|
||||
`authorize(action_id, principal, for_execution=False)` returns a decision
|
||||
record and **denies by default**. The deny reasons are closed and enumerated:
|
||||
|
||||
| Reason code | Meaning |
|
||||
|-------------|---------|
|
||||
| `unknown_action` | No such console action is registered. |
|
||||
| `unauthenticated` | The principal is anonymous. |
|
||||
| `unknown_role` | The role is not in the matrix. |
|
||||
| `insufficient_role` | The role ranks below the action's minimum. |
|
||||
| `phase_not_active` | Execution requested for an action whose phase is not open. |
|
||||
| `allowed_preview_only` | Authorized — preview only, execution still disabled. |
|
||||
|
||||
There is no implicit allow branch. Even the allow result reports
|
||||
`execution_enabled: false` while the console is in Phase 1, so no caller can
|
||||
read an allow as permission to mutate.
|
||||
|
||||
## Secret redaction
|
||||
|
||||
One pass applies to **API payloads, rendered HTML, server logs, and audit
|
||||
records** — the four surfaces where a credential could escape.
|
||||
|
||||
Redaction reuses `gitea_audit.redact` rather than forking it: that remains the
|
||||
authority for secret-looking dict keys, `Authorization` material, and raw URLs.
|
||||
The console layer then applies its own patterns:
|
||||
|
||||
Each rule below matches an *assignment form*: the named key, followed by `=` or
|
||||
`:`, followed by the value. The keys are listed bare rather than spelled out as
|
||||
complete assignments, because this document is itself scanned by
|
||||
`scan_for_secrets` — writing the examples in full assignment form would make the
|
||||
documentation trip the very detectors it documents.
|
||||
|
||||
| Rule | Catches (as an assignment) |
|
||||
|------|----------------------------|
|
||||
| `credential_assignment` | `token`, `password`, `passwd`, `secret`, `api_key`, `access_key`, `client_secret`, `private_key` |
|
||||
| `credential_env_assignment` | `GITEA_TOKEN`, `GITEA_PASS`, `GITEA_PASSWORD` and suffixed variants |
|
||||
| `keychain_reference` | `keychain:` entry references |
|
||||
| `keychain_command` | macOS `security` keychain lookups (`find-generic-password`, `find-internet-password`) |
|
||||
| `private_key_block` | PEM `BEGIN ... PRIVATE KEY` blocks |
|
||||
| `json_web_token` | Three-segment `eyJ...` JWTs |
|
||||
| `bearer_credential` | `Bearer` / `Basic` credentials |
|
||||
|
||||
Assignments keep the key and replace only the value, so an operator can still
|
||||
see *what* was removed. Two behaviours are deliberate:
|
||||
|
||||
- **Fail closed.** A value that cannot be redacted becomes `[REDACTED]`
|
||||
outright rather than being emitted raw. Redaction never raises.
|
||||
- **Redact before persist.** `console_audit.build_event` redacts before
|
||||
serialization, and `write_event` re-scans and **drops** any record that still
|
||||
trips a detector. An unredacted record is never durable.
|
||||
|
||||
`scan_for_secrets` is the assertion helper: it returns the detector names still
|
||||
matching a payload, and already-redacted hits are not findings. Tests use it to
|
||||
prove the published policy, the security-model endpoint, and this document
|
||||
itself carry no secret material.
|
||||
|
||||
## Audit event schema
|
||||
|
||||
`gitea_audit` records MCP-side *mutations* — which profile and Gitea user
|
||||
performed which tool call. It has no console actor, no identity source, no
|
||||
correlation identifier, and no retention class, and an authorization **denial**
|
||||
is not a mutation, so it would never appear there at all. The console record is
|
||||
additive, not a replacement: a Phase 2 action emits both, joined on
|
||||
`correlation.request_id`.
|
||||
|
||||
Required fields, all asserted by tests so an edit cannot quietly drop one:
|
||||
|
||||
| Field | Content |
|
||||
|-------|---------|
|
||||
| `schema_version` | Currently `1`. |
|
||||
| `event_id` | Unique per record. |
|
||||
| `timestamp` | Timezone-aware ISO-8601, UTC. |
|
||||
| `actor` | `subject`, `role`, `identity_source`, `authenticated`. |
|
||||
| `action` | Console action id. |
|
||||
| `action_class` | `gated_write`, `privileged`, `destructive`, or `unknown`. |
|
||||
| `target` | `{kind, ref}`, e.g. `{"kind": "pr", "ref": "#123"}`. |
|
||||
| `result` | `allowed`, `denied`, `previewed`, `failed`, `succeeded`. |
|
||||
| `reason_code` | The authorization reason code above. |
|
||||
| `correlation` | `request_id`, `session_id`, `mcp_task`, `mcp_permission`. |
|
||||
| `retention` | `class`, `days`, `expires_at`. |
|
||||
| `redacted` | Always `true`; records are redacted at build time. |
|
||||
|
||||
An unrecognised `result` degrades to `failed` rather than being stored
|
||||
verbatim.
|
||||
|
||||
The sink is an append-only JSON Lines file named by
|
||||
`WEBUI_CONSOLE_AUDIT_LOG`. It is **off by default**: with the variable unset,
|
||||
events are still built — so callers and tests exercise the schema — but nothing
|
||||
is written. Auditing never raises; a failed write returns `False` rather than
|
||||
breaking the request it describes.
|
||||
|
||||
## Retention
|
||||
|
||||
| Class | Applies to | Default |
|
||||
|-------|-----------|---------|
|
||||
| `standard` | Routine gated writes | 90 days |
|
||||
| `privileged` | `review_pr`, `close_pr`, and any unclassifiable action | 365 days |
|
||||
| `break_glass` | `merge_pr`, `delete_branch` | 730 days |
|
||||
|
||||
Each record carries its own class, day count, and computed `expires_at`, so
|
||||
retention is auditable per record rather than inferred from file age. An
|
||||
**unknown action is retained as privileged, not standard** — for a safety
|
||||
control the conservative direction is to keep the record longer.
|
||||
|
||||
Nothing in this module updates or deletes. Expiry is enforced by an
|
||||
operator-run policy against `expires_at`, never by the console silently
|
||||
rewriting its own history.
|
||||
|
||||
## Phase 2 integration
|
||||
|
||||
Phase 2 opens gated writes. It must reuse this model rather than introduce a
|
||||
second one. The integration points are already wired and observable:
|
||||
|
||||
- **`GET /api/actions/{action_id}/preview`** attaches an `authorization` block
|
||||
to the existing preview payload and records a `previewed` audit event.
|
||||
- **`POST /api/actions/{action_id}/attempt`** attaches the same block and
|
||||
records a `denied` event. The terminal outcome is unchanged — the MVP
|
||||
registry in `webui/gated_actions.py` still fails closed for every action — so
|
||||
Phase 1 cannot loosen anything. Phase 2 enforces on this same decision
|
||||
instead of adding a parallel check.
|
||||
- **`GET /api/console/security-model`** publishes the RBAC matrix, redaction
|
||||
policy, and audit policy as JSON for operators and tests.
|
||||
|
||||
To open Phase 2, a child issue must: raise `ACTIVE_PHASE`, implement the
|
||||
confirmation and dual-control flow the matrix already declares, emit a
|
||||
`succeeded` or `failed` record alongside the `gitea_audit` mutation record, and
|
||||
keep `viewer` unable to reach any of it. Turning on execution without the
|
||||
confirmation flow contradicts a declared requirement and is a review failure,
|
||||
not a shortcut.
|
||||
|
||||
## Local-dev mode
|
||||
|
||||
`WEBUI_AUTH_MODE=local-dev` reads the principal straight from the environment:
|
||||
|
||||
| Variable | Purpose |
|
||||
|----------|---------|
|
||||
| `WEBUI_DEV_SUBJECT` | Subject string; absent ⇒ anonymous |
|
||||
| `WEBUI_DEV_ROLE` | One of `viewer`, `operator`, `controller`, `admin`; unrecognised ⇒ `viewer` |
|
||||
|
||||
**INSECURE — this mode is for loopback development only.** The subject and role
|
||||
are *asserted by the developer running the process and verified by nothing*.
|
||||
Anyone able to set an environment variable on the host is an `admin`, and
|
||||
anyone able to reach the port inherits that principal. It provides no
|
||||
authentication whatsoever; it exists so Phase 2 authorization paths can be
|
||||
exercised without standing up a proxy.
|
||||
|
||||
Never enable local-dev mode on a non-loopback bind. Combining it with
|
||||
`WEBUI_ALLOW_PUBLIC_BIND=1` or `WEBUI_ALLOW_REMOTE_BIND=1` publishes an
|
||||
unauthenticated admin console.
|
||||
|
||||
For anything beyond a laptop use `access-proxy` mode behind Cloudflare Access,
|
||||
WARP, or a VPN, as [`webui-deployment.md`](webui-deployment.md) requires.
|
||||
|
||||
### Probe authentication
|
||||
|
||||
`WEBUI_REQUIRE_PROBE_AUTH=1` declares that non-public probes should require an
|
||||
authenticated principal. It is **opt-in**: the default is off so the MVP
|
||||
`/health` contract is unchanged.
|
||||
|
||||
**This flag is declarative in Phase 1 and enforces nothing today.**
|
||||
`console_authz.probe_auth_required()` reports the operator's intent, and no
|
||||
route consults it — setting the variable does not currently change the
|
||||
behaviour of `/health` or any other endpoint. It is published here so the Phase
|
||||
2 action framework has a declared policy to honour rather than inventing a
|
||||
second one, exactly as `ACTIVE_PHASE` gates execution while the matrix is
|
||||
already declared. A regression test pins this "declared, not enforced" status,
|
||||
so wiring it later is a deliberate change rather than a silent one.
|
||||
|
||||
Until Phase 2 wires it, probe protection rests on network placement alone, as
|
||||
[`webui-deployment.md`](webui-deployment.md) (#435) states.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `WEBUI_AUTH_MODE` | `none` | Identity source selection |
|
||||
| `WEBUI_DEV_SUBJECT` | unset | Local-dev subject (insecure) |
|
||||
| `WEBUI_DEV_ROLE` | `viewer` | Local-dev role (insecure) |
|
||||
| `WEBUI_ROLE_MAP` | unset | JSON subject → role map |
|
||||
| `WEBUI_REQUIRE_PROBE_AUTH` | unset | Require auth for non-public probes |
|
||||
| `WEBUI_CONSOLE_AUDIT_LOG` | unset | Append-only audit sink path |
|
||||
|
||||
All are read server-side only. None is ever rendered into a page or returned by
|
||||
an API.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No full SSO product; authentication stays delegated to the proxy.
|
||||
- No browser-initiated merges or approvals in any phase covered here.
|
||||
- No tokens in the frontend, in browser storage, or in committed config.
|
||||
@@ -7,7 +7,10 @@ only.
|
||||
## MVP deployment model
|
||||
|
||||
- **Default bind:** `127.0.0.1:8765` (`WEBUI_HOST` / `WEBUI_PORT`)
|
||||
- **Authentication:** none in MVP — protection comes from network placement
|
||||
- **Authentication:** none in MVP — protection comes from network placement.
|
||||
The authorization, RBAC, redaction, and audit model that future gated writes
|
||||
must pass through is defined in
|
||||
[`webui-authz-audit.md`](webui-authz-audit.md) (#633).
|
||||
- **Mutations:** read-only routes; gated write actions remain disabled (#434)
|
||||
- **Secrets:** resolved server-side via `gitea_auth` / `GITEA_MCP_CONFIG`; never
|
||||
embedded in HTML, JavaScript, or browser storage
|
||||
|
||||
@@ -43,6 +43,10 @@ for the console architecture: layer and authority boundaries, the redaction
|
||||
boundary, `/api/v1/...` versioning, the target page map, and the phase gates
|
||||
that govern when a write path may open (#632, epic #631).
|
||||
|
||||
See [webui-project-registry-api.md](webui-project-registry-api.md) for the
|
||||
versioned project registry contract: registry schema versions 1 and 2, project
|
||||
status, onboarding checklist state, and the fail-closed error payloads (#635).
|
||||
|
||||
## Routes (MVP)
|
||||
|
||||
| Path | Description |
|
||||
@@ -51,9 +55,11 @@ that govern when a write path may open (#632, epic #631).
|
||||
| `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`) |
|
||||
| `/queue` | Live PR and issue queue dashboard (#429) |
|
||||
| `/api/queue` | JSON queue export with pagination metadata |
|
||||
| `/projects` | Project registry list (#427) |
|
||||
| `/projects` | Project registry list with status and onboarding progress (#427, #635) |
|
||||
| `/projects/{id}` | Project detail + onboarding checklist |
|
||||
| `/api/projects` | JSON registry export |
|
||||
| `/api/v1/projects` | Versioned JSON registry export (#635) |
|
||||
| `/api/v1/projects/{id}` | Versioned JSON project detail (#635) |
|
||||
| `/api/projects` | JSON registry export — unversioned Phase 1 alias of `/api/v1/projects` |
|
||||
| `/prompts` | Prompt library with per-prompt copy buttons (#428) |
|
||||
| `/api/prompts` | JSON prompt export with workflow hashes |
|
||||
| `/runtime` | MCP runtime health and stale detection (#430) |
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
# Project registry API (#635)
|
||||
|
||||
Phase 1 of the [console architecture ADR](architecture/webui-control-plane-console-architecture-adr.md)
|
||||
gives the project registry a versioned, read-only API. This document is the
|
||||
field-by-field contract for that API and for the registry file behind it.
|
||||
|
||||
Everything here is **read-only**. The console never writes the registry; an
|
||||
operator edits the JSON file, and an invalid file fails closed rather than
|
||||
rendering a partial inventory.
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Method | Description |
|
||||
|-------|--------|-------------|
|
||||
| `/api/v1/projects` | GET | Versioned registry export: all projects, with provenance |
|
||||
| `/api/v1/projects/{project_id}` | GET | Single project; `404` with `project_not_found` when unknown |
|
||||
| `/api/projects` | GET | Unversioned MVP alias (#427), retained for all of Phase 1 |
|
||||
| `/projects` | GET | HTML list — status and onboarding progress per project |
|
||||
| `/projects/{project_id}` | GET | HTML detail — identity, profiles, paths, checklist |
|
||||
|
||||
Per ADR section 6 the unversioned alias may be retired no earlier than Phase 2,
|
||||
and only after this document and `webui-local-dev.md` record the swap. The alias
|
||||
returns the same payload as `/api/v1/projects`, including the legacy `version`
|
||||
and `source_path` keys #427 consumers already read.
|
||||
|
||||
The HTML views render from the same DTO the JSON routes serialize
|
||||
(`project_to_dict`), so the console and the API cannot disagree about a
|
||||
project's status or onboarding progress.
|
||||
|
||||
## Registry file
|
||||
|
||||
Default location: `webui/data/projects.registry.json`. Override with the
|
||||
`WEBUI_PROJECT_REGISTRY` environment variable.
|
||||
|
||||
Schema versions: **1** and **2** are accepted; **2** is current. A version 1
|
||||
file loads unchanged and is normalized with the documented defaults, so an
|
||||
existing operator registry keeps working without edits.
|
||||
|
||||
### Root
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `version` | int | yes | `1` or `2`. Anything else fails closed |
|
||||
| `projects` | array | yes | Must be non-empty |
|
||||
|
||||
### Project
|
||||
|
||||
| Field | Type | Required | Default | Notes |
|
||||
|-------|------|----------|---------|-------|
|
||||
| `id` | string | yes | — | Stable registry id used in URLs |
|
||||
| `repo_name` | string | yes | — | Gitea repository name |
|
||||
| `gitea_owner` | string | yes | — | Owning org or user |
|
||||
| `remote_host` | string | yes | — | Instance base URL, no credentials |
|
||||
| `remote_name` | string | no | `null` | Logical remote label, e.g. `prgs` (v2) |
|
||||
| `default_branch` | string | yes | — | Stable branch name |
|
||||
| `local_checkout_path` | string | yes | — | Control checkout path |
|
||||
| `status` | string | no | `active` | `active`, `onboarding`, `paused`, `archived` (v2) |
|
||||
| `profiles` | object | yes | — | Must map `author`, `reviewer`, `reconciler` |
|
||||
| `workflow_paths` | object | yes | — | Non-empty; label to repo-relative path |
|
||||
| `schema_paths` | object | no | `{}` | Label to repo-relative path |
|
||||
| `onboarding_checklist` | array | no | `[]` | See below |
|
||||
| `last_seen_health` | object | no | `null` | Redacted health only (v2) |
|
||||
|
||||
### Onboarding step
|
||||
|
||||
| Field | Type | Required | Default | Notes |
|
||||
|-------|------|----------|---------|-------|
|
||||
| `id` | string | yes | — | Stable step id |
|
||||
| `title` | string | yes | — | Short operator-facing label |
|
||||
| `description` | string | yes | — | Self-contained; assumes no chat history |
|
||||
| `state` | string | no | `pending` | `complete`, `pending`, `blocked`, `not_applicable` (v2) |
|
||||
| `required` | bool | no | `true` | Optional steps never block readiness (v2) |
|
||||
|
||||
### Last-seen health
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `status` | string | no (default `unknown`) | `healthy`, `degraded`, `unreachable`, `unknown` |
|
||||
| `checked_at` | string | no | ISO-8601 UTC timestamp, e.g. `2026-01-01T00:00:00Z` |
|
||||
| `detail` | string | no | Short redacted note |
|
||||
|
||||
Health is recorded metadata, not a live probe: Phase 1 performs no outbound
|
||||
health checks. Endpoints, tokens, and keychain identifiers must never appear
|
||||
here.
|
||||
|
||||
## Response shape
|
||||
|
||||
`GET /api/v1/projects`:
|
||||
|
||||
```json
|
||||
{
|
||||
"api_version": "v1",
|
||||
"schema_version": 2,
|
||||
"version": 2,
|
||||
"source_path": "/path/to/webui/data/projects.registry.json",
|
||||
"source": {
|
||||
"kind": "file",
|
||||
"path": "/path/to/webui/data/projects.registry.json",
|
||||
"inventory_complete": true
|
||||
},
|
||||
"project_count": 1,
|
||||
"projects": [
|
||||
{
|
||||
"id": "example",
|
||||
"repo_name": "Example",
|
||||
"gitea_owner": "Org",
|
||||
"repo_full_name": "Org/Example",
|
||||
"remote_host": "https://gitea.example.invalid",
|
||||
"remote_name": "example-remote",
|
||||
"default_branch": "main",
|
||||
"local_checkout_path": ".",
|
||||
"status": "active",
|
||||
"profiles": {"author": "...", "reviewer": "...", "reconciler": "..."},
|
||||
"workflow_paths": {"skill": "skills/..."},
|
||||
"schema_paths": {},
|
||||
"onboarding_checklist": [
|
||||
{
|
||||
"id": "profiles",
|
||||
"title": "Configure execution profiles",
|
||||
"description": "...",
|
||||
"state": "complete",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"onboarding_summary": {
|
||||
"total": 1,
|
||||
"complete": 1,
|
||||
"pending": 0,
|
||||
"blocked": 0,
|
||||
"not_applicable": 0,
|
||||
"required_outstanding": 0,
|
||||
"onboarding_complete": true
|
||||
},
|
||||
"last_seen_health": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`GET /api/v1/projects/{project_id}` returns `api_version`, `schema_version`,
|
||||
`source`, and a single `project` object with the same fields.
|
||||
|
||||
The `source` block satisfies the ADR section 6 provenance rule: every payload
|
||||
states where the data came from and whether the inventory is complete. A
|
||||
file-backed registry is always complete — there is no pagination to truncate it.
|
||||
|
||||
`onboarding_summary` is derived, never stored. `required_outstanding` counts
|
||||
steps that are `required` **and** in state `pending` or `blocked`;
|
||||
`onboarding_complete` is true when that count is zero.
|
||||
|
||||
## Fail-closed errors
|
||||
|
||||
Validation failures raise `RegistryError`, which routes render instead of a
|
||||
traceback.
|
||||
|
||||
`404` — unknown project id on `/api/v1/projects/{project_id}`:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "project_not_found",
|
||||
"project_id": "not-registered",
|
||||
"known_project_ids": ["example"],
|
||||
"remediation": "Request one of the known project ids, or add the project ...",
|
||||
"source": {"kind": "file", "path": "...", "inventory_complete": true}
|
||||
}
|
||||
```
|
||||
|
||||
`500` — invalid registry, on both the versioned route and the alias:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "registry_invalid",
|
||||
"detail": "unsupported registry version: 42",
|
||||
"remediation": "Set 'version' to one of 1, 2 (current schema is 2) ...",
|
||||
"field_path": "version",
|
||||
"source_path": "/path/to/registry.json"
|
||||
}
|
||||
```
|
||||
|
||||
`field_path` points at the offending location (`projects[0].profiles.reconciler`,
|
||||
`projects[0].onboarding_checklist[2].state`, and so on). The HTML routes render
|
||||
the same detail, field, source, and remediation on a "Project registry
|
||||
unavailable" page.
|
||||
|
||||
Conditions that fail closed:
|
||||
|
||||
* file missing or unreadable;
|
||||
* invalid JSON (the remediation names line and column);
|
||||
* root not an object, or `projects` missing/empty;
|
||||
* unsupported `version`;
|
||||
* a credential-shaped key anywhere in the file (`token`, `*_secret`, `auth_*`, and similar);
|
||||
* a project missing a required field, or missing an `author`/`reviewer`/`reconciler` profile;
|
||||
* an unknown `status`, onboarding `state`, or health `status`.
|
||||
|
||||
## Credential rule
|
||||
|
||||
The registry stores redacted metadata only. Credential-shaped keys are
|
||||
rejected at load time, before any DTO is built, consistent with
|
||||
[safety-model.md](safety-model.md) and
|
||||
[credential-isolation.md](credential-isolation.md). Tokens live in the keychain
|
||||
and are resolved server-side by `gitea_auth`.
|
||||
|
||||
## Migrating a version 1 registry
|
||||
|
||||
1. Set `"version": 2`.
|
||||
2. Optionally add `"status"` per project (omitted means `active`).
|
||||
3. Optionally add `"remote_name"` per project.
|
||||
4. Optionally add `"state"` and `"required"` to each onboarding step (omitted
|
||||
means `pending` and `true`).
|
||||
5. Optionally add `"last_seen_health"`.
|
||||
|
||||
No step is mandatory: a version 1 file keeps loading. Bumping the version only
|
||||
declares that the file may use the v2 fields.
|
||||
Reference in New Issue
Block a user