Nova is the unified control-plane UI + API gateway for stardelt. A Rust (axum) backend serves the React SPA and proxies the Lakekeeper Iceberg REST catalog and the Trino query engine.
Browser
│
└─── :5173 (dev) / :8080 (prod)
│
├─ GET /api/health liveness + state-store status (never behind sign-in)
├─ GET /api/me current user + demo flag
├─ GET /api/auth/login start OIDC sign-in (oidc mode)
├─ GET /api/auth/callback OIDC redirect target (oidc mode)
├─ POST /api/auth/logout sign out here and at the IdP
├─ GET /api/trino/cluster → Trino /v1/info
├─ GET /api/catalog/catalogs the catalogs the caller may browse (?catalog= names one)
├─ GET /api/catalog/metrics what is in each of them: schemas, tables, rows, bytes, last change
├─ POST /api/catalog/metrics/refresh read every catalog again now (administrators)
├─ GET /api/catalog/* → Lakekeeper REST catalog, in the catalog ?catalog= names
├─ POST /api/catalog/namespaces create schema
├─ GET/PUT /api/catalog/tables/:ns/:tbl/documentation description, tags, owner, domain
├─ PUT /api/catalog/tables/:ns/:tbl/columns/:column/description one column's description
├─ POST /api/query → Trino query engine
├─ GET /api/warehouses the SQL compute classes; the one the caller uses
├─ GET /api/jobs recent activity
├─ GET /api/saved-queries the caller's saved queries (needs the state store)
├─ POST /api/saved-queries save one: {title, sql, warehouse?}
├─ DELETE /api/saved-queries/:id delete one of the caller's own
├─ GET /api/settings the caller's personal settings, one JSON object (needs the state store)
├─ PUT /api/settings/:key set one (body = any JSON value, at most 16 KiB)
├─ DELETE /api/settings/:key remove one
├─ GET /api/query-log the caller's queries (admins: everyone's), keyset-paged, one query per row
├─ GET /api/query-log/:id one query's detail: timing, tables read, error code
├─ GET /api/apps installed apps: live status + images
├─ GET /api/apps/:id one app: workloads, facts, links, embed check
├─ GET /api/workloads workloads in Nova's namespace + read state (ok/stale/unreachable)
├─ GET /api/admin/catalogs every Catalog and its grants (administrators only)
├─ PUT /api/admin/catalogs/:name/access change owner, managed access and grants (administrators only)
├─ GET /api/connections the caller's connections (needs the secret store)
├─ POST /api/connections create one: the values go to the store, never back
├─ PUT /api/connections/…/secret rotate fields of one connection
├─ DELETE /api/connections/… delete one connection
├─ POST /api/connections/group/:group/:name/break-glass emergency read (administrators only)
├─ GET /api/connections/break-glass-log who used break-glass and why (administrators only)
├─ GET /api/pipelines the pipelines of the caller's groups (administrators: all)
├─ GET /api/pipelines/principals each of the caller's groups and its pipeline principal
├─ POST /api/pipelines schedule a statement or a published notebook: a Nova-owned Pipeline
├─ POST /api/pipelines/check what the engine says about a statement (EXPLAIN, never runs it)
├─ POST /api/pipelines/preview-schedule the next fire times of a cron in its zone
├─ GET /api/pipelines/:namespace/:name one pipeline: latest runs, params, options
├─ PUT /api/pipelines/:namespace/:name change the statement or the notebook version, schedule, params and options
├─ DELETE /api/pipelines/:namespace/:name delete a Nova pipeline (kubectl/GitOps ones are 409)
├─ POST /api/pipelines/:namespace/:name/runs run now (409 paused/active/unloaded, 429 over 10 a minute)
├─ POST /api/pipelines/:namespace/:name/pause pause (idempotent, answers the fresh view)
├─ POST /api/pipelines/:namespace/:name/resume resume (idempotent, answers the fresh view)
├─ GET /api/pipelines/…/runs/:run_id one run with its task instances
├─ POST /api/pipelines/…/runs/:run_id/stop mark a run failed — not a cancel
├─ GET /api/pipelines/…/tasks/:task_id/logs one task's log, paged and capped
├─ GET /api/pipelines/…/tasks/:task_id/notebook the run's executed notebook, as the run wrote it
├─ POST /api/ingest/upload load CSV/JSON into a table (a failed load keeps the old table)
├─ POST /api/transfer copy table → table (a failed copy keeps the target)
│
└─ /* React SPA (static files)
Every non-2xx answer under /api/ is {"error": "..."} with
content-type: application/json — including a malformed or missing request body
(400/415/422) and an unknown path, which is 404 {"error":"not found"} rather than
the SPA's index.html. The 401 that starts sign-in keeps its detail/login fields.
Every response carries X-Content-Type-Options: nosniff, X-Frame-Options: SAMEORIGIN,
Content-Security-Policy: frame-ancestors 'self' and Referrer-Policy: same-origin;
/api/* responses additionally carry Cache-Control: no-store (static assets do not,
their names are hashed). Upstream failures answer one sentence — Trino's raw error
document, with its Java stack, stays in Nova's log at debug level.
GET /api/catalog/tables/:ns/:tbl/documentation reads a table's description, tags,
owner, domain and every column of the current schema, plus canEdit/editRule.
PUT on the same path applies any subset of {description, tags, owner, domain} in one
Lakekeeper commit; PUT …/columns/:column/description writes one column's description
through Trino.
The fields live in the catalog, so every engine sees them:
| Field | Where |
|---|---|
| description | Iceberg property comment (the REST set-properties path, one commit) |
| tags | custom property stardelt.tags (sorted, comma separated; at most 20, ^[a-z0-9][a-z0-9_-]{0,31}$) |
| owner | custom property stardelt.owner (a group name) |
| domain | custom property stardelt.domain (^[a-z0-9][a-z0-9_-]{0,39}$) |
| column description | the field's doc in a new Iceberg schema — only COMMENT ON COLUMN writes it, run as the caller with source nova-catalog |
A write body is a patch: an absent key changes nothing, null (or [], or a blank
description) removes the property, and a write with nothing to change sends no commit.
Descriptions are capped at 4,000 characters. Writes are serialised per table in the
process and a commit answered 500/503 is retried (three attempts), because Lakekeeper
0.12.2 deadlocks on concurrent commits to one table; a catalog that stays busy answers
502 {"error":"the catalog is busy, try again"}.
Interim rule, enforced by Nova and not yet by the catalog: an administrator may edit; a member of the owner group may edit; a table with no owner may be edited by anybody, but the first editor must set the owner to one of their own groups. Everyone else gets 403. Anyone who reaches Lakekeeper or Trino directly can still change these properties until the catalog enforces permissions. The lock covers one Nova replica; more than one replica needs a database lock (a later change). Demo mode answers 503.
The platform runs several catalogs (one Lakekeeper warehouse each, rendered into Trino as one catalog per warehouse). A person sees only the catalogs they may browse, and Nova does not decide that itself: it asks the engine.
GET /api/catalog/catalogs answers the caller's catalogs, each with its warehouse id,
— when it is one — its medallion layer, and who owns it and may use it, plus the default:
{"catalogs": [{"name": "bronze", "id": "3d0a…", "layer": "bronze"},
{"name": "finance", "id": "5e6c…",
"owner": "finance-team", "description": "the finance lakehouse",
"grants": [{"group": "analysts", "verb": "select"},
{"group": "engineers", "verb": "write"}]},
{"name": "warehouse", "id": "a1b2…"}],
"default": "warehouse"}owner is spec.owner of the catalog's Catalog resource and description its
spec.description (null for a catalog without one); grants is spec.grants in the
order it declares them — one group and one verb (browse, select, write, create,
manage) each, empty when there is none. The Catalog page shows them as one line — whose
data it is, which groups read and write it, and whom to ask for access — so a member never
has to open the administrator page. Access itself is granted by the company's sign-in
provider groups, not in Nova.
The order is bronze, silver, gold first (in that order), then every other catalog
alphabetically. The layer is the stardelt.io/layer label on the catalog's Catalog
resource, and when the label is absent the catalog's own name if it is one of the three;
labels are not sensitive, and a value that is not bronze, silver or gold is ignored.
The label list is read once and cached for 60 s, so a changed label shows up within a
minute; without a cluster connection only the name convention applies. layer is absent,
not null, for every other catalog. Owner and grants are read from the Catalog list on
every request (not cached, so a grant change shows at once); a list that fails leaves
them null/empty and never fails the catalog list.
Every /api/catalog/* route takes an optional ?catalog=<name>; omitted, the default is
used — the configured NOVA_WAREHOUSE_NAME when the caller may browse it, otherwise the
first catalog they may. What the list contains is what Trino's SHOW CATALOGS answers
for that person, through their own token (the platform's permission bridge filters it);
Nova only drops the engine's own catalogs — system, information_schema, memory,
tpch, tpcds, jmx. The answer is cached for 30 s per person, so a grant change is
visible within half a minute without a statement per click. Nova never proxies a catalog
outside that list:
| Request | Answer |
|---|---|
?catalog=finance, allowed |
the catalog's namespaces/tables |
?catalog=product, a catalog the caller may not browse |
403 {"error":"you cannot browse catalog 'product'"} |
?catalog=nope, no catalog by that name |
404 {"error":"catalog 'nope' not found"} |
?catalog=System, not a catalog name (1–40 chars, [a-z][a-z0-9]*) |
400 {"error":"catalog name must be lower-case letters and digits"} |
| no catalog is open to the caller and none was named | 403 {"error":"no catalog is open to you yet; ask an administrator for access"} |
Unreachable Trino or Lakekeeper stays 502; a configured default warehouse Lakekeeper does
not know is 404 on the catalog routes (503 on /api/catalog/warehouse, which names the
configured warehouse itself). In dev mode and in demo mode there is one catalog — the
configured warehouse — and no permission bridge, so every request works exactly as before.
Table documentation, history and rollback are scoped to the same catalog: the SQL they run
names "<catalog>".<ns>.<tbl>, and the per-table edit lock keys on all three.
In the UI the Catalog page and the SQL editor carry the choice: a switcher above the
table filter (and above the editor) lists the catalogs the API returned, writes
?catalog= into the URL, and every request the pages make names it. With one catalog — demo
mode, a single-warehouse install — the switcher is not shown at all. Generated SQL names the
chosen catalog (finance.seed.nation), so a statement keeps working after it is pasted
elsewhere; a catalog the person may not browse answers with the API's own refusal instead of
an empty list.
GET /api/catalog/metrics answers what is in the lakehouse for the catalogs the caller may
browse — the tile on the Overview page instead of a static "Format: Iceberg":
{"catalogs": [{"name": "finance", "schemas": 1, "tables": 4, "records": 1200,
"sizeBytes": 524288, "lastCommitMs": 1761000000000, "truncated": false,
"updatedAt": 1761000000000, "pending": false}],
"totals": {"catalogs": 1, "schemas": 1, "tables": 4, "records": 1200, "sizeBytes": 524288},
"generatedAt": 1761000001234}The list is the caller's own (GET /api/catalog/catalogs): a catalog they may not browse is
never read and never appears. Rows carry the same layer and the same order — bronze,
silver, gold first, then by name. The numbers come from each table's Iceberg metadata — the
current snapshot's total-records, total-files-size and timestamp-ms — so the endpoint
runs no query over the data. At most 200 tables per catalog are loaded: tables stays
exact, truncated: true says records and sizeBytes are a sample. A catalog whose scan
failed keeps its last numbers and carries an error sentence (zeros for one that never
answered) rather than failing the page; only an unreachable engine or catalog is 502, and
no catalog open to the caller is 403 with the sentence above. Demo mode derives the
numbers from its seeded tables.
The numbers describe the catalog, not the person, so Nova reads them in the background:
one round every five minutes (NOVA_METRICS_REFRESH_SECS), with Nova's own service token,
two catalogs at a time, after a ten-second wait at start-up. A request never scans — it
answers from the last numbers at once, however old they are, and updatedAt says when they
were read (the Overview's "Updated 4 min ago"). A catalog that has no numbers yet — it
appeared after the last round, or Nova just started — is listed with zeros, pending: true
and updatedAt: null, and one background scan reads it (two requests for the same new
catalog start one scan, and the page asks again every five seconds until it has numbers).
totals leave pending rows out: zeros are not numbers. A failed refresh keeps the last
good numbers, their updatedAt, and adds the sentence.
An administrator can ask for a round now — the Refresh button on the Overview catalogs
card — with POST /api/catalog/metrics/refresh: 202 {"accepted":true} and the round runs
in the background, one at a time, never on top of the five-minute round. At most one per
minute; a second call answers 429 {"error":"the numbers were refreshed less than a minute ago"}. A non-administrator answers 403 like every other operator route.
An administrator changes a catalog's owner, managed access and grants in Admin →
Catalogs: Edit on a row opens a dialog with the owner, the managed-access switch and
one line per group with the five verbs (browse, select, write, create, manage).
Save writes the change; removing every grant, or removing stardelt-users from
warehouse, asks for a second Confirm because those actions hide the catalog from
people who are not administrators.
The dialog writes a JSON merge patch on the Catalog resource (spec.owner,
spec.managedAccess and the whole spec.grants array), carrying the resourceVersion
the page read. A concurrent change is a 409 and the dialog says so instead of
overwriting; more than 30 changes per administrator in an hour is a 429. Nova never
writes Lakekeeper: the resource is the only state, and the access-sync job applies the new
spec to Lakekeeper every two minutes. The row says Applying… until the job reports
status.observedGeneration equal to the generation the save answered, then Applied, or
the sync's own verdict (a group that does not exist in the realm is UnknownGroup; it
grants nothing for that group).
The same change is one ordinary resource edit, so kubectl or Git works exactly as the dialog does:
apiVersion: stardelt.io/v1alpha1
kind: Catalog
metadata:
name: sandbox
namespace: stardelt
spec:
lakehouseRef:
name: default
owner: stardelt-finops
managedAccess: true
grants:
- group: stardelt-users
verb: browse
- group: stardelt-finops
verb: selectkubectl -n stardelt apply -f sandbox-catalog.yaml
# or a one-field change, the same merge patch the dialog sends:
kubectl -n stardelt patch catalog sandbox --type merge \
-p '{"spec":{"grants":[{"group":"stardelt-users","verb":"browse"},{"group":"stardelt-finops","verb":"select"}]}}'The resource stays the source of truth. A later GitOps source or kubectl apply of the
same resource overwrites a change made in the dialog; keep the manifest as the record, or
do not reconcile this resource from both places. Nova's ServiceAccount needs patch on
catalogs.stardelt.io for the dialog (part of the platform's nova-pipelines Role);
without a cluster connection the route answers 503 and demo mode changes memory.
GET /api/warehouses answers the SqlWarehouse objects of the platform namespace — the
compute classes the platform renders into Trino resource groups — largest class first
(large, medium, small, xs, name order inside one size):
[{"name": "large", "displayName": "Large", "size": "large", "running": 6, "queued": 70,
"memoryShare": "70%", "default": false, "canUse": true, "yours": true}]running, queued and memoryShare are the class defaults (xs 2/10/10 %, small
10/100/20 %, medium 5/50/40 %, large 6/70/70 %) with each field overridden by
spec.concurrency. canUse says one of the caller's groups is in spec.grants, matched by
the exact group name. yours is the first usable row — the class the engine's first matching
selector picks — or the one default: true class when nothing is granted. An administrator
gets one synthetic row first (admin / Administrators, 50 running, 100 queued, 100 % of the
memory, no size: the admin root group), with yours on it and false on every other row.
Nova is not the authority: the engine picks the class from the authenticated identity, so
bypassing Nova and calling Trino directly changes nothing, and a warehouse grant is scheduler
placement rather than an admission decision. The SQL editor shows
Warehouse: <displayName or name> (<size>) next to Run, and the Profile page shows the
same line as SQL warehouse; the title tooltip carries the three numbers. This is not
me.warehouse, which is the default catalog a query addresses. Without a cluster
connection (demo mode, a local run) the answer is the one small class. Nova's
ServiceAccount needs get/list on sqlwarehouses.stardelt.io (part of the platform's
nova-pipelines Role); a cluster that refuses or fails is a 502, never an empty list.
NOVA_DEMO=1 serves an in-memory lakehouse — a seeded Iceberg-shaped catalog
(nyc_taxi, analytics), a small SQL responder, and working load/transfer — so the
whole UI can be explored without Kubernetes:
make demo # backend :8080 + frontend :5173In this mode every operation mutates memory and is recorded as a job. Set
NOVA_DEMO unset to talk to real Trino and Lakekeeper.
stardelt-nova/
├── backend/ Rust crate (axum, tower-http, reqwest, sqlx)
│ ├── src/
│ │ ├── main.rs server setup, /api/health, /api/me, /api/trino
│ │ ├── api_json.rs `ApiJson` body extractor: JSON, never text, on rejection
│ │ ├── catalog.rs Lakekeeper proxy routes
│ │ ├── catalog_docs.rs table documentation: description, tags, owner, domain, column docs
│ │ ├── connection_routes.rs `/api/connections`: list, create, rotate, delete, break-glass
│ │ ├── connections.rs connection rules: validation, authorization, payloads
│ │ ├── secret_store.rs secret store client, signed in with the caller's own token
│ │ ├── db.rs optional PostgreSQL state store: pool, migrations, health
│ │ ├── saved_queries.rs /api/saved-queries (owner-scoped rows in the state store)
│ │ ├── user_settings.rs /api/settings (one JSON value per person and key)
│ │ └── trino.rs SQL query proxy
│ └── migrations/ numbered `NNNN_name.sql` files, embedded in the binary (sqlx)
├── frontend/ Vite + React + TypeScript + Tailwind SPA
│ └── public/ brand assets (logo.svg, logo-wordmark.svg)
├── image/
│ └── Dockerfile multi-stage build: node → rust → debian-slim
├── Cargo.toml workspace root (members: ["backend"])
└── Makefile dev / build / image targets
/apps lists every service Nova knows about with its live status and the images
its workloads actually run. /apps/:id opens one service: the workloads and
their replicas, deep links into the app's own UI, the URL Nova probes for
liveness, and facts read from the service's own API —
| App | Facts Nova reads |
|---|---|
| Trino | engine version, state, coordinator, uptime (/v1/info) |
| Lakekeeper | warehouse name + id, catalog version, bootstrap state, authorization backend, license, dependency health (/management/v1/*, /health) |
| Superset | health, version (/health) |
| Airflow | per-component health: metadata database, scheduler, triggerer, DAG processor (/health) |
| JupyterHub | hub/proxy/scheduler workloads and their replicas — the hub API needs a token, so Nova reads no fact from it |
Every fact names its source and is omitted when the service does not answer: the page shows what Nova observed, never a guess.
Nova frames Superset (/dashboards) and JupyterHub (/notebooks) in an iframe,
and both ship with headers that refuse framing by default — JupyterHub sends
content-security-policy: frame-ancestors 'none', Superset x-frame-options.
The browser enforces that client-side, so the naive result is a blank frame with
the reason hidden in the console.
/api/apps/:id therefore reports what the app itself says (embed.state:
allowed / blocked / unknown, with the header that blocks it), read from the
app's own response. Those pages show an “embeds ok” badge when framing is
permitted, and the offending header plus what to change when it is not — while
still offering the deep link out. unknown (demo mode, or a service Nova cannot
reach) makes no claim either way.
The header check follows redirects only while they stay on the app's own hosts:
a signed-out JupyterHub visitor is sent through the hub's login page (the same
headers as its pages) and then off to Keycloak, and judging Keycloak's login page
would report “blocked” for an app that is frameable. That off-host hop is
reported as embed.sign_in_host instead, and the Notebooks page turns it into an
explicit “open in a new tab and sign in” action — a deliberately unframeable
sign-in page must never look like a broken notebook.
Prerequisites: Rust stable, Node 20+, npm, Docker (for image builds).
# Clone and install frontend deps
git clone https://github.com/stardelt/stardelt-nova.git
cd stardelt-nova
npm --prefix frontend install
# Start both servers in one terminal
make dev| Service | URL |
|---|---|
| React SPA (Vite HMR) | http://localhost:5173 |
| Rust backend | http://localhost:8080 |
The Vite dev server is already configured to proxy /api/* → http://localhost:8080,
so the frontend can reach the backend transparently.
Useful targets:
make check # cargo check + TypeScript type-check (fast, no emit)
make fmt # cargo fmt + frontend lint
make build # release binary + production frontend bundle
make image # build container image locally (tag: ghcr.io/stardelt/nova:dev)npm --prefix frontend run e2e opens every page in a real browser (dark and light, desktop and phone width)
against the backend with no cluster behind it and checks the rules in the design note (accessible names,
contrast, page headings, readable errors, admin pages hidden from ordinary users, hostile names). Needs
cargo build, npm --prefix frontend run build and npx playwright install chromium once. A finding that already has
a ticket is listed in frontend/e2e/known.ts; a new finding fails the run. CI runs it as the job ui-conformance.
make image
# or
docker build -t ghcr.io/stardelt/nova:dev -f image/Dockerfile .The multi-stage image/Dockerfile produces a debian:bookworm-slim image
with the static frontend served directly by the backend — no separate web
server required.
The backend is configured entirely through environment variables:
| Variable | Default | Description |
|---|---|---|
NOVA_BIND_ADDR |
0.0.0.0:8080 |
TCP address the HTTP server listens on |
NOVA_INTERNAL_ADDR |
— (off) | Second, internal-only listener that receives the engine's query events and OpenLineage events (e.g. 0.0.0.0:8081). Opened only when NOVA_EVENTS_TOKEN is set too; with the address alone a warning is logged and no second port opens. The routes exist on this listener only — never on the public port, never through the Ingress |
NOVA_EVENTS_TOKEN |
— (required with the address) | Shared bearer token the engine presents on POST /internal/trino-events and POST /internal/lineage (env from the nova-events Secret). Wrong or missing token = 401; full queue = 503; accepted = 204. Compared in constant time and never logged |
NOVA_STATIC_DIR |
/app/static |
Directory containing the compiled frontend assets |
NOVA_TRINO_URL |
http://trino.stardelt.svc.cluster.local:8080 |
Trino coordinator base URL. The platform's installer points this at the coordinator's in-cluster HTTPS endpoint (https://trino.<ns>.svc.cluster.local:8443) once an IdP is installed — see NOVA_TRINO_CA_PEM |
NOVA_TRINO_CA_PEM |
— (empty) | PEM of a CA to add to the TLS roots the upstream client trusts (the platform's internal CA for the coordinator certificate, from the trino-ca Secret). Empty = only the public WebPKI roots; never a replacement, and verification is never disabled |
NOVA_LAKEKEEPER_URL |
http://lakekeeper.stardelt.svc.cluster.local:8181 |
Lakekeeper REST catalog base URL |
NOVA_CATALOG_TOKEN_URL |
— (empty) | The Keycloak token endpoint for Nova's own catalog client, e.g. https://id.<domain>/realms/stardelt/protocol/openid-connect/token. With the two below, every Lakekeeper call carries Authorization: Bearer <token>; unset, no header is sent (the catalog still answers allowall). All three NOVA_CATALOG_* variables or none — a partial set refuses to start rather than calling the catalog unauthenticated |
NOVA_CATALOG_CLIENT_ID |
— (required with the URL) | Confidential client id svc-catalog-nova (audience lakekeeper, stardelt-platform#282) |
NOVA_CATALOG_CLIENT_SECRET |
— (required with the id) | That client's secret, from the svc-catalog-nova Secret (key client-secret). Never logged, and no error or response ever carries it |
NOVA_WAREHOUSE_NAME |
warehouse |
The default catalog: the Lakekeeper warehouse the catalog routes work in when a request names none and the caller may browse it. /api/catalog/warehouse and the dashboard facts always describe this warehouse |
NOVA_METRICS_REFRESH_SECS |
300 |
How often the background refresher re-reads every catalog for GET /api/catalog/metrics (the Overview's numbers). Clamped to 30–3600; a value that is not a number means the default. The first round runs ten seconds after start-up, and a request never scans — it answers from the last numbers and says when they were read (updatedAt). Administrators can start a round with POST /api/catalog/metrics/refresh, at most once a minute |
NOVA_SUPERSET_URL |
http://superset:8088 |
Superset base URL (probe + embed check) |
NOVA_JUPYTERHUB_URL |
http://proxy-public:80 |
JupyterHub public proxy base URL (probe + embed check) |
NOVA_AIRFLOW_URL |
— (empty) | Airflow API server base URL, plain HTTP in-cluster (http://airflow-api-server:8080). Needs NOVA_AIRFLOW_USER and NOVA_AIRFLOW_PASSWORD too; unless all three are set the pipelines API is disabled and says so. Nova signs in once as that service user (built-in role User, enough to read, trigger, pause and mark a run failed), keeps the 24 h token until shortly before it expires, and decides itself who may see what — Airflow cannot scope the credential to owner groups |
NOVA_AIRFLOW_USER |
— (required with the URL) | The Airflow user Nova signs in as (nova-api) |
NOVA_AIRFLOW_PASSWORD |
— (required with the user) | That user's password, from a Kubernetes Secret. Never logged, never part of an error |
NOVA_JUPYTERHUB_API_URL |
— (empty) | JupyterHub's API root, in-cluster (http://hub:8081/hub/api). Needs NOVA_JUPYTERHUB_API_TOKEN too; unless both are set the workspace card on the Notebooks page answers unavailable and the page still renders. Nova is a hub service with read:servers, servers, read:users:activity and read:users:name — never access:servers, so it can read a server's state and start or stop it but never open it or run code in it |
NOVA_JUPYTERHUB_API_TOKEN |
— (required with the URL) | The hub service token for services.nova (chart-generated, Secret hub, key hub.services.nova.apiToken). Sent as Authorization: token …, held as an auth::Secret, never logged and never part of an error or a response |
NOVA_WORKSPACE_IDLE_SECONDS |
3600 |
How long a workspace may sit idle before the hub culls it; the card answers idleStopAt = lastActivity + this. Must equal the hub's cull.timeout (the install sets both from the same value). A value that is not a positive number means the default |
NOVA_S3_URL |
— (empty) | S3 endpoint of the versioned notebooks bucket, plain HTTP in-cluster (http://seaweedfs-s3.stardelt.svc.cluster.local:8333). Needs the three below; unless all four are set the notebook routes answer 503 {"error":"notebook storage is not configured"} and publishing is off. A partial set refuses to start |
NOVA_S3_ACCESS_KEY |
— (required with the URL) | Access key of the platform identity every object-store consumer already uses (Secret stardelt-s3-creds, key access-key) |
NOVA_S3_SECRET_KEY |
— (required with the URL) | That identity's secret key. Held as an auth::Secret; never logged, never part of an error or a response |
NOVA_S3_NOTEBOOK_BUCKET |
— (required with the URL) | Bucket for published notebooks (notebooks). Versioning must be enabled (make notebooks-bucket in stardelt-platform); a publish without a returned version is 502 rather than a Notebook that is not immutable |
POD_NAMESPACE |
stardelt |
Namespace whose workloads Nova reports (downward API in the manifests); NOVA_NAMESPACE overrides it for a local run |
NOVA_BASE_DOMAIN |
— (empty) | Base domain for the app deep-links (trino.<domain>, …). Empty = the UI links nothing; the install sets it from a ConfigMap |
NOVA_FORWARD_IDENTITY |
off | Send the caller's own token to Trino (Authorization: Bearer). Only useful once the platform configures the engine to validate it — the identity contract's phase 2. With it on, the engine can also filter SHOW CATALOGS per person, which is what Nova's catalog list reads (GET /api/catalog/catalogs); with it off the engine sees an unauthenticated request. Lakekeeper still sees Nova's own service token (above), never the caller's. /api/me reports whether forwarding is on (identity_forwarding) |
NOVA_QUERY_TIMEOUT_SECS |
300 |
How long POST /api/query waits for Trino before cancelling the query server-side and answering 400 {"error":"query exceeded the Ns limit and was cancelled"}. A client disconnect cancels the Trino query too. Results larger than 50,000 rows are cut to exactly that many and answered with "truncated": true |
NOVA_DATABASE_URL |
— (empty) | PostgreSQL URL of the state store (postgres://user:password@host:5432/nova). Unset = disabled: Nova behaves as before and /api/health answers {"status":"ok","db":"disabled"}. Set = connect and apply the migrations at start-up, or refuse to start; the platform fills it from the nova-pg-app Secret. After start-up an unreachable database only turns db into down (the probe still answers 200) |
NOVA_QUERY_LOG_RETENTION_DAYS |
90 |
How long query-log rows are kept; the backend purges hourly. 0 disables the purge; an unparsable value warns and uses the default |
NOVA_SHARED_ACCOUNTS |
— (empty) | Sign-in names whose account many people use — the public demo — comma-separated. /api/me answers shared_account: true for them (never in dev mode), so the UI shows the public-demo banner. Every night at 03:00 UTC Nova deletes their saved queries (with the shares), personal settings and query-log rows in one transaction; the log carries the row count, never a name. Unset or empty = no account is shared and nothing is reset |
NOVA_LINEAGE_RETENTION_DAYS |
90 |
How long lineage events are kept; the backend purges hourly. 0 disables the purge; an unparsable value warns and uses the default |
NOVA_LINEAGE_EDGE_RETENTION_DAYS |
365 |
How long lineage edges are kept; the backend purges hourly. 0 disables the purge; an unparsable value warns and uses the default |
NOVA_SECRET_STORE_URL |
— (empty) | Base URL of the platform's secret store (OpenBao), for example http://openbao.stardelt.svc.cluster.local:8200. Unset = disabled: features that need the store answer 503. Nova signs in with the caller's own token and sees only what the store's policies give that person; values are never logged |
NOVA_SECRET_STORE_ROLE |
platform-users |
The store's JWT sign-in role used for the caller's token |
NOVA_SECRET_STORE_BREAKGLASS_ROLE |
breakglass |
The store's JWT sign-in role for an administrator's break-glass read of a group connection: read on connections/group/* only, bound to azp = nova and the administrator group, 15-minute token. Declared and audited on the store side (stardelt-platform#225) |
NOVA_SECRET_STORE_MOUNT |
secret |
The KV v2 mount that holds connections/… |
NOVA_DEV_USER |
stardelt-dev |
Static user identity returned by /api/me (dev mode only) |
NOVA_PEER_STAGES |
— (empty) | Other stages this Nova may compare its deployed pipelines with, as name=url pairs, e.g. prod=https://nova.prod.example. Only configured names are accepted, so a browser can never point Nova at an arbitrary address. GET /api/stages/compare?peer=<name> calls the peer's GET /api/pipelines with the caller's own token, so the peer answers with that person's rights; a refused token is status: "unauthorized", no answer within 5 s status: "unreachable". Nova never deploys to a peer and stores no secret for one. /api/me lists the names (peers) |
NOVA_AUTH_MODE |
dev |
dev (no login) or oidc — see Sign-in |
RUST_LOG |
info |
Tracing filter for the backend (debug, info, warn, …) |
Every mutating request to the API gets one structured line on the audit tracing target —
actor, subject, how they authenticated, method, path, status:
INFO audit: mutating request actor="admin" sub="52f3331c…" via=session method=POST path=/query status=200
Reads are not recorded (they dwarf everything else and would bury the signal), and neither query strings nor request bodies are: the record is the action, not its content. Statement-level auditing belongs in Trino's query events, which see the SQL and are already configured for it.
Refusals land on the same audit target from the middleware that refused — a 403 names the caller and
the path — because the audit layer itself sits inside authentication (it needs the principal, and a
refused request never reaches it).
Route it separately from the application log:
RUST_LOG=audit=info,info
A connection is typed settings plus secret fields — a database password, an S3 key. Nova keeps it in
the platform's secret store, never in its own database, and a value is written and never read back:
no answer, error sentence or log line carries one, only the field names (secretKeys).
| Scope | Who may create, rotate, delete | Where the value lives | A Connection resource too |
|---|---|---|---|
personal |
the person, only their own | connections/user/<sub>/<name> |
no |
group |
a member of the owner group; an administrator who is not one may delete (incident) but never create or rotate | connections/group/<group>/<name> |
yes, the operator syncs it |
managed |
administrators only; nobody can read it | connections/managed/<name> |
yes |
GET /api/connections lists the caller's own connections, their groups', and — for an
administrator — every other group's (the store names the groups; an administrator's policy lists
every scope's metadata) and the managed ones. Every row carries member: true for a personal or
managed connection and for a group connection of one of the caller's groups, false for a group
row an administrator sees only because they are an administrator. false means read-only for that
person — no create, no rotation — except delete and break-glass.
Every route signs in to the store with the caller's own token (never a service identity), so the
store's policies decide what is possible and its audit log names the person. Without
NOVA_SECRET_STORE_URL the routes answer 503 {"error":"the secret store is not configured"}; without
a cluster client the group and managed routes answer 503 {"error":"no cluster connection"}. An
unreachable store or cluster is 502, never 500.
A rotation sends only the fields to change; a managed connection cannot be read, so it sends every
field. Deleting removes the value first and the resource second — if the second step fails the value is
gone and the entry stays visible as not-ready, which is retryable. Each create, rotation and delete
also writes a connection line on the audit target with the scope, the group, the name and the
person — never a value; a delete by an administrator who is not a member of the group also says
via=administrator.
Break-glass. In an emergency an administrator may read a group connection through
POST /api/connections/group/<group>/<name>/break-glass with {"reason": "…"} (20 to 500 characters).
The route signs in to the store's breakglass role with the administrator's own token, reads only
that group's path, revokes the 15-minute store token, and returns the fields once with
Cache-Control: no-store and Pragma: no-cache. Before answering it writes the notice
stardelt.io/break-glass (by, at, reason) on the Connection, which the group's Connections page
shows as breakGlass; a rotation clears it. Every attempt — also a refusal — writes an audit line and
a row in break_glass_read, which never has a column for a value. Personal and managed connections are
always refused (403 break-glass covers group connections only); only administrators may call it, and
at most five attempts per administrator in ten minutes (429). If the notice cannot be written, nothing
is returned (502) and the attempt is recorded as notice-failed. GET /api/connections/break-glass-log
returns the last 200 events (newest first) to administrators. Without a state store the log answers
503 the state store is not configured; the read itself needs only the secret store.
A pipeline is a Pipeline resource (stardelt.io/v1alpha1) plus what Airflow says about it. Nova
shows the pipelines whose spec.ownerGroup is one of the caller's groups; an administrator sees all,
and a pipeline without an ownerGroup is administrators-only. Whoever may see one may act on it — v1
has no read-only role — and a pipeline of another group answers 404, not 403.
Schedule in the SQL editor writes the resource; the loader turns it into a DAG. Nova validates what
the loader would refuse, so a pipeline it writes always becomes a DAG: the name (1 to 40 lower-case
letters, digits and dashes, starting with a letter), the statement (at most 20,000 characters, one
trailing ; dropped), its parameter table (at most 16 names of [a-z][a-z0-9_]{0,31}, values up to
256 characters), every ${…} placeholder the statement uses (run.date, run.id, or a
params.<name> the table defines — an unknown one would fail the run, so it is a 400), a five-field
cron no more often than every five minutes, and an IANA timezone. The optional options carry the
retries (0 to 5, default 1), the wait between them (1 to 60 minutes, default 5), the time limit (1 to
720 minutes, default 120) and the run mode (Forbid, the default, or Allow); an omitted field keeps
its default, so an older body writes what it always wrote.
The owner group must be one of the caller's own — an administrator schedules for the groups they
belong to, never on somebody else's behalf — and the group's principal must exist and be ready:
# one principal per group; the convention is `pipeline-<group>`
make sync-principal NAME=pipeline-analytics GROUP=analyticsGET /api/pipelines/principals answers every group of the caller with principal, ready and, when
not ready, a message — the Ready condition's, or the make sync-principal command for a group
without one. A create for a group without a ready principal answers 409 with that sentence; the
resource carries runAs: pipeline-<group> next to ownerGroup.
POST /api/pipelines also schedules a published notebook version instead of a statement
(stardelt-nova#237): send notebook: {"name", "objectVersion", "params"} and no sql. A body gives
exactly one of the two — both or neither is 400 give a statement or a notebook. The resource then
carries one task {"name": "run", "kind": "notebook", "config": {"notebook", "objectVersion", "params"}}, where params is a JSON object string with sorted keys: the shape the operator's
Pipeline CRD and the loader accept (operator#130). Everything else — runAs, the schedule, the
retries, the time limit, the run mode — is the statement shape's.
Nova validates what the CRD and the loader would refuse: the notebook file name
(^[A-Za-z0-9][A-Za-z0-9._ -]{0,127}\.ipynb$), at most 32 parameters named
[A-Za-z_][A-Za-z0-9_]{0,63} with values up to 256 characters, a params JSON text of at most 4096
characters — and that the pinned version exists in the store. That last check is an S3 HEAD:
400 that notebook version does not exist in group <g> for a version nobody published,
503 notebook storage is not configured without NOVA_S3_*, 502 when S3 does not answer. A
version is never a branch or a tag, and the loader reads <ownerGroup>/<notebook>, so a notebook of
another group can never be named (and a hidden notebook pipeline is a 404, like any other).
A PUT on a notebook pipeline changes the notebook it runs (file name, version and parameters),
its schedule and its options — never what it runs: a body with sql is 400 this pipeline runs a notebook, and a notebook body on a statement pipeline is 400 this pipeline runs a statement. The detail route carries the task's notebook,
objectVersion and notebookParams, so the edit dialog can prefill; the list route leaves them out
like sql.
Only a pipeline labelled app.kubernetes.io/managed-by: stardelt-nova may be changed or deleted: a
kubectl or GitOps manifest answers 409 this pipeline is managed outside Nova (kubectl or Git); change it there, so Nova never rewrites somebody's file. PUT changes spec.tasks[0].sql (a
notebook task's config instead), spec.schedule, spec.params and the options and nothing else —
the body describes the whole editable state, so an omitted schedule or params removes it and an
omitted option keeps its default — carrying the resourceVersion it read (a concurrent change is
409 the pipeline changed while you edited it: reload); a task shape other than one run task of
kind sql or notebook is 409. At most ten pipelines may be created per person per hour (429,
in memory). Airflow is not needed to create, change or delete one — the resource is the source of
truth and the loader removes a deleted DAG from its default list after about 90 seconds, while the
run history stays readable; the read routes keep answering 503 without Airflow. Each create, change
and delete writes one audit line with the statement's length and the cron — and, for a
notebook, the group, the file name and the pinned version — never the statement and never a
parameter value.
Two helpers belong to the Schedule dialog. POST /api/pipelines/check fills the placeholders and sends
EXPLAIN <statement> to the engine as the caller (never EXPLAIN ANALYZE, never the statement itself),
so the dialog can say whether the engine accepts it before anything is saved: {"checked": true, "ok": true}, {"checked": true, "ok": false, "message": "<engine sentence>"} for a refused statement,
{"checked": false} for a kind of statement that cannot be explained, and 502 when the engine does not
answer. POST /api/pipelines/preview-schedule answers the next fire times of a cron in its zone
({"runs": ["2026-10-21T03:00:00+02:00", …]}, count 1 to 10, default 5), following the same cron and
timezone rules as a create.
A notebook is published as an immutable object version in the versioned notebooks bucket, so a
schedule can pin exactly the bytes a person approved. The bucket and its versioning are created by
stardelt-platform (make notebooks-bucket); Nova only writes to it with the platform identity in
NOVA_S3_* (see Configuration), never with the caller's token.
PUT /api/notebooks/{group}/{name} body: the .ipynb (nbformat 4, at most 5 MiB)
GET /api/notebooks the caller's groups, latest version, newest first
GET /api/notebooks/{group}/{name}/versions at most 50 versions, newest first
GET /api/notebooks/{group}/{name}/parameters one version's parameters (?version=<id>, latest when omitted)
Publishing needs a signed-in person (browser session or Authorization: Bearer). {group} must be
one of the caller's groups (403 you are not a member of the group <g>); {name} must match
^[A-Za-z0-9][A-Za-z0-9._ -]{0,127}\.ipynb$; the body must be an nbformat 4 notebook with a cells
array. Cell outputs, execution_count and the notebook's widget metadata are removed before the
bytes are stored — a cell that printed os.environ leaves no token or secret in the object store.
The answer carries the new objectVersion, the sha256 of the stored bytes, the size and the
notebook's parameters (the name = literal lines of the first cell tagged parameters, at most 32,
defaults as text and never evaluated). A 61st publish by one person in an hour is 429. Every publish
writes one audit line with the group, the name, the version and the size — never content.
The key is built by Nova as {group}/{name}; the browser never supplies a path. Listing and versions
are owner-group scoped: an administrator sees their own groups only, because being a platform
administrator does not make someone a member of a tenant's group. Every list row carries the
immutable objectVersion, the size, the publisher's uploadedBy and uploadedAt as RFC 3339 in
UTC (2026-10-21T01:24:28.000Z) — the gateway's RFC 1123 Last-Modified is normalised, so the UI
parses one shape. Without NOVA_S3_URL (or any of the
other three NOVA_S3_* variables) the three routes answer
503 {"error":"notebook storage is not configured"}; an S3 gateway that is down or refuses answers
502, never 500. Answers are application/json with Cache-Control: no-store.
GET …/parameters?version=<id> answers the parameters of that exact version
({"parameters": [{"name": "region", "default": "eu"}, …], "objectVersion": "<id>"}); without
version it reads the latest and names it. The Schedule dialog uses it to offer the form the bytes
it will run declare. An unknown version is 404 no such notebook version — never an empty list a
schedule could pin.
The hub token already carries audience nova (the aud-nova mapper, stardelt-platform#290), which is
Nova's client id, so a notebook can call PUT /api/notebooks/... with the token it holds.
Nova is an OIDC client; the identity provider is either your own (Microsoft Entra ID, Okta, any standards-compliant OIDC provider) or the platform's Keycloak. The contract is Identity.
NOVA_AUTH_MODE=dev (the default) keeps the no-login behaviour that CI
and make demo use. NOVA_AUTH_MODE=oidc puts every /api/* route except
/api/health and /api/auth/* behind sign-in: a browser gets a 401 and the UI sends
it to /api/auth/login?return_to=<path> (authorization code + PKCE + state +
nonce); scripts send Authorization: Bearer <access token>. Any configuration
error in oidc mode makes Nova refuse to start; an unreachable IdP is a failed
sign-in, never a fallback to dev.
| Variable | Default | Description |
|---|---|---|
NOVA_OIDC_ISSUER |
— (required) | Issuer URL; discovery at /.well-known/openid-configuration must report the same issuer. Entra: https://login.microsoftonline.com/<tenant-id>/v2.0; Keycloak: https://id.<baseDomain>/realms/stardelt. https only (plain http only for localhost). |
NOVA_OIDC_CLIENT_ID |
— (required) | Registered (confidential) client id |
NOVA_OIDC_CLIENT_SECRET |
— (required) | Client secret |
NOVA_PUBLIC_URL |
— (required) | Nova's external origin, e.g. https://nova.example.com. Redirect URI is <NOVA_PUBLIC_URL>/api/auth/callback; post-logout redirect is <NOVA_PUBLIC_URL>/ — register both at the IdP. Cookies are Secure when this is https. |
NOVA_SESSION_SECRET |
— (required) | ≥ 32 bytes; signs the session cookie (openssl rand -base64 48) |
NOVA_OIDC_SCOPES |
openid profile email |
Must include openid |
NOVA_OIDC_GROUP_CLAIM |
groups |
Claim carrying group names (or Entra group object ids) |
NOVA_OIDC_ADMIN_GROUP |
stardelt-admins |
Group that makes a Nova admin; Keycloak's full path /stardelt-admins also matches. Set it to an empty value to turn the group mechanism off (nobody becomes an admin through a group). |
NOVA_OIDC_ADMIN_ROLE |
= admin group | Value in the roles claim (Entra app roles) that makes a Nova admin. Empty turns the role mechanism off. |
NOVA_OIDC_AUDIENCES |
client id | Comma-separated aud values accepted on bearer access tokens |
NOVA_OIDC_DEVICE_CLIENT_ID |
unset | The public client (device flow) a command line signs in with; GET /api/auth/config (no sign-in needed) tells it, with the issuer, to stardelt login |
Claims. sub is the identifier; display uses preferred_username → email →
upn → name. Admin precedence: the roles claim is checked against
NOVA_OIDC_ADMIN_ROLE first, then the group claim against NOVA_OIDC_ADMIN_GROUP;
either grants admin. When Entra reports a group overage (_claim_names.groups or
hasgroups), Nova logs a warning and treats the user as having no groups (it does
not call Graph) — use app roles or a group filter on the app registration. /api/me
returns claims, never tokens, and identity_forwarding: false: Nova still queries
Trino and Lakekeeper with its own service connection — and Lakekeeper with the
svc-catalog-nova service token, never the caller's. Catalog visibility is the one
question Nova asks with the caller's token (SHOW CATALOGS, see "Several catalogs"),
which needs NOVA_FORWARD_IDENTITY=1.
The person behind the request. In oidc mode /api/me adds three facts to the
claim fields, so the Profile page can explain the caller's role and warn before a
sign-in is needed. dev mode keeps exactly its old answer.
| Field | Meaning |
|---|---|
admin_group |
The configured value behind the caller's administrator decision: the group that matched (NOVA_OIDC_ADMIN_GROUP) or the role that was held (NOVA_OIDC_ADMIN_ROLE); for a member, the configured precedence (role, then group). null when neither is configured (both variables empty). |
admin_by |
"group" or "role": which of the two admin_group holds. null with it. |
session_expires_at |
Unix seconds at which the browser session can no longer be renewed — the session's absolute 8 h lifetime (a refresh never moves it) — so the person signs in again then. null for an Authorization: Bearer call (no session), and never the access token's own exp. |
Entra ID notes. Single-tenant issuer only (the common/organizations endpoints'
{tenantid} issuer template is not supported). For bearer access, set
accessTokenAcceptedVersion: 2 in the app manifest so access tokens carry the v2.0
issuer, and list the token's aud in NOVA_OIDC_AUDIENCES (e.g. api://<client-id>).
Tokens Entra issues for Microsoft Graph cannot be validated by anyone but Graph.
Token refresh. One sign-in outlives many access tokens. There is no timer and no background task: when a request finds the session's access token inside its refresh window, that request exchanges the refresh token at the IdP's token endpoint (the same client id, secret and auth method as the code exchange), stores the new tokens and re-derives the identity from the refreshed claims before serving anything. So
- a token that expires mid-session does not turn Trino queries into 401s — the request carrying it is served with a fresh one;
- group changes take effect within one token lifetime, not at the end of the session: a
user removed from
NOVA_OIDC_ADMIN_GROUPloses/api/me'sis_admin, the admin UI and the admin API as soon as their next token is fetched, with no restart and no re-login; - the refresh window is
min(60 s, half the token's life), so a realm with 60-second tokens refreshes half-way through them instead of on every request.expires_inschedules the next refresh; when the IdP reports neitherexpires_innor a decodableexp, Nova assumes 300 s and says so in the log.
Refreshes are serialized per session (Keycloak rotates refresh tokens, so spending one
twice would invalidate the session). Any refresh failure — revoked session, spent refresh
token, an unreachable IdP, or tokens whose claims cannot be verified — drops the session
and answers a plain 401 {"detail":"not signed in","error":"unauthenticated", "login":"/api/auth/login"}, so the browser signs in again instead of looping; a refresh
never extends the 8 h SESSION_TTL. Nova keeps the refresh token server-side only, never
in a cookie, a URL, a log line or a response body.
Keycloak notes. Add a Group Membership mapper (groups) to the nova client;
for bearer access add an Audience mapper so access tokens carry aud: nova.
With a Keycloak-style issuer (…/realms/<realm>) /api/me also carries
admin_console_url, the realm's own admin console (…/admin/<realm>/console/); the
sidebar links to it as Users for administrators. It is null in dev mode, for
non-Keycloak issuers (Entra ID) and for the master realm — Nova never links to a console
it cannot address, and the console still enforces who may manage users.
Keycloak shows a logout confirmation page because Nova does not put the ID token in
the end-session URL (client_id + post_logout_redirect_uri are sent instead).
Limitations. Sessions are held in memory: run one Nova replica in oidc mode; a
restart signs everyone out. Nova does not request offline_access, so the refresh token
lives as long as the IdP's SSO session while it is in use (Keycloak's defaults: 30 min
idle, 10 h max) — a session left idle past that signs in again rather than refreshing.
Revoking a single session at the IdP is noticed at the next refresh (within one token
lifetime); the session is otherwise not revoked.
- stardelt.io — full documentation
- Architecture overview — how Nova fits into the stardelt platform
- stardelt/stardelt — mono-repo with Helm charts and platform infrastructure