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.
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.
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.
Authorization: Bearer <HELMSPUR_API_KEY>sha256(plaintext) fingerprint that identifies the key in the admin list without leaking it.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"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.
| Field | Type | Notes |
|---|---|---|
| generatedAt | string (ISO 8601) | Server clock at handler entry; reused by every per-project count so the snapshot is internally consistent. |
| apiVersion | string | Literal "1". A SemVer-incompatible major version; bumped when the project shape changes. |
| version | string | Literal "v1". The versioned route tag; a future v2 will carry both "1" and a different route path so consumers can pin. |
| projects | array<Project> | Every Project in status: ACTIVE, ordered by createdAt descending. |
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.
| Field | Type | Notes |
|---|---|---|
| id | string | Stable cuid(); safe to cache. |
| name | string | Project name as set at creation. Verify against String.maxLength = 120. |
| status | enum | Always ACTIVE in the v1 payload — the route filters at query time. Pinned to the same enum the Prisma schema carries. |
| revisionsLast24h | integer ≥ 0 | Count of Revision rows with uploadedAt ≥ now − 24h — the live activity reading. |
| openClashes | integer ≥ 0 | Count of Clash rows regardless of status — the digest that drives the dashboard's clash-pulse card. |
| briefSentAt | string | null | ISO 8601 timestamp of the last morning-brief email send for this project, or null. Tracked via Project.briefSentAt. |
| overdueDeliverables | object | { count, top[≤3] } — see below. |
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:
| Field | Type | Notes |
|---|---|---|
| count | integer ≥ 0 | Total PENDING rows where dueDate < now. The full corpus for the project — not just the top 3. |
| top | array<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. |
| topId | string | Deliverable id; stable across snapshots. |
| topItemName | string | Short description of the deliverable (engineering dossier, test certificate, subsea spool drawing). |
| topOwnerName | string | Supplier representative name — what the dashboard's overdue card shows. |
| topDueDate | string (ISO 8601) | Per-row due date. Capped at 1e15 ms (year 33658) so overdue filtering stays finite on legacy rows. |
| topStatus | enum | Always "PENDING" in the digest (the filter excludes RECEIVED); the field is on the model for forward-compatibility with received rows. |
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.
userId filter is a dashboard construct, not a security boundary./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().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.