@zeroxsolutions/server 0.3.0 → 0.3.3
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 +26 -0
- package/README.md +7 -2
- package/dist/error-handler.d.ts +6 -5
- package/dist/error-handler.d.ts.map +1 -1
- package/dist/error-handler.js +53 -23
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/permission-refused.d.ts +13 -0
- package/dist/permission-refused.d.ts.map +1 -0
- package/dist/permission-refused.js +16 -0
- package/dist/unauthenticated.d.ts +11 -0
- package/dist/unauthenticated.d.ts.map +1 -0
- package/dist/unauthenticated.js +13 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
# @zeroxsolutions/server@0.3.3 (2026-09-18)
|
|
2
|
+
|
|
3
|
+
### 🚀 Features
|
|
4
|
+
|
|
5
|
+
- **server:** answer a guard's refusal from the one error handler ([d4ae306](https://github.com/zeroxsolutions/zeroxsolutions/commit/d4ae306))
|
|
6
|
+
|
|
7
|
+
### ❤️ Thank You
|
|
8
|
+
|
|
9
|
+
- Claude Opus 5 (1M context)
|
|
10
|
+
- Lương Văn Tú
|
|
11
|
+
|
|
12
|
+
## 0.3.2 (2026-09-18)
|
|
13
|
+
|
|
14
|
+
This was a version bump only for @zeroxsolutions/server to align it with other projects, there were no code changes.
|
|
15
|
+
|
|
16
|
+
## 0.3.1 (2026-09-18)
|
|
17
|
+
|
|
18
|
+
### 🚀 Features
|
|
19
|
+
|
|
20
|
+
- **server:** log every error the handler answers ([c003f8e](https://github.com/zeroxsolutions/zeroxsolutions/commit/c003f8e))
|
|
21
|
+
|
|
22
|
+
### ❤️ Thank You
|
|
23
|
+
|
|
24
|
+
- Claude Opus 5 (1M context)
|
|
25
|
+
- Lương Văn Tú
|
|
26
|
+
|
|
1
27
|
## 0.3.0 (2026-09-13)
|
|
2
28
|
|
|
3
29
|
### 💅 Refactors
|
package/README.md
CHANGED
|
@@ -82,8 +82,13 @@ why `Content-Type` is read on a write only, and why an empty `Accept` is refused
|
|
|
82
82
|
`@zeroxsolutions/response`'s `createDomainErrorResolver`;
|
|
83
83
|
2. a request the validation hook rejected, one error object per issue;
|
|
84
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
|
|
86
|
-
|
|
85
|
+
4. otherwise the status alone - a hono `HTTPException` at its own, anything else as a 500.
|
|
86
|
+
|
|
87
|
+
Whichever step answers, the handler writes one `http.error.answered` line under `category` before the
|
|
88
|
+
document goes out, at the level the answered status class decides: a 5xx at error, anything else at
|
|
89
|
+
warn. The line carries the request id, the method, the path, the answered status, the step that claimed
|
|
90
|
+
the failure (`resolve`, `validation`, `jsonapi`, `status`) and the error's type and message. Without it
|
|
91
|
+
a mapped 500 or a guard's 403 reaches the client with nothing in the log for an operator to join it to.
|
|
87
92
|
|
|
88
93
|
The `requestId` option's value becomes the `id` of each error object that carries none, so a client
|
|
89
94
|
quotes the id the request's log lines carry.
|
package/dist/error-handler.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { SurfaceVocabulary } from './surface-vocabulary.js';
|
|
|
4
4
|
/** Tunes {@link createErrorHandler} for a specific transport (the edge gateway vs a service). */
|
|
5
5
|
export interface ErrorHandlerOptions extends SurfaceVocabulary {
|
|
6
6
|
/**
|
|
7
|
-
* The ISOLATE's own logging category, which
|
|
7
|
+
* The ISOLATE's own logging category, which every answered failure's line is written under. Required
|
|
8
8
|
* rather than defaulted: a category the isolate did not configure is silent, and silence here
|
|
9
9
|
* looks the same as a request that never failed.
|
|
10
10
|
*/
|
|
@@ -16,11 +16,12 @@ export interface ErrorHandlerOptions extends SurfaceVocabulary {
|
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
18
|
* 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
|
|
20
|
-
* {@link JsonApiError} as thrown, then the HTTP status alone.
|
|
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.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
22
|
+
* Every answer is written to the log before it goes out, at the level its status class decides: a 5xx at error,
|
|
23
|
+
* because this side went wrong, anything else at warn, because the request was refused. Without the line, a
|
|
24
|
+
* mapped 500 and a guard's 401 reach the client leaving no trace that the request arrived.
|
|
24
25
|
*/
|
|
25
26
|
export declare function createErrorHandler(options: ErrorHandlerOptions): ErrorHandler;
|
|
26
27
|
//# 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;
|
|
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"}
|
package/dist/error-handler.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { getLogger } from '@logtape/logtape';
|
|
2
2
|
import { HTTPException } from 'hono/http-exception';
|
|
3
3
|
import { BASE_ERROR_CODES, JsonApiError, JSON_API_MEDIA_TYPE, toErrorDocument, toErrorObject, } from '@zeroxsolutions/response';
|
|
4
|
+
import { PermissionRefused } from './permission-refused.js';
|
|
4
5
|
import { RequestInvalid } from './request-invalid.js';
|
|
6
|
+
import { Unauthenticated } from './unauthenticated.js';
|
|
5
7
|
// The `code` and `title` each HTTP status is answered with, from the first entry the base registry gives it.
|
|
6
8
|
const META_BY_STATUS = new Map();
|
|
7
9
|
for (const [name, meta] of Object.entries(BASE_ERROR_CODES)) {
|
|
@@ -9,9 +11,10 @@ for (const [name, meta] of Object.entries(BASE_ERROR_CODES)) {
|
|
|
9
11
|
META_BY_STATUS.set(meta.status, { code: name.toLowerCase(), title: meta.title });
|
|
10
12
|
}
|
|
11
13
|
}
|
|
14
|
+
// The message template both levels write, so one search finds every error this handler answered.
|
|
15
|
+
const ANSWERED_LINE = 'http.error.answered {method} {path} {status} {claimedBy}';
|
|
12
16
|
// Projects a failure hono raised - or one nothing claimed - onto a JSON:API error object by HTTP status.
|
|
13
|
-
function errorObjectByStatus(err, namespace) {
|
|
14
|
-
const status = err instanceof HTTPException ? err.status : 500;
|
|
17
|
+
function errorObjectByStatus(err, status, namespace) {
|
|
15
18
|
const meta = META_BY_STATUS.get(status) ?? { code: 'error', title: 'Error' };
|
|
16
19
|
const detail = err instanceof HTTPException ? err.message : 'Internal server error';
|
|
17
20
|
return {
|
|
@@ -21,37 +24,64 @@ function errorObjectByStatus(err, namespace) {
|
|
|
21
24
|
...(detail && detail !== meta.title ? { detail } : {}),
|
|
22
25
|
};
|
|
23
26
|
}
|
|
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.
|
|
29
|
+
function claimError(err, c, options) {
|
|
30
|
+
const resolved = options.resolve?.(err, c);
|
|
31
|
+
if (resolved) {
|
|
32
|
+
return { claimedBy: 'resolve', resolved };
|
|
33
|
+
}
|
|
34
|
+
if (err instanceof Unauthenticated) {
|
|
35
|
+
const error = { status: '401', code: `${options.namespace}.unauthenticated`, title: 'Unauthenticated' };
|
|
36
|
+
return { claimedBy: 'auth', resolved: { status: 401, errors: [error] } };
|
|
37
|
+
}
|
|
38
|
+
if (err instanceof PermissionRefused) {
|
|
39
|
+
const error = { status: '403', code: `${options.namespace}.forbidden`, title: 'Forbidden' };
|
|
40
|
+
return { claimedBy: 'auth', resolved: { status: 403, errors: [error] }, logged: { permissions: err.permissions } };
|
|
41
|
+
}
|
|
42
|
+
if (err instanceof RequestInvalid) {
|
|
43
|
+
return { claimedBy: 'validation', resolved: { status: err.status, errors: err.errors } };
|
|
44
|
+
}
|
|
45
|
+
if (err instanceof JsonApiError) {
|
|
46
|
+
return { claimedBy: 'jsonapi', resolved: { status: err.status, errors: [toErrorObject(err)] } };
|
|
47
|
+
}
|
|
48
|
+
const status = err instanceof HTTPException ? err.status : 500;
|
|
49
|
+
return { claimedBy: 'status', resolved: { status, errors: [errorObjectByStatus(err, status, options.namespace)] } };
|
|
50
|
+
}
|
|
24
51
|
/**
|
|
25
52
|
* The one central `onError` a transport installs, rendering every failure as one `application/vnd.api+json`
|
|
26
|
-
* `errors[]` document. It asks, in order: `resolve`, then a
|
|
27
|
-
* {@link JsonApiError} as thrown, then the HTTP status alone.
|
|
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.
|
|
28
55
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
56
|
+
* Every answer is written to the log before it goes out, at the level its status class decides: a 5xx at error,
|
|
57
|
+
* because this side went wrong, anything else at warn, because the request was refused. Without the line, a
|
|
58
|
+
* mapped 500 and a guard's 401 reach the client leaving no trace that the request arrived.
|
|
31
59
|
*/
|
|
32
60
|
export function createErrorHandler(options) {
|
|
33
61
|
const logger = getLogger(options.category);
|
|
34
62
|
return (err, c) => {
|
|
35
63
|
const requestId = options.requestId?.(c);
|
|
36
64
|
const stamp = (e) => (requestId && !e.id ? { ...e, id: requestId } : e);
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
const resolved = claimed ?? {
|
|
52
|
-
status: err instanceof HTTPException ? err.status : 500,
|
|
53
|
-
errors: [errorObjectByStatus(err, options.namespace)],
|
|
65
|
+
const { claimedBy, resolved, logged } = claimError(err, c, options);
|
|
66
|
+
const line = {
|
|
67
|
+
// First, so a step's own facts can never take the place of the ones every line carries.
|
|
68
|
+
...logged,
|
|
69
|
+
// Undefined where a transport never mounted `requestId()` - minting one here is a second id for one request.
|
|
70
|
+
requestId: requestId ?? c.get('requestId'),
|
|
71
|
+
method: c.req.method,
|
|
72
|
+
// `path`, not `url`: a query string is where a caller puts a token, and a sink keeps what it is given.
|
|
73
|
+
path: c.req.path,
|
|
74
|
+
status: resolved.status,
|
|
75
|
+
claimedBy,
|
|
76
|
+
type: err.name,
|
|
77
|
+
message: err.message,
|
|
54
78
|
};
|
|
79
|
+
if (resolved.status >= 500) {
|
|
80
|
+
logger.error(ANSWERED_LINE, line);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
logger.warn(ANSWERED_LINE, line);
|
|
84
|
+
}
|
|
55
85
|
const doc = toErrorDocument(resolved.errors.map(stamp));
|
|
56
86
|
return c.body(JSON.stringify(doc), resolved.status, {
|
|
57
87
|
'Content-Type': JSON_API_MEDIA_TYPE,
|
package/dist/index.d.ts
CHANGED
|
@@ -5,5 +5,7 @@ export { createEnvironment, type EnvironmentOptions } from './environment.js';
|
|
|
5
5
|
export type { SurfaceVocabulary } from './surface-vocabulary.js';
|
|
6
6
|
export { jsonApiContentNegotiation } from './content-negotiation.js';
|
|
7
7
|
export { createErrorHandler, type ErrorHandlerOptions } from './error-handler.js';
|
|
8
|
+
export { PermissionRefused } from './permission-refused.js';
|
|
9
|
+
export { Unauthenticated } from './unauthenticated.js';
|
|
8
10
|
export { createValidationHook } from './validation-hook.js';
|
|
9
11
|
//# sourceMappingURL=index.d.ts.map
|
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,MAAM,0BAA0B,CAAC;AACrE,OAAO,EAAE,kBAAkB,EAAE,KAAK,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAClF,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,MAAM,0BAA0B,CAAC;AACrE,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"}
|
package/dist/index.js
CHANGED
|
@@ -4,4 +4,6 @@
|
|
|
4
4
|
export { createEnvironment } from './environment.js';
|
|
5
5
|
export { jsonApiContentNegotiation } from './content-negotiation.js';
|
|
6
6
|
export { createErrorHandler } from './error-handler.js';
|
|
7
|
+
export { PermissionRefused } from './permission-refused.js';
|
|
8
|
+
export { Unauthenticated } from './unauthenticated.js';
|
|
7
9
|
export { createValidationHook } from './validation-hook.js';
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A caller who is authenticated and may not perform what the route requires, thrown by the authorization
|
|
3
|
+
* guard so the app's one `onError` renders and logs it.
|
|
4
|
+
*
|
|
5
|
+
* `permissions` is what the route asked for, never who asked: the log needs it to say which guard refused,
|
|
6
|
+
* and the answer withholds it, because a 403 naming the resource draws the permission map for the caller
|
|
7
|
+
* who just failed it.
|
|
8
|
+
*/
|
|
9
|
+
export declare class PermissionRefused extends Error {
|
|
10
|
+
readonly permissions: Readonly<Record<string, readonly string[]>>;
|
|
11
|
+
constructor(permissions: Readonly<Record<string, readonly string[]>>);
|
|
12
|
+
}
|
|
13
|
+
//# sourceMappingURL=permission-refused.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"permission-refused.d.ts","sourceRoot":"","sources":["../src/permission-refused.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,qBAAa,iBAAkB,SAAQ,KAAK;IAC9B,QAAQ,CAAC,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC;gBAAxD,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC;CAI9E"}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A caller who is authenticated and may not perform what the route requires, thrown by the authorization
|
|
3
|
+
* guard so the app's one `onError` renders and logs it.
|
|
4
|
+
*
|
|
5
|
+
* `permissions` is what the route asked for, never who asked: the log needs it to say which guard refused,
|
|
6
|
+
* and the answer withholds it, because a 403 naming the resource draws the permission map for the caller
|
|
7
|
+
* who just failed it.
|
|
8
|
+
*/
|
|
9
|
+
export class PermissionRefused extends Error {
|
|
10
|
+
permissions;
|
|
11
|
+
constructor(permissions) {
|
|
12
|
+
super('The caller may not perform every action this route requires');
|
|
13
|
+
this.permissions = permissions;
|
|
14
|
+
this.name = 'PermissionRefused';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A request that resolved no caller, thrown by whatever guard establishes identity so the app's one
|
|
3
|
+
* `onError` renders and logs it.
|
|
4
|
+
*
|
|
5
|
+
* A guard that answers the request itself writes a second spelling of 401 per surface, and the two drift:
|
|
6
|
+
* one says `unauthenticated`, the next says `unauthorized`, for the same refusal.
|
|
7
|
+
*/
|
|
8
|
+
export declare class Unauthenticated extends Error {
|
|
9
|
+
constructor(message?: string);
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=unauthenticated.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"unauthenticated.d.ts","sourceRoot":"","sources":["../src/unauthenticated.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,qBAAa,eAAgB,SAAQ,KAAK;gBAC5B,OAAO,SAAgD;CAIpE"}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A request that resolved no caller, thrown by whatever guard establishes identity so the app's one
|
|
3
|
+
* `onError` renders and logs it.
|
|
4
|
+
*
|
|
5
|
+
* A guard that answers the request itself writes a second spelling of 401 per surface, and the two drift:
|
|
6
|
+
* one says `unauthenticated`, the next says `unauthorized`, for the same refusal.
|
|
7
|
+
*/
|
|
8
|
+
export class Unauthenticated extends Error {
|
|
9
|
+
constructor(message = 'The request carries no authenticated caller') {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = 'Unauthenticated';
|
|
12
|
+
}
|
|
13
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeroxsolutions/server",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
23
|
"tslib": "^2.3.0",
|
|
24
|
-
"@zeroxsolutions/jsonapi": "0.11.
|
|
25
|
-
"@zeroxsolutions/response": "0.12.
|
|
24
|
+
"@zeroxsolutions/jsonapi": "0.11.1",
|
|
25
|
+
"@zeroxsolutions/response": "0.12.1"
|
|
26
26
|
},
|
|
27
27
|
"peerDependencies": {
|
|
28
28
|
"@hono/zod-openapi": "^1.0.0",
|