Skip to content

Machine Ingest API

The HTTP contract external producers use to push data into a deployment. NiFi flows, an interop gateway, and force-tracking sources POST detections and targets to the web tier; the web tier persists them and fans them onto the mesh. Ground truth is the web repo (web/app/domains/api/, web/app/domains/api_credentials/, and the auth middleware).

This is a machine-to-machine boundary and a separate trust root from operator, device, and server identity. Ingest credentials are HS256 JWTs verified against a database registry; they do not ride an AuthEnvelope, are not Directory-signed, and are not classification-gated. For principal identity (envelope, Ed25519, revocation) see security/model.md; for trust roots see security/pki.md.

Minting and revoking is an operator task

Issuing and revoking an ingest credential is an admin operation, not a self-service flow. See the runbook Issue an API credential.

Endpoints

Both endpoints are mounted outside the session / operationScope group: they are cookie-less and CSRF-exempt. The real route prefix in code is /api/feed.

Endpoint Scope required Purpose
POST /api/feed/detections detections:write Push detection rows
POST /api/feed/targets targets:write Push target rows

There is no browser session on this boundary; the bearer token is the sole credential.

Authentication — HS256 bearer JWT

Every request carries Authorization: Bearer <JWT>.

  • The JWT is HS256, signed with the deployment secret WAYPOINT_API_JWT_SECRET. The algorithm is pinned to HS256 — there is no none downgrade.
  • The header carries a kid (key/credential id). Claims are { scopes, ops, iat, exp? }.
  • The signature is the credential. No secret or hash is stored at rest — verification is a signature check against the deployment secret, then a liveness lookup of kid in the registry (below).
Authorization: Bearer <HS256 JWT>
  header   { alg: "HS256", kid: "<credential id>" }
  claims   { scopes: ["detections:write", ...], ops: ["*" | "<op id>", ...], iat, exp? }

Two authorization axes

Authorization is enforced on two independent axes:

  1. scopes — the capability set (detections:write | detections:read | targets:write | targets:read). Enforced in the auth middleware: a token missing the scope the endpoint requires is rejected with 403 before the controller runs.
  2. ops — an operation-id allowlist. ["*"] is account-wide; an empty list is deny-all. Enforced per row in the controller, because the operation id rides on the request body — each row names the operation_id it targets, so the allowlist must be checked against the row, not the route.

Revocation registry

Live credentials are tracked in the api_credentials table. There is no secret column — the row is metadata and revocation state only:

Column Meaning
kid (unique) credential id named in the JWT header
name human label
scopes (jsonb) capability set baked into issued tokens
ops (jsonb) operation-id allowlist
created_by_id issuing operator
expires_at optional hard expiry
last_used_at last successful verify
revoked_at set on revoke; NULL = live

Verify path: signature check then kid must be live — present in the registry, not revoked, not expired (the live lookup, findByKidLive).

Revocation stamps revoked_at. It is effective from the next request: the signature still verifies (the secret is unchanged), but the live lookup fails, so the credential is dead. There is no un-revoke — issue a fresh credential instead. Rotating WAYPOINT_API_JWT_SECRET invalidates all outstanding tokens at once.

Request & response shape

Both endpoints take a batch (1–1000 rows) and are partial-success: good rows land even if some are rejected.

  • Request: Content-Type: application/json; body is { "detections": [ … ] } or { "targets": [ … ] }.
  • Success — 201 Created with a per-row outcome body. A batch with some bad rows is still 201 — the rejected rows are listed; only a whole-batch failure returns 4xx.
// detections
{ "accepted": 2, "rejected": [ { "index": 2, "reason": "op_not_found" } ] }
// targets — also returns the new ids
{ "accepted": 1, "createdIds": [123], "rejected": [ … ] }

accepted is a count; each rejection is { index, reason } where index is the row's position in the request array.

