@rubriclab/rubrot-api
v0.2.1
Published
The fleet's API contract layer: structured errors, zod request parsing, pagination, and fail-closed authenticated route wrappers
Keywords
Readme
@rubriclab/rubrot-api
The fleet's API contract layer, folded together from the metal /api/v1 _lib (structured errors, zod parsing, pagination — its error shape is THE standard) and vault's api-handler (fail-closed authenticated wrappers, generalized to an injected authenticator). Ships as TypeScript source; peer dep is zod only. No framework imports — handlers take a plain Request, so Next 16 route files export them directly.
The contract
Every failure body is { error: { code, message } } with a stable ApiErrorCode (AUTH_MISSING, AUTH_INVALID, SCOPE_MISSING, BAD_REQUEST, INVALID_JSON, NOT_FOUND, UPSTREAM_CONTRACT, UPSTREAM_ERROR, INTERNAL). Every response built here — success or failure — carries cache-control: no-store.
jsonError(code, message, status) structured failure, explicit code (the primitive)
apiError(message, status, code?) structured failure, code defaulted by status
codeForStatus(status) the default status → ApiErrorCode map
apiJson(data, status?) non-cached JSON success
apiText(body, status?) non-cached text/plain (dotenv projections)
parseRequestJson(request, contract) JSON + zod in one step → data | ready 400
zodMessage(error) first issue, offending field named
parsePagination(request) ?limit=&offset= via strict contract → data | ready 400
pageItems / pageLimitOnlyItems { items, pagination } envelope (exact / count-free total)
limitForOffset(query) SQL LIMIT with the next-page sentinel rowStatus → code
codeForStatus(status) is the one default map — the glue vault (codeForStatus)
and rubrot (authErrorCode) each hand-rolled, reconciled:
| status | code |
| ------ | --------------- |
| 400 | BAD_REQUEST |
| 401 | AUTH_INVALID |
| 403 | SCOPE_MISSING |
| 404 | NOT_FOUND |
| else | INTERNAL |
apiError(message, status, code?) builds a failure from a status alone
(code defaults via codeForStatus) or takes an explicit code to override —
it is vault's apiError and rubrot's caughtApiError folded into one, with
jsonError as the explicit-code primitive underneath.
AUTH_MISSING is never produced by the default map — a 401 defaults to
AUTH_INVALID (a presented credential was bad). "No credential at all" is a
distinction only the caller can draw, so it is consumer-emitted: pass
code: "AUTH_MISSING" (on an AuthFailure, or straight to apiError) where the
app decides a request carried no credentials. INTERNAL is the fail-closed
floor for any unmapped status.
Authenticated routes
withAuth(authenticate, handler) collapses what every route must get right — the auth gate, no-store, and fail-closed errors (a thrown handler logs and returns a clean structured 500, never a leak) — into one place. The authenticator is injected: header in, { ok, identity } | { ok: false, status, error, code? } out. On failure the wrapper builds the response via apiError — a set code wins, otherwise it defaults through codeForStatus (see the table above).
// lib/api.ts — bind once per app
import { createApiAuth } from "@rubriclab/rubrot-api";
export const api = createApiAuth({
read: (authorization) => authenticateBearer(/* app's engine */),
write: (authorization) => authenticateBearer(/* required: "write"/"operate" */),
});
// app/api/v1/machines/[id]/route.ts
export const GET = api.withRead<{ id: string }>(async (request, identity, ctx) => {
const { id } = await ctx.params;
return apiJson(await getMachine(id));
});What "read" vs "write" means (ranked floor, orthogonal capability) is the authenticator's business — pair with @rubriclab/rubrot-tokens.
