Ctrl K
ComunidadGuías…

web/admin/admin.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/* 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 <key>` --
 * 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 <key>` 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;
  }
}