@zeroxsolutions/server 0.3.5 → 0.4.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/CHANGELOG.md +36 -0
- package/README.md +19 -6
- package/dist/content-negotiation.d.ts +17 -5
- package/dist/content-negotiation.d.ts.map +1 -1
- package/dist/content-negotiation.js +17 -5
- package/dist/error-handler.d.ts +13 -3
- package/dist/error-handler.d.ts.map +1 -1
- package/dist/error-handler.js +43 -7
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/not-found.d.ts +5 -0
- package/dist/not-found.d.ts.map +1 -0
- package/dist/not-found.js +8 -0
- package/dist/validation-hook.d.ts +7 -0
- package/dist/validation-hook.d.ts.map +1 -1
- package/dist/validation-hook.js +43 -24
- package/package.json +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
|
+
## 0.4.0 (2026-09-28)
|
|
2
|
+
|
|
3
|
+
### 🚀 Features
|
|
4
|
+
|
|
5
|
+
- ⚠️ **server:** challenge a 401 and keep a thrown response's headers ([e252634](https://github.com/zeroxsolutions/zeroxsolutions/commit/e252634))
|
|
6
|
+
- ⚠️ **server:** answer a refused query as 400 with its parameter ([61ffe94](https://github.com/zeroxsolutions/zeroxsolutions/commit/61ffe94))
|
|
7
|
+
|
|
8
|
+
### 🩹 Fixes
|
|
9
|
+
|
|
10
|
+
- **server:** name the challenge on every 401 ([0a0b2af](https://github.com/zeroxsolutions/zeroxsolutions/commit/0a0b2af))
|
|
11
|
+
- ⚠️ **server:** point error sources at what exists, name unknown keys ([44d2116](https://github.com/zeroxsolutions/zeroxsolutions/commit/44d2116))
|
|
12
|
+
- ⚠️ **server:** label only json:api bodies and answer unmatched routes ([a6d3a08](https://github.com/zeroxsolutions/zeroxsolutions/commit/a6d3a08))
|
|
13
|
+
|
|
14
|
+
### ⚠️ Breaking Changes
|
|
15
|
+
|
|
16
|
+
- **server:** point error sources at what exists, name unknown keys ([44d2116](https://github.com/zeroxsolutions/zeroxsolutions/commit/44d2116))
|
|
17
|
+
a missing member points at its parent; a whole-target
|
|
18
|
+
issue carries pointer "" for a body and no source otherwise; an
|
|
19
|
+
unrecognized query key is its own 400.
|
|
20
|
+
- **server:** label only json:api bodies and answer unmatched routes ([a6d3a08](https://github.com/zeroxsolutions/zeroxsolutions/commit/a6d3a08))
|
|
21
|
+
a body with no Content-Type is 415, and a response of
|
|
22
|
+
another media type keeps its own header.
|
|
23
|
+
- **server:** challenge a 401 and keep a thrown response's headers ([e252634](https://github.com/zeroxsolutions/zeroxsolutions/commit/e252634))
|
|
24
|
+
createErrorHandler requires authenticationChallenge.
|
|
25
|
+
- **server:** answer a refused query as 400 with its parameter ([61ffe94](https://github.com/zeroxsolutions/zeroxsolutions/commit/61ffe94))
|
|
26
|
+
QueryNotPermitted is answered by the library before resolve, as <namespace>.query_not_permitted.
|
|
27
|
+
|
|
28
|
+
### ❤️ Thank You
|
|
29
|
+
|
|
30
|
+
- Claude Opus 5.5 (1M context)
|
|
31
|
+
- Lương Văn Tú
|
|
32
|
+
|
|
33
|
+
## 0.3.5 (2026-09-27)
|
|
34
|
+
|
|
35
|
+
This was a version bump only for @zeroxsolutions/server to align it with other projects, there were no code changes.
|
|
36
|
+
|
|
1
37
|
## 0.3.4 (2026-09-27)
|
|
2
38
|
|
|
3
39
|
### 🚀 Features
|
package/README.md
CHANGED
|
@@ -28,8 +28,8 @@ pnpm add @zeroxsolutions/server
|
|
|
28
28
|
```
|
|
29
29
|
|
|
30
30
|
`hono`, `zod` and `@logtape/logtape` are peers - the worker already declares them.
|
|
31
|
-
`@zeroxsolutions/jsonapi` and `@zeroxsolutions/response` come with it, as
|
|
32
|
-
CALLS them rather than reading a host's instance.
|
|
31
|
+
`@zeroxsolutions/jsonapi`, `@zeroxsolutions/query` and `@zeroxsolutions/response` come with it, as
|
|
32
|
+
dependencies: this package CALLS them rather than reading a host's instance.
|
|
33
33
|
|
|
34
34
|
## Quickstart
|
|
35
35
|
|
|
@@ -78,11 +78,13 @@ why `Content-Type` is read on a write only, and why an empty `Accept` is refused
|
|
|
78
78
|
`createErrorHandler` is the app's `onError`, and every failure renders through it as one
|
|
79
79
|
`application/vnd.api+json` error document carrying `jsonapi: { version: '1.1' }`. It asks, in order:
|
|
80
80
|
|
|
81
|
-
1.
|
|
81
|
+
1. a query the permit-list refused (`QueryNotPermitted`), answered `400` as
|
|
82
|
+
`<namespace>.query_not_permitted`, naming the parameter in `source.parameter`, before `resolve` sees it;
|
|
83
|
+
2. `resolve(err, c)` - where a surface's domain error map answers, through
|
|
82
84
|
`@zeroxsolutions/response`'s `createDomainErrorResolver`;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
3. a request the validation hook rejected, one error object per issue;
|
|
86
|
+
4. a `JsonApiError`, rendered as it was thrown;
|
|
87
|
+
5. otherwise the status alone - a hono `HTTPException` at its own, anything else as a 500.
|
|
86
88
|
|
|
87
89
|
Whichever step answers, the handler writes one `http.error.answered` line under `category` before the
|
|
88
90
|
document goes out, at the level the answered status class decides: a 5xx at error, anything else at
|
|
@@ -107,10 +109,21 @@ app.onError(
|
|
|
107
109
|
category: LOG_CATEGORY,
|
|
108
110
|
resolve: createDomainErrorResolver(apiErrors),
|
|
109
111
|
requestId: (c) => c.var.requestId,
|
|
112
|
+
authenticationChallenge: 'Bearer',
|
|
110
113
|
}),
|
|
111
114
|
);
|
|
112
115
|
```
|
|
113
116
|
|
|
117
|
+
`jsonApiNotFound({ namespace })` is `app.notFound(...)` - the answer for a route nobody declared. hono
|
|
118
|
+
runs `notFound` outside `onError`, so this renders the same 404 error document without writing an
|
|
119
|
+
`http.error.answered` line or stamping a request id: there is no failure here for the log line to join
|
|
120
|
+
to, since nothing was thrown.
|
|
121
|
+
|
|
122
|
+
```ts
|
|
123
|
+
// entrypoints/http/app.ts
|
|
124
|
+
app.notFound(jsonApiNotFound({ namespace: 'api' }));
|
|
125
|
+
```
|
|
126
|
+
|
|
114
127
|
## Why a binder rather than a ready middleware
|
|
115
128
|
|
|
116
129
|
The schema and the log category are the package's, not the library's: the schema is what the transport
|
|
@@ -2,18 +2,26 @@ import type { MiddlewareHandler } from 'hono';
|
|
|
2
2
|
import type { SurfaceVocabulary } from './surface-vocabulary.js';
|
|
3
3
|
/**
|
|
4
4
|
* Hono adapter over {@link negotiateJsonApi}: rejects with 415/406 per JSON:API v1.1 content
|
|
5
|
-
* negotiation, then
|
|
5
|
+
* negotiation, then labels a JSON:API response body with the JSON:API media type.
|
|
6
6
|
*
|
|
7
7
|
* `Content-Type` is read only where the request carries content - a bodyless `Request` has `body === null`,
|
|
8
8
|
* a write's is a stream - because a header describes a representation and a read route has none, so an
|
|
9
|
-
* unconditional read would cost a client sending a blanket default the whole collection.
|
|
9
|
+
* unconditional read would cost a client sending a blanket default the whole collection. A write that does
|
|
10
|
+
* carry a body but sent no `Content-Type` reads as `''`, which the negotiator refuses (415): a wildcard never
|
|
11
|
+
* stands in for the one type this surface reads.
|
|
12
|
+
*
|
|
13
|
+
* The response side only labels a body `c.json` left as `application/json` (or with no `Content-Type` at all):
|
|
14
|
+
* a route that answers `c.text(...)` or `c.body(null, 204)` keeps its own header, since neither is a JSON:API
|
|
15
|
+
* document.
|
|
10
16
|
*
|
|
11
17
|
* @example
|
|
12
18
|
* ```ts
|
|
13
19
|
* app.use('*', jsonApiContentNegotiation({ namespace: 'api' })); // one middleware, before the routes
|
|
14
20
|
* ```
|
|
15
21
|
*/
|
|
16
|
-
export declare function jsonApiContentNegotiation(options: SurfaceVocabulary
|
|
22
|
+
export declare function jsonApiContentNegotiation(options: SurfaceVocabulary & {
|
|
23
|
+
supportedExtensions?: readonly string[];
|
|
24
|
+
}): MiddlewareHandler;
|
|
17
25
|
/**
|
|
18
26
|
* The refusals {@link jsonApiContentNegotiation} answers with, for a route to spread into its declared
|
|
19
27
|
* responses, so a status the gate gains is declared wherever it is mounted.
|
|
@@ -109,9 +117,11 @@ export declare const NEGOTIATION_RESPONSES: {
|
|
|
109
117
|
}, import("zod/v4/core").$strip>>;
|
|
110
118
|
jsonapi: import("zod").ZodOptional<import("zod").ZodObject<{
|
|
111
119
|
version: import("zod").ZodOptional<import("zod").ZodString>;
|
|
120
|
+
ext: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
|
|
121
|
+
profile: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
|
|
112
122
|
meta: import("zod").ZodOptional<import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodUnknown>>;
|
|
113
123
|
}, import("zod/v4/core").$strip>>;
|
|
114
|
-
}, import("zod/v4/core").$
|
|
124
|
+
}, import("zod/v4/core").$strict>>;
|
|
115
125
|
readonly 415: import("@zeroxsolutions/response").JsonApiResponse<import("zod").ZodObject<{
|
|
116
126
|
errors: import("zod").ZodArray<import("zod").ZodObject<{
|
|
117
127
|
id: import("zod").ZodOptional<import("zod").ZodString>;
|
|
@@ -197,8 +207,10 @@ export declare const NEGOTIATION_RESPONSES: {
|
|
|
197
207
|
}, import("zod/v4/core").$strip>>;
|
|
198
208
|
jsonapi: import("zod").ZodOptional<import("zod").ZodObject<{
|
|
199
209
|
version: import("zod").ZodOptional<import("zod").ZodString>;
|
|
210
|
+
ext: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
|
|
211
|
+
profile: import("zod").ZodOptional<import("zod").ZodArray<import("zod").ZodString>>;
|
|
200
212
|
meta: import("zod").ZodOptional<import("zod").ZodRecord<import("zod").ZodString, import("zod").ZodUnknown>>;
|
|
201
213
|
}, import("zod/v4/core").$strip>>;
|
|
202
|
-
}, import("zod/v4/core").$
|
|
214
|
+
}, import("zod/v4/core").$strict>>;
|
|
203
215
|
};
|
|
204
216
|
//# sourceMappingURL=content-negotiation.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"content-negotiation.d.ts","sourceRoot":"","sources":["../src/content-negotiation.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAC9C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAEjE
|
|
1
|
+
{"version":3,"file":"content-negotiation.d.ts","sourceRoot":"","sources":["../src/content-negotiation.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,MAAM,CAAC;AAC9C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAEjE;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,iBAAiB,GAAG;IAAE,mBAAmB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,GACvE,iBAAiB,CAanB;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAGxB,CAAC"}
|
|
@@ -2,11 +2,17 @@ import { negotiateJsonApi } from '@zeroxsolutions/jsonapi';
|
|
|
2
2
|
import { errorResponse, JSON_API_MEDIA_TYPE } from '@zeroxsolutions/response';
|
|
3
3
|
/**
|
|
4
4
|
* Hono adapter over {@link negotiateJsonApi}: rejects with 415/406 per JSON:API v1.1 content
|
|
5
|
-
* negotiation, then
|
|
5
|
+
* negotiation, then labels a JSON:API response body with the JSON:API media type.
|
|
6
6
|
*
|
|
7
7
|
* `Content-Type` is read only where the request carries content - a bodyless `Request` has `body === null`,
|
|
8
8
|
* a write's is a stream - because a header describes a representation and a read route has none, so an
|
|
9
|
-
* unconditional read would cost a client sending a blanket default the whole collection.
|
|
9
|
+
* unconditional read would cost a client sending a blanket default the whole collection. A write that does
|
|
10
|
+
* carry a body but sent no `Content-Type` reads as `''`, which the negotiator refuses (415): a wildcard never
|
|
11
|
+
* stands in for the one type this surface reads.
|
|
12
|
+
*
|
|
13
|
+
* The response side only labels a body `c.json` left as `application/json` (or with no `Content-Type` at all):
|
|
14
|
+
* a route that answers `c.text(...)` or `c.body(null, 204)` keeps its own header, since neither is a JSON:API
|
|
15
|
+
* document.
|
|
10
16
|
*
|
|
11
17
|
* @example
|
|
12
18
|
* ```ts
|
|
@@ -14,13 +20,19 @@ import { errorResponse, JSON_API_MEDIA_TYPE } from '@zeroxsolutions/response';
|
|
|
14
20
|
* ```
|
|
15
21
|
*/
|
|
16
22
|
export function jsonApiContentNegotiation(options) {
|
|
23
|
+
const supported = new Set(options.supportedExtensions ?? []);
|
|
17
24
|
return async (c, next) => {
|
|
18
|
-
|
|
19
|
-
const
|
|
25
|
+
// A body sent without a Content-Type is judged as an empty one, which the negotiator refuses.
|
|
26
|
+
const contentType = c.req.raw.body === null ? undefined : (c.req.header('Content-Type') ?? '');
|
|
27
|
+
const error = negotiateJsonApi(contentType, c.req.header('Accept'), options.namespace, supported);
|
|
20
28
|
if (error)
|
|
21
29
|
throw error;
|
|
22
30
|
await next();
|
|
23
|
-
c.
|
|
31
|
+
// `c.json` writes application/json, which is how a document leaves a handler; any other type is not a document.
|
|
32
|
+
const current = c.res.headers.get('Content-Type');
|
|
33
|
+
const unlabelled = current === null || current.split(';')[0]?.trim().toLowerCase() === 'application/json';
|
|
34
|
+
if (c.res.body !== null && unlabelled)
|
|
35
|
+
c.header('Content-Type', JSON_API_MEDIA_TYPE);
|
|
24
36
|
};
|
|
25
37
|
}
|
|
26
38
|
/**
|
package/dist/error-handler.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Context, ErrorHandler } from 'hono';
|
|
2
|
-
import { type ResolvedError } from '@zeroxsolutions/response';
|
|
2
|
+
import { type ErrorObject, type ResolvedError } from '@zeroxsolutions/response';
|
|
3
3
|
import type { SurfaceVocabulary } from './surface-vocabulary.js';
|
|
4
|
+
export declare function errorObjectByStatus(err: Error, status: number, namespace: string): ErrorObject;
|
|
4
5
|
/** Tunes {@link createErrorHandler} for a specific transport (the edge gateway vs a service). */
|
|
5
6
|
export interface ErrorHandlerOptions extends SurfaceVocabulary {
|
|
6
7
|
/**
|
|
@@ -13,15 +14,24 @@ export interface ErrorHandlerOptions extends SurfaceVocabulary {
|
|
|
13
14
|
resolve?: (err: Error, c: Context) => ResolvedError | undefined;
|
|
14
15
|
/** A request id stamped onto each error object's `id` when it carries none, so a client can correlate. */
|
|
15
16
|
requestId?: (c: Context) => string | undefined | null;
|
|
17
|
+
/**
|
|
18
|
+
* The challenge every 401 names in `WWW-Authenticate`, for example `Bearer`. Required: RFC 9110 makes a 401 without
|
|
19
|
+
* one a malformed answer, and a client then cannot tell which scheme to retry with.
|
|
20
|
+
*/
|
|
21
|
+
authenticationChallenge: string;
|
|
16
22
|
}
|
|
17
23
|
/**
|
|
18
24
|
* The one central `onError` a transport installs, rendering every failure as one `application/vnd.api+json`
|
|
19
|
-
* `errors[]` document. It asks, in order: `resolve`, then a guard's own
|
|
20
|
-
* hook rejected, then a {@link JsonApiError} as thrown, then the HTTP
|
|
25
|
+
* `errors[]` document. It asks, in order: a query the permit-list refused, then `resolve`, then a guard's own
|
|
26
|
+
* refusal, then a request the validation hook rejected, then a {@link JsonApiError} as thrown, then the HTTP
|
|
27
|
+
* status alone.
|
|
21
28
|
*
|
|
22
29
|
* Every answer is written to the log before it goes out, at the level its status class decides: a 5xx at error,
|
|
23
30
|
* because this side went wrong, anything else at warn, because the request was refused. Without the line, a
|
|
24
31
|
* mapped 500 and a guard's 401 reach the client leaving no trace that the request arrived.
|
|
32
|
+
*
|
|
33
|
+
* Whichever step answers a 401, the response carries `WWW-Authenticate: {@link ErrorHandlerOptions.authenticationChallenge}`
|
|
34
|
+
* - unless a kept `HTTPException` response already names one, which is left as the more specific answer.
|
|
25
35
|
*/
|
|
26
36
|
export declare function createErrorHandler(options: ErrorHandlerOptions): ErrorHandler;
|
|
27
37
|
//# sourceMappingURL=error-handler.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"error-handler.d.ts","sourceRoot":"","sources":["../src/error-handler.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"error-handler.d.ts","sourceRoot":"","sources":["../src/error-handler.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,OAAO,EAAE,YAAY,EAAE,MAAM,MAAM,CAAC;AAIlD,OAAO,EAML,KAAK,WAAW,EAChB,KAAK,aAAa,EACnB,MAAM,0BAA0B,CAAC;AAGlC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AA4BjE,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,WAAW,CAS9F;AAED,iGAAiG;AACjG,MAAM,WAAW,mBAAoB,SAAQ,iBAAiB;IAC5D;;;;OAIG;IACH,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;IAC5B,uGAAuG;IACvG,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,KAAK,EAAE,CAAC,EAAE,OAAO,KAAK,aAAa,GAAG,SAAS,CAAC;IAChE,0GAA0G;IAC1G,SAAS,CAAC,EAAE,CAAC,CAAC,EAAE,OAAO,KAAK,MAAM,GAAG,SAAS,GAAG,IAAI,CAAC;IACtD;;;OAGG;IACH,uBAAuB,EAAE,MAAM,CAAC;CACjC;AAyDD;;;;;;;;;;;;GAYG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,mBAAmB,GAAG,YAAY,CAkC7E"}
|
package/dist/error-handler.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { getLogger } from '@logtape/logtape';
|
|
2
2
|
import { HTTPException } from 'hono/http-exception';
|
|
3
|
+
import { QueryNotPermitted } from '@zeroxsolutions/query';
|
|
3
4
|
import { BASE_ERROR_CODES, JsonApiError, JSON_API_MEDIA_TYPE, toErrorDocument, toErrorObject, } from '@zeroxsolutions/response';
|
|
4
5
|
import { PermissionRefused } from './permission-refused.js';
|
|
5
6
|
import { RequestInvalid } from './request-invalid.js';
|
|
@@ -14,7 +15,7 @@ for (const [name, meta] of Object.entries(BASE_ERROR_CODES)) {
|
|
|
14
15
|
// The message template both levels write, so one search finds every error this handler answered.
|
|
15
16
|
const ANSWERED_LINE = 'http.error.answered {method} {path} {status} {claimedBy}';
|
|
16
17
|
// Projects a failure hono raised - or one nothing claimed - onto a JSON:API error object by HTTP status.
|
|
17
|
-
function errorObjectByStatus(err, status, namespace) {
|
|
18
|
+
export function errorObjectByStatus(err, status, namespace) {
|
|
18
19
|
const meta = META_BY_STATUS.get(status) ?? { code: 'error', title: 'Error' };
|
|
19
20
|
const detail = err instanceof HTTPException ? err.message : undefined;
|
|
20
21
|
return {
|
|
@@ -24,9 +25,19 @@ function errorObjectByStatus(err, status, namespace) {
|
|
|
24
25
|
...(detail && detail !== meta.title ? { detail } : {}),
|
|
25
26
|
};
|
|
26
27
|
}
|
|
27
|
-
// Asks each step of the dispatch in turn: `resolve`, a guard's refusal, a request
|
|
28
|
-
// `JsonApiError` as thrown, the HTTP status alone.
|
|
28
|
+
// Asks each step of the dispatch in turn: a query the permit-list refused, `resolve`, a guard's refusal, a request
|
|
29
|
+
// the validation hook rejected, a `JsonApiError` as thrown, the HTTP status alone.
|
|
29
30
|
function claimError(err, c, options) {
|
|
31
|
+
if (err instanceof QueryNotPermitted) {
|
|
32
|
+
const error = {
|
|
33
|
+
status: '400',
|
|
34
|
+
code: `${options.namespace}.query_not_permitted`,
|
|
35
|
+
title: BASE_ERROR_CODES.BAD_REQUEST.title,
|
|
36
|
+
detail: err.message,
|
|
37
|
+
source: { parameter: err.parameter },
|
|
38
|
+
};
|
|
39
|
+
return { claimedBy: 'query', resolved: { status: 400, errors: [error] } };
|
|
40
|
+
}
|
|
30
41
|
const resolved = options.resolve?.(err, c);
|
|
31
42
|
if (resolved) {
|
|
32
43
|
return { claimedBy: 'resolve', resolved };
|
|
@@ -46,23 +57,47 @@ function claimError(err, c, options) {
|
|
|
46
57
|
return { claimedBy: 'jsonapi', resolved: { status: err.status, errors: [toErrorObject(err)] } };
|
|
47
58
|
}
|
|
48
59
|
const status = err instanceof HTTPException ? err.status : 500;
|
|
49
|
-
|
|
60
|
+
// A thrown HTTPException can carry its own Response (e.g. a rate limiter's Retry-After); content-type and
|
|
61
|
+
// content-length are this render's own to set, not the thrown response's.
|
|
62
|
+
const kept = err instanceof HTTPException && err.res
|
|
63
|
+
? Object.fromEntries([...err.res.headers].filter(([name]) => name !== 'content-type' && name !== 'content-length'))
|
|
64
|
+
: undefined;
|
|
65
|
+
return {
|
|
66
|
+
claimedBy: 'status',
|
|
67
|
+
resolved: { status, errors: [errorObjectByStatus(err, status, options.namespace)] },
|
|
68
|
+
headers: kept,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
// Names the challenge on a 401 whichever step claimed it, since a step answers its own status rather than the
|
|
72
|
+
// header RFC 9110 requires beside it. A kept HTTPException header already naming one is left alone: naming a
|
|
73
|
+
// second challenge would tell the client to retry with THIS scheme, which may not be the one the response chose.
|
|
74
|
+
function withChallenge(claimed, authenticationChallenge) {
|
|
75
|
+
if (claimed.resolved.status !== 401)
|
|
76
|
+
return claimed;
|
|
77
|
+
const named = Object.keys(claimed.headers ?? {}).some((name) => name.toLowerCase() === 'www-authenticate');
|
|
78
|
+
if (named)
|
|
79
|
+
return claimed;
|
|
80
|
+
return { ...claimed, headers: { ...claimed.headers, 'WWW-Authenticate': authenticationChallenge } };
|
|
50
81
|
}
|
|
51
82
|
/**
|
|
52
83
|
* The one central `onError` a transport installs, rendering every failure as one `application/vnd.api+json`
|
|
53
|
-
* `errors[]` document. It asks, in order: `resolve`, then a guard's own
|
|
54
|
-
* hook rejected, then a {@link JsonApiError} as thrown, then the HTTP
|
|
84
|
+
* `errors[]` document. It asks, in order: a query the permit-list refused, then `resolve`, then a guard's own
|
|
85
|
+
* refusal, then a request the validation hook rejected, then a {@link JsonApiError} as thrown, then the HTTP
|
|
86
|
+
* status alone.
|
|
55
87
|
*
|
|
56
88
|
* Every answer is written to the log before it goes out, at the level its status class decides: a 5xx at error,
|
|
57
89
|
* because this side went wrong, anything else at warn, because the request was refused. Without the line, a
|
|
58
90
|
* mapped 500 and a guard's 401 reach the client leaving no trace that the request arrived.
|
|
91
|
+
*
|
|
92
|
+
* Whichever step answers a 401, the response carries `WWW-Authenticate: {@link ErrorHandlerOptions.authenticationChallenge}`
|
|
93
|
+
* - unless a kept `HTTPException` response already names one, which is left as the more specific answer.
|
|
59
94
|
*/
|
|
60
95
|
export function createErrorHandler(options) {
|
|
61
96
|
const logger = getLogger(options.category);
|
|
62
97
|
return (err, c) => {
|
|
63
98
|
const requestId = options.requestId?.(c);
|
|
64
99
|
const stamp = (e) => (requestId && !e.id ? { ...e, id: requestId } : e);
|
|
65
|
-
const { claimedBy, resolved, logged } = claimError(err, c, options);
|
|
100
|
+
const { claimedBy, resolved, logged, headers } = withChallenge(claimError(err, c, options), options.authenticationChallenge);
|
|
66
101
|
const line = {
|
|
67
102
|
// First, so a step's own facts can never take the place of the ones every line carries.
|
|
68
103
|
...logged,
|
|
@@ -84,6 +119,7 @@ export function createErrorHandler(options) {
|
|
|
84
119
|
}
|
|
85
120
|
const doc = toErrorDocument(resolved.errors.map(stamp));
|
|
86
121
|
return c.body(JSON.stringify(doc), resolved.status, {
|
|
122
|
+
...headers,
|
|
87
123
|
'Content-Type': JSON_API_MEDIA_TYPE,
|
|
88
124
|
});
|
|
89
125
|
};
|
package/dist/index.d.ts
CHANGED
|
@@ -5,6 +5,7 @@ export { createEnvironment, type EnvironmentOptions } from './environment.js';
|
|
|
5
5
|
export type { SurfaceVocabulary } from './surface-vocabulary.js';
|
|
6
6
|
export { jsonApiContentNegotiation, NEGOTIATION_RESPONSES } from './content-negotiation.js';
|
|
7
7
|
export { createErrorHandler, type ErrorHandlerOptions } from './error-handler.js';
|
|
8
|
+
export { jsonApiNotFound } from './not-found.js';
|
|
8
9
|
export { PermissionRefused } from './permission-refused.js';
|
|
9
10
|
export { Unauthenticated } from './unauthenticated.js';
|
|
10
11
|
export { createValidationHook } from './validation-hook.js';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EAAE,iBAAiB,EAAE,KAAK,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAC9E,YAAY,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AACjE,OAAO,EAAE,yBAAyB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAC5F,OAAO,EAAE,kBAAkB,EAAE,KAAK,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAClF,OAAO,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAC5D,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AACvD,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,OAAO,EAAE,iBAAiB,EAAE,KAAK,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAC9E,YAAY,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AACjE,OAAO,EAAE,yBAAyB,EAAE,qBAAqB,EAAE,MAAM,0BAA0B,CAAC;AAC5F,OAAO,EAAE,kBAAkB,EAAE,KAAK,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAClF,OAAO,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAC5D,OAAO,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAC;AACvD,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
export { createEnvironment } from './environment.js';
|
|
5
5
|
export { jsonApiContentNegotiation, NEGOTIATION_RESPONSES } from './content-negotiation.js';
|
|
6
6
|
export { createErrorHandler } from './error-handler.js';
|
|
7
|
+
export { jsonApiNotFound } from './not-found.js';
|
|
7
8
|
export { PermissionRefused } from './permission-refused.js';
|
|
8
9
|
export { Unauthenticated } from './unauthenticated.js';
|
|
9
10
|
export { createValidationHook } from './validation-hook.js';
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { NotFoundHandler } from 'hono';
|
|
2
|
+
import type { SurfaceVocabulary } from './surface-vocabulary.js';
|
|
3
|
+
/** The `notFound` handler a JSON:API surface installs, so a route nobody declared answers an error document. */
|
|
4
|
+
export declare function jsonApiNotFound(options: SurfaceVocabulary): NotFoundHandler;
|
|
5
|
+
//# sourceMappingURL=not-found.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"not-found.d.ts","sourceRoot":"","sources":["../src/not-found.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,MAAM,CAAC;AAE5C,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAEjE,gHAAgH;AAChH,wBAAgB,eAAe,CAAC,OAAO,EAAE,iBAAiB,GAAG,eAAe,CAK3E"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { JSON_API_MEDIA_TYPE, toErrorDocument } from '@zeroxsolutions/response';
|
|
2
|
+
import { errorObjectByStatus } from './error-handler.js';
|
|
3
|
+
/** The `notFound` handler a JSON:API surface installs, so a route nobody declared answers an error document. */
|
|
4
|
+
export function jsonApiNotFound(options) {
|
|
5
|
+
return (c) => c.body(JSON.stringify(toErrorDocument(errorObjectByStatus(new Error('Not Found'), 404, options.namespace))), 404, {
|
|
6
|
+
'Content-Type': JSON_API_MEDIA_TYPE,
|
|
7
|
+
});
|
|
8
|
+
}
|
|
@@ -9,6 +9,13 @@ import type { SurfaceVocabulary } from './surface-vocabulary.js';
|
|
|
9
9
|
* failure, carrying the request id that handler stamps; an app without that handler answers the throw with hono's
|
|
10
10
|
* text 500. @hono/zod-validator 0.9.0 lets a hook's throw through, and answers `c.json(result, 400)` itself unless a
|
|
11
11
|
* hook answers or throws first - which is why the hook is still passed to the constructor.
|
|
12
|
+
*
|
|
13
|
+
* A body issue's `source.pointer` names the deepest object that exists in what the client sent, not the member the
|
|
14
|
+
* schema expected - a missing member has no member of its own to point at, so the pointer stops at its parent (`""`
|
|
15
|
+
* when nothing past the document root was sent). A query issue names its parameter; a route's query schema is a
|
|
16
|
+
* house query schema or a `z.strictObject`, and each key such a schema does not recognize answers as its own error
|
|
17
|
+
* object naming that key. A header issue names its header. A `param` or `cookie` issue carries no spec member for a
|
|
18
|
+
* path segment, so it goes in `meta` instead.
|
|
12
19
|
*/
|
|
13
20
|
export declare function createValidationHook<E extends Env = Env>(options: SurfaceVocabulary): Hook<unknown, E, string, undefined>;
|
|
14
21
|
//# sourceMappingURL=validation-hook.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"validation-hook.d.ts","sourceRoot":"","sources":["../src/validation-hook.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,mBAAmB,CAAC;AAC9C,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,MAAM,CAAC;AAGhC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;
|
|
1
|
+
{"version":3,"file":"validation-hook.d.ts","sourceRoot":"","sources":["../src/validation-hook.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,mBAAmB,CAAC;AAC9C,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,MAAM,CAAC;AAGhC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAqBjE;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,SAAS,GAAG,GAAG,GAAG,EACtD,OAAO,EAAE,iBAAiB,GACzB,IAAI,CAAC,OAAO,EAAE,CAAC,EAAE,MAAM,EAAE,SAAS,CAAC,CA+BrC"}
|
package/dist/validation-hook.js
CHANGED
|
@@ -1,22 +1,19 @@
|
|
|
1
1
|
import { BASE_ERROR_CODES } from '@zeroxsolutions/response';
|
|
2
2
|
import { RequestInvalid } from './request-invalid.js';
|
|
3
|
-
/**
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
if (target === 'query')
|
|
15
|
-
return { source: { parameter: path } };
|
|
16
|
-
if (target === 'header')
|
|
17
|
-
return { source: { header: path } };
|
|
18
|
-
return { meta: { target, path } };
|
|
3
|
+
/** The deepest prefix of `path` that exists in what the client sent, which is where a pointer may point. */
|
|
4
|
+
function existingPrefix(data, path) {
|
|
5
|
+
const found = [];
|
|
6
|
+
let current = data;
|
|
7
|
+
for (const segment of path) {
|
|
8
|
+
if (typeof current !== 'object' || current === null || !Object.hasOwn(current, segment))
|
|
9
|
+
break;
|
|
10
|
+
current = current[segment];
|
|
11
|
+
found.push(segment);
|
|
12
|
+
}
|
|
13
|
+
return found;
|
|
19
14
|
}
|
|
15
|
+
/** A JSON Pointer to a path, `""` for the whole document (RFC 6901 escapes `~` and `/`). */
|
|
16
|
+
const pointerTo = (path) => path.map((segment) => `/${String(segment).replaceAll('~', '~0').replaceAll('/', '~1')}`).join('');
|
|
20
17
|
/**
|
|
21
18
|
* The `defaultHook` an `OpenAPIHono` is constructed with, rejecting a request its own schema refuses as one error
|
|
22
19
|
* object per issue - 422 for a `json` or `form` target, 400 otherwise.
|
|
@@ -25,6 +22,13 @@ function sourceFor(target, path) {
|
|
|
25
22
|
* failure, carrying the request id that handler stamps; an app without that handler answers the throw with hono's
|
|
26
23
|
* text 500. @hono/zod-validator 0.9.0 lets a hook's throw through, and answers `c.json(result, 400)` itself unless a
|
|
27
24
|
* hook answers or throws first - which is why the hook is still passed to the constructor.
|
|
25
|
+
*
|
|
26
|
+
* A body issue's `source.pointer` names the deepest object that exists in what the client sent, not the member the
|
|
27
|
+
* schema expected - a missing member has no member of its own to point at, so the pointer stops at its parent (`""`
|
|
28
|
+
* when nothing past the document root was sent). A query issue names its parameter; a route's query schema is a
|
|
29
|
+
* house query schema or a `z.strictObject`, and each key such a schema does not recognize answers as its own error
|
|
30
|
+
* object naming that key. A header issue names its header. A `param` or `cookie` issue carries no spec member for a
|
|
31
|
+
* path segment, so it goes in `meta` instead.
|
|
28
32
|
*/
|
|
29
33
|
export function createValidationHook(options) {
|
|
30
34
|
return (result) => {
|
|
@@ -34,14 +38,29 @@ export function createValidationHook(options) {
|
|
|
34
38
|
const body = target === 'json' || target === 'form';
|
|
35
39
|
const { status, title } = body ? BASE_ERROR_CODES.UNPROCESSABLE_ENTITY : BASE_ERROR_CODES.BAD_REQUEST;
|
|
36
40
|
const code = `${options.namespace}.${body ? 'unprocessable_entity' : 'bad_request'}`;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
detail: issue.message
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
// @hono/zod-validator 0.9.0 calls the hook with `data`, the value it validated; the `Hook` type this package
|
|
42
|
+
// takes from `@hono/zod-openapi` does not declare that member on a failed result, so it is read through a cast.
|
|
43
|
+
const data = result.data;
|
|
44
|
+
const errors = result.error.issues.flatMap((issue) => {
|
|
45
|
+
const base = { status: String(status), code, title, detail: issue.message };
|
|
46
|
+
if (target === 'query' && issue.code === 'unrecognized_keys') {
|
|
47
|
+
return issue.keys.map((key) => ({
|
|
48
|
+
...base,
|
|
49
|
+
detail: `The query parameter "${key}" cannot be processed.`,
|
|
50
|
+
source: { parameter: key },
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
if (body)
|
|
54
|
+
return [{ ...base, source: { pointer: pointerTo(existingPrefix(data, issue.path)) } }];
|
|
55
|
+
const path = issue.path.map(String).join('.');
|
|
56
|
+
if (path === '')
|
|
57
|
+
return [base];
|
|
58
|
+
if (target === 'query')
|
|
59
|
+
return [{ ...base, source: { parameter: path } }];
|
|
60
|
+
if (target === 'header')
|
|
61
|
+
return [{ ...base, source: { header: path } }];
|
|
62
|
+
return [{ ...base, meta: { target, path } }];
|
|
63
|
+
});
|
|
45
64
|
throw new RequestInvalid(status, errors);
|
|
46
65
|
};
|
|
47
66
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeroxsolutions/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"tslib": "^2.3.0",
|
|
24
|
-
"@zeroxsolutions/jsonapi": "0.
|
|
25
|
-
"@zeroxsolutions/
|
|
24
|
+
"@zeroxsolutions/jsonapi": "0.12.0",
|
|
25
|
+
"@zeroxsolutions/query": "0.7.0",
|
|
26
|
+
"@zeroxsolutions/response": "0.14.0"
|
|
26
27
|
},
|
|
27
28
|
"peerDependencies": {
|
|
28
29
|
"@hono/zod-openapi": "^1.0.0",
|