@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/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,58 @@
|
|
|
1
|
+
## 0.12.72
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 384e484: Apply schema defaults, which nothing was ever filling in
|
|
6
|
+
|
|
7
|
+
A `default` on an input property reaches the generated JSON Schema and keeps
|
|
8
|
+
that property out of `required`, so a call that omits it validates. Nothing
|
|
9
|
+
then filled it in: JSON Schema validators are pure by specification, and
|
|
10
|
+
neither `@cfworker/json-schema` nor Ajv (without `useDefaults`) annotates the
|
|
11
|
+
instance being checked. The function received `undefined` for a property its
|
|
12
|
+
own generated type declares as present.
|
|
13
|
+
|
|
14
|
+
That is the worst shape the mismatch can take. Validation permits the omission,
|
|
15
|
+
the type promises a value, and the body reads `undefined` — so it surfaces far
|
|
16
|
+
from its cause, as `const offset = (page - 1) * limit` evaluating to `NaN` and
|
|
17
|
+
`.limit(undefined)` reaching the database on a paginated call made with no
|
|
18
|
+
arguments.
|
|
19
|
+
|
|
20
|
+
Defaults are now filled in before validation, on every path. Deliberately not
|
|
21
|
+
gated on `coerceDataFromSchema`, the flag guarding the neighbouring coercion
|
|
22
|
+
step: that flag is about decoding transport-encoded values (a query string's
|
|
23
|
+
`"1,2"` into an array) and is absent on a direct RPC invocation. A default
|
|
24
|
+
belongs to the schema rather than to the transport a call arrived on, so
|
|
25
|
+
gating it there would fill defaults over HTTP and skip them on RPC.
|
|
26
|
+
|
|
27
|
+
Filling is by presence rather than truthiness, so a supplied `0` or `false`
|
|
28
|
+
survives, and a call made with no arguments at all still gets its defaults.
|
|
29
|
+
Values are cloned, because an object or array default would otherwise be a
|
|
30
|
+
single mutable instance shared by every request in the process — one request's
|
|
31
|
+
`push` showing up in the next.
|
|
32
|
+
|
|
33
|
+
Nothing needs to change in generated types or call sites: both were already
|
|
34
|
+
written as though defaults worked. This makes them true.
|
|
35
|
+
|
|
36
|
+
- b5a73fb: fix: stop leaking internal error detail and bound the request body size
|
|
37
|
+
|
|
38
|
+
HTTP error responses no longer forward an error's `payload` or its raw `message` for
|
|
39
|
+
registered 5xx errors — those responses carry the registered error message instead, so an
|
|
40
|
+
internal error that happens to hold a `payload` cannot leak it to the client. Errors
|
|
41
|
+
registered with a 4xx status keep their message and payload, and `exposeErrors` still
|
|
42
|
+
surfaces the full detail outside production.
|
|
43
|
+
|
|
44
|
+
`PikkuFetchHTTPRequest` now caps how much of a request body it buffers, rejecting the
|
|
45
|
+
declared `content-length` up front and measuring the stream as it arrives so a lying or
|
|
46
|
+
absent header cannot exhaust memory. Exceeding the limit throws `PayloadTooLargeError`
|
|
47
|
+
(413). The ceiling defaults to 10MB and is configurable via the new `maxBodySize` option on
|
|
48
|
+
the constructor and on `RunHTTPWiringOptions`.
|
|
49
|
+
|
|
50
|
+
- 6be5ab0: Security hardening: removed the gopass secret service and stopped MCP internal errors leaking stack traces.
|
|
51
|
+
|
|
52
|
+
**Breaking:** `GopassSecretService` and the `@pikku/core/services/gopass-secrets` subpath export are gone. The service shelled out to the `gopass` binary and its key validation accepted `../`, so a caller-supplied key could traverse out of the configured prefix namespace and read secrets outside it. Rather than harden a shell-out that few projects used, the service is removed. Anyone importing it should implement `SecretService` against their own secret backend. Pre-0.13 breaking changes still ship as a patch.
|
|
53
|
+
|
|
54
|
+
MCP internal errors (JSON-RPC `-32603`) previously always attached `data: { message, stack }`, handing any MCP client an internal stack trace. That payload is now gated on `exposeErrors`, which defaults to `!isProduction()` — the same convention `handleHTTPError` already uses. In production a client receives a bare `Internal error` with no `message` and no `stack`. `RunMCPEndpointParams` accepts an explicit `exposeErrors` to suppress the detail outside production as well; it cannot force the detail on in production, because the check is `exposeErrors && !isProduction()` — again matching `handleHTTPError`.
|
|
55
|
+
|
|
1
56
|
## 0.12.71
|
|
2
57
|
|
|
3
58
|
### Patch Changes
|
|
@@ -2,7 +2,7 @@ import { runMiddleware, combineMiddleware } from '../middleware-runner.js';
|
|
|
2
2
|
import { combineChannelMiddleware, wrapChannelWithMiddleware, } from '../wirings/channel/channel-middleware-runner.js';
|
|
3
3
|
import { runPermissions } from '../permissions.js';
|
|
4
4
|
import { pikkuState } from '../pikku-state.js';
|
|
5
|
-
import { coerceTopLevelDataFromSchema, validateSchema } from '../schema.js';
|
|
5
|
+
import { applyDefaultsFromSchema, coerceTopLevelDataFromSchema, validateSchema, } from '../schema.js';
|
|
6
6
|
import { parseVersionedId } from '../version.js';
|
|
7
7
|
import { PikkuSessionService } from '../services/user-session-service.js';
|
|
8
8
|
import { ForbiddenError, ReadonlySessionError } from '../errors/errors.js';
|
|
@@ -166,10 +166,15 @@ export const runPikkuFunc = async (wireType, wireId, funcName, { singletonServic
|
|
|
166
166
|
// evaluates the function's own OR-groups against request data.
|
|
167
167
|
verifyScopes(funcConfig.scopes ?? funcMeta.scopes, session);
|
|
168
168
|
// Evaluate the data from the lazy function
|
|
169
|
-
|
|
169
|
+
let actualData = await data();
|
|
170
170
|
// Validate and coerce data if schema is defined
|
|
171
171
|
const inputSchemaName = funcMeta.inputSchemaName;
|
|
172
172
|
if (inputSchemaName) {
|
|
173
|
+
// Fill in schema defaults before anything reads the data. Unconditional:
|
|
174
|
+
// a default belongs to the schema, not to the transport the call arrived
|
|
175
|
+
// on. Runs before coercion so a defaulted value and a supplied one are
|
|
176
|
+
// treated identically from here on.
|
|
177
|
+
actualData = applyDefaultsFromSchema(inputSchemaName, actualData, packageName);
|
|
173
178
|
// Coerce (top level) data types before validation (e.g. string→array, string→date)
|
|
174
179
|
if (coerceDataFromSchema) {
|
|
175
180
|
coerceTopLevelDataFromSchema(inputSchemaName, actualData, packageName);
|
package/dist/handle-error.d.ts
CHANGED
|
@@ -10,5 +10,7 @@ import type { PikkuHTTP } from './wirings/http/http.types.js';
|
|
|
10
10
|
* @param {number[]} logWarningsForStatusCodes - HTTP status codes to log as warnings
|
|
11
11
|
* @param {boolean} respondWith404 - Whether to respond with 404 for NotFoundError
|
|
12
12
|
* @param {boolean} bubbleError - Whether to throw the error after handling
|
|
13
|
+
* @param {boolean} exposeErrors - Whether to include internal error details (message, stack and
|
|
14
|
+
* payload of 5xx errors) in the response body. Ignored in production.
|
|
13
15
|
*/
|
|
14
16
|
export declare const handleHTTPError: (e: any, http: PikkuHTTP | undefined, traceId: string | undefined, logger: Logger, logWarningsForStatusCodes: number[], respondWith404: boolean, bubbleError: boolean, exposeErrors?: boolean) => void;
|
package/dist/handle-error.js
CHANGED
|
@@ -11,6 +11,8 @@ import { NotFoundError } from './errors/errors.js';
|
|
|
11
11
|
* @param {number[]} logWarningsForStatusCodes - HTTP status codes to log as warnings
|
|
12
12
|
* @param {boolean} respondWith404 - Whether to respond with 404 for NotFoundError
|
|
13
13
|
* @param {boolean} bubbleError - Whether to throw the error after handling
|
|
14
|
+
* @param {boolean} exposeErrors - Whether to include internal error details (message, stack and
|
|
15
|
+
* payload of 5xx errors) in the response body. Ignored in production.
|
|
14
16
|
*/
|
|
15
17
|
export const handleHTTPError = (e, http, traceId, logger, logWarningsForStatusCodes, respondWith404, bubbleError, exposeErrors = false) => {
|
|
16
18
|
// Skip 404 handling if configured to do so
|
|
@@ -20,14 +22,18 @@ export const handleHTTPError = (e, http, traceId, logger, logWarningsForStatusCo
|
|
|
20
22
|
// Get appropriate error response
|
|
21
23
|
const errorResponse = getErrorResponse(e);
|
|
22
24
|
if (errorResponse != null) {
|
|
25
|
+
const clientFacing = errorResponse.status < 500 || (exposeErrors && !isProduction());
|
|
23
26
|
// Set status and response body
|
|
24
27
|
http?.response?.status(errorResponse.status);
|
|
25
28
|
http?.response?.json({
|
|
26
29
|
name: e instanceof Error ? e.name : undefined,
|
|
27
|
-
message:
|
|
30
|
+
message: clientFacing &&
|
|
31
|
+
e instanceof Error &&
|
|
32
|
+
e.message &&
|
|
33
|
+
e.message !== 'An error occurred'
|
|
28
34
|
? e.message
|
|
29
35
|
: errorResponse.message,
|
|
30
|
-
payload: e.payload,
|
|
36
|
+
payload: clientFacing ? e.payload : undefined,
|
|
31
37
|
errorId: traceId,
|
|
32
38
|
});
|
|
33
39
|
// Log certain status codes as warnings
|
package/dist/schema.d.ts
CHANGED
|
@@ -21,5 +21,29 @@ export declare const getSchema: (name: string, packageName?: string | null) => R
|
|
|
21
21
|
* @param logger - A logger for logging information.
|
|
22
22
|
*/
|
|
23
23
|
export declare const compileAllSchemas: (logger: Logger, schemaService?: SchemaService) => void;
|
|
24
|
+
/**
|
|
25
|
+
* Fill in absent top-level properties from their schema `default`.
|
|
26
|
+
*
|
|
27
|
+
* A `default` reaches the generated JSON Schema and keeps the property out of
|
|
28
|
+
* `required`, so omitting it validates — but nothing was ever filling it in.
|
|
29
|
+
* JSON Schema validators are pure by specification and none of the ones Pikku
|
|
30
|
+
* ships with (`@cfworker/json-schema`, and Ajv unless `useDefaults` is set)
|
|
31
|
+
* annotate the instance, so the function received `undefined` for a property
|
|
32
|
+
* its generated type declares as present. That is the worst shape a mismatch
|
|
33
|
+
* can take: validation permits the omission, the type says the value is there,
|
|
34
|
+
* and the body reads `undefined`.
|
|
35
|
+
*
|
|
36
|
+
* Applied unconditionally rather than alongside `coerceTopLevelDataFromSchema`,
|
|
37
|
+
* whose `coerceDataFromSchema` flag is about decoding transport-encoded values
|
|
38
|
+
* (a query string's `"1,2"` into an array). Defaults are a property of the
|
|
39
|
+
* schema, not of how the call arrived, so gating them on that flag would apply
|
|
40
|
+
* them over HTTP and skip them on a direct RPC invocation.
|
|
41
|
+
*
|
|
42
|
+
* Returns the data to use, which is a new object only when defaults had to be
|
|
43
|
+
* added to a nullish input — a call made with no arguments at all still gets
|
|
44
|
+
* them. Values are cloned so an object or array default (`[]`, `{}`) is never
|
|
45
|
+
* shared as one mutable instance across every request.
|
|
46
|
+
*/
|
|
47
|
+
export declare const applyDefaultsFromSchema: (schemaName: string, data: any, packageName?: string | null) => any;
|
|
24
48
|
export declare const coerceTopLevelDataFromSchema: (schemaName: string, data: any, packageName?: string | null) => void;
|
|
25
49
|
export declare const validateSchema: (logger: Logger, schemaService: SchemaService | undefined, schemaName: string | undefined | null, data: any, packageName?: string | null) => Promise<void>;
|
package/dist/schema.js
CHANGED
|
@@ -65,6 +65,52 @@ const validateAllSchemasLoaded = (logger, schemaService) => {
|
|
|
65
65
|
logger.info('All schemas loaded');
|
|
66
66
|
}
|
|
67
67
|
};
|
|
68
|
+
/**
|
|
69
|
+
* Fill in absent top-level properties from their schema `default`.
|
|
70
|
+
*
|
|
71
|
+
* A `default` reaches the generated JSON Schema and keeps the property out of
|
|
72
|
+
* `required`, so omitting it validates — but nothing was ever filling it in.
|
|
73
|
+
* JSON Schema validators are pure by specification and none of the ones Pikku
|
|
74
|
+
* ships with (`@cfworker/json-schema`, and Ajv unless `useDefaults` is set)
|
|
75
|
+
* annotate the instance, so the function received `undefined` for a property
|
|
76
|
+
* its generated type declares as present. That is the worst shape a mismatch
|
|
77
|
+
* can take: validation permits the omission, the type says the value is there,
|
|
78
|
+
* and the body reads `undefined`.
|
|
79
|
+
*
|
|
80
|
+
* Applied unconditionally rather than alongside `coerceTopLevelDataFromSchema`,
|
|
81
|
+
* whose `coerceDataFromSchema` flag is about decoding transport-encoded values
|
|
82
|
+
* (a query string's `"1,2"` into an array). Defaults are a property of the
|
|
83
|
+
* schema, not of how the call arrived, so gating them on that flag would apply
|
|
84
|
+
* them over HTTP and skip them on a direct RPC invocation.
|
|
85
|
+
*
|
|
86
|
+
* Returns the data to use, which is a new object only when defaults had to be
|
|
87
|
+
* added to a nullish input — a call made with no arguments at all still gets
|
|
88
|
+
* them. Values are cloned so an object or array default (`[]`, `{}`) is never
|
|
89
|
+
* shared as one mutable instance across every request.
|
|
90
|
+
*/
|
|
91
|
+
export const applyDefaultsFromSchema = (schemaName, data, packageName = null) => {
|
|
92
|
+
const schema = pikkuState(packageName, 'misc', 'schemas').get(schemaName);
|
|
93
|
+
if (!schema?.properties)
|
|
94
|
+
return data;
|
|
95
|
+
// A primitive body cannot carry named properties; leave it for the validator
|
|
96
|
+
// to reject rather than reshaping it into something that would pass.
|
|
97
|
+
if (data != null && typeof data !== 'object')
|
|
98
|
+
return data;
|
|
99
|
+
let result = data;
|
|
100
|
+
for (const key in schema.properties) {
|
|
101
|
+
const property = schema.properties[key];
|
|
102
|
+
if (typeof property === 'boolean' || !('default' in property)) {
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
// Allocated only once a default is actually found, so a schema without any
|
|
106
|
+
// leaves the caller's data (and its absence) exactly as it was.
|
|
107
|
+
result ??= {};
|
|
108
|
+
if (result[key] === undefined) {
|
|
109
|
+
result[key] = structuredClone(property.default);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
};
|
|
68
114
|
export const coerceTopLevelDataFromSchema = (schemaName, data, packageName = null) => {
|
|
69
115
|
const schema = pikkuState(packageName, 'misc', 'schemas').get(schemaName);
|
|
70
116
|
if (!schema?.properties)
|
|
@@ -116,4 +116,4 @@ export declare const pikkuFetch: <In, Out>(request: Request | PikkuHTTPRequest,
|
|
|
116
116
|
* @param {RunHTTPWiringOptions} options - Options such as singleton services, session handling, and error configuration.
|
|
117
117
|
* @returns {Promise<Out | void>} The output from the route handler or void if an error occurred.
|
|
118
118
|
*/
|
|
119
|
-
export declare const fetchData: <In, Out>(request: Request | PikkuHTTPRequest, response: PikkuHTTPResponse, { skipUserSession, respondWith404, logWarningsForStatusCodes, coerceDataFromSchema, bubbleErrors, exposeErrors, generateRequestId, traceId: externalTraceId, }?: RunHTTPWiringOptions) => Promise<Out | void>;
|
|
119
|
+
export declare const fetchData: <In, Out>(request: Request | PikkuHTTPRequest, response: PikkuHTTPResponse, { skipUserSession, respondWith404, logWarningsForStatusCodes, coerceDataFromSchema, bubbleErrors, exposeErrors, generateRequestId, traceId: externalTraceId, maxBodySize, }?: RunHTTPWiringOptions) => Promise<Out | void>;
|
|
@@ -386,13 +386,15 @@ export const pikkuFetch = async (request, params = {}) => {
|
|
|
386
386
|
*/
|
|
387
387
|
export const fetchData = async (request, response, { skipUserSession = false, respondWith404 = true, logWarningsForStatusCodes = [], coerceDataFromSchema = true, bubbleErrors = false,
|
|
388
388
|
// Surface the error message + stack on unexpected 500s unless in production.
|
|
389
|
-
exposeErrors = !isProduction(), generateRequestId, traceId: externalTraceId, } = {}) => {
|
|
389
|
+
exposeErrors = !isProduction(), generateRequestId, traceId: externalTraceId, maxBodySize, } = {}) => {
|
|
390
390
|
const singletonServices = getSingletonServices();
|
|
391
391
|
const createWireServices = getCreateWireServices();
|
|
392
392
|
let wireServices;
|
|
393
393
|
let result;
|
|
394
394
|
// Combine the request and response into one wire object
|
|
395
|
-
const pikkuRequest = request instanceof Request
|
|
395
|
+
const pikkuRequest = request instanceof Request
|
|
396
|
+
? new PikkuFetchHTTPRequest(request, { maxBodySize })
|
|
397
|
+
: request;
|
|
396
398
|
// Resolve traceId: external (e.g. CF-Ray) > x-request-id header > generated
|
|
397
399
|
let requestId = externalTraceId ?? null;
|
|
398
400
|
if (!requestId) {
|
|
@@ -19,6 +19,8 @@ export type RunHTTPWiringOptions = Partial<{
|
|
|
19
19
|
generateRequestId: () => string;
|
|
20
20
|
/** Pre-resolved trace ID (e.g. CF-Ray). Falls back to x-request-id header or generated ID. */
|
|
21
21
|
traceId: string;
|
|
22
|
+
/** Maximum request body size in bytes, applied when pikku wraps a fetch `Request`. */
|
|
23
|
+
maxBodySize: number;
|
|
22
24
|
}>;
|
|
23
25
|
/**
|
|
24
26
|
* Represents the HTTP methods supported for API HTTP wirings.
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { PikkuFetchHTTPRequest } from './pikku-fetch-http-request.js';
|
|
1
|
+
export { PikkuFetchHTTPRequest, DEFAULT_MAX_BODY_SIZE, } from './pikku-fetch-http-request.js';
|
|
2
|
+
export type { PikkuFetchHTTPRequestOptions } from './pikku-fetch-http-request.js';
|
|
2
3
|
export { PikkuFetchHTTPResponse } from './pikku-fetch-http-response.js';
|
|
3
4
|
export { logRoutes } from './log-http-routes.js';
|
|
4
5
|
export { fetch, fetchData, wireHTTP, addHTTPMiddleware, addHTTPPermission, } from './http-runner.js';
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { PikkuFetchHTTPRequest } from './pikku-fetch-http-request.js';
|
|
1
|
+
export { PikkuFetchHTTPRequest, DEFAULT_MAX_BODY_SIZE, } from './pikku-fetch-http-request.js';
|
|
2
2
|
export { PikkuFetchHTTPResponse } from './pikku-fetch-http-response.js';
|
|
3
3
|
export { logRoutes } from './log-http-routes.js';
|
|
4
4
|
export { fetch, fetchData, wireHTTP, addHTTPMiddleware, addHTTPPermission, } from './http-runner.js';
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import type { HTTPMethod, PikkuHTTPRequest, PikkuQuery } from './http.types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The largest request body read into memory when no limit is configured. Ample
|
|
4
|
+
* for JSON APIs and typical uploads while keeping a single request's memory
|
|
5
|
+
* footprint bounded.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEFAULT_MAX_BODY_SIZE: number;
|
|
8
|
+
export type PikkuFetchHTTPRequestOptions = Partial<{
|
|
9
|
+
/** Maximum request body size in bytes. Defaults to {@link DEFAULT_MAX_BODY_SIZE}. */
|
|
10
|
+
maxBodySize: number;
|
|
11
|
+
}>;
|
|
2
12
|
/**
|
|
3
13
|
* Abstract class representing a pikku request.
|
|
4
14
|
* @template In - The type of the request body.
|
|
@@ -7,7 +17,7 @@ import type { HTTPMethod, PikkuHTTPRequest, PikkuQuery } from './http.types.js';
|
|
|
7
17
|
export declare class PikkuFetchHTTPRequest<In = unknown> implements PikkuHTTPRequest<In> {
|
|
8
18
|
#private;
|
|
9
19
|
private request;
|
|
10
|
-
constructor(request: Request);
|
|
20
|
+
constructor(request: Request, { maxBodySize }?: PikkuFetchHTTPRequestOptions);
|
|
11
21
|
method(): HTTPMethod;
|
|
12
22
|
path(): string;
|
|
13
23
|
/**
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { parse as parseQuery } from 'picoquery';
|
|
2
2
|
import { parse as parseCookie } from 'cookie';
|
|
3
|
-
import { UnprocessableContentError } from '../../errors/errors.js';
|
|
3
|
+
import { PayloadTooLargeError, UnprocessableContentError, } from '../../errors/errors.js';
|
|
4
|
+
/**
|
|
5
|
+
* The largest request body read into memory when no limit is configured. Ample
|
|
6
|
+
* for JSON APIs and typical uploads while keeping a single request's memory
|
|
7
|
+
* footprint bounded.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024;
|
|
4
10
|
/**
|
|
5
11
|
* Abstract class representing a pikku request.
|
|
6
12
|
* @template In - The type of the request body.
|
|
@@ -14,9 +20,11 @@ export class PikkuFetchHTTPRequest {
|
|
|
14
20
|
#rawBodyText;
|
|
15
21
|
#rawBodyBuffer;
|
|
16
22
|
#rawBufferPromise;
|
|
17
|
-
|
|
23
|
+
#maxBodySize;
|
|
24
|
+
constructor(request, { maxBodySize = DEFAULT_MAX_BODY_SIZE } = {}) {
|
|
18
25
|
this.request = request;
|
|
19
26
|
this.#url = new URL(request.url);
|
|
27
|
+
this.#maxBodySize = maxBodySize;
|
|
20
28
|
}
|
|
21
29
|
method() {
|
|
22
30
|
return this.request.method.toLowerCase();
|
|
@@ -68,12 +76,65 @@ export class PikkuFetchHTTPRequest {
|
|
|
68
76
|
`toWebRequest just for headers) should be removed.`);
|
|
69
77
|
return this.#rawBufferPromise;
|
|
70
78
|
}
|
|
71
|
-
this.#rawBufferPromise = this
|
|
79
|
+
this.#rawBufferPromise = this.#readBoundedBuffer().then((buf) => {
|
|
72
80
|
this.#rawBodyBuffer = buf;
|
|
73
81
|
return buf;
|
|
74
82
|
});
|
|
75
83
|
return this.#rawBufferPromise;
|
|
76
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Reads the body while refusing to buffer more than `maxBodySize` bytes. The
|
|
87
|
+
* declared `content-length` is rejected up front so an oversized body is never
|
|
88
|
+
* transferred, and the stream is measured as it arrives because that header is
|
|
89
|
+
* both optional and attacker-controlled.
|
|
90
|
+
*/
|
|
91
|
+
async #readBoundedBuffer() {
|
|
92
|
+
const contentLength = this.request.headers.get('content-length');
|
|
93
|
+
if (contentLength !== null) {
|
|
94
|
+
const declaredSize = Number(contentLength);
|
|
95
|
+
if (Number.isFinite(declaredSize) && declaredSize > this.#maxBodySize) {
|
|
96
|
+
throw this.#payloadTooLarge();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const stream = this.request.body;
|
|
100
|
+
if (stream === null) {
|
|
101
|
+
const buffer = await this.request.arrayBuffer();
|
|
102
|
+
if (buffer.byteLength > this.#maxBodySize) {
|
|
103
|
+
throw this.#payloadTooLarge();
|
|
104
|
+
}
|
|
105
|
+
return buffer;
|
|
106
|
+
}
|
|
107
|
+
const reader = stream.getReader();
|
|
108
|
+
const chunks = [];
|
|
109
|
+
let size = 0;
|
|
110
|
+
try {
|
|
111
|
+
while (true) {
|
|
112
|
+
const { done, value } = await reader.read();
|
|
113
|
+
if (done) {
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
size += value.byteLength;
|
|
117
|
+
if (size > this.#maxBodySize) {
|
|
118
|
+
await reader.cancel();
|
|
119
|
+
throw this.#payloadTooLarge();
|
|
120
|
+
}
|
|
121
|
+
chunks.push(value);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
reader.releaseLock();
|
|
126
|
+
}
|
|
127
|
+
const body = new Uint8Array(size);
|
|
128
|
+
let offset = 0;
|
|
129
|
+
for (const chunk of chunks) {
|
|
130
|
+
body.set(chunk, offset);
|
|
131
|
+
offset += chunk.byteLength;
|
|
132
|
+
}
|
|
133
|
+
return body.buffer;
|
|
134
|
+
}
|
|
135
|
+
#payloadTooLarge() {
|
|
136
|
+
return new PayloadTooLargeError(`Request body exceeds the maximum size of ${this.#maxBodySize} bytes`);
|
|
137
|
+
}
|
|
77
138
|
async #readRawText() {
|
|
78
139
|
if (this.#rawBodyText !== undefined) {
|
|
79
140
|
return this.#rawBodyText;
|
|
@@ -191,6 +252,9 @@ export class PikkuFetchHTTPRequest {
|
|
|
191
252
|
}
|
|
192
253
|
}
|
|
193
254
|
catch (e) {
|
|
255
|
+
if (e instanceof PayloadTooLargeError) {
|
|
256
|
+
throw e;
|
|
257
|
+
}
|
|
194
258
|
throw new UnprocessableContentError(`Error parsing body: ${e}`);
|
|
195
259
|
}
|
|
196
260
|
return body;
|
|
@@ -6,6 +6,11 @@ export declare class MCPError extends Error {
|
|
|
6
6
|
}
|
|
7
7
|
export type RunMCPEndpointParams<Tools extends string = any> = {
|
|
8
8
|
mcp?: PikkuMCP<Tools>;
|
|
9
|
+
/**
|
|
10
|
+
* Surface the error message + stack on unexpected internal errors.
|
|
11
|
+
* Defaults to enabled outside of production.
|
|
12
|
+
*/
|
|
13
|
+
exposeErrors?: boolean;
|
|
9
14
|
};
|
|
10
15
|
export type JsonRpcError = {
|
|
11
16
|
code: number;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getErrorResponse } from '../../errors/error-handler.js';
|
|
2
|
+
import { isProduction } from '../../env.js';
|
|
2
3
|
import { closeWireServices } from '../../utils.js';
|
|
3
4
|
import { pikkuState, getSingletonServices, getCreateWireServices, } from '../../pikku-state.js';
|
|
4
5
|
import { addFunction, runPikkuFunc } from '../../function/function-runner.js';
|
|
@@ -88,7 +89,7 @@ export async function runMCPPrompt(request, params, name) {
|
|
|
88
89
|
/**
|
|
89
90
|
* JSON-RPC 2.0 compatible MCP endpoint runner
|
|
90
91
|
*/
|
|
91
|
-
async function runMCPPikkuFunc(request, type, name, mcp, pikkuFuncId, { mcp: mcpWire }) {
|
|
92
|
+
async function runMCPPikkuFunc(request, type, name, mcp, pikkuFuncId, { mcp: mcpWire, exposeErrors = !isProduction() }) {
|
|
92
93
|
const singletonServices = getSingletonServices();
|
|
93
94
|
const createWireServices = getCreateWireServices();
|
|
94
95
|
let wireServices;
|
|
@@ -165,7 +166,9 @@ async function runMCPPikkuFunc(request, type, name, mcp, pikkuFuncId, { mcp: mcp
|
|
|
165
166
|
id: request.id,
|
|
166
167
|
code: -32603,
|
|
167
168
|
message: 'Internal error',
|
|
168
|
-
data:
|
|
169
|
+
data: exposeErrors && !isProduction() && e instanceof Error
|
|
170
|
+
? { message: e.message, stack: e.stack }
|
|
171
|
+
: undefined,
|
|
169
172
|
});
|
|
170
173
|
}
|
|
171
174
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/core",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.72",
|
|
4
4
|
"description": "The Pikku runtime — functions, wirings, services, middleware and types",
|
|
5
5
|
"author": "yasser.fadl@gmail.com",
|
|
6
6
|
"license": "MIT",
|
|
@@ -55,7 +55,6 @@
|
|
|
55
55
|
"./services/istanbul-coverage": "./dist/services/istanbul-coverage-service.js",
|
|
56
56
|
"./services/local-content": "./dist/services/local-content.js",
|
|
57
57
|
"./services/temporary-file-service": "./dist/services/temporary-file-service.js",
|
|
58
|
-
"./services/gopass-secrets": "./dist/services/gopass-secrets.js",
|
|
59
58
|
"./crypto-utils": "./dist/crypto-utils.js",
|
|
60
59
|
"./hmac": "./dist/utils/hmac.js",
|
|
61
60
|
"./internal": "./dist/internal.js",
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
runPikkuFunc,
|
|
8
8
|
} from './function-runner.js'
|
|
9
9
|
import { addGlobalPermission, addTagMiddleware } from '../index.js'
|
|
10
|
+
import { addSchema } from '../schema.js'
|
|
10
11
|
import { resetPikkuState, pikkuState } from '../pikku-state.js'
|
|
11
12
|
import type { CoreServices, CorePikkuMiddleware } from '../types/core.types.js'
|
|
12
13
|
import type { CorePermissionGroup } from './functions.types.js'
|
|
@@ -1494,4 +1495,78 @@ describe('runPikkuFunc - scopes', () => {
|
|
|
1494
1495
|
MissingScopeError
|
|
1495
1496
|
)
|
|
1496
1497
|
})
|
|
1498
|
+
|
|
1499
|
+
describe('schema defaults', () => {
|
|
1500
|
+
const addPagedFunction = (funcName: string, seen: { data?: any }) => {
|
|
1501
|
+
addSchema('PagedInput', {
|
|
1502
|
+
properties: {
|
|
1503
|
+
page: { type: 'number', default: 1 },
|
|
1504
|
+
limit: { type: 'number', default: 50 },
|
|
1505
|
+
},
|
|
1506
|
+
})
|
|
1507
|
+
addTestFunction(funcName, {
|
|
1508
|
+
func: async (_services: any, data: any) => {
|
|
1509
|
+
seen.data = data
|
|
1510
|
+
return 'ok'
|
|
1511
|
+
},
|
|
1512
|
+
})
|
|
1513
|
+
pikkuState(null, 'function', 'meta')[funcName]!.inputSchemaName =
|
|
1514
|
+
'PagedInput'
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// The whole point of the fix: `{}` validates because a defaulted property
|
|
1518
|
+
// is not `required`, so without this the body reads `undefined` for a value
|
|
1519
|
+
// its generated type declares as a number.
|
|
1520
|
+
test('a function called with {} receives its schema defaults', async () => {
|
|
1521
|
+
const seen: { data?: any } = {}
|
|
1522
|
+
addPagedFunction('paged', seen)
|
|
1523
|
+
|
|
1524
|
+
await runPikkuFunc('rpc', Math.random().toString(), 'paged', {
|
|
1525
|
+
singletonServices: mockSingletonServices,
|
|
1526
|
+
getAllServices: () => mockServices,
|
|
1527
|
+
data: () => ({}),
|
|
1528
|
+
auth: false,
|
|
1529
|
+
wire: {},
|
|
1530
|
+
})
|
|
1531
|
+
|
|
1532
|
+
assert.equal(seen.data.page, 1)
|
|
1533
|
+
assert.equal(seen.data.limit, 50)
|
|
1534
|
+
})
|
|
1535
|
+
|
|
1536
|
+
// `coerceDataFromSchema` is not passed here, exactly as a direct RPC
|
|
1537
|
+
// invocation leaves it. Defaults belong to the schema rather than to the
|
|
1538
|
+
// transport, so they must not ride on that flag — and a call made with no
|
|
1539
|
+
// arguments at all still has to get them.
|
|
1540
|
+
test('defaults apply with coercion off and no data', async () => {
|
|
1541
|
+
const seen: { data?: any } = {}
|
|
1542
|
+
addPagedFunction('pagedNoCoerce', seen)
|
|
1543
|
+
|
|
1544
|
+
await runPikkuFunc('rpc', Math.random().toString(), 'pagedNoCoerce', {
|
|
1545
|
+
singletonServices: mockSingletonServices,
|
|
1546
|
+
getAllServices: () => mockServices,
|
|
1547
|
+
data: () => undefined,
|
|
1548
|
+
auth: false,
|
|
1549
|
+
wire: {},
|
|
1550
|
+
})
|
|
1551
|
+
|
|
1552
|
+
assert.equal(seen.data.page, 1)
|
|
1553
|
+
assert.equal(seen.data.limit, 50)
|
|
1554
|
+
})
|
|
1555
|
+
|
|
1556
|
+
test('a supplied value survives', async () => {
|
|
1557
|
+
const seen: { data?: any } = {}
|
|
1558
|
+
addPagedFunction('pagedSupplied', seen)
|
|
1559
|
+
|
|
1560
|
+
await runPikkuFunc('rpc', Math.random().toString(), 'pagedSupplied', {
|
|
1561
|
+
singletonServices: mockSingletonServices,
|
|
1562
|
+
getAllServices: () => mockServices,
|
|
1563
|
+
data: () => ({ page: 3 }),
|
|
1564
|
+
auth: false,
|
|
1565
|
+
wire: {},
|
|
1566
|
+
})
|
|
1567
|
+
|
|
1568
|
+
assert.equal(seen.data.page, 3)
|
|
1569
|
+
assert.equal(seen.data.limit, 50)
|
|
1570
|
+
})
|
|
1571
|
+
})
|
|
1497
1572
|
})
|
|
@@ -5,7 +5,11 @@ import {
|
|
|
5
5
|
} from '../wirings/channel/channel-middleware-runner.js'
|
|
6
6
|
import { runPermissions } from '../permissions.js'
|
|
7
7
|
import { pikkuState } from '../pikku-state.js'
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
applyDefaultsFromSchema,
|
|
10
|
+
coerceTopLevelDataFromSchema,
|
|
11
|
+
validateSchema,
|
|
12
|
+
} from '../schema.js'
|
|
9
13
|
import type {
|
|
10
14
|
CoreUserSession,
|
|
11
15
|
CorePikkuMiddleware,
|
|
@@ -293,11 +297,20 @@ export const runPikkuFunc = async <In = any, Out = any>(
|
|
|
293
297
|
verifyScopes(funcConfig.scopes ?? funcMeta.scopes, session)
|
|
294
298
|
|
|
295
299
|
// Evaluate the data from the lazy function
|
|
296
|
-
|
|
300
|
+
let actualData = await data()
|
|
297
301
|
|
|
298
302
|
// Validate and coerce data if schema is defined
|
|
299
303
|
const inputSchemaName = funcMeta.inputSchemaName
|
|
300
304
|
if (inputSchemaName) {
|
|
305
|
+
// Fill in schema defaults before anything reads the data. Unconditional:
|
|
306
|
+
// a default belongs to the schema, not to the transport the call arrived
|
|
307
|
+
// on. Runs before coercion so a defaulted value and a supplied one are
|
|
308
|
+
// treated identically from here on.
|
|
309
|
+
actualData = applyDefaultsFromSchema(
|
|
310
|
+
inputSchemaName,
|
|
311
|
+
actualData,
|
|
312
|
+
packageName
|
|
313
|
+
)
|
|
301
314
|
// Coerce (top level) data types before validation (e.g. string→array, string→date)
|
|
302
315
|
if (coerceDataFromSchema) {
|
|
303
316
|
coerceTopLevelDataFromSchema(inputSchemaName, actualData, packageName)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, test } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { join, dirname } from 'node:path'
|
|
6
|
+
|
|
7
|
+
const srcRoot = dirname(fileURLToPath(import.meta.url))
|
|
8
|
+
const packageRoot = join(srcRoot, '..')
|
|
9
|
+
|
|
10
|
+
const collectSourceFiles = (directory: string): string[] =>
|
|
11
|
+
readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
12
|
+
const entryPath = join(directory, entry.name)
|
|
13
|
+
if (entry.isDirectory()) {
|
|
14
|
+
return entry.name === 'dist' ? [] : collectSourceFiles(entryPath)
|
|
15
|
+
}
|
|
16
|
+
return /\.(ts|js|mts|cts)$/.test(entry.name) ? [entryPath] : []
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
describe('gopass secret service removal', () => {
|
|
20
|
+
test('the gopass-secrets module no longer exists', () => {
|
|
21
|
+
assert.equal(
|
|
22
|
+
existsSync(join(srcRoot, 'services/gopass-secrets.ts')),
|
|
23
|
+
false,
|
|
24
|
+
'services/gopass-secrets.ts was reintroduced'
|
|
25
|
+
)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('package.json no longer exports ./services/gopass-secrets', () => {
|
|
29
|
+
const packageJson = JSON.parse(
|
|
30
|
+
readFileSync(join(packageRoot, 'package.json'), 'utf-8')
|
|
31
|
+
)
|
|
32
|
+
assert.equal(
|
|
33
|
+
packageJson.exports['./services/gopass-secrets'],
|
|
34
|
+
undefined,
|
|
35
|
+
'./services/gopass-secrets export was reintroduced'
|
|
36
|
+
)
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('no source file references gopass', () => {
|
|
40
|
+
const offenders = collectSourceFiles(srcRoot).filter(
|
|
41
|
+
(file) =>
|
|
42
|
+
file !== fileURLToPath(import.meta.url) &&
|
|
43
|
+
/gopass/i.test(readFileSync(file, 'utf-8'))
|
|
44
|
+
)
|
|
45
|
+
assert.deepEqual(
|
|
46
|
+
offenders,
|
|
47
|
+
[],
|
|
48
|
+
`gopass references found in:\n${offenders.join('\n')}`
|
|
49
|
+
)
|
|
50
|
+
})
|
|
51
|
+
})
|