/* web/admin/admin.js * * Request layer for the admin panel. Phase 1 (read-only observability -- * see docs/admin-panel-architecture.md and the frozen phase-1 spec it * implements a slice of) made every call here a `GET /admin/*` route on * purpose: this panel started as a dashboard, not a tool * (docs/admin-panel-architecture.md §3.1 / the phase-1 spec's "What this * phase must not do"). Phase 4 adds the one deliberate exception -- * `resolveFeedback`, a real write -- because the whole point of the * triage queue is that a human resolves a flag; see that method's own * comment for why it is still safe under §3.1's "no free-form write * surface" spirit (it can only ever record a fixed, closed-set decision, * never touch domain knowledge or the guard's allowlist directly). * * Credentials: reuses web/js/apikey.js's per-analyst key storage * unchanged -- an admin key is just a key with the `admin` capability * (security.auth.Principal.is_admin), so there is no second credential * store here (the phase-1 spec is explicit about this). Every request * below routes through the single `_get` chokepoint, which is what * `tests/web_ui/run_admin_auth_boundary.mjs` drives directly under Node * to prove every admin call attaches `Authorization: Bearer ` -- * mirrors web/js/api.js's own `_fetchV2` chokepoint and the test that * covers it (tests/web_ui/run_auth_boundary.mjs). */ "use strict"; import { getApiKey } from "../js/apikey.js"; // Shared with web/js/api.js's own `_fetchV2` -- one function builds the // bilingual "unreachable host or CORS" hint for both pages, so a // deployment fixing this symptom on the analyst UI gets the identical // diagnosis (and the identical CORS_ALLOWED_ORIGINS line, naming THIS // page's own origin) on the admin panel too. import { describeTransportFailure } from "../js/api.js"; export class AdminApiError extends Error { /** `code` is "TRANSPORT_ERROR" (no response at all) or * "UNEXPECTED_PAYLOAD" (a response whose body is not JSON) when the * failure is one of those, else null -- web/js/diagnosis.js reads it to * tell a wrong address from an API that answered. */ constructor(message, status, code = null) { super(message); this.name = "AdminApiError"; this.status = status; this.code = code; } } /** A 401: no credential was presented/accepted at all. */ export class AdminUnauthorizedError extends AdminApiError { constructor(message) { super(message, 401); this.name = "AdminUnauthorizedError"; } } /** A 403: the credential is real, but it is not an admin key * (security.auth.Principal.is_admin is false -- api/errors.py's * ADMIN_REQUIRED). Distinguished from AdminUnauthorizedError so the panel * can say plainly "this key is not an admin key" instead of showing an * empty dashboard -- the phase-1 spec's own requirement. */ export class AdminForbiddenError extends AdminApiError { constructor(message) { super(message, 403); this.name = "AdminForbiddenError"; } } export class AdminApi { /** @param {string} baseUrl */ constructor(baseUrl) { this.baseUrl = baseUrl.replace(/\/+$/, ""); } /** GET /admin/summary -- scripts.analyze_audit_log.build_report's report. * Defaults to the aggregate-safe mode server-side; `includeExamples` * only ever opts IN to the verbatim-examples mode, never the reverse. */ async summary(includeExamples = false) { const qs = includeExamples ? "?include_examples=true" : ""; return this._get(`/admin/summary${qs}`); } /** GET /admin/health/checks -- scripts.verify_deployment's checks, run * now (server-cached; see api/admin_result_cache.py). `refresh` bypasses * that cache for this one call; `deep` also runs check_login_is_read_only * (a rolled-back CREATE TABLE attempt) and check_query_timeout (a * WAITFOR probe) -- both skipped by default (2026 warehouse-load audit, * Required item 2). Neither is ever set by the 30s auto-refresh. */ async healthChecks({ refresh = false, deep = false } = {}) { const qs = new URLSearchParams(); if (refresh) qs.set("refresh", "1"); if (deep) qs.set("deep", "1"); const query = qs.toString(); return this._get(`/admin/health/checks${query ? `?${query}` : ""}`); } /** GET /admin/cache -- the existing query-result cache statistics. */ async cache() { return this._get("/admin/cache"); } /** GET /admin/config -- which project_config/ files loaded, and how * many entries each yielded. Never file contents. */ async config() { return this._get("/admin/config"); } /* ── Admin panel phase 4: the wrong-answer feedback triage queue ──── * The one place this file's own "read-only, no method here changes * server state" rule (see module docstring) is deliberately no longer * true: resolveFeedback (below) is the panel's first write. Reachable * only by the operations/security capability * (api.auth.require_operations_or_security), same as every /admin * write route -- see api/admin_feedback_routes.py. */ /** GET /admin/feedback?status=... -- the triage queue, joined to each * flag's audit record (question, SQL, guard verdict, assumptions). */ async feedbackList(status) { const qs = status ? `?status=${encodeURIComponent(status)}` : ""; return this._get(`/admin/feedback${qs}`); } /** GET /admin/feedback/stats -- flag volume, outcomes and the golden * set's size/pending count (spec §5's "closing the loop visibly"). */ async feedbackStats() { return this._get("/admin/feedback/stats"); } /** POST /admin/feedback/{id}/resolve -- resolve one flag into exactly * one outcome (spec §3.1). Never creates or applies a configuration * version itself, and never writes to the golden set except through * the server's own promotion path for the "golden_case" outcome -- * this call only ever sends the admin's *decision*. */ async resolveFeedback(feedbackId, { outcome, note, configVersionId, tags }) { return this._post(`/admin/feedback/${encodeURIComponent(feedbackId)}/resolve`, { outcome, note: note || "", config_version_id: configVersionId ?? null, tags: tags || [], }); } /* ── ADR-004 part 1: "Request access" from a denied-column guard * rejection -- SECURITY only, both listing and acting (owner decision; * unlike the feedback triage queue above, which either admin role may * read/resolve). Approve goes through the existing ACL path * (appdb.key_store.update_denied_columns) server-side; this module * sends nothing but the decision. */ /** GET /admin/access-requests?status=... -- the queue, joined to each * request's audit record (the question only -- never the SQL or guard * detail the feedback queue's own join carries, since there is nothing * to triage here beyond "should this column open up"). */ async accessRequestsList(status) { const qs = status ? `?status=${encodeURIComponent(status)}` : ""; return this._get(`/admin/access-requests${qs}`); } /** POST /admin/access-requests/{id}/approve -- widens denied_columns on * every LIVE key the requester holds, through the server's existing ACL * path. Returns the resolved request row. */ async approveAccessRequest(requestId) { return this._post(`/admin/access-requests/${encodeURIComponent(requestId)}/approve`, {}); } /** POST /admin/access-requests/{id}/deny -- reason is required * non-blank server-side too; this call just forwards whatever the panel * collected. */ async denyAccessRequest(requestId, reason) { return this._post(`/admin/access-requests/${encodeURIComponent(requestId)}/deny`, { reason }); } /* ── Admin panel phase 6: the operational tier -- maintenance mode, * schema drift, vocabulary freshness, per-analyst usage, cache * controls, failed-auth visibility. Every write below (maintenance * toggle, vocabulary refresh, cache clear/invalidate) is recorded in * the admin-action trail server-side -- nothing here bypasses that. */ /** GET /admin/maintenance -- always reachable, regardless of state. */ async maintenanceState() { return this._get("/admin/maintenance"); } /** POST /admin/maintenance -- switch maintenance mode on or off. */ async setMaintenance(active, note) { return this._post("/admin/maintenance", { active, note: note || null }); } /** GET /admin/schema-drift -- read-only; never applies anything. * Server-cached (see api/admin_result_cache.py); `refresh` bypasses that * cache for this one call -- the panel's own refresh button, never the * 30s auto-refresh (2026 warehouse-load audit, Required item 1). */ async schemaDrift({ refresh = false } = {}) { const qs = refresh ? "?refresh=1" : ""; return this._get(`/admin/schema-drift${qs}`); } /** GET /admin/quality -- real-use quality and latency over the last * `days` days and all time (audit log + analyst confirmations and flags). * Read-only; server-cached because it reads the whole audit log, so the * panel's own refresh button passes `refresh` to bypass that cache for * one call and the 30s auto-refresh never does. */ async quality({ days = 7, refresh = false } = {}) { const qs = new URLSearchParams({ days: String(days) }); if (refresh) qs.set("refresh", "1"); return this._get(`/admin/quality?${qs.toString()}`); } /** GET /admin/vocabulary -- per prefetched column freshness/failure state. */ async vocabularyStatus() { return this._get("/admin/vocabulary"); } /** POST /admin/vocabulary/{table}/{column}/refresh -- an operator's own refresh. */ async vocabularyRefresh(table, column) { return this._post( `/admin/vocabulary/${encodeURIComponent(table)}/${encodeURIComponent(column)}/refresh`, {}, ); } /** GET /admin/usage -- per-principal queries/failures/latency/rate-limit hits. */ async usage(since, until) { const qs = new URLSearchParams(); if (since) qs.set("since", since); if (until) qs.set("until", until); const query = qs.toString(); return this._get(`/admin/usage${query ? `?${query}` : ""}`); } /** POST /admin/cache/clear -- the panel must show the pre-clear cost * BEFORE calling this (see main.js's confirm flow); this call still * echoes that same snapshot in its response. */ async cacheClear() { return this._post("/admin/cache/clear", {}); } /** POST /admin/cache/invalidate -- evict a single cached entry. */ async cacheInvalidate(question, mode) { return this._post("/admin/cache/invalidate", { question, mode: mode || "full" }); } /** GET /admin/security/auth-failures -- count and source breakdown. */ async authFailures(windowSeconds) { const qs = windowSeconds ? `?window_seconds=${encodeURIComponent(windowSeconds)}` : ""; return this._get(`/admin/security/auth-failures${qs}`); } /* ── Admin panel phase 7: key lifecycle and roles ────────────────── * The routes behind these have existed since phase 2 * (api/admin_write_routes.py) with no UI calling them, so issuing a key * or granting a role meant the CLI plus a server restart. These are the * panel's first writes that change *who can get in*, so each one is * capability-gated server-side -- operations for the key lifecycle, * security for anything that changes what a key can see * (docs/admin-panel-architecture.md §2's dividing rule) -- and the panel * never guesses at that gate client-side: it makes the call and reports * the server's own 403. */ /** GET /admin/keys -- every key row's metadata. Never a raw key: those * exist only in the response to the issue call that minted them. */ async listKeys() { return this._get("/admin/keys"); } /** POST /admin/keys -- mint a key. The response carries `raw_key`, the * ONLY time it is ever transmitted; the panel must show it once and * hold it nowhere. Note the server's restrictive default: the new key's * denied_columns is every column in schema.yaml, so it authenticates * but can read nothing until a security admin loosens it. */ async issueKey(principalId, name) { return this._post("/admin/keys", { principal_id: principalId, name }); } /** POST /admin/keys/{hash}/disable -- reversible (operations). */ async disableKey(keySha256) { return this._post(`/admin/keys/${encodeURIComponent(keySha256)}/disable`); } /** POST /admin/keys/{hash}/enable -- undoes a disable (operations). */ async enableKey(keySha256) { return this._post(`/admin/keys/${encodeURIComponent(keySha256)}/enable`); } /** POST /admin/keys/{hash}/revoke -- permanent (operations). The row is * tombstoned, never deleted, so restoring the application database to a * point before this call cannot silently un-revoke a leaked key. */ async revokeKey(keySha256) { return this._post(`/admin/keys/${encodeURIComponent(keySha256)}/revoke`); } /** PATCH /admin/keys/{hash}/acl -- the column ACL (security only). An * empty list means no column restriction at all. */ async updateKeyAcl(keySha256, deniedColumns) { return this._patch(`/admin/keys/${encodeURIComponent(keySha256)}/acl`, { denied_columns: deniedColumns, }); } /** GET /admin/roles/{capability} -- who holds it, from either source * (environment bootstrap or a database grant). Readable by either admin * role: seeing who holds a role is not itself a visibility change. */ async roleHolders(capability) { return this._get(`/admin/roles/${encodeURIComponent(capability)}`); } /** POST /admin/roles/{principalId} -- grant or revoke (security only). * The server refuses with 409 rather than removing the last holder of * a capability, so the panel surfaces that message as-is. */ async changeRole(principalId, capability, grant) { return this._post(`/admin/roles/${encodeURIComponent(principalId)}`, { capability, grant, }); } /** The one chokepoint every admin call above routes through. Always * attaches `Authorization: Bearer ` when a key is stored (never * omitted for an admin route -- unlike web/js/api.js's health(), there * is no unauthenticated admin route to degrade to); a call made with no * key stored is still attempted, so the server's real 401 (not a * client-side guess) is what tells the operator a key is needed. * * `_get`, `_post` and `_patch` are thin wrappers over this rather than * three copies of the same auth-and-error block. They were two copies * until phase 7 needed a third verb, at which point a third copy would * have meant three places for a 401/403 distinction to drift apart -- * and that distinction is load-bearing: the panel says "this key is not * an admin key" on one and "enter a key" on the other. */ async _request(method, path, jsonBody) { const headers = {}; const key = getApiKey(); if (key) headers["Authorization"] = `Bearer ${key}`; const init = { method, headers, signal: AbortSignal.timeout(20000) }; if (jsonBody !== undefined) { headers["Content-Type"] = "application/json"; init.body = JSON.stringify(jsonBody); } let res; try { res = await fetch(`${this.baseUrl}${path}`, init); } catch (err) { // No HTTP response at all -- unreachable host, or a CORS rejection // that presents identically ("Failed to fetch") from the page's // point of view. describeTransportFailure states both // possibilities and, when this call was cross-origin, names this // page's own origin and CORS_ALLOWED_ORIGINS explicitly instead of // leaving the operator to guess -- see its docstring for the real // deployment incident this exists for. throw new AdminApiError(describeTransportFailure(this.baseUrl, path, err), 0, "TRANSPORT_ERROR"); } if (res.status === 401) { const body = await _safeJson(res); throw new AdminUnauthorizedError( body?.error?.message || body?.detail || "Missing or invalid API key.", ); } if (res.status === 403) { const body = await _safeJson(res); throw new AdminForbiddenError( body?.error?.message || body?.detail || "This key does not have admin access.", ); } if (!res.ok) { const body = await _safeJson(res); throw new AdminApiError(body?.error?.message || body?.detail || `HTTP ${res.status}`, res.status); } try { return await res.json(); } catch { // A 2xx whose body is not JSON is not this API answering (a static // server handing back its index page, say). Typed so the panel can // ask whether the address is the API at all, once, instead of every // card showing the parser's own wording. throw new AdminApiError( "پاسخ سرور JSON نیست — احتمالاً این نشانی، نشانی API نیست.", res.status, "UNEXPECTED_PAYLOAD", ); } } async _get(path) { return this._request("GET", path); } async _post(path, jsonBody) { return this._request("POST", path, jsonBody ?? {}); } async _patch(path, jsonBody) { return this._request("PATCH", path, jsonBody ?? {}); } } async function _safeJson(res) { try { return await res.json(); } catch { return null; } }