Schema reference · Brief API

/api/brief/v1 — the public per-project brief snapshot.

A versioned, profile-friendly JSON endpoint that exposes the same per-project state that powers the Helmspur dashboard — names, status, revisions ingested in the last 24h, open clash count, and an overdue-deliverables digest. Designed for enterprise procurement and project-control dashboards (Kongsberg, Aker, Subsea 7) that pull the brief from outside the Helmspur session seam.

Endpoint

The route and method.

Method: GET only. The route refuses other methods with a 405 before they ever reach the auth check — a non-GET request therefore tells a caller nothing about the configured token.

Path: /api/brief/v1. The version suffix is required — a request to /api/brief hits the user-scoped dashboard route, which is gated by requireAuth() and only returns the caller's own overdue rows. The two routes are siblings, not aliases.

Cache: the response is served with Cache-Control: no-store. Each request is a fresh generation — there is no edge-cached envelope, so callers should maintain their own polling cadence.

Authentication

Per-org API key (hashed at rest).

A single API key per organization authenticates every read from this endpoint. The key is generated and rotated from the /dashboard/admin/api-keys view (admin-only), stored hashed in the database via node:crypto.scryptSync with a per-row random salt, and the plaintext is shown once at issue / rotation time — it is never written to a log line, never re-displayed, and not recoverable from the persisted material.

  • Header: Authorization: Bearer <HELMSPUR_API_KEY>
  • Plaintext is a 32-byte random hex string (64 chars). The stored material is the scrypt-derived bytes + per-row salt, plus a 16-hex-char sha256(plaintext) fingerprint that identifies the key in the admin list without leaking it.
  • Verification re-derives the hash with the same scrypt parameters (N=2^15, r=8, p=1, keylen=64) and runs node:crypto.timingSafeEqual over the equal-length UTF-8 bytes — an attacker cannot infer key contents from response-time variance.

A missing Authorization header, a present header without the Bearer prefix, or a key that doesn't match the active row all return a uniform 401:

{ "error": "Unauthorized" }

Request example.

curl -sS https://<host>/api/brief/v1 \
  -H "Authorization: Bearer $HELMSPUR_API_KEY"
Response schema

The shape of the payload.

The top-level document carries the per-request generation time, the pinned API version, and the only versioned route literal; the projects array keeps the same shape the dashboard grid renders against.

FieldTypeNotes
generatedAtstring (ISO 8601)Server clock at handler entry; reused by every per-project count so the snapshot is internally consistent.
apiVersionstringLiteral "1". A SemVer-incompatible major version; bumped when the project shape changes.
versionstringLiteral "v1". The versioned route tag; a future v2 will carry both "1" and a different route path so consumers can pin.
projectsarray<Project>Every Project in status: ACTIVE, ordered by createdAt descending.
Project fields

What each project row carries.

The per-project shape mirrors the dashboard wire-format — the on-screen card grid consumes the same fields, so any drift would surface as a UI bug, not a quiet contract change.

FieldTypeNotes
idstringStable cuid(); safe to cache.
namestringProject name as set at creation. Verify against String.maxLength = 120.
statusenumAlways ACTIVE in the v1 payload — the route filters at query time. Pinned to the same enum the Prisma schema carries.
revisionsLast24hinteger ≥ 0Count of Revision rows with uploadedAt ≥ now − 24h — the live activity reading.
openClashesinteger ≥ 0Count of Clash rows regardless of status — the digest that drives the dashboard's clash-pulse card.
briefSentAtstring | nullISO 8601 timestamp of the last morning-brief email send for this project, or null. Tracked via Project.briefSentAt.
overdueDeliverablesobject{ count, top[≤3] } — see below.
Overdue deliverables digest

Why the v1 endpoint skips the userId filter.

A Deliverable row is per-owner (supplier representative), but the corporate brief is consumed by a procurement role with cross-supplier visibility. The v1 endpoint therefore drops the Deliverable.userId filter that the dashboard route applies, and returns every PENDING row with dueDate < now — never sender-specific.

