@owlmeans/server-api 0.1.18-rc.52 → 0.1.18-rc.53
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 +2 -2
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-api/SKILL.md +22 -8
- package/build/server.d.ts.map +1 -1
- package/build/server.js +10 -7
- package/build/server.js.map +1 -1
- package/build/utils/error.d.ts.map +1 -1
- package/build/utils/error.js +3 -31
- package/build/utils/error.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 +3 -10
- package/build/utils/log.d.ts.map +1 -1
- package/build/utils/log.js +161 -48
- package/build/utils/log.js.map +1 -1
- package/package.json +6 -6
- package/src/server.ts +10 -7
- package/src/utils/error.ts +3 -31
- package/src/utils/log/consts.ts +18 -0
- package/src/utils/log/types.ts +43 -0
- package/src/utils/log.ts +181 -50
- package/tests/log-adapter.spec.ts +17 -7
- package/tests/log-redaction.spec.ts +181 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owlmeans/server-api",
|
|
3
|
-
"version": "0.1.18-rc.
|
|
3
|
+
"version": "0.1.18-rc.53",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -33,15 +33,15 @@
|
|
|
33
33
|
"@fastify/helmet": "^13.1.1",
|
|
34
34
|
"@fastify/middie": "^9.3.4",
|
|
35
35
|
"@fastify/multipart": "^10.1.2",
|
|
36
|
-
"@owlmeans/api": "^0.1.18-rc.
|
|
37
|
-
"@owlmeans/auth": "^0.1.18-rc.
|
|
38
|
-
"@owlmeans/auth-common": "^0.1.18-rc.
|
|
36
|
+
"@owlmeans/api": "^0.1.18-rc.49",
|
|
37
|
+
"@owlmeans/auth": "^0.1.18-rc.41",
|
|
38
|
+
"@owlmeans/auth-common": "^0.1.18-rc.49",
|
|
39
39
|
"@owlmeans/context": "^0.1.18-rc.34",
|
|
40
40
|
"@owlmeans/entrypoint": "^0.1.18-rc.41",
|
|
41
41
|
"@owlmeans/error": "^0.1.18-rc.38",
|
|
42
|
-
"@owlmeans/log": "^0.1.18-rc.
|
|
42
|
+
"@owlmeans/log": "^0.1.18-rc.3",
|
|
43
43
|
"@owlmeans/route": "^0.1.18-rc.35",
|
|
44
|
-
"@owlmeans/server-context": "^0.1.18-rc.
|
|
44
|
+
"@owlmeans/server-context": "^0.1.18-rc.44",
|
|
45
45
|
"@owlmeans/server-entrypoint": "^0.1.18-rc.40",
|
|
46
46
|
"ajv-errors": "^3.0.0",
|
|
47
47
|
"fastify": "^5.12.5",
|
package/src/server.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { TOKEN_UPDATE } from '@owlmeans/auth-common'
|
|
|
10
10
|
import { INCIDENT_ID_HEADER } from './utils/consts.js'
|
|
11
11
|
import { DENIAL_KIND_HEADER } from '@owlmeans/api'
|
|
12
12
|
import { logger } from '@owlmeans/log'
|
|
13
|
-
import {
|
|
13
|
+
import { fastifyLogUtils } from './utils/log.js'
|
|
14
14
|
|
|
15
15
|
import Fastify from 'fastify'
|
|
16
16
|
import type { FastifyRequest } from 'fastify'
|
|
@@ -41,12 +41,16 @@ ajvErrors(ajv, { singleError: true })
|
|
|
41
41
|
const http = logger('http')
|
|
42
42
|
|
|
43
43
|
/**
|
|
44
|
-
* Fastify logging through `@owlmeans/log
|
|
45
|
-
*
|
|
46
|
-
*
|
|
44
|
+
* Fastify logging through `@owlmeans/log`, never with what a caller sent. The adapter writes every
|
|
45
|
+
* record's data through `safeData` (no body, raw body, multipart bytes, headers, cookies or query);
|
|
46
|
+
* the log controller drops Fastify's per-request pair — a request is a debug record written once
|
|
47
|
+
* from `onResponse` (below) — and logs a request Fastify itself refuses (a schema-invalid body, an
|
|
48
|
+
* unparsable payload, a size limit) like `handleError` logs a failed handler: method, path, status,
|
|
49
|
+
* error code and message.
|
|
47
50
|
*/
|
|
48
51
|
const createFastify = () => Fastify({
|
|
49
|
-
loggerInstance:
|
|
52
|
+
loggerInstance: fastifyLogUtils.logger(http),
|
|
53
|
+
logController: fastifyLogUtils.controller(),
|
|
50
54
|
bodyLimit,
|
|
51
55
|
})
|
|
52
56
|
|
|
@@ -88,8 +92,7 @@ export const createApiServer = (alias: string): ApiServer => {
|
|
|
88
92
|
if (http.enabled('debug')) {
|
|
89
93
|
// The path only: a query string can carry a token.
|
|
90
94
|
http.debug('request', {
|
|
91
|
-
|
|
92
|
-
ms: Math.round(reply.elapsedTime),
|
|
95
|
+
...fastifyLogUtils.requestOf(request), status: reply.statusCode, ms: Math.round(reply.elapsedTime),
|
|
93
96
|
})
|
|
94
97
|
}
|
|
95
98
|
done()
|
package/src/utils/error.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { AccessError, AuthFailedError } from '../errors.js'
|
|
|
6
6
|
import { AuthForbidden, AuthorizationError } from '@owlmeans/auth'
|
|
7
7
|
import { isResilientError, ResilientError, SEPARATOR } from '@owlmeans/error'
|
|
8
8
|
import { randomUUID } from 'node:crypto'
|
|
9
|
-
import {
|
|
9
|
+
import { fastifyLogUtils } from './log.js'
|
|
10
10
|
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'
|
|
@@ -139,42 +139,14 @@ export const createHttpErrorHelper = (): HttpErrorHelper => {
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
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
142
|
const handleError = (
|
|
172
143
|
error: Error, reply: FastifyReply, exposure: HttpErrorExposure = 'production'
|
|
173
144
|
): void => {
|
|
174
145
|
if (!reply.sent) {
|
|
175
146
|
const serialized = serializeError(error, exposure)
|
|
176
147
|
applyErrorHeaders(error, reply)
|
|
177
|
-
|
|
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)
|
|
178
150
|
reply.header(INCIDENT_ID_HEADER, serialized.incidentId)
|
|
179
151
|
.code(serialized.status)
|
|
180
152
|
.send(serialized.body)
|
|
@@ -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
CHANGED
|
@@ -1,62 +1,193 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { LogController } from 'fastify'
|
|
2
|
+
import type { FastifyBaseLogger, FastifyReply, FastifyRequest } from 'fastify'
|
|
3
|
+
import { FORBIDDEN_ERROR, SERVER_ERROR, UNAUTHORIZED_ERROR } from '@owlmeans/api'
|
|
2
4
|
import { logger, type LogLevel, type Logger } from '@owlmeans/log'
|
|
3
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'
|
|
4
8
|
|
|
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
|
-
}
|
|
9
|
+
const pathOf = (url: string): string => url.split('?')[0]
|
|
27
10
|
|
|
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
|
-
}
|
|
11
|
+
const withoutQueries = (text: string): string => text.replace(QUERY_IN_TEXT, '$1')
|
|
47
12
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
silent: () => undefined,
|
|
54
|
-
child: (extra: Record<string, unknown>) => fastifyLogger(log, { ...bindings, ...extra }),
|
|
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
|
|
55
18
|
}
|
|
56
|
-
|
|
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
|
|
57
28
|
}
|
|
58
29
|
|
|
59
30
|
const levelName = (log: Logger): string => {
|
|
60
31
|
const order: LogLevel[] = ['debug', 'info', 'warn', 'error']
|
|
61
32
|
return order.find(level => log.enabled(level)) ?? 'silent'
|
|
62
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()
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
2
|
import { addLogPlugin, configureLog, memoryPlugin, resetLog } from '@owlmeans/log'
|
|
3
|
-
import {
|
|
3
|
+
import { fastifyLogUtils } from '../src/utils/log.js'
|
|
4
|
+
|
|
5
|
+
const fastifyLogger = fastifyLogUtils.logger
|
|
4
6
|
|
|
5
7
|
const memory = memoryPlugin('adapter')
|
|
6
8
|
|
|
@@ -12,24 +14,32 @@ beforeEach(() => {
|
|
|
12
14
|
afterEach(() => { memory.clear(); resetLog() })
|
|
13
15
|
|
|
14
16
|
describe('@owlmeans/server-api — the Fastify logger adapter', () => {
|
|
15
|
-
test('
|
|
17
|
+
test('lowers the listening line, keeps the rest', () => {
|
|
16
18
|
const log = fastifyLogger() as unknown as Record<string, (...args: unknown[]) => void>
|
|
17
|
-
log.info({ req: {} }, 'incoming request')
|
|
18
|
-
log.info({ res: {} }, 'request completed')
|
|
19
19
|
log.info('Server listening at http://127.0.0.1:80')
|
|
20
|
-
log.info({ url: '/x' }, 'Route GET:/x not found')
|
|
21
20
|
log.info('something else')
|
|
22
21
|
log.warn({ a: 1 }, 'warned')
|
|
23
22
|
log.error({ err: new Error('boom') }, 'failed')
|
|
24
23
|
|
|
25
24
|
expect(memory.records.map(r => [r.level, r.message])).toEqual([
|
|
26
25
|
['debug', 'Server listening at http://127.0.0.1:80'],
|
|
27
|
-
['debug', 'Route GET:/x not found'],
|
|
28
26
|
['info', 'something else'],
|
|
29
27
|
['warn', 'warned'],
|
|
30
28
|
['error', 'failed'],
|
|
31
29
|
])
|
|
32
|
-
expect(memory.records[
|
|
30
|
+
expect(memory.records[3].error?.message).toBe('boom')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('the controller writes no per-request pair and a missing route without its query', () => {
|
|
34
|
+
const controller = fastifyLogUtils.controller()
|
|
35
|
+
const request = { method: 'GET', url: '/x?token=abc' } as never
|
|
36
|
+
controller.incomingRequest(request, {} as never)
|
|
37
|
+
controller.requestCompleted(null, request, { statusCode: 200 } as never)
|
|
38
|
+
controller.routeNotFound(request, {} as never)
|
|
39
|
+
|
|
40
|
+
expect(memory.records.map(r => [r.level, r.message, r.data])).toEqual([
|
|
41
|
+
['debug', 'Route not found', { method: 'GET', path: '/x' }],
|
|
42
|
+
])
|
|
33
43
|
})
|
|
34
44
|
|
|
35
45
|
test('child loggers bind their fields and the level is the process level', () => {
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { AppType } from '@owlmeans/context'
|
|
3
|
+
import { addLogPlugin, configureLog, logStateHelper, memoryPlugin, resetLog } from '@owlmeans/log'
|
|
4
|
+
import type { LogRecord, NativeConsole } from '@owlmeans/log'
|
|
5
|
+
import { contract, protocol, schema, typed } from '@owlmeans/entrypoint'
|
|
6
|
+
import { backend, route, RouteMethod } from '@owlmeans/route'
|
|
7
|
+
import { config, makeServerContext } from '@owlmeans/server-context'
|
|
8
|
+
import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
9
|
+
import { bind } from '@owlmeans/server-entrypoint'
|
|
10
|
+
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
|
11
|
+
import { handlers } from '../src/protocol.js'
|
|
12
|
+
import { appendApiServer } from '../src/server.js'
|
|
13
|
+
import { fastifyLogUtils } from '../src/utils/log.js'
|
|
14
|
+
|
|
15
|
+
/** What a caller sent; none of it may reach a log record or a written line. */
|
|
16
|
+
const MARKER = 'pii-marker-7f3a'
|
|
17
|
+
const SECRETS = ['auth-secret-91c2', 'cookie-secret-44d0', 'query-secret-0b5e']
|
|
18
|
+
|
|
19
|
+
interface Inquiry { email: string, message: string }
|
|
20
|
+
|
|
21
|
+
const inquiry = protocol(
|
|
22
|
+
route('server-api-test:inquiry', '/inquiry', backend(null, RouteMethod.POST)),
|
|
23
|
+
contract(schema<Inquiry>({
|
|
24
|
+
type: 'object',
|
|
25
|
+
properties: { email: { type: 'string', format: 'email' }, message: { type: 'string', maxLength: 10 } },
|
|
26
|
+
required: ['email', 'message'],
|
|
27
|
+
}), typed<{ ok: boolean }>()),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
const memory = memoryPlugin('redaction')
|
|
31
|
+
const written: string[] = []
|
|
32
|
+
const saved: Partial<NativeConsole> = {}
|
|
33
|
+
|
|
34
|
+
/** Every record as a plugin receives it, plus the error's own text: the sink writes no more than that. */
|
|
35
|
+
const recorded = (records: LogRecord[]): string => JSON.stringify(records.map(record => ({
|
|
36
|
+
...record, error: record.error != null ? { message: record.error.message, stack: record.error.stack } : undefined,
|
|
37
|
+
})))
|
|
38
|
+
|
|
39
|
+
const leaks = (): string[] => {
|
|
40
|
+
const everything = `${recorded(memory.records)}\n${written.join('\n')}`
|
|
41
|
+
return [MARKER, ...SECRETS].filter(secret => everything.includes(secret))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
memory.clear()
|
|
46
|
+
written.length = 0
|
|
47
|
+
addLogPlugin(memory)
|
|
48
|
+
configureLog({ level: 'debug', console: 'native' })
|
|
49
|
+
// The sink itself: read what it writes, through the captured natives it writes with.
|
|
50
|
+
const native = logStateHelper.nativeConsole()
|
|
51
|
+
for (const method of ['debug', 'info', 'warn', 'error'] as const) {
|
|
52
|
+
saved[method] = native[method]
|
|
53
|
+
native[method] = (...args: unknown[]) => { written.push(args.map(String).join(' ')) }
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
Object.assign(logStateHelper.nativeConsole(), saved)
|
|
59
|
+
resetLog()
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('@owlmeans/server-api — a request never reaches the log', () => {
|
|
63
|
+
let server: FastifyInstance
|
|
64
|
+
|
|
65
|
+
beforeAll(async () => {
|
|
66
|
+
const service = 'server-api-redaction'
|
|
67
|
+
const context = makeServerContext(config<ServerConfig>(service, {
|
|
68
|
+
services: { [service]: { service, type: AppType.Backend, host: '127.0.0.1', default: true } },
|
|
69
|
+
} as Partial<ServerConfig>)) as ServerContext<ServerConfig>
|
|
70
|
+
const served = appendApiServer(context)
|
|
71
|
+
const api = handlers<ServerContext<ServerConfig>>()
|
|
72
|
+
context.registerEntrypoint(bind(inquiry, api.request(inquiry, async () => ({ ok: true }))))
|
|
73
|
+
context.configure()
|
|
74
|
+
await context.init()
|
|
75
|
+
server = served.getApiServer().server
|
|
76
|
+
await server.ready()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
afterAll(async () => { await server?.close() })
|
|
80
|
+
|
|
81
|
+
const headers = {
|
|
82
|
+
authorization: `Bearer ${SECRETS[0]}`, cookie: `session=${SECRETS[1]}`,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
test('a JSON body the schema refuses: method, path, status, code and message only', async () => {
|
|
86
|
+
const response = await server.inject({
|
|
87
|
+
method: 'POST', url: `/inquiry?token=${SECRETS[2]}`,
|
|
88
|
+
headers: { ...headers, 'content-type': 'application/json' },
|
|
89
|
+
payload: JSON.stringify({ email: `${MARKER}@`, message: `${MARKER} wrote this` }),
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
expect(response.statusCode).toBe(400)
|
|
93
|
+
expect(leaks()).toEqual([])
|
|
94
|
+
const refused = memory.records.find(record => record.message === 'Request refused')
|
|
95
|
+
expect(refused).toMatchObject({
|
|
96
|
+
level: 'debug', scope: 'http',
|
|
97
|
+
data: { method: 'POST', path: '/inquiry', status: 400, code: 'FST_ERR_VALIDATION' },
|
|
98
|
+
})
|
|
99
|
+
expect(Object.keys(refused!.data as object).sort()).toEqual(['code', 'message', 'method', 'path', 'status'])
|
|
100
|
+
// Nothing about a refused request is written at info.
|
|
101
|
+
expect(memory.records.filter(record => record.level !== 'debug')).toEqual([])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('a multipart body the schema refuses: no field, no file byte', async () => {
|
|
105
|
+
const boundary = 'owlmeansboundary'
|
|
106
|
+
const payload = [
|
|
107
|
+
`--${boundary}`, 'Content-Disposition: form-data; name="email"', '', `${MARKER}@`,
|
|
108
|
+
`--${boundary}`, 'Content-Disposition: form-data; name="message"', '', `${MARKER} wrote this`,
|
|
109
|
+
`--${boundary}`, 'Content-Disposition: form-data; name="file"; filename="note.txt"', 'Content-Type: text/plain', '',
|
|
110
|
+
`${MARKER} file bytes`, `--${boundary}--`, '',
|
|
111
|
+
].join('\r\n')
|
|
112
|
+
const response = await server.inject({
|
|
113
|
+
method: 'POST', url: '/inquiry', payload,
|
|
114
|
+
headers: { ...headers, 'content-type': `multipart/form-data; boundary=${boundary}` },
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
expect(response.statusCode).toBe(400)
|
|
118
|
+
expect(leaks()).toEqual([])
|
|
119
|
+
expect(memory.records.some(record => record.message === 'Request refused')).toBe(true)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
test('an unparsable body and a missing route: no body, no query', async () => {
|
|
123
|
+
const broken = await server.inject({
|
|
124
|
+
method: 'POST', url: `/inquiry?token=${SECRETS[2]}`,
|
|
125
|
+
headers: { ...headers, 'content-type': 'application/json' }, payload: `{"email":"${MARKER}`,
|
|
126
|
+
})
|
|
127
|
+
const missing = await server.inject({ method: 'GET', url: `/nowhere?token=${SECRETS[2]}`, headers })
|
|
128
|
+
|
|
129
|
+
expect(broken.statusCode).toBe(400)
|
|
130
|
+
expect(missing.statusCode).toBe(404)
|
|
131
|
+
expect(leaks()).toEqual([])
|
|
132
|
+
expect(memory.records.find(record => record.message === 'Route not found')?.data)
|
|
133
|
+
.toEqual({ method: 'GET', path: '/nowhere' })
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
describe('@owlmeans/server-api — what the Fastify log adapter writes', () => {
|
|
138
|
+
const request = {
|
|
139
|
+
method: 'POST', url: `/inquiry?token=${SECRETS[2]}`, id: 'req-1',
|
|
140
|
+
headers: { ...{ authorization: `Bearer ${SECRETS[0]}`, cookie: `session=${SECRETS[1]}` }, 'x-note': MARKER },
|
|
141
|
+
body: { email: `${MARKER}@example.com` }, rawBody: `{"email":"${MARKER}@example.com"}`,
|
|
142
|
+
query: { token: SECRETS[2] },
|
|
143
|
+
}
|
|
144
|
+
const reply = { statusCode: 500, request, header: () => reply }
|
|
145
|
+
|
|
146
|
+
test('a request becomes its method and path, a reply its status, bytes their size', () => {
|
|
147
|
+
const fastifyRequest = Object.assign(Object.create({ kind: 'request' }) as object, request)
|
|
148
|
+
const fastifyReply = Object.assign(Object.create({ kind: 'reply' }) as object, reply)
|
|
149
|
+
const log = fastifyLogUtils.logger() as unknown as Record<string, (...args: unknown[]) => void>
|
|
150
|
+
log.error({ req: fastifyRequest, res: fastifyReply, err: new Error('boom') }, 'failed')
|
|
151
|
+
log.info({ busboyOptions: { headers: request.headers, limits: { files: 5 } } }, 'parsing')
|
|
152
|
+
log.warn({ body: request.body, rawBody: request.rawBody, file: new TextEncoder().encode(MARKER) }, 'dumped')
|
|
153
|
+
log.warn({ upload: new TextEncoder().encode(MARKER), url: request.url }, `Reply was already sent in "${request.url}"`)
|
|
154
|
+
|
|
155
|
+
expect(leaks()).toEqual([])
|
|
156
|
+
expect(memory.records.map(record => record.data)).toEqual([
|
|
157
|
+
{ req: { method: 'POST', path: '/inquiry' }, res: { statusCode: 500 } },
|
|
158
|
+
{ busboyOptions: { limits: { files: 5 } } },
|
|
159
|
+
{},
|
|
160
|
+
{ upload: `[Uint8Array ${MARKER.length} bytes]`, url: '/inquiry' },
|
|
161
|
+
])
|
|
162
|
+
expect(memory.records[3].message).toBe('Reply was already sent in "/inquiry"')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('the controller logs a request the default error handler answers by its status alone', () => {
|
|
166
|
+
const controller = fastifyLogUtils.controller()
|
|
167
|
+
const asRequest = request as unknown as FastifyRequest
|
|
168
|
+
controller.defaultErrorLog(new Error('fault'), asRequest, { ...reply, statusCode: 500 } as unknown as FastifyReply)
|
|
169
|
+
controller.defaultErrorLog(new Error('denied'), asRequest, { ...reply, statusCode: 403 } as unknown as FastifyReply)
|
|
170
|
+
controller.incomingRequest(asRequest, reply as unknown as FastifyReply)
|
|
171
|
+
controller.requestCompleted(null, asRequest, reply as unknown as FastifyReply)
|
|
172
|
+
|
|
173
|
+
expect(leaks()).toEqual([])
|
|
174
|
+
expect(memory.records.map(record => [record.level, record.message, record.data])).toEqual([
|
|
175
|
+
['error', 'Request failed', { method: 'POST', path: '/inquiry', status: 500 }],
|
|
176
|
+
['warn', 'Access forbidden', { message: 'denied', method: 'POST', path: '/inquiry', status: 403 }],
|
|
177
|
+
])
|
|
178
|
+
expect(memory.records[0].error?.message).toBe('fault')
|
|
179
|
+
expect(memory.records[1].event).toBe('access.forbidden')
|
|
180
|
+
})
|
|
181
|
+
})
|