@owlmeans/server-api 0.1.18-rc.31 → 0.1.18-rc.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ only when composing a context without that package. WebSocket routes belong to
10
10
  ## Installation
11
11
 
12
12
  ```bash
13
- bun add @owlmeans/server-api@^0.1.18-rc.27
13
+ bun add @owlmeans/server-api@^0.1.18-rc.33
14
14
  ```
15
15
 
16
16
  ## Concepts
@@ -27,7 +27,12 @@ bun add @owlmeans/server-api@^0.1.18-rc.27
27
27
  - **Validation** — the contract's schemas are compiled with AJV (`removeAdditional`, `useDefaults`,
28
28
  `coerceTypes`), so a handler receives cleaned, coerced input.
29
29
  - **Error mapping** — `AuthForbidden` / `AccessError` answer 403, `AuthorizationError` /
30
- `AuthFailedError` answer 401, anything else 500, with the marshalled `ResilientError` as the body.
30
+ `AuthFailedError` answer 401, a class declaring `static httpStatus` as an integer 4xx answers that
31
+ status, and anything else 500 — always with the marshalled `ResilientError` as the body. A refusal
32
+ of the caller's condition declares its status; a fault declares nothing and stays 500. The status
33
+ is taken from the error as thrown first, and from the ensured (rebuilt) error only when that
34
+ answers 500. An auth family is matched by class or by exact registered type name (the instance's
35
+ `type` or a `typeName` on its constructor chain), so duplicate module copies answer the same.
31
36
 
32
37
  ## Usage
33
38
 
@@ -82,6 +87,22 @@ export const rename = api.body(projectProtocols.rename, async ({ name }, context
82
87
  })
83
88
  ```
84
89
 
90
+ A refusal that is not about permission declares its own 4xx on the class. The declaring package
91
+ never imports this one — the static property is read structurally and inherited by subclasses:
92
+
93
+ ```ts
94
+ import { ResilientError } from '@owlmeans/error'
95
+
96
+ export class ProjectBusy extends ResilientError {
97
+ public static override typeName = 'ProjectBusy'
98
+ public static httpStatus = 409 // honoured only as an integer 400–499; anything else answers 500
99
+
100
+ constructor(message: string = 'error') {
101
+ super(ProjectBusy.typeName, `project-busy:${message}`)
102
+ }
103
+ }
104
+ ```
105
+
85
106
  ### File upload
86
107
 
87
108
  `uploadedFile(request)` is the only place a handler touches the Fastify multipart API. The protocol
@@ -171,7 +192,10 @@ code does not need it.
171
192
  | `canServeModule(context, module)` | function | Whether an entrypoint belongs on this HTTP server |
172
193
  | `provideRequest(alias, req, provision?)` | function | Build an `AbstractRequest` from a Fastify request |
173
194
  | `executeResponse(response, reply, throwOnError?)` | function | Send an `AbstractResponse` onto a reply |
174
- | `handleError(error, reply)` | function | Map an error onto 401 / 403 / 500 |
195
+ | `handleError(error, reply)` | function | Answer an error with `errorStatus` and the marshalled body |
196
+ | `errorStatus(error)` | function | 403 / 401 for auth errors (by class or type name), else the class's declared 4xx, else 500 |
197
+ | `declaredErrorStatus(error)` | function | The integer 4xx a class declares through `static httpStatus`, or `null` |
198
+ | `HttpStatusDeclaration` | type | `{ httpStatus?: unknown }` — the structural shape a declaring class has |
175
199
  | `fixFormatDates(schema)` | function | Rewrite `date-time` object schemas as strings |
176
200
  | `populateContext(req, context)`, `extractContext(req, ctx?, location?)` | function | Carry the request-scoped context on the raw request |
177
201
 
@@ -216,7 +240,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
216
240
  your project's skill store (`.agents/skills/`):
217
241
 
218
242
  ```sh