The shape of overdueDeliverables is:

FieldTypeNotes
countinteger ≥ 0Total PENDING rows where dueDate < now. The full corpus for the project — not just the top 3.
toparray<Item>The top 3 rows ordered by dueDate ASC (most-overdue first). The shape carries the supplier owner (so the digest can name the hold), not the underlying user id.
topIdstringDeliverable id; stable across snapshots.
topItemNamestringShort description of the deliverable (engineering dossier, test certificate, subsea spool drawing).
topOwnerNamestringSupplier representative name — what the dashboard's overdue card shows.
topDueDatestring (ISO 8601)Per-row due date. Capped at 1e15 ms (year 33658) so overdue filtering stays finite on legacy rows.
topStatusenumAlways "PENDING" in the digest (the filter excludes RECEIVED); the field is on the model for forward-compatibility with received rows.
Working sample

A live response to read against.

A successful 200 response — the same shape the dashboard grid consumes; a version: "v1" field is the only addition over the authed dashboard endpoint.

{
  "generatedAt": "2026-08-18T07:32:11.840Z",
  "apiVersion": "1",
  "version": "v1",
  "projects": [
    {
      "id": "ckm9pq1r20001abcd1efghij2",
      "name": "Troll Phase 3 — pipeline spool rework",
      "status": "ACTIVE",
      "revisionsLast24h": 4,
      "openClashes": 17,
      "briefSentAt": "2026-08-18T07:00:00.000Z",
      "overdueDeliverables": {
        "count": 3,
        "top": [
          {
            "id": "ckmabcdefghijklmnopqrstuv",
            "itemName": "Pipe support fabrication dossier",
            "ownerName": "Subsea Solutions Ltd",
            "dueDate": "2026-08-16T17:00:00.000Z",
            "status": "PENDING"
          }
        ]
      }
    }
  ]
}

Headers on a 200 response include Cache-Control: no-store and a content-type of application/json; charset=utf-8. There is no ETag — caching is the caller's responsibility.

Guarantees & limits

What the API does and does not promise.

  • No rate limit yet. The endpoint is a single SQL fan-out (one read per project); the engineering team will add a per-token throttle before the surface is publicized to more than a handful of buyers.
  • The same per-project query as the dashboard. The route runs three queries per ACTIVE project (revisions, clashes, deliverables) in parallel — the timings reported by the v1 endpoint match the dashboard grid exactly.
  • Not user-scoped. The endpoint exposes every ACTIVE project — including the requester's own. The userId filter is a dashboard construct, not a security boundary.
  • Key rotation is self-serve from the admin view. The admin generates / rotates the active key from /dashboard/admin/api-keys without redeploying. In flight, any caller still holding the previous plaintext will start receiving 401 Unauthorized from the very next request — the old row is retired (not deleted, so the audit trail is preserved) and is no longer matched by loadActiveApiKey().
Version policy

How the contract evolves.

The endpoint pins apiVersion: "1" for every 200 response. Additive fields (new Project metadata, deeper deliverable breakdowns) are non-breaking — a flagged version, not a bumped version.

Breaking changes (new required fields, restructured top-level shape, orphaned-deliverable representation) bump apiVersion to "2" and a sibling route is added under /api/brief/v2. The v1 route stays live for an extended deprecation window — at least one published EPC contract cycle — so existing buyers don't break on the day the contract changes.

Consumers SHOULD gate on apiVersion === "1" (and on the route literal "v1") and surface a maintenance alert on any future mismatch — a non-1 apiVersion means a contract change is in flight.

Reviewer's checklist

Every claim above is enforced in code you can clone: the schema at src/lib/contracts/brief.ts, the route at src/app/api/brief/v1/route.ts, the token check at src/lib/brief/api-key-auth.ts, the model at prisma/schema/api-keys.prisma, and the admin CRUD at src/app/api/admin/api-keys/route.ts. If a claim doesn't match the code, treat that as a bug — the brief-side contract and the schema reference can't shift independently.