@owlmeans/server-api 0.1.18-rc.5 → 0.1.18-rc.51

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 (129) hide show
  1. package/README.md +203 -46
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/server-api/SKILL.md +128 -22
  4. package/build/consts.local.d.ts +2 -0
  5. package/build/consts.local.d.ts.map +1 -0
  6. package/build/consts.local.js +2 -0
  7. package/build/consts.local.js.map +1 -0
  8. package/build/errors.d.ts +12 -0
  9. package/build/errors.d.ts.map +1 -1
  10. package/build/errors.js +16 -0
  11. package/build/errors.js.map +1 -1
  12. package/build/helper.d.ts +3 -6
  13. package/build/helper.d.ts.map +1 -1
  14. package/build/helper.js +10 -1
  15. package/build/helper.js.map +1 -1
  16. package/build/hold.d.ts +35 -0
  17. package/build/hold.d.ts.map +1 -0
  18. package/build/hold.js +57 -0
  19. package/build/hold.js.map +1 -0
  20. package/build/index.d.ts +2 -0
  21. package/build/index.d.ts.map +1 -1
  22. package/build/index.js +2 -0
  23. package/build/index.js.map +1 -1
  24. package/build/protocol.d.ts +18 -0
  25. package/build/protocol.d.ts.map +1 -0
  26. package/build/protocol.js +87 -0
  27. package/build/protocol.js.map +1 -0
  28. package/build/server.d.ts +2 -3
  29. package/build/server.d.ts.map +1 -1
  30. package/build/server.js +39 -34
  31. package/build/server.js.map +1 -1
  32. package/build/types.d.ts +31 -2
  33. package/build/types.d.ts.map +1 -1
  34. package/build/types.local.d.ts +38 -0
  35. package/build/types.local.d.ts.map +1 -0
  36. package/build/types.local.js +2 -0
  37. package/build/types.local.js.map +1 -0
  38. package/build/utils/consts.d.ts +2 -0
  39. package/build/utils/consts.d.ts.map +1 -0
  40. package/build/utils/consts.js +2 -0
  41. package/build/utils/consts.js.map +1 -0
  42. package/build/utils/consts.local.d.ts +5 -0
  43. package/build/utils/consts.local.d.ts.map +1 -0
  44. package/build/utils/consts.local.js +5 -0
  45. package/build/utils/consts.local.js.map +1 -0
  46. package/build/utils/context/types.d.ts +13 -0
  47. package/build/utils/context/types.d.ts.map +1 -0
  48. package/build/utils/context/types.js +2 -0
  49. package/build/utils/context/types.js.map +1 -0
  50. package/build/utils/context.d.ts +3 -3
  51. package/build/utils/context.d.ts.map +1 -1
  52. package/build/utils/context.js +5 -2
  53. package/build/utils/context.js.map +1 -1
  54. package/build/utils/error/types.d.ts +52 -0
  55. package/build/utils/error/types.d.ts.map +1 -0
  56. package/build/utils/error/types.js +2 -0
  57. package/build/utils/error/types.js.map +1 -0
  58. package/build/utils/error.d.ts +5 -2
  59. package/build/utils/error.d.ts.map +1 -1
  60. package/build/utils/error.js +163 -13
  61. package/build/utils/error.js.map +1 -1
  62. package/build/utils/guards.d.ts +1 -5
  63. package/build/utils/guards.d.ts.map +1 -1
  64. package/build/utils/guards.js +15 -19
  65. package/build/utils/guards.js.map +1 -1
  66. package/build/utils/index.d.ts +6 -1
  67. package/build/utils/index.d.ts.map +1 -1
  68. package/build/utils/index.js +2 -1
  69. package/build/utils/index.js.map +1 -1
  70. package/build/utils/log.d.ts +11 -0
  71. package/build/utils/log.d.ts.map +1 -0
  72. package/build/utils/log.js +57 -0
  73. package/build/utils/log.js.map +1 -0
  74. package/build/utils/payload/types.d.ts +18 -0
  75. package/build/utils/payload/types.d.ts.map +1 -0
  76. package/build/utils/payload/types.js +2 -0
  77. package/build/utils/payload/types.js.map +1 -0
  78. package/build/utils/payload.d.ts +3 -15
  79. package/build/utils/payload.d.ts.map +1 -1
  80. package/build/utils/payload.js +41 -57
  81. package/build/utils/payload.js.map +1 -1
  82. package/build/utils/schema.d.ts +7 -0
  83. package/build/utils/schema.d.ts.map +1 -0
  84. package/build/utils/schema.js +20 -0
  85. package/build/utils/schema.js.map +1 -0
  86. package/build/utils/server.d.ts +3 -6
  87. package/build/utils/server.d.ts.map +1 -1
  88. package/build/utils/server.js +12 -13
  89. package/build/utils/server.js.map +1 -1
  90. package/build/utils/types.d.ts +18 -0
  91. package/build/utils/types.d.ts.map +1 -0
  92. package/build/utils/types.js +2 -0
  93. package/build/utils/types.js.map +1 -0
  94. package/build/utils/types.local.d.ts +9 -0
  95. package/build/utils/types.local.d.ts.map +1 -0
  96. package/build/utils/types.local.js +2 -0
  97. package/build/utils/types.local.js.map +1 -0
  98. package/package.json +22 -18
  99. package/src/consts.local.ts +1 -0
  100. package/src/errors.ts +18 -0
  101. package/src/helper.ts +13 -8
  102. package/src/hold.ts +66 -0
  103. package/src/index.ts +2 -0
  104. package/src/protocol.ts +149 -0
  105. package/src/server.ts +69 -69
  106. package/src/types.local.ts +44 -0
  107. package/src/types.ts +38 -4
  108. package/src/utils/consts.local.ts +7 -0
  109. package/src/utils/consts.ts +1 -0
  110. package/src/utils/context/types.ts +13 -0
  111. package/src/utils/context.ts +9 -4
  112. package/src/utils/error/types.ts +52 -0
  113. package/src/utils/error.ts +184 -12
  114. package/src/utils/guards.ts +16 -26
  115. package/src/utils/index.ts +6 -1
  116. package/src/utils/log.ts +62 -0
  117. package/src/utils/payload/types.ts +18 -0
  118. package/src/utils/payload.ts +42 -60
  119. package/src/utils/schema.ts +23 -0
  120. package/src/utils/server.ts +16 -21
  121. package/src/utils/types.local.ts +12 -0
  122. package/src/utils/types.ts +18 -0
  123. package/tests/error-status.spec.ts +349 -0
  124. package/tests/handlers.spec.ts +83 -0
  125. package/tests/json-body.spec.ts +72 -0
  126. package/tests/log-adapter.spec.ts +52 -0
  127. package/tests/tsconfig.json +12 -0
  128. package/tsconfig.json +1 -0
  129. package/build/.gitkeep +0 -0
