Operating Husaria
Admin console
Admin console #
The console is the embedded operator UI mounted under /console when HUSARIA_ENABLE_CONSOLE=true. It's a Vite + React SPA built into the Go binary via //go:embed — no separate static-asset host, no separate deployment, the same binary serves both your GraphQL API and the operator interface.
Authentication #
The /console/api/* JSON API is gated by AdminSecretGinMiddleware:
- Secured mode — every request requires
X-Husaria-Admin-Secret(or the aliasX-Admin-Secret). - Open mode — middleware passes through; every caller is admin.
A GET /console/api/features payload advertises auth_mode: "open"|"secured" so the UI adjusts its login prompt accordingly.
In secured mode the SPA prompts for the admin secret on first load and stores it in sessionStorage for the session duration. The same secret is required to mint impersonation requests by setting X-Husaria-Role alongside it — that's how you preview what a regular user role sees from inside the console.
Open-mode warning pill #
When auth_mode === "open" the sidebar renders a sticky amber warning pill at the bottom (separate from the build-version chip at the top) — AlertTriangle icon, role="status", copy: "No admin secret configured. Every request is treated as admin." This was split out of the version chip so operators don't mistake the warning for a build label. The pill stays visible regardless of which tab is open.
Tabs #
The console ships eighteen tabs, each a single React component under internal/console/web/src/components/tabs/. In sidebar order: GraphQL, Data Browser, SQL Runner, Schema, API Explorer, Permissions, Actions, Delegations, Remote Schemas, Exposed Functions, REST Endpoints, Allow-list, Sources, Events, Migrations, Performance, Metadata, Settings. The Events tab is feature-gated behind HUSARIA_ENABLE_EVENTS and stays hidden when off.
Data + schema #
| Tab | What it does | Backend routes |
|---|---|---|
| GraphQL | Custom GraphiQL-style explorer. Query / mutation over HTTP, subscriptions over WebSocket via the vanilla graphql-transport-ws client (no graphql-ws npm dep). UI shows a "Subscribed — N events" chip + Stop button while a socket is open. | POST /console/api/graphql, GET /v1/graphql/ws |
| Data Browser | Browse, filter, and edit rows in any table. Multi-level filter builder (AND / OR groups, type-aware operators), FK pills (click to jump), composite-PK aware edit / delete, bounded row counts on tables up to 193k+ rows. | GET /tables, GET /table/:name/data, `POST |
| Schema | Table list + columns + Dagre-laid FK diagram with BFS focus on click. Includes a Reload Schema button — POSTs to a manual rebuild endpoint to pick up DDL applied outside Husaria (direct psql, foreign migration tooling) without a pod restart. | GET /schema, GET /schema/unindexed-fks, POST /schema/create-fk-index, GET /schema/relationships, POST /schema/reload |
| SQL Runner | Paginated SQL execution. Bounded COUNT(*) (capped at 50001) avoids full-table scans on huge tables. | POST /sql |
Configuration #
| Tab | What it does | Backend routes |
|---|---|---|
| Permissions | Role + per-table per-op permission editor. Saving an edit triggers a schema rebuild + RLS registry reload — changes are live without restart. | /permissions/roles, /roles aliases for role CRUD and table_permissions editing |
| Actions | Register / edit / delete webhook actions (see Actions). Talks to the separate /api/actions/* admin API — not /console/api/*. | /api/actions/* |
| Delegations | Manage REST-to-GraphQL bridges from OpenAPI 3 specs (see Delegations). Source CRUD, spec refresh, endpoint discovery + import dialog, per-endpoint cache TTL editor, role grants. After a successful import the panel auto-fires the schema reloader so the new delegated_* fields appear in GraphQL without a manual reload. Talks to /api/delegations/*. | /api/delegations/* |
| Remote Schemas | Stitch a remote GraphQL endpoint into the schema under a namespaced prefix (see Remote schemas). Per-source editor: name (the key, also used for delete), url, headers as a JSON object, forward_client_headers, and timeout_seconds (defaults to 30 server-side when 0). Per-schema role permissions are managed inline. | /api/remote-schemas/*, /api/remote-schemas/:name/permissions[/:role] |
| Exposed Functions | Declare a Postgres function for inclusion in the GraphQL schema (see Exposed functions). The natural key is (function_schema, function_name, target_table); function_schema defaults to public when blank. exposure picks the root the function attaches to (e.g. query_root). Backs computed fields and function-backed roots. | /api/exposed-functions/* |
| REST Endpoints | Map an HTTP method + URL template to a persisted GraphQL operation (see REST endpoints). (method, url_template) is the natural key — there is no id; delete uses those two query params. Stores the operation query, operation_name, and a description. | /api/rest-endpoints/* |
| Allow-list | Manage the query allow-list / persisted-query registry (see Allow-list). Create an entry from query text — the server hashes it on POST and returns the read-only hash; delete is by hash. Entries carry an optional name and role. Enforcement is gated separately by HUSARIA_ALLOWLIST_MODE / the allowlist_mode runtime setting. | GET /api/allowlist, POST /api/allowlist, DELETE /api/allowlist/:hash |
| Sources | Read-only operator aid listing registered data-source names for multi-source deployments (see Sources). Names only — DSNs are never exposed over the wire, and there is no create / delete from the console. | GET /api/sources |
| Migrations | Create / edit / apply / rollback migration rows tracked in husaria_data.migrations. Apply triggers a schema reload + RLS reload. Rollback keeps the row as pending for re-edit. | GET /migrations, POST /migrations, PUT /migrations/:id, POST /migrations/:id/apply, POST /migrations/:id/rollback |
| Metadata | Export / import / reload the native metadata document (the JSON snapshot of husaria_data.* — roles, permissions, runtime settings, actions). See Migrations for how this differs from DDL migrations. | GET /v1/metadata, POST /v1/metadata |
Observability #
| Tab | What it does | Backend routes |
|---|---|---|
| API Explorer | Surfaces the registered HTTP endpoints with per-route docs. | GET /endpoints |
| Events | Event-trigger management. Feature-flagged off by default (HUSARIA_ENABLE_EVENTS returns 503 when off). | /events/triggers/* (when enabled) |
| Performance | pg_stat_statements-backed top-N slow queries + explain. Requires the pg_stat_statements extension. | GET /performance/top, POST /performance/reset, POST /performance/explain |
| Settings | Authentication card (auth mode, JWT issuer / audience, JWKS URL + cache TTL when configured) + Optional features card (events flag, helper-server port, build version) + Runtime Settings editor. Both top cards read public env-sourced values from /console/api/features — no admin secret or JWT key material crosses the wire. | GET /features, GET /settings, PUT /settings, DELETE /settings/:key |
Reload Schema button #
The Schema tab's Reload Schema button is worth calling out separately. The same rebuild path that runs after a migration apply or a metadata reload is now operator-callable:
- Direct
psqladds / drops columns on a running deployment? Reload picks them up. - A foreign migration tool (Liquibase, Atlas, sqitch) applies DDL outside Husaria's migration system? Reload picks it up.
- A console-managed migration rolled back and you want to confirm the schema reflects the prior state? Reload re-runs introspection.
The button shows an inflight spinner while the rebuild runs and surfaces any error inline (e.g. introspection refused if pg_catalog access is somehow blocked).
Settings — auth + features cards #
Above the Runtime Settings editor the Settings tab shows two read-only cards built from /console/api/features:
- Authentication —
auth_mode(always shown). WhenHUSARIA_JWT_JWKS_URLis set, also listsjwks_url,jwks_cache_ttl(defaults to1h),jwt_issuer,jwt_audience. When unset, a single-line hint points atHUSARIA_JWT_JWKS_URLso operators know how to switch from PEM-on-disk to lazy JWKS fetch. - Optional features —
eventson/off (drives the Events tab),helper_port(the MCP helper port; omitted whenHUSARIA_HELPER_PORTis unset or0), and the buildversion. Each row notes the env var so operators know which knob to flip.
Both cards are intentionally read-only: every value is env-sourced and a process restart is needed to change it. No secret material crosses the wire — the JWKS URL is a public IDP endpoint by definition, iss / aud are public claims, the cache TTL is the local cache window, and the helper port is a network address. The resolved JWKS public-key bytes stay in the pod.
MCP + /llms.txt snippet block #
When the helper port is enabled, the Optional features card expands with copy-paste configs so operators don't have to look up the MCP wire format. Three snippets, each with a Copy button:
curlone-liner for/llms.txt— host and port baked in fromwindow.locationso a remote console gives you the right URL.- Claude Code MCP server config for
~/.claude.json(orclaude mcp addinteractively). - Cursor MCP server config for
~/.cursor/mcp.json.
The snippets adapt to auth mode: secured mode renders "X-Husaria-Admin-Secret": "<HUSARIA_ADMIN_SECRET>" with a hint to substitute the real value; open mode renders an inline comment ("open mode — no header needed") and adds an explainer that NetworkPolicy / sidecar restriction is the only fence in that configuration.
Copy uses navigator.clipboard with a 1.5-second "Copied!" affordance. On non-https origins where the clipboard API is blocked, the operator can still select + copy manually.
The Layout-level feature query refreshes on window focus and every 30 seconds, so a pod restart that flips HUSARIA_ENABLE_EVENTS or HUSARIA_HELPER_PORT surfaces in the Settings cards within half a minute — no hard refresh needed.
Read-only replicas #
When HUSARIA_READ_ONLY=true, the console UI still mounts and GET paths still work — you can browse data, view schema, inspect performance, review permissions. Write paths (POST/PUT/DELETE/PATCH on /console/api/*) return 503 with {error, read_only:true}. The GraphQL tab is operation-aware: read-only queries resolve, mutations get a 503. See Read-only replicas.