/** * Admin console plumbing. * * Server side (route handlers / server actions / layouts): cookie name + options, API base, token verification. * Client side: `adminFetch()` — every browser call goes to the same-origin proxy `/admin/api/` which adds the * admin token from the httpOnly cookie. The token never reaches the browser bundle. * * Path conventions for the proxy: `connectors`, `documents//raw`… map to `/api/admin/`; * a `v1/` prefix maps to the public API (`v1/events` → `/api/v1/events`). */ import type { ClaimDTO, ConnectorHealthDTO, DetectedChange, EventDTO, FacilityDetail, FacilitySummary, ProvenanceDTO, QualityFlagDTO, QualityOverview, SourceKind } from "@dci/core"; export const ADMIN_COOKIE = "dci_admin"; export const ADMIN_COOKIE_MAX_AGE = 60 * 60 * 12; // 12 h /** Base URL of the API as seen from the Next server (never exposed to the client). */ export function adminApiBase(): string { const base = process.env.API_URL_INTERNAL ?? process.env.NEXT_PUBLIC_API_URL ?? "http://127.0.0.1:8311"; return base.replace(/\/$/, ""); } /** Server-only: check a token against the API (GET /api/admin/ops). Never throws. */ export async function verifyAdminToken(token: string, timeoutMs = 8000): Promise<{ ok: boolean; status: number; error?: string }> { if (!token || token.length > 512) return { ok: false, status: 400, error: "empty token" }; const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), timeoutMs); try { const res = await fetch(`${adminApiBase()}/api/admin/ops`, { headers: { "x-dci-admin-token": token, accept: "application/json" }, cache: "no-store", signal: ctrl.signal }); if (res.ok) return { ok: true, status: res.status }; if (res.status === 401) return { ok: false, status: 401, error: "Invalid token" }; if (res.status === 503) return { ok: false, status: 503, error: "Admin API disabled (DCI_ADMIN_TOKEN not configured on the API)" }; return { ok: false, status: res.status, error: `API responded HTTP ${res.status}` }; } catch (e) { return { ok: false, status: 0, error: e instanceof Error && e.name === "AbortError" ? "API timeout" : "API unreachable" }; } finally { clearTimeout(timer); } } /* ------------------------------------------------------------------ client fetch */ export type AdminResult = | { ok: true; data: T; meta: Record | undefined; status: number } | { ok: false; data: null; meta?: undefined; error: string; details?: unknown; status: number }; export type AdminQuery = Record; export function adminQuery(q?: AdminQuery): string { if (!q) return ""; const sp = new URLSearchParams(); for (const [k, v] of Object.entries(q)) { if (v === undefined || v === null || v === "" || v === false) continue; sp.set(k, v === true ? "1" : String(v)); } const s = sp.toString(); return s ? `?${s}` : ""; } export function adminProxyUrl(path: string, q?: AdminQuery): string { return `/admin/api/${path.replace(/^\/+/, "")}${adminQuery(q)}`; } interface AdminFetchInit { method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; body?: unknown; query?: AdminQuery; signal?: AbortSignal; timeoutMs?: number; /** when true, a 401 redirects the browser to the login page (default true) */ redirectOn401?: boolean; } let redirecting = false; /** Browser-side JSON call through the admin proxy. NEVER throws. */ export async function adminFetch(path: string, init: AdminFetchInit = {}): Promise> { const url = adminProxyUrl(path, init.query); const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(new Error("timeout")), init.timeoutMs ?? 60_000); if (init.signal) init.signal.addEventListener("abort", () => ctrl.abort(init.signal?.reason), { once: true }); try { const res = await fetch(url, { method: init.method ?? "GET", headers: { accept: "application/json", ...(init.body !== undefined ? { "content-type": "application/json" } : {}) }, body: init.body !== undefined ? JSON.stringify(init.body) : undefined, signal: ctrl.signal, credentials: "same-origin", cache: "no-store", }); if (res.status === 401 && init.redirectOn401 !== false && typeof window !== "undefined" && !redirecting) { redirecting = true; const next = encodeURIComponent(window.location.pathname + window.location.search); // full navigation on purpose: the login page is server-rendered and client state must be dropped window.location.assign(new URL(`/admin/login?next=${next}&reason=expired`, window.location.origin).href); } const text = await res.text(); let json: unknown = null; try { json = text ? JSON.parse(text) : null; } catch { json = null; } if (!res.ok) { const err = (json as { error?: string; message?: string; details?: unknown } | null) ?? {}; return { ok: false, data: null, error: err.error ?? err.message ?? (text ? text.slice(0, 200) : `HTTP ${res.status}`), details: err.details, status: res.status }; } if (json && typeof json === "object" && "data" in (json as object)) { const env = json as { data: T; meta?: Record }; return { ok: true, data: env.data, meta: env.meta, status: res.status }; } return { ok: true, data: json as T, meta: undefined, status: res.status }; } catch (e) { const aborted = e instanceof Error && (e.name === "AbortError" || e.message === "timeout"); return { ok: false, data: null, error: aborted ? (init.signal?.aborted ? "aborted" : "timeout") : "network error", status: 0 }; } finally { clearTimeout(timer); } } /** Raw (non-JSON) proxy call — used for document bodies and YAML text. */ export async function adminFetchText(path: string, query?: AdminQuery, signal?: AbortSignal): Promise<{ ok: boolean; text: string; contentType: string | null; status: number; truncated: boolean; error?: string }> { try { const res = await fetch(adminProxyUrl(path, query), { credentials: "same-origin", cache: "no-store", signal }); const text = await res.text(); if (!res.ok) { let err = `HTTP ${res.status}`; try { err = (JSON.parse(text) as { error?: string }).error ?? err; } catch { /* keep */ } return { ok: false, text: "", contentType: null, status: res.status, truncated: false, error: err }; } return { ok: true, text, contentType: res.headers.get("content-type"), status: res.status, truncated: res.headers.get("x-truncated") === "1" }; } catch (e) { return { ok: false, text: "", contentType: null, status: 0, truncated: false, error: e instanceof Error ? e.message : "network error" }; } } /* ------------------------------------------------------------------ admin DTOs (mirror apps/api/src/routes/admin) */ export type { ConnectorHealthDTO }; export interface AdminRun { id: string; connectorId: string; task: string; startedAt: string | null; finishedAt: string | null; status: string; stats: Record; error: string | null; /** true when the connector was quarantined during the run (nothing published) */ quarantined?: boolean; log?: Array<{ t?: string; level?: string; msg?: string; [k: string]: unknown }>; } export interface ConnectorAdminDetail { health: ConnectorHealthDTO; config: Record; paused: boolean; lastError: string | null; runs: Array>; documentsByPageType: Array<{ pageType: string; count: number; changed: number; failed: number; quarantined: number }>; errorSamples: Array<{ id: string; url: string; error: string | null; statusCode: number | null; errorCount: number; lastChecked: string | null }>; createdAt: string | null; updatedAt: string | null; } export interface AdminDocument { id: string; connectorId: string; sourceId: string; url: string; canonicalUrl: string; pageType: string; classifier: string | null; fetchLevel: number; priority: number; contentHash: string | null; etag: string | null; lastModified: string | null; statusCode: number | null; contentType: string | null; sizeBytes: number | null; title: string | null; storageKey: string | null; extractorVersion: string | null; extractOk: boolean | null; extractCount: number; error: string | null; errorCount: number; firstSeen: string | null; lastFetched: string | null; lastChanged: string | null; lastChecked: string | null; nextCheck: string | null; changeFrequencyScore: number | null; fetchCount: number; changeCount: number; discoveredFrom: string | null; quarantined: boolean; entityRefs: Array<{ type: string; id: string }>; updatedAt: string | null; versionCount?: number; } export interface AdminDocVersion { id: string; documentId: string; contentHash: string; fetchedAt: string | null; fetchLevel: number; statusCode: number | null; sizeBytes: number | null; storageKey: string | null; significance: number; diffSummary: { added?: number; removed?: number; addedCount?: number; removedCount?: number; ratio?: number; [k: string]: unknown } | null; detectedChanges: DetectedChange[]; } export interface AdminDocumentDetail { document: AdminDocument; versions: AdminDocVersion[]; provenance: Array; entities: Array<{ type: string; id: string; slug: string | null; name: string | null }>; } export interface AdminVersionDiff { version: AdminDocVersion; previous: { id: string; contentHash: string; fetchedAt: string | null } | null; diffSummary: AdminDocVersion["diffSummary"]; detectedChanges: DetectedChange[]; } export interface MatchCandidate { key?: string; url?: string | null; name?: string | null; city?: string | null; address?: string | null; status?: string | null; countryIso2?: string | null; operatorName?: string | null; itCapacityMw?: number | null; totalPowerMw?: number | null; plannedPowerMw?: number | null; geo?: { lat?: number; lng?: number; precision?: string } | null; lat?: number | null; lng?: number | null; sourceId?: string | null; externalIds?: Record; createdFacilityId?: string | null; [k: string]: unknown; } /** One side of a duplicate pair (mirrors `Side` in apps/api/src/routes/admin/matches.ts). Empty id = candidate never persisted. */ export interface MatchSide { id: string; slug: string; name: string; operator: string | null; city: string | null; address: string | null; lat: number | null; lng: number | null; codes: string[]; externalIds: Record; sourceCount: number; status: string; recordScope: string; parentFacilityId: string | null; } export interface AdminMatch { id: string; connectorId: string; candidateKey: string; candidate: MatchCandidate; matchedFacilityId: string | null; score: number | null; reasons: string[]; status: string; decidedBy: string | null; decidedAt: string | null; createdAt: string | null; candidateFacilityId: string | null; matched: FacilitySummary | null; candidateFacility: FacilitySummary | null; pair?: { candidate: Partial | null; matched: MatchSide | null; distanceKm: number | null; sameCodes: string[] }; } /* -------- quality / data gaps / runs / trace (2026-09-12) */ export type { QualityFlagDTO, QualityOverview, ClaimDTO }; /** Worker `dataGaps()` — counters keyed by gap name (facilities_without_operator, projects_without_coordinates…). */ export type DataGaps = Record; export interface RunChanges { runId: string; provenance: Array; claims: ClaimDTO[]; events: EventDTO[]; documentVersions: Array<{ id: string; documentId: string; url: string | null; fetchedAt: string | null; significance: number; changes: number }>; counts: { provenance: number; claims: number; events: number; documentVersions: number }; } export interface RollbackResult { runId: string; claimsRejected: number; provenanceRetired: number; winnersRestored: number; eventsRejected: number; } /** Mirror of apps/worker/src/trace.ts TraceResult (proxied as GET /api/admin/documents/:id/trace). */ export interface TraceClaim { entityKey: string; field: string; value: number; unit: "MW" | "USD"; scope: string; scopeReason: string; semantics: string | null; evidence: { text: string; start: number; end: number } | null; } export interface TraceMatch { entityKey: string; how: string; matchedId: string | null; candidates: Array<{ id: string; name: string; operatorName: string | null; city: string | null; score: number; reasons: string[]; distanceKm: number | null }>; } export interface TraceResult { document: Record | null; fetch: { source: "archive" | "live"; status: number; contentType: string | null; bytes: number; storageKey: string | null; level: number; fetcher: string } | null; text: { title: string | null; length: number; excerpt: string; classification: { pageType: string; rule: string; eventType: string | null; mw: number[] } | null }; /** news announcement classification (class, figures with offsets, location…) — shape is connector-defined */ announcement: Record | null; records: Array<{ kind: string; key: string; certainty: number | null; pageType: string | null; data: Record; methods: Record }>; entities: Array>; validation: { total: number; valid: number; rejected: number; issues: ValidationIssue[] }; claims: TraceClaim[]; matches: TraceMatch[]; reconciliation: { created: number; updated: number; unchanged: number; merged: number; pendingMatches: number; rejected: number; events: number; provenanceRows: number; claims: number; qualityFlags: number; unscopedClaims: number; projectsVetoed: number; changes: unknown[]; refs: Array<{ type: string; id: string }> } | null; logs: string[]; error: string | null; } export interface QueueCounts { waiting?: number; active?: number; completed?: number; failed?: number; delayed?: number; paused?: number; prioritized?: number; } export interface OpsOverview { worker: unknown; queues: Record; budgets: { source: string; day?: string; limits: { scrapfly: number; firecrawl: number }; used?: Record; remaining?: { scrapfly: number; firecrawl: number }; [k: string]: unknown; }; db: { sizeBytes: number | null; sizePretty: string | null; tables: Array<{ table: string; rows: number; bytes: number | null }> }; clickhouse: { ok: boolean; url: string }; minio: { ok: boolean; endpoint: string; bucket: string }; redis: { ok: boolean }; apiCache: { entries: number; max: number }; alerts: Array<{ id: string; level: string; component: string; message: string; details: unknown; createdAt: string | null }>; lastRuns: AdminRun[]; } export interface SourceListItem { id: string; name: string; domain: string; kind: SourceKind; url: string | null; license: string | null; attribution: string | null; connectorId: string | null; priority: number | null; robotsAllowed: boolean | null; notes: string | null; documents: number; provenanceRows: number; facilities: number; lastDocumentAt: string | null; updatedAt: string | null; } /* -------- dev tool */ export interface DevtoolFetchResult { url: string; finalUrl: string; status: number; contentType: string | null; fetcher: string; level: number; credits: number; durationMs: number; bytes: number; error: { message?: string; code?: string; [k: string]: unknown } | null; attempts: unknown; title: string | null; description: string | null; published: string | null; classification: unknown; geo: unknown; address: unknown; jsonLd: unknown[]; embeddedJson: Array<{ id: string; preview: string }>; links: Array<{ href: string; text: string }>; html?: string; htmlTruncated?: boolean; text?: string; markdown?: string | null; } export interface ValidationIssue { entity?: string | number; key?: string; field?: string; path?: string; level?: string; severity?: string; message?: string; [k: string]: unknown; } export interface ValidationReport { total: number; valid: number; rejected: number; issues: ValidationIssue[]; } export interface DevtoolPreviewResult { fetch: DevtoolFetchResult; records: Array>; entities: Array>; validation: ValidationReport; logs: Array<{ level: string; msg: string }>; pageMeta?: unknown; } export interface ConfigError { path: string; message: string; } export type ValidateConfigResult = { ok: false; errors: ConfigError[] } | { ok: true; config: Record; warnings: string[] }; export interface SaveConfigResult { id: string; path: string; backup: string | null; parserVersion: string; enabled: boolean; } export interface RunSampleUrlResult { url: string; ok: boolean; fetch?: DevtoolFetchResult; error?: string; records?: Array>; entities?: Array>; validation?: ValidationReport; logs?: Array<{ level: string; msg: string }>; durationMs: number; } export interface RunSampleResult { results: RunSampleUrlResult[]; summary: { urls: number; ok: number; credits: number; fieldCoverage: Record }; } export interface ConfigListItem { file: string; id: string; name: string | null; enabled: boolean; parserVersion: string; kind: string | null; domain: string | null; valid: boolean; errors: string[]; } export interface FacilityPatch { name?: string; status?: string; facility_type?: string; lat?: number | null; lng?: number | null; geo_precision?: string; it_capacity_mw?: number | null; total_power_mw?: number | null; planned_power_mw?: number | null; mw_is_estimate?: boolean; opened_on?: string | null; operator_id?: string | null; is_ai?: boolean; is_hyperscale?: boolean; description?: string | null; note?: string; } export interface FacilityPatchResult { id: string; changed: Array<{ field: string; column: string; oldValue: unknown; newValue: unknown }>; facility: FacilityDetail | null; message?: string; } export type { EventDTO, FacilityDetail, FacilitySummary };