@@ -1,18 +1,190 @@
1
- import { FORBIDDEN_ERROR, SERVER_ERROR, UNAUTHORIZED_ERROR } from '@owlmeans/api'
1
+ import {
2
+ FORBIDDEN_ERROR, SERVER_ERROR, UNAUTHORIZED_ERROR, DENIAL_KIND_HEADER, ACCESS_DENIED_KIND,
3
+ } from '@owlmeans/api'
2
4
  import type { FastifyReply } from 'fastify'
3
5
  import { AccessError, AuthFailedError } from '../errors.js'
4
- import { ResilientError } from '@owlmeans/error'
6
+ import { AuthForbidden, AuthorizationError } from '@owlmeans/auth'
7
+ import { isResilientError, ResilientError, SEPARATOR } from '@owlmeans/error'
8
+ import { randomUUID } from 'node:crypto'
9
+ import { logger } from '@owlmeans/log'
10
+ import type { Config, HttpErrorExposure } from '../types.js'
11
+ import { CLIENT_ERROR_FIRST, CLIENT_ERROR_LAST, SERVER_ERROR_FIRST, SERVER_ERROR_LAST } from './consts.local.js'
12
+ import { INCIDENT_ID_HEADER } from './consts.js'
13
+ import type { HttpStatusDeclaration, SerializedHttpError } from './types.js'
14
+ import type { HttpErrorHelper } from './error/types.js'
5
15
 
6
- export const handleError = (error: Error, reply: FastifyReply) => {
7
- let code: number = SERVER_ERROR
8
- if (error instanceof AuthFailedError) {
9
- code = UNAUTHORIZED_ERROR
10
- } else if (error instanceof AccessError) {
11
- code = FORBIDDEN_ERROR
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
29
+
30
+ return null
31
+ }
32
+
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)
57
+ }
58
+
59
+ return names
60
+ }
61
+
62
+ const isOfFamily = (names: Set<string>, family: string[]): boolean =>
63
+ family.some(typeName => names.has(typeName))
64
+
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
80
+ }
81
+
82
+ return declaredErrorStatus(error) ?? SERVER_ERROR
12
83
  }
