@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +221 -0
  2. package/dist/permissions.d.ts +12 -4
  3. package/dist/permissions.js +11 -32
  4. package/dist/testing/service-tests.js +37 -0
  5. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +64 -0
  6. package/dist/wirings/ai-agent/ai-agent-prepare.js +103 -5
  7. package/dist/wirings/ai-agent/ai-agent-runner.js +5 -1
  8. package/dist/wirings/ai-agent/ai-agent-stream.js +28 -7
  9. package/dist/wirings/ai-agent/ai-agent.types.d.ts +29 -1
  10. package/dist/wirings/ai-agent/index.d.ts +1 -1
  11. package/dist/wirings/ai-agent/index.js +1 -1
  12. package/dist/wirings/ai-agent/voice-input.js +3 -3
  13. package/dist/wirings/cli/cli-runner.js +3 -0
  14. package/dist/wirings/cli/command-parser.d.ts +2 -0
  15. package/dist/wirings/cli/command-parser.js +59 -2
  16. package/dist/wirings/credential/credential.types.d.ts +14 -0
  17. package/dist/wirings/credential/validate-credential-definitions.js +1 -0
  18. package/dist/wirings/gateway/gateway-runner.js +100 -50
  19. package/dist/wirings/gateway/gateway.types.d.ts +8 -5
  20. package/dist/wirings/http/http.types.d.ts +3 -3
  21. package/dist/wirings/secret/secret.types.d.ts +14 -0
  22. package/dist/wirings/secret/validate-secret-definitions.js +2 -0
  23. package/dist/wirings/variable/validate-variable-definitions.js +2 -0
  24. package/dist/wirings/variable/variable.types.d.ts +14 -0
  25. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +36 -6
  26. package/dist/wirings/workflow/pikku-workflow-service.d.ts +8 -0
  27. package/dist/wirings/workflow/pikku-workflow-service.js +16 -0
  28. package/dist/wirings/workflow/workflow.types.d.ts +0 -2
  29. package/package.json +2 -1
  30. package/src/permissions.test.ts +14 -8
  31. package/src/permissions.ts +14 -36
  32. package/src/testing/service-tests.ts +49 -0
  33. package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
  34. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +175 -0
  35. package/src/wirings/ai-agent/ai-agent-prepare.ts +132 -5
  36. package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
  37. package/src/wirings/ai-agent/ai-agent-runner.ts +7 -0
  38. package/src/wirings/ai-agent/ai-agent-stream.test.ts +103 -0
  39. package/src/wirings/ai-agent/ai-agent-stream.ts +38 -6
  40. package/src/wirings/ai-agent/ai-agent.types.ts +29 -0
  41. package/src/wirings/ai-agent/index.ts +4 -0
  42. package/src/wirings/ai-agent/voice-input.test.ts +90 -0
  43. package/src/wirings/ai-agent/voice-input.ts +8 -10
  44. package/src/wirings/cli/cli-runner.ts +4 -0
  45. package/src/wirings/cli/command-parser.test.ts +130 -0
  46. package/src/wirings/cli/command-parser.ts +80 -2
  47. package/src/wirings/credential/credential.types.ts +14 -0
  48. package/src/wirings/credential/validate-credential-definitions.ts +1 -0
  49. package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
  50. package/src/wirings/gateway/gateway-runner.ts +114 -68
  51. package/src/wirings/gateway/gateway.types.ts +7 -9
  52. package/src/wirings/http/http.types.ts +6 -4
  53. package/src/wirings/secret/secret.types.ts +14 -0
  54. package/src/wirings/secret/validate-secret-definitions.ts +2 -0
  55. package/src/wirings/variable/validate-variable-definitions.ts +2 -0
  56. package/src/wirings/variable/variable.types.ts +14 -0
  57. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +36 -6
  58. package/src/wirings/workflow/pikku-workflow-service.ts +36 -0
  59. package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
  60. package/src/wirings/workflow/workflow.types.ts +0 -2
  61. 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
- flushStep();
116
+ flushDetached();
101
117
  break;
102
118
  case 'done':
103
- flushStep();
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 transcribeAudio = services.aiAgentRunner?.transcribe;
27
- if (!transcribeAudio)
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 transcribeAudio({
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
  };
@@ -31,6 +31,7 @@ export function validateAndBuildCredentialDefinitionsMeta(definitions, schemaLoo
31
31
  type: def.type,
32
32
  schema: def.schema,
33
33
  oauth2: def.oauth2,
34
+ docsUrl: def.docsUrl,
34
35
  sourceFile: def.sourceFile,
35
36
  };
36
37
  }
@@ -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. Call user func with parsed message
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, func: userFunc, middleware: userMiddleware } = config;
115
- const userFuncConfig = userFunc;
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
- // Build combined middleware chain: gateway-level + func-level
140
- const allMiddleware = [
141
- ...(userMiddleware || []),
142
- ...(userFuncConfig.middleware || []),
143
- ];
144
- const exec = async () => {
145
- const result = await userFuncConfig.func(services, parsed, wire);
146
- // Auto-send response if the func returns outbound content
147
- if (result && (result.text || result.richContent || result.attachments)) {
148
- await adapter.send(parsed.senderId, result);
149
- }
150
- return { ok: true };
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
- if (allMiddleware.length > 0) {
153
- return await runMiddleware(services, wire, allMiddleware, exec);
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 await exec();
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 allMiddleware = [
253
- ...(userMiddleware || []),
254
- ...(userFuncConfig.middleware || []),
255
- ];
256
- const exec = async () => {
257
- const result = await userFuncConfig.func(services, parsed, wire);
258
- if (result &&
259
- (result.text || result.richContent || result.attachments)) {
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
- if (allMiddleware.length > 0) {
264
- await runMiddleware(services, wire, allMiddleware, exec);
265
- }
266
- else {
267
- await exec();
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 allMiddleware = [
319
- ...(userMiddleware || []),
320
- ...(userFuncConfig.middleware || []),
321
- ];
322
- const exec = async () => {
323
- const result = await userFuncConfig.func(singletonServices, parsed, wire);
324
- if (result && (result.text || result.richContent || result.attachments)) {
325
- await adapter.send(parsed.senderId, result);
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
- if (allMiddleware.length > 0) {
329
- await runMiddleware(singletonServices, wire, allMiddleware, exec);
330
- }
331
- else {
332
- await exec();
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, CorePermissionGroup, CorePikkuPermission } from '../../function/functions.types.js';
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>, PikkuPermission extends CorePikkuPermission = CorePikkuPermission, PikkuMiddleware extends CorePikkuMiddleware = CorePikkuMiddleware> = Partial<Pick<CommonWireMeta, 'title' | 'summary' | 'description' | 'errors'>> & {
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
- /** Whether authentication is required (default: true) */
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
  }