What the REST API is, how it is wired, and why a runtime container needs this many
endpoints. Written so you can answer those questions without opening src/.
Operators and integrators: this is the reference for driving a Tsak node from curl, CI, Kubernetes, or your own C# service. For deployment see DEPLOYMENT.md, for settings see CONFIG_GUIDE.md.
The management API is not a separate ASP.NET application bolted onto the worker.
It is a redb.Route context named _system, started by SystemContextBuilder alongside
your own module contexts, on the same runtime, with the same lifecycle.
Tsak worker process
├── _system ← the management API (this document)
├── orders ← your module context
└── billing ← your module context
Consequences that matter in practice:
tsak context listshows_systemnext to your contexts. It has routes, metrics, and in-flight exchanges like any other context — the API can be observed by itself.- One HTTP listener, one port:
Tsak:Api:Host/Tsak:Api:Port(default0.0.0.0:9090). - The whole API can be switched off with
Tsak:Api:Enabled=false— the worker then runs your routes headlessly, with no control plane exposed. - The Web dashboard is a separate process/port (default
5100) and is just another client of this API. Nothing in the API depends on the UI being deployed.
There is exactly one route for the whole API — a catch-all HTTP listener whose
Process block is the entire pipeline:
HTTP request
│
▼
from("http:0.0.0.0:9090/{**path}") ← single listener, single socket
│
├─ BridgeHttpHeaders redbHttp.* → route.* + query.* headers
│
├─ AuthorizeProcessor skipped when path matches Tsak:Api:AuthExempt
│ └─ KeyedThrottleProcessor per-IP gate, only on /api/auth/*
│
├─ ControllerDispatcherProcessor path/method → controller method
│ └─ AuthController, ContextsController, HealthProbeController, …
│
└─ PrepareHttpResponse object → JSON, status code, content type
Controllers are plain classes deriving from RedbController (from redb.Route.Controllers),
annotated with [Route] / [HttpGet] / [HttpPost] / [FromQuery]. They are discovered by
assembly scan, so adding an endpoint means adding a method — no registration, no DI wiring,
no change to SystemContextBuilder. Pro contributes its own controllers by registering an
ISystemContextPlugin, which hands additional assemblies to the same registry.
Inside a controller you work with Route primitives, not ASP.NET ones: Exchange for the
message, Context.GetService<T>() for dependencies, ApiResponse.* + Exchange.Stop() to
short-circuit with a status code.
Two endpoints need to bypass the shared pipeline, so they are registered as their own routes on the same port (path specificity makes them out-rank the catch-all):
| Route id | Path | Why it is standalone |
|---|---|---|
system-echo |
/api/echo (Tsak:Api:Echo:Path) |
Own pipeline with no auth processor, and AutoStart(false) so it can be started/stopped on demand from the Routes API or dashboard. Reflects the request back as JSON — "am I reaching the host, and what did it actually receive?" |
system-metrics |
/metrics |
Proxies the loopback OpenTelemetry scrape listener so Prometheus can scrape the main port. Returns text/plain; version=0.0.4, not JSON. Mounted only when Tsak:Metrics:Prometheus:Enabled=true. |
The health probes are not in this list — they are ordinary controllers
(HealthProbeController), because they need nothing special: authentication is skipped
declaratively via Tsak:Api:AuthExempt, and their response is plain JSON.
Every endpoint exists to answer a question you would otherwise answer by SSH-ing into the box, reading log files, or restarting the process and hoping. The API surface is the set of operational questions a route runtime gets asked:
| Question | Endpoints | Who asks |
|---|---|---|
| Is the process alive / ready for traffic? | /api/health/* |
Kubernetes, load balancers |
| What is actually running right now? | /api/contexts, .../routes, /api/modules, /api/dashboard |
operators, UI |
| Why is this route stuck? | .../inflight, .../force-stop, /api/watchdog/*, /api/diagnostics/* |
on-call |
| What happened just before it broke? | /api/lifecycle, /api/logs* |
on-call |
| Who did what, and when? | /api/audit |
admins, compliance |
| How much is it costing me? | /api/system/metrics, /metrics/history, .../routes/{id}/metrics, Prometheus /metrics |
monitoring |
| Deploy / roll a module without downtime | /api/contexts/{name}/start, /stop, /restart, /api/modules/{name} DELETE |
CI/CD |
| What is the cluster doing? | /api/cluster/* |
operators (Pro) |
| When does this job fire, and can I pause it? | /api/scheduler/* |
operators |
| Who is allowed to do all of the above? | /api/auth/keys*, /api/users* |
admins |
The design rule: anything the dashboard can show, the API can return, and anything the API
can do, the tsak CLI can do — so a human, a script, and the UI are never on unequal footing.
69 endpoints across 16 controllers. All speak JSON. 14 controllers ship in
redb.Tsak.Core; ClusterController comes from redb.Tsak.Core.Pro via ISystemContextPlugin.
| Method | Path | 200 when |
|---|---|---|
| GET | /api/health/startup |
contexts are loaded and the cluster coordinator has synced |
| GET | /api/health/live |
process is responsive; no hung exchanges reported by the watchdog |
| GET | /api/health/ready |
full health evaluation is not Unhealthy |
Failures return 503 with a short reason. Bodies are intentionally minimal — kubelet
does not read them. See §6 for the rich variant.
| Method | Path | Returns |
|---|---|---|
| GET | /api/system/health |
aggregated health with per-component statuses |
| GET | /api/system/metrics |
latest CPU / memory / GC / thread snapshot |
| GET | /api/system/metrics/history |
stored metric points for charting |
| GET | /api/system/info |
version, uptime, machine info |
| GET | /api/system/config |
effective merged config (Tsak:*/ConnectionStrings:*), secrets redacted (admin) |
/api/system/config answers "what settings is this node actually on" on an incident — a flat
key→value map with sensitive keys masked and connection-string passwords stripped. admin only
(it reveals topology). Client GetConfigAsync, CLI tsak system config.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/contexts |
list all contexts with status and counts |
| GET | /api/contexts/{name} |
one context in detail |
| POST | /api/contexts/{name}/start |
start |
| POST | /api/contexts/{name}/stop |
graceful stop |
| POST | /api/contexts/{name}/restart |
stop + start |
| POST | /api/contexts/{name}/reset-route-states |
clear persisted per-route start/stop state |
| GET | /api/contexts/{name}/endpoints |
endpoints exposed by the context, with health |
| DELETE | /api/contexts/{name} |
remove the context |
Routes live under their context, which is why they share the /api/contexts prefix.
| Method | Path | Purpose |
|---|---|---|
| GET | .../routes |
list routes of a context |
| GET | .../routes/{routeId} |
one route in detail |
| POST | .../routes/{routeId}/start |
start a single route |
| POST | .../routes/{routeId}/stop |
graceful stop of a single route |
| POST | .../routes/{routeId}/force-stop |
abandon in-flight work and stop |
| GET | .../routes/{routeId}/inflight |
exchanges currently inside this route |
| GET | /api/contexts/{name}/inflight |
in-flight exchanges across the whole context |
| GET | .../routes/{routeId}/metrics |
per-route counters and timings |
inflight + force-stop are the pair you reach for when a route will not drain: look at
what is stuck, then decide whether to abandon it.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/modules |
loaded modules, versions, origin (.tpkg or bare directory) |
| GET | /api/modules/{name} |
module detail |
| DELETE | /api/modules/{name} |
unload a module and shut down its contexts gracefully |
| POST | /api/modules/upload |
deploy a signed .tpkg (admin; off by default) |
| POST | /api/modules/validate |
dry-run: validate a .tpkg without installing (admin) |
| POST | /api/modules/{name}/rollback |
restore the previous on-disk version (admin) |
A package update is staged: the new .tpkg is loaded and checked in a throwaway ALC before
the running version is torn down, so a broken package leaves the live context running.
Upload/rollback deploy executable code, so they are disabled by default
(Tsak:Modules:Upload:Enabled), admin-only, audited, and gated by signature verification. The
full trust model — the two verification boundaries, the two "require signature" switches, and the
keygen/sign flow — is in MODULE_DEPLOYMENT.md.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/watchdog/status |
suspected / hung exchange counters |
| GET | /api/watchdog/alerts |
recent alerts |
| POST | /api/watchdog/enable |
turn detection on at runtime |
| POST | /api/watchdog/disable |
turn it off (e.g. during a known long batch) |
| GET | /api/watchdog/alerts/status |
is alert delivery active, and which channels are enabled |
| POST | /api/watchdog/test-alert |
send a synthetic alert through every enabled channel (per-channel result) |
New alerts are pushed to configurable channels (all off by default): webhook, telegram,
email (all native, no extra deps) and a generic endpoint channel that sends to any
redb.Route producer URI (kafka:, rabbitmq:, amqp:, sqs:, mqtt: …) — one channel for
every broker, no per-connector code. Delivery is fire-and-forget with dedup by incident.
Configure under Tsak:Watchdog:Alerts; verify with test-alert.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/diagnostics/dump |
full dump — GC, threads, contexts, routes (cluster-wide) |
| GET | /api/diagnostics/{contextName}/{routeId} |
route-scoped diagnostics |
| GET | /api/lifecycle |
recent lifecycle events, filterable by context and limit |
| Method | Path | Purpose |
|---|---|---|
| GET | /api/audit?actor=&action=&target=&since=&until=&limit=&offset= |
persisted admin-action trail, newest-first |
Every mutating admin action (marked [AuditAdminAction]) is persisted to the flat
tsak_audit_log table — who, what, target, status, duration, source IP — and survives
restarts. Requires the admin role: the trail records who did what, so it must not be
enumerable by a lower-privilege caller. Without a database the trail lives in the log instead
(a [tsak-audit]-anchored JSON line) and this endpoint reports available: false.
Filtering and paging are server-side (the table can be large): limit is clamped to
1..1000, offset pages. Exposed through ITsakApiClient.GetAuditAsync, the tsak audit CLI
command, and the dashboard Audit page.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/exchanges/failed?context=&route=&status=&since=&until=&limit=&offset= |
failed exchanges, newest-first (operator) |
| POST | /api/exchanges/{id}/replay |
re-run the entry's route tail from its snapshot (operator) |
| DELETE | /api/exchanges/{id} |
discard an entry (admin) |
An exchange that fails at a redb.Route replay checkpoint (.Replayable("…") in the route) is
captured into the flat tsak_dlq table — snapshot, route, marker, exception. Opt-in by
construction: only routes carrying a marker leave a checkpoint, so the DLQ never tries to own an
exchange a broker/transaction already redelivers. Replay rehydrates the snapshot and re-runs the
route tail (the tail gets fresh redb/SQL connections). Replay is at-least-once + manual — make
replayed side-effects idempotent. Client GetFailedExchangesAsync/ReplayExchangeAsync, CLI
tsak dlq, dashboard Dead-letter page. Without a database the endpoint reports
available: false.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/logs?afterId=&limit=&level= |
ring-buffer entries; afterId makes tailing incremental |
| GET | /api/logs/files |
list rolling log files with size and mtime |
| GET | /api/logs/files/{filename} |
download one file as a ZIP |
| Method | Path | Purpose |
|---|---|---|
| GET | /api/scheduler/status |
started / standby / stopped, job and trigger counts |
| GET | /api/scheduler/scheduled |
every scheduled job with its triggers and next fire time |
| GET | /api/scheduler/jobs |
jobs currently executing |
| POST | /api/scheduler/start |
start the scheduler |
| POST | /api/scheduler/standby |
pause firing without losing schedules |
| POST | /api/scheduler/pause-job?key={jobKey} |
pause one job |
| POST | /api/scheduler/resume-job?key={jobKey} |
resume it |
| POST | /api/scheduler/fire-job?key={jobKey} |
fire a job immediately, out of schedule (operator) |
| Method | Path | Purpose |
|---|---|---|
| GET | /api/cluster |
leader, epoch, membership, assignment state |
| GET | /api/cluster/nodes |
node list with heartbeats and cordoned flag |
| POST | /api/cluster/rebalance |
force a reassignment pass |
| DELETE | /api/cluster/nodes/{nodeId} |
evict a dead node from the registry |
| POST | /api/cluster/nodes/{nodeId}/cordon |
cordon a node — keeps current work, takes no new work, drains locks to peers |
| POST | /api/cluster/nodes/{nodeId}/uncordon |
uncordon — resume taking work |
Cordon is the graceful middle state for planned maintenance / rolling upgrade: remove-node is a
hard eviction, cordon lets the node drain first. A cordoned node stays Online (heartbeating) but
its clustered route locks release to peers.
| Method | Path | Purpose |
|---|---|---|
| POST | /api/auth/keys |
create an API key (raw value returned once) |
| GET | /api/auth/keys |
list key metadata — never the secrets |
| DELETE | /api/auth/keys/{id} |
revoke a key |
| GET | /api/users |
list users |
| GET | /api/users/{login} |
one user |
| POST | /api/users |
create |
| PUT | /api/users/{login} |
update |
| DELETE | /api/users/{login} |
delete |
/api/users is backed by redb's user provider, so it requires a redb-backed storage mode —
in standalone/in-memory mode there is nothing to store users in.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/dashboard |
one aggregated snapshot: metrics + contexts + top routes + alerts |
Exists so the UI renders a screen with a single round-trip instead of six.
Auth is opt-in: Tsak:Auth:Enabled (default false). With it off, every endpoint is
open — appropriate only for a private network or a local dev box.
With it on, each request carries a key in either form:
Authorization: Bearer tsak_xxxxxxxx
X-Api-Key: tsak_xxxxxxxx
- Keys are created via
/api/auth/keysand carry roles; see Roles below. - Failures are
401(missing/invalid key) or403(valid key, insufficient roles). /api/auth/*is additionally rate-limited per remote IP —Tsak:Api:AuthThrottle:Limit(default 10) perWindowSeconds(default 60). Set the limit to0to disable.Tsak:Api:AuthExemptlists paths that skip auth entirely. Default:["/api/health/*", "/api/health"]. A trailing*is a prefix match; otherwise the match is exact and case-insensitive.
Every key carries a comma-separated role list. Three roles form a ladder — a higher one satisfies any requirement for a lower one:
| Role | May do |
|---|---|
viewer |
every GET: contexts, routes, modules, metrics, health, lifecycle, dashboard |
operator |
plus lifecycle actions — start / stop / restart contexts and routes, scheduler control, watchdog toggle, diagnostics dumps, log access |
admin |
plus API keys, users, module removal, context removal, route force-stop, cluster rebalance and node eviction |
Custom roles (any other string) are matched by exact name only — admin does not
satisfy a requirement for release-bot.
Endpoints declare their requirement with [RequiresRole(...)] on the action or the
controller. An endpoint with no attribute falls back to a safe default derived from the HTTP
method: GET needs viewer, everything else needs operator — a newly added endpoint is
therefore never open by accident.
Technical endpoints are never role-gated. The check runs only for authenticated
requests, so the auth-exempt Kubernetes probes pass straight through; on top of that
HealthProbeController is marked [NoRoleRequired], so even if an operator narrows
Tsak:Api:AuthExempt, a probe can start requiring a key but will never answer 403 — a
probe rejected on privileges would take a healthy pod out of rotation. The echo and
Prometheus routes have their own pipelines and never reach the check at all.
Two switches control the behaviour:
Tsak:Auth:EnforceRoles(defaulttrue) — turn role checking off entirely.Tsak:Auth:RolelessKeysAreAdmin(defaulttrue) — keys issued before roles were enforced carry no roles; they keep working asadminand log a one-time warning. Set tofalseto deny them once all keys have been re-issued with explicit roles.
Deployment note. Three things answer without a key: the health probes (by the exempt
list) plus /api/echo and /metrics (own pipelines). None of them expose business data, but
all three confirm the node exists and /metrics describes its load — keep the API port on an
internal network or behind an ingress rule rather than on the public internet. /api/echo
additionally ships stopped (AutoStart(false)), so it answers only after somebody starts it.
/api/health/{startup,live,ready} |
/api/system/health |
|
|---|---|---|
| Audience | kubelet, load balancers | humans, CLI, dashboard |
| Auth | exempt by default | subject to auth |
| Body | {"status":"ok"} or 503 + reason |
full HealthCheckResult with per-check statuses |
| Cost | minimal | runs every contributor |
HealthCheckService aggregates the built-in contexts / metrics / watchdog checks with
two extension points, and never throws — an exception inside a probe becomes Unhealthy,
not a 500. Worst status wins: Unhealthy > Degraded > Healthy.
IHealthContributor— sees the whole evaluation bag. Pro'sClusterHealthContributoruses it to report leader and member health.IModuleHealthContributor— per-module probe, published under the keymodule:{name}. Implement it in your module to surface "my downstream is unreachable" to/api/health/ready, which is what makes Kubernetes take the pod out of rotation.
Note that Degraded still returns 200 on readiness — only Unhealthy is a 503.
# 1. curl — everything is JSON
curl -s http://localhost:9090/api/health/ready -o /dev/null -w '%{http_code}\n'
curl -s -H "X-Api-Key: $KEY" http://localhost:9090/api/system/health | jq# 2. tsak CLI — same operations, tabular or --output json for CI
tsak context list
tsak health
tsak route inflight orders route-1// 3. Typed C# client — ITsakApiClient covers the whole surface
services.AddTsakClient(o => { o.BaseUrl = "http://tsak-prod:9090"; o.ApiKey = key; });
public class Ops(ITsakApiClient tsak)
{
public async Task RestartFailedAsync(CancellationToken ct)
{
foreach (var c in (await tsak.ListContextsAsync(ct)).Where(c => c.Status == "Failed"))
await tsak.RestartContextAsync(c.Name, ct);
}
}- Web dashboard — a Blazor app on its own port that consumes exactly the endpoints above.
| You want to… | Do this |
|---|---|
| add endpoints from a Pro/host assembly | implement ISystemContextPlugin, return your assembly from GetControllerAssemblies(), register it in DI |
| require a specific role on an endpoint | annotate the action or controller with [RequiresRole(TsakRoles.Admin)] |
| expose a technical endpoint that must never 403 | annotate it [NoRoleRequired] and add its path to Tsak:Api:AuthExempt |
| report module health to k8s | implement IModuleHealthContributor in the module |
| add a system-wide health check | implement IHealthContributor |
| expose an endpoint without auth | add its path to Tsak:Api:AuthExempt |
| expose an endpoint that needs its own pipeline | register a route on the API port — a concrete path out-ranks the {**path} dispatcher |
| Key | Default | Effect |
|---|---|---|
Tsak:Api:Enabled |
true |
build and start the _system context at all |
Tsak:Api:Host |
0.0.0.0 |
listener bind address |
Tsak:Api:Port |
9090 |
listener port (shared by API, echo, /metrics) |
Tsak:Auth:Enabled |
false |
require an API key |
Tsak:Auth:EnforceRoles |
true |
enforce [RequiresRole] on endpoints (no effect when auth is off) |
Tsak:Auth:RolelessKeysAreAdmin |
true |
keys with no roles keep full access (back-compat); false denies them |
Tsak:Api:AuthExempt |
["/api/health/*", "/api/health"] |
paths that skip auth |
Tsak:Api:AuthThrottle:Limit |
10 |
per-IP attempts on the auth prefix; 0 disables |
Tsak:Api:AuthThrottle:WindowSeconds |
60 |
throttle window |
Tsak:Api:AuthThrottle:PathPrefix |
/api/auth/ |
what the throttle covers |
Tsak:Api:Echo:Path |
/api/echo |
echo route path |
Tsak:Metrics:Prometheus:Enabled |
false |
mount /metrics on the API port |
Tsak:Metrics:Prometheus:Port |
9464 |
loopback OTel listener being proxied |
Tsak:Audit:Enabled |
true |
persist admin actions to tsak_audit_log (falls back to log with no DB) |
Tsak:Audit:RetentionDays |
90 |
prune audit entries older than this; 0 keeps forever |
Tsak:Dlq:Enabled |
true |
capture failed exchanges (at route checkpoints) into tsak_dlq |
Tsak:Dlq:RetentionDays |
30 |
prune DLQ entries older than this; 0 keeps forever |
Tsak:Health:DegradedNotReady |
false |
when true, the readiness probe also returns 503 on Degraded |
Tsak:Modules:Upload:Enabled |
false |
enable POST /api/modules/upload + /rollback (RCE surface — keep off unless needed) |
Tsak:Modules:Signature:Required |
false |
refuse unsigned/tampered .tpkg at load (needs PublicKeyPath) |
- README.md — the project as a whole
- CONFIG_GUIDE.md — the 5-layer configuration model
- DEPLOYMENT.md — running the published images
- deploy/README.md — Kubernetes manifests with the probes already wired
- SECURITY.md — hardening and vulnerability reporting