13
- if (!reply.sent) {
14
- reply.code(code).send(
15
- ResilientError.marshal(ResilientError.ensure(error as Error)).message
16
- )
84
+
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
17
106
  }
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 }
124
+ }
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
+ }
140
+ }
141
+
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
+ }
169
+ }
170
+
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
+ }
182
+ }
183
+
184
+ return { declaredErrorStatus, errorStatus, errorExposure, serializeError, applyErrorHeaders, handleError }
18
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,15 +1,12 @@
1
- import { isContextWithoutIds, Layer } from '@owlmeans/context'
2
1
  import type { FastifyReply, FastifyRequest } from 'fastify'
3
2
  import type { ServerEntrypoint } from '@owlmeans/server-entrypoint'
4
- import type { GuardService } from '@owlmeans/entrypoint'
5
- import { provideResponse } from '@owlmeans/entrypoint'
6
- import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
7
- import { executeResponse, provideRequest } from './payload.js'
3
+ import { type GuardService, provideResponse } from '@owlmeans/entrypoint'
4
+ import { payloadHelper } from './payload.js'
8
5
  import { AuthFailedError } from '../errors.js'
9
6
  import type { Auth } from '@owlmeans/auth'
7
+ import type { Config, Context } from './types.local.js'
8
+ import { makeEntityScope } from '@owlmeans/auth-common'
10
9
 
11
- type Config = ServerConfig
12
- interface Context<C extends Config = Config> extends ServerContext<C> { }
13
10
 
