@rubriclab/rubrot-api 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # @rubriclab/rubrot-api
2
+
3
+ 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.
4
+
5
+ ## The contract
6
+
7
+ 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`.
8
+
9
+ ```
10
+ jsonError(code, message, status) structured failure
11
+ apiJson(data, status?) non-cached JSON success
12
+ apiText(body, status?) non-cached text/plain (dotenv projections)
13
+ parseRequestJson(request, contract) JSON + zod in one step → data | ready 400
14
+ zodMessage(error) first issue, offending field named
15
+ parsePagination(request) ?limit=&offset= via strict contract → data | ready 400
16
+ pageItems / pageLimitOnlyItems { items, pagination } envelope (exact / count-free total)
17
+ limitForOffset(query) SQL LIMIT with the next-page sentinel row
18
+ ```
19
+
20
+ ## Authenticated routes
21
+
22
+ `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, `code` defaults by status (403 → `SCOPE_MISSING`, else `AUTH_INVALID`).
23
+
24
+ ```ts
25
+ // lib/api.ts — bind once per app
26
+ import { createApiAuth } from "@rubriclab/rubrot-api";
27
+ export const api = createApiAuth({
28
+ read: (authorization) => authenticateBearer(/* app's engine */),
29
+ write: (authorization) => authenticateBearer(/* required: "write"/"operate" */),
30
+ });
31
+
32
+ // app/api/v1/machines/[id]/route.ts
33
+ export const GET = api.withRead<{ id: string }>(async (request, identity, ctx) => {
34
+ const { id } = await ctx.params;
35
+ return apiJson(await getMachine(id));
36
+ });
37
+ ```
38
+
39
+ What "read" vs "write" means (ranked floor, orthogonal capability) is the authenticator's business — pair with `@rubriclab/rubrot-tokens`.
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@rubriclab/rubrot-api",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "description": "The fleet's API contract layer: structured errors, zod request parsing, pagination, and fail-closed authenticated route wrappers",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/RubricLab/Rubrot.git",
10
+ "directory": "packages/api"
11
+ },
12
+ "exports": {
13
+ ".": "./src/index.ts",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "src",
18
+ "README.md"
19
+ ],
20
+ "peerDependencies": {
21
+ "zod": "^4.0.0"
22
+ }
23
+ }
package/src/handler.ts ADDED
@@ -0,0 +1,103 @@
1
+ import { type ApiErrorCode, jsonError } from "./json";
2
+
3
+ /**
4
+ * The shared handler harness — vault's `withRead`/`withWrite` pattern with the
5
+ * auth check INJECTED, so each app supplies its own authenticator (a service
6
+ * token gate, a session cookie, an HMAC check) and keeps the three things
7
+ * every route must get right in one audited place:
8
+ *
9
+ * 1. Authentication for the RIGHT capability — the structural gate.
10
+ * 2. `no-store` on every path — a vended secret is never cached en route.
11
+ * 3. FAIL-CLOSED error handling — a handler that throws never leaks an
12
+ * internal error or a non-JSON body; it logs and returns a clean
13
+ * structured 500.
14
+ *
15
+ * The payoff: a consumer always gets parseable `{ error: { code, message } }`
16
+ * JSON on every failure path, the auth gate is impossible to forget on a new
17
+ * route, and a new endpoint is a few lines of real logic.
18
+ */
19
+
20
+ /** The context Next.js passes a dynamic route — params resolve lazily. */
21
+ export type RouteContext<P = Record<string, string | string[]>> = {
22
+ params: Promise<P>;
23
+ };
24
+
25
+ /** An authentication failure; `code` defaults by status when omitted. */
26
+ export type AuthFailure = {
27
+ ok: false;
28
+ status: number;
29
+ error: string;
30
+ code?: ApiErrorCode;
31
+ };
32
+
33
+ export type AuthResult<Identity> =
34
+ | { ok: true; identity: Identity }
35
+ | AuthFailure;
36
+
37
+ /**
38
+ * The injected auth check: raw `Authorization` header in, verdict out. Apps
39
+ * bind this to their own token engine (e.g. rubrot-tokens' authenticateBearer)
40
+ * — the wrapper never sees credentials, only the verdict.
41
+ */
42
+ export type Authenticator<Identity> = (
43
+ authorization: string | null,
44
+ ) => Promise<AuthResult<Identity>>;
45
+
46
+ export type AuthedHandler<Identity, P = Record<string, string | string[]>> = (
47
+ request: Request,
48
+ identity: Identity,
49
+ context: RouteContext<P>,
50
+ ) => Promise<Response>;
51
+
52
+ function failureCode(failure: AuthFailure): ApiErrorCode {
53
+ if (failure.code) return failure.code;
54
+ return failure.status === 403 ? "SCOPE_MISSING" : "AUTH_INVALID";
55
+ }
56
+
57
+ /**
58
+ * The core wrapper: authenticate with `authenticate`, then run the handler
59
+ * under a fail-closed catch. An auth failure and a thrown handler both resolve
60
+ * to the canonical structured error — never an unstructured 500.
61
+ */
62
+ export function withAuth<Identity, P = Record<string, string | string[]>>(
63
+ authenticate: Authenticator<Identity>,
64
+ handler: AuthedHandler<Identity, P>,
65
+ ): (request: Request, context: RouteContext<P>) => Promise<Response> {
66
+ return async (request, context) => {
67
+ const auth = await authenticate(request.headers.get("authorization"));
68
+ if (!auth.ok) return jsonError(failureCode(auth), auth.error, auth.status);
69
+ try {
70
+ return await handler(request, auth.identity, context);
71
+ } catch (error) {
72
+ console.error("[api] handler error", error);
73
+ return jsonError("INTERNAL", "internal error", 500);
74
+ }
75
+ };
76
+ }
77
+
78
+ export type ApiAuth<Identity> = {
79
+ /** Wrap a READ endpoint — every list/view/projection path. */
80
+ withRead: <P = Record<string, string | string[]>>(
81
+ handler: AuthedHandler<Identity, P>,
82
+ ) => (request: Request, context: RouteContext<P>) => Promise<Response>;
83
+ /** Wrap a WRITE endpoint — the provision/mutate path. */
84
+ withWrite: <P = Record<string, string | string[]>>(
85
+ handler: AuthedHandler<Identity, P>,
86
+ ) => (request: Request, context: RouteContext<P>) => Promise<Response>;
87
+ };
88
+
89
+ /**
90
+ * Bind read/write authenticators once (per app, in one lib file) and get the
91
+ * vault-style `withRead`/`withWrite` conveniences back. What "read" and
92
+ * "write" mean — ranked capability floor, orthogonal capability, anything —
93
+ * is entirely the authenticators' business.
94
+ */
95
+ export function createApiAuth<Identity>(authenticators: {
96
+ read: Authenticator<Identity>;
97
+ write: Authenticator<Identity>;
98
+ }): ApiAuth<Identity> {
99
+ return {
100
+ withRead: (handler) => withAuth(authenticators.read, handler),
101
+ withWrite: (handler) => withAuth(authenticators.write, handler),
102
+ };
103
+ }
package/src/index.ts ADDED
@@ -0,0 +1,30 @@
1
+ export {
2
+ type ApiAuth,
3
+ type AuthedHandler,
4
+ type Authenticator,
5
+ type AuthFailure,
6
+ type AuthResult,
7
+ createApiAuth,
8
+ type RouteContext,
9
+ withAuth,
10
+ } from "./handler";
11
+ export {
12
+ type ApiErrorBody,
13
+ type ApiErrorCode,
14
+ apiJson,
15
+ apiText,
16
+ jsonError,
17
+ type ParsedRequestJson,
18
+ parseRequestJson,
19
+ zodMessage,
20
+ } from "./json";
21
+ export {
22
+ limitForOffset,
23
+ type Pagination,
24
+ type PaginationQuery,
25
+ type ParsedPagination,
26
+ pageItems,
27
+ pageLimitOnlyItems,
28
+ paginationQueryContract,
29
+ parsePagination,
30
+ } from "./pagination";
package/src/json.ts ADDED
@@ -0,0 +1,100 @@
1
+ import type { ZodError, z } from "zod";
2
+
3
+ /**
4
+ * The fleet's structured API error contract — metal's `/api/v1` shape, adopted
5
+ * as the standard: every failure body is `{ error: { code, message } }` where
6
+ * `code` is a stable machine-readable enum and `message` is for humans. A
7
+ * consumer switches on `code`, never on message text.
8
+ *
9
+ * Every response built here carries `cache-control: no-store` (vault's rule):
10
+ * these surfaces vend credentials and live state, and a forgotten header on
11
+ * one route is exactly the kind of bug this package exists to delete.
12
+ */
13
+
14
+ export type ApiErrorCode =
15
+ | "AUTH_INVALID"
16
+ | "AUTH_MISSING"
17
+ | "BAD_REQUEST"
18
+ | "INTERNAL"
19
+ | "INVALID_JSON"
20
+ | "NOT_FOUND"
21
+ | "SCOPE_MISSING"
22
+ | "UPSTREAM_CONTRACT"
23
+ | "UPSTREAM_ERROR";
24
+
25
+ export type ApiErrorBody = {
26
+ error: {
27
+ code: ApiErrorCode;
28
+ message: string;
29
+ };
30
+ };
31
+
32
+ const noStore = { "cache-control": "no-store" } as const;
33
+
34
+ /** A non-cached JSON response — the canonical success shape. */
35
+ export function apiJson(data: unknown, status = 200): Response {
36
+ return Response.json(data, { status, headers: noStore });
37
+ }
38
+
39
+ /** A non-cached `text/plain` response — e.g. a dotenv projection. */
40
+ export function apiText(body: string, status = 200): Response {
41
+ return new Response(body, {
42
+ status,
43
+ headers: { "content-type": "text/plain; charset=utf-8", ...noStore },
44
+ });
45
+ }
46
+
47
+ /** The canonical structured failure: `{ error: { code, message } }`. */
48
+ export function jsonError(
49
+ code: ApiErrorCode,
50
+ message: string,
51
+ status: number,
52
+ ): Response {
53
+ return Response.json({ error: { code, message } } satisfies ApiErrorBody, {
54
+ status,
55
+ headers: noStore,
56
+ });
57
+ }
58
+
59
+ export type ParsedRequestJson<ParsedBody> =
60
+ | { success: true; data: ParsedBody }
61
+ | { success: false; response: Response };
62
+
63
+ /**
64
+ * Parse and validate a JSON request body against a zod contract. Failures come
65
+ * back as a ready-to-return Response: non-JSON is `INVALID_JSON`, a contract
66
+ * miss is `BAD_REQUEST` with the offending field named.
67
+ */
68
+ export async function parseRequestJson<ParsedBody>(
69
+ request: Request,
70
+ contract: z.ZodType<ParsedBody>,
71
+ ): Promise<ParsedRequestJson<ParsedBody>> {
72
+ let body: unknown;
73
+ try {
74
+ body = await request.json();
75
+ } catch {
76
+ return {
77
+ success: false,
78
+ response: jsonError("INVALID_JSON", "request body must be JSON", 400),
79
+ };
80
+ }
81
+
82
+ const parsedBody = contract.safeParse(body);
83
+ if (!parsedBody.success) {
84
+ return {
85
+ success: false,
86
+ response: jsonError("BAD_REQUEST", zodMessage(parsedBody.error), 400),
87
+ };
88
+ }
89
+
90
+ return { success: true, data: parsedBody.data };
91
+ }
92
+
93
+ export function zodMessage(error: ZodError): string {
94
+ const issue = error.issues[0];
95
+ if (!issue) return "invalid request";
96
+ // Name the offending field: a bare "expected string, received undefined"
97
+ // leaves the caller (and the dashboard) guessing which input was wrong.
98
+ const field = issue.path.join(".");
99
+ return field ? `${field}: ${issue.message}` : issue.message;
100
+ }
@@ -0,0 +1,92 @@
1
+ import { z } from "zod";
2
+ import { jsonError, zodMessage } from "./json";
3
+
4
+ /**
5
+ * Offset pagination for list endpoints — metal's `/api/v1` helpers. Query
6
+ * params parse through a strict contract with clamped bounds; the response
7
+ * envelope is `{ items, pagination: { limit, offset, total, nextOffset } }`
8
+ * where `nextOffset: null` means the caller has everything.
9
+ */
10
+
11
+ export const paginationQueryContract = z.strictObject({
12
+ limit: z.coerce.number().int().min(1).max(500).default(30),
13
+ offset: z.coerce.number().int().min(0).max(10_000).default(0),
14
+ });
15
+
16
+ export type PaginationQuery = z.infer<typeof paginationQueryContract>;
17
+
18
+ export type Pagination = {
19
+ limit: number;
20
+ offset: number;
21
+ total: number;
22
+ nextOffset: number | null;
23
+ };
24
+
25
+ export type ParsedPagination =
26
+ | { success: true; data: PaginationQuery }
27
+ | { success: false; response: Response };
28
+
29
+ /** Parse `?limit=&offset=` from a request URL; out-of-bounds is a 400. */
30
+ export function parsePagination(request: Request): ParsedPagination {
31
+ const searchParams = new URL(request.url).searchParams;
32
+ const parsedQuery = paginationQueryContract.safeParse({
33
+ limit: searchParams.get("limit") ?? undefined,
34
+ offset: searchParams.get("offset") ?? undefined,
35
+ });
36
+ if (!parsedQuery.success) {
37
+ return {
38
+ success: false,
39
+ response: jsonError("BAD_REQUEST", zodMessage(parsedQuery.error), 400),
40
+ };
41
+ }
42
+ return { success: true, data: parsedQuery.data };
43
+ }
44
+
45
+ /** Page a fully-materialized list; `total` is exact. */
46
+ export function pageItems<Item>(
47
+ items: readonly Item[],
48
+ query: PaginationQuery,
49
+ ): { items: Item[]; pagination: Pagination } {
50
+ const total = items.length;
51
+ const nextOffset =
52
+ query.offset + query.limit < total ? query.offset + query.limit : null;
53
+
54
+ return {
55
+ items: items.slice(query.offset, query.offset + query.limit),
56
+ pagination: {
57
+ limit: query.limit,
58
+ offset: query.offset,
59
+ total,
60
+ nextOffset,
61
+ },
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Page a list fetched with `limitForOffset` (one sentinel row past the page):
67
+ * `total` is a floor, not an exact count — it says "at least this many" when
68
+ * a next page exists, avoiding a COUNT(*) on hot paths.
69
+ */
70
+ export function pageLimitOnlyItems<Item>(
71
+ items: readonly Item[],
72
+ query: PaginationQuery,
73
+ ): { items: Item[]; pagination: Pagination } {
74
+ const page = items.slice(query.offset, query.offset + query.limit);
75
+ const hasNext = items.length > query.offset + query.limit;
76
+ const total = hasNext ? query.offset + query.limit + 1 : items.length;
77
+
78
+ return {
79
+ items: page,
80
+ pagination: {
81
+ limit: query.limit,
82
+ offset: query.offset,
83
+ total,
84
+ nextOffset: hasNext ? query.offset + query.limit : null,
85
+ },
86
+ };
87
+ }
88
+
89
+ /** The SQL LIMIT that lets `pageLimitOnlyItems` detect a next page. */
90
+ export function limitForOffset(query: PaginationQuery): number {
91
+ return query.limit + query.offset + 1;
92
+ }