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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/server-api",
3
- "version": "0.1.18-rc.52",
3
+ "version": "0.1.18-rc.54",
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.48",
37
- "@owlmeans/auth": "^0.1.18-rc.40",
38
- "@owlmeans/auth-common": "^0.1.18-rc.48",
36
+ "@owlmeans/api": "^0.1.18-rc.50",
37
+ "@owlmeans/auth": "^0.1.18-rc.41",
38
+ "@owlmeans/auth-common": "^0.1.18-rc.50",
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.2",
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.43",
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 { fastifyLogger } from './utils/log.js'
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`. Fastify's own request/response lines are dropped by the
45
- * adapter: a request is a debug record written once from `onResponse` (below), and a failed one is
46
- * classified by `handleError`, so an info-level process is not flooded with every call it serves.
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: fastifyLogger(http),
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
- method: request.method, path: request.url.split('?')[0], status: reply.statusCode,
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()
@@ -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 { logger } from '@owlmeans/log'
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
- logFailure(error, reply, serialized.status, serialized.incidentId)
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 type { FastifyBaseLogger } from 'fastify'
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
- 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 }),
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
- return adapter as unknown as FastifyBaseLogger
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 { fastifyLogger } from '../src/utils/log.js'
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('drops the request pair, lowers the listening and not-found lines, keeps the rest', () => {
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[4].error?.message).toBe('boom')
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
+ })