14
11
  export const authorize = async <C extends Config, T extends Context<C>>(
15
12
  context: T, module: ServerEntrypoint<FastifyRequest>,
@@ -18,7 +15,7 @@ export const authorize = async <C extends Config, T extends Context<C>>(
18
15
  const guards = module.getGuards()
19
16
  if (guards.length > 0) {
20
17
  const response = provideResponse(reply)
21
- const request = provideRequest(module.alias, req)
18
+ const request = payloadHelper.provideRequest(module.alias, req)
22
19
 
23
20
  let guard: GuardService | undefined = undefined
24
21
  for (const alias of guards) {
@@ -26,7 +23,7 @@ export const authorize = async <C extends Config, T extends Context<C>>(
26
23
  if (await _guard.match(request, response)) {
27
24
  guard = _guard
28
25
  }
29
- executeResponse(response, reply, true)
26
+ payloadHelper.executeResponse(response, reply, true)
30
27
  if (guard != null) {
31
28
  break
32
29
  }
@@ -40,7 +37,7 @@ export const authorize = async <C extends Config, T extends Context<C>>(
40
37
  if (!await guard.handle<boolean>(request, authResponse)) {
41
38
  throw new AuthFailedError(guard.alias)
42
39
  }
43
- executeResponse(authResponse, reply, true)
40
+ payloadHelper.executeResponse(authResponse, reply, true)
44
41
  // Guard that returns true and does not provide an error is an optional guard
45
42
  // if (authResponse.value == null) {
46
43
  // throw SyntaxError(`Guard that returns true and does not provide an error, should provide authorization`)
@@ -50,22 +47,15 @@ export const authorize = async <C extends Config, T extends Context<C>>(
50
47
  (req as any)._auth = request.auth
51
48
  }
52
49
 
53
- if (request.auth?.entityId != null) {
54
- // @TODO Probably we need to downgrade context in this case
55
- if (!isContextWithoutIds(context as any)) {
56
- throw SyntaxError(`Context should be without ids during authorization ${context.cfg.layer}:${context.cfg.layerId}`)
57
- }
58
-
59
- if (isContextWithoutIds(context as any) && context.cfg.layer !== Layer.Service) {
60
- context = await context.updateContext(undefined, Layer.Service)
61
- await context.waitForInitialized()
62
- }
63
- context = await context.updateContext(request.auth.entityId, Layer.Entity)
64
- await context.waitForInitialized()
65
-
66
- // We elevate module to the context level if it was changed
67
- module = context.entrypoint(module.alias)
68
- await module.resolve()
50
+ // The token names the organization by slug; everything downstream — grants, records, minted
51
+ // infrastructure names — keys on the entity's stable id. Resolving here, once, is what keeps a
52
+ // rename from being a sweep of every handler: the id arrives on the request, and a slug that
53
+ // has since been retired still resolves to the entity that retired it.
54
+ const entity = await makeEntityScope(request).attachEntity(context)
55
+ if (entity != null) {
56
+ // Carried on the raw request the same way `_auth` is, so the request the handler is given
57
+ // later — a different object, built after this runs — sees the same resolution.
58
+ ;(req as any)._entity = entity
69
59
  }
70
60
  }
71
61
  // Update context in request object
@@ -1,7 +1,12 @@
1
1
 
2
2
  export * from './error.js'
3
+ export * from './consts.js'
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'
3
8
  export * from './server.js'
4
9
  export * from './payload.js'
5
- export * from './server.js'
10
+ export * from './schema.js'
6
11
  export * from './guards.js'
7
12
  export * from './context.js'
@@ -0,0 +1,62 @@
1
+ import type { FastifyBaseLogger } from 'fastify'
2
+ import { logger, type LogLevel, type Logger } from '@owlmeans/log'
3
+ import type { Severity } from './types.local.js'
4
+
5
+ /**
6
+ * What Fastify says about itself, mapped to what an operator needs at each level.
7
+ *
8
+ * Fastify writes a pair of `info` lines for EVERY request and one for each unknown route, and
9
+ * announces its own listening address. The server writes one `debug` record per request itself
10
+ * (`onResponse`), a failed request is classified once in `handleError`, and the listening line is
11
+ * `server.listening` — so these are dropped or lowered here, by their fixed wording, rather than by
12
+ * a Fastify option (`disableRequestLogging` is deprecated from 5.12, `logController` does not exist
13
+ * before it).
14
+ */
15
+ const levelOf = (level: Severity, message: unknown): Severity | undefined => {
16
+ if (typeof message !== 'string' || level !== 'info') {
17
+ return level
18
+ }
19
+ if (message === 'incoming request' || message === 'request completed') {
20
+ return undefined
21
+ }
22
+ if (message.startsWith('Server listening at') || /^Route .* not found$/.test(message)) {
23
+ return 'debug'
24
+ }
25
+ return level
26
+ }
27
+
28
+ /**
29
+ * A pino-shaped logger over `@owlmeans/log`, handed to Fastify as its `loggerInstance`.
30
+ *
31
+ * Fastify then logs through the process's own level, format and plugins instead of a second pino
32
+ * instance that ignores them. `(object, message)` and `(message)` are the two shapes Fastify and
33
+ * its plugins call with; an `err` in the object travels as the record's error.
34
+ */
35
+ export const fastifyLogger = (log: Logger = logger('http'), bindings?: Record<string, unknown>): FastifyBaseLogger => {
36
+ const call = (requested: Severity) => (first: unknown, second?: unknown): void => {
37
+ const [object, message] = typeof first === 'string' || first instanceof Error
38
+ ? [undefined, first] : [first, second]
39
+ const level = levelOf(requested, message)
40
+ if (level == null) {
41
+ return
42
+ }
43
+ const data = bindings != null && Object.keys(bindings).length > 0
44
+ ? { ...bindings, ...(object != null && typeof object === 'object' ? object : {}) } : object
45
+ log[level](message == null ? '' : message instanceof Error ? message : String(message), data)
46
+ }
47
+
48
+ const adapter = {
49
+ get level(): string { return levelName(log) },
50
+ set level(_value: string) { /* the level belongs to the process, not to this server */ },
51
+ fatal: call('error'), error: call('error'), warn: call('warn'),
52
+ info: call('info'), debug: call('debug'), trace: call('debug'),
53
+ silent: () => undefined,
54
+ child: (extra: Record<string, unknown>) => fastifyLogger(log, { ...bindings, ...extra }),
55
+ }
56
+ return adapter as unknown as FastifyBaseLogger
57
+ }
58
+
59
+ const levelName = (log: Logger): string => {
60
+ const order: LogLevel[] = ['debug', 'info', 'warn', 'error']
61
+ return order.find(level => log.enabled(level)) ?? 'silent'
62
+ }
@@ -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
+ }
@@ -1,72 +1,54 @@
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'
5
- import type { AnySchemaObject } from 'ajv'
4
+ import { ACCEPTED, CREATED, OK } from '@owlmeans/api'
5
+ import { httpErrorHelper } from './error.js'
6
+ import type { PayloadHelper } from './payload/types.js'
6
7
 
7
- export const provideRequest = (alias: string, req: Request, provision?: boolean): AbstractRequest => {
8
- provision = provision ?? false
9
- return {
10
- alias,
11
- auth: (req as any)._auth ?? undefined,
12
- params: req.params as Record<string, string | number | undefined | null>,
13
- body: req.body as Record<string, any>,
14
- headers: req.headers,
15
- query: req.query as Record<string, string | number | undefined | null>,
16
- path: req.url,
17
- original: provision ? req : undefined
18
- }
19
- }
20
-
21
- /**
22
- * Emits the response onto the fastify reply if the handler produced an
23
- * error or an outcome. Returns `true` when something was actually sent so
24
- * callers don't rely on `reply.sent`, which in fastify v5 only flips once
25
- * the raw socket finishes writing (asynchronously) and therefore reads
26
- * `false` synchronously right after `reply.send()`.
27
- *
28
- * @throws {Error}
29
- */
30
- export const executeResponse = <T>(response: AbstractResponse<T>, reply: Response, throwOnError?: boolean): boolean => {
31
- if (response.error != null) {
32
- if (throwOnError ?? false) {
33
- 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
34
23
  }
35
- reply.code(SERVER_ERROR).send(response.error.message)
36
- return true
37
- } else if (response.outcome != null) {
38
- switch (response.outcome) {
39
- case EntrypointOutcome.Accepted:
40
- reply.code(ACCEPTED).send(response.value)
41
- break
42
- case EntrypointOutcome.Created:
43
- reply.code(CREATED).send(response.value)
44
- break
45
- case EntrypointOutcome.Ok:
46
- default:
47
- reply.code(OK).send(response.value)
48
- }
49
- return true
50
24
  }
51
25
 
52
- return false
53
- }
54
-
55
-
56
- export const fixFormatDates = (schema: AnySchemaObject) => {
57
- if (schema.type === 'object') {
58
- if (schema.format === 'date-time') {
59
- schema.type = 'string'
60
- schema.format = 'date-time'
61
- if (schema.required != null) {
62
- 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)
63
44
  }
64
- } else if (schema.properties != null) {
65
- schema.properties = Object.fromEntries(
66
- Object.entries(schema.properties).map(([key, value]) => [key, fixFormatDates(value as AnySchemaObject)])
67
- )
45
+ return true
68
46
  }
47
+
48
+ return false
69
49
  }
70
50
 
71
- return schema
51
+ return { provideRequest, executeResponse }
72
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
+ }
@@ -1,29 +1,27 @@
1
- import { AppType } from '@owlmeans/context'
1
+ import { AppType, assertContext } from '@owlmeans/context'
2
2
  import type { FastifyReply, FastifyRequest } from 'fastify'
3
- import { assertContext } from '@owlmeans/context'
4
3
  import type { FixerService, ServerEntrypoint } from '@owlmeans/server-entrypoint'
5
- import type { CommonEntrypoint } from '@owlmeans/entrypoint'
6
- import type { GateService } from '@owlmeans/entrypoint'
7
- import { provideResponse } from '@owlmeans/entrypoint'
8
- import type { ServerContext, ServerConfig } from '@owlmeans/server-context'
4
+ import { type CommonEntrypoint, type GateService, provideResponse } from '@owlmeans/entrypoint'
9
5
  import { ResilientError } from '@owlmeans/error'
10
6
  import { OK } from '@owlmeans/api'
11
- import { handleError } from './error.js'
12
- import { executeResponse, provideRequest } from './payload.js'
7
+ import { logger } from '@owlmeans/log'
8
+ import { httpErrorHelper } from './error.js'
9
+ import { payloadHelper } from './payload.js'
13
10
  import { authorize } from './guards.js'
14
11
  import { RouteProtocols } from '@owlmeans/route'
12
+ import type { ServerConfig2, ServerContext2 } from './types.local.js'
15
13
 
16
- type Config = ServerConfig
17
- type Context = ServerContext<Config>
18
14
 
19
- export const canServeModule = (context: Context, module: CommonEntrypoint): module is ServerEntrypoint<unknown> => {
15
+ export const canServeModule = (context: ServerContext2, module: CommonEntrypoint): module is ServerEntrypoint<unknown> => {
20
16
  if (module.route.route.type !== AppType.Backend) {
21
17
  return false
22
18
  }
23
19
  if (module.route.route.service != null && module.route.route.service !== context.cfg.service) {
24
20
  return false
25
21
  }
26
- if (module.route.route.protocol === RouteProtocols.SOCKET) {
22
+ // HTTP serves only its own protocol. Custom transports are handled by their owning packages.
23
+ if (module.route.route.protocol != null
24
+ && module.route.route.protocol !== RouteProtocols.WEB) {
27
25
  return false
28
26
  }
29
27
 
@@ -33,20 +31,20 @@ export const canServeModule = (context: Context, module: CommonEntrypoint): modu
33
31
  export const createServerHandler = (module: ServerEntrypoint<FastifyRequest>, location: string) =>
34
32
  async (req: FastifyRequest, reply: FastifyReply) => {
35
33
  // We passed context using fastify request object
36
- let context = assertContext<Config, Context>((req as any)._ctx, location)
34
+ let context = assertContext<ServerConfig2, ServerContext2>((req as any)._ctx, location)
37
35
  try {
38
36
  const authorized = await authorize(context, module, req, reply)
39
37
  context = authorized[0]
40
38
  module = authorized[1]
41
39
 
42
40
  const response = provideResponse(reply)
43
- const request = provideRequest(module.alias, req, true)
41
+ const request = payloadHelper.provideRequest(module.alias, req, true)
44
42
 
45
43
  const gates = module.getGates()
46
44
  for (const [srv, params] of gates) {
47
45
  const gate: GateService = context.service(srv)
48
46
  await gate.assert(request, response, params)
49
- executeResponse(response, reply, true)
47
+ payloadHelper.executeResponse(response, reply, true)
50
48
  }
51
49
 
52
50
  await module.handle(request, response)
@@ -57,20 +55,17 @@ export const createServerHandler = (module: ServerEntrypoint<FastifyRequest>, lo
57
55
  // synchronously right after `reply.send()`. Track the emitted state
58
56
  // explicitly instead, and only fall back to a default response when
59
57
  // neither `executeResponse` nor the handler itself (hijack) replied.
60
- const responded = executeResponse(response, reply, true)
58
+ const responded = payloadHelper.executeResponse(response, reply, true)
61
59
  if (!responded && !reply.sent) {
62
- console.warn(`SENDS DEFAULT RESPONSE: ${module.alias}`)
60
+ logger('http').warn(`SENDS DEFAULT RESPONSE: ${module.alias}`)
63
61
  reply.code(OK).send(response.value)
64
62
  }
65
63
  } catch (error) {
66
- console.error(`Error in ${module.alias} (${location})`)
67
- console.error(JSON.stringify(error, null, 2))
68
- console.error(error)
69
64
  if (module.fixer != null) {
70
65
  const fixer: FixerService = context.service(module.fixer)
71
66
  fixer.handle(reply, ResilientError.ensure(error as Error))
72
67
  return
73
68
  }
74
- handleError(error as Error, reply)
69
+ httpErrorHelper.handleError(error as Error, reply, httpErrorHelper.errorExposure(context.cfg))
75
70
  }
76
71
  }
@@ -0,0 +1,12 @@
1
+ import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
2
+ import type { Config as ApiConfig } from '../types.js'
3
+
4
+ export type Config = ServerConfig
5
+
6
+ export interface Context<C extends Config = Config> extends ServerContext<C> { }
7
+
8
+ export type Severity = 'debug' | 'info' | 'warn' | 'error'
9
+
10
+ export type ServerConfig2 = ApiConfig
11
+
12
+ export type ServerContext2 = ServerContext<ServerConfig2>
@@ -0,0 +1,18 @@
1
+ /**
2
+ * What an error class may declare about the HTTP status it is answered with.
3
+ *
4
+ * Structural on purpose: the classes that declare it live in packages that must not depend on an
5
+ * HTTP server (`@owlmeans/payment`, a product's contract package), so they write a plain
6
+ * `public static httpStatus = 409` and nothing imports this type.
7
+ */
8
+ export interface HttpStatusDeclaration {
9
+ httpStatus?: unknown
10
+ /** Explicit opt-in for a declared 5xx response. Client-error declarations need no opt-in. */
11
+ allowServerErrorStatus?: unknown
12
+ }
13
+
14
+ export interface SerializedHttpError {
15
+ status: number
16
+ body: string
17
+ incidentId: string
18
+ }