@owlmeans/server-api 0.1.18-rc.32 → 0.1.18-rc.34

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/server-api",
3
- "version": "0.1.18-rc.32",
3
+ "version": "0.1.18-rc.34",
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.28",
37
- "@owlmeans/auth": "^0.1.18-rc.26",
38
- "@owlmeans/auth-common": "^0.1.18-rc.28",
39
- "@owlmeans/context": "^0.1.18-rc.24",
40
- "@owlmeans/error": "^0.1.18-rc.26",
41
- "@owlmeans/entrypoint": "^0.1.18-rc.27",
42
- "@owlmeans/route": "^0.1.18-rc.25",
43
- "@owlmeans/server-context": "^0.1.18-rc.27",
44
- "@owlmeans/server-entrypoint": "^0.1.18-rc.26",
36
+ "@owlmeans/api": "^0.1.18-rc.30",
37
+ "@owlmeans/auth": "^0.1.18-rc.28",
38
+ "@owlmeans/auth-common": "^0.1.18-rc.30",
39
+ "@owlmeans/context": "^0.1.18-rc.26",
40
+ "@owlmeans/error": "^0.1.18-rc.28",
41
+ "@owlmeans/entrypoint": "^0.1.18-rc.29",
42
+ "@owlmeans/route": "^0.1.18-rc.27",
43
+ "@owlmeans/server-context": "^0.1.18-rc.29",
44
+ "@owlmeans/server-entrypoint": "^0.1.18-rc.28",
45
45
  "ajv-errors": "^3.0.0",
46
46
  "fastify": "^5.0.0",
47
47
  "fastify-raw-body": "^5.0.0",
package/src/server.ts CHANGED
@@ -8,6 +8,7 @@ import { RouteMethod } from '@owlmeans/route'
8
8
  import { createServerHandler, fixFormatDates } from './utils/index.js'
9
9
  import { provideResponse } from '@owlmeans/entrypoint'
10
10
  import { TOKEN_UPDATE } from '@owlmeans/auth-common'
11
+ import { INCIDENT_ID_HEADER } from './utils/error.js'
11
12
 
12
13
  import Fastify from 'fastify'
13
14
  import type { FastifyRequest } from 'fastify'
@@ -99,7 +100,7 @@ export const createApiServer = (alias: string): ApiServer => {
99
100
  ...Object.values(RouteMethod).map(method => method.toUpperCase()),
100
101
  'HEAD', 'OPTIONS'
101
102
  ],
102
- exposedHeaders: [TOKEN_UPDATE]
103
+ exposedHeaders: [TOKEN_UPDATE, INCIDENT_ID_HEADER]
103
104
  })
104
105
 
