@pikku/core 0.12.112 → 0.12.114
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 +137 -0
- package/dist/function/function-runner.js +8 -2
- package/dist/middleware-runner.js +8 -3
- package/dist/services/credential-wire-service.d.ts +31 -1
- package/dist/services/credential-wire-service.js +47 -10
- package/dist/services/typed-secret-service.d.ts +11 -0
- package/dist/services/typed-secret-service.js +11 -1
- package/dist/types/state.types.d.ts +2 -1
- package/dist/wirings/addon/addon-runner.d.ts +2 -1
- package/dist/wirings/addon/wire-addon.d.ts +14 -4
- package/dist/wirings/cli/index.d.ts +6 -0
- package/dist/wirings/cli/index.js +6 -0
- package/dist/wirings/credential/credential-overrides.d.ts +26 -0
- package/dist/wirings/credential/credential-overrides.js +45 -0
- package/dist/wirings/credential/index.d.ts +2 -0
- package/dist/wirings/credential/index.js +1 -0
- package/dist/wirings/http/http-routes.js +1 -0
- package/dist/wirings/http/http-runner.js +5 -5
- package/dist/wirings/http/http-stream-protocol.d.ts +12 -0
- package/dist/wirings/http/http-stream-protocol.js +16 -0
- package/dist/wirings/http/http.types.d.ts +35 -0
- package/dist/wirings/http/index.d.ts +1 -1
- package/dist/wirings/mcp/index.d.ts +2 -1
- package/dist/wirings/mcp/index.js +1 -1
- package/dist/wirings/mcp/mcp-runner.d.ts +27 -0
- package/dist/wirings/mcp/mcp-runner.js +111 -0
- package/dist/wirings/rpc/rpc-types.d.ts +2 -1
- package/dist/wirings/secret/derive-oauth2-app-secrets.d.ts +20 -0
- package/dist/wirings/secret/derive-oauth2-app-secrets.js +47 -0
- package/dist/wirings/secret/index.d.ts +1 -0
- package/dist/wirings/secret/index.js +1 -0
- package/knowledge/decisions/internals/global-middleware-resolves-from-the-root-namespace-too.md +32 -0
- package/knowledge/decisions/internals/index.md +3 -0
- package/knowledge/decisions/internals/mcp-wire-names-are-assigned-over-the-whole-registry.md +39 -0
- package/knowledge/decisions/internals/the-mcp-handshake-is-challenged-only-when-every-target-is-gated.md +29 -0
- package/knowledge/decisions/security/an-mcp-refusal-is-decided-before-dispatch.md +48 -0
- package/knowledge/decisions/security/an-oauth2-credential-is-read-by-its-account-row.md +40 -0
- package/knowledge/decisions/security/index.md +2 -0
- package/package.json +1 -1
- package/src/public-surface.json +21 -12
|
@@ -40,9 +40,33 @@ export type CoreHTTPFunction = HTTPRouteBaseConfig & {
|
|
|
40
40
|
/** Sends the returned value as-is rather than JSON-encoding it, for a route whose body is binary or already serialised. */
|
|
41
41
|
returnsJSON?: false;
|
|
42
42
|
};
|
|
43
|
+
/**
|
|
44
|
+
* The claims a transport that already verified a bearer token hands on.
|
|
45
|
+
*
|
|
46
|
+
* Strictly pass-through: nothing in pikku derives this from a request's own
|
|
47
|
+
* headers, because verifying a token is the host's job, not the wire's. A
|
|
48
|
+
* function can always read the `Authorization` header itself — what this adds
|
|
49
|
+
* is what the raw header cannot carry, the scopes and client the token was
|
|
50
|
+
* actually issued for.
|
|
51
|
+
*
|
|
52
|
+
* Shaped to match the MCP SDK's `AuthInfo`, which is the one caller that
|
|
53
|
+
* populates it today.
|
|
54
|
+
*/
|
|
55
|
+
export interface PikkuHTTPAuthInfo {
|
|
56
|
+
token: string;
|
|
57
|
+
clientId: string;
|
|
58
|
+
scopes: string[];
|
|
59
|
+
/** Seconds since the epoch. */
|
|
60
|
+
expiresAt?: number;
|
|
61
|
+
/** The RFC 8707 resource server this token is valid for. */
|
|
62
|
+
resource?: URL;
|
|
63
|
+
extra?: Record<string, unknown>;
|
|
64
|
+
}
|
|
43
65
|
export interface PikkuHTTP<In = unknown> {
|
|
44
66
|
request?: PikkuHTTPRequest<In>;
|
|
45
67
|
response?: PikkuHTTPResponse;
|
|
68
|
+
/** Verified token claims, when the transport was handed them. */
|
|
69
|
+
authInfo?: PikkuHTTPAuthInfo;
|
|
46
70
|
}
|
|
47
71
|
export type PikkuQuery<T = Record<string, string | undefined>> = Record<string, string | T | null | Array<T | null>>;
|
|
48
72
|
/**
|
|
@@ -78,6 +102,13 @@ type HTTPWiringAuth<In, Out, PikkuFunction extends CorePikkuFunction<In, Out, an
|
|
|
78
102
|
/** On an open route there is no session, so this must be a sessionless function. */
|
|
79
103
|
func: CorePikkuFunctionConfig<PikkuFunctionSessionless, PikkuPermission, PikkuMiddleware>;
|
|
80
104
|
};
|
|
105
|
+
/**
|
|
106
|
+
* The event protocol a streaming route's frames follow, so the layer that
|
|
107
|
+
* terminates a failed stream can speak the same one the client is parsing.
|
|
108
|
+
* `'pikku'` frames are `{ type: 'error' | 'done' }`; `'agui'` frames are
|
|
109
|
+
* AG-UI events, where a failure is a single `RUN_ERROR`.
|
|
110
|
+
*/
|
|
111
|
+
export type HTTPStreamProtocol = 'pikku' | 'agui';
|
|
81
112
|
/**
|
|
82
113
|
* `sse` and `query` are each valid on one method only, so the method carries
|
|
83
114
|
* them: streaming is a GET, and naming which input keys arrive in the query
|
|
@@ -92,6 +123,8 @@ type HTTPWiringMethod<In> = {
|
|
|
92
123
|
method: 'get';
|
|
93
124
|
/** Streams the response as server-sent events instead of returning it once. GET only. */
|
|
94
125
|
sse?: boolean;
|
|
126
|
+
/** Which event protocol the frames on this stream follow. Defaults to `'pikku'`. */
|
|
127
|
+
streamProtocol?: HTTPStreamProtocol;
|
|
95
128
|
} | {
|
|
96
129
|
/** The HTTP method. A route and method together address one wiring. */
|
|
97
130
|
method: 'post';
|
|
@@ -162,6 +195,8 @@ export type HTTPRouteConfig<PikkuFunction extends CorePikkuFunction<any, any, an
|
|
|
162
195
|
auth?: boolean;
|
|
163
196
|
middleware?: PikkuMiddleware[];
|
|
164
197
|
sse?: boolean;
|
|
198
|
+
/** Which event protocol the frames on this stream follow. Defaults to `'pikku'`. */
|
|
199
|
+
streamProtocol?: HTTPStreamProtocol;
|
|
165
200
|
};
|
|
166
201
|
export type HTTPRoutesGroupConfig<PikkuPermission extends CorePikkuPermission<any, any, any> = CorePikkuPermission<any, any, any>, PikkuMiddleware extends CorePikkuMiddleware<any, any> = CorePikkuMiddleware<any, any>> = {
|
|
167
202
|
basePath?: string;
|
|
@@ -5,4 +5,4 @@ export { fetch, fetchData, wireHTTP, addHTTPMiddleware } from './http-runner.js'
|
|
|
5
5
|
export { wireHTTPRoutes, defineHTTPRoutes } from './http-routes.js';
|
|
6
6
|
export { toWebRequest, applyWebResponse } from './web-request.js';
|
|
7
7
|
export { httpRouter } from './routers/http-router.js';
|
|
8
|
-
export type { AssertHTTPWiringParams, CoreHTTPFunctionWiring, HTTPMethod, HTTPRouteBaseConfig, HTTPRouteContract, HTTPRouteMap, HTTPWiringsMeta, PikkuHTTP, PikkuHTTPRequest, PikkuHTTPResponse, PikkuQuery, RunHTTPWiringOptions, } from './http.types.js';
|
|
8
|
+
export type { AssertHTTPWiringParams, CoreHTTPFunctionWiring, HTTPMethod, HTTPRouteBaseConfig, HTTPRouteContract, HTTPRouteMap, HTTPWiringsMeta, PikkuHTTP, PikkuHTTPAuthInfo, PikkuHTTPRequest, PikkuHTTPResponse, PikkuQuery, RunHTTPWiringOptions, } from './http.types.js';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { MCPEndpointRegistry } from './mcp-endpoint-registry.js';
|
|
2
2
|
export { MCPError, wireMCPResource, wireMCPPrompt, runMCPResource, runMCPTool, runMCPPrompt, } from './mcp-runner.js';
|
|
3
|
-
export { getMCPResourcesMeta, getMCPToolsMeta, getMCPPromptsMeta, } from './mcp-runner.js';
|
|
3
|
+
export { getMCPResourcesMeta, getMCPToolsMeta, getMCPPromptsMeta, mcpTargetRequiresSession, mcpEveryTargetRequiresSession, mcpWireName, mcpResolveWireName, } from './mcp-runner.js';
|
|
4
|
+
export type { McpTargetType } from './mcp-runner.js';
|
|
4
5
|
export type { AssertMCPResourceURIParams, CoreMCPPrompt, CoreMCPResource, MCPPromptResponse, MCPResourceMeta, MCPResourceResponse, MCPToolMeta, MCPToolResponse, MCPPromptMeta, PikkuMCP, } from './mcp.types.js';
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { MCPEndpointRegistry } from './mcp-endpoint-registry.js';
|
|
2
2
|
export { MCPError, wireMCPResource, wireMCPPrompt, runMCPResource, runMCPTool, runMCPPrompt, } from './mcp-runner.js';
|
|
3
|
-
export { getMCPResourcesMeta, getMCPToolsMeta, getMCPPromptsMeta, } from './mcp-runner.js';
|
|
3
|
+
export { getMCPResourcesMeta, getMCPToolsMeta, getMCPPromptsMeta, mcpTargetRequiresSession, mcpEveryTargetRequiresSession, mcpWireName, mcpResolveWireName, } from './mcp-runner.js';
|
|
@@ -32,3 +32,30 @@ export declare function runMCPPrompt(request: JsonRpcRequest, params: RunMCPEndp
|
|
|
32
32
|
export declare const getMCPResourcesMeta: () => import("./mcp.types.js").MCPResourceMeta;
|
|
33
33
|
export declare const getMCPToolsMeta: () => import("./mcp.types.js").MCPToolMeta;
|
|
34
34
|
export declare const getMCPPromptsMeta: () => import("./mcp.types.js").MCPPromptMeta;
|
|
35
|
+
/**
|
|
36
|
+
* Whether a call to this MCP target would need a session to run.
|
|
37
|
+
*
|
|
38
|
+
* Read from the same declarations the runner enforces: a `pikkuFunc` always
|
|
39
|
+
* needs one, and a `pikkuSessionlessFunc` needs one only where it says
|
|
40
|
+
* `auth: true`. A transport asks this to answer an unauthenticated call with a
|
|
41
|
+
* `401` challenge instead of dispatching it — the status and the
|
|
42
|
+
* `WWW-Authenticate` header have to be chosen before the response starts, which
|
|
43
|
+
* is earlier than the refusal itself can be known.
|
|
44
|
+
*
|
|
45
|
+
* Unknown targets are treated as open: a name nobody registered is a
|
|
46
|
+
* `Method not found`, and answering it with a challenge would invite a client
|
|
47
|
+
* to authenticate its way towards a tool that does not exist.
|
|
48
|
+
*/
|
|
49
|
+
export declare const mcpTargetRequiresSession: (type: 'tool' | 'resource' | 'prompt', name: string) => boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Whether every registered MCP target needs a session. An empty registry is
|
|
52
|
+
* not "all gated" — there is nothing to gate.
|
|
53
|
+
*
|
|
54
|
+
* See `the-mcp-handshake-is-challenged-only-when-every-target-is-gated.md`.
|
|
55
|
+
*/
|
|
56
|
+
export declare const mcpEveryTargetRequiresSession: () => boolean;
|
|
57
|
+
export type McpTargetType = 'tool' | 'resource' | 'prompt';
|
|
58
|
+
/** How a registered target's name is spelled on the wire. */
|
|
59
|
+
export declare const mcpWireName: (type: McpTargetType, name: string) => string;
|
|
60
|
+
/** The registered name a client's wire name refers to. */
|
|
61
|
+
export declare const mcpResolveWireName: (type: McpTargetType, wireName: string) => string;
|
|
@@ -187,3 +187,114 @@ export const getMCPToolsMeta = () => {
|
|
|
187
187
|
export const getMCPPromptsMeta = () => {
|
|
188
188
|
return pikkuState(null, 'mcp', 'promptsMeta');
|
|
189
189
|
};
|
|
190
|
+
/**
|
|
191
|
+
* Whether a call to this MCP target would need a session to run.
|
|
192
|
+
*
|
|
193
|
+
* Read from the same declarations the runner enforces: a `pikkuFunc` always
|
|
194
|
+
* needs one, and a `pikkuSessionlessFunc` needs one only where it says
|
|
195
|
+
* `auth: true`. A transport asks this to answer an unauthenticated call with a
|
|
196
|
+
* `401` challenge instead of dispatching it — the status and the
|
|
197
|
+
* `WWW-Authenticate` header have to be chosen before the response starts, which
|
|
198
|
+
* is earlier than the refusal itself can be known.
|
|
199
|
+
*
|
|
200
|
+
* Unknown targets are treated as open: a name nobody registered is a
|
|
201
|
+
* `Method not found`, and answering it with a challenge would invite a client
|
|
202
|
+
* to authenticate its way towards a tool that does not exist.
|
|
203
|
+
*/
|
|
204
|
+
export const mcpTargetRequiresSession = (type, name) => {
|
|
205
|
+
// The transport passes the name exactly as the client sent it, which is the
|
|
206
|
+
// wire spelling — see `mcpWireName`.
|
|
207
|
+
name = mcpResolveWireName(type, name);
|
|
208
|
+
const meta = type === 'tool'
|
|
209
|
+
? pikkuState(null, 'mcp', 'toolsMeta')[name]
|
|
210
|
+
: type === 'resource'
|
|
211
|
+
? pikkuState(null, 'mcp', 'resourcesMeta')[name]
|
|
212
|
+
: pikkuState(null, 'mcp', 'promptsMeta')[name];
|
|
213
|
+
if (!meta) {
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
let funcName = meta.pikkuFuncId;
|
|
217
|
+
let packageName = null;
|
|
218
|
+
if (funcName.includes(':')) {
|
|
219
|
+
const resolved = resolveNamespace(funcName);
|
|
220
|
+
if (resolved) {
|
|
221
|
+
funcName = resolved.function;
|
|
222
|
+
packageName = resolved.package;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const funcMeta = pikkuState(packageName, 'function', 'meta')[funcName];
|
|
226
|
+
if (!funcMeta) {
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
return !funcMeta.sessionless || funcMeta.auth === true;
|
|
230
|
+
};
|
|
231
|
+
/**
|
|
232
|
+
* Whether every registered MCP target needs a session. An empty registry is
|
|
233
|
+
* not "all gated" — there is nothing to gate.
|
|
234
|
+
*
|
|
235
|
+
* See `the-mcp-handshake-is-challenged-only-when-every-target-is-gated.md`.
|
|
236
|
+
*/
|
|
237
|
+
export const mcpEveryTargetRequiresSession = () => {
|
|
238
|
+
const targets = [
|
|
239
|
+
['tool', Object.keys(pikkuState(null, 'mcp', 'toolsMeta'))],
|
|
240
|
+
['resource', Object.keys(pikkuState(null, 'mcp', 'resourcesMeta'))],
|
|
241
|
+
['prompt', Object.keys(pikkuState(null, 'mcp', 'promptsMeta'))],
|
|
242
|
+
];
|
|
243
|
+
let seen = 0;
|
|
244
|
+
for (const [type, names] of targets) {
|
|
245
|
+
for (const name of names) {
|
|
246
|
+
seen++;
|
|
247
|
+
if (!mcpTargetRequiresSession(type, name)) {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return seen > 0;
|
|
253
|
+
};
|
|
254
|
+
const mcpMetaFor = (type) => type === 'tool'
|
|
255
|
+
? pikkuState(null, 'mcp', 'toolsMeta')
|
|
256
|
+
: type === 'resource'
|
|
257
|
+
? pikkuState(null, 'mcp', 'resourcesMeta')
|
|
258
|
+
: pikkuState(null, 'mcp', 'promptsMeta');
|
|
259
|
+
const WIRE_LEGAL = /^[A-Za-z0-9_-]+$/;
|
|
260
|
+
/**
|
|
261
|
+
* Registered name -> wire name for every target of one type.
|
|
262
|
+
*
|
|
263
|
+
* See `mcp-wire-names-are-assigned-over-the-whole-registry.md`.
|
|
264
|
+
*/
|
|
265
|
+
const mcpWireNames = (type) => {
|
|
266
|
+
const names = Object.keys(mcpMetaFor(type)).sort();
|
|
267
|
+
const assigned = new Map();
|
|
268
|
+
const taken = new Set();
|
|
269
|
+
for (const name of names) {
|
|
270
|
+
if (WIRE_LEGAL.test(name)) {
|
|
271
|
+
assigned.set(name, name);
|
|
272
|
+
taken.add(name);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
for (const name of names) {
|
|
276
|
+
if (assigned.has(name)) {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const base = name.replace(/[^A-Za-z0-9_-]/g, '_');
|
|
280
|
+
let candidate = base;
|
|
281
|
+
let n = 2;
|
|
282
|
+
while (taken.has(candidate)) {
|
|
283
|
+
candidate = `${base}_${n++}`;
|
|
284
|
+
}
|
|
285
|
+
assigned.set(name, candidate);
|
|
286
|
+
taken.add(candidate);
|
|
287
|
+
}
|
|
288
|
+
return assigned;
|
|
289
|
+
};
|
|
290
|
+
/** How a registered target's name is spelled on the wire. */
|
|
291
|
+
export const mcpWireName = (type, name) => mcpWireNames(type).get(name) ?? name;
|
|
292
|
+
/** The registered name a client's wire name refers to. */
|
|
293
|
+
export const mcpResolveWireName = (type, wireName) => {
|
|
294
|
+
for (const [name, wire] of mcpWireNames(type)) {
|
|
295
|
+
if (wire === wireName) {
|
|
296
|
+
return name;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return wireName;
|
|
300
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { CredentialOverrides } from '../credential/credential-overrides.js';
|
|
1
2
|
import type { PikkuRawWire } from '../../types/core.types.js';
|
|
2
3
|
import type { AgentInterruptResult } from '../agent/agent-interrupt.js';
|
|
3
4
|
export type PikkuRPC<Invoke extends (...args: any[]) => any = (...args: any[]) => any, Remote extends (...args: any[]) => any = (...args: any[]) => any, startWorkflow extends (...args: any[]) => any = (...args: any[]) => any, AgentRun extends (...args: any[]) => any = (...args: any[]) => any, AgentStream extends (...args: any[]) => any = (...args: any[]) => any> = {
|
|
@@ -47,7 +48,7 @@ export interface ResolvedFunction {
|
|
|
47
48
|
rpcEndpoint?: string;
|
|
48
49
|
secretOverrides?: Record<string, string>;
|
|
49
50
|
variableOverrides?: Record<string, string>;
|
|
50
|
-
credentialOverrides?:
|
|
51
|
+
credentialOverrides?: CredentialOverrides;
|
|
51
52
|
/** Set by the consuming app: secrets it lends this instance, as the addon names them */
|
|
52
53
|
secretGrants?: string[];
|
|
53
54
|
/** Set by the consuming app: credentials it lends this instance, as the addon names them */
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { CredentialDefinitions } from '../credential/credential.types.js';
|
|
2
|
+
import type { SecretDefinitions } from './secret.types.js';
|
|
3
|
+
/**
|
|
4
|
+
* The app secrets implied by the OAuth2 credentials a project declares.
|
|
5
|
+
*
|
|
6
|
+
* An OAuth2 credential always needs the app's client id and secret, and that
|
|
7
|
+
* pair is the same shape every time — `OAuth2AppCredential`, which is what the
|
|
8
|
+
* runtime reads it as and what the generated types already say it is. Making
|
|
9
|
+
* every author restate it as a hand-written `defineSecret` bought nothing and
|
|
10
|
+
* was silently skipped often enough that deployments were never asked for
|
|
11
|
+
* credentials their connect flow needed.
|
|
12
|
+
*
|
|
13
|
+
* A declaration that already covers the secret id wins, so an author who wants
|
|
14
|
+
* their own description or `docsUrl` keeps it.
|
|
15
|
+
*
|
|
16
|
+
* The derived secret is optional, matching what every hand-written declaration
|
|
17
|
+
* chose: an addon that also authenticates by API key must still deploy without
|
|
18
|
+
* an OAuth app configured.
|
|
19
|
+
*/
|
|
20
|
+
export declare const deriveOAuth2AppSecrets: (credentials: CredentialDefinitions, declared: SecretDefinitions) => SecretDefinitions;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The app secrets implied by the OAuth2 credentials a project declares.
|
|
3
|
+
*
|
|
4
|
+
* An OAuth2 credential always needs the app's client id and secret, and that
|
|
5
|
+
* pair is the same shape every time — `OAuth2AppCredential`, which is what the
|
|
6
|
+
* runtime reads it as and what the generated types already say it is. Making
|
|
7
|
+
* every author restate it as a hand-written `defineSecret` bought nothing and
|
|
8
|
+
* was silently skipped often enough that deployments were never asked for
|
|
9
|
+
* credentials their connect flow needed.
|
|
10
|
+
*
|
|
11
|
+
* A declaration that already covers the secret id wins, so an author who wants
|
|
12
|
+
* their own description or `docsUrl` keeps it.
|
|
13
|
+
*
|
|
14
|
+
* The derived secret is optional, matching what every hand-written declaration
|
|
15
|
+
* chose: an addon that also authenticates by API key must still deploy without
|
|
16
|
+
* an OAuth app configured.
|
|
17
|
+
*/
|
|
18
|
+
export const deriveOAuth2AppSecrets = (credentials, declared) => {
|
|
19
|
+
const covered = new Set(declared.map((secret) => secret.secretId));
|
|
20
|
+
const derived = [];
|
|
21
|
+
for (const credential of credentials) {
|
|
22
|
+
const { oauth2 } = credential;
|
|
23
|
+
if (!oauth2)
|
|
24
|
+
continue;
|
|
25
|
+
if (covered.has(oauth2.appCredentialSecretId))
|
|
26
|
+
continue;
|
|
27
|
+
covered.add(oauth2.appCredentialSecretId);
|
|
28
|
+
derived.push({
|
|
29
|
+
name: `${credential.name}App`,
|
|
30
|
+
displayName: `${credential.displayName} OAuth App`,
|
|
31
|
+
description: `OAuth2 app client id and secret for ${credential.displayName}.`,
|
|
32
|
+
secretId: oauth2.appCredentialSecretId,
|
|
33
|
+
optional: true,
|
|
34
|
+
docsUrl: credential.docsUrl,
|
|
35
|
+
oauth2: {
|
|
36
|
+
tokenSecretId: oauth2.tokenSecretId,
|
|
37
|
+
authorizationUrl: oauth2.authorizationUrl,
|
|
38
|
+
tokenUrl: oauth2.tokenUrl,
|
|
39
|
+
scopes: oauth2.scopes,
|
|
40
|
+
pkce: oauth2.pkce,
|
|
41
|
+
additionalParams: oauth2.additionalParams,
|
|
42
|
+
},
|
|
43
|
+
sourceFile: credential.sourceFile,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return derived;
|
|
47
|
+
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { defineSecret } from './secret.types.js';
|
|
2
2
|
export type { CoreSecret, OAuth2CredentialConfig, SecretDefinitionMeta, SecretDefinitionsMeta, SecretDefinitions, } from './secret.types.js';
|
|
3
3
|
export { validateAndBuildSecretDefinitionsMeta } from './validate-secret-definitions.js';
|
|
4
|
+
export { deriveOAuth2AppSecrets } from './derive-oauth2-app-secrets.js';
|
package/knowledge/decisions/internals/global-middleware-resolves-from-the-root-namespace-too.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: Global middleware resolves from the root namespace too
|
|
4
|
+
description: An addon's dispatch carries the addon's package name, so reading globals from that namespace alone meant the application's own global middleware never ran for it
|
|
5
|
+
tags: [middleware, addons, packages]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Global middleware resolves from the root namespace too
|
|
9
|
+
|
|
10
|
+
`combineMiddleware` collects global middleware from `[null]` for a root
|
|
11
|
+
dispatch, and from `[null, packageName]` for a dispatch belonging to a package.
|
|
12
|
+
|
|
13
|
+
It previously read one namespace: whichever `packageName` the dispatch carried.
|
|
14
|
+
Application wirings carry `null`, so they were unaffected. A wiring contributed
|
|
15
|
+
by an addon carries that addon's package name, and the application's own globals
|
|
16
|
+
were not in that list — so an addon-contributed MCP tool ran with no session
|
|
17
|
+
middleware at all and refused every caller, while the same function reached over
|
|
18
|
+
HTTP authenticated normally.
|
|
19
|
+
|
|
20
|
+
Global middleware is application-wide by definition, and an addon's function
|
|
21
|
+
still runs inside the host application, so the root namespace always applies.
|
|
22
|
+
One addon's globals still do not reach another addon's dispatches.
|
|
23
|
+
|
|
24
|
+
The consequence is that a global written against the application's services now
|
|
25
|
+
runs where those services may not exist — an addon builds its own. Middleware
|
|
26
|
+
that reaches for a service it was not given has to stand down rather than throw;
|
|
27
|
+
see `a-session-middleware-stands-down-where-it-cannot-authenticate.md` in
|
|
28
|
+
`@pikku/better-auth`, which is where this first surfaced.
|
|
29
|
+
|
|
30
|
+
**What this rules out:** treating a package namespace as a middleware boundary
|
|
31
|
+
in both directions. It is a boundary for what an addon contributes, not a wall
|
|
32
|
+
that the host application's own policy stops at.
|
|
@@ -74,6 +74,7 @@ caller is entitled to assume.
|
|
|
74
74
|
- [Gateway webhook challenges echo bytes not JSON](gateway-webhook-challenges-echo-bytes-not-json.md) — String verification challenges are returned raw with returnsJSON false, because platforms byte-compare the echo and JSON quoting fails the handshake
|
|
75
75
|
- [Gateway wiring is a meta-wiring over HTTP and channels](gateway-wiring-is-a-meta-wiring-over-http-and-channels.md) — wireGateway writes handler implementations into the HTTP and channel state directly while the inspector compiles the corresponding meta, so runtime registration deliberately writes no meta
|
|
76
76
|
- [Generated src paths in pikku meta are absolute](generated-src-paths-in-pikku-meta-are-absolute.md) — emailsMeta.src is resolved by the CLI at generation time, so reading through the project-relative helpers produces a wrong compound path
|
|
77
|
+
- [Global middleware resolves from the root namespace too](global-middleware-resolves-from-the-root-namespace-too.md) — An addon's dispatch carries the addon's package name, so reading globals from that namespace alone meant the application's own global middleware never ran for it
|
|
77
78
|
- [Hot reload reads the changed source, never a compiled copy of it](hot-reload-reads-the-changed-source-never-a-compiled-copy.md) — A leftover .js beside a .ts made the reloader announce a reload while re-registering the previous implementation; project TypeScript is now compiled by the reloader itself
|
|
78
79
|
- [Hot reload writes into the function map captured at startup, not pikkuState's current one](hot-reload-writes-into-the-function-map-captured-at-startup.md) — A dev-server watcher may have swapped in a codegen-scoped map whose writes are discarded on restore, and schemas are deliberately left alone
|
|
79
80
|
- [HTTP request bodies are read once and shared between consumers](http-request-bodies-are-read-once-and-shared.md) — The fetch request wrapper memoises the single-use body and builds web Requests lazily, at the cost of holding the whole body in memory
|
|
@@ -87,6 +88,7 @@ caller is entitled to assume.
|
|
|
87
88
|
- [In-memory workflow history aliases the live step object](in-memory-workflow-history-aliases-the-live-step-object.md) — stepHistory pushes the same StepState reference that steps holds, so later mutations to a step are visible in its history entry
|
|
88
89
|
- [Istanbul statement counts attach to the start line only](istanbul-statement-counts-attach-to-the-start-line-only.md) — The istanbul coverage reader credits a statement's hits to its first line, so an enclosing multi-line statement cannot mask an unexecuted inner one
|
|
89
90
|
- [Local trigger and gateway services assume a single process](local-trigger-and-gateway-services-assume-a-single-process.md) — InMemoryTriggerService and LocalGatewayService start every listener unconditionally with no distributed claiming, so a second instance duplicates every event
|
|
91
|
+
- [MCP wire names are assigned over the whole registry, not derived per name](mcp-wire-names-are-assigned-over-the-whole-registry.md) — Sanitising each name on its own is lossy — `a:b` and `a.b` both become `a_b` — so one of the two targets would be unreachable
|
|
90
92
|
- [Node-only builtins are imported dynamically](node-only-builtins-are-imported-dynamically.md) — V8CoverageService imports node:inspector inside start() so the module stays loadable on runtimes that have no such builtin
|
|
91
93
|
- [One project-shape check, called by both validators](one-project-shape-check-two-validators.md) — workspace validate and fabric validate were separate walks over the same project that duplicated sixteen findings verbatim; the shared half now lives in shared-checks.ts and fabric validate is that plus the deploy-shaped checks
|
|
92
94
|
- [Only functions marked `expose: true` enter a virtual user's catalogue](only-exposed-functions-enter-a-virtual-user-catalogue.md) — Absent is not permissive — an unexposed function 404s over rpc, so offering one spends a step to learn nothing about the product
|
|
@@ -111,6 +113,7 @@ caller is entitled to assume.
|
|
|
111
113
|
- [The embedding model is pinned per service and doc/query embedding is split](the-embedding-model-is-pinned-per-service-and-doc-query-embedding-is-split.md) — AIEmbeddingService fixes its model at construction so index and query share a vector space, and separates embedDocuments from embedQuery for asymmetric models
|
|
112
114
|
- [The in-memory workflow service is inline-only and single-process](the-in-memory-workflow-service-is-inline-only-and-single-process.md) — InMemoryWorkflowService wires no queues and implements withRunLock/withStepLock as pass-throughs, because inline execution has no second holder to exclude
|
|
113
115
|
- [The KEK salt is scoped to the key version, not the secret](the-kek-salt-is-scoped-to-the-key-version.md) — One stored salt per key version means N secrets cost one derivation, which is the point of envelope encryption
|
|
116
|
+
- [The MCP handshake is challenged only when every target is gated](the-mcp-handshake-is-challenged-only-when-every-target-is-gated.md) — A client decides whether a server speaks OAuth from the handshake alone, so a fully-gated server that answers initialize with a 200 is detected as needing no sign-in
|
|
114
117
|
- [The middleware resolution cache is deliberately unbounded](the-middleware-resolution-cache-is-deliberately-unbounded.md) — Its keyspace is the set of registered wires, not request traffic, and middleware is dynamic — so eviction would buy nothing and cost the dedupe guarantee
|
|
115
118
|
- [The per-invocation rpc view is a class, because an object literal with a getter is slow to build](the-per-invocation-rpc-view-is-a-class.md) — An accessor declared on an object literal is defined per instance, which drops the literal off V8's fast construction path — measured at 1.15µs against 0.47µs, on every request
|
|
116
119
|
- [The persona runtime is exported from @pikku/core/persona, never from services](the-persona-runtime-is-exported-from-the-persona-entry-point.md) — Those values reach the actor-flow and agent runners, which no production server runs — and an unbundled deploy loads whatever the import graph names
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: MCP wire names are assigned over the whole registry, not derived per name
|
|
4
|
+
description: Sanitising each name on its own is lossy — `a:b` and `a.b` both become `a_b` — so one of the two targets would be unreachable
|
|
5
|
+
tags: [mcp, naming]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# MCP wire names are assigned over the whole registry
|
|
9
|
+
|
|
10
|
+
MCP clients constrain tool and prompt names to `[A-Za-z0-9_-]`. Pikku's
|
|
11
|
+
namespace separator is `:`, so every target contributed by an addon — `bb2:getMe`
|
|
12
|
+
and friends — carries a name the client cannot accept. Clients drop such names
|
|
13
|
+
from the session rather than fail the connection, which reads as the tools simply
|
|
14
|
+
not existing. The names are therefore rewritten at the transport boundary and
|
|
15
|
+
resolved back on the way in; the namespace itself is untouched, since it is what
|
|
16
|
+
dispatch keys on.
|
|
17
|
+
|
|
18
|
+
The rewrite is computed for **all** names of a target type at once, not for one
|
|
19
|
+
name in isolation. Replacing illegal characters one name at a time is not
|
|
20
|
+
injective: `a:b` and `a.b` both sanitise to `a_b`, so the list handlers would
|
|
21
|
+
advertise one name twice and the resolver could only ever pick one of them. The
|
|
22
|
+
other target becomes unreachable, silently.
|
|
23
|
+
|
|
24
|
+
The assignment runs in two passes over the sorted names:
|
|
25
|
+
|
|
26
|
+
1. Every name that is already wire-legal claims itself. A target whose name a
|
|
27
|
+
client can already spell keeps that spelling, whatever else is registered.
|
|
28
|
+
2. The rest are sanitised, and a collision takes the next free `_2`, `_3`, …
|
|
29
|
+
suffix.
|
|
30
|
+
|
|
31
|
+
Sorting makes the result depend only on the set of registered names, so the same
|
|
32
|
+
registry always produces the same wire names, across processes and restarts.
|
|
33
|
+
|
|
34
|
+
**What this rules out:** a pure `wireName(name)` function. Correctness here is a
|
|
35
|
+
property of the whole registry, and a signature that cannot see the registry
|
|
36
|
+
cannot have it. It also rules out percent-style escaping, which would be
|
|
37
|
+
injective but would turn every namespaced tool into something unreadable in the
|
|
38
|
+
client's UI, for a collision that needs two targets differing only in their
|
|
39
|
+
separator.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: The MCP handshake is challenged only when every target is gated
|
|
4
|
+
description: A client decides whether a server speaks OAuth from the handshake alone, so a fully-gated server that answers initialize with a 200 is detected as needing no sign-in
|
|
5
|
+
tags: [mcp, auth, oauth]
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# The MCP handshake is challenged only when every target is gated
|
|
9
|
+
|
|
10
|
+
`requestNeedsCredentials` challenges `initialize` when
|
|
11
|
+
`mcpEveryTargetRequiresSession()` is true, and only then.
|
|
12
|
+
|
|
13
|
+
A client decides at connection time whether a server speaks OAuth, and the only
|
|
14
|
+
thing it can decide from is whether the handshake came back `401` with a
|
|
15
|
+
`WWW-Authenticate` header naming the resource metadata. A server that answers
|
|
16
|
+
`initialize` with a `200` and then `401`s every single tool call has told the
|
|
17
|
+
client "no sign-in needed" and then refused it everything. That is how a fully
|
|
18
|
+
gated server ends up shown as open, with no way for the user to sign in — the
|
|
19
|
+
client never offers the option, because it was told there was nothing to offer.
|
|
20
|
+
|
|
21
|
+
The condition is deliberately all-or-nothing. A server with even one open target
|
|
22
|
+
genuinely is usable anonymously, and challenging its handshake would lock out
|
|
23
|
+
clients that have no credentials to offer at all. An empty registry is not "all
|
|
24
|
+
gated" either: there is nothing there to gate, so the handshake stays open.
|
|
25
|
+
|
|
26
|
+
**What this rules out:** challenging the handshake whenever _any_ target needs a
|
|
27
|
+
session. The handshake is not the place to express per-target policy — the
|
|
28
|
+
per-target `401` already does that, and a client that has been let in can act on
|
|
29
|
+
it. The handshake only answers "is there a sign-in here at all".
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: An MCP refusal is decided before dispatch, not raised from the tool
|
|
4
|
+
description: mcpTargetRequiresSession lets the transport answer 401 with an OAuth challenge, because the MCP response streams and the status is gone by the time the runner refuses
|
|
5
|
+
tags: mcp, permissions, transport
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# An MCP refusal is decided before dispatch, not raised from the tool
|
|
9
|
+
|
|
10
|
+
An MCP client discovers how to authenticate from a `401` carrying a
|
|
11
|
+
`WWW-Authenticate` challenge — that header names the RFC 9728 Protected Resource
|
|
12
|
+
Metadata document, which names the authorization server, which is where OAuth
|
|
13
|
+
starts. Delivered any other way, the refusal is not a refusal: a `MissingSessionError`
|
|
14
|
+
flattened into a JSON-RPC result arrives as `200` with `isError: true`, which a
|
|
15
|
+
client reads as a tool that failed, and no discovery happens.
|
|
16
|
+
|
|
17
|
+
The obvious implementation — catch the refusal in `tools/call` and set the status
|
|
18
|
+
afterwards — cannot work, and not for a reason a test makes obvious. The MCP HTTP
|
|
19
|
+
response **streams**: the entry returns a `Response` whose body the transport is
|
|
20
|
+
still writing when the JSON-RPC handler runs. Instrumenting both ends shows the
|
|
21
|
+
entry observing its own flag as unset *before* the tool ever refuses. Status and
|
|
22
|
+
headers are chosen at the top of the response; by the time anything knows a
|
|
23
|
+
session was missing, they are already on the wire.
|
|
24
|
+
|
|
25
|
+
So the decision moves earlier, to the only two things knowable before dispatch:
|
|
26
|
+
which target the request body names, and whether the request presents anything to
|
|
27
|
+
authenticate with. `mcpTargetRequiresSession` answers the first from the same
|
|
28
|
+
declarations the runner enforces — a `pikkuFunc` always needs a session, a
|
|
29
|
+
`pikkuSessionlessFunc` needs one only where it says `auth: true` — so the
|
|
30
|
+
transport's answer and the runner's cannot disagree. An unregistered name is
|
|
31
|
+
treated as open, because a name nobody registered is a `Method not found`, and
|
|
32
|
+
challenging it invites a client to authenticate its way towards a tool that does
|
|
33
|
+
not exist.
|
|
34
|
+
|
|
35
|
+
This is what makes a single endpoint serve public and private tools together,
|
|
36
|
+
which the MCP spec allows and pikku's per-function `auth` flag already describes.
|
|
37
|
+
`tools/list` stays ungated, so a client can see the menu before it has a token.
|
|
38
|
+
|
|
39
|
+
**What this rules out:** verifying tokens in the transport. Only a request with
|
|
40
|
+
*no* credentials is challenged; a present-but-expired token is dispatched and its
|
|
41
|
+
refusal reaches the client as a tool error. Doing better means resolving the
|
|
42
|
+
session before dispatch, which runs the app's middleware twice per call and puts
|
|
43
|
+
the transport in the business of second-guessing the middleware that owns session
|
|
44
|
+
resolution. The cheaper half — no credentials at all — covers the case that
|
|
45
|
+
actually matters, because that is the state every client starts in.
|
|
46
|
+
|
|
47
|
+
Also ruled out: gating the endpoint as a whole with `requireBearerAuth`. It would
|
|
48
|
+
work, and it would make every public tool private, which is a different product.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
type: decision
|
|
3
|
+
title: An OAuth2 credential is read by its account row, not by its provider name
|
|
4
|
+
description: better-auth 1.7.5 selects an account by row id under a strict body schema, so BetterAuthCredentialService resolves the row through the internal adapter before asking for a token
|
|
5
|
+
tags: better-auth, credentials, oauth2
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# An OAuth2 credential is read by its account row, not by its provider name
|
|
9
|
+
|
|
10
|
+
A pikku OAuth2 credential is named — `user-oauth`, `company-slack` — and that
|
|
11
|
+
name is the better-auth `providerId` its account row carries. Reading the
|
|
12
|
+
credential used to mean handing better-auth the name:
|
|
13
|
+
`getAccessToken({ body: { providerId, userId } })`.
|
|
14
|
+
|
|
15
|
+
better-auth 1.7.5 stopped accepting that. `get-access-token` and
|
|
16
|
+
`unlink-account` both take a strict union selecting the account by its **own row
|
|
17
|
+
id**, so a provider name is not merely ignored, it is rejected — `[body] Invalid
|
|
18
|
+
input`, a 500 on every read of a linked credential: a tool's token, an agent's,
|
|
19
|
+
the console's status card. An agent that cannot resolve its credential never
|
|
20
|
+
makes a model call, so the failure surfaces far from its cause.
|
|
21
|
+
|
|
22
|
+
So the name is resolved to a row first, through
|
|
23
|
+
`context.internalAdapter.findAccounts(userId)` — the same lookup the link
|
|
24
|
+
callback writes the row with and `unlinkAccount` already used. That is also the
|
|
25
|
+
only account lookup that takes a userId at all: `listUserAccounts` resolves the
|
|
26
|
+
*caller's session* and throws UNAUTHORIZED server-side, which is no use for the
|
|
27
|
+
two revocations that have no session — a platform credential, whose owner never
|
|
28
|
+
signs in, and an admin acting on someone else's.
|
|
29
|
+
|
|
30
|
+
A useful consequence: an unlinked provider is now answered before better-auth is
|
|
31
|
+
asked, rather than by catching its `ACCOUNT_NOT_FOUND`. The catch stays, because
|
|
32
|
+
a failed refresh must still not read as "not connected yet" — that would show a
|
|
33
|
+
connect button for an account that is linked but broken.
|
|
34
|
+
|
|
35
|
+
**What this rules out:** treating `providerId` as a credential's address
|
|
36
|
+
anywhere a token is read or revoked. It remains the credential's *name* — what
|
|
37
|
+
an app declares and what the link flow writes — but the row id is what
|
|
38
|
+
better-auth is spoken to in. A fake of better-auth that keys tokens by provider
|
|
39
|
+
name will pass while the real thing rejects every call, which is how this
|
|
40
|
+
survived a bump with 222 green unit tests behind it.
|
|
@@ -34,6 +34,8 @@ A rule about who may do what, and which way it fails when it is unsure.
|
|
|
34
34
|
- [Agent tool permission filtering reads the live function config, not the metadata](ai-agent-tool-filtering-reads-the-live-function-config.md) — The pikkuAuth brand survives only on live permission objects, so a metadata-driven check would silently admit every gated tool
|
|
35
35
|
- [An agent approval is claimed before the tool runs](an-agent-approval-is-claimed-before-the-tool-runs.md) — resolveApproval is a compare-and-swap returning whether this caller won, because the read that precedes it is not a claim and ten concurrent approvals would otherwise mean ten refunds
|
|
36
36
|
- [An empty owners constraint matches nothing](an-empty-owners-constraint-matches-nothing.md) — owners is an authorization boundary, so every storage backend must treat [] as no rows rather than no filter
|
|
37
|
+
- [An MCP refusal is decided before dispatch, not raised from the tool](an-mcp-refusal-is-decided-before-dispatch.md) — mcpTargetRequiresSession lets the transport answer 401 with an OAuth challenge, because the MCP response streams and the status is gone by the time the runner refuses
|
|
38
|
+
- [An OAuth2 credential is read by its account row, not by its provider name](an-oauth2-credential-is-read-by-its-account-row.md) — better-auth 1.7.5 selects an account by row id under a strict body schema, so BetterAuthCredentialService resolves the row through the internal adapter before asking for a token
|
|
37
39
|
- [An exposed function with no gate is reported at codegen, not at boot](an-exposed-ungated-function-is-a-codegen-warning.md) — The check runs in the inspector where function meta and every wireAddon declaration are both in hand, because neither source alone can tell a gated function from an ungated one
|
|
38
40
|
- [An upload is counted as it arrives, not buffered and then measured](an-upload-is-counted-as-it-arrives-not-buffered-then-measured.md) — Reading the whole body before checking its size hands an unauthenticated caller a way to spend the server's memory
|
|
39
41
|
- [The console addon's privileged functions gate themselves](console-addon-privileged-functions-gate-themselves.md) — Thread listing is owner-scoped unless the caller holds admin, and addon installation requires an admin session, rather than trusting the host to register a global permission
|