@owlmeans/server-api 0.1.18-rc.8 → 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 -15
- package/build/utils/error.d.ts.map +1 -1
- package/build/utils/error.js +134 -29
- 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 +156 -29
- 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,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
|
+
})
|
package/tsconfig.json
CHANGED
package/build/.gitkeep
DELETED
|
File without changes
|