@owlmeans/server-api 0.1.18-rc.50 → 0.1.18-rc.52

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.
Files changed (55) hide show
  1. package/README.md +20 -19
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/server-api/SKILL.md +9 -9
  4. package/build/server.js +3 -3
  5. package/build/server.js.map +1 -1
  6. package/build/utils/context/types.d.ts +13 -0
  7. package/build/utils/context/types.d.ts.map +1 -0
  8. package/build/utils/context/types.js +2 -0
  9. package/build/utils/context/types.js.map +1 -0
  10. package/build/utils/context.d.ts +3 -3
  11. package/build/utils/context.d.ts.map +1 -1
  12. package/build/utils/context.js +5 -2
  13. package/build/utils/context.js.map +1 -1
  14. package/build/utils/error/types.d.ts +52 -0
  15. package/build/utils/error/types.d.ts.map +1 -0
  16. package/build/utils/error/types.js +2 -0
  17. package/build/utils/error/types.js.map +1 -0
  18. package/build/utils/error.d.ts +4 -47
  19. package/build/utils/error.d.ts.map +1 -1
  20. package/build/utils/error.js +154 -187
  21. package/build/utils/error.js.map +1 -1
  22. package/build/utils/guards.d.ts.map +1 -1
  23. package/build/utils/guards.js +6 -6
  24. package/build/utils/guards.js.map +1 -1
  25. package/build/utils/index.d.ts +4 -1
  26. package/build/utils/index.d.ts.map +1 -1
  27. package/build/utils/index.js +1 -1
  28. package/build/utils/index.js.map +1 -1
  29. package/build/utils/payload/types.d.ts +18 -0
  30. package/build/utils/payload/types.d.ts.map +1 -0
  31. package/build/utils/payload/types.js +2 -0
  32. package/build/utils/payload/types.js.map +1 -0
  33. package/build/utils/payload.d.ts +3 -15
  34. package/build/utils/payload.d.ts.map +1 -1
  35. package/build/utils/payload.js +40 -60
  36. package/build/utils/payload.js.map +1 -1
  37. package/build/utils/schema.d.ts +7 -0
  38. package/build/utils/schema.d.ts.map +1 -0
  39. package/build/utils/schema.js +20 -0
  40. package/build/utils/schema.js.map +1 -0
  41. package/build/utils/server.js +6 -6
  42. package/build/utils/server.js.map +1 -1
  43. package/package.json +12 -12
  44. package/src/server.ts +3 -3
  45. package/src/utils/context/types.ts +13 -0
  46. package/src/utils/context.ts +9 -4
  47. package/src/utils/error/types.ts +52 -0
  48. package/src/utils/error.ts +156 -186
  49. package/src/utils/guards.ts +6 -6
  50. package/src/utils/index.ts +4 -1
  51. package/src/utils/payload/types.ts +18 -0
  52. package/src/utils/payload.ts +41 -63
  53. package/src/utils/schema.ts +23 -0
  54. package/src/utils/server.ts +6 -6
  55. package/tests/error-status.spec.ts +11 -11
@@ -11,210 +11,180 @@ import type { Config, HttpErrorExposure } from '../types.js'
11
11
  import { CLIENT_ERROR_FIRST, CLIENT_ERROR_LAST, SERVER_ERROR_FIRST, SERVER_ERROR_LAST } from './consts.local.js'
12
12
  import { INCIDENT_ID_HEADER } from './consts.js'
13
13
  import type { HttpStatusDeclaration, SerializedHttpError } from './types.js'
14
+ import type { HttpErrorHelper } from './error/types.js'
14
15
 