105
106
  await server.register(Multipart, {
@@ -199,4 +200,3 @@ export const appendApiServer = <C extends Config, T extends ServerContext<C>>(
199
200
 
200
201
  return context
201
202
  }
202
-
package/src/types.ts CHANGED
@@ -18,7 +18,20 @@ export interface ApiServerAppend {
18
18
  getApiServer: () => ApiServer
19
19
  }
20
20
 
21
- export interface Config extends ServerConfig { }
21
+ export type HttpErrorExposure = 'production' | 'development'
22
+
23
+ export interface HttpErrorsConfig {
24
+ /** Production is the safe default; development is the only mode that exposes stacks. */
25
+ exposure?: HttpErrorExposure
26
+ }
27
+
28
+ export interface HttpConfig {
29
+ errors?: HttpErrorsConfig
30
+ }
31
+
32
+ export interface Config extends ServerConfig {
33
+ http?: HttpConfig
34
+ }
22
35
 
23
36
  export interface Context<C extends Config = Config> extends ServerContext<C>,
24
37
  ApiServerAppend { }
@@ -2,34 +2,205 @@ import { FORBIDDEN_ERROR, SERVER_ERROR, UNAUTHORIZED_ERROR } from '@owlmeans/api
2
2
  import type { FastifyReply } from 'fastify'
3
3
  import { AccessError, AuthFailedError } from '../errors.js'
4
4
  import { AuthForbidden, AuthorizationError } from '@owlmeans/auth'
5
- import { ResilientError } from '@owlmeans/error'
5
+ import { isResilientError, ResilientError, SEPARATOR } from '@owlmeans/error'
6
+ import { randomUUID } from 'node:crypto'
7
+ import type { Config, HttpErrorExposure } from '../types.js'
8
+
9
+ export const INCIDENT_ID_HEADER = 'X-Incident-ID'
10
+
11
+ /**
12
+ * What an error class may declare about the HTTP status it is answered with.
13
+ *
14
+ * Structural on purpose: the classes that declare it live in packages that must not depend on an
15
+ * HTTP server (`@owlmeans/payment`, a product's contract package), so they write a plain
16
+ * `public static httpStatus = 409` and nothing imports this type.
17
+ */
18
+ export interface HttpStatusDeclaration {
19
+ httpStatus?: unknown
20
+ /** Explicit opt-in for a declared 5xx response. Client-error declarations need no opt-in. */
21
+ allowServerErrorStatus?: unknown
22
+ }
23
+
24
+ const CLIENT_ERROR_FIRST = 400
25
+ const CLIENT_ERROR_LAST = 499
26
+ const SERVER_ERROR_FIRST = 500
27
+ const SERVER_ERROR_LAST = 599
28
+
29
+ /**
30
+ * The HTTP status an error's class declares, or `null` when it declares none that may be honoured.
31
+ *
32
+ * Read through the constructor chain — a static property is inherited, so a subclass answers what
33
+ * its nearest declaring ancestor says, and redeclares to change it. An integer 4xx is honoured
34
+ * directly. A 5xx is honoured only with `allowServerErrorStatus = true`, so a domain class must
35
+ * explicitly opt in to exposing a precise availability response. The read is structural, so a
36
+ * class from a duplicate module copy declares exactly what the imported one does.
37
+ */
38
+ export const declaredErrorStatus = (error: unknown): number | null => {
39
+ if (error == null || typeof error !== 'object') {
40
+ return null
41
+ }
42
+ const declaration = error.constructor as HttpStatusDeclaration | undefined
43
+ const status = declaration?.httpStatus
44
+ if (typeof status !== 'number' || !Number.isInteger(status)) {
45
+ return null
46
+ }
47
+ if (status >= CLIENT_ERROR_FIRST && status <= CLIENT_ERROR_LAST) return status
48
+ if (status >= SERVER_ERROR_FIRST && status <= SERVER_ERROR_LAST
49
+ && declaration?.allowServerErrorStatus === true) return status
50
+
51
+ return null
52
+ }
53
+
54
+ /**
55
+ * The registered type names an error carries: its own `type`, and the static `typeName` of every
56
+ * class on its constructor chain.
57
+ *
58
+ * This is how a class is recognised when `instanceof` cannot see it. A process that loads a package
59
+ * twice — `bun --preserve-symlinks` over a linked workspace resolves one package through several
60
+ * `node_modules` paths — holds several copies of every error class, and an error built by one copy
61
+ * is not an instance of another. The registered names are the same in every copy. A subclass's
62
+ * `typeName` does not reliably embed its parent's (`EntitlementRefusal` extends `AuthForbidden`), so
63
+ * a family is recognised by walking the chain and matching names exactly, never by a substring.
64
+ */
65
+ const typeNamesOf = (error: object): Set<string> => {
66
+ const names = new Set<string>()
67
+ const type = (error as { type?: unknown }).type
68
+ if (typeof type === 'string') {
69
+ names.add(type)
70
+ }
71
+ let ctor: unknown = error.constructor
72
+ while (typeof ctor === 'function') {
73
+ const name = (ctor as { typeName?: unknown }).typeName
74
+ if (typeof name === 'string') {
75
+ names.add(name)
76
+ }
77
+ ctor = Object.getPrototypeOf(ctor)
78
+ }
79
+
80
+ return names
81
+ }
82
+
83
+ const isOfFamily = (names: Set<string>, family: string[]): boolean =>
84
+ family.some(typeName => names.has(typeName))
6
85
 
7
86
  /**
8
- * Map a thrown error onto an HTTP status.
87
+ * The HTTP status an error is answered with, read off the error exactly as given.
9
88
  *
10
89
  * Two families reach here and both must be recognised. `AuthFailedError` / `AccessError` are this
11
90
  * package's own, raised by the request pipeline itself. `AuthorizationError` / `AuthForbidden`
12
91
  * come from `@owlmeans/auth` and are what the guards and gates actually throw — an unrecognised
13
92
  * one answers 500, which reports a refused request as a crashed server: the client cannot tell
14
93
  * "sign in again" from "the service is broken", and a consumer watching statuses concludes the
15
- * application fell over.
94
+ * application fell over. A family member is recognised by class OR by registered type name
95
+ * ({@link typeNamesOf}), so a class from a duplicate module copy answers the same status.
16
96
  *
17
97
  * **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.
98
+ * tested first or every refusal of a permission is reported as a failure to authenticate. Both
99
+ * auth branches come before a class's own declaration, so an auth refusal can never be restated
100
+ * as something else. Everything that is neither an auth error nor a class declaring an allowed
101
+ * {@link declaredErrorStatus} answers 500.
19
102
  */
20
- export const handleError = (error: Error, reply: FastifyReply) => {
21
- let code: number = SERVER_ERROR
22
- if (error instanceof AuthForbidden || error instanceof AccessError) {
103
+ export const errorStatus = (error: unknown): number => {
104
+ if (error == null || typeof error !== 'object') {
105
+ return SERVER_ERROR
106
+ }
107
+ const names = typeNamesOf(error)
108
+ if (error instanceof AuthForbidden || error instanceof AccessError
109
+ || isOfFamily(names, [AuthForbidden.typeName, AccessError.typeName])) {
23
110
  // An established identity that lacks the permission. Re-authenticating changes nothing.
24
- code = FORBIDDEN_ERROR
25
- } else if (error instanceof AuthorizationError || error instanceof AuthFailedError) {
111
+ return FORBIDDEN_ERROR
112
+ }
113
+ if (error instanceof AuthorizationError || error instanceof AuthFailedError
114
+ || isOfFamily(names, [AuthorizationError.typeName, AuthFailedError.typeName])) {
26
115
  // No usable credential — absent, expired, revoked, or naming a session this server no longer
27
116
  // holds. The caller is told to sign in, not that signing in would not help.
28
- code = UNAUTHORIZED_ERROR
117
+ return UNAUTHORIZED_ERROR
118
+ }
119
+
120
+ return declaredErrorStatus(error) ?? SERVER_ERROR
121
+ }
122
+
123
+ export interface SerializedHttpError {
124
+ status: number
125
+ body: string
126
+ incidentId: string
127
+ }
128
+
129
+ /** Invalid or absent configuration always resolves to the production-safe policy. */
130
+ export const errorExposure = (config?: Pick<Config, 'http'>): HttpErrorExposure =>
131
+ config?.http?.errors?.exposure === 'development' ? 'development' : 'production'
132
+
133
+ /**
134
+ * Preserve a typed/marshalled error when possible without asking `ensure` to convert a plain
135
+ * error. In particular, `ensure` deliberately rethrows SyntaxError; an HTTP boundary still has
136
+ * to serialize it safely and let the incident log retain the original object.
137
+ */
138
+ const normalizeForWire = (error: Error): Error => {
139
+ if (error instanceof SyntaxError || isResilientError(error)) {
140
+ return error
141
+ }
142
+ if (error.message.includes(SEPARATOR)) {
143
+ const ensured = ResilientError.ensure(error)
144
+ // The catch-all converter's legacy constructor shape shifts an ordinary error's fields.
145
+ // Only keep `ensure`'s result when a registered wire prefix actually rebuilt something.
146
+ if (ensured.type !== error.message || ensured.message !== error.stack) {
147
+ return ensured
148
+ }
29
149
  }
150
+ return error
151
+ }
152
+
153
+ /** One central wire shape for every server error. */
154
+ export const serializeError = (
155
+ error: Error, exposure: HttpErrorExposure = 'production'
156
+ ): SerializedHttpError => {
157
+ const normalized = normalizeForWire(error)
158
+ const thrown = errorStatus(error)
159
+ const status = thrown !== SERVER_ERROR ? thrown : errorStatus(normalized)
160
+ const incidentId = randomUUID()
161
+ // The server log owns every diagnostic. Production callers receive only its correlation key;
162
+ // development keeps the typed wire form so local clients can retain their usual error details.
163
+ Object.assign(error, { incidentId })
164
+ if (isResilientError(normalized)) normalized.incidentId = incidentId
165
+ const body = exposure === 'production'
166
+ ? incidentId
167
+ : ResilientError.marshal(normalized, { includeStack: true, incidentId }).message
168
+
169
+ return { status, body, incidentId }
170
+ }
171
+
172
+ /** Apply safe response metadata carried by an error without coupling its package to Fastify. */
173
+ export const applyErrorHeaders = (error: unknown, reply: FastifyReply): void => {
174
+ if (error == null || typeof error !== 'object') {
175
+ return
176
+ }
177
+ const retryAfter = (error as { retryAfter?: unknown }).retryAfter
178
+ if (typeof retryAfter === 'number' && Number.isFinite(retryAfter) && retryAfter > 0) {
179
+ reply.header('Retry-After', String(Math.ceil(retryAfter)))
180
+ }
181
+ }
182
+
183
+ /**
184
+ * Answer a thrown error: a status, and the marshalled `ResilientError` as the body.
185
+ *
186
+ * The status is resolved in two steps. The error AS THROWN comes first: it is the one object whose
187
+ * class is certainly the class that was raised, while `ResilientError.ensure` rebuilds through the
188
+ * registry of whichever `@owlmeans/error` copy this module loaded — under duplicate module copies
189
+ * that turns a perfectly recognisable auth refusal into a plain `ResilientError` answering 500.
190
+ * Only when the thrown error answers 500 is the ENSURED error asked, because the rebuild is what
191
+ * gives a status to a marshalled error that crossed a hop as a plain `Error`.
192
+ */
193
+ export const handleError = (
194
+ error: Error, reply: FastifyReply, exposure: HttpErrorExposure = 'production'
195
+ ) => {
30
196
  if (!reply.sent) {
31
- reply.code(code).send(
32
- ResilientError.marshal(ResilientError.ensure(error as Error)).message
33
- )
197
+ const serialized = serializeError(error, exposure)
198
+ applyErrorHeaders(error, reply)
199
+ // Fastify/Pino's `err` serializer retains the original type, message and full stack. This is
200
+ // the one log entry that owns those details in production, keyed by the response incident ID.
201
+ reply.log.error({ err: error, incidentId: serialized.incidentId }, 'Request failed')
202
+ reply.header(INCIDENT_ID_HEADER, serialized.incidentId)
203
+ .code(serialized.status)
204
+ .send(serialized.body)
34
205
  }
35
206
  }
@@ -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 { handleError } 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,7 @@ 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
+ handleError(response.error, reply)
39
40
  return true
40
41
  } else if (response.outcome != null) {
41
42
  switch (response.outcome) {
@@ -5,15 +5,16 @@ import type { FixerService, ServerEntrypoint } from '@owlmeans/server-entrypoint
5
5
  import type { CommonEntrypoint } from '@owlmeans/entrypoint'
6
6
  import type { GateService } from '@owlmeans/entrypoint'
7
7
  import { provideResponse } from '@owlmeans/entrypoint'
8
- import type { ServerContext, ServerConfig } from '@owlmeans/server-context'
8
+ import type { ServerContext } from '@owlmeans/server-context'
9
9
  import { ResilientError } from '@owlmeans/error'
10
10
  import { OK } from '@owlmeans/api'
11
- import { handleError } from './error.js'
11
+ import { errorExposure, handleError } from './error.js'
12
12
  import { executeResponse, provideRequest } from './payload.js'
13
13
  import { authorize } from './guards.js'
14
14
  import { RouteProtocols } from '@owlmeans/route'
15
+ import type { Config as ApiConfig } from '../types.js'
15
16
 
16
- type Config = ServerConfig
17
+ type Config = ApiConfig
17
18
  type Context = ServerContext<Config>
18
19
 
19
20
  export const canServeModule = (context: Context, module: CommonEntrypoint): module is ServerEntrypoint<unknown> => {
@@ -23,10 +24,9 @@ export const canServeModule = (context: Context, module: CommonEntrypoint): modu
23
24
  if (module.route.route.service != null && module.route.route.service !== context.cfg.service) {
24
25
  return false
25
26
  }
26
- // Only the protocols HTTP actually carries. A socket is upgraded elsewhere and a queued job is
27
- // taken off the broker by the worker — mounting either on the HTTP server would answer it twice.
28
- if (module.route.route.protocol === RouteProtocols.SOCKET
29
- || module.route.route.protocol === RouteProtocols.QUEUE) {
27
+ // HTTP serves only its own protocol. Custom transports are handled by their owning packages.
28
+ if (module.route.route.protocol != null
29
+ && module.route.route.protocol !== RouteProtocols.WEB) {
30
30
  return false
31
31
  }
32
32
 
@@ -66,14 +66,11 @@ export const createServerHandler = (module: ServerEntrypoint<FastifyRequest>, lo
66
66
  reply.code(OK).send(response.value)
67
67
  }
68
68
  } catch (error) {
69
- console.error(`Error in ${module.alias} (${location})`)
70
- console.error(JSON.stringify(error, null, 2))
71
- console.error(error)
72
69
  if (module.fixer != null) {
73
70
  const fixer: FixerService = context.service(module.fixer)
74
71
  fixer.handle(reply, ResilientError.ensure(error as Error))
75
72
  return
76
73
  }
77
- handleError(error as Error, reply)
74
+ handleError(error as Error, reply, errorExposure(context.cfg))
78
75
  }
79
76
  }
@@ -0,0 +1,290 @@
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, SEPARATOR } from '@owlmeans/error'
6
+ import { provideResponse } from '@owlmeans/entrypoint'
7
+ import { errorExposure, errorStatus, handleError, INCIDENT_ID_HEADER } 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
+ class TemporarilyUnavailable extends ResilientError {
53
+ public static override typeName = 'ServerApiSpecTemporarilyUnavailable'
54
+ public static httpStatus = 503
55
+ public static allowServerErrorStatus = true
56
+
57
+ constructor() {
58
+ super(TemporarilyUnavailable.typeName, 'temporarily-unavailable')
59
+ }
60
+ }
61
+
62
+ class Throttled extends ResilientError {
63
+ public static override typeName = 'ServerApiSpecThrottled'
64
+ public static httpStatus = 429
65
+ public readonly retryAfter = 17.2
66
+
67
+ constructor() {
68
+ super(Throttled.typeName, 'throttled')
69
+ }
70
+ }
71
+
72
+ const declaring = (typeName: string, httpStatus: unknown) => {
73
+ class Declaring extends ResilientError {
74
+ public static override typeName = typeName
75
+ public static httpStatus = httpStatus
76
+
77
+ constructor(message: string = 'error') {
78
+ super(typeName, message)
79
+ }
80
+ }
81
+ return Declaring
82
+ }
83
+
84
+ /**
85
+ * A duplicate module copy of the error family — what a process loading `@owlmeans/error` through
86
+ * several `node_modules` paths holds: the same registered names, none of the imported classes.
87
+ */
88
+ class ForeignResilientError extends Error {
89
+ public static typeName = 'ResilientError'
90
+ public type: string
91
+
92
+ constructor(type: string, message: string) {
93
+ super(message)
94
+ this.type = type
95
+ }
96
+ }
97
+
98
+ class ForeignAuthFailedError extends ForeignResilientError {
99
+ public static override typeName = 'AuthFailedError'
100
+
101
+ constructor() {
102
+ super(ForeignAuthFailedError.typeName, 'api:auth:error')
103
+ }
104
+ }
105
+
106
+ class ForeignAuthorizationError extends ForeignResilientError {
107
+ public static override typeName = 'AuthorizationError'
108
+
109
+ constructor(message: string = 'error') {
110
+ super(ForeignAuthorizationError.typeName, `auth:authorization:${message}`)
111
+ }
112
+ }
113
+
114
+ class ForeignAuthForbidden extends ForeignAuthorizationError {
115
+ public static override typeName = 'ForbiddenAuthorizationError'
116
+
117
+ constructor(message: string = 'error') {
118
+ super(`forbidden:${message}`)
119
+ this.type = ForeignAuthForbidden.typeName
120
+ }
121
+ }
122
+
123
+ /** A subclass whose own name embeds nothing of its parent's, like `EntitlementRefusal`. */
124
+ class ForeignEntitlementRefusal extends ForeignAuthForbidden {
125
+ public static override typeName = 'EntitlementRefusal'
126
+
127
+ constructor() {
128
+ super('entitlement')
129
+ this.type = ForeignEntitlementRefusal.typeName
130
+ }
131
+ }
132
+
133
+ /** A declared refusal no registry of this process knows. */
134
+ class ForeignOutOfCredit extends ForeignResilientError {
135
+ public static override typeName = 'ServerApiSpecForeignOutOfCredit'
136
+ public static httpStatus = 402
137
+
138
+ constructor() {
139
+ super(ForeignOutOfCredit.typeName, 'out-of-credit:foreign')
140
+ }
141
+ }
142
+
143
+ const typed = (type: string) => Object.assign(new Error('typed'), { type })
144
+
145
+ ResilientError.registerErrorClass(OutOfCredit)
146
+ ResilientError.registerErrorClass(OutOfCreditForStory)
147
+ ResilientError.registerErrorClass(Busy)
148
+ ResilientError.registerErrorClass(DeclaringForbidden)
149
+
150
+ let server: FastifyInstance
151
+ let thrown: unknown
152
+
153
+ beforeAll(async () => {
154
+ server = Fastify()
155
+ server.get('/handle', async (_req, reply) => {
156
+ handleError(thrown as Error, reply)
157
+ return reply
158
+ })
159
+ server.get('/handle-development', async (_req, reply) => {
160
+ handleError(thrown as Error, reply, 'development')
161
+ return reply
162
+ })
163
+ server.get('/execute', async (_req, reply) => {
164
+ const response = provideResponse(reply)
165
+ response.reject(thrown as Error)
166
+ executeResponse(response, reply)
167
+ return reply
168
+ })
169
+ await server.ready()
170
+ })
171
+
172
+ afterAll(async () => {
173
+ await server.close()
174
+ })
175
+
176
+ const answer = async (error: unknown, path: string = '/handle') => {
177
+ thrown = error
178
+ const reply = await server.inject({ method: 'GET', url: path })
179
+ return {
180
+ status: reply.statusCode,
181
+ body: reply.body,
182
+ incidentId: reply.headers[INCIDENT_ID_HEADER.toLowerCase()],
183
+ retryAfter: reply.headers['retry-after'],
184
+ }
185
+ }
186
+
187
+ describe('@owlmeans/server-api — production error exposure', () => {
188
+ test('returns only an incident id in production and correlates it with the header', async () => {
189
+ const response = await answer(new Error('private-marker'))
190
+ expect(response.body).toBe(response.incidentId)
191
+ expect(response.body).not.toContain('private-marker')
192
+ expect(response.body).not.toContain(SEPARATOR)
193
+ expect(response.body).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
194
+ })
195
+
196
+ test('includes stacks only under explicit development exposure and safely serializes SyntaxError', async () => {
197
+ const development = await answer(new Error('development-marker'), '/handle-development')
198
+ const [type, message, stack, incidentId] = development.body.split(SEPARATOR, 4)
199
+ expect(type).toBe(ResilientError.typeName)
200
+ expect(message).toBe('development-marker')
201
+ expect(stack).toContain('development-marker')
202
+ expect(incidentId).toBe(development.incidentId)
203
+
204
+ const syntax = await answer(new SyntaxError('bad wiring'))
205
+ expect(syntax.status).toBe(500)
206
+ expect(syntax.body).toBe(syntax.incidentId)
207
+ expect(syntax.body).not.toContain('bad wiring')
208
+
209
+ expect(errorExposure()).toBe('production')
210
+ expect(errorExposure({ http: { errors: { exposure: 'development' } } })).toBe('development')
211
+ })
212
+ })
213
+
214
+ describe('@owlmeans/server-api — handleError status', () => {
215
+ test('keeps the auth mappings: forbidden 403, unauthenticated 401', async () => {
216
+ expect((await answer(new AuthForbidden('x'))).status).toBe(403)
217
+ expect((await answer(new AccessError())).status).toBe(403)
218
+ expect((await answer(new AuthorizationError('x'))).status).toBe(401)
219
+ expect((await answer(new AuthFailedError())).status).toBe(401)
220
+ expect((await answer(new DeclaringForbidden())).status).toBe(403)
221
+ })
222
+
223
+ test('honours a 4xx a class declares — inherited, redeclared, or after a marshal hop', async () => {
224
+ const refused = await answer(new OutOfCredit('create'))
225
+ expect(refused.status).toBe(402)
226
+ expect(refused.body).toBe(refused.incidentId)
227
+
228
+ expect((await answer(new OutOfCreditForStory())).status).toBe(402)
229
+ expect((await answer(new Busy())).status).toBe(409)
230
+ expect((await answer(ResilientError.marshal(new Busy()))).status).toBe(409)
231
+ expect((await answer(new ForeignOutOfCredit())).status).toBe(402)
232
+ })
233
+
234
+ test('answers 500 for no declaration, or one outside the client-error range', async () => {
235
+ expect((await answer(new ResilientError('Plain', 'plain'))).status).toBe(500)
236
+ expect((await answer(new Error('boom'))).status).toBe(500)
237
+ for (const status of [500, 503, 200, 302, 399, 600, 402.5, '402', null]) {
238
+ const Declaring = declaring(`ServerApiSpecDeclaring${String(status)}`, status)
239
+ expect((await answer(new Declaring())).status).toBe(500)
240
+ }
241
+ for (const type of ['AuthFailedErrorX', 'NotAuthorizationError', 'Forbidden', 'ResilientError']) {
242
+ expect((await answer(typed(type))).status).toBe(500)
243
+ expect((await answer(new ForeignResilientError(type, 'near-miss'))).status).toBe(500)
244
+ }
245
+ })
246
+
247
+ test('honours an explicitly exposed 5xx without opening arbitrary server statuses', async () => {
248
+ expect((await answer(new TemporarilyUnavailable())).status).toBe(503)
249
+ const Unexposed = declaring('ServerApiSpecUnexposed503', 503)
250
+ expect((await answer(new Unexposed())).status).toBe(500)
251
+ })
252
+
253
+ test('emits Retry-After for a throttled response', async () => {
254
+ const response = await answer(new Throttled())
255
+ expect(response.status).toBe(429)
256
+ expect(response.retryAfter).toBe('18')
257
+ })
258
+
259
+ test('executeResponse answers a rejected response with the same status', async () => {
260
+ expect((await answer(new OutOfCredit('create'), '/execute')).status).toBe(402)
261
+ expect((await answer(new AuthForbidden('x'), '/execute')).status).toBe(403)
262
+ expect((await answer(new ResilientError('Plain', 'plain'), '/execute')).status).toBe(500)
263
+ })
264
+ })
265
+
266
+ describe('@owlmeans/server-api — auth status across a rebuild and duplicate module copies', () => {
267
+ test('an AuthFailedError answers 401 as thrown and after a marshal round trip', async () => {
268
+ expect((await answer(new AuthFailedError())).status).toBe(401)
269
+ expect((await answer(ResilientError.marshal(new AuthFailedError()))).status).toBe(401)
270
+ expect(errorStatus(ResilientError.ensure(ResilientError.marshal(new AuthFailedError())))).toBe(401)
271
+ expect((await answer(new AuthFailedError(), '/execute')).status).toBe(401)
272
+ })
273
+
274
+ test('a class from another module copy answers by its registered type name', async () => {
275
+ // The rebuild alone loses it — the regression this resolution exists for.
276
+ expect(errorStatus(ResilientError.ensure(new ForeignAuthFailedError()))).toBe(500)
277
+ expect((await answer(new ForeignAuthFailedError())).status).toBe(401)
278
+ expect((await answer(new ForeignAuthorizationError())).status).toBe(401)
279
+ expect((await answer(new ForeignAuthForbidden())).status).toBe(403)
280
+ expect((await answer(new ForeignAuthForbidden(), '/execute')).status).toBe(403)
281
+ })
282
+
283
+ test('the family is read off the instance type or the constructor chain, 403 before 401', async () => {
284
+ expect((await answer(typed('AuthFailedError'))).status).toBe(401)
285
+ expect((await answer(typed('ForbiddenAuthorizationError'))).status).toBe(403)
286
+ expect((await answer(typed('AccessAuthFailedError'))).status).toBe(403)
287
+ // Its own type names nothing of the family; only the chain does.
288
+ expect((await answer(new ForeignEntitlementRefusal())).status).toBe(403)
289
+ })
290
+ })