219
- npx @owlmeans/agent-skills@^0.1.18-rc.26
243
+ npx @owlmeans/agent-skills@^0.1.18-rc.28
220
244
  ```
221
245
 
222
246
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 2,
3
3
  "package": "@owlmeans/server-api",
4
- "version": "0.1.18-rc.31",
5
- "generatedAt": "2026-09-15T13:12:48.098Z",
4
+ "version": "0.1.18-rc.33",
5
+ "generatedAt": "2026-09-17T08:16:11.165Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -7,7 +7,7 @@ user-invocable: false
7
7
 
8
8
  # @owlmeans/server-api
9
9
 
10
- **Install:** `bun add @owlmeans/server-api@^0.1.18-rc.27`
10
+ **Install:** `bun add @owlmeans/server-api@^0.1.18-rc.33`
11
11
 
12
12
  Make handlers from the protocol declaration so input and output types stay coupled to the shared
13
13
  contract:
@@ -56,6 +56,49 @@ protocol unchanged, with a one-time warning; anything else that is not a plain f
56
56
  that one route with `HandlerMisconfiguredError`, instead of the opaque
57
57
  `TypeError: handler is not a function`.
58
58
 
59
+ ## The status a thrown error answers
60
+
61
+ A thrown error (or a rejected response) is answered with the marshalled `ResilientError` as the
62
+ body and a status from `errorStatus(error)` (`./utils`), resolved in this order:
63
+
64
+ | Error | Status |
65
+ |---|---|
66
+ | `AuthForbidden`, `AccessError` or a subclass — by class or registered type name | 403 |
67
+ | `AuthorizationError`, `AuthFailedError` or a subclass — by class or registered type name | 401 |
68
+ | a class declaring `static httpStatus` as an integer 400–499 | that status |
69
+ | anything else, including a declaration outside 400–499 | 500 |
70
+
71
+ - **A refusal of the caller's condition declares its status; a fault declares nothing.** 400 a
72
+ malformed request, 402 an unpaid balance or plan, 404 an addressed target that does not exist
73
+ (or is another organization's), 409 a target whose current state conflicts, 422 content or a body
74
+ that is understood and refused — and a missing configuration, a broken peer, a timeout or a bug
75
+ stays 500, because monitoring, logs, proxies and retry logic read a 5xx as the server failing.
76
+ The table and the leaf-class rule are the `error` skill's.
77
+ - Declare it on the class: `public static httpStatus = 409` (`override` only when an ancestor
78
+ already declares one). The declaration is structural — the package declaring an error never
79
+ imports `@owlmeans/server-api` — and a static property is inherited, so a subclass answers its
80
+ nearest declaring ancestor's status and redeclares to change it.
81
+ - The auth branches win over a declaration, in that order (`AuthForbidden extends
82
+ AuthorizationError`, so 403 is tested first). An entitlement or permission refusal extends
83
+ `AuthForbidden` rather than declaring 403.
84
+ - `handleError` resolves the status on the error AS THROWN first, and asks the ENSURED
85
+ (`ResilientError.ensure`) error only when that answers 500. The thrown object is the one whose
86
+ class is certainly what was raised; the rebuild is what gives a status to a marshalled error that
87
+ crossed a hop as a plain `Error`. `ensure` returns an error from any `@owlmeans/error` copy
88
+ untouched, so the body keeps the thrown class's `type` (`AuthFailedError|||api:auth:…`) even in a
89
+ process holding duplicate module copies (`bun --preserve-symlinks`). `executeResponse` ensures
90
+ nothing and answers the rejected error's status. `@owlmeans/server-socket` answers an upgrade
91
+ through the same `handleError`.
92
+ - An auth family is recognised by `instanceof` OR by an exact registered type name — the instance's
93
+ `type` or any static `typeName` on its constructor chain — so a class from another module copy
94
+ answers the same status. Match whole names, never substrings: a subclass's `typeName` does not
95
+ reliably embed its parent's (`EntitlementRefusal` extends `AuthForbidden`). A declared
96
+ `httpStatus` is a structural static read and survives duplicate copies as it is.
97
+ - The status never changes what a client rebuilds: `@owlmeans/api` rehydrates the class from the
98
+ body for any non-2xx answer, so a caller branches on the class, never on the number. Nothing in
99
+ the framework treats a 404 specially — a missing route is a Fastify JSON body the client turns
100
+ into `ApiClientError('404')`, a refusal is a marshalled string rebuilt as its class.
101
+
59
102
  `uploadedFile(request)` is the Fastify multipart boundary. Keep raw Fastify access there rather
60
103
  than reaching through `request.original` in application code.
61
104
 
@@ -1,16 +1,51 @@
1
1
  import type { FastifyReply } from 'fastify';
2
2
  /**
3
- * Map a thrown error onto an HTTP status.
3
+ * What an error class may declare about the HTTP status it is answered with.
4
+ *
5
+ * Structural on purpose: the classes that declare it live in packages that must not depend on an
6
+ * HTTP server (`@owlmeans/payment`, a product's contract package), so they write a plain
7
+ * `public static httpStatus = 409` and nothing imports this type.
8
+ */
9
+ export interface HttpStatusDeclaration {
10
+ httpStatus?: unknown;
11
+ }
12
+ /**
13
+ * The 4xx status an error's class declares, or `null` when it declares none that may be honoured.
14
+ *
15
+ * Read through the constructor chain — a static property is inherited, so a subclass answers what
16
+ * its nearest declaring ancestor says, and redeclares to change it. Only an integer 4xx is honoured:
17
+ * a declaration is how a class says "this is the caller's condition, not a fault", and a 5xx, a 2xx,
18
+ * a string or a fraction says nothing a boundary can act on. The read is structural, so a class from
19
+ * a duplicate module copy declares exactly what the imported one does.
20
+ */
21
+ export declare const declaredErrorStatus: (error: unknown) => number | null;
22
+ /**
23
+ * The HTTP status an error is answered with, read off the error exactly as given.
4
24
  *
5
25
  * Two families reach here and both must be recognised. `AuthFailedError` / `AccessError` are this
6
26
  * package's own, raised by the request pipeline itself. `AuthorizationError` / `AuthForbidden`
7
27
  * come from `@owlmeans/auth` and are what the guards and gates actually throw — an unrecognised
8
28
  * one answers 500, which reports a refused request as a crashed server: the client cannot tell
9
29
  * "sign in again" from "the service is broken", and a consumer watching statuses concludes the
10
- * application fell over.
30
+ * application fell over. A family member is recognised by class OR by registered type name
31
+ * ({@link typeNamesOf}), so a class from a duplicate module copy answers the same status.
11
32
  *
12
33
  * **Order matters.** `AuthForbidden extends AuthorizationError`, so the 403 branch has to be
13
- * tested first or every refusal of a permission is reported as a failure to authenticate.
34
+ * tested first or every refusal of a permission is reported as a failure to authenticate. Both
35
+ * auth branches come before a class's own declaration, so an auth refusal can never be restated
36
+ * as something else. Everything that is neither an auth error nor a class declaring a 4xx
37
+ * {@link declaredErrorStatus} answers 500.
38
+ */
39
+ export declare const errorStatus: (error: unknown) => number;
40
+ /**
41
+ * Answer a thrown error: a status, and the marshalled `ResilientError` as the body.
42
+ *
43
+ * The status is resolved in two steps. The error AS THROWN comes first: it is the one object whose
44
+ * class is certainly the class that was raised, while `ResilientError.ensure` rebuilds through the
45
+ * registry of whichever `@owlmeans/error` copy this module loaded — under duplicate module copies
46
+ * that turns a perfectly recognisable auth refusal into a plain `ResilientError` answering 500.
47
+ * Only when the thrown error answers 500 is the ENSURED error asked, because the rebuild is what
48
+ * gives a status to a marshalled error that crossed a hop as a plain `Error`.
14
49
  */
15
50
  export declare const handleError: (error: Error, reply: FastifyReply) => void;
16
51
  //# sourceMappingURL=error.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"error.d.ts","sourceRoot":"","sources":["../../src/utils/error.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AAK3C;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,WAAW,UAAW,KAAK,SAAS,YAAY,SAe5D,CAAA"}
1
+ {"version":3,"file":"error.d.ts","sourceRoot":"","sources":["../../src/utils/error.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AAK3C;;;;;;GAMG;AACH,MAAM,WAAW,qBAAqB;IACpC,UAAU,CAAC,EAAE,OAAO,CAAA;CACrB;AAKD;;;;;;;;GAQG;AACH,eAAO,MAAM,mBAAmB,UAAW,OAAO,KAAG,MAAM,GAAG,IAW7D,CAAA;AAkCD;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,WAAW,UAAW,OAAO,KAAG,MAkB5C,CAAA;AAED;;;;;;;;;GASG;AACH,eAAO,MAAM,WAAW,UAAW,KAAK,SAAS,YAAY,SAO5D,CAAA"}
@@ -2,32 +2,107 @@ import { FORBIDDEN_ERROR, SERVER_ERROR, UNAUTHORIZED_ERROR } from '@owlmeans/api
2
2
  import { AccessError, AuthFailedError } from '../errors.js';
3
3
  import { AuthForbidden, AuthorizationError } from '@owlmeans/auth';
4
4
  import { ResilientError } from '@owlmeans/error';
5
+ const CLIENT_ERROR_FIRST = 400;
6
+ const CLIENT_ERROR_LAST = 499;
5
7
  /**
6
- * Map a thrown error onto an HTTP status.
8
+ * The 4xx status an error's class declares, or `null` when it declares none that may be honoured.
9
+ *
10
+ * Read through the constructor chain — a static property is inherited, so a subclass answers what
11
+ * its nearest declaring ancestor says, and redeclares to change it. Only an integer 4xx is honoured:
12
+ * a declaration is how a class says "this is the caller's condition, not a fault", and a 5xx, a 2xx,
13
+ * a string or a fraction says nothing a boundary can act on. The read is structural, so a class from
14
+ * a duplicate module copy declares exactly what the imported one does.
15
+ */
16
+ export const declaredErrorStatus = (error) => {
17
+ if (error == null || typeof error !== 'object') {
18
+ return null;
19
+ }
20
+ const status = error.constructor?.httpStatus;
21
+ if (typeof status !== 'number' || !Number.isInteger(status)
22
+ || status < CLIENT_ERROR_FIRST || status > CLIENT_ERROR_LAST) {
23
+ return null;
24
+ }
25
+ return status;
26
+ };
27
+ /**
28
+ * The registered type names an error carries: its own `type`, and the static `typeName` of every
29
+ * class on its constructor chain.
30
+ *
31
+ * This is how a class is recognised when `instanceof` cannot see it. A process that loads a package
32
+ * twice — `bun --preserve-symlinks` over a linked workspace resolves one package through several
33
+ * `node_modules` paths — holds several copies of every error class, and an error built by one copy
34
+ * is not an instance of another. The registered names are the same in every copy. A subclass's
35
+ * `typeName` does not reliably embed its parent's (`EntitlementRefusal` extends `AuthForbidden`), so
36
+ * a family is recognised by walking the chain and matching names exactly, never by a substring.
37
+ */
38
+ const typeNamesOf = (error) => {
39
+ const names = new Set();
40
+ const type = error.type;
41
+ if (typeof type === 'string') {
42
+ names.add(type);
43
+ }
44
+ let ctor = error.constructor;
45
+ while (typeof ctor === 'function') {
46
+ const name = ctor.typeName;
47
+ if (typeof name === 'string') {
48
+ names.add(name);
49
+ }
50
+ ctor = Object.getPrototypeOf(ctor);
51
+ }
52
+ return names;
53
+ };
54
+ const isOfFamily = (names, family) => family.some(typeName => names.has(typeName));
55
+ /**
56
+ * The HTTP status an error is answered with, read off the error exactly as given.
7
57
  *
8
58
  * Two families reach here and both must be recognised. `AuthFailedError` / `AccessError` are this
9
59
  * package's own, raised by the request pipeline itself. `AuthorizationError` / `AuthForbidden`
10
60
  * come from `@owlmeans/auth` and are what the guards and gates actually throw — an unrecognised
11
61
  * one answers 500, which reports a refused request as a crashed server: the client cannot tell
12
62
  * "sign in again" from "the service is broken", and a consumer watching statuses concludes the
13
- * application fell over.
63
+ * application fell over. A family member is recognised by class OR by registered type name
64
+ * ({@link typeNamesOf}), so a class from a duplicate module copy answers the same status.
14
65
  *
15
66
  * **Order matters.** `AuthForbidden extends AuthorizationError`, so the 403 branch has to be
16
- * tested first or every refusal of a permission is reported as a failure to authenticate.
67
+ * tested first or every refusal of a permission is reported as a failure to authenticate. Both
68
+ * auth branches come before a class's own declaration, so an auth refusal can never be restated
69
+ * as something else. Everything that is neither an auth error nor a class declaring a 4xx
70
+ * {@link declaredErrorStatus} answers 500.
17
71
  */
18
- export const handleError = (error, reply) => {
19
- let code = SERVER_ERROR;
20
- if (error instanceof AuthForbidden || error instanceof AccessError) {
72
+ export const errorStatus = (error) => {
73
+ if (error == null || typeof error !== 'object') {
74
+ return SERVER_ERROR;
75
+ }
76
+ const names = typeNamesOf(error);
77
+ if (error instanceof AuthForbidden || error instanceof AccessError
78
+ || isOfFamily(names, [AuthForbidden.typeName, AccessError.typeName])) {
21
79
  // An established identity that lacks the permission. Re-authenticating changes nothing.
22
- code = FORBIDDEN_ERROR;
80
+ return FORBIDDEN_ERROR;
23
81
  }
24
- else if (error instanceof AuthorizationError || error instanceof AuthFailedError) {
82
+ if (error instanceof AuthorizationError || error instanceof AuthFailedError
83
+ || isOfFamily(names, [AuthorizationError.typeName, AuthFailedError.typeName])) {
25
84
  // No usable credential — absent, expired, revoked, or naming a session this server no longer
26
85
  // holds. The caller is told to sign in, not that signing in would not help.
27
- code = UNAUTHORIZED_ERROR;
86
+ return UNAUTHORIZED_ERROR;
28
87
  }
88
+ return declaredErrorStatus(error) ?? SERVER_ERROR;
89
+ };
90
+ /**
91
+ * Answer a thrown error: a status, and the marshalled `ResilientError` as the body.
92
+ *
93
+ * The status is resolved in two steps. The error AS THROWN comes first: it is the one object whose
94
+ * class is certainly the class that was raised, while `ResilientError.ensure` rebuilds through the
95
+ * registry of whichever `@owlmeans/error` copy this module loaded — under duplicate module copies
96
+ * that turns a perfectly recognisable auth refusal into a plain `ResilientError` answering 500.
97
+ * Only when the thrown error answers 500 is the ENSURED error asked, because the rebuild is what
98
+ * gives a status to a marshalled error that crossed a hop as a plain `Error`.
99
+ */
100
+ export const handleError = (error, reply) => {
29
101
  if (!reply.sent) {
30
- reply.code(code).send(ResilientError.marshal(ResilientError.ensure(error)).message);
102
+ const resilient = ResilientError.ensure(error);
103
+ const thrown = errorStatus(error);
104
+ reply.code(thrown !== SERVER_ERROR ? thrown : errorStatus(resilient))
105
+ .send(ResilientError.marshal(resilient).message);
31
106
  }
32
107
  };
33
108
  //# sourceMappingURL=error.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"error.js","sourceRoot":"","sources":["../../src/utils/error.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,kBAAkB,EAAE,MAAM,eAAe,CAAA;AAEjF,OAAO,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAC3D,OAAO,EAAE,aAAa,EAAE,kBAAkB,EAAE,MAAM,gBAAgB,CAAA;AAClE,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAEhD;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,KAAY,EAAE,KAAmB,EAAE,EAAE;IAC/D,IAAI,IAAI,GAAW,YAAY,CAAA;IAC/B,IAAI,KAAK,YAAY,aAAa,IAAI,KAAK,YAAY,WAAW,EAAE,CAAC;QACnE,wFAAwF;QACxF,IAAI,GAAG,eAAe,CAAA;IACxB,CAAC;SAAM,IAAI,KAAK,YAAY,kBAAkB,IAAI,KAAK,YAAY,eAAe,EAAE,CAAC;QACnF,6FAA6F;QAC7F,4EAA4E;QAC5E,IAAI,GAAG,kBAAkB,CAAA;IAC3B,CAAC;IACD,IAAI,CAAC,KAAK,CAAC,IAAI,EAAE,CAAC;QAChB,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,IAAI,CACnB,cAAc,CAAC,OAAO,CAAC,cAAc,CAAC,MAAM,CAAC,KAAc,CAAC,CAAC,CAAC,OAAO,CACtE,CAAA;IACH,CAAC;AACH,CAAC,CAAA"}
1
+ {"version":3,"file":"error.js","sourceRoot":"","sources":["../../src/utils/error.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"payload.d.ts","sourceRoot":"","sources":["../../src/utils/payload.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AAC7E,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AAEpD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,KAAK,CAAA;AAE1C,eAAO,MAAM,cAAc,UAAW,MAAM,OAAO,OAAO,cAAc,OAAO,KAAG,eAejF,CAAA;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,GAAI,CAAC,YAAY,gBAAgB,CAAC,CAAC,CAAC,SAAS,QAAQ,iBAAiB,OAAO,KAAG,OAuB3G,CAAA;AAGD,eAAO,MAAM,cAAc,WAAY,eAAe,oBAgBrD,CAAA"}
1
+ {"version":3,"file":"payload.d.ts","sourceRoot":"","sources":["../../src/utils/payload.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,sBAAsB,CAAA;AAC7E,OAAO,KAAK,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAA;AAEpD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,KAAK,CAAA;AAG1C,eAAO,MAAM,cAAc,UAAW,MAAM,OAAO,OAAO,cAAc,OAAO,KAAG,eAejF,CAAA;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,eAAe,GAAI,CAAC,YAAY,gBAAgB,CAAC,CAAC,CAAC,SAAS,QAAQ,iBAAiB,OAAO,KAAG,OA0B3G,CAAA;AAGD,eAAO,MAAM,cAAc,WAAY,eAAe,oBAgBrD,CAAA"}
@@ -1,5 +1,6 @@
1
1
  import { EntrypointOutcome } from '@owlmeans/entrypoint';
2
- import { ACCEPTED, CREATED, OK, SERVER_ERROR } from '@owlmeans/api';
2
+ import { ACCEPTED, CREATED, OK } from '@owlmeans/api';
3
+ import { errorStatus } from './error.js';
3
4
  export const provideRequest = (alias, req, provision) => {
4
5
  provision = provision ?? false;
5
6
  return {
@@ -30,7 +31,10 @@ export const executeResponse = (response, reply, throwOnError) => {
30
31
  if (throwOnError ?? false) {
31
32
  throw response.error;
32
33
  }
33
- reply.code(SERVER_ERROR).send(response.error.message);
34
+ // The status of the error as rejected, resolved like `handleError`'s first step — every pipeline
35
+ // caller passes `throwOnError` and lands there; a caller that does not must not report a refusal
36
+ // as a crash either. Nothing is ensured here, so there is no rebuilt error to ask second.
37
+ reply.code(errorStatus(response.error)).send(response.error.message);
34
38
  return true;
35
39
  }
36
40
  else if (response.outcome != null) {
@@ -1 +1 @@
1
- {"version":3,"file":"payload.js","sourceRoot":"","sources":["../../src/utils/payload.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"payload.js","sourceRoot":"","sources":["../../src/utils/payload.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/server-api",
3
- "version": "0.1.18-rc.31",
3
+ "version": "0.1.18-rc.33",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -33,15 +33,15 @@
33
33
  "@fastify/helmet": "^13.1.0",
34
34
  "@fastify/middie": "^9.0.2",
35
35
  "@fastify/multipart": "^9.0.1",
36
- "@owlmeans/api": "^0.1.18-rc.27",
37
- "@owlmeans/auth": "^0.1.18-rc.23",
38
- "@owlmeans/auth-common": "^0.1.18-rc.27",
39
- "@owlmeans/context": "^0.1.18-rc.22",
40
- "@owlmeans/error": "^0.1.18-rc.22",
41
- "@owlmeans/entrypoint": "^0.1.18-rc.25",
42
- "@owlmeans/route": "^0.1.18-rc.23",
43
- "@owlmeans/server-context": "^0.1.18-rc.26",
44
- "@owlmeans/server-entrypoint": "^0.1.18-rc.25",
36
+ "@owlmeans/api": "^0.1.18-rc.29",
37
+ "@owlmeans/auth": "^0.1.18-rc.27",
38
+ "@owlmeans/auth-common": "^0.1.18-rc.29",
39
+ "@owlmeans/context": "^0.1.18-rc.25",
40
+ "@owlmeans/error": "^0.1.18-rc.27",
41
+ "@owlmeans/entrypoint": "^0.1.18-rc.28",
42
+ "@owlmeans/route": "^0.1.18-rc.26",
43
+ "@owlmeans/server-context": "^0.1.18-rc.28",
44
+ "@owlmeans/server-entrypoint": "^0.1.18-rc.27",
45
45
  "ajv-errors": "^3.0.0",
46
46
  "fastify": "^5.0.0",
47
47
  "fastify-raw-body": "^5.0.0",
@@ -5,31 +5,125 @@ import { AuthForbidden, AuthorizationError } from '@owlmeans/auth'
5
5
  import { ResilientError } from '@owlmeans/error'
6
6
 
7
7
  /**
8
- * Map a thrown error onto an HTTP status.
8
+ * What an error class may declare about the HTTP status it is answered with.
9
+ *
10
+ * Structural on purpose: the classes that declare it live in packages that must not depend on an
11
+ * HTTP server (`@owlmeans/payment`, a product's contract package), so they write a plain
12
+ * `public static httpStatus = 409` and nothing imports this type.
13
+ */
14
+ export interface HttpStatusDeclaration {
15
+ httpStatus?: unknown
16
+ }
17
+
18
+ const CLIENT_ERROR_FIRST = 400
19
+ const CLIENT_ERROR_LAST = 499
20
+
21
+ /**
22
+ * The 4xx status an error's class declares, or `null` when it declares none that may be honoured.
23
+ *
24
+ * Read through the constructor chain — a static property is inherited, so a subclass answers what
25
+ * its nearest declaring ancestor says, and redeclares to change it. Only an integer 4xx is honoured:
26
+ * a declaration is how a class says "this is the caller's condition, not a fault", and a 5xx, a 2xx,
27
+ * a string or a fraction says nothing a boundary can act on. The read is structural, so a class from
28
+ * a duplicate module copy declares exactly what the imported one does.
29
+ */
30
+ export const declaredErrorStatus = (error: unknown): number | null => {
31
+ if (error == null || typeof error !== 'object') {
32
+ return null
33
+ }
34
+ const status = (error.constructor as HttpStatusDeclaration | undefined)?.httpStatus
35
+ if (typeof status !== 'number' || !Number.isInteger(status)
36
+ || status < CLIENT_ERROR_FIRST || status > CLIENT_ERROR_LAST) {
37
+ return null
38
+ }
39
+
40
+ return status
41
+ }
42
+
43
+ /**
44
+ * The registered type names an error carries: its own `type`, and the static `typeName` of every
45
+ * class on its constructor chain.
46
+ *
47
+ * This is how a class is recognised when `instanceof` cannot see it. A process that loads a package
48
+ * twice — `bun --preserve-symlinks` over a linked workspace resolves one package through several
49
+ * `node_modules` paths — holds several copies of every error class, and an error built by one copy
50
+ * is not an instance of another. The registered names are the same in every copy. A subclass's
51
+ * `typeName` does not reliably embed its parent's (`EntitlementRefusal` extends `AuthForbidden`), so
52
+ * a family is recognised by walking the chain and matching names exactly, never by a substring.
53
+ */
54
+ const typeNamesOf = (error: object): Set<string> => {
55
+ const names = new Set<string>()
56
+ const type = (error as { type?: unknown }).type
57
+ if (typeof type === 'string') {
58
+ names.add(type)
59
+ }
60
+ let ctor: unknown = error.constructor
61
+ while (typeof ctor === 'function') {
62
+ const name = (ctor as { typeName?: unknown }).typeName
63
+ if (typeof name === 'string') {
64
+ names.add(name)
65
+ }
66
+ ctor = Object.getPrototypeOf(ctor)
67
+ }
68
+
69
+ return names
70
+ }
71
+
72
+ const isOfFamily = (names: Set<string>, family: string[]): boolean =>
73
+ family.some(typeName => names.has(typeName))
74
+
75
+ /**
76
+ * The HTTP status an error is answered with, read off the error exactly as given.
9
77
  *
10
78
  * Two families reach here and both must be recognised. `AuthFailedError` / `AccessError` are this
11
79
  * package's own, raised by the request pipeline itself. `AuthorizationError` / `AuthForbidden`
12
80
  * come from `@owlmeans/auth` and are what the guards and gates actually throw — an unrecognised
13
81
  * one answers 500, which reports a refused request as a crashed server: the client cannot tell
14
82
  * "sign in again" from "the service is broken", and a consumer watching statuses concludes the
15
- * application fell over.
83
+ * application fell over. A family member is recognised by class OR by registered type name
84
+ * ({@link typeNamesOf}), so a class from a duplicate module copy answers the same status.
16
85
  *
17
86
  * **Order matters.** `AuthForbidden extends AuthorizationError`, so the 403 branch has to be
18
- * tested first or every refusal of a permission is reported as a failure to authenticate.
87
+ * tested first or every refusal of a permission is reported as a failure to authenticate. Both
88
+ * auth branches come before a class's own declaration, so an auth refusal can never be restated
89
+ * as something else. Everything that is neither an auth error nor a class declaring a 4xx
90
+ * {@link declaredErrorStatus} answers 500.
19
91
  */
20
- export const handleError = (error: Error, reply: FastifyReply) => {
21
- let code: number = SERVER_ERROR
22
- if (error instanceof AuthForbidden || error instanceof AccessError) {
92
+ export const errorStatus = (error: unknown): number => {
93
+ if (error == null || typeof error !== 'object') {
94
+ return SERVER_ERROR
95
+ }
96
+ const names = typeNamesOf(error)
97
+ if (error instanceof AuthForbidden || error instanceof AccessError
98
+ || isOfFamily(names, [AuthForbidden.typeName, AccessError.typeName])) {
23
99
  // An established identity that lacks the permission. Re-authenticating changes nothing.
24
- code = FORBIDDEN_ERROR
25
- } else if (error instanceof AuthorizationError || error instanceof AuthFailedError) {
100
+ return FORBIDDEN_ERROR
101
+ }
102
+ if (error instanceof AuthorizationError || error instanceof AuthFailedError
103
+ || isOfFamily(names, [AuthorizationError.typeName, AuthFailedError.typeName])) {
26
104
  // No usable credential — absent, expired, revoked, or naming a session this server no longer
27
105
  // holds. The caller is told to sign in, not that signing in would not help.
28
- code = UNAUTHORIZED_ERROR
106
+ return UNAUTHORIZED_ERROR
29
107
  }
108
+
109
+ return declaredErrorStatus(error) ?? SERVER_ERROR
110
+ }
111
+
112
+ /**
113
+ * Answer a thrown error: a status, and the marshalled `ResilientError` as the body.
114
+ *
115
+ * The status is resolved in two steps. The error AS THROWN comes first: it is the one object whose
116
+ * class is certainly the class that was raised, while `ResilientError.ensure` rebuilds through the
117
+ * registry of whichever `@owlmeans/error` copy this module loaded — under duplicate module copies
118
+ * that turns a perfectly recognisable auth refusal into a plain `ResilientError` answering 500.
119
+ * Only when the thrown error answers 500 is the ENSURED error asked, because the rebuild is what
120
+ * gives a status to a marshalled error that crossed a hop as a plain `Error`.
121
+ */
122
+ export const handleError = (error: Error, reply: FastifyReply) => {
30
123
  if (!reply.sent) {
31
- reply.code(code).send(
32
- ResilientError.marshal(ResilientError.ensure(error as Error)).message
33
- )
124
+ const resilient = ResilientError.ensure(error as Error)
125
+ const thrown = errorStatus(error)
126
+ reply.code(thrown !== SERVER_ERROR ? thrown : errorStatus(resilient))
127
+ .send(ResilientError.marshal(resilient).message)
34
128
  }
35
129
  }
@@ -1,8 +1,9 @@
1
1
  import { EntrypointOutcome } from '@owlmeans/entrypoint'
2
2
  import type { AbstractResponse, AbstractRequest } from '@owlmeans/entrypoint'
3
3
  import type { Request, Response } from '../types.js'
4
- import { ACCEPTED, CREATED, OK, SERVER_ERROR } from '@owlmeans/api'
4
+ import { ACCEPTED, CREATED, OK } from '@owlmeans/api'
5
5
  import type { AnySchemaObject } from 'ajv'
6
+ import { errorStatus } from './error.js'
6
7
 
7
8
  export const provideRequest = (alias: string, req: Request, provision?: boolean): AbstractRequest => {
8
9
  provision = provision ?? false
@@ -35,7 +36,10 @@ export const executeResponse = <T>(response: AbstractResponse<T>, reply: Respons
35
36
  if (throwOnError ?? false) {
36
37
  throw response.error
37
38
  }
38
- reply.code(SERVER_ERROR).send(response.error.message)
39
+ // The status of the error as rejected, resolved like `handleError`'s first step — every pipeline
40
+ // caller passes `throwOnError` and lands there; a caller that does not must not report a refusal
41
+ // as a crash either. Nothing is ensured here, so there is no rebuilt error to ask second.
42
+ reply.code(errorStatus(response.error)).send(response.error.message)
39
43
  return true
40
44
  } else if (response.outcome != null) {
41
45
  switch (response.outcome) {
@@ -0,0 +1,222 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
+ import Fastify from 'fastify'
3
+ import type { FastifyInstance } from 'fastify'
4
+ import { AuthForbidden, AuthorizationError } from '@owlmeans/auth'
5
+ import { ResilientError } from '@owlmeans/error'
6
+ import { provideResponse } from '@owlmeans/entrypoint'
7
+ import { errorStatus, handleError } from '../src/utils/error.js'
8
+ import { executeResponse } from '../src/utils/payload.js'
9
+ import { AccessError, AuthFailedError } from '../src/errors.js'
10
+
11
+ class OutOfCredit extends ResilientError {
12
+ public static override typeName = 'ServerApiSpecOutOfCredit'
13
+ public static httpStatus = 402
14
+
15
+ constructor(message: string = 'error') {
16
+ super(OutOfCredit.typeName, `out-of-credit:${message}`)
17
+ }
18
+ }
19
+
20
+ /** Inherits 402 without redeclaring it. */
21
+ class OutOfCreditForStory extends OutOfCredit {
22
+ public static override typeName = 'ServerApiSpecOutOfCreditForStory'
23
+
24
+ constructor() {
25
+ super('story')
26
+ this.type = OutOfCreditForStory.typeName
27
+ }
28
+ }
29
+
30
+ /** Redeclares its parent's status to a conflict. */
31
+ class Busy extends OutOfCredit {
32
+ public static override typeName = 'ServerApiSpecBusy'
33
+ public static override httpStatus = 409
34
+
35
+ constructor() {
36
+ super('busy')
37
+ this.type = Busy.typeName
38
+ }
39
+ }
40
+
41
+ /** An auth refusal that also declares a status: the auth mapping still wins. */
42
+ class DeclaringForbidden extends AuthForbidden {
43
+ public static override typeName = 'ServerApiSpecDeclaringForbidden'
44
+ public static httpStatus = 402
45
+
46
+ constructor() {
47
+ super('declaring')
48
+ this.type = DeclaringForbidden.typeName
49
+ }
50
+ }
51
+
52
+ const declaring = (typeName: string, httpStatus: unknown) => {
53
+ class Declaring extends ResilientError {
54
+ public static override typeName = typeName
55
+ public static httpStatus = httpStatus
56
+
57
+ constructor(message: string = 'error') {
58
+ super(typeName, message)
59
+ }
60
+ }
61
+ return Declaring
62
+ }
63
+
64
+ /**
65
+ * A duplicate module copy of the error family — what a process loading `@owlmeans/error` through
66
+ * several `node_modules` paths holds: the same registered names, none of the imported classes.
67
+ */
68
+ class ForeignResilientError extends Error {
69
+ public static typeName = 'ResilientError'
70
+ public type: string
71
+
72
+ constructor(type: string, message: string) {
73
+ super(message)
74
+ this.type = type
75
+ }
76
+ }
77
+
78
+ class ForeignAuthFailedError extends ForeignResilientError {
79
+ public static override typeName = 'AuthFailedError'
80
+
81
+ constructor() {
82
+ super(ForeignAuthFailedError.typeName, 'api:auth:error')
83
+ }
84
+ }
85
+
86
+ class ForeignAuthorizationError extends ForeignResilientError {
87
+ public static override typeName = 'AuthorizationError'
88
+
89
+ constructor(message: string = 'error') {
90
+ super(ForeignAuthorizationError.typeName, `auth:authorization:${message}`)
91
+ }
92
+ }
93
+
94
+ class ForeignAuthForbidden extends ForeignAuthorizationError {
95
+ public static override typeName = 'ForbiddenAuthorizationError'
96
+
97
+ constructor(message: string = 'error') {
98
+ super(`forbidden:${message}`)
99
+ this.type = ForeignAuthForbidden.typeName
100
+ }
101
+ }
102
+
103
+ /** A subclass whose own name embeds nothing of its parent's, like `EntitlementRefusal`. */
104
+ class ForeignEntitlementRefusal extends ForeignAuthForbidden {
105
+ public static override typeName = 'EntitlementRefusal'
106
+
107
+ constructor() {
108
+ super('entitlement')
109
+ this.type = ForeignEntitlementRefusal.typeName
110
+ }
111
+ }
112
+
113
+ /** A declared refusal no registry of this process knows. */
114
+ class ForeignOutOfCredit extends ForeignResilientError {
115
+ public static override typeName = 'ServerApiSpecForeignOutOfCredit'
116
+ public static httpStatus = 402
117
+
118
+ constructor() {
119
+ super(ForeignOutOfCredit.typeName, 'out-of-credit:foreign')
120
+ }
121
+ }
122
+
123
+ const typed = (type: string) => Object.assign(new Error('typed'), { type })
124
+
125
+ ResilientError.registerErrorClass(OutOfCredit)
126
+ ResilientError.registerErrorClass(OutOfCreditForStory)
127
+ ResilientError.registerErrorClass(Busy)
128
+ ResilientError.registerErrorClass(DeclaringForbidden)
129
+
130
+ let server: FastifyInstance
131
+ let thrown: unknown
132
+
133
+ beforeAll(async () => {
134
+ server = Fastify()
135
+ server.get('/handle', async (_req, reply) => {
136
+ handleError(thrown as Error, reply)
137
+ return reply
138
+ })
139
+ server.get('/execute', async (_req, reply) => {
140
+ const response = provideResponse(reply)
141
+ response.reject(thrown as Error)
142
+ executeResponse(response, reply)
143
+ return reply
144
+ })
145
+ await server.ready()
146
+ })
147
+
148
+ afterAll(async () => {
149
+ await server.close()
150
+ })
151
+
152
+ const answer = async (error: unknown, path: string = '/handle') => {
153
+ thrown = error
154
+ const reply = await server.inject({ method: 'GET', url: path })
155
+ return { status: reply.statusCode, body: reply.body }
156
+ }
157
+
158
+ describe('@owlmeans/server-api — handleError status', () => {
159
+ test('keeps the auth mappings: forbidden 403, unauthenticated 401', async () => {
160
+ expect((await answer(new AuthForbidden('x'))).status).toBe(403)
161
+ expect((await answer(new AccessError())).status).toBe(403)
162
+ expect((await answer(new AuthorizationError('x'))).status).toBe(401)
163
+ expect((await answer(new AuthFailedError())).status).toBe(401)
164
+ expect((await answer(new DeclaringForbidden())).status).toBe(403)
165
+ })
166
+
167
+ test('honours a 4xx a class declares — inherited, redeclared, or after a marshal hop', async () => {
168
+ const refused = await answer(new OutOfCredit('create'))
169
+ expect(refused.status).toBe(402)
170
+ expect(ResilientError.ensure(new Error(refused.body))).toBeInstanceOf(OutOfCredit)
171
+
172
+ expect((await answer(new OutOfCreditForStory())).status).toBe(402)
173
+ expect((await answer(new Busy())).status).toBe(409)
174
+ expect((await answer(ResilientError.marshal(new Busy()))).status).toBe(409)
175
+ expect((await answer(new ForeignOutOfCredit())).status).toBe(402)
176
+ })
177
+
178
+ test('answers 500 for no declaration, or one outside the client-error range', async () => {
179
+ expect((await answer(new ResilientError('Plain', 'plain'))).status).toBe(500)
180
+ expect((await answer(new Error('boom'))).status).toBe(500)
181
+ for (const status of [500, 503, 200, 302, 399, 600, 402.5, '402', null]) {
182
+ const Declaring = declaring(`ServerApiSpecDeclaring${String(status)}`, status)
183
+ expect((await answer(new Declaring())).status).toBe(500)
184
+ }
185
+ for (const type of ['AuthFailedErrorX', 'NotAuthorizationError', 'Forbidden', 'ResilientError']) {
186
+ expect((await answer(typed(type))).status).toBe(500)
187
+ expect((await answer(new ForeignResilientError(type, 'near-miss'))).status).toBe(500)
188
+ }
189
+ })
190
+
191
+ test('executeResponse answers a rejected response with the same status', async () => {
192
+ expect((await answer(new OutOfCredit('create'), '/execute')).status).toBe(402)
193
+ expect((await answer(new AuthForbidden('x'), '/execute')).status).toBe(403)
194
+ expect((await answer(new ResilientError('Plain', 'plain'), '/execute')).status).toBe(500)
195
+ })
196
+ })
197
+
198
+ describe('@owlmeans/server-api — auth status across a rebuild and duplicate module copies', () => {
199
+ test('an AuthFailedError answers 401 as thrown and after a marshal round trip', async () => {
200
+ expect((await answer(new AuthFailedError())).status).toBe(401)
201
+ expect((await answer(ResilientError.marshal(new AuthFailedError()))).status).toBe(401)
202
+ expect(errorStatus(ResilientError.ensure(ResilientError.marshal(new AuthFailedError())))).toBe(401)
203
+ expect((await answer(new AuthFailedError(), '/execute')).status).toBe(401)
204
+ })
205
+
206
+ test('a class from another module copy answers by its registered type name', async () => {
207
+ // The rebuild alone loses it — the regression this resolution exists for.
208
+ expect(errorStatus(ResilientError.ensure(new ForeignAuthFailedError()))).toBe(500)
209
+ expect((await answer(new ForeignAuthFailedError())).status).toBe(401)
210
+ expect((await answer(new ForeignAuthorizationError())).status).toBe(401)
211
+ expect((await answer(new ForeignAuthForbidden())).status).toBe(403)
212
+ expect((await answer(new ForeignAuthForbidden(), '/execute')).status).toBe(403)
213
+ })
214
+
215
+ test('the family is read off the instance type or the constructor chain, 403 before 401', async () => {
216
+ expect((await answer(typed('AuthFailedError'))).status).toBe(401)
217
+ expect((await answer(typed('ForbiddenAuthorizationError'))).status).toBe(403)
218
+ expect((await answer(typed('AccessAuthFailedError'))).status).toBe(403)
219
+ // Its own type names nothing of the family; only the chain does.
220
+ expect((await answer(new ForeignEntitlementRefusal())).status).toBe(403)
221
+ })
222
+ })