@pikku/core 0.12.64 → 0.12.67

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 (74) hide show
  1. package/CHANGELOG.md +311 -0
  2. package/dist/index.d.ts +2 -2
  3. package/dist/index.js +1 -1
  4. package/dist/permissions.d.ts +12 -4
  5. package/dist/permissions.js +11 -32
  6. package/dist/scopes.d.ts +14 -0
  7. package/dist/scopes.js +39 -8
  8. package/dist/testing/service-tests.js +37 -0
  9. package/dist/types/core.types.d.ts +22 -0
  10. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +64 -0
  11. package/dist/wirings/ai-agent/ai-agent-prepare.js +103 -5
  12. package/dist/wirings/ai-agent/ai-agent-runner.js +5 -1
  13. package/dist/wirings/ai-agent/ai-agent-stream.js +28 -7
  14. package/dist/wirings/ai-agent/ai-agent.types.d.ts +29 -1
  15. package/dist/wirings/ai-agent/index.d.ts +1 -1
  16. package/dist/wirings/ai-agent/index.js +1 -1
  17. package/dist/wirings/ai-agent/voice-input.js +3 -3
  18. package/dist/wirings/cli/cli-runner.js +3 -0
  19. package/dist/wirings/cli/command-parser.d.ts +2 -0
  20. package/dist/wirings/cli/command-parser.js +59 -2
  21. package/dist/wirings/credential/credential.types.d.ts +14 -0
  22. package/dist/wirings/credential/validate-credential-definitions.js +1 -0
  23. package/dist/wirings/gateway/gateway-runner.js +100 -50
  24. package/dist/wirings/gateway/gateway.types.d.ts +8 -5
  25. package/dist/wirings/http/http.types.d.ts +3 -3
  26. package/dist/wirings/scope/validate-scope-definitions.d.ts +8 -0
  27. package/dist/wirings/scope/validate-scope-definitions.js +16 -1
  28. package/dist/wirings/secret/secret.types.d.ts +14 -0
  29. package/dist/wirings/secret/validate-secret-definitions.js +2 -0
  30. package/dist/wirings/variable/validate-variable-definitions.js +2 -0
  31. package/dist/wirings/variable/variable.types.d.ts +14 -0
  32. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +36 -6
  33. package/dist/wirings/workflow/pikku-workflow-service.d.ts +8 -0
  34. package/dist/wirings/workflow/pikku-workflow-service.js +16 -0
  35. package/dist/wirings/workflow/workflow.types.d.ts +0 -2
  36. package/package.json +2 -1
  37. package/src/index.ts +2 -1
  38. package/src/permissions.test.ts +14 -8
  39. package/src/permissions.ts +14 -36
  40. package/src/scopes.test.ts +37 -1
  41. package/src/scopes.ts +48 -9
  42. package/src/testing/service-tests.ts +49 -0
  43. package/src/types/core.types.ts +23 -0
  44. package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
  45. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +175 -0
  46. package/src/wirings/ai-agent/ai-agent-prepare.ts +132 -5
  47. package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
  48. package/src/wirings/ai-agent/ai-agent-runner.ts +7 -0
  49. package/src/wirings/ai-agent/ai-agent-stream.test.ts +103 -0
  50. package/src/wirings/ai-agent/ai-agent-stream.ts +38 -6
  51. package/src/wirings/ai-agent/ai-agent.types.ts +29 -0
  52. package/src/wirings/ai-agent/index.ts +4 -0
  53. package/src/wirings/ai-agent/voice-input.test.ts +90 -0
  54. package/src/wirings/ai-agent/voice-input.ts +8 -10
  55. package/src/wirings/cli/cli-runner.ts +4 -0
  56. package/src/wirings/cli/command-parser.test.ts +130 -0
  57. package/src/wirings/cli/command-parser.ts +80 -2
  58. package/src/wirings/credential/credential.types.ts +14 -0
  59. package/src/wirings/credential/validate-credential-definitions.ts +1 -0
  60. package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
  61. package/src/wirings/gateway/gateway-runner.ts +114 -68
  62. package/src/wirings/gateway/gateway.types.ts +7 -9
  63. package/src/wirings/http/http.types.ts +6 -4
  64. package/src/wirings/scope/scope.test.ts +25 -0
  65. package/src/wirings/scope/validate-scope-definitions.ts +16 -1
  66. package/src/wirings/secret/secret.types.ts +14 -0
  67. package/src/wirings/secret/validate-secret-definitions.ts +2 -0
  68. package/src/wirings/variable/validate-variable-definitions.ts +2 -0
  69. package/src/wirings/variable/variable.types.ts +14 -0
  70. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +36 -6
  71. package/src/wirings/workflow/pikku-workflow-service.ts +36 -0
  72. package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
  73. package/src/wirings/workflow/workflow.types.ts +0 -2
  74. package/tsconfig.tsbuildinfo +1 -1
@@ -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;
@@ -3,6 +3,14 @@ import type { FlatScope, ScopeDefinitions, ScopeDefinitionsMeta } from './scope.
3
3
  * Flattens declared scope trees into the full list of grantable scope ids,
4
4
  * depth-first. Every node is emitted, including intermediate ones.
5
5
  *
6
+ * Ids are unique. A root may legitimately be declared more than once — an addon
7
+ * and its host app both contributing the same `admin` tree, say — and
8
+ * {@link validateAndBuildScopeDefinitionsMeta} already guarantees those
9
+ * declarations are identical, so the second one is redundant rather than
10
+ * conflicting. Collapsing it here keeps every consumer honest: codegen emits an
11
+ * object literal keyed by id (duplicates are a TypeScript error), and a
12
+ * ScopeService syncs one row per scope instead of re-writing the same one.
13
+ *
6
14
  * Used by codegen to build the `ScopeId` union, and by a ScopeService to sync
7
15
  * the declared set into its store.
8
16
  */
@@ -30,6 +30,14 @@ const flattenNodes = (nodes, prefix, out) => {
30
30
  * Flattens declared scope trees into the full list of grantable scope ids,
31
31
  * depth-first. Every node is emitted, including intermediate ones.
32
32
  *
33
+ * Ids are unique. A root may legitimately be declared more than once — an addon
34
+ * and its host app both contributing the same `admin` tree, say — and
35
+ * {@link validateAndBuildScopeDefinitionsMeta} already guarantees those
36
+ * declarations are identical, so the second one is redundant rather than
37
+ * conflicting. Collapsing it here keeps every consumer honest: codegen emits an
38
+ * object literal keyed by id (duplicates are a TypeScript error), and a
39
+ * ScopeService syncs one row per scope instead of re-writing the same one.
40
+ *
33
41
  * Used by codegen to build the `ScopeId` union, and by a ScopeService to sync
34
42
  * the declared set into its store.
35
43
  */
@@ -39,7 +47,14 @@ export const flattenScopeDefinitions = (definitions) => {
39
47
  out.push({ id: def.name, description: def.description });
40
48
  flattenNodes(def.scopes, def.name, out);
41
49
  }
42
- return out;
50
+ const seen = new Set();
51
+ return out.filter((scope) => {
52
+ if (seen.has(scope.id)) {
53
+ return false;
54
+ }
55
+ seen.add(scope.id);
56
+ return true;
57
+ });
43
58
  };
44
59
  /**
45
60
  * Validates declared scopes and keys them by name.
@@ -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
  }
@@ -24,6 +24,7 @@ export function validateAndBuildVariableDefinitionsMeta(definitions, schemaLooku
24
24
  description: def.description,
25
25
  variableId: def.variableId,
26
26
  schema: def.schema,
27
+ docsUrl: def.docsUrl,
27
28
  sourceFile: def.sourceFile,
28
29
  };
29
30
  }
@@ -37,6 +38,7 @@ export function validateAndBuildVariableDefinitionsMeta(definitions, schemaLooku
37
38
  description: def.description,
38
39
  variableId: def.variableId,
39
40
  schema: def.schema,
41
+ docsUrl: def.docsUrl,
40
42
  sourceFile: def.sourceFile,
41
43
  };
42
44
  }
@@ -4,6 +4,13 @@ export type CoreVariable<T = unknown> = {
4
4
  description?: string;
5
5
  variableId: 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
  export type VariableDefinitionMeta = {
9
16
  name: string;
@@ -11,6 +18,13 @@ export type VariableDefinitionMeta = {
11
18
  description?: string;
12
19
  variableId: string;
13
20
  schema?: Record<string, unknown> | string;
21
+ /**
22
+ * Link to documentation explaining how to obtain this value — a provider's
23
+ * API-key page, a setup guide, an internal runbook. Surfaced by consoles and
24
+ * deploy UIs so a user facing a missing value has somewhere to go instead of
25
+ * an opaque identifier.
26
+ */
27
+ docsUrl?: string;
14
28
  sourceFile?: string;
15
29
  };
16
30
  export type VariableDefinitionsMeta = Record<string, VariableDefinitionMeta>;
@@ -15,6 +15,13 @@ export interface WorkflowStepOptions {
15
15
  retries?: number;
16
16
  /** Delay between retry attempts (e.g., '1s', '2s', '2min') */
17
17
  retryDelay?: string | number;
18
+ /**
19
+ * RPC to invoke for compensation when this step fails after exhausting its
20
+ * retries. Mirrors a graph node's `onError`: the handler receives
21
+ * `{ error: { message } }` and the original error is still thrown, so the
22
+ * workflow fails — this is compensation, not recovery.
23
+ */
24
+ onError?: string;
18
25
  /**
19
26
  * Run this step as an actor (scenarios). The RPC is sent through the
20
27
  * actor's authenticated client over the REAL transport — never dispatched
@@ -214,16 +221,20 @@ export interface ParallelGroupStepMeta {
214
221
  */
215
222
  export interface FanoutStepMeta {
216
223
  type: 'fanout';
217
- /** Step name for this fanout */
218
- stepName: string;
224
+ /**
225
+ * Step name for this fanout. Optional: a fanout is not itself a cached step,
226
+ * and node ids are step names — borrowing a body step's name would give the
227
+ * loop and that step the same id, collapsing one onto the other.
228
+ */
229
+ stepName?: string;
219
230
  /** Source array variable name */
220
231
  sourceVar: string;
221
232
  /** Iterator variable name */
222
233
  itemVar: string;
223
234
  /** Execution mode */
224
235
  mode: 'parallel' | 'sequential';
225
- /** Child step to execute per iteration */
226
- child: RpcStepMeta;
236
+ /** Steps to execute inline per iteration, in order */
237
+ body: Array<RpcStepMeta | SleepStepMeta | SuspendStepMeta>;
227
238
  /** Time between iterations (sequential mode only) */
228
239
  timeBetween?: string;
229
240
  }
@@ -234,6 +245,12 @@ export interface ReturnStepMeta {
234
245
  type: 'return';
235
246
  /** Output bindings */
236
247
  outputs: Record<string, OutputBinding>;
248
+ /**
249
+ * Variables spread into the returned object (`return { ...r }`), or the sole
250
+ * returned variable (`return r`). Their fields are not enumerable statically,
251
+ * so they are recorded by name rather than expanded into `outputs`.
252
+ */
253
+ spread?: string[];
237
254
  }
238
255
  /**
239
256
  * Inline step metadata (legacy support)
@@ -258,6 +275,13 @@ export interface SleepStepMeta {
258
275
  stepName: string;
259
276
  /** Sleep duration */
260
277
  duration: string | number;
278
+ /**
279
+ * Source text of a duration only known at runtime (e.g. a loop variable).
280
+ * The closure evaluates it, so it is legal DSL; it is kept separate from
281
+ * `duration` so regenerated code emits it raw rather than as a string
282
+ * literal, exactly as `expression` does on a set step.
283
+ */
284
+ expression?: string;
261
285
  }
262
286
  /**
263
287
  * Cancel step metadata
@@ -276,8 +300,14 @@ export interface SetStepMeta {
276
300
  type: 'set';
277
301
  /** Variable name to set (must be in context) */
278
302
  variable: string;
279
- /** Value to assign (literal or expression) */
280
- value: unknown;
303
+ /** Literal value to assign. Mutually exclusive with `expression`. */
304
+ value?: unknown;
305
+ /**
306
+ * Source text of a non-literal assignment (e.g. `count + 1`). Kept separate
307
+ * from `value` so regenerated code can emit it raw — a string `value` is a
308
+ * string literal, an `expression` is code.
309
+ */
310
+ expression?: string;
281
311
  }
282
312
  /**
283
313
  * Switch case metadata
@@ -443,6 +443,14 @@ export declare abstract class PikkuWorkflowService implements WorkflowService {
443
443
  * loop exits immediately without recording a step error or retrying.
444
444
  */
445
445
  private runInlineRetryLoop;
446
+ /**
447
+ * Run a failed step's compensation handler as a durable step of its own.
448
+ *
449
+ * Durable rather than a bare invoke so a replay does not compensate twice —
450
+ * a handler is typically a refund or a rollback. `onError` is deliberately
451
+ * not forwarded: a compensation handler cannot itself compensate.
452
+ */
453
+ private runStepCompensation;
446
454
  private rpcStep;
447
455
  private inlineStep;
448
456
  private sleepStep;
@@ -1156,6 +1156,16 @@ export class PikkuWorkflowService {
1156
1156
  }
1157
1157
  }
1158
1158
  }
1159
+ /**
1160
+ * Run a failed step's compensation handler as a durable step of its own.
1161
+ *
1162
+ * Durable rather than a bare invoke so a replay does not compensate twice —
1163
+ * a handler is typically a refund or a rollback. `onError` is deliberately
1164
+ * not forwarded: a compensation handler cannot itself compensate.
1165
+ */
1166
+ async runStepCompensation(runId, stepName, onErrorRpcName, rpcService, error) {
1167
+ await this.rpcStep(runId, `${stepName}:onError`, onErrorRpcName, { error: { message: error.message } }, rpcService, { retries: 0 });
1168
+ }
1159
1169
  async rpcStep(runId, logicalStepName, rpcName, data, rpcService, stepOptions) {
1160
1170
  // Capture the predecessor before nextStepKey advances the lineage to us.
1161
1171
  const fromStepName = this.lastStepName(runId);
@@ -1168,6 +1178,7 @@ export class PikkuWorkflowService {
1168
1178
  retries: stepOptions?.retries ?? DEFAULT_STEP_RETRIES,
1169
1179
  retryDelay: stepOptions?.retryDelay,
1170
1180
  actor: stepOptions?.actor,
1181
+ onError: stepOptions?.onError,
1171
1182
  };
1172
1183
  // Check if step already exists
1173
1184
  let stepState;
@@ -1186,6 +1197,11 @@ export class PikkuWorkflowService {
1186
1197
  // Step failed with retries exhausted - throw error to fail the workflow
1187
1198
  const error = new Error(stepState.error?.message ||
1188
1199
  `Step '${stepName}' failed after exhausting all retries`);
1200
+ // Compensation, mirroring a graph node's onError: run the handler, then
1201
+ // still throw — the workflow fails either way.
1202
+ if (resolvedStepOptions.onError) {
1203
+ await this.runStepCompensation(runId, stepName, resolvedStepOptions.onError, rpcService, error);
1204
+ }
1189
1205
  // Preserve original error properties if available
1190
1206
  if (stepState.error) {
1191
1207
  Object.assign(error, stepState.error);
@@ -194,8 +194,6 @@ export type CoreWorkflow<PikkuFunctionConfig extends CorePikkuFunctionConfig<any
194
194
  func: PikkuFunctionConfig;
195
195
  /** Middleware chain for this workflow */
196
196
  middleware?: PikkuFunctionConfig['middleware'];
197
- /** Permission requirements */
198
- permissions?: PikkuFunctionConfig['permissions'];
199
197
  /** Tags for organization and filtering */
200
198
  tags?: string[];
201
199
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/core",
3
- "version": "0.12.64",
3
+ "version": "0.12.67",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/index.js",
@@ -33,6 +33,7 @@
33
33
  "./scheduler": "./dist/wirings/scheduler/index.js",
34
34
  "./trigger": "./dist/wirings/trigger/index.js",
35
35
  "./rpc": "./dist/wirings/rpc/index.js",
36
+ "./safe-fetch": "./dist/utils/safe-fetch.js",
36
37
  "./mcp": "./dist/wirings/mcp/index.js",
37
38
  "./ai-agent": "./dist/wirings/ai-agent/index.js",
38
39
  "./gateway": "./dist/wirings/gateway/index.js",
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  * @module @pikku/core
3
3
  */
4
4
  export type {
5
+ AuthInstance,
5
6
  CommonWireMeta,
6
7
  CoreConfig,
7
8
  CorePikkuMiddleware,
@@ -197,7 +198,7 @@ export {
197
198
  addGlobalMiddleware,
198
199
  } from './middleware-runner.js'
199
200
  export { addGlobalPermission, checkAuthPermissions } from './permissions.js'
200
- export { verifyScopes } from './scopes.js'
201
+ export { hasScopes, verifyScopes } from './scopes.js'
201
202
  export {
202
203
  isSerializable,
203
204
  stopSingletonServices,
@@ -6,7 +6,7 @@ import {
6
6
  clearPermissionsCache,
7
7
  checkAuthPermissions,
8
8
  } from './permissions.js'
9
- import { pikkuState, resetPikkuState } from './pikku-state.js'
9
+ import { resetPikkuState } from './pikku-state.js'
10
10
  import { pikkuAuth } from './function/functions.types.js'
11
11
  import type { CoreServices, CoreUserSession } from './types/core.types.js'
12
12
  import type { CorePermissionGroup } from './function/functions.types.js'
@@ -190,25 +190,31 @@ describe('checkAuthPermissions', () => {
190
190
  )
191
191
  })
192
192
 
193
- test('resolves a function-referenced (wire) auth predicate by name', async () => {
194
- const store = pikkuState(null, 'misc', 'permissions')
195
- store['isAdmin'] = [pikkuAuth(async () => false)] as any
193
+ test('evaluates a live pikkuAuth predicate from the config group', async () => {
196
194
  assert.equal(
197
195
  await checkAuthPermissions(
198
- [{ type: 'wire', name: 'isAdmin' }],
196
+ { admin: pikkuAuth(async () => false) as any },
199
197
  mockSession,
200
198
  mockServices
201
199
  ),
202
200
  false
203
201
  )
202
+ assert.equal(
203
+ await checkAuthPermissions(
204
+ { admin: pikkuAuth(async () => true) as any },
205
+ mockSession,
206
+ mockServices
207
+ ),
208
+ true
209
+ )
204
210
  })
205
211
 
206
212
  test('ignores data-dependent permissions (no auth marker)', async () => {
207
- const store = pikkuState(null, 'misc', 'permissions')
208
- store['ownsRow'] = [async () => false] as any
213
+ // A bare pikkuPermission carries no __pikkuAuth brand, so it cannot be
214
+ // evaluated at filter time and must not gate the tool list.
209
215
  assert.equal(
210
216
  await checkAuthPermissions(
211
- [{ type: 'wire', name: 'ownsRow' }],
217
+ { ownsRow: (async () => false) as any },
212
218
  mockSession,
213
219
  mockServices
214
220
  ),