16
+ export const createHttpErrorHelper = (): HttpErrorHelper => {
17
+ const declaredErrorStatus = (error: unknown): number | null => {
18
+ if (error == null || typeof error !== 'object') {
19
+ return null
20
+ }
21
+ const declaration = error.constructor as HttpStatusDeclaration | undefined
22
+ const status = declaration?.httpStatus
23
+ if (typeof status !== 'number' || !Number.isInteger(status)) {
24
+ return null
25
+ }
26
+ if (status >= CLIENT_ERROR_FIRST && status <= CLIENT_ERROR_LAST) return status
27
+ if (status >= SERVER_ERROR_FIRST && status <= SERVER_ERROR_LAST
28
+ && declaration?.allowServerErrorStatus === true) return status
15
29
 
16
- /**
17
- * The HTTP status an error's class declares, or `null` when it declares none that may be honoured.
18
- *
19
- * Read through the constructor chain — a static property is inherited, so a subclass answers what
20
- * its nearest declaring ancestor says, and redeclares to change it. An integer 4xx is honoured
21
- * directly. A 5xx is honoured only with `allowServerErrorStatus = true`, so a domain class must
22
- * explicitly opt in to exposing a precise availability response. The read is structural, so a
23
- * class from a duplicate module copy declares exactly what the imported one does.
24
- */
25
- export const declaredErrorStatus = (error: unknown): number | null => {
26
- if (error == null || typeof error !== 'object') {
27
30
  return null
28
31
  }
29
- const declaration = error.constructor as HttpStatusDeclaration | undefined
30
- const status = declaration?.httpStatus
31
- if (typeof status !== 'number' || !Number.isInteger(status)) {
32
- return null
33
- }
34
- if (status >= CLIENT_ERROR_FIRST && status <= CLIENT_ERROR_LAST) return status
35
- if (status >= SERVER_ERROR_FIRST && status <= SERVER_ERROR_LAST
36
- && declaration?.allowServerErrorStatus === true) return status
37
-
38
- return null
39
- }
40
32
 
41
- /**
42
- * The registered type names an error carries: its own `type`, and the static `typeName` of every
43
- * class on its constructor chain.
44
- *
45
- * This is how a class is recognised when `instanceof` cannot see it. A process that loads a package
46
- * twice — `bun --preserve-symlinks` over a linked workspace resolves one package through several
47
- * `node_modules` paths — holds several copies of every error class, and an error built by one copy
48
- * is not an instance of another. The registered names are the same in every copy. A subclass's
49
- * `typeName` does not reliably embed its parent's (`EntitlementRefusal` extends `AuthForbidden`), so
50
- * a family is recognised by walking the chain and matching names exactly, never by a substring.
51
- */
52
- const typeNamesOf = (error: object): Set<string> => {
53
- const names = new Set<string>()
54
- const type = (error as { type?: unknown }).type
55
- if (typeof type === 'string') {
56
- names.add(type)
57
- }
58
- let ctor: unknown = error.constructor
59
- while (typeof ctor === 'function') {
60
- const name = (ctor as { typeName?: unknown }).typeName
61
- if (typeof name === 'string') {
62
- names.add(name)
33
+ /**
34
+ * The registered type names an error carries: its own `type`, and the static `typeName` of every
35
+ * class on its constructor chain.
36
+ *
37
+ * This is how a class is recognised when `instanceof` cannot see it. A process that loads a package
38
+ * twice — `bun --preserve-symlinks` over a linked workspace resolves one package through several
39
+ * `node_modules` paths — holds several copies of every error class, and an error built by one copy
40
+ * is not an instance of another. The registered names are the same in every copy. A subclass's
41
+ * `typeName` does not reliably embed its parent's (`EntitlementRefusal` extends `AuthForbidden`), so
42
+ * a family is recognised by walking the chain and matching names exactly, never by a substring.
43
+ */
44
+ const typeNamesOf = (error: object): Set<string> => {
45
+ const names = new Set<string>()
46
+ const type = (error as { type?: unknown }).type
47
+ if (typeof type === 'string') {
48
+ names.add(type)
49
+ }
50
+ let ctor: unknown = error.constructor
51
+ while (typeof ctor === 'function') {
52
+ const name = (ctor as { typeName?: unknown }).typeName
53
+ if (typeof name === 'string') {
54
+ names.add(name)
55
+ }
56
+ ctor = Object.getPrototypeOf(ctor)
63
57
  }
64
- ctor = Object.getPrototypeOf(ctor)
65
- }
66
-
67
- return names
68
- }
69
58
 
70
- const isOfFamily = (names: Set<string>, family: string[]): boolean =>
71
- family.some(typeName => names.has(typeName))
72
-
73
- /**
74
- * The HTTP status an error is answered with, read off the error exactly as given.
75
- *
76
- * Two families reach here and both must be recognised. `AuthFailedError` / `AccessError` are this
77
- * package's own, raised by the request pipeline itself. `AuthorizationError` / `AuthForbidden`
78
- * come from `@owlmeans/auth` and are what the guards and gates actually throw — an unrecognised
79
- * one answers 500, which reports a refused request as a crashed server: the client cannot tell
80
- * "sign in again" from "the service is broken", and a consumer watching statuses concludes the
81
- * application fell over. A family member is recognised by class OR by registered type name
82
- * ({@link typeNamesOf}), so a class from a duplicate module copy answers the same status.
83
- *
84
- * **Order matters.** `AuthForbidden extends AuthorizationError`, so the 403 branch has to be
85
- * tested first or every refusal of a permission is reported as a failure to authenticate. Both
86
- * auth branches come before a class's own declaration, so an auth refusal can never be restated
87
- * as something else. Everything that is neither an auth error nor a class declaring an allowed
88
- * {@link declaredErrorStatus} answers 500.
89
- */
90
- export const errorStatus = (error: unknown): number => {
91
- if (error == null || typeof error !== 'object') {
92
- return SERVER_ERROR
93
- }
94
- const names = typeNamesOf(error)
95
- if (error instanceof AuthForbidden || error instanceof AccessError
96
- || isOfFamily(names, [AuthForbidden.typeName, AccessError.typeName])) {
97
- // An established identity that lacks the permission. Re-authenticating changes nothing.
98
- return FORBIDDEN_ERROR
99
- }
100
- if (error instanceof AuthorizationError || error instanceof AuthFailedError
101
- || isOfFamily(names, [AuthorizationError.typeName, AuthFailedError.typeName])) {
102
- // No usable credential — absent, expired, revoked, or naming a session this server no longer
103
- // holds. The caller is told to sign in, not that signing in would not help.
104
- return UNAUTHORIZED_ERROR
59
+ return names
105
60
  }
106
61
 
107
- return declaredErrorStatus(error) ?? SERVER_ERROR
108
- }
62
+ const isOfFamily = (names: Set<string>, family: string[]): boolean =>
63
+ family.some(typeName => names.has(typeName))
109
64
 
110
- /** Invalid or absent configuration always resolves to the production-safe policy. */
111
- export const errorExposure = (config?: Pick<Config, 'http'>): HttpErrorExposure =>
112
- config?.http?.errors?.exposure === 'development' ? 'development' : 'production'
113
-
114
- /**
115
- * Preserve a typed/marshalled error when possible without asking `ensure` to convert a plain
116
- * error. In particular, `ensure` deliberately rethrows SyntaxError; an HTTP boundary still has
117
- * to serialize it safely and let the incident log retain the original object.
118
- */
119
- const normalizeForWire = (error: Error): Error => {
120
- if (error instanceof SyntaxError || isResilientError(error)) {
121
- return error
122
- }
123
- if (error.message.includes(SEPARATOR)) {
124
- const ensured = ResilientError.ensure(error)
125
- // The catch-all converter's legacy constructor shape shifts an ordinary error's fields.
126
- // Only keep `ensure`'s result when a registered wire prefix actually rebuilt something.
127
- if (ensured.type !== error.message || ensured.message !== error.stack) {
128
- return ensured
65
+ const errorStatus = (error: unknown): number => {
66
+ if (error == null || typeof error !== 'object') {
67
+ return SERVER_ERROR
68
+ }
69
+ const names = typeNamesOf(error)
70
+ if (error instanceof AuthForbidden || error instanceof AccessError
71
+ || isOfFamily(names, [AuthForbidden.typeName, AccessError.typeName])) {
72
+ // An established identity that lacks the permission. Re-authenticating changes nothing.
73
+ return FORBIDDEN_ERROR
74
+ }
75
+ if (error instanceof AuthorizationError || error instanceof AuthFailedError
76
+ || isOfFamily(names, [AuthorizationError.typeName, AuthFailedError.typeName])) {
77
+ // No usable credential — absent, expired, revoked, or naming a session this server no longer
78
+ // holds. The caller is told to sign in, not that signing in would not help.
79
+ return UNAUTHORIZED_ERROR
129
80
  }
130
- }
131
- return error
132
- }
133
81
 
134
- /** One central wire shape for every server error. */
135
- export const serializeError = (
136
- error: Error, exposure: HttpErrorExposure = 'production'
137
- ): SerializedHttpError => {
138
- const normalized = normalizeForWire(error)
139
- const thrown = errorStatus(error)
140
- const status = thrown !== SERVER_ERROR ? thrown : errorStatus(normalized)
141
- const incidentId = randomUUID()
142
- // The server log owns every diagnostic. Production callers receive only its correlation key;
143
- // development keeps the typed wire form so local clients can retain their usual error details.
144
- Object.assign(error, { incidentId })
145
- if (isResilientError(normalized)) normalized.incidentId = incidentId
146
- const body = exposure === 'production'
147
- ? incidentId
148
- : ResilientError.marshal(normalized, { includeStack: true, incidentId }).message
149
-
150
- return { status, body, incidentId }
151
- }
82
+ return declaredErrorStatus(error) ?? SERVER_ERROR
83
+ }
152
84
 
153
- /** Apply safe response metadata carried by an error without coupling its package to Fastify. */
154
- export const applyErrorHeaders = (error: unknown, reply: FastifyReply): void => {
155
- if (error == null || typeof error !== 'object') {
156
- return
85
+ const errorExposure = (config?: Pick<Config, 'http'>): HttpErrorExposure =>
86
+ config?.http?.errors?.exposure === 'development' ? 'development' : 'production'
87
+
88
+ /**
89
+ * Preserve a typed/marshalled error when possible without asking `ensure` to convert a plain
90
+ * error. In particular, `ensure` deliberately rethrows SyntaxError; an HTTP boundary still has
91
+ * to serialize it safely and let the incident log retain the original object.
92
+ */
93
+ const normalizeForWire = (error: Error): Error => {
94
+ if (error instanceof SyntaxError || isResilientError(error)) {
95
+ return error
96
+ }
97
+ if (error.message.includes(SEPARATOR)) {
98
+ const ensured = ResilientError.ensure(error)
99
+ // The catch-all converter's legacy constructor shape shifts an ordinary error's fields.
100
+ // Only keep `ensure`'s result when a registered wire prefix actually rebuilt something.
101
+ if (ensured.type !== error.message || ensured.message !== error.stack) {
102
+ return ensured
103
+ }
104
+ }
105
+ return error
157
106
  }
158
- const retryAfter = (error as { retryAfter?: unknown }).retryAfter
159
- if (typeof retryAfter === 'number' && Number.isFinite(retryAfter) && retryAfter > 0) {
160
- reply.header('Retry-After', String(Math.ceil(retryAfter)))
107
+
108
+ const serializeError = (
109
+ error: Error, exposure: HttpErrorExposure = 'production'
110
+ ): SerializedHttpError => {
111
+ const normalized = normalizeForWire(error)
112
+ const thrown = errorStatus(error)
113
+ const status = thrown !== SERVER_ERROR ? thrown : errorStatus(normalized)
114
+ const incidentId = randomUUID()
115
+ // The server log owns every diagnostic. Production callers receive only its correlation key;
116
+ // development keeps the typed wire form so local clients can retain their usual error details.
117
+ Object.assign(error, { incidentId })
118
+ if (isResilientError(normalized)) normalized.incidentId = incidentId
119
+ const body = exposure === 'production'
120
+ ? incidentId
121
+ : ResilientError.marshal(normalized, { includeStack: true, incidentId }).message
122
+
123
+ return { status, body, incidentId }
161
124
  }
162
- // Match exact registered types. EntitlementRefusal also extends AuthForbidden but is a plan
163
- // decision, not a person's IAM permission denial.
164
- const type = (error as { type?: unknown }).type
165
- if (type === AuthForbidden.typeName || type === AccessError.typeName) {
166
- reply.header(DENIAL_KIND_HEADER, ACCESS_DENIED_KIND)
125
+
126
+ const applyErrorHeaders = (error: unknown, reply: FastifyReply): void => {
127
+ if (error == null || typeof error !== 'object') {
128
+ return
129
+ }
130
+ const retryAfter = (error as { retryAfter?: unknown }).retryAfter
131
+ if (typeof retryAfter === 'number' && Number.isFinite(retryAfter) && retryAfter > 0) {
132
+ reply.header('Retry-After', String(Math.ceil(retryAfter)))
133
+ }
134
+ // Match exact registered types. EntitlementRefusal also extends AuthForbidden but is a plan
135
+ // decision, not a person's IAM permission denial.
136
+ const type = (error as { type?: unknown }).type
137
+ if (type === AuthForbidden.typeName || type === AccessError.typeName) {
138
+ reply.header(DENIAL_KIND_HEADER, ACCESS_DENIED_KIND)
139
+ }
167
140
  }
168
- }
169
141
 
170
- const http = logger('http')
171
-
172
- /**
173
- * The one log entry of a failed request, by what the failure means:
174
- *
175
- * | Status | Level | Why |
176
- * |---|---|---|
177
- * | 5xx | error | a fault — the one entry that owns type, message and stack, keyed by the incident id |
178
- * | 403 | warn, `access.forbidden` | an established identity was refused; worth an operator's eye |
179
- * | 401 | debug, `auth.refused` | an absent or expired credential is the ordinary shape of a sign-in |
180
- * | other 4xx | debug | the caller's mistake, not the server's |
181
- *
182
- * The method and path are logged; the query string is not — it can carry a token.
183
- */
184
- const logFailure = (error: Error, reply: FastifyReply, status: number, incidentId: string): void => {
185
- const request = reply.request
186
- const where = { method: request?.method, path: request?.url?.split('?')[0], status, incidentId }
187
- if (status >= SERVER_ERROR) {
188
- http.error('Request failed', { err: error, ...where })
189
- } else if (status === FORBIDDEN_ERROR) {
190
- http.warn('Access forbidden', { type: (error as { type?: unknown }).type, message: error.message, ...where },
191
- { event: 'access.forbidden' })
192
- } else if (status === UNAUTHORIZED_ERROR) {
193
- http.debug('Authentication refused', { message: error.message, ...where }, { event: 'auth.refused' })
194
- } else {
195
- http.debug('Request refused', { message: error.message, ...where })
142
+ const http = logger('http')
143
+
144
+ /**
145
+ * The one log entry of a failed request, by what the failure means:
146
+ *
147
+ * | Status | Level | Why |
148
+ * |---|---|---|
149
+ * | 5xx | error | a fault — the one entry that owns type, message and stack, keyed by the incident id |
150
+ * | 403 | warn, `access.forbidden` | an established identity was refused; worth an operator's eye |
151
+ * | 401 | debug, `auth.refused` | an absent or expired credential is the ordinary shape of a sign-in |
152
+ * | other 4xx | debug | the caller's mistake, not the server's |
153
+ *
154
+ * The method and path are logged; the query string is not — it can carry a token.
155
+ */
156
+ const logFailure = (error: Error, reply: FastifyReply, status: number, incidentId: string): void => {
157
+ const request = reply.request
158
+ const where = { method: request?.method, path: request?.url?.split('?')[0], status, incidentId }
159
+ if (status >= SERVER_ERROR) {
160
+ http.error('Request failed', { err: error, ...where })
161
+ } else if (status === FORBIDDEN_ERROR) {
162
+ http.warn('Access forbidden', { type: (error as { type?: unknown }).type, message: error.message, ...where },
163
+ { event: 'access.forbidden' })
164
+ } else if (status === UNAUTHORIZED_ERROR) {
165
+ http.debug('Authentication refused', { message: error.message, ...where }, { event: 'auth.refused' })
166
+ } else {
167
+ http.debug('Request refused', { message: error.message, ...where })
168
+ }
196
169
  }
197
- }
198
170
 
199
- /**
200
- * Answer a thrown error: a status, and the marshalled `ResilientError` as the body.
201
- *
202
- * The status is resolved in two steps. The error AS THROWN comes first: it is the one object whose
203
- * class is certainly the class that was raised, while `ResilientError.ensure` rebuilds through the
204
- * registry of whichever `@owlmeans/error` copy this module loaded — under duplicate module copies
205
- * that turns a perfectly recognisable auth refusal into a plain `ResilientError` answering 500.
206
- * Only when the thrown error answers 500 is the ENSURED error asked, because the rebuild is what
207
- * gives a status to a marshalled error that crossed a hop as a plain `Error`.
208
- */
209
- export const handleError = (
210
- error: Error, reply: FastifyReply, exposure: HttpErrorExposure = 'production'
211
- ) => {
212
- if (!reply.sent) {
213
- const serialized = serializeError(error, exposure)
214
- applyErrorHeaders(error, reply)
215
- logFailure(error, reply, serialized.status, serialized.incidentId)
216
- reply.header(INCIDENT_ID_HEADER, serialized.incidentId)
217
- .code(serialized.status)
218
- .send(serialized.body)
171
+ const handleError = (
172
+ error: Error, reply: FastifyReply, exposure: HttpErrorExposure = 'production'
173
+ ): void => {
174
+ if (!reply.sent) {
175
+ const serialized = serializeError(error, exposure)
176
+ applyErrorHeaders(error, reply)
177
+ logFailure(error, reply, serialized.status, serialized.incidentId)
178
+ reply.header(INCIDENT_ID_HEADER, serialized.incidentId)
179
+ .code(serialized.status)
180
+ .send(serialized.body)
181
+ }
219
182
  }
183
+
184
+ return { declaredErrorStatus, errorStatus, errorExposure, serializeError, applyErrorHeaders, handleError }
220
185
  }
186
+
187
+ export const httpErrorHelper = createHttpErrorHelper()
188
+
189
+ /** @deprecated compat:factory-refactor — use `httpErrorHelper.errorStatus(…)` */
190
+ export const errorStatus = (error: unknown): number => httpErrorHelper.errorStatus(error)
@@ -1,11 +1,11 @@
1
1
  import type { FastifyReply, FastifyRequest } from 'fastify'
2
2
  import type { ServerEntrypoint } from '@owlmeans/server-entrypoint'
3
3
  import { type GuardService, provideResponse } from '@owlmeans/entrypoint'
4
- import { executeResponse, provideRequest } from './payload.js'
4
+ import { payloadHelper } from './payload.js'
5
5
  import { AuthFailedError } from '../errors.js'
6
6
  import type { Auth } from '@owlmeans/auth'
7
- import { attachEntity } from '@owlmeans/auth-common'
8
7
  import type { Config, Context } from './types.local.js'
8
+ import { makeEntityScope } from '@owlmeans/auth-common'
9
9
 
10
10
 
11
11
  export const authorize = async <C extends Config, T extends Context<C>>(
@@ -15,7 +15,7 @@ export const authorize = async <C extends Config, T extends Context<C>>(
15
15
  const guards = module.getGuards()
16
16
  if (guards.length > 0) {
17
17
  const response = provideResponse(reply)
18
- const request = provideRequest(module.alias, req)
18
+ const request = payloadHelper.provideRequest(module.alias, req)
19
19
 
20
20
  let guard: GuardService | undefined = undefined
21
21
  for (const alias of guards) {
@@ -23,7 +23,7 @@ export const authorize = async <C extends Config, T extends Context<C>>(
23
23
  if (await _guard.match(request, response)) {
24
24
  guard = _guard
25
25
  }
26
- executeResponse(response, reply, true)
26
+ payloadHelper.executeResponse(response, reply, true)
27
27
  if (guard != null) {
28
28
  break
29
29
  }
@@ -37,7 +37,7 @@ export const authorize = async <C extends Config, T extends Context<C>>(
37
37
  if (!await guard.handle<boolean>(request, authResponse)) {
38
38
  throw new AuthFailedError(guard.alias)
39
39
  }
40
- executeResponse(authResponse, reply, true)
40
+ payloadHelper.executeResponse(authResponse, reply, true)
41
41
  // Guard that returns true and does not provide an error is an optional guard
42
42
  // if (authResponse.value == null) {
43
43
  // throw SyntaxError(`Guard that returns true and does not provide an error, should provide authorization`)
@@ -51,7 +51,7 @@ export const authorize = async <C extends Config, T extends Context<C>>(
51
51
  // infrastructure names — keys on the entity's stable id. Resolving here, once, is what keeps a
52
52
  // rename from being a sweep of every handler: the id arrives on the request, and a slug that
53
53
  // has since been retired still resolves to the entity that retired it.
54
- const entity = await attachEntity(context, request)
54
+ const entity = await makeEntityScope(request).attachEntity(context)
55
55
  if (entity != null) {
56
56
  // Carried on the raw request the same way `_auth` is, so the request the handler is given
57
57
  // later — a different object, built after this runs — sees the same resolution.
@@ -2,8 +2,11 @@
2
2
  export * from './error.js'
3
3
  export * from './consts.js'
4
4
  export type * from './types.js'
5
+ export type * from './error/types.js'
6
+ export type * from './payload/types.js'
7
+ export type * from './context/types.js'
5
8
  export * from './server.js'
6
9
  export * from './payload.js'
7
- export * from './server.js'
10
+ export * from './schema.js'
8
11
  export * from './guards.js'
9
12
  export * from './context.js'
@@ -0,0 +1,18 @@
1
+ import type { AbstractRequest, AbstractResponse } from '@owlmeans/entrypoint'
2
+ import type { Request, Response } from '../../types.js'
3
+
4
+ /** Moves an exchange between Fastify and the entrypoint shapes: the request in, the response out. */
5
+ export interface PayloadHelper {
6
+ /** The entrypoint request of a Fastify request; `provision` keeps the original on it. */
7
+ provideRequest: (alias: string, req: Request, provision?: boolean) => AbstractRequest
8
+ /**
9
+ * Emits the response onto the fastify reply if the handler produced an
10
+ * error or an outcome. Returns `true` when something was actually sent so
11
+ * callers don't rely on `reply.sent`, which in fastify v5 only flips once
12
+ * the raw socket finishes writing (asynchronously) and therefore reads
13
+ * `false` synchronously right after `reply.send()`.
14
+ *
15
+ * @throws {Error}
16
+ */
17
+ executeResponse: <T>(response: AbstractResponse<T>, reply: Response, throwOnError?: boolean) => boolean
18
+ }
@@ -2,75 +2,53 @@ import { EntrypointOutcome } from '@owlmeans/entrypoint'
2
2
  import type { AbstractResponse, AbstractRequest } from '@owlmeans/entrypoint'
3
3
  import type { Request, Response } from '../types.js'
4
4
  import { ACCEPTED, CREATED, OK } from '@owlmeans/api'
5
- import type { AnySchemaObject } from 'ajv'
6
- import { handleError } from './error.js'
5
+ import { httpErrorHelper } from './error.js'
6
+ import type { PayloadHelper } from './payload/types.js'
7
7
 
8
- export const provideRequest = (alias: string, req: Request, provision?: boolean): AbstractRequest => {
9
- provision = provision ?? false
10
- return {
11
- alias,
12
- auth: (req as any)._auth ?? undefined,
13
- // Resolved once by `authorize` and carried by reference, exactly like `_auth`, so a handler
14
- // reached through any path sees the same entity the guard admitted.
15
- entity: (req as any)._entity ?? undefined,
16
- params: req.params as Record<string, string | number | undefined | null>,
17
- body: req.body as Record<string, any>,
18
- headers: req.headers,
19
- query: req.query as Record<string, string | number | undefined | null>,
20
- path: req.url,
21
- original: provision ? req : undefined
22
- }
23
- }
24
-
25
- /**
26
- * Emits the response onto the fastify reply if the handler produced an
27
- * error or an outcome. Returns `true` when something was actually sent so
28
- * callers don't rely on `reply.sent`, which in fastify v5 only flips once
29
- * the raw socket finishes writing (asynchronously) and therefore reads
30
- * `false` synchronously right after `reply.send()`.
31
- *
32
- * @throws {Error}
33
- */
34
- export const executeResponse = <T>(response: AbstractResponse<T>, reply: Response, throwOnError?: boolean): boolean => {
35
- if (response.error != null) {
36
- if (throwOnError ?? false) {
37
- throw response.error
8
+ export const createPayloadHelper = (): PayloadHelper => {
9
+ const provideRequest = (alias: string, req: Request, provision?: boolean): AbstractRequest => {
10
+ provision = provision ?? false
11
+ return {
12
+ alias,
13
+ auth: (req as any)._auth ?? undefined,
14
+ // Resolved once by `authorize` and carried by reference, exactly like `_auth`, so a handler
15
+ // reached through any path sees the same entity the guard admitted.
16
+ entity: (req as any)._entity ?? undefined,
17
+ params: req.params as Record<string, string | number | undefined | null>,
18
+ body: req.body as Record<string, any>,
19
+ headers: req.headers,
20
+ query: req.query as Record<string, string | number | undefined | null>,
21
+ path: req.url,
22
+ original: provision ? req : undefined
38
23
  }
39
- handleError(response.error, reply)
40
- return true
41
- } else if (response.outcome != null) {
42
- switch (response.outcome) {
43
- case EntrypointOutcome.Accepted:
44
- reply.code(ACCEPTED).send(response.value)
45
- break
46
- case EntrypointOutcome.Created:
47
- reply.code(CREATED).send(response.value)
48
- break
49
- case EntrypointOutcome.Ok:
50
- default:
51
- reply.code(OK).send(response.value)
52
- }
53
- return true
54
24
  }
55
25
 
56
- return false
57
- }
58
-
59
-
60
- export const fixFormatDates = (schema: AnySchemaObject) => {
61
- if (schema.type === 'object') {
62
- if (schema.format === 'date-time') {
63
- schema.type = 'string'
64
- schema.format = 'date-time'
65
- if (schema.required != null) {
66
- delete schema.required
26
+ const executeResponse = <T>(response: AbstractResponse<T>, reply: Response, throwOnError?: boolean): boolean => {
27
+ if (response.error != null) {
28
+ if (throwOnError ?? false) {
29
+ throw response.error
30
+ }
31
+ httpErrorHelper.handleError(response.error, reply)
32
+ return true
33
+ } else if (response.outcome != null) {
34
+ switch (response.outcome) {
35
+ case EntrypointOutcome.Accepted:
36
+ reply.code(ACCEPTED).send(response.value)
37
+ break
38
+ case EntrypointOutcome.Created:
39
+ reply.code(CREATED).send(response.value)
40
+ break
41
+ case EntrypointOutcome.Ok:
42
+ default:
43
+ reply.code(OK).send(response.value)
67
44
  }
68
- } else if (schema.properties != null) {
69
- schema.properties = Object.fromEntries(
70
- Object.entries(schema.properties).map(([key, value]) => [key, fixFormatDates(value as AnySchemaObject)])
71
- )
45
+ return true
72
46
  }
47
+
48
+ return false
73
49
  }
74
50
 
75
- return schema
51
+ return { provideRequest, executeResponse }
76
52
  }
53
+
54
+ export const payloadHelper = createPayloadHelper()
@@ -0,0 +1,23 @@
1
+ import type { AnySchemaObject } from 'ajv'
2
+
3
+ /**
4
+ * A request schema Fastify can serve: a `date-time` object becomes a `date-time` string (and loses
5
+ * its `required` list), recursively through object properties. Mutates and returns `schema`.
6
+ */
7
+ export const fixFormatDates = (schema: AnySchemaObject): AnySchemaObject => {
8
+ if (schema.type === 'object') {
9
+ if (schema.format === 'date-time') {
10
+ schema.type = 'string'
11
+ schema.format = 'date-time'
12
+ if (schema.required != null) {
13
+ delete schema.required
14
+ }
15
+ } else if (schema.properties != null) {
16
+ schema.properties = Object.fromEntries(
17
+ Object.entries(schema.properties).map(([key, value]) => [key, fixFormatDates(value as AnySchemaObject)])
18
+ )
19
+ }
20
+ }
21
+
22
+ return schema
23
+ }
@@ -5,8 +5,8 @@ import { type CommonEntrypoint, type GateService, provideResponse } from '@owlme
5
5
  import { ResilientError } from '@owlmeans/error'
6
6
  import { OK } from '@owlmeans/api'
7
7
  import { logger } from '@owlmeans/log'
8
- import { errorExposure, handleError } from './error.js'
9
- import { executeResponse, provideRequest } from './payload.js'
8
+ import { httpErrorHelper } from './error.js'
9
+ import { payloadHelper } from './payload.js'
10
10
  import { authorize } from './guards.js'
11
11
  import { RouteProtocols } from '@owlmeans/route'
12
12
  import type { ServerConfig2, ServerContext2 } from './types.local.js'
@@ -38,13 +38,13 @@ export const createServerHandler = (module: ServerEntrypoint<FastifyRequest>, lo
38
38
  module = authorized[1]
39
39
 
40
40
  const response = provideResponse(reply)
41
- const request = provideRequest(module.alias, req, true)
41
+ const request = payloadHelper.provideRequest(module.alias, req, true)
42
42
 
43
43
  const gates = module.getGates()
44
44
  for (const [srv, params] of gates) {
45
45
  const gate: GateService = context.service(srv)
46
46
  await gate.assert(request, response, params)
47
- executeResponse(response, reply, true)
47
+ payloadHelper.executeResponse(response, reply, true)
48
48
  }
49
49
 
50
50
  await module.handle(request, response)
@@ -55,7 +55,7 @@ export const createServerHandler = (module: ServerEntrypoint<FastifyRequest>, lo
55
55
  // synchronously right after `reply.send()`. Track the emitted state
56
56
  // explicitly instead, and only fall back to a default response when
57
57
  // neither `executeResponse` nor the handler itself (hijack) replied.
58
- const responded = executeResponse(response, reply, true)
58
+ const responded = payloadHelper.executeResponse(response, reply, true)
59
59
  if (!responded && !reply.sent) {
60
60
  logger('http').warn(`SENDS DEFAULT RESPONSE: ${module.alias}`)
61
61
  reply.code(OK).send(response.value)
@@ -66,6 +66,6 @@ export const createServerHandler = (module: ServerEntrypoint<FastifyRequest>, lo
66
66
  fixer.handle(reply, ResilientError.ensure(error as Error))
67
67
  return
68
68
  }
69
- handleError(error as Error, reply, errorExposure(context.cfg))
69
+ httpErrorHelper.handleError(error as Error, reply, httpErrorHelper.errorExposure(context.cfg))
70
70
  }
71
71
  }