The REST API

Every board, sync, and widget in Deckgauge is served by one Fastify API — the same one the web app and the MCP server call. This page covers how to authenticate against it, what a request and an error look like, and a grouped map of what's there.

deckgauge · API · Authorization
GET /boards/plat-42 · valid Bearer token, Viewer+ access → 200 OK
GET /boards/plat-42 · valid token, no board access → 403 Forbidden

Same request, with and without board access.

What it does, and where the data comes from

This is the web app's own backend (apps/api), not a separately productized public API. It's read here because the same routes are what the MCP server sits in front of, and because a board's front end is only one client of it. In local development it listens on http://localhost:3001 by default — that default comes from the API's own PORT environment variable, which isn't actually documented in .env.example. Don't confuse that with API_URL: that's a separate variable the web app reads to know where to reach the API, not what the API listens on — it also defaults to http://localhost:3001 when unset, which is why the two are easy to conflate. Routes are mounted at the API's root with no version prefix, so a board's routes look like /boards/:id, not /v1/boards/:id.

Authentication

The web app signs you in through NextAuth's Keycloak OIDC flow and attaches the resulting access token to every API call as Authorization: Bearer <token>. On the API side, a Fastify preHandler verifies that JWT's RS256 signature against Keycloak's JWKS endpoint and checks its issuer, then resolves the token's subject claim to a local User row (creating one on first sign-in) that every route reads as request.user. That verification step doesn't reject a request by itself — a missing or invalid token just means no request.user gets set.

A second, separate check does the rejecting: every route declares an authorization policy (public, authenticated, a board or roadmap role, comparison, or connection ownership — see Access control), and the API refuses to start at all if a route doesn't declare one. A preHandler evaluates that policy on every request: no valid token answers 401; a valid token that doesn't meet the policy — wrong board role, not the resource's creator, someone else's connection — answers 403. Only routes declared public, such as the health check, accept a request with no token at all.

There's no separate API key or client-credentials flow today — the only way to obtain a token is to complete the web login and reuse the JWT it gives you. Treat this as an authenticated app backend you can script against with a browser-obtained token, not an API designed for arms-length third-party integration.

Board-scoped authorization

Most routes hang off a specific board and require a BoardAccess row for the calling user at or above a minimum role — Viewer, Editor, or Owner, ranked in that order. A user with no row for that board gets 403; the check runs fresh on every request, not once per session. See Access control for how those roles are granted.

Not every route is board-scoped, though. Org trees and employee boards use the same Viewer/Editor/Owner model as boards, just on a per-tree OrgTreeAccess row instead of a per-board one — listing your own trees and creating a new one only require being signed in, since those routes filter or stamp ownership themselves rather than checking a role up front. Engineering-intelligence and timesheet reads instead check a Keycloak realm role (cockpit-analytics) — timesheet reads check that role and Viewer on the org tree the request names, since only those carry a tree id. Comparisons check that the caller created the comparison, not a role. Provider-connection writes (Jira, GitHub, GitLab, and Azure DevOps instances) check that the caller created the connection — any signed-in user may create one, and a connection that predates this model has no owner until whoever edits it first claims it. A handful of other resources, such as editing a project sync's board-facing settings, still only require being signed in, with no per-resource role at all. Each of these still answers 401/403 the same way; only what counts as "authorized" differs. See Access control for the full breakdown.

Request & response conventions

Request bodies and query strings are validated with Zod schemas from packages/shared/src — the same schemas the web app's forms use, so client and server agree on shape. A failing validation returns 400 with the parsed Zod error, for example {"error": {"fieldErrors": {...}, "formErrors": [...]}}, so the failing field is named rather than guessed at. Authentication and access failures return a flat {"error": "<message>"} instead (for example {"error": "Forbidden"}). Successful responses return the resource itself with no wrapping envelope — 200/201 with a body, or 204 with none for most deletes and simple updates.

Route families

Grouped by what they act on, not enumerated endpoint by endpoint — each family holds several routes under a shared prefix.

FamilyCovers
Boards, groups, projects, columnsA board's own CRUD — its row groups, project rows, and custom column definitions
Comments & uploadsThreaded comments on a row, and the image attachments they (and employee records) carry
Board access & ownersPer-user Viewer/Editor/Owner grants, your own role, plus the legacy owner-string labels a board can still carry
Board statuses, views & widgetsA board's status vocabulary, its saved views, the dashboard widgets and layout on each, and view presets
Board tree & automationsYour personal folder layout and prefs, and if-this-then-that rules that react to a board's changes
Provider connectionsShared Jira/GitHub/GitLab/Azure DevOps credentials, reused across boards (/jira/instances and its per-provider siblings)
Project syncsWhich project, repo, or org a connection tracks, one family per provider
Board sources (per provider)Attaching a synced project/repo to one board's group, with board-specific settings — Jira, GitHub (incl. the repo picker), GitLab, Azure DevOps
Board sync & retired projectsManual sync triggers, sync health/status, and marking a decommissioned project so its hours stop counting
Org trees & employee boardsA reporting hierarchy, its employees, Microsoft Graph sourcing, and the per-tree employee workload boards
Developer profiles & usersIdentity/aliasing used to attribute commits and reviews, and user search for picking people
LocationsLocation search, used on employee records
TimesheetThe hours grid, CAPEX report, epic breakdown, and in-progress status rules
Intelligence & intelligence-queryThe ClickHouse-backed metric builders behind every widget, their manual sync trigger, and an ad hoc read-only SQL endpoint for the query-builder widget
ComparisonMulti-board comparison sets and their membership
RoadmapsA board's own roadmap schedule, plus the standalone cross-board roadmaps list
RecruitmentCandidate boards, moving a candidate onto a project, and calendar-source interview ingest
AdvisorThe Advisor chat's ask endpoint and its provider configuration
MCPThe /mcp Streamable HTTP server — see the MCP server

If it looks wrong

SymptomCauseFix
{"error": "Forbidden"} on a route with a real board idYour user has no BoardAccess row on that board, or one below the required role — or, on a route that isn't board-scoped, you aren't the resource's creator (a comparison, a provider connection)Ask a board owner for at least Viewer (Editor for writes); for a comparison or connection, ask its creator to make the change
401 on a route that requires sign-inNo Authorization header, or the bearer token failed JWT verification — the authorization-policy preHandler denies before the route's own handler runs, on every route except the few declared publicSign in through the web app and reuse its bearer token — it must be a Keycloak-issued JWT that verifies against this deployment's realm
400 with a nested fieldErrors/formErrors objectThe request body or query failed its Zod schemaRead the named field in the error — it's the one that didn't match
A GET on a board id you don't have access to answers 403, not 404The board-role policy denies before the route's own handler runs, and it can't tell "no such board" apart from "a board you can't see" — both look identical to the caller, so a real board id still isn't confirmedConfirm the id is right, then check your board access

Related

Last updated