@pikku/core 0.12.64 → 0.12.66
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 +221 -0
- package/dist/permissions.d.ts +12 -4
- package/dist/permissions.js +11 -32
- package/dist/testing/service-tests.js +37 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +64 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.js +103 -5
- package/dist/wirings/ai-agent/ai-agent-runner.js +5 -1
- package/dist/wirings/ai-agent/ai-agent-stream.js +28 -7
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +29 -1
- package/dist/wirings/ai-agent/index.d.ts +1 -1
- package/dist/wirings/ai-agent/index.js +1 -1
- package/dist/wirings/ai-agent/voice-input.js +3 -3
- package/dist/wirings/cli/cli-runner.js +3 -0
- package/dist/wirings/cli/command-parser.d.ts +2 -0
- package/dist/wirings/cli/command-parser.js +59 -2
- package/dist/wirings/credential/credential.types.d.ts +14 -0
- package/dist/wirings/credential/validate-credential-definitions.js +1 -0
- package/dist/wirings/gateway/gateway-runner.js +100 -50
- package/dist/wirings/gateway/gateway.types.d.ts +8 -5
- package/dist/wirings/http/http.types.d.ts +3 -3
- package/dist/wirings/secret/secret.types.d.ts +14 -0
- package/dist/wirings/secret/validate-secret-definitions.js +2 -0
- package/dist/wirings/variable/validate-variable-definitions.js +2 -0
- package/dist/wirings/variable/variable.types.d.ts +14 -0
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +36 -6
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +8 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +16 -0
- package/dist/wirings/workflow/workflow.types.d.ts +0 -2
- package/package.json +2 -1
- package/src/permissions.test.ts +14 -8
- package/src/permissions.ts +14 -36
- package/src/testing/service-tests.ts +49 -0
- package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
- package/src/wirings/ai-agent/ai-agent-prepare.test.ts +175 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +132 -5
- package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
- package/src/wirings/ai-agent/ai-agent-runner.ts +7 -0
- package/src/wirings/ai-agent/ai-agent-stream.test.ts +103 -0
- package/src/wirings/ai-agent/ai-agent-stream.ts +38 -6
- package/src/wirings/ai-agent/ai-agent.types.ts +29 -0
- package/src/wirings/ai-agent/index.ts +4 -0
- package/src/wirings/ai-agent/voice-input.test.ts +90 -0
- package/src/wirings/ai-agent/voice-input.ts +8 -10
- package/src/wirings/cli/cli-runner.ts +4 -0
- package/src/wirings/cli/command-parser.test.ts +130 -0
- package/src/wirings/cli/command-parser.ts +80 -2
- package/src/wirings/credential/credential.types.ts +14 -0
- package/src/wirings/credential/validate-credential-definitions.ts +1 -0
- package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
- package/src/wirings/gateway/gateway-runner.ts +114 -68
- package/src/wirings/gateway/gateway.types.ts +7 -9
- package/src/wirings/http/http.types.ts +6 -4
- package/src/wirings/secret/secret.types.ts +14 -0
- package/src/wirings/secret/validate-secret-definitions.ts +2 -0
- package/src/wirings/variable/validate-variable-definitions.ts +2 -0
- package/src/wirings/variable/variable.types.ts +14 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +36 -6
- package/src/wirings/workflow/pikku-workflow-service.ts +36 -0
- package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
- package/src/wirings/workflow/workflow.types.ts +0 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -3,9 +3,9 @@ import { AIProviderNotConfiguredError } from '../../errors/errors.js';
|
|
|
3
3
|
import { randomUUID } from './ai-agent-utils.js';
|
|
4
4
|
import { combineChannelMiddleware, wrapChannelWithMiddleware, } from '../channel/channel-middleware-runner.js';
|
|
5
5
|
import { resolveMemoryServices, loadContextMessages, trimMessages, getWorkingMemoryMiddleware, } from './ai-agent-memory.js';
|
|
6
|
-
import { prepareAgentRun, resolveAgent, buildInstructions, buildToolDefs, createScopedChannel, resolveOwnerResourceId, agentSessionScope, assertResourceOwner, ToolApprovalRequired, ToolCredentialRequired, APPROVAL_REQUIRED, } from './ai-agent-prepare.js';
|
|
6
|
+
import { prepareAgentRun, resolveAgent, buildInstructions, buildToolDefs, createScopedChannel, resolveOwnerResourceId, agentSessionScope, assertResourceOwner, assertAgentAuthorized, ToolApprovalRequired, ToolCredentialRequired, APPROVAL_REQUIRED, } from './ai-agent-prepare.js';
|
|
7
7
|
import { resolveModelConfig } from './ai-agent-model-config.js';
|
|
8
|
-
function createPersistingChannel(parent, storage, threadId) {
|
|
8
|
+
function createPersistingChannel(parent, storage, threadId, logger) {
|
|
9
9
|
let fullText = '';
|
|
10
10
|
let stepText = '';
|
|
11
11
|
let stepGenerativeUI = null;
|
|
@@ -51,6 +51,22 @@ function createPersistingChannel(parent, storage, threadId) {
|
|
|
51
51
|
await storage.saveMessages(threadId, messages);
|
|
52
52
|
}
|
|
53
53
|
};
|
|
54
|
+
/**
|
|
55
|
+
* `send` is synchronous and so cannot await the flush. A rejection would have
|
|
56
|
+
* nothing to propagate to and would take the process down as an unhandled
|
|
57
|
+
* rejection — a model reusing a toolCallId, which is a primary key in AI
|
|
58
|
+
* storage, is enough to trigger it. Persistence from inside `send` is
|
|
59
|
+
* therefore best-effort: the run carries on, and the awaited `flush()` on the
|
|
60
|
+
* suspend paths still surfaces failures to its caller.
|
|
61
|
+
*/
|
|
62
|
+
const flushDetached = () => {
|
|
63
|
+
void flushStep().catch((error) => {
|
|
64
|
+
logger?.error('Failed to persist agent messages', {
|
|
65
|
+
threadId,
|
|
66
|
+
error,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
};
|
|
54
70
|
const channel = {
|
|
55
71
|
channelId: parent.channelId,
|
|
56
72
|
openingData: parent.openingData,
|
|
@@ -97,10 +113,10 @@ function createPersistingChannel(parent, storage, threadId) {
|
|
|
97
113
|
totalUsage.outputTokens += event.tokens.output;
|
|
98
114
|
if (event.model)
|
|
99
115
|
totalUsage.model = event.model;
|
|
100
|
-
|
|
116
|
+
flushDetached();
|
|
101
117
|
break;
|
|
102
118
|
case 'done':
|
|
103
|
-
|
|
119
|
+
flushDetached();
|
|
104
120
|
break;
|
|
105
121
|
}
|
|
106
122
|
}
|
|
@@ -469,7 +485,7 @@ export async function streamAIAgent(agentName, input, channel, params, agentSess
|
|
|
469
485
|
...streamMiddleware,
|
|
470
486
|
],
|
|
471
487
|
});
|
|
472
|
-
const persistingChannel = createPersistingChannel(channel, storage, threadId);
|
|
488
|
+
const persistingChannel = createPersistingChannel(channel, storage, threadId, singletonServices.logger);
|
|
473
489
|
const wrappedChannel = allChannelMiddleware.length > 0
|
|
474
490
|
? wrapChannelWithMiddleware({ channel: persistingChannel }, singletonServices, allChannelMiddleware).channel
|
|
475
491
|
: persistingChannel;
|
|
@@ -578,8 +594,13 @@ export async function resumeAIAgent(input, channel, params, options) {
|
|
|
578
594
|
if (!pending) {
|
|
579
595
|
throw new Error(`No pending approval for toolCallId ${input.toolCallId} on run ${input.runId}`);
|
|
580
596
|
}
|
|
581
|
-
await aiRunState.resolveApproval(input.toolCallId, input.approved ? 'approved' : 'denied');
|
|
582
597
|
const { agent, packageName, resolvedName } = resolveAgent(run.agentName);
|
|
598
|
+
// Gate before resolving the approval: recording it is a persisted side
|
|
599
|
+
// effect, so an unauthorized caller must not reach it. Run ownership alone is
|
|
600
|
+
// not enough — a grant revoked while the run was suspended must stop the
|
|
601
|
+
// caller from approving its pending tool calls.
|
|
602
|
+
await assertAgentAuthorized(agent, params, packageName);
|
|
603
|
+
await aiRunState.resolveApproval(input.toolCallId, input.approved ? 'approved' : 'denied');
|
|
583
604
|
const { storage } = resolveMemoryServices(agent, singletonServices);
|
|
584
605
|
const memoryConfig = agent.memory;
|
|
585
606
|
const agentRunner = singletonServices.aiAgentRunner;
|
|
@@ -807,7 +828,7 @@ async function continueAfterToolResult(run, agent, packageName, resolvedName, st
|
|
|
807
828
|
...streamMiddleware,
|
|
808
829
|
],
|
|
809
830
|
});
|
|
810
|
-
const persistingChannel = createPersistingChannel(channel, storage, run.threadId);
|
|
831
|
+
const persistingChannel = createPersistingChannel(channel, storage, run.threadId, singletonServices.logger);
|
|
811
832
|
const wrappedChannel = allChannelMiddleware.length > 0
|
|
812
833
|
? wrapChannelWithMiddleware({ channel: persistingChannel }, singletonServices, allChannelMiddleware).channel
|
|
813
834
|
: persistingChannel;
|
|
@@ -211,7 +211,7 @@ export type AIAgentMemoryConfig = {
|
|
|
211
211
|
lastMessages?: number;
|
|
212
212
|
workingMemory?: unknown;
|
|
213
213
|
};
|
|
214
|
-
export type CoreAIAgent<PikkuPermission = CorePikkuPermission<any, any>, PikkuMiddleware = CorePikkuMiddleware<any
|
|
214
|
+
export type CoreAIAgent<PikkuPermission = CorePikkuPermission<any, any>, PikkuMiddleware = CorePikkuMiddleware<any>, Scope extends string = string> = {
|
|
215
215
|
name: string;
|
|
216
216
|
description: string;
|
|
217
217
|
summary?: string;
|
|
@@ -244,6 +244,23 @@ export type CoreAIAgent<PikkuPermission = CorePikkuPermission<any, any>, PikkuMi
|
|
|
244
244
|
middleware?: PikkuMiddleware[];
|
|
245
245
|
channelMiddleware?: CorePikkuChannelMiddleware<any, any>[];
|
|
246
246
|
aiMiddleware?: PikkuAIMiddlewareHooks<any, any>[];
|
|
247
|
+
/**
|
|
248
|
+
* Whether a session is required to run this agent. Defaults to `false`, since
|
|
249
|
+
* agents are commonly invoked from an already-authenticated `pikkuFunc` or
|
|
250
|
+
* from genuinely sessionless contexts (crons, queue workers). Set `true` to
|
|
251
|
+
* require a session at the agent itself. `scopes` and `permissions` are
|
|
252
|
+
* enforced either way.
|
|
253
|
+
*/
|
|
254
|
+
auth?: boolean;
|
|
255
|
+
/**
|
|
256
|
+
* Scopes the session must hold to run this agent. All of them are required
|
|
257
|
+
* (AND), and they are checked before `permissions` — unlike permissions,
|
|
258
|
+
* which OR together, a scope can only narrow access.
|
|
259
|
+
*
|
|
260
|
+
* Narrowed to the generated `ScopeId` union in a project's own
|
|
261
|
+
* `pikku-types.gen.ts`, so an undeclared scope is a compile error.
|
|
262
|
+
*/
|
|
263
|
+
scopes?: Scope[];
|
|
247
264
|
permissions?: CorePermissionGroup<PikkuPermission>;
|
|
248
265
|
};
|
|
249
266
|
export type AIStreamEvent = {
|
|
@@ -413,6 +430,17 @@ export interface AgentRunService {
|
|
|
413
430
|
listThreads(options?: {
|
|
414
431
|
agentName?: string;
|
|
415
432
|
resourceId?: string;
|
|
433
|
+
/**
|
|
434
|
+
* Restrict results to threads owned by one of these session principals. A
|
|
435
|
+
* thread matches when its `resourceId` is the principal itself or one of its
|
|
436
|
+
* `principal:` sub-partitions, mirroring the composition
|
|
437
|
+
* `resolveOwnerResourceId` writes.
|
|
438
|
+
*
|
|
439
|
+
* Unlike `resourceId`, which is an optional exact-match filter, this is an
|
|
440
|
+
* authorization constraint: an empty array matches nothing. Callers exposing
|
|
441
|
+
* threads over the wire must derive it from the session, never from input.
|
|
442
|
+
*/
|
|
443
|
+
owners?: string[];
|
|
416
444
|
limit?: number;
|
|
417
445
|
offset?: number;
|
|
418
446
|
}): Promise<AIThread[]>;
|
|
@@ -4,6 +4,6 @@ export { runAIAgent, resumeAIAgentSync } from './ai-agent-runner.js';
|
|
|
4
4
|
export { streamAIAgent, resumeAIAgent } from './ai-agent-stream.js';
|
|
5
5
|
export { voiceInput } from './voice-input.js';
|
|
6
6
|
export { voiceOutput } from './voice-output.js';
|
|
7
|
-
export { type RunAIAgentParams, type StreamAIAgentOptions, ToolApprovalRequired, ToolCredentialRequired, } from './ai-agent-prepare.js';
|
|
7
|
+
export { type RunAIAgentParams, type StreamAIAgentOptions, ToolApprovalRequired, ToolCredentialRequired, canAccessThread, isOwnedByPrincipal, sessionPrincipals, threadOwnerConstraint, } from './ai-agent-prepare.js';
|
|
8
8
|
export { addAIAgent, approveAIAgent, getAIAgents, getAIAgentsMeta, } from './ai-agent-registry.js';
|
|
9
9
|
export type { AIAgentInput, AIAgentInputAttachment, AIAgentMeta, AIAgentMemoryConfig, AIAgentStep, AIContentPart, AgentRunRow, AgentRunService, AgentRunState, AIMessage, AIStreamChannel, AIStreamEvent, AIThread, CoreAIAgent, PendingApproval, PikkuAIMiddlewareHooks, } from './ai-agent.types.js';
|
|
@@ -4,5 +4,5 @@ export { runAIAgent, resumeAIAgentSync } from './ai-agent-runner.js';
|
|
|
4
4
|
export { streamAIAgent, resumeAIAgent } from './ai-agent-stream.js';
|
|
5
5
|
export { voiceInput } from './voice-input.js';
|
|
6
6
|
export { voiceOutput } from './voice-output.js';
|
|
7
|
-
export { ToolApprovalRequired, ToolCredentialRequired, } from './ai-agent-prepare.js';
|
|
7
|
+
export { ToolApprovalRequired, ToolCredentialRequired, canAccessThread, isOwnedByPrincipal, sessionPrincipals, threadOwnerConstraint, } from './ai-agent-prepare.js';
|
|
8
8
|
export { addAIAgent, approveAIAgent, getAIAgents, getAIAgentsMeta, } from './ai-agent-registry.js';
|
|
@@ -23,8 +23,8 @@ async function fetchAsUint8Array(url, allowedAudioHosts) {
|
|
|
23
23
|
}
|
|
24
24
|
export const voiceInput = (config) => pikkuAIMiddleware({
|
|
25
25
|
modifyInput: async (services, { messages, instructions }) => {
|
|
26
|
-
const
|
|
27
|
-
if (!
|
|
26
|
+
const aiAgentRunner = services.aiAgentRunner;
|
|
27
|
+
if (!aiAgentRunner?.transcribe)
|
|
28
28
|
return { messages, instructions };
|
|
29
29
|
const last = messages[messages.length - 1];
|
|
30
30
|
if (!last || last.role !== 'user' || typeof last.content === 'string') {
|
|
@@ -51,7 +51,7 @@ export const voiceInput = (config) => pikkuAIMiddleware({
|
|
|
51
51
|
const audioData = p.data
|
|
52
52
|
? base64ToUint8Array(p.data)
|
|
53
53
|
: await fetchAsUint8Array(p.url, config.allowedAudioHosts);
|
|
54
|
-
const result = await
|
|
54
|
+
const result = await aiAgentRunner.transcribe({
|
|
55
55
|
model: config.model,
|
|
56
56
|
audio: audioData,
|
|
57
57
|
...(config.language
|
|
@@ -319,6 +319,9 @@ export async function executeCLI({ programName, args, createConfig, createSingle
|
|
|
319
319
|
console.log(helpText);
|
|
320
320
|
return;
|
|
321
321
|
}
|
|
322
|
+
// Non-fatal diagnostics (unknown options are still accepted) go to stderr
|
|
323
|
+
// so they never pollute a command's machine-readable stdout.
|
|
324
|
+
parsed.warnings.forEach((warning) => console.error(`Warning: ${warning}`));
|
|
322
325
|
if (parsed.errors.length > 0) {
|
|
323
326
|
// Check if any error is about an unknown command
|
|
324
327
|
const hasUnknownCommand = parsed.errors.some((error) => error.startsWith('Unknown command:') ||
|
|
@@ -8,6 +8,8 @@ export interface ParsedCommand {
|
|
|
8
8
|
positionals: Record<string, any>;
|
|
9
9
|
options: Record<string, any>;
|
|
10
10
|
errors: string[];
|
|
11
|
+
/** Non-fatal diagnostics (e.g. unknown options that were accepted+ignored) */
|
|
12
|
+
warnings: string[];
|
|
11
13
|
}
|
|
12
14
|
/**
|
|
13
15
|
* Parses raw CLI arguments into structured data for a specific program
|
|
@@ -8,6 +8,54 @@ function toCamelCase(str) {
|
|
|
8
8
|
function toKebabCase(str) {
|
|
9
9
|
return str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
|
|
10
10
|
}
|
|
11
|
+
/** Options the runner handles itself — never reported as unknown. */
|
|
12
|
+
const RESERVED_OPTIONS = new Set(['help']);
|
|
13
|
+
/** Levenshtein distance, capped-free and dependency-free. Used only to suggest
|
|
14
|
+
* a near-miss option name, so the naive O(n*m) implementation is fine. */
|
|
15
|
+
function levenshtein(a, b) {
|
|
16
|
+
if (a === b)
|
|
17
|
+
return 0;
|
|
18
|
+
if (a.length === 0)
|
|
19
|
+
return b.length;
|
|
20
|
+
if (b.length === 0)
|
|
21
|
+
return a.length;
|
|
22
|
+
let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
23
|
+
for (let i = 1; i <= a.length; i++) {
|
|
24
|
+
const row = [i];
|
|
25
|
+
for (let j = 1; j <= b.length; j++) {
|
|
26
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
27
|
+
row[j] = Math.min(row[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
28
|
+
}
|
|
29
|
+
prev = row;
|
|
30
|
+
}
|
|
31
|
+
return prev[b.length];
|
|
32
|
+
}
|
|
33
|
+
/** Finds the closest declared option (distance <= 2) to what the user typed.
|
|
34
|
+
* Compares against the kebab-case rendering, since that is what is typed. */
|
|
35
|
+
function suggestOption(typed, availableOptions) {
|
|
36
|
+
let best = null;
|
|
37
|
+
let bestDistance = 3;
|
|
38
|
+
for (const name of Object.keys(availableOptions)) {
|
|
39
|
+
const kebab = toKebabCase(name);
|
|
40
|
+
const distance = Math.min(levenshtein(typed, kebab), levenshtein(typed, name));
|
|
41
|
+
if (distance < bestDistance) {
|
|
42
|
+
bestDistance = distance;
|
|
43
|
+
best = kebab;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return best;
|
|
47
|
+
}
|
|
48
|
+
/** Records a warning that an unknown long option was accepted but ignored.
|
|
49
|
+
* Unknown options stay non-fatal for forward compatibility (a newer command
|
|
50
|
+
* version may understand them) — they are just no longer silent. */
|
|
51
|
+
function warnUnknownOption(typed, availableOptions, result) {
|
|
52
|
+
if (RESERVED_OPTIONS.has(toCamelCase(typed))) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const suggestion = suggestOption(typed, availableOptions);
|
|
56
|
+
result.warnings.push(`Unknown option: --${typed} (ignored)` +
|
|
57
|
+
(suggestion ? ` Did you mean --${suggestion}?` : ''));
|
|
58
|
+
}
|
|
11
59
|
/**
|
|
12
60
|
* Parses raw CLI arguments into structured data for a specific program
|
|
13
61
|
*/
|
|
@@ -18,6 +66,7 @@ export function parseCLIArguments(args, programName, allMeta) {
|
|
|
18
66
|
positionals: {},
|
|
19
67
|
options: {},
|
|
20
68
|
errors: [],
|
|
69
|
+
warnings: [],
|
|
21
70
|
};
|
|
22
71
|
const meta = allMeta.programs[programName];
|
|
23
72
|
if (!meta) {
|
|
@@ -106,7 +155,11 @@ export function parseCLIArguments(args, programName, allMeta) {
|
|
|
106
155
|
// --option=value format
|
|
107
156
|
const key = toCamelCase(arg.slice(2, equalIndex));
|
|
108
157
|
const optionDef = availableOptions[key];
|
|
109
|
-
// Unknown options are allowed for forward compatibility
|
|
158
|
+
// Unknown options are allowed for forward compatibility, but warned
|
|
159
|
+
// about so they are not silently dropped by the input schema.
|
|
160
|
+
if (!optionDef) {
|
|
161
|
+
warnUnknownOption(arg.slice(2, equalIndex), availableOptions, result);
|
|
162
|
+
}
|
|
110
163
|
const value = arg.slice(equalIndex + 1);
|
|
111
164
|
optionArgs[key] = parseOptionValue(value, optionDef);
|
|
112
165
|
}
|
|
@@ -114,7 +167,11 @@ export function parseCLIArguments(args, programName, allMeta) {
|
|
|
114
167
|
// --option value format
|
|
115
168
|
const key = toCamelCase(arg.slice(2));
|
|
116
169
|
const optionDef = availableOptions[key];
|
|
117
|
-
// Unknown options are allowed for forward compatibility
|
|
170
|
+
// Unknown options are allowed for forward compatibility, but warned
|
|
171
|
+
// about so they are not silently dropped by the input schema.
|
|
172
|
+
if (!optionDef) {
|
|
173
|
+
warnUnknownOption(arg.slice(2), availableOptions, result);
|
|
174
|
+
}
|
|
118
175
|
if (optionDef && optionDef.array) {
|
|
119
176
|
// Array option - collect all following non-flag values
|
|
120
177
|
currentIndex++;
|
|
@@ -5,6 +5,13 @@ export type CoreCredential<T = unknown> = {
|
|
|
5
5
|
description?: string;
|
|
6
6
|
type: 'singleton' | 'wire';
|
|
7
7
|
schema: T;
|
|
8
|
+
/**
|
|
9
|
+
* Link to documentation explaining how to obtain this value — a provider's
|
|
10
|
+
* API-key page, a setup guide, an internal runbook. Surfaced by consoles and
|
|
11
|
+
* deploy UIs so a user facing a missing value has somewhere to go instead of
|
|
12
|
+
* an opaque identifier.
|
|
13
|
+
*/
|
|
14
|
+
docsUrl?: string;
|
|
8
15
|
oauth2?: OAuth2CredentialConfig & {
|
|
9
16
|
appCredentialSecretId: string;
|
|
10
17
|
};
|
|
@@ -15,6 +22,13 @@ export type CredentialDefinitionMeta = {
|
|
|
15
22
|
description?: string;
|
|
16
23
|
type: 'singleton' | 'wire';
|
|
17
24
|
schema?: Record<string, unknown> | string;
|
|
25
|
+
/**
|
|
26
|
+
* Link to documentation explaining how to obtain this value — a provider's
|
|
27
|
+
* API-key page, a setup guide, an internal runbook. Surfaced by consoles and
|
|
28
|
+
* deploy UIs so a user facing a missing value has somewhere to go instead of
|
|
29
|
+
* an opaque identifier.
|
|
30
|
+
*/
|
|
31
|
+
docsUrl?: string;
|
|
18
32
|
oauth2?: OAuth2CredentialConfig & {
|
|
19
33
|
appCredentialSecretId: string;
|
|
20
34
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { pikkuState } from '../../pikku-state.js';
|
|
2
2
|
import { NotFoundError, UnauthorizedError } from '../../errors/errors.js';
|
|
3
|
-
import { addFunction } from '../../function/function-runner.js';
|
|
3
|
+
import { addFunction, runPikkuFunc } from '../../function/function-runner.js';
|
|
4
4
|
import { runMiddleware } from '../../middleware-runner.js';
|
|
5
5
|
import { httpRouter } from '../http/routers/http-router.js';
|
|
6
6
|
/**
|
|
@@ -9,6 +9,52 @@ import { httpRouter } from '../http/routers/http-router.js';
|
|
|
9
9
|
* requests share one construction).
|
|
10
10
|
*/
|
|
11
11
|
const resolvedAdapters = new WeakMap();
|
|
12
|
+
/**
|
|
13
|
+
* The generated function id a gateway's handler is registered under.
|
|
14
|
+
*/
|
|
15
|
+
const gatewayHandlerFuncId = (name) => `gateway__${name}__handler`;
|
|
16
|
+
/**
|
|
17
|
+
* Bridges a session established by gateway middleware onto the wire so the
|
|
18
|
+
* handler's gate can see it.
|
|
19
|
+
*
|
|
20
|
+
* Gateway middleware is the only place a webhook can acquire a session (e.g.
|
|
21
|
+
* mapping a verified platform sender to a user). Middleware that assigns
|
|
22
|
+
* `wire.session` needs nothing, but middleware using the idiomatic
|
|
23
|
+
* `wire.setSession()` writes to the enclosing wiring's session service, which
|
|
24
|
+
* the handler's own invocation does not read — without this the session would
|
|
25
|
+
* be silently invisible to `auth` and `scopes`.
|
|
26
|
+
*/
|
|
27
|
+
const bridgeMiddlewareSession = async (wire) => {
|
|
28
|
+
if (wire.session || !wire.getSession)
|
|
29
|
+
return;
|
|
30
|
+
const session = await wire.getSession();
|
|
31
|
+
if (session) {
|
|
32
|
+
wire.session = session;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Registers a gateway's handler as a real pikku function so that invoking it
|
|
37
|
+
* goes through the function runner's gate. Without this the handler is called
|
|
38
|
+
* directly and its own `auth`, `scopes` and `permissions` are never evaluated.
|
|
39
|
+
*
|
|
40
|
+
* The handler is registered as sessionless: a gateway's inbound traffic is
|
|
41
|
+
* platform-authenticated (adapter signature verification), not session-bearing,
|
|
42
|
+
* so requiring a session by default would break every webhook. A handler that
|
|
43
|
+
* does need one declares `auth: true`, exactly like `pikkuSessionlessFunc`.
|
|
44
|
+
* `scopes` and `permissions` are always enforced when declared.
|
|
45
|
+
*/
|
|
46
|
+
const registerGatewayHandler = (config) => {
|
|
47
|
+
const funcId = gatewayHandlerFuncId(config.name);
|
|
48
|
+
const funcMeta = pikkuState(null, 'function', 'meta');
|
|
49
|
+
funcMeta[funcId] = {
|
|
50
|
+
pikkuFuncId: funcId,
|
|
51
|
+
inputSchemaName: null,
|
|
52
|
+
outputSchemaName: null,
|
|
53
|
+
sessionless: true,
|
|
54
|
+
};
|
|
55
|
+
addFunction(funcId, config.func);
|
|
56
|
+
return funcId;
|
|
57
|
+
};
|
|
12
58
|
export const resolveGatewayAdapter = (config, services) => {
|
|
13
59
|
let resolved = resolvedAdapters.get(config);
|
|
14
60
|
if (!resolved) {
|
|
@@ -107,12 +153,13 @@ const wireWebhookGateway = (config) => {
|
|
|
107
153
|
* 2. Parse body via adapter → GatewayInboundMessage (or null to ignore)
|
|
108
154
|
* 3. Populate `wire.gateway`
|
|
109
155
|
* 4. Run user middleware (which can read `wire.gateway` for auth)
|
|
110
|
-
* 5.
|
|
156
|
+
* 5. Invoke the handler through the function runner, which enforces its
|
|
157
|
+
* `auth`/`scopes`/`permissions` before running it
|
|
111
158
|
* 6. Auto-send response via adapter if func returns outbound content
|
|
112
159
|
*/
|
|
113
160
|
const createWebhookPostHandler = (config) => {
|
|
114
|
-
const { name,
|
|
115
|
-
const
|
|
161
|
+
const { name, middleware: userMiddleware } = config;
|
|
162
|
+
const handlerFuncId = registerGatewayHandler(config);
|
|
116
163
|
return async (services, data, wire) => {
|
|
117
164
|
const adapter = await resolveGatewayAdapter(config, services);
|
|
118
165
|
// Check for POST-based webhook verification (e.g. Slack url_verification)
|
|
@@ -136,23 +183,28 @@ const createWebhookPostHandler = (config) => {
|
|
|
136
183
|
send: (msg) => adapter.send(parsed.senderId, msg),
|
|
137
184
|
};
|
|
138
185
|
wire.gateway = gateway;
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
186
|
+
// Gateway middleware runs first and outside the gate, so it can establish
|
|
187
|
+
// the session the gate then checks. The handler is invoked through the
|
|
188
|
+
// function runner, which enforces its auth, scopes and permissions and
|
|
189
|
+
// applies the handler's own middleware.
|
|
190
|
+
const invoke = async () => {
|
|
191
|
+
await bridgeMiddlewareSession(wire);
|
|
192
|
+
return await runPikkuFunc('gateway', name, handlerFuncId, {
|
|
193
|
+
singletonServices: services,
|
|
194
|
+
data: () => parsed,
|
|
195
|
+
auth: config.auth,
|
|
196
|
+
wire: wire,
|
|
197
|
+
});
|
|
151
198
|
};
|
|
152
|
-
|
|
153
|
-
|
|
199
|
+
const gatewayMiddleware = userMiddleware;
|
|
200
|
+
const result = gatewayMiddleware?.length
|
|
201
|
+
? await runMiddleware(services, wire, gatewayMiddleware, invoke)
|
|
202
|
+
: await invoke();
|
|
203
|
+
// Auto-send response if the func returns outbound content
|
|
204
|
+
if (result && (result.text || result.richContent || result.attachments)) {
|
|
205
|
+
await adapter.send(parsed.senderId, result);
|
|
154
206
|
}
|
|
155
|
-
return
|
|
207
|
+
return { ok: true };
|
|
156
208
|
};
|
|
157
209
|
};
|
|
158
210
|
/**
|
|
@@ -215,8 +267,8 @@ const wireWebsocketGateway = (config) => {
|
|
|
215
267
|
connect: { pikkuFuncId: connectFuncId },
|
|
216
268
|
message: { pikkuFuncId: messageFuncId },
|
|
217
269
|
};
|
|
218
|
-
const userFuncConfig = config.func;
|
|
219
270
|
const userMiddleware = config.middleware;
|
|
271
|
+
const handlerFuncId = registerGatewayHandler(config);
|
|
220
272
|
// Register onConnect
|
|
221
273
|
addFunction(connectFuncId, {
|
|
222
274
|
auth: false,
|
|
@@ -249,22 +301,21 @@ const wireWebsocketGateway = (config) => {
|
|
|
249
301
|
},
|
|
250
302
|
};
|
|
251
303
|
wire.gateway = gateway;
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
wire.channel?.send(result);
|
|
261
|
-
}
|
|
304
|
+
const invoke = async () => {
|
|
305
|
+
await bridgeMiddlewareSession(wire);
|
|
306
|
+
return await runPikkuFunc('gateway', name, handlerFuncId, {
|
|
307
|
+
singletonServices: services,
|
|
308
|
+
data: () => parsed,
|
|
309
|
+
auth: config.auth,
|
|
310
|
+
wire: wire,
|
|
311
|
+
});
|
|
262
312
|
};
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
313
|
+
const gatewayMiddleware = userMiddleware;
|
|
314
|
+
const result = gatewayMiddleware?.length
|
|
315
|
+
? await runMiddleware(services, wire, gatewayMiddleware, invoke)
|
|
316
|
+
: await invoke();
|
|
317
|
+
if (result && (result.text || result.richContent || result.attachments)) {
|
|
318
|
+
wire.channel?.send(result);
|
|
268
319
|
}
|
|
269
320
|
},
|
|
270
321
|
});
|
|
@@ -300,8 +351,8 @@ const wireListenerGateway = (config) => {
|
|
|
300
351
|
* @param singletonServices - Singleton services to pass to handler/middleware
|
|
301
352
|
*/
|
|
302
353
|
export const createListenerMessageHandler = (name, config, singletonServices) => {
|
|
303
|
-
const userFuncConfig = config.func;
|
|
304
354
|
const userMiddleware = config.middleware;
|
|
355
|
+
const handlerFuncId = registerGatewayHandler(config);
|
|
305
356
|
return async (rawData) => {
|
|
306
357
|
const adapter = await resolveGatewayAdapter(config, singletonServices);
|
|
307
358
|
const parsed = adapter.parse(rawData);
|
|
@@ -315,21 +366,20 @@ export const createListenerMessageHandler = (name, config, singletonServices) =>
|
|
|
315
366
|
send: (msg) => adapter.send(parsed.senderId, msg),
|
|
316
367
|
};
|
|
317
368
|
wire.gateway = gateway;
|
|
318
|
-
const
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
}
|
|
369
|
+
const invoke = async () => {
|
|
370
|
+
await bridgeMiddlewareSession(wire);
|
|
371
|
+
return await runPikkuFunc('gateway', name, handlerFuncId, {
|
|
372
|
+
singletonServices,
|
|
373
|
+
data: () => parsed,
|
|
374
|
+
auth: config.auth,
|
|
375
|
+
wire,
|
|
376
|
+
});
|
|
327
377
|
};
|
|
328
|
-
|
|
329
|
-
await runMiddleware(singletonServices, wire,
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
await
|
|
378
|
+
const result = userMiddleware?.length
|
|
379
|
+
? await runMiddleware(singletonServices, wire, userMiddleware, invoke)
|
|
380
|
+
: await invoke();
|
|
381
|
+
if (result && (result.text || result.richContent || result.attachments)) {
|
|
382
|
+
await adapter.send(parsed.senderId, result);
|
|
333
383
|
}
|
|
334
384
|
};
|
|
335
385
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { CommonWireMeta, CorePikkuMiddleware, CorePikkuMiddlewareGroup, CoreSingletonServices } from '../../types/core.types.js';
|
|
2
|
-
import type { CorePikkuFunctionConfig
|
|
2
|
+
import type { CorePikkuFunctionConfig } from '../../function/functions.types.js';
|
|
3
3
|
import type { PikkuHTTPRequest } from '../http/http.types.js';
|
|
4
4
|
/**
|
|
5
5
|
* Attachment in gateway messages (images, files, etc.)
|
|
@@ -102,7 +102,7 @@ export type GatewayTransportType = 'webhook' | 'websocket' | 'listener';
|
|
|
102
102
|
/**
|
|
103
103
|
* Core gateway configuration for wireGateway()
|
|
104
104
|
*/
|
|
105
|
-
export type CoreGateway<PikkuFunctionConfig = CorePikkuFunctionConfig<any, any>,
|
|
105
|
+
export type CoreGateway<PikkuFunctionConfig = CorePikkuFunctionConfig<any, any>, PikkuMiddleware extends CorePikkuMiddleware = CorePikkuMiddleware> = Partial<Pick<CommonWireMeta, 'title' | 'summary' | 'description' | 'errors'>> & {
|
|
106
106
|
/** Unique name for this gateway */
|
|
107
107
|
name: string;
|
|
108
108
|
/** Transport type */
|
|
@@ -117,11 +117,14 @@ export type CoreGateway<PikkuFunctionConfig = CorePikkuFunctionConfig<any, any>,
|
|
|
117
117
|
func: PikkuFunctionConfig;
|
|
118
118
|
/** Optional middleware chain (e.g., auth) */
|
|
119
119
|
middleware?: CorePikkuMiddlewareGroup<any, any>;
|
|
120
|
-
/** Optional permissions */
|
|
121
|
-
permissions?: CorePermissionGroup | PikkuPermission[];
|
|
122
120
|
/** Optional tags for categorization */
|
|
123
121
|
tags?: string[];
|
|
124
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* Whether the handler requires a session. Left unset, the handler's own
|
|
124
|
+
* `auth` governs, and a gateway handler is sessionless by default — inbound
|
|
125
|
+
* gateway traffic is platform-authenticated by the adapter, not
|
|
126
|
+
* session-bearing. Set `true` to require a session for every message.
|
|
127
|
+
*/
|
|
125
128
|
auth?: boolean;
|
|
126
129
|
};
|
|
127
130
|
/**
|
|
@@ -71,7 +71,7 @@ export type PikkuQuery<T = Record<string, string | undefined>> = Record<string,
|
|
|
71
71
|
* @template PikkuFunctionSessionless - The sessionless API function type, defaults to `CorePikkuFunctionSessionless`.
|
|
72
72
|
* @template PikkuPermission - The permission function type, defaults to `CorePikkuPermission`.
|
|
73
73
|
*/
|
|
74
|
-
export type CoreHTTPFunctionWiring<In, Out, R extends string, PikkuFunction extends CorePikkuFunction<In, Out, any, any, any> = CorePikkuFunction<In, Out>, PikkuFunctionSessionless extends CorePikkuFunctionSessionless<In, Out, any, any, any> = CorePikkuFunctionSessionless<In, Out>, PikkuPermission extends CorePikkuPermission<In, any, any> = CorePikkuPermission<In, any, any>, PikkuMiddleware extends CorePikkuMiddleware<any, any> = CorePikkuMiddleware<any>> = (CoreHTTPFunction & {
|
|
74
|
+
export type CoreHTTPFunctionWiring<In, Out, R extends string, PikkuFunction extends CorePikkuFunction<In, Out, any, any, any> = CorePikkuFunction<In, Out>, PikkuFunctionSessionless extends CorePikkuFunctionSessionless<In, Out, any, any, any> = CorePikkuFunctionSessionless<In, Out>, PikkuPermission extends CorePikkuPermission<In, any, any> = CorePikkuPermission<In, any, any>, PikkuMiddleware extends CorePikkuMiddleware<any, any> = CorePikkuMiddleware<any, any>> = (CoreHTTPFunction & {
|
|
75
75
|
route: R;
|
|
76
76
|
method: HTTPMethod;
|
|
77
77
|
func: CorePikkuFunctionConfig<PikkuFunction, PikkuPermission, PikkuMiddleware>;
|
|
@@ -177,7 +177,7 @@ export interface PikkuHTTPResponse<Out = unknown> {
|
|
|
177
177
|
/**
|
|
178
178
|
* Single route configuration - supports all wireHTTP options
|
|
179
179
|
*/
|
|
180
|
-
export type HTTPRouteConfig<PikkuFunction extends CorePikkuFunction<any, any, any, any, any> | CorePikkuFunctionSessionless<any, any, any, any, any> = CorePikkuFunction<any, any, any, any, any> | CorePikkuFunctionSessionless<any, any, any, any, any>, PikkuPermission extends CorePikkuPermission<any, any, any> = CorePikkuPermission<any>, PikkuMiddleware extends CorePikkuMiddleware<any, any> = CorePikkuMiddleware<any, any>> = HTTPRouteBaseConfig & {
|
|
180
|
+
export type HTTPRouteConfig<PikkuFunction extends CorePikkuFunction<any, any, any, any, any> | CorePikkuFunctionSessionless<any, any, any, any, any> = CorePikkuFunction<any, any, any, any, any> | CorePikkuFunctionSessionless<any, any, any, any, any>, PikkuPermission extends CorePikkuPermission<any, any, any> = CorePikkuPermission<any, any, any>, PikkuMiddleware extends CorePikkuMiddleware<any, any> = CorePikkuMiddleware<any, any>> = HTTPRouteBaseConfig & {
|
|
181
181
|
method: HTTPMethod;
|
|
182
182
|
route: string;
|
|
183
183
|
func: CorePikkuFunctionConfig<PikkuFunction, PikkuPermission, PikkuMiddleware>;
|
|
@@ -188,7 +188,7 @@ export type HTTPRouteConfig<PikkuFunction extends CorePikkuFunction<any, any, an
|
|
|
188
188
|
/**
|
|
189
189
|
* Group-level configuration applied to all routes
|
|
190
190
|
*/
|
|
191
|
-
export type HTTPRoutesGroupConfig<PikkuPermission extends CorePikkuPermission<any, any, any> = CorePikkuPermission<any>, PikkuMiddleware extends CorePikkuMiddleware<any, any> = CorePikkuMiddleware<any, any>> = {
|
|
191
|
+
export type HTTPRoutesGroupConfig<PikkuPermission extends CorePikkuPermission<any, any, any> = CorePikkuPermission<any, any, any>, PikkuMiddleware extends CorePikkuMiddleware<any, any> = CorePikkuMiddleware<any, any>> = {
|
|
192
192
|
basePath?: string;
|
|
193
193
|
tags?: string[];
|
|
194
194
|
auth?: boolean;
|
|
@@ -4,6 +4,13 @@ export type CoreSecret<T = unknown> = {
|
|
|
4
4
|
description?: string;
|
|
5
5
|
secretId: string;
|
|
6
6
|
schema: T;
|
|
7
|
+
/**
|
|
8
|
+
* Link to documentation explaining how to obtain this value — a provider's
|
|
9
|
+
* API-key page, a setup guide, an internal runbook. Surfaced by consoles and
|
|
10
|
+
* deploy UIs so a user facing a missing value has somewhere to go instead of
|
|
11
|
+
* an opaque identifier.
|
|
12
|
+
*/
|
|
13
|
+
docsUrl?: string;
|
|
7
14
|
/**
|
|
8
15
|
* Optional rotation cadence for this secret, e.g. '1d', '30day', '1w'.
|
|
9
16
|
* Stored in the generated secrets metadata so consumers can tell when a
|
|
@@ -25,6 +32,13 @@ export type SecretDefinitionMeta = {
|
|
|
25
32
|
description?: string;
|
|
26
33
|
secretId: string;
|
|
27
34
|
schema?: Record<string, unknown> | string;
|
|
35
|
+
/**
|
|
36
|
+
* Link to documentation explaining how to obtain this value — a provider's
|
|
37
|
+
* API-key page, a setup guide, an internal runbook. Surfaced by consoles and
|
|
38
|
+
* deploy UIs so a user facing a missing value has somewhere to go instead of
|
|
39
|
+
* an opaque identifier.
|
|
40
|
+
*/
|
|
41
|
+
docsUrl?: string;
|
|
28
42
|
oauth2?: OAuth2CredentialConfig;
|
|
29
43
|
rotationPeriod?: string;
|
|
30
44
|
sourceFile?: string;
|
|
@@ -34,6 +34,7 @@ export function validateAndBuildSecretDefinitionsMeta(definitions, schemaLookup)
|
|
|
34
34
|
schema: def.schema,
|
|
35
35
|
oauth2: def.oauth2,
|
|
36
36
|
rotationPeriod: def.rotationPeriod,
|
|
37
|
+
docsUrl: def.docsUrl,
|
|
37
38
|
sourceFile: def.sourceFile,
|
|
38
39
|
};
|
|
39
40
|
}
|
|
@@ -49,6 +50,7 @@ export function validateAndBuildSecretDefinitionsMeta(definitions, schemaLookup)
|
|
|
49
50
|
schema: def.schema,
|
|
50
51
|
oauth2: def.oauth2,
|
|
51
52
|
rotationPeriod: def.rotationPeriod,
|
|
53
|
+
docsUrl: def.docsUrl,
|
|
52
54
|
sourceFile: def.sourceFile,
|
|
53
55
|
};
|
|
54
56
|
}
|