@pikku/core 0.12.71 → 0.12.72
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/CHANGELOG.md +55 -0
- package/dist/function/function-runner.js +7 -2
- package/dist/handle-error.d.ts +2 -0
- package/dist/handle-error.js +8 -2
- package/dist/schema.d.ts +24 -0
- package/dist/schema.js +46 -0
- package/dist/wirings/http/http-runner.d.ts +1 -1
- package/dist/wirings/http/http-runner.js +4 -2
- package/dist/wirings/http/http.types.d.ts +2 -0
- package/dist/wirings/http/index.d.ts +2 -1
- package/dist/wirings/http/index.js +1 -1
- package/dist/wirings/http/pikku-fetch-http-request.d.ts +11 -1
- package/dist/wirings/http/pikku-fetch-http-request.js +67 -3
- package/dist/wirings/mcp/mcp-runner.d.ts +5 -0
- package/dist/wirings/mcp/mcp-runner.js +5 -2
- package/package.json +1 -2
- package/src/function/function-runner.test.ts +75 -0
- package/src/function/function-runner.ts +15 -2
- package/src/gopass-secrets-removed.test.ts +51 -0
- package/src/handle-error.test.ts +108 -1
- package/src/handle-error.ts +10 -2
- package/src/schema.test.ts +103 -0
- package/src/schema.ts +51 -0
- package/src/wirings/http/http-runner.ts +4 -1
- package/src/wirings/http/http.types.ts +2 -0
- package/src/wirings/http/index.ts +5 -1
- package/src/wirings/http/pikku-fetch-http-request.test.ts +89 -0
- package/src/wirings/http/pikku-fetch-http-request.ts +85 -3
- package/src/wirings/mcp/mcp-runner.test.ts +40 -0
- package/src/wirings/mcp/mcp-runner.ts +11 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/src/services/gopass-secrets.ts +0 -78
package/src/handle-error.test.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { describe, test, beforeEach } from 'node:test'
|
|
2
2
|
import assert from 'node:assert'
|
|
3
3
|
import { handleHTTPError } from './handle-error.js'
|
|
4
|
-
import { addError } from './errors/error-handler.js'
|
|
4
|
+
import { addError, PikkuError } from './errors/error-handler.js'
|
|
5
5
|
import {
|
|
6
6
|
NotFoundError,
|
|
7
7
|
BadRequestError,
|
|
8
8
|
ForbiddenError,
|
|
9
|
+
MissingScopeError,
|
|
9
10
|
} from './errors/errors.js'
|
|
10
11
|
import { resetPikkuState } from './pikku-state.js'
|
|
11
12
|
|
|
@@ -399,6 +400,112 @@ describe('handleHTTPError', () => {
|
|
|
399
400
|
assert.deepStrictEqual(http._state.jsonBody, { errorId: 'tracker-str' })
|
|
400
401
|
})
|
|
401
402
|
|
|
403
|
+
test('should not leak the payload of a registered server error', () => {
|
|
404
|
+
class LeakyInternalError extends PikkuError {}
|
|
405
|
+
addError(LeakyInternalError, {
|
|
406
|
+
status: 500,
|
|
407
|
+
message: 'Something went wrong',
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
const logger = createMockLogger()
|
|
411
|
+
const http = createMockHTTP()
|
|
412
|
+
const error = new LeakyInternalError(
|
|
413
|
+
'connection to 10.0.0.1 refused'
|
|
414
|
+
) as any
|
|
415
|
+
error.payload = { connectionString: 'postgres://user:hunter2@10.0.0.1' }
|
|
416
|
+
|
|
417
|
+
handleHTTPError(
|
|
418
|
+
error,
|
|
419
|
+
http as any,
|
|
420
|
+
'tracker-leak',
|
|
421
|
+
logger as any,
|
|
422
|
+
[],
|
|
423
|
+
true,
|
|
424
|
+
false
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
assert.strictEqual(http._state.statusCode, 500)
|
|
428
|
+
assert.strictEqual(http._state.jsonBody.payload, undefined)
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
test('should not leak the raw message of a registered server error', () => {
|
|
432
|
+
class InternalMessageError extends PikkuError {}
|
|
433
|
+
addError(InternalMessageError, {
|
|
434
|
+
status: 500,
|
|
435
|
+
message: 'Something went wrong',
|
|
436
|
+
})
|
|
437
|
+
|
|
438
|
+
const logger = createMockLogger()
|
|
439
|
+
const http = createMockHTTP()
|
|
440
|
+
const error = new InternalMessageError('secret internal detail')
|
|
441
|
+
|
|
442
|
+
handleHTTPError(
|
|
443
|
+
error,
|
|
444
|
+
http as any,
|
|
445
|
+
'tracker-msg',
|
|
446
|
+
logger as any,
|
|
447
|
+
[],
|
|
448
|
+
true,
|
|
449
|
+
false
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
assert.strictEqual(http._state.statusCode, 500)
|
|
453
|
+
assert.strictEqual(http._state.jsonBody.message, 'Something went wrong')
|
|
454
|
+
})
|
|
455
|
+
|
|
456
|
+
test('should keep the payload of a client facing error', () => {
|
|
457
|
+
const logger = createMockLogger()
|
|
458
|
+
const http = createMockHTTP()
|
|
459
|
+
const error = new MissingScopeError('admin:write')
|
|
460
|
+
|
|
461
|
+
handleHTTPError(
|
|
462
|
+
error,
|
|
463
|
+
http as any,
|
|
464
|
+
'tracker-scope',
|
|
465
|
+
logger as any,
|
|
466
|
+
[],
|
|
467
|
+
true,
|
|
468
|
+
false
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
assert.strictEqual(http._state.statusCode, 403)
|
|
472
|
+
assert.deepStrictEqual(http._state.jsonBody.payload, {
|
|
473
|
+
error: 'missing_scope',
|
|
474
|
+
scope: 'admin:write',
|
|
475
|
+
})
|
|
476
|
+
assert.strictEqual(
|
|
477
|
+
http._state.jsonBody.message,
|
|
478
|
+
'Missing required scope: admin:write'
|
|
479
|
+
)
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
test('should expose server error details when exposeErrors is true', () => {
|
|
483
|
+
class ExposableInternalError extends PikkuError {}
|
|
484
|
+
addError(ExposableInternalError, {
|
|
485
|
+
status: 500,
|
|
486
|
+
message: 'Something went wrong',
|
|
487
|
+
})
|
|
488
|
+
|
|
489
|
+
const logger = createMockLogger()
|
|
490
|
+
const http = createMockHTTP()
|
|
491
|
+
const error = new ExposableInternalError('internal detail') as any
|
|
492
|
+
error.payload = { debug: true }
|
|
493
|
+
|
|
494
|
+
handleHTTPError(
|
|
495
|
+
error,
|
|
496
|
+
http as any,
|
|
497
|
+
'tracker-expose-known',
|
|
498
|
+
logger as any,
|
|
499
|
+
[],
|
|
500
|
+
true,
|
|
501
|
+
false,
|
|
502
|
+
true
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
assert.strictEqual(http._state.jsonBody.message, 'internal detail')
|
|
506
|
+
assert.deepStrictEqual(http._state.jsonBody.payload, { debug: true })
|
|
507
|
+
})
|
|
508
|
+
|
|
402
509
|
test('should handle warning log without trackerId', () => {
|
|
403
510
|
const logger = createMockLogger()
|
|
404
511
|
const http = createMockHTTP()
|
package/src/handle-error.ts
CHANGED
|
@@ -14,6 +14,8 @@ import type { PikkuHTTP } from './wirings/http/http.types.js'
|
|
|
14
14
|
* @param {number[]} logWarningsForStatusCodes - HTTP status codes to log as warnings
|
|
15
15
|
* @param {boolean} respondWith404 - Whether to respond with 404 for NotFoundError
|
|
16
16
|
* @param {boolean} bubbleError - Whether to throw the error after handling
|
|
17
|
+
* @param {boolean} exposeErrors - Whether to include internal error details (message, stack and
|
|
18
|
+
* payload of 5xx errors) in the response body. Ignored in production.
|
|
17
19
|
*/
|
|
18
20
|
export const handleHTTPError = (
|
|
19
21
|
e: any,
|
|
@@ -33,15 +35,21 @@ export const handleHTTPError = (
|
|
|
33
35
|
// Get appropriate error response
|
|
34
36
|
const errorResponse = getErrorResponse(e)
|
|
35
37
|
if (errorResponse != null) {
|
|
38
|
+
const clientFacing =
|
|
39
|
+
errorResponse.status < 500 || (exposeErrors && !isProduction())
|
|
40
|
+
|
|
36
41
|
// Set status and response body
|
|
37
42
|
http?.response?.status(errorResponse.status)
|
|
38
43
|
http?.response?.json({
|
|
39
44
|
name: e instanceof Error ? e.name : undefined,
|
|
40
45
|
message:
|
|
41
|
-
|
|
46
|
+
clientFacing &&
|
|
47
|
+
e instanceof Error &&
|
|
48
|
+
e.message &&
|
|
49
|
+
e.message !== 'An error occurred'
|
|
42
50
|
? e.message
|
|
43
51
|
: errorResponse.message,
|
|
44
|
-
payload: (e as any).payload,
|
|
52
|
+
payload: clientFacing ? (e as any).payload : undefined,
|
|
45
53
|
errorId: traceId,
|
|
46
54
|
})
|
|
47
55
|
|
package/src/schema.test.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as assert from 'assert'
|
|
|
3
3
|
import {
|
|
4
4
|
addSchema,
|
|
5
5
|
getSchema,
|
|
6
|
+
applyDefaultsFromSchema,
|
|
6
7
|
coerceTopLevelDataFromSchema,
|
|
7
8
|
validateSchema,
|
|
8
9
|
compileAllSchemas,
|
|
@@ -111,6 +112,108 @@ describe('Schema', () => {
|
|
|
111
112
|
})
|
|
112
113
|
})
|
|
113
114
|
|
|
115
|
+
describe('applyDefaultsFromSchema', () => {
|
|
116
|
+
before(() => {
|
|
117
|
+
addSchema('defaultsSchema', {
|
|
118
|
+
properties: {
|
|
119
|
+
page: { type: 'number', default: 1 },
|
|
120
|
+
limit: { type: 'number', default: 50 },
|
|
121
|
+
addons: { type: 'array', default: [] },
|
|
122
|
+
mode: { type: 'string', default: 'manual' },
|
|
123
|
+
enabled: { type: 'boolean', default: true },
|
|
124
|
+
name: { type: 'string' },
|
|
125
|
+
},
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
addSchema('noDefaultsSchema', {
|
|
129
|
+
properties: { name: { type: 'string' } },
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
addSchema('booleanPropSchema', {
|
|
133
|
+
properties: { isActive: true, page: { type: 'number', default: 1 } },
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('should fill in absent properties from their default', () => {
|
|
138
|
+
const data = applyDefaultsFromSchema('defaultsSchema', {})
|
|
139
|
+
assert.strictEqual(data.page, 1)
|
|
140
|
+
assert.strictEqual(data.limit, 50)
|
|
141
|
+
assert.strictEqual(data.mode, 'manual')
|
|
142
|
+
assert.strictEqual(data.enabled, true)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('should not overwrite a supplied value', () => {
|
|
146
|
+
const data = applyDefaultsFromSchema('defaultsSchema', {
|
|
147
|
+
page: 7,
|
|
148
|
+
limit: 200,
|
|
149
|
+
})
|
|
150
|
+
assert.strictEqual(data.page, 7)
|
|
151
|
+
assert.strictEqual(data.limit, 200)
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
// `false` and `0` are the values a truthiness check silently replaces, and
|
|
155
|
+
// the reason this tests presence rather than falsiness.
|
|
156
|
+
test('should not overwrite a supplied falsy value', () => {
|
|
157
|
+
const data = applyDefaultsFromSchema('defaultsSchema', {
|
|
158
|
+
page: 0,
|
|
159
|
+
enabled: false,
|
|
160
|
+
})
|
|
161
|
+
assert.strictEqual(data.page, 0)
|
|
162
|
+
assert.strictEqual(data.enabled, false)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
test('should fill defaults into nullish data', () => {
|
|
166
|
+
assert.strictEqual(
|
|
167
|
+
applyDefaultsFromSchema('defaultsSchema', undefined).page,
|
|
168
|
+
1
|
|
169
|
+
)
|
|
170
|
+
assert.strictEqual(
|
|
171
|
+
applyDefaultsFromSchema('defaultsSchema', null).limit,
|
|
172
|
+
50
|
|
173
|
+
)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test('should leave properties without a default absent', () => {
|
|
177
|
+
const data = applyDefaultsFromSchema('defaultsSchema', {})
|
|
178
|
+
assert.ok(!('name' in data))
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// Two requests sharing one array default would let the first request's
|
|
182
|
+
// pushes show up in the second.
|
|
183
|
+
test('should clone object and array defaults per call', () => {
|
|
184
|
+
const first = applyDefaultsFromSchema('defaultsSchema', {})
|
|
185
|
+
const second = applyDefaultsFromSchema('defaultsSchema', {})
|
|
186
|
+
first.addons.push('leaked')
|
|
187
|
+
assert.deepStrictEqual(second.addons, [])
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test('should return the same data when the schema has no defaults', () => {
|
|
191
|
+
const data = { name: 'x' }
|
|
192
|
+
assert.strictEqual(
|
|
193
|
+
applyDefaultsFromSchema('noDefaultsSchema', data),
|
|
194
|
+
data
|
|
195
|
+
)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('should return data untouched for an unknown schema', () => {
|
|
199
|
+
const data = { name: 'x' }
|
|
200
|
+
assert.strictEqual(applyDefaultsFromSchema('nonExistent', data), data)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('should leave a primitive body for the validator to reject', () => {
|
|
204
|
+
assert.strictEqual(
|
|
205
|
+
applyDefaultsFromSchema('defaultsSchema', 'nope'),
|
|
206
|
+
'nope'
|
|
207
|
+
)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
test('should skip boolean schema properties', () => {
|
|
211
|
+
const data = applyDefaultsFromSchema('booleanPropSchema', {})
|
|
212
|
+
assert.strictEqual(data.page, 1)
|
|
213
|
+
assert.ok(!('isActive' in data))
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
114
217
|
describe('validateSchema', () => {
|
|
115
218
|
beforeEach(() => {
|
|
116
219
|
resetPikkuState()
|
package/src/schema.ts
CHANGED
|
@@ -94,6 +94,57 @@ const validateAllSchemasLoaded = (
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Fill in absent top-level properties from their schema `default`.
|
|
99
|
+
*
|
|
100
|
+
* A `default` reaches the generated JSON Schema and keeps the property out of
|
|
101
|
+
* `required`, so omitting it validates — but nothing was ever filling it in.
|
|
102
|
+
* JSON Schema validators are pure by specification and none of the ones Pikku
|
|
103
|
+
* ships with (`@cfworker/json-schema`, and Ajv unless `useDefaults` is set)
|
|
104
|
+
* annotate the instance, so the function received `undefined` for a property
|
|
105
|
+
* its generated type declares as present. That is the worst shape a mismatch
|
|
106
|
+
* can take: validation permits the omission, the type says the value is there,
|
|
107
|
+
* and the body reads `undefined`.
|
|
108
|
+
*
|
|
109
|
+
* Applied unconditionally rather than alongside `coerceTopLevelDataFromSchema`,
|
|
110
|
+
* whose `coerceDataFromSchema` flag is about decoding transport-encoded values
|
|
111
|
+
* (a query string's `"1,2"` into an array). Defaults are a property of the
|
|
112
|
+
* schema, not of how the call arrived, so gating them on that flag would apply
|
|
113
|
+
* them over HTTP and skip them on a direct RPC invocation.
|
|
114
|
+
*
|
|
115
|
+
* Returns the data to use, which is a new object only when defaults had to be
|
|
116
|
+
* added to a nullish input — a call made with no arguments at all still gets
|
|
117
|
+
* them. Values are cloned so an object or array default (`[]`, `{}`) is never
|
|
118
|
+
* shared as one mutable instance across every request.
|
|
119
|
+
*/
|
|
120
|
+
export const applyDefaultsFromSchema = (
|
|
121
|
+
schemaName: string,
|
|
122
|
+
data: any,
|
|
123
|
+
packageName: string | null = null
|
|
124
|
+
) => {
|
|
125
|
+
const schema = pikkuState(packageName, 'misc', 'schemas').get(schemaName)
|
|
126
|
+
if (!schema?.properties) return data
|
|
127
|
+
|
|
128
|
+
// A primitive body cannot carry named properties; leave it for the validator
|
|
129
|
+
// to reject rather than reshaping it into something that would pass.
|
|
130
|
+
if (data != null && typeof data !== 'object') return data
|
|
131
|
+
|
|
132
|
+
let result = data
|
|
133
|
+
for (const key in schema.properties) {
|
|
134
|
+
const property = schema.properties[key]
|
|
135
|
+
if (typeof property === 'boolean' || !('default' in property)) {
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
// Allocated only once a default is actually found, so a schema without any
|
|
139
|
+
// leaves the caller's data (and its absence) exactly as it was.
|
|
140
|
+
result ??= {}
|
|
141
|
+
if (result[key] === undefined) {
|
|
142
|
+
result[key] = structuredClone(property.default)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return result
|
|
146
|
+
}
|
|
147
|
+
|
|
97
148
|
export const coerceTopLevelDataFromSchema = (
|
|
98
149
|
schemaName: string,
|
|
99
150
|
data: any,
|
|
@@ -531,6 +531,7 @@ export const fetchData = async <In, Out>(
|
|
|
531
531
|
exposeErrors = !isProduction(),
|
|
532
532
|
generateRequestId,
|
|
533
533
|
traceId: externalTraceId,
|
|
534
|
+
maxBodySize,
|
|
534
535
|
}: RunHTTPWiringOptions = {}
|
|
535
536
|
): Promise<Out | void> => {
|
|
536
537
|
const singletonServices = getSingletonServices()
|
|
@@ -540,7 +541,9 @@ export const fetchData = async <In, Out>(
|
|
|
540
541
|
|
|
541
542
|
// Combine the request and response into one wire object
|
|
542
543
|
const pikkuRequest =
|
|
543
|
-
request instanceof Request
|
|
544
|
+
request instanceof Request
|
|
545
|
+
? new PikkuFetchHTTPRequest(request, { maxBodySize })
|
|
546
|
+
: request
|
|
544
547
|
|
|
545
548
|
// Resolve traceId: external (e.g. CF-Ray) > x-request-id header > generated
|
|
546
549
|
let requestId: string | null = externalTraceId ?? null
|
|
@@ -38,6 +38,8 @@ export type RunHTTPWiringOptions = Partial<{
|
|
|
38
38
|
generateRequestId: () => string
|
|
39
39
|
/** Pre-resolved trace ID (e.g. CF-Ray). Falls back to x-request-id header or generated ID. */
|
|
40
40
|
traceId: string
|
|
41
|
+
/** Maximum request body size in bytes, applied when pikku wraps a fetch `Request`. */
|
|
42
|
+
maxBodySize: number
|
|
41
43
|
}>
|
|
42
44
|
|
|
43
45
|
/**
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
PikkuFetchHTTPRequest,
|
|
3
|
+
DEFAULT_MAX_BODY_SIZE,
|
|
4
|
+
} from './pikku-fetch-http-request.js'
|
|
5
|
+
export type { PikkuFetchHTTPRequestOptions } from './pikku-fetch-http-request.js'
|
|
2
6
|
export { PikkuFetchHTTPResponse } from './pikku-fetch-http-response.js'
|
|
3
7
|
export { logRoutes } from './log-http-routes.js'
|
|
4
8
|
|
|
@@ -221,6 +221,95 @@ test('data() throws on boolean conflict', async () => {
|
|
|
221
221
|
|
|
222
222
|
// --- Safe fallback: only one source
|
|
223
223
|
|
|
224
|
+
// --- Body size limits
|
|
225
|
+
|
|
226
|
+
const streamedRequest = (chunks) =>
|
|
227
|
+
new Request('http://localhost', {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: { 'Content-Type': 'application/json' },
|
|
230
|
+
body: new ReadableStream({
|
|
231
|
+
start(controller) {
|
|
232
|
+
for (const chunk of chunks) {
|
|
233
|
+
controller.enqueue(new TextEncoder().encode(chunk))
|
|
234
|
+
}
|
|
235
|
+
controller.close()
|
|
236
|
+
},
|
|
237
|
+
}),
|
|
238
|
+
duplex: 'half',
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('arrayBuffer() rejects a body larger than the configured limit', async () => {
|
|
242
|
+
const req = new Request('http://localhost', {
|
|
243
|
+
method: 'POST',
|
|
244
|
+
headers: { 'Content-Type': 'application/octet-stream' },
|
|
245
|
+
body: 'x'.repeat(64),
|
|
246
|
+
})
|
|
247
|
+
const pikkuReq = new PikkuFetchHTTPRequest(req, { maxBodySize: 16 })
|
|
248
|
+
await assert.rejects(async () => await pikkuReq.arrayBuffer(), {
|
|
249
|
+
name: 'PayloadTooLargeError',
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
test('arrayBuffer() rejects an oversized body with no content-length header', async () => {
|
|
254
|
+
const req = streamedRequest(['x'.repeat(32), 'y'.repeat(32)])
|
|
255
|
+
assert.equal(req.headers.get('content-length'), null)
|
|
256
|
+
const pikkuReq = new PikkuFetchHTTPRequest(req, { maxBodySize: 16 })
|
|
257
|
+
await assert.rejects(async () => await pikkuReq.arrayBuffer(), {
|
|
258
|
+
name: 'PayloadTooLargeError',
|
|
259
|
+
})
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test('arrayBuffer() rejects a body whose content-length under-reports its size', async () => {
|
|
263
|
+
const req = new Request('http://localhost', {
|
|
264
|
+
method: 'POST',
|
|
265
|
+
headers: {
|
|
266
|
+
'Content-Type': 'application/json',
|
|
267
|
+
'Content-Length': '4',
|
|
268
|
+
},
|
|
269
|
+
body: new ReadableStream({
|
|
270
|
+
start(controller) {
|
|
271
|
+
controller.enqueue(new TextEncoder().encode('z'.repeat(128)))
|
|
272
|
+
controller.close()
|
|
273
|
+
},
|
|
274
|
+
}),
|
|
275
|
+
duplex: 'half',
|
|
276
|
+
})
|
|
277
|
+
const pikkuReq = new PikkuFetchHTTPRequest(req, { maxBodySize: 16 })
|
|
278
|
+
await assert.rejects(async () => await pikkuReq.arrayBuffer(), {
|
|
279
|
+
name: 'PayloadTooLargeError',
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('data() surfaces PayloadTooLargeError rather than wrapping it', async () => {
|
|
284
|
+
const req = createRequest(
|
|
285
|
+
'POST',
|
|
286
|
+
'http://localhost',
|
|
287
|
+
{ padding: 'x'.repeat(256) },
|
|
288
|
+
{ 'Content-Type': 'application/json' }
|
|
289
|
+
)
|
|
290
|
+
const pikkuReq = new PikkuFetchHTTPRequest(req, { maxBodySize: 16 })
|
|
291
|
+
await assert.rejects(async () => await pikkuReq.data(), {
|
|
292
|
+
name: 'PayloadTooLargeError',
|
|
293
|
+
})
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
test('data() accepts a body within the configured limit', async () => {
|
|
297
|
+
const req = createRequest(
|
|
298
|
+
'POST',
|
|
299
|
+
'http://localhost',
|
|
300
|
+
{ ok: true },
|
|
301
|
+
{ 'Content-Type': 'application/json' }
|
|
302
|
+
)
|
|
303
|
+
const pikkuReq = new PikkuFetchHTTPRequest(req, { maxBodySize: 1024 })
|
|
304
|
+
assert.deepEqual(await pikkuReq.data(), { ok: true })
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test('data() accepts a streamed body within the default limit', async () => {
|
|
308
|
+
const req = streamedRequest(['{"ok"', ':true}'])
|
|
309
|
+
const pikkuReq = new PikkuFetchHTTPRequest(req)
|
|
310
|
+
assert.deepEqual(await pikkuReq.data(), { ok: true })
|
|
311
|
+
})
|
|
312
|
+
|
|
224
313
|
test('data() works when only body has values', async () => {
|
|
225
314
|
const req = createRequest(
|
|
226
315
|
'POST',
|
|
@@ -1,7 +1,22 @@
|
|
|
1
1
|
import { parse as parseQuery } from 'picoquery'
|
|
2
2
|
import { parse as parseCookie } from 'cookie'
|
|
3
3
|
import type { HTTPMethod, PikkuHTTPRequest, PikkuQuery } from './http.types.js'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
PayloadTooLargeError,
|
|
6
|
+
UnprocessableContentError,
|
|
7
|
+
} from '../../errors/errors.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The largest request body read into memory when no limit is configured. Ample
|
|
11
|
+
* for JSON APIs and typical uploads while keeping a single request's memory
|
|
12
|
+
* footprint bounded.
|
|
13
|
+
*/
|
|
14
|
+
export const DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024
|
|
15
|
+
|
|
16
|
+
export type PikkuFetchHTTPRequestOptions = Partial<{
|
|
17
|
+
/** Maximum request body size in bytes. Defaults to {@link DEFAULT_MAX_BODY_SIZE}. */
|
|
18
|
+
maxBodySize: number
|
|
19
|
+
}>
|
|
5
20
|
|
|
6
21
|
/**
|
|
7
22
|
* Abstract class representing a pikku request.
|
|
@@ -17,9 +32,14 @@ export class PikkuFetchHTTPRequest<
|
|
|
17
32
|
#rawBodyText: string | undefined
|
|
18
33
|
#rawBodyBuffer: ArrayBuffer | undefined
|
|
19
34
|
#rawBufferPromise: Promise<ArrayBuffer> | undefined
|
|
35
|
+
#maxBodySize: number
|
|
20
36
|
|
|
21
|
-
constructor(
|
|
37
|
+
constructor(
|
|
38
|
+
private request: Request,
|
|
39
|
+
{ maxBodySize = DEFAULT_MAX_BODY_SIZE }: PikkuFetchHTTPRequestOptions = {}
|
|
40
|
+
) {
|
|
22
41
|
this.#url = new URL(request.url)
|
|
42
|
+
this.#maxBodySize = maxBodySize
|
|
23
43
|
}
|
|
24
44
|
|
|
25
45
|
public method(): HTTPMethod {
|
|
@@ -78,13 +98,72 @@ export class PikkuFetchHTTPRequest<
|
|
|
78
98
|
)
|
|
79
99
|
return this.#rawBufferPromise
|
|
80
100
|
}
|
|
81
|
-
this.#rawBufferPromise = this
|
|
101
|
+
this.#rawBufferPromise = this.#readBoundedBuffer().then((buf) => {
|
|
82
102
|
this.#rawBodyBuffer = buf
|
|
83
103
|
return buf
|
|
84
104
|
})
|
|
85
105
|
return this.#rawBufferPromise
|
|
86
106
|
}
|
|
87
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Reads the body while refusing to buffer more than `maxBodySize` bytes. The
|
|
110
|
+
* declared `content-length` is rejected up front so an oversized body is never
|
|
111
|
+
* transferred, and the stream is measured as it arrives because that header is
|
|
112
|
+
* both optional and attacker-controlled.
|
|
113
|
+
*/
|
|
114
|
+
async #readBoundedBuffer(): Promise<ArrayBuffer> {
|
|
115
|
+
const contentLength = this.request.headers.get('content-length')
|
|
116
|
+
if (contentLength !== null) {
|
|
117
|
+
const declaredSize = Number(contentLength)
|
|
118
|
+
if (Number.isFinite(declaredSize) && declaredSize > this.#maxBodySize) {
|
|
119
|
+
throw this.#payloadTooLarge()
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const stream = this.request.body
|
|
124
|
+
if (stream === null) {
|
|
125
|
+
const buffer = await this.request.arrayBuffer()
|
|
126
|
+
if (buffer.byteLength > this.#maxBodySize) {
|
|
127
|
+
throw this.#payloadTooLarge()
|
|
128
|
+
}
|
|
129
|
+
return buffer
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const reader = stream.getReader()
|
|
133
|
+
const chunks: Uint8Array[] = []
|
|
134
|
+
let size = 0
|
|
135
|
+
try {
|
|
136
|
+
while (true) {
|
|
137
|
+
const { done, value } = await reader.read()
|
|
138
|
+
if (done) {
|
|
139
|
+
break
|
|
140
|
+
}
|
|
141
|
+
size += value.byteLength
|
|
142
|
+
if (size > this.#maxBodySize) {
|
|
143
|
+
await reader.cancel()
|
|
144
|
+
throw this.#payloadTooLarge()
|
|
145
|
+
}
|
|
146
|
+
chunks.push(value)
|
|
147
|
+
}
|
|
148
|
+
} finally {
|
|
149
|
+
reader.releaseLock()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const body = new Uint8Array(size)
|
|
153
|
+
let offset = 0
|
|
154
|
+
for (const chunk of chunks) {
|
|
155
|
+
body.set(chunk, offset)
|
|
156
|
+
offset += chunk.byteLength
|
|
157
|
+
}
|
|
158
|
+
return body.buffer as ArrayBuffer
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#payloadTooLarge(): PayloadTooLargeError {
|
|
162
|
+
return new PayloadTooLargeError(
|
|
163
|
+
`Request body exceeds the maximum size of ${this.#maxBodySize} bytes`
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
88
167
|
async #readRawText(): Promise<string> {
|
|
89
168
|
if (this.#rawBodyText !== undefined) {
|
|
90
169
|
return this.#rawBodyText
|
|
@@ -214,6 +293,9 @@ export class PikkuFetchHTTPRequest<
|
|
|
214
293
|
)
|
|
215
294
|
}
|
|
216
295
|
} catch (e) {
|
|
296
|
+
if (e instanceof PayloadTooLargeError) {
|
|
297
|
+
throw e
|
|
298
|
+
}
|
|
217
299
|
throw new UnprocessableContentError(`Error parsing body: ${e}`)
|
|
218
300
|
}
|
|
219
301
|
return body
|
|
@@ -329,6 +329,46 @@ describe('runMCPTool', () => {
|
|
|
329
329
|
}
|
|
330
330
|
)
|
|
331
331
|
})
|
|
332
|
+
|
|
333
|
+
test('withholds the error message and stack from internal MCP errors in production', async () => {
|
|
334
|
+
pikkuState(null, 'mcp', 'toolsMeta').prodBoom = {
|
|
335
|
+
name: 'prodBoom',
|
|
336
|
+
title: 'Prod Boom',
|
|
337
|
+
description: 'Prod Boom',
|
|
338
|
+
pikkuFuncId: 'prodBoomFunc',
|
|
339
|
+
inputSchema: null,
|
|
340
|
+
outputSchema: null,
|
|
341
|
+
} as never
|
|
342
|
+
registerFunction('prodBoomFunc', async () => {
|
|
343
|
+
throw new Error('secret internal detail')
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
const originalNodeEnv = process.env.NODE_ENV
|
|
347
|
+
process.env.NODE_ENV = 'production'
|
|
348
|
+
try {
|
|
349
|
+
await assert.rejects(
|
|
350
|
+
() =>
|
|
351
|
+
runMCPTool(
|
|
352
|
+
{ jsonrpc: '2.0', id: 'prod-boom-1', params: {} },
|
|
353
|
+
{ mcp: mcpWire as never },
|
|
354
|
+
'prodBoom'
|
|
355
|
+
),
|
|
356
|
+
(error: unknown) => {
|
|
357
|
+
assert.ok(error instanceof MCPError)
|
|
358
|
+
assert.equal(error.error.code, -32603)
|
|
359
|
+
assert.equal(error.error.message, 'Internal error')
|
|
360
|
+
assert.equal(error.error.data, undefined)
|
|
361
|
+
return true
|
|
362
|
+
}
|
|
363
|
+
)
|
|
364
|
+
} finally {
|
|
365
|
+
if (originalNodeEnv === undefined) {
|
|
366
|
+
delete process.env.NODE_ENV
|
|
367
|
+
} else {
|
|
368
|
+
process.env.NODE_ENV = originalNodeEnv
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
})
|
|
332
372
|
})
|
|
333
373
|
|
|
334
374
|
describe('runMCPPrompt', () => {
|
|
@@ -12,6 +12,7 @@ import type {
|
|
|
12
12
|
CorePikkuFunctionSessionless,
|
|
13
13
|
} from '../../function/functions.types.js'
|
|
14
14
|
import { getErrorResponse } from '../../errors/error-handler.js'
|
|
15
|
+
import { isProduction } from '../../env.js'
|
|
15
16
|
import { closeWireServices } from '../../utils.js'
|
|
16
17
|
import {
|
|
17
18
|
pikkuState,
|
|
@@ -35,6 +36,11 @@ export class MCPError extends Error {
|
|
|
35
36
|
|
|
36
37
|
export type RunMCPEndpointParams<Tools extends string = any> = {
|
|
37
38
|
mcp?: PikkuMCP<Tools>
|
|
39
|
+
/**
|
|
40
|
+
* Surface the error message + stack on unexpected internal errors.
|
|
41
|
+
* Defaults to enabled outside of production.
|
|
42
|
+
*/
|
|
43
|
+
exposeErrors?: boolean
|
|
38
44
|
}
|
|
39
45
|
|
|
40
46
|
export type JsonRpcError = {
|
|
@@ -187,7 +193,7 @@ async function runMCPPikkuFunc(
|
|
|
187
193
|
name: string,
|
|
188
194
|
mcp: CoreMCPResource | CoreMCPPrompt | undefined,
|
|
189
195
|
pikkuFuncId: string | undefined,
|
|
190
|
-
{ mcp: mcpWire }: RunMCPEndpointParams
|
|
196
|
+
{ mcp: mcpWire, exposeErrors = !isProduction() }: RunMCPEndpointParams
|
|
191
197
|
): Promise<JsonRpcResponse> {
|
|
192
198
|
const singletonServices = getSingletonServices()
|
|
193
199
|
const createWireServices = getCreateWireServices()
|
|
@@ -289,7 +295,10 @@ async function runMCPPikkuFunc(
|
|
|
289
295
|
id: request.id,
|
|
290
296
|
code: -32603,
|
|
291
297
|
message: 'Internal error',
|
|
292
|
-
data:
|
|
298
|
+
data:
|
|
299
|
+
exposeErrors && !isProduction() && e instanceof Error
|
|
300
|
+
? { message: e.message, stack: e.stack }
|
|
301
|
+
: undefined,
|
|
293
302
|
})
|
|
294
303
|
}
|
|
295
304
|
} finally {
|