@owlmeans/server-api 0.1.18-rc.6 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +203 -46
- package/agent-meta/manifest.json +3 -3
- package/agent-meta/skills/server-api/SKILL.md +142 -22
- package/build/consts.local.d.ts +2 -0
- package/build/consts.local.d.ts.map +1 -0
- package/build/consts.local.js +2 -0
- package/build/consts.local.js.map +1 -0
- package/build/errors.d.ts +12 -0
- package/build/errors.d.ts.map +1 -1
- package/build/errors.js +16 -0
- package/build/errors.js.map +1 -1
- package/build/helper.d.ts +3 -6
- package/build/helper.d.ts.map +1 -1
- package/build/helper.js +10 -1
- package/build/helper.js.map +1 -1
- package/build/hold.d.ts +35 -0
- package/build/hold.d.ts.map +1 -0
- package/build/hold.js +57 -0
- package/build/hold.js.map +1 -0
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -0
- package/build/index.js.map +1 -1
- package/build/protocol.d.ts +18 -0
- package/build/protocol.d.ts.map +1 -0
- package/build/protocol.js +87 -0
- package/build/protocol.js.map +1 -0
- package/build/server.d.ts +2 -3
- package/build/server.d.ts.map +1 -1
- package/build/server.js +42 -34
- package/build/server.js.map +1 -1
- package/build/types.d.ts +31 -2
- package/build/types.d.ts.map +1 -1
- package/build/types.local.d.ts +38 -0
- package/build/types.local.d.ts.map +1 -0
- package/build/types.local.js +2 -0
- package/build/types.local.js.map +1 -0
- package/build/utils/consts.d.ts +2 -0
- package/build/utils/consts.d.ts.map +1 -0
- package/build/utils/consts.js +2 -0
- package/build/utils/consts.js.map +1 -0
- package/build/utils/consts.local.d.ts +5 -0
- package/build/utils/consts.local.d.ts.map +1 -0
- package/build/utils/consts.local.js +5 -0
- package/build/utils/consts.local.js.map +1 -0
- package/build/utils/context/types.d.ts +13 -0
- package/build/utils/context/types.d.ts.map +1 -0
- package/build/utils/context/types.js +2 -0
- package/build/utils/context/types.js.map +1 -0
- package/build/utils/context.d.ts +3 -3
- package/build/utils/context.d.ts.map +1 -1
- package/build/utils/context.js +5 -2
- package/build/utils/context.js.map +1 -1
- package/build/utils/error/types.d.ts +52 -0
- package/build/utils/error/types.d.ts.map +1 -0
- package/build/utils/error/types.js +2 -0
- package/build/utils/error/types.js.map +1 -0
- package/build/utils/error.d.ts +5 -2
- package/build/utils/error.d.ts.map +1 -1
- package/build/utils/error.js +135 -13
- package/build/utils/error.js.map +1 -1
- package/build/utils/guards.d.ts +1 -5
- package/build/utils/guards.d.ts.map +1 -1
- package/build/utils/guards.js +15 -19
- package/build/utils/guards.js.map +1 -1
- package/build/utils/index.d.ts +6 -1
- package/build/utils/index.d.ts.map +1 -1
- package/build/utils/index.js +2 -1
- package/build/utils/index.js.map +1 -1
- package/build/utils/log/consts.d.ts +14 -0
- package/build/utils/log/consts.d.ts.map +1 -0
- package/build/utils/log/consts.js +17 -0
- package/build/utils/log/consts.js.map +1 -0
- package/build/utils/log/types.d.ts +42 -0
- package/build/utils/log/types.d.ts.map +1 -0
- package/build/utils/log/types.js +2 -0
- package/build/utils/log/types.js.map +1 -0
- package/build/utils/log.d.ts +4 -0
- package/build/utils/log.d.ts.map +1 -0
- package/build/utils/log.js +170 -0
- package/build/utils/log.js.map +1 -0
- package/build/utils/payload/types.d.ts +18 -0
- package/build/utils/payload/types.d.ts.map +1 -0
- package/build/utils/payload/types.js +2 -0
- package/build/utils/payload/types.js.map +1 -0
- package/build/utils/payload.d.ts +3 -15
- package/build/utils/payload.d.ts.map +1 -1
- package/build/utils/payload.js +41 -57
- package/build/utils/payload.js.map +1 -1
- package/build/utils/schema.d.ts +7 -0
- package/build/utils/schema.d.ts.map +1 -0
- package/build/utils/schema.js +20 -0
- package/build/utils/schema.js.map +1 -0
- package/build/utils/server.d.ts +3 -6
- package/build/utils/server.d.ts.map +1 -1
- package/build/utils/server.js +12 -13
- package/build/utils/server.js.map +1 -1
- package/build/utils/types.d.ts +18 -0
- package/build/utils/types.d.ts.map +1 -0
- package/build/utils/types.js +2 -0
- package/build/utils/types.js.map +1 -0
- package/build/utils/types.local.d.ts +9 -0
- package/build/utils/types.local.d.ts.map +1 -0
- package/build/utils/types.local.js +2 -0
- package/build/utils/types.local.js.map +1 -0
- package/package.json +23 -19
- package/src/consts.local.ts +1 -0
- package/src/errors.ts +18 -0
- package/src/helper.ts +13 -8
- package/src/hold.ts +66 -0
- package/src/index.ts +2 -0
- package/src/protocol.ts +149 -0
- package/src/server.ts +72 -69
- package/src/types.local.ts +44 -0
- package/src/types.ts +38 -4
- package/src/utils/consts.local.ts +7 -0
- package/src/utils/consts.ts +1 -0
- package/src/utils/context/types.ts +13 -0
- package/src/utils/context.ts +9 -4
- package/src/utils/error/types.ts +52 -0
- package/src/utils/error.ts +157 -13
- package/src/utils/guards.ts +16 -26
- package/src/utils/index.ts +6 -1
- package/src/utils/log/consts.ts +18 -0
- package/src/utils/log/types.ts +43 -0
- package/src/utils/log.ts +193 -0
- package/src/utils/payload/types.ts +18 -0
- package/src/utils/payload.ts +42 -60
- package/src/utils/schema.ts +23 -0
- package/src/utils/server.ts +16 -21
- package/src/utils/types.local.ts +12 -0
- package/src/utils/types.ts +18 -0
- package/tests/error-status.spec.ts +349 -0
- package/tests/handlers.spec.ts +83 -0
- package/tests/json-body.spec.ts +72 -0
- package/tests/log-adapter.spec.ts +62 -0
- package/tests/log-redaction.spec.ts +181 -0
- package/tests/tsconfig.json +12 -0
- package/tsconfig.json +1 -0
- package/build/.gitkeep +0 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { FastifyReply } from 'fastify'
|
|
2
|
+
import type { Config, HttpErrorExposure } from '../../types.js'
|
|
3
|
+
import type { SerializedHttpError } from '../types.js'
|
|
4
|
+
|
|
5
|
+
/** How the HTTP boundary answers a thrown error: its status, its wire body, its headers and its log entry. */
|
|
6
|
+
export interface HttpErrorHelper {
|
|
7
|
+
/**
|
|
8
|
+
* The HTTP status an error's class declares, or `null` when it declares none that may be honoured.
|
|
9
|
+
*
|
|
10
|
+
* Read through the constructor chain — a static property is inherited, so a subclass answers what
|
|
11
|
+
* its nearest declaring ancestor says, and redeclares to change it. An integer 4xx is honoured
|
|
12
|
+
* directly. A 5xx is honoured only with `allowServerErrorStatus = true`, so a domain class must
|
|
13
|
+
* explicitly opt in to exposing a precise availability response. The read is structural, so a
|
|
14
|
+
* class from a duplicate module copy declares exactly what the imported one does.
|
|
15
|
+
*/
|
|
16
|
+
declaredErrorStatus: (error: unknown) => number | null
|
|
17
|
+
/**
|
|
18
|
+
* The HTTP status an error is answered with, read off the error exactly as given.
|
|
19
|
+
*
|
|
20
|
+
* Two families reach here and both must be recognised. `AuthFailedError` / `AccessError` are this
|
|
21
|
+
* package's own, raised by the request pipeline itself. `AuthorizationError` / `AuthForbidden`
|
|
22
|
+
* come from `@owlmeans/auth` and are what the guards and gates actually throw — an unrecognised
|
|
23
|
+
* one answers 500, which reports a refused request as a crashed server: the client cannot tell
|
|
24
|
+
* "sign in again" from "the service is broken", and a consumer watching statuses concludes the
|
|
25
|
+
* application fell over. A family member is recognised by class OR by registered type name, so a
|
|
26
|
+
* class from a duplicate module copy answers the same status.
|
|
27
|
+
*
|
|
28
|
+
* **Order matters.** `AuthForbidden extends AuthorizationError`, so the 403 branch has to be
|
|
29
|
+
* tested first or every refusal of a permission is reported as a failure to authenticate. Both
|
|
30
|
+
* auth branches come before a class's own declaration, so an auth refusal can never be restated
|
|
31
|
+
* as something else. Everything that is neither an auth error nor a class declaring an allowed
|
|
32
|
+
* {@link HttpErrorHelper.declaredErrorStatus} answers 500.
|
|
33
|
+
*/
|
|
34
|
+
errorStatus: (error: unknown) => number
|
|
35
|
+
/** Invalid or absent configuration always resolves to the production-safe policy. */
|
|
36
|
+
errorExposure: (config?: Pick<Config, 'http'>) => HttpErrorExposure
|
|
37
|
+
/** One central wire shape for every server error. */
|
|
38
|
+
serializeError: (error: Error, exposure?: HttpErrorExposure) => SerializedHttpError
|
|
39
|
+
/** Apply safe response metadata carried by an error without coupling its package to Fastify. */
|
|
40
|
+
applyErrorHeaders: (error: unknown, reply: FastifyReply) => void
|
|
41
|
+
/**
|
|
42
|
+
* Answer a thrown error: a status, and the marshalled `ResilientError` as the body.
|
|
43
|
+
*
|
|
44
|
+
* The status is resolved in two steps. The error AS THROWN comes first: it is the one object whose
|
|
45
|
+
* class is certainly the class that was raised, while `ResilientError.ensure` rebuilds through the
|
|
46
|
+
* registry of whichever `@owlmeans/error` copy this module loaded — under duplicate module copies
|
|
47
|
+
* that turns a perfectly recognisable auth refusal into a plain `ResilientError` answering 500.
|
|
48
|
+
* Only when the thrown error answers 500 is the ENSURED error asked, because the rebuild is what
|
|
49
|
+
* gives a status to a marshalled error that crossed a hop as a plain `Error`.
|
|
50
|
+
*/
|
|
51
|
+
handleError: (error: Error, reply: FastifyReply, exposure?: HttpErrorExposure) => void
|
|
52
|
+
}
|
package/src/utils/error.ts
CHANGED
|
@@ -1,18 +1,162 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
import { AuthForbidden, AuthorizationError } from '@owlmeans/auth'
|
|
7
|
+
import { isResilientError, ResilientError, SEPARATOR } from '@owlmeans/error'
|
|
8
|
+
import { randomUUID } from 'node:crypto'
|
|
9
|
+
import { fastifyLogUtils } from './log.js'
|
|
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'
|
|
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
|
|
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
|
|
12
60
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
83
|
+
}
|
|
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 handleError = (
|
|
143
|
+
error: Error, reply: FastifyReply, exposure: HttpErrorExposure = 'production'
|
|
144
|
+
): void => {
|
|
145
|
+
if (!reply.sent) {
|
|
146
|
+
const serialized = serializeError(error, exposure)
|
|
147
|
+
applyErrorHeaders(error, reply)
|
|
148
|
+
// The one record of a failed request: method, path and status — never what the caller sent.
|
|
149
|
+
fastifyLogUtils.failure(error, reply.request, serialized.status, serialized.incidentId)
|
|
150
|
+
reply.header(INCIDENT_ID_HEADER, serialized.incidentId)
|
|
151
|
+
.code(serialized.status)
|
|
152
|
+
.send(serialized.body)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { declaredErrorStatus, errorStatus, errorExposure, serializeError, applyErrorHeaders, handleError }
|
|
18
157
|
}
|
|
158
|
+
|
|
159
|
+
export const httpErrorHelper = createHttpErrorHelper()
|
|
160
|
+
|
|
161
|
+
/** @deprecated compat:factory-refactor — use `httpErrorHelper.errorStatus(…)` */
|
|
162
|
+
export const errorStatus = (error: unknown): number => httpErrorHelper.errorStatus(error)
|
package/src/utils/guards.ts
CHANGED
|
@@ -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
|
|
5
|
-
import {
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
package/src/utils/index.ts
CHANGED
|
@@ -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 './
|
|
10
|
+
export * from './schema.js'
|
|
6
11
|
export * from './guards.js'
|
|
7
12
|
export * from './context.js'
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keys of a Fastify log record that carry what a caller sent. They are dropped at every depth: a
|
|
3
|
+
* request body and its raw form (`rawBody`), multipart fields and file buffers, headers (with the
|
|
4
|
+
* authorization and cookie values), cookies and the parsed query string.
|
|
5
|
+
*/
|
|
6
|
+
export const UNSAFE_LOG_KEYS: ReadonlySet<string> = new Set([
|
|
7
|
+
'body', 'rawBody', 'payload', 'files', 'file', 'headers', 'rawHeaders', 'trailers', 'rawTrailers',
|
|
8
|
+
'query', 'querystring', 'cookies', 'cookie', 'authorization',
|
|
9
|
+
])
|
|
10
|
+
|
|
11
|
+
/** Deepest nesting `safeData` walks; anything below is one marker. */
|
|
12
|
+
export const SAFE_DATA_DEPTH = 4
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The query string of a path or URL inside free text (a Fastify warning names `"/x?token=…"`): the
|
|
16
|
+
* path is kept (`$1`), the query is cut.
|
|
17
|
+
*/
|
|
18
|
+
export const QUERY_IN_TEXT = /(\/[^\s?"'`]*)\?[^\s"'`)]+/g
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { FastifyBaseLogger, LogController } from 'fastify'
|
|
2
|
+
import type { Logger } from '@owlmeans/log'
|
|
3
|
+
|
|
4
|
+
/** All a log record may say about a request: its method and its path — never the query string. */
|
|
5
|
+
export interface RequestSummary {
|
|
6
|
+
method?: string
|
|
7
|
+
path?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* How the API server's Fastify instance logs: through `@owlmeans/log`, and never with what a caller
|
|
12
|
+
* sent — a body, a raw body, multipart bytes, headers, cookies or a query string.
|
|
13
|
+
*/
|
|
14
|
+
export interface FastifyLogUtils {
|
|
15
|
+
/**
|
|
16
|
+
* A pino-shaped logger over `log` (scope `http` by default), handed to Fastify as its
|
|
17
|
+
* `loggerInstance`. Every record's data — Fastify's own and any plugin's — passes `safeData`.
|
|
18
|
+
*/
|
|
19
|
+
logger: (log?: Logger, bindings?: Record<string, unknown>) => FastifyBaseLogger
|
|
20
|
+
/**
|
|
21
|
+
* The `logController` Fastify writes its own request lines through: no per-request pair (the
|
|
22
|
+
* server writes one debug record from `onResponse`), a request the default error handler answers
|
|
23
|
+
* is `failure`, a missing route is one debug line without its query.
|
|
24
|
+
*/
|
|
25
|
+
controller: () => LogController
|
|
26
|
+
/**
|
|
27
|
+
* A copy of a record's data that is safe to write: a request becomes its `RequestSummary`, a reply
|
|
28
|
+
* its status, bytes their size, any other class instance its name; the keys that carry what a
|
|
29
|
+
* caller sent (`body`, `rawBody`, `headers`, `query`, `cookies`, files…) are dropped at every depth
|
|
30
|
+
* (a number or boolean under them stays: a count, a flag), and any string loses the query of a URL
|
|
31
|
+
* it names. An `Error` passes as it is (a copy, when its message names a URL with a query) — the
|
|
32
|
+
* log writes only its name, message, code and stack.
|
|
33
|
+
*/
|
|
34
|
+
safeData: (data: unknown) => unknown
|
|
35
|
+
/** The method and the path, without the query string, of a Fastify or Node request. */
|
|
36
|
+
requestOf: (request: unknown) => RequestSummary
|
|
37
|
+
/**
|
|
38
|
+
* The one record of a failed request, by what its status means: 5xx `error` with the error,
|
|
39
|
+
* 403 `warn` `access.forbidden`, 401 `debug` `auth.refused`, any other 4xx `debug` with the error's
|
|
40
|
+
* code and message. Method, path and status only — never the request's content.
|
|
41
|
+
*/
|
|
42
|
+
failure: (error: Error, request: unknown, status: number, incidentId?: string) => void
|
|
43
|
+
}
|
package/src/utils/log.ts
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { LogController } from 'fastify'
|
|
2
|
+
import type { FastifyBaseLogger, FastifyReply, FastifyRequest } from 'fastify'
|
|
3
|
+
import { FORBIDDEN_ERROR, SERVER_ERROR, UNAUTHORIZED_ERROR } from '@owlmeans/api'
|
|
4
|
+
import { logger, type LogLevel, type Logger } from '@owlmeans/log'
|
|
5
|
+
import type { Severity } from './types.local.js'
|
|
6
|
+
import { QUERY_IN_TEXT, SAFE_DATA_DEPTH, UNSAFE_LOG_KEYS } from './log/consts.js'
|
|
7
|
+
import type { FastifyLogUtils, RequestSummary } from './log/types.js'
|
|
8
|
+
|
|
9
|
+
const pathOf = (url: string): string => url.split('?')[0]
|
|
10
|
+
|
|
11
|
+
const withoutQueries = (text: string): string => text.replace(QUERY_IN_TEXT, '$1')
|
|
12
|
+
|
|
13
|
+
/** The error itself, or — when its message names a URL with a query — a copy without the query. */
|
|
14
|
+
const safeError = (error: Error): Error => {
|
|
15
|
+
const message = withoutQueries(error.message)
|
|
16
|
+
if (message === error.message) {
|
|
17
|
+
return error
|
|
18
|
+
}
|
|
19
|
+
const copy: Error = Object.assign(Object.create(Object.getPrototypeOf(error) as object) as Error, error)
|
|
20
|
+
copy.message = message
|
|
21
|
+
copy.stack = error.stack != null ? withoutQueries(error.stack) : undefined
|
|
22
|
+
return copy
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const isPlain = (value: object): boolean => {
|
|
26
|
+
const proto = Object.getPrototypeOf(value)
|
|
27
|
+
return proto === Object.prototype || proto === null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const levelName = (log: Logger): string => {
|
|
31
|
+
const order: LogLevel[] = ['debug', 'info', 'warn', 'error']
|
|
32
|
+
return order.find(level => log.enabled(level)) ?? 'silent'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const createFastifyLogUtils = (): FastifyLogUtils => {
|
|
36
|
+
const http = logger('http')
|
|
37
|
+
|
|
38
|
+
const requestOf = (request: unknown): RequestSummary => {
|
|
39
|
+
const { method, url } = (request ?? {}) as { method?: unknown, url?: unknown }
|
|
40
|
+
return {
|
|
41
|
+
...(typeof method === 'string' ? { method } : {}),
|
|
42
|
+
...(typeof url === 'string' ? { path: pathOf(url) } : {}),
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A class instance as a record may carry it: a request or reply by summary, anything else by name. */
|
|
47
|
+
const instanceOf = (value: object): unknown => {
|
|
48
|
+
const shape = value as { method?: unknown, url?: unknown, statusCode?: unknown }
|
|
49
|
+
if (typeof shape.method === 'string' && typeof shape.url === 'string') {
|
|
50
|
+
return requestOf(value)
|
|
51
|
+
}
|
|
52
|
+
if (typeof shape.statusCode === 'number') {
|
|
53
|
+
return { statusCode: shape.statusCode }
|
|
54
|
+
}
|
|
55
|
+
const name = value.constructor?.name ?? 'object'
|
|
56
|
+
if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) {
|
|
57
|
+
return `[${name} ${value.byteLength} bytes]`
|
|
58
|
+
}
|
|
59
|
+
return `[${name}]`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const safe = (value: unknown, depth: number, seen: WeakSet<object>): unknown => {
|
|
63
|
+
if (typeof value === 'string') {
|
|
64
|
+
return withoutQueries(value)
|
|
65
|
+
}
|
|
66
|
+
if (value instanceof Error) {
|
|
67
|
+
return safeError(value)
|
|
68
|
+
}
|
|
69
|
+
if (value == null || typeof value !== 'object' || value instanceof Date) {
|
|
70
|
+
return value
|
|
71
|
+
}
|
|
72
|
+
if (seen.has(value)) {
|
|
73
|
+
return '[circular]'
|
|
74
|
+
}
|
|
75
|
+
if (!Array.isArray(value) && !isPlain(value)) {
|
|
76
|
+
return instanceOf(value)
|
|
77
|
+
}
|
|
78
|
+
if (depth >= SAFE_DATA_DEPTH) {
|
|
79
|
+
return '[…]'
|
|
80
|
+
}
|
|
81
|
+
seen.add(value)
|
|
82
|
+
if (Array.isArray(value)) {
|
|
83
|
+
return value.map(item => safe(item, depth + 1, seen))
|
|
84
|
+
}
|
|
85
|
+
const out: Record<string, unknown> = {}
|
|
86
|
+
for (const [key, item] of Object.entries(value)) {
|
|
87
|
+
// A count or a flag under such a key (`limits.files: 5`) says nothing a caller sent.
|
|
88
|
+
if (UNSAFE_LOG_KEYS.has(key) && typeof item !== 'number' && typeof item !== 'boolean') {
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
out[key] = key === 'url' && typeof item === 'string' ? pathOf(item) : safe(item, depth + 1, seen)
|
|
92
|
+
}
|
|
93
|
+
return out
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const safeData = (data: unknown): unknown => safe(data, 0, new WeakSet())
|
|
97
|
+
|
|
98
|
+
const failure: FastifyLogUtils['failure'] = (error, request, status, incidentId) => {
|
|
99
|
+
const where = { ...requestOf(request), status, ...(incidentId != null ? { incidentId } : {}) }
|
|
100
|
+
const safe = safeError(error)
|
|
101
|
+
if (status >= SERVER_ERROR) {
|
|
102
|
+
http.error('Request failed', { err: safe, ...where })
|
|
103
|
+
} else if (status === FORBIDDEN_ERROR) {
|
|
104
|
+
http.warn('Access forbidden', { type: (error as { type?: unknown }).type, message: safe.message, ...where },
|
|
105
|
+
{ event: 'access.forbidden' })
|
|
106
|
+
} else if (status === UNAUTHORIZED_ERROR) {
|
|
107
|
+
http.debug('Authentication refused', { message: safe.message, ...where }, { event: 'auth.refused' })
|
|
108
|
+
} else {
|
|
109
|
+
const code = (error as { code?: unknown }).code
|
|
110
|
+
http.debug('Request refused', { ...(typeof code === 'string' ? { code } : {}), message: safe.message, ...where })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Fastify announces its listening address at info; the server writes `server.listening` itself.
|
|
116
|
+
* Its request lines no longer reach the adapter at all — `controller()` owns them.
|
|
117
|
+
*/
|
|
118
|
+
const levelOf = (level: Severity, message: unknown): Severity =>
|
|
119
|
+
level === 'info' && typeof message === 'string' && message.startsWith('Server listening at') ? 'debug' : level
|
|
120
|
+
|
|
121
|
+
const fastifyLogger = (log: Logger = http, bindings?: Record<string, unknown>): FastifyBaseLogger => {
|
|
122
|
+
const call = (requested: Severity) => (first: unknown, second?: unknown): void => {
|
|
123
|
+
const [object, message] = typeof first === 'string' || first instanceof Error
|
|
124
|
+
? [undefined, first] : [first, second]
|
|
125
|
+
const level = levelOf(requested, message)
|
|
126
|
+
if (!log.enabled(level)) {
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
const data = bindings != null && Object.keys(bindings).length > 0
|
|
130
|
+
? { ...bindings, ...(object != null && typeof object === 'object' ? object : {}) } : object
|
|
131
|
+
const text = message == null ? '' : message instanceof Error ? safeError(message) : withoutQueries(String(message))
|
|
132
|
+
log[level](text, safeData(data))
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const adapter = {
|
|
136
|
+
get level(): string { return levelName(log) },
|
|
137
|
+
set level(_value: string) { /* the level belongs to the process, not to this server */ },
|
|
138
|
+
fatal: call('error'), error: call('error'), warn: call('warn'),
|
|
139
|
+
info: call('info'), debug: call('debug'), trace: call('debug'),
|
|
140
|
+
silent: () => undefined,
|
|
141
|
+
// Fastify's second argument (pino options: its own `req`/`res` serializers) is ignored —
|
|
142
|
+
// `safeData` is this adapter's serializer.
|
|
143
|
+
child: (extra: Record<string, unknown>) => fastifyLogger(log, { ...bindings, ...extra }),
|
|
144
|
+
}
|
|
145
|
+
return adapter as unknown as FastifyBaseLogger
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Fastify accepts a `logController` only as an instance of its own class, so this one member is a
|
|
150
|
+
* subclass; its methods use no `this`.
|
|
151
|
+
*/
|
|
152
|
+
const controller = (): LogController => {
|
|
153
|
+
class SafeLogController extends LogController {
|
|
154
|
+
override incomingRequest (): void { /* `onResponse` writes the one record of a request */ }
|
|
155
|
+
|
|
156
|
+
override requestCompleted (error: Error | null | undefined, request: FastifyRequest, reply: FastifyReply): void {
|
|
157
|
+
if (error != null) {
|
|
158
|
+
http.error('Response failed', { err: error, ...requestOf(request), status: reply.statusCode })
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
override defaultErrorLog (error: Error, request: FastifyRequest, reply: FastifyReply): void {
|
|
163
|
+
failure(error, request, reply.statusCode)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
override streamError (error: Error, request: FastifyRequest, reply: FastifyReply): void {
|
|
167
|
+
const where = { ...requestOf(request), status: reply.statusCode }
|
|
168
|
+
if ((error as { code?: unknown }).code === 'ERR_STREAM_PREMATURE_CLOSE') {
|
|
169
|
+
http.debug('Response stream closed prematurely', where)
|
|
170
|
+
} else {
|
|
171
|
+
http.warn('Response stream failed after its headers were sent', { err: error, ...where })
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
override routeNotFound (request: FastifyRequest): void {
|
|
176
|
+
http.debug('Route not found', requestOf(request))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
override writeHeadError (error: Error, request: FastifyRequest, reply: FastifyReply): void {
|
|
180
|
+
http.warn('Response headers not written', { err: error, ...requestOf(request), status: reply.statusCode })
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
override serializerError (error: Error, request: FastifyRequest, _reply: FastifyReply, metadata: { statusCode: number }): void {
|
|
184
|
+
http.error('Response serializer failed', { err: error, ...requestOf(request), status: metadata.statusCode })
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return new SafeLogController()
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { logger: fastifyLogger, controller, safeData, requestOf, failure }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export const fastifyLogUtils = createFastifyLogUtils()
|
|
@@ -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
|
+
}
|