@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 +29 -2
- package/package.json +1 -1
- package/src/handler.ts +6 -9
- package/src/index.ts +2 -8
- package/src/json.ts +42 -0
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
|
|
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.
|
|
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,
|
|
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
|
|
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
|
|
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 });
|