@zeroxsolutions/server 0.3.5 → 0.4.1

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 CHANGED
@@ -1,3 +1,43 @@
1
+ ## 0.4.1 (2026-09-28)
2
+
3
+ This was a version bump only for @zeroxsolutions/server to align it with other projects, there were no code changes.
4
+
5
+ ## 0.4.0 (2026-09-28)
6
+
7
+ ### 🚀 Features
8
+
9
+ - ⚠️ **server:** challenge a 401 and keep a thrown response's headers ([e252634](https://github.com/zeroxsolutions/zeroxsolutions/commit/e252634))
10
+ - ⚠️ **server:** answer a refused query as 400 with its parameter ([61ffe94](https://github.com/zeroxsolutions/zeroxsolutions/commit/61ffe94))
11
+
12
+ ### 🩹 Fixes
13
+
14
+ - **server:** name the challenge on every 401 ([0a0b2af](https://github.com/zeroxsolutions/zeroxsolutions/commit/0a0b2af))
15
+ - ⚠️ **server:** point error sources at what exists, name unknown keys ([44d2116](https://github.com/zeroxsolutions/zeroxsolutions/commit/44d2116))
16
+ - ⚠️ **server:** label only json:api bodies and answer unmatched routes ([a6d3a08](https://github.com/zeroxsolutions/zeroxsolutions/commit/a6d3a08))
17
+
18
+ ### ⚠️ Breaking Changes
19
+
20
+ - **server:** point error sources at what exists, name unknown keys ([44d2116](https://github.com/zeroxsolutions/zeroxsolutions/commit/44d2116))
21
+ a missing member points at its parent; a whole-target
22
+ issue carries pointer "" for a body and no source otherwise; an
23
+ unrecognized query key is its own 400.
24
+ - **server:** label only json:api bodies and answer unmatched routes ([a6d3a08](https://github.com/zeroxsolutions/zeroxsolutions/commit/a6d3a08))
25
+ a body with no Content-Type is 415, and a response of
26
+ another media type keeps its own header.
27
+ - **server:** challenge a 401 and keep a thrown response's headers ([e252634](https://github.com/zeroxsolutions/zeroxsolutions/commit/e252634))
28
+ createErrorHandler requires authenticationChallenge.
29
+ - **server:** answer a refused query as 400 with its parameter ([61ffe94](https://github.com/zeroxsolutions/zeroxsolutions/commit/61ffe94))
30
+ QueryNotPermitted is answered by the library before resolve, as <namespace>.query_not_permitted.
31
+
32
+ ### ❤️ Thank You
33
+
34
+ - Claude Opus 5.5 (1M context)
35
+ - Lương Văn Tú
36
+
37
+ ## 0.3.5 (2026-09-27)
38
+
39
+ This was a version bump only for @zeroxsolutions/server to align it with other projects, there were no code changes.
40
+
1
41
  ## 0.3.4 (2026-09-27)
2
42
 
3
43
  ### 🚀 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 dependencies: this package
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. `resolve(err, c)` - where a surface's domain error map answers, through
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
- 2. a request the validation hook rejected, one error object per issue;
84
- 3. a `JsonApiError`, rendered as it was thrown;
85
- 4. otherwise the status alone - a hono `HTTPException` at its own, anything else as a 500.
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 stamps the JSON:API media type onto the response `Content-Type`.
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): MiddlewareHandler;
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").$strip>>;
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").$strip>>;
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;;;;;;;;;;;;GAYG;AACH,wBAAgB,yBAAyB,CAAC,OAAO,EAAE,iBAAiB,GAAG,iBAAiB,CAQvF;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAGxB,CAAC"}
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 stamps the JSON:API media type onto the response `Content-Type`.
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
- const contentType = c.req.raw.body === null ? undefined : c.req.header('Content-Type');
19
- const error = negotiateJsonApi(contentType, c.req.header('Accept'), options.namespace);
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.header('Content-Type', JSON_API_MEDIA_TYPE);
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
  /**
@@ -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 refusal, then a request the validation
20
- * hook rejected, then a {@link JsonApiError} as thrown, then the HTTP status alone.
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;AAGlD,OAAO,EAOL,KAAK,aAAa,EACnB,MAAM,0BAA0B,CAAC;AAGlC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAC;AAqCjE,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;CACvD;AA2BD;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,mBAAmB,GAAG,YAAY,CAiC7E"}
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"}
@@ -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 the validation hook rejected, a
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
- return { claimedBy: 'status', resolved: { status, errors: [errorObjectByStatus(err, status, options.namespace)] } };
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 refusal, then a request the validation
54
- * hook rejected, then a {@link JsonApiError} as thrown, then the HTTP status alone.
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';
@@ -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;AAoBjE;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,SAAS,GAAG,GAAG,GAAG,EACtD,OAAO,EAAE,iBAAiB,GACzB,IAAI,CAAC,OAAO,EAAE,CAAC,EAAE,MAAM,EAAE,SAAS,CAAC,CAoBrC"}
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"}
@@ -1,22 +1,19 @@
1
1
  import { BASE_ERROR_CODES } from '@zeroxsolutions/response';
2
2
  import { RequestInvalid } from './request-invalid.js';
3
- /**
4
- * The error object's `source` for one issue, by the slot it was read from.
5
- *
6
- * The spec defines exactly three members, and a path segment matches none of them: `pointer` is a JSON
7
- * Pointer into the request DOCUMENT, `parameter` a URI query parameter, `header` a header name. So a
8
- * `param` or `cookie` issue names its slot in `meta` rather than borrowing a member that means
9
- * something else.
10
- */
11
- function sourceFor(target, path) {
12
- if (target === 'json' || target === 'form')
13
- return { source: { pointer: `/${path.split('.').join('/')}` } };
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
- const errors = result.error.issues.map((issue) => ({
38
- status: String(status),
39
- code,
40
- title,
41
- detail: issue.message,
42
- // A whole-object refinement carries an empty path, so the slot it was read from stands in.
43
- ...sourceFor(target, issue.path.map(String).join('.') || target),
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.5",
3
+ "version": "0.4.1",
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.11.3",
25
- "@zeroxsolutions/response": "0.13.0"
24
+ "@zeroxsolutions/jsonapi": "0.12.1",
25
+ "@zeroxsolutions/response": "0.14.1",
26
+ "@zeroxsolutions/query": "0.7.0"
26
27
  },
27
28
  "peerDependencies": {
28
29
  "@hono/zod-openapi": "^1.0.0",