Status Meaning
201 batch processed — inspect rejected for per-row failures (incl. (0,0) coords, or an operationId outside the token's ops)
401 missing or bad token (signature fails, or kid not live)
403 valid token lacking the endpoint's scope
404 every referenced operationId is unknown
422 malformed payload (missing required field, bad enum/range, > 1000 rows)

POST /api/feed/detections

Scope detections:write. Body { "detections": [ row, … ] }.

Field Type Req Notes
operationId int > 0 must be in the token's ops claim
kind enum detection | ew_emitter
affiliation enum hostile | friendly | neutral | unknown
lat / lon number −90..90 / −180..180; (0,0) rejected per-row
source string 1–64; free-text producer/feed name
observedAt int epoch ms
sourceKind enum ai | sensor | feed | human | manual
sourceLabel string ≤120; human-readable producer
confidence int 0–100
sidc string APP-6E (ADATP-37) 30-char only — this is an internal boundary; 20-char legacy / pre-E codes are rejected here. A producer holding legacy codes must normalize via the interop gateway first. See glossary SIDC.
callsign / displayName string ≤255
heading / speed / alt number 0–360° / ≥0 m/s / metres. Speed is stored as m/s system-wide and converted to knots/km-h for display per the track's SIDC — see glossary track_hits.
freqBand string ≤32
externalId string ≤128; producer's own id
tleErrorM number metres (< 6 = CAT-1)
tleCategory enum cat1cat4 (derived from tleErrorM if omitted)
imageUrl url http/https; imagery by-reference, never inline bytes
imageCaption string
capturedAtMs int epoch ms
curl -sS -X POST https://web.example/api/feed/detections \
  -H "Authorization: Bearer $WAYPOINT_INGEST_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "detections": [
      { "operationId": 42, "kind": "detection", "affiliation": "unknown",
        "lat": 50.812, "lon": -1.087, "source": "nightingale",
        "sourceKind": "sensor", "sourceLabel": "Nightingale radar",
        "confidence": 80, "sidc": "100130000000000000000000000000", "observedAt": 1700000000000 }
    ]
  }'
# → 201  { "accepted": 1, "rejected": [] }

POST /api/feed/targets

Scope targets:write. Body { "targets": [ row, … ] }. Ingested targets enter the board in the detected state; source is forced to ingest server-side (not a caller field).

Field Type Req Notes
operationId int > 0 must be in the token's ops claim
name string 1–255
description string ≤4000
priority enum low | medium | high | urgent
affiliation enum friendly | hostile | neutral | unknown
unitType enum APP-6 unit-type key (see UNIT_TYPES)
lat / lon number −90..90 / −180..180
linkedPrincipalId string ≤64
linkedDetectionId int > 0 provenance link to a detection
tleErrorM number metres (< 6 = CAT-1)
tleCategory enum cat1cat4 (derived from tleErrorM if omitted)
imageUrl url http/https; imagery by-reference
imageCaption string
capturedAtMs int epoch ms
metadata jsonb free-form (e.g. SIGINT intel)
confidence int 0–100
curl -sS -X POST https://web.example/api/feed/targets \
  -H "Authorization: Bearer $WAYPOINT_INGEST_JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "targets": [
      { "operationId": 42, "name": "SAM site ALPHA", "affiliation": "hostile",
        "lat": 50.812, "lon": -1.087, "tleErrorM": 4.2, "tleCategory": "cat1",
        "imageUrl": "https://imagery.example/geoint/alpha-001.jpg",
        "imageCaption": "GEOINT pass 2026-06-14", "confidence": 90 }
    ]
  }'
# → 201  { "accepted": 1, "createdIds": [123], "rejected": [] }

Tracks (track_hits) have no HTTP endpoint

Ambient position feeds (AIS/ADS-B/radar) are not part of this API — NiFi writes them directly to Postgres under a least-privilege role. There is nothing to curl; the "interface" is the track_hits column set. See Provision feed ingestion.

Relationship to identity

These are machine credentials and a separate trust root from operator / device / server identity:

Operator / device / server Machine ingest
Credential Directory-signed Ed25519 IdentityToken / ServerToken HS256 JWT
Verified by verifyAuthEnvelope (envelope gates) signature + DB live-lookup
Trust root Directory Ed25519 signing key deployment secret WAYPOINT_API_JWT_SECRET
Revocation Directory-signed RevocationList api_credentials.revoked_at
Classification-gated Yes No
Rides AuthEnvelope Yes No

An ingest credential authorizes a machine to write feed data; it is not a principal identity and does not carry a clearance.

Where to read the code

Concern Location
Credential registry (table, findByKidLive, revoke) web/app/domains/api_credentials/
Bearer verify + scope enforcement web/app/middleware/api_auth_middleware.ts
Ingest controllers + payload shape + per-row ops check web/app/domains/api/