@rubriclab/rubrot-api 0.1.0 → 0.2.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 CHANGED
@@ -7,7 +7,9 @@ The fleet's API contract layer, folded together from the metal `/api/v1` `_lib`
7
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
8
 
9
9
  ```
10
- jsonError(code, message, status) structured failure
10
+ jsonError(code, message, status) structured failure, explicit code (the primitive)
11
+ apiError(message, status, code?) structured failure, code defaulted by status
12
+ codeForStatus(status) the default status → ApiErrorCode map
11
13
  apiJson(data, status?) non-cached JSON success
12
14
  apiText(body, status?) non-cached text/plain (dotenv projections)
13
15
  parseRequestJson(request, contract) JSON + zod in one step → data | ready 400
@@ -17,9 +19,34 @@ pageItems / pageLimitOnlyItems { items, pagination } envelope (exact / c
17
19
  limitForOffset(query) SQL LIMIT with the next-page sentinel row
18
20
  ```
19
21
 
22
+ ### Status → code
23
+
24
+ `codeForStatus(status)` is the one default map — the glue vault (`codeForStatus`)
25
+ and rubrot (`authErrorCode`) each hand-rolled, reconciled:
26
+
27
+ | status | code |
28
+ | ------ | --------------- |
29
+ | 400 | `BAD_REQUEST` |
30
+ | 401 | `AUTH_INVALID` |
31
+ | 403 | `SCOPE_MISSING` |
32
+ | 404 | `NOT_FOUND` |
33
+ | else | `INTERNAL` |
34
+
35
+ `apiError(message, status, code?)` builds a failure from a status alone
36
+ (`code` defaults via `codeForStatus`) or takes an explicit `code` to override —
37
+ it is vault's `apiError` and rubrot's `caughtApiError` folded into one, with
38
+ `jsonError` as the explicit-code primitive underneath.
39
+
40
+ `AUTH_MISSING` is **never** produced by the default map — a 401 defaults to
41
+ `AUTH_INVALID` (a presented credential was bad). "No credential at all" is a
42
+ distinction only the caller can draw, so it is **consumer-emitted**: pass
43
+ `code: "AUTH_MISSING"` (on an `AuthFailure`, or straight to `apiError`) where the
44
+ app decides a request carried no credentials. `INTERNAL` is the fail-closed
45
+ floor for any unmapped status.
46
+
20
47
  ## Authenticated routes
21
48
 
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`).
49
+ `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).
23
50
 
24
51
  ```ts
25
52
  // lib/api.ts — bind once per app
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubriclab/rubrot-api",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "The fleet's API contract layer: structured errors, zod request parsing, pagination, and fail-closed authenticated route wrappers",
package/src/handler.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type ApiErrorCode, jsonError } from "./json";
1
+ import { type ApiErrorCode, apiError } from "./json";
2
2
 
3
3
  /**
4
4
  * The shared handler harness — vault's `withRead`/`withWrite` pattern with the
@@ -49,15 +49,12 @@ export type AuthedHandler<Identity, P = Record<string, string | string[]>> = (
49
49
  context: RouteContext<P>,
50
50
  ) => Promise<Response>;
51
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
52
  /**
58
53
  * The core wrapper: authenticate with `authenticate`, then run the handler
59
54
  * under a fail-closed catch. An auth failure and a thrown handler both resolve
60
- * to the canonical structured error — never an unstructured 500.
55
+ * to the canonical structured error — never an unstructured 500. On an auth
56
+ * failure `apiError` fills `code` from `auth.code` if the authenticator set one
57
+ * (e.g. `AUTH_MISSING`), else defaults it via `codeForStatus`.
61
58
  */
62
59
  export function withAuth<Identity, P = Record<string, string | string[]>>(
63
60
  authenticate: Authenticator<Identity>,
@@ -65,12 +62,12 @@ export function withAuth<Identity, P = Record<string, string | string[]>>(
65
62
  ): (request: Request, context: RouteContext<P>) => Promise<Response> {
66
63
  return async (request, context) => {
67
64
  const auth = await authenticate(request.headers.get("authorization"));
68
- if (!auth.ok) return jsonError(failureCode(auth), auth.error, auth.status);
65
+ if (!auth.ok) return apiError(auth.error, auth.status, auth.code);
69
66
  try {
70
67
  return await handler(request, auth.identity, context);
71
68
  } catch (error) {
72
69
  console.error("[api] handler error", error);
73
- return jsonError("INTERNAL", "internal error", 500);
70
+ return apiError("internal error", 500);
74
71
  }
75
72
  };
76
73
  }
package/src/index.ts CHANGED
@@ -1,8 +1,5 @@
1
1
  export {
2
- type ApiAuth,
3
- type AuthedHandler,
4
2
  type Authenticator,
5
- type AuthFailure,
6
3
  type AuthResult,
7
4
  createApiAuth,
8
5
  type RouteContext,
@@ -11,20 +8,17 @@ export {
11
8
  export {
12
9
  type ApiErrorBody,
13
10
  type ApiErrorCode,
11
+ apiError,
14
12
  apiJson,
15
13
  apiText,
14
+ codeForStatus,
16
15
  jsonError,
17
- type ParsedRequestJson,
18
16
  parseRequestJson,
19
17
  zodMessage,
20
18
  } from "./json";
21
19
  export {
22
20
  limitForOffset,
23
- type Pagination,
24
- type PaginationQuery,
25
- type ParsedPagination,
26
21
  pageItems,
27
22
  pageLimitOnlyItems,
28
- paginationQueryContract,
29
23
  parsePagination,
30
24
  } from "./pagination";
package/src/json.ts CHANGED
@@ -31,6 +31,48 @@ export type ApiErrorBody = {
31
31
 
32
32
  const noStore = { "cache-control": "no-store" } as const;
33
33
 
34
+ /**
35
+ * The default status → code map — the glue every consumer hand-rolled (vault's
36
+ * `codeForStatus`, rubrot's `authErrorCode`), reconciled into one place:
37
+ *
38
+ * 400 → BAD_REQUEST 401 → AUTH_INVALID 403 → SCOPE_MISSING
39
+ * 404 → NOT_FOUND else → INTERNAL
40
+ *
41
+ * `AUTH_MISSING` is deliberately NOT here: a 401 defaults to `AUTH_INVALID`
42
+ * (a presented credential was bad/expired), and "no credential presented" is a
43
+ * distinction only the caller can draw — so it is consumer-emitted (pass a
44
+ * `code` explicitly), never inferred from status. `INTERNAL` is the fail-closed
45
+ * floor for anything unmapped — 5xx and unexpected codes alike.
46
+ */
47
+ export function codeForStatus(status: number): ApiErrorCode {
48
+ switch (status) {
49
+ case 400:
50
+ return "BAD_REQUEST";
51
+ case 401:
52
+ return "AUTH_INVALID";
53
+ case 403:
54
+ return "SCOPE_MISSING";
55
+ case 404:
56
+ return "NOT_FOUND";
57
+ default:
58
+ return "INTERNAL";
59
+ }
60
+ }
61
+
62
+ /**
63
+ * The status-defaulted structured failure — vault's `apiError` and rubrot's
64
+ * `caughtApiError`, one function: `code` defaults via `codeForStatus(status)`,
65
+ * or pass it to override (e.g. `AUTH_MISSING`, `INVALID_JSON`). `jsonError` is
66
+ * the explicit-code primitive underneath.
67
+ */
68
+ export function apiError(
69
+ message: string,
70
+ status: number,
71
+ code?: ApiErrorCode,
72
+ ): Response {
73
+ return jsonError(code ?? codeForStatus(status), message, status);
74
+ }
75
+
34
76
  /** A non-cached JSON response — the canonical success shape. */
35
77
  export function apiJson(data: unknown, status = 200): Response {
36
78
  return Response.json(data, { status, headers: noStore });