Skip to main content

Architecture

A node is a complete Veodyn instance scoped to one agency. It runs on infrastructure that agency controls, it is the system of record for that agency's transportation data, and it works standing alone. This page describes a node.

Internally a node is three services plus three datastores, and one rule shapes how they connect: the browser only ever talks to the frontend. Every backend call goes through a same-origin proxy route on the Next.js server, authenticated with the user's own session, so backend URLs and credentials never reach the client.

This page describes the community edition, which is the whole of this repository. The enterprise edition adds code to two of these three services and changes none of the connections below.

The five surfaces

A node is sold as five surfaces. They are a description of what it does, not of how it is packaged, so each one is delivered by a different slice of the three services below. This table is the map between the two vocabularies:

SurfaceDelivered by
AdaptersQuery runners in the query service. Eleven transportation connectors ship today, beside the SQL and warehouse sources. See the connector list
NormalizationJSON endpoint descriptors resolved to typed columns by the runner, and scheduled results captured into ClickHouse in a stable shape
WarehousePostgreSQL as the system of record, ClickHouse as the historical store, both local to the node
APIsThe query service's REST API, the sidecar's REST API, the MCP endpoint, and per-query API keys
VisualizationThe frontend: 15 core types, dashboards, plugins, embeds, and Create with AI

Nodes and hubs

A hub runs the same five surfaces over its own data, and adds a federation layer on top: it registers member nodes, aggregates across them, and pushes selected data back down. The default direction is node to hub; nodes never talk to each other directly.

Federation is what a hub is for, and it is the commercial part of the product. None of it is in this repository, so nothing on this documentation site describes it. A node is complete without it: an agency running one standing alone is not running a degraded version of anything.

The services

The same edges in full, with the credential each one carries, which is the part the diagram cannot show. The first row is the invariant the rest of the design follows from: there is no second row starting at the browser.

FromToCarrying
Browserapp (frontend)Same-origin /api/* only, with the user's session cookie
appQuery serviceThe user's own session cookie, or the internal admin key on /api/admin/* after an admin check
appapi (sidecar)A service key, with the caller's identity forwarded for resolution
appAI providerA shared bearer key. The user's cookie is stripped before the call leaves
Query servicePostgreSQL, RedisIts own database, and its job queues
Query serviceClickHouseHistorical capture, write, opt-in per data source
apiPostgreSQL, RedisIts own database and its own Redis index
apiClickHouseThe data catalog, read only
apiQuery serviceIdentity resolution, and query runs
apiAI providerThe Claude API

app (frontend)

A Next.js App Router application. It renders every screen, and its server side hosts the proxy routes under src/app/api/*:

Route prefixBackendCredential
/api/node/* (/api/redash/* is a deprecated alias, kept for one release)The query serviceThe user's own session cookie, so the query service enforces per-user permissions
/api/admin/*The query serviceAn internal admin API key, used only after verifying the caller's own session holds the required admin permission
/api/catalog, /api/domains/*, /api/tags, /api/feeds*, /api/favorites*apiThe caller's identity is forwarded and resolved against the query service
/api/ai/*The AI providerA shared bearer key; the user's cookie is stripped before the call leaves

The /api/node/* prefix is a path, not a description of what is behind it: it addresses the query service, and it keeps that spelling because changing a deployed route is a breaking change for anything already calling it.

If a backend for a surface is not configured, that surface answers 503 and the app falls back to demo fixtures rather than breaking.

The query service

The "flow" service, in node/: the system of record for queries, query results, schedules, dashboards, visualization widgets, users, groups, and data-source permissions. It is vendored upstream code, and on top of what it inherits it adds:

  • Transportation connectors: adapters that poll a live transit, traffic, weather or fleet API with JSON endpoint descriptors instead of SQL. See the connector list for the public set.
  • Historical capture: an admin can opt a data source in, and every scheduled result of its queries then lands in ClickHouse, so a warehouse of feed history accumulates on its own.
  • JSON invite and password-reset endpoints so the Veodyn frontend can drive account flows without a second web UI.

The service ships a legacy web UI of its own that still exists and works, but Veodyn users never see it; the frontend replaces it completely.

api (sidecar)

A FastAPI service owning everything the query service's data model does not: the data catalog (a read-only view over ClickHouse), domain pages, tags, favorites, feed health and expectations, and the AI provider.

It stores no users. Every request's identity is resolved by forwarding the caller's credential to the query service, so permissions stay in one place. It has its own PostgreSQL database. Where there is no caller to borrow a credential from, arming a feed alert or assembling AI grounding, it acts as a dedicated service account.

A community deployment runs no sidecar worker. The only recurring job the sidecar has ever had evaluates KPIs, which are enterprise, so the whole worker package ships with the enterprise pack. There is no api-worker service in the local Compose stack and no worker release in the Helm chart; a deployment that installs the pack adds both back.

Adding the pack registers extra HTTP routers, extra object kinds in the tag/favorite registry, extra domain-page counter providers, and a second Alembic chain with its own version table. The seam is one environment variable, VEODYN_EXTRA_MODULES, read at startup; nothing else in this service knows the pack exists.

ClickHouse (historical warehouse)

Written by the query service's capture layer (scheduled results, opt-in per data source) and read by the sidecar's data catalog and by the query service itself through a "historical" data source. The frontend never talks to ClickHouse directly.

Identity and permissions

  • One identity system: the query service. Users, groups, and data-source access live there. The frontend logs in against it, the sidecar resolves identities against it.
  • Per-user enforcement everywhere. Because query reads ride the user's own session, a user can never read a result their groups do not allow, no matter which service asked.
  • The AI path is the exception, by design: AI grounding runs as the service account, so a suggestion can name (but never show) a query the reader cannot open. Result reads still go through the query service under the reader's own credential.
  • Admin routes are double-gated: the internal admin key is only used after the caller's own session proves the admin permission.

Public surfaces

Dashboards and single visualizations can be shared by unlisted token URLs (/dashboards/public/<token>, /embed/public/<token>). Tokens are lookup keys: a revoked or unknown token gets a neutral refusal page that never echoes the token.

Public report links (/reports/public/<token>) and the Admin → Shared Links audit and bulk-revoke surface are enterprise: both are served by routes a community build does not register.

Each service also has its own ingress in production, so anything that must always happen on a request (an org check, audit logging) lives in the backend that resolves the token, not in the frontend proxy.

Repository layout

PathContents
app/The Next.js frontend
node/The query service
api/The FastAPI sidecar
helm/Helm charts and per-environment values
ci/CI pipeline manifests
docs/This documentation site, plus internal engineering notes

The node/ directory holds one service, not a whole node. It predates the current naming and is left alone deliberately: the path is referenced by the build, the charts and the packs that overlay onto this tree, so renaming it is a coordinated change rather than a cosmetic one.