@pikku/core 0.12.63 → 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 (225) hide show
  1. package/CHANGELOG.md +368 -0
  2. package/dist/dev/hot-reload.js +13 -55
  3. package/dist/dev/module-runner.d.ts +14 -0
  4. package/dist/dev/module-runner.js +59 -0
  5. package/dist/dev/reload-meta.d.ts +13 -0
  6. package/dist/dev/reload-meta.js +22 -0
  7. package/dist/errors/errors.d.ts +12 -0
  8. package/dist/errors/errors.js +19 -0
  9. package/dist/function/function-runner.d.ts +5 -5
  10. package/dist/function/function-runner.js +39 -111
  11. package/dist/function/functions.types.d.ts +17 -7
  12. package/dist/index.d.ts +5 -1
  13. package/dist/index.js +3 -1
  14. package/dist/middleware/auth-apikey.d.ts +1 -0
  15. package/dist/middleware/auth-bearer.d.ts +1 -0
  16. package/dist/middleware/auth-cookie.d.ts +1 -0
  17. package/dist/middleware/cors.d.ts +1 -0
  18. package/dist/middleware/index.d.ts +1 -1
  19. package/dist/middleware/index.js +1 -1
  20. package/dist/middleware/remote-auth.d.ts +1 -0
  21. package/dist/middleware/telemetry.d.ts +2 -0
  22. package/dist/middleware/timeout.d.ts +1 -0
  23. package/dist/permissions.d.ts +42 -27
  24. package/dist/permissions.js +101 -203
  25. package/dist/pikku-state.js +0 -2
  26. package/dist/scopes.d.ts +17 -0
  27. package/dist/scopes.js +59 -0
  28. package/dist/services/ai-embedding-service.d.ts +31 -0
  29. package/dist/services/ai-embedding-service.js +1 -0
  30. package/dist/services/credential-wire-service.d.ts +3 -1
  31. package/dist/services/credential-wire-service.js +10 -4
  32. package/dist/services/index.d.ts +4 -0
  33. package/dist/services/index.js +2 -0
  34. package/dist/services/meta-service.d.ts +15 -2
  35. package/dist/services/meta-service.js +51 -1
  36. package/dist/services/queue-webhook-service.d.ts +55 -0
  37. package/dist/services/queue-webhook-service.js +136 -0
  38. package/dist/services/scope-service.d.ts +67 -0
  39. package/dist/services/scope-service.js +1 -0
  40. package/dist/services/typed-secret-service.d.ts +8 -0
  41. package/dist/services/typed-secret-service.js +39 -4
  42. package/dist/services/webhook-service.d.ts +140 -0
  43. package/dist/services/webhook-service.js +44 -0
  44. package/dist/services/workflow-service.d.ts +7 -0
  45. package/dist/testing/service-tests.js +37 -0
  46. package/dist/types/core.types.d.ts +34 -6
  47. package/dist/types/state.types.d.ts +20 -2
  48. package/dist/utils/hmac.d.ts +16 -0
  49. package/dist/utils/hmac.js +26 -0
  50. package/dist/utils/safe-fetch.d.ts +51 -0
  51. package/dist/utils/safe-fetch.js +192 -0
  52. package/dist/wirings/ai-agent/ai-agent-agui.d.ts +84 -0
  53. package/dist/wirings/ai-agent/ai-agent-agui.js +305 -0
  54. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +105 -1
  55. package/dist/wirings/ai-agent/ai-agent-prepare.js +249 -11
  56. package/dist/wirings/ai-agent/ai-agent-runner.js +10 -1
  57. package/dist/wirings/ai-agent/ai-agent-stream.js +88 -16
  58. package/dist/wirings/ai-agent/ai-agent.types.d.ts +56 -2
  59. package/dist/wirings/ai-agent/index.d.ts +2 -1
  60. package/dist/wirings/ai-agent/index.js +2 -1
  61. package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
  62. package/dist/wirings/ai-agent/voice-input.js +5 -41
  63. package/dist/wirings/ai-agent/voice-output.d.ts +1 -0
  64. package/dist/wirings/channel/channel-common.js +0 -1
  65. package/dist/wirings/channel/channel-handler.js +1 -4
  66. package/dist/wirings/channel/channel-middleware-runner.d.ts +12 -0
  67. package/dist/wirings/channel/channel-middleware-runner.js +34 -19
  68. package/dist/wirings/channel/channel.types.d.ts +2 -6
  69. package/dist/wirings/cli/cli-runner.js +3 -2
  70. package/dist/wirings/cli/cli.types.d.ts +1 -2
  71. package/dist/wirings/cli/command-parser.d.ts +2 -0
  72. package/dist/wirings/cli/command-parser.js +59 -2
  73. package/dist/wirings/credential/credential.types.d.ts +14 -0
  74. package/dist/wirings/credential/validate-credential-definitions.js +1 -0
  75. package/dist/wirings/gateway/gateway-runner.js +100 -50
  76. package/dist/wirings/gateway/gateway.types.d.ts +8 -5
  77. package/dist/wirings/http/http-routes.js +0 -3
  78. package/dist/wirings/http/http-runner.d.ts +9 -29
  79. package/dist/wirings/http/http-runner.js +9 -34
  80. package/dist/wirings/http/http.types.d.ts +4 -12
  81. package/dist/wirings/mcp/mcp-runner.js +0 -2
  82. package/dist/wirings/mcp/mcp.types.d.ts +5 -11
  83. package/dist/wirings/oauth2/index.d.ts +0 -3
  84. package/dist/wirings/oauth2/index.js +1 -2
  85. package/dist/wirings/rpc/addon-runner.d.ts +28 -0
  86. package/dist/wirings/rpc/addon-runner.js +173 -0
  87. package/dist/wirings/rpc/index.d.ts +5 -1
  88. package/dist/wirings/rpc/index.js +3 -1
  89. package/dist/wirings/rpc/remote-addon-auth.d.ts +26 -0
  90. package/dist/wirings/rpc/remote-addon-auth.js +43 -0
  91. package/dist/wirings/rpc/rpc-runner.d.ts +19 -0
  92. package/dist/wirings/rpc/rpc-runner.js +109 -3
  93. package/dist/wirings/rpc/rpc-types.d.ts +15 -1
  94. package/dist/wirings/rpc/wire-addon.js +9 -0
  95. package/dist/wirings/rpc/wire-remote-addon.d.ts +47 -0
  96. package/dist/wirings/rpc/wire-remote-addon.js +19 -0
  97. package/dist/wirings/scope/index.d.ts +3 -0
  98. package/dist/wirings/scope/index.js +2 -0
  99. package/dist/wirings/scope/scope.types.d.ts +39 -0
  100. package/dist/wirings/scope/scope.types.js +1 -0
  101. package/dist/wirings/scope/validate-scope-definitions.d.ts +16 -0
  102. package/dist/wirings/scope/validate-scope-definitions.js +76 -0
  103. package/dist/wirings/scope/wire-scope.d.ts +33 -0
  104. package/dist/wirings/scope/wire-scope.js +32 -0
  105. package/dist/wirings/secret/secret.types.d.ts +14 -0
  106. package/dist/wirings/secret/validate-secret-definitions.js +2 -0
  107. package/dist/wirings/variable/validate-variable-definitions.js +2 -0
  108. package/dist/wirings/variable/variable.types.d.ts +14 -0
  109. package/dist/wirings/workflow/dsl/index.d.ts +1 -1
  110. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +89 -7
  111. package/dist/wirings/workflow/graph/graph-node.d.ts +2 -0
  112. package/dist/wirings/workflow/graph/graph-runner.js +3 -0
  113. package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +6 -0
  114. package/dist/wirings/workflow/graph/wire-workflow-graph.js +1 -0
  115. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +5 -0
  116. package/dist/wirings/workflow/index.d.ts +2 -2
  117. package/dist/wirings/workflow/index.js +1 -1
  118. package/dist/wirings/workflow/pikku-workflow-service.d.ts +59 -1
  119. package/dist/wirings/workflow/pikku-workflow-service.js +187 -0
  120. package/dist/wirings/workflow/workflow.types.d.ts +1 -3
  121. package/package.json +6 -1
  122. package/run-tests.sh +1 -0
  123. package/src/dev/hot-reload.ts +13 -68
  124. package/src/dev/module-runner.test.ts +169 -0
  125. package/src/dev/module-runner.ts +103 -0
  126. package/src/dev/reload-meta.test.ts +31 -2
  127. package/src/dev/reload-meta.ts +26 -0
  128. package/src/errors/errors.ts +24 -0
  129. package/src/function/function-runner.test.ts +276 -142
  130. package/src/function/function-runner.ts +52 -145
  131. package/src/function/functions.types.ts +28 -38
  132. package/src/index.ts +8 -6
  133. package/src/middleware/index.ts +1 -5
  134. package/src/permissions.test.ts +160 -373
  135. package/src/permissions.ts +137 -279
  136. package/src/pikku-state.ts +0 -2
  137. package/src/scopes.test.ts +167 -0
  138. package/src/scopes.ts +69 -0
  139. package/src/services/ai-embedding-service.ts +31 -0
  140. package/src/services/credential-wire-service.ts +10 -4
  141. package/src/services/index.ts +20 -0
  142. package/src/services/meta-service.ts +68 -3
  143. package/src/services/queue-webhook-service.test.ts +408 -0
  144. package/src/services/queue-webhook-service.ts +182 -0
  145. package/src/services/scope-service.ts +75 -0
  146. package/src/services/typed-secret-service.test.ts +35 -0
  147. package/src/services/typed-secret-service.ts +39 -4
  148. package/src/services/webhook-service.ts +180 -0
  149. package/src/services/workflow-service.ts +7 -0
  150. package/src/testing/service-tests.ts +49 -0
  151. package/src/types/core.types.ts +42 -14
  152. package/src/types/state.types.ts +23 -3
  153. package/src/utils/hmac.ts +27 -0
  154. package/src/utils/safe-fetch.test.ts +373 -0
  155. package/src/utils/safe-fetch.ts +213 -0
  156. package/src/wirings/ai-agent/ai-agent-agui.test.ts +1127 -0
  157. package/src/wirings/ai-agent/ai-agent-agui.ts +386 -0
  158. package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
  159. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +483 -0
  160. package/src/wirings/ai-agent/ai-agent-prepare.ts +324 -13
  161. package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
  162. package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
  163. package/src/wirings/ai-agent/ai-agent-runner.ts +28 -0
  164. package/src/wirings/ai-agent/ai-agent-stream.test.ts +301 -10
  165. package/src/wirings/ai-agent/ai-agent-stream.ts +122 -16
  166. package/src/wirings/ai-agent/ai-agent.types.ts +57 -0
  167. package/src/wirings/ai-agent/index.ts +5 -0
  168. package/src/wirings/ai-agent/voice-input.test.ts +90 -0
  169. package/src/wirings/ai-agent/voice-input.ts +9 -40
  170. package/src/wirings/channel/channel-common.ts +0 -1
  171. package/src/wirings/channel/channel-handler.ts +1 -9
  172. package/src/wirings/channel/channel-middleware-runner.test.ts +28 -2
  173. package/src/wirings/channel/channel-middleware-runner.ts +39 -27
  174. package/src/wirings/channel/channel.types.ts +0 -6
  175. package/src/wirings/cli/cli-runner.ts +4 -2
  176. package/src/wirings/cli/cli.types.ts +0 -2
  177. package/src/wirings/cli/command-parser.test.ts +130 -0
  178. package/src/wirings/cli/command-parser.ts +80 -2
  179. package/src/wirings/credential/credential.types.ts +14 -0
  180. package/src/wirings/credential/validate-credential-definitions.ts +1 -0
  181. package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
  182. package/src/wirings/gateway/gateway-runner.ts +114 -68
  183. package/src/wirings/gateway/gateway.types.ts +7 -9
  184. package/src/wirings/http/http-routes.ts +0 -3
  185. package/src/wirings/http/http-runner.test.ts +1 -11
  186. package/src/wirings/http/http-runner.ts +16 -39
  187. package/src/wirings/http/http.types.ts +7 -14
  188. package/src/wirings/mcp/mcp-runner.ts +0 -2
  189. package/src/wirings/mcp/mcp.types.ts +3 -11
  190. package/src/wirings/oauth2/index.ts +0 -3
  191. package/src/wirings/rpc/addon-runner.ts +233 -0
  192. package/src/wirings/rpc/index.ts +17 -1
  193. package/src/wirings/rpc/remote-addon-auth.ts +69 -0
  194. package/src/wirings/rpc/rpc-runner.test.ts +319 -16
  195. package/src/wirings/rpc/rpc-runner.ts +155 -3
  196. package/src/wirings/rpc/rpc-types.ts +16 -5
  197. package/src/wirings/rpc/wire-addon.test.ts +6 -3
  198. package/src/wirings/rpc/wire-addon.ts +9 -0
  199. package/src/wirings/rpc/wire-remote-addon.ts +57 -0
  200. package/src/wirings/scope/index.ts +14 -0
  201. package/src/wirings/scope/scope.test.ts +135 -0
  202. package/src/wirings/scope/scope.types.ts +44 -0
  203. package/src/wirings/scope/validate-scope-definitions.ts +110 -0
  204. package/src/wirings/scope/wire-scope.ts +34 -0
  205. package/src/wirings/secret/secret.types.ts +14 -0
  206. package/src/wirings/secret/validate-secret-definitions.ts +2 -0
  207. package/src/wirings/variable/validate-variable-definitions.ts +2 -0
  208. package/src/wirings/variable/variable.types.ts +14 -0
  209. package/src/wirings/workflow/dsl/index.ts +4 -0
  210. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +97 -6
  211. package/src/wirings/workflow/graph/graph-node.ts +2 -0
  212. package/src/wirings/workflow/graph/graph-runner.test.ts +58 -0
  213. package/src/wirings/workflow/graph/graph-runner.ts +3 -0
  214. package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
  215. package/src/wirings/workflow/graph/workflow-graph.types.ts +5 -0
  216. package/src/wirings/workflow/index.ts +5 -0
  217. package/src/wirings/workflow/pikku-workflow-service.test.ts +341 -0
  218. package/src/wirings/workflow/pikku-workflow-service.ts +272 -0
  219. package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
  220. package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
  221. package/src/wirings/workflow/workflow.types.ts +4 -2
  222. package/tsconfig.tsbuildinfo +1 -1
  223. package/src/wirings/oauth2/oauth2-client.test.ts +0 -930
  224. package/src/wirings/oauth2/oauth2-client.ts +0 -331
  225. package/src/wirings/oauth2/oauth2-routes.ts +0 -234
@@ -1,4 +1,6 @@
1
1
  import { readFile, readdir } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ import { createRequire } from 'node:module';
2
4
  import { join } from 'node:path';
3
5
  /**
4
6
  * Node.js filesystem implementation of MetaService.
@@ -54,6 +56,54 @@ export class LocalMetaService {
54
56
  return null;
55
57
  }
56
58
  }
59
+ // Resolve an installed addon package's root dir from the app's node_modules.
60
+ // The addon packages have no main `exports` entry, so `require.resolve(pkg)`
61
+ // throws; instead we walk the node_modules search paths (anchored at the app
62
+ // root, one level above `.pikku`) and pick the first that contains the package.
63
+ packageRootCache = new Map();
64
+ resolvePackageRoot(packageName) {
65
+ const cached = this.packageRootCache.get(packageName);
66
+ if (cached !== undefined)
67
+ return cached;
68
+ let root = null;
69
+ try {
70
+ const require = createRequire(join(this.basePath, '..', 'index.js'));
71
+ for (const dir of require.resolve.paths(packageName) ?? []) {
72
+ const candidate = join(dir, packageName);
73
+ if (existsSync(join(candidate, 'package.json'))) {
74
+ root = candidate;
75
+ break;
76
+ }
77
+ }
78
+ }
79
+ catch {
80
+ root = null;
81
+ }
82
+ this.packageRootCache.set(packageName, root);
83
+ return root;
84
+ }
85
+ async readPackageFile(packageName, relativePath) {
86
+ const root = this.resolvePackageRoot(packageName);
87
+ if (!root)
88
+ return null;
89
+ try {
90
+ return await readFile(join(root, '.pikku', relativePath), 'utf-8');
91
+ }
92
+ catch {
93
+ return null;
94
+ }
95
+ }
96
+ async readPackageDir(packageName, relativePath) {
97
+ const root = this.resolvePackageRoot(packageName);
98
+ if (!root)
99
+ return [];
100
+ try {
101
+ return await readdir(join(root, '.pikku', relativePath));
102
+ }
103
+ catch {
104
+ return [];
105
+ }
106
+ }
57
107
  clearCache() {
58
108
  this.httpMetaCache = null;
59
109
  this.channelsMetaCache = null;
@@ -246,7 +296,7 @@ export class LocalMetaService {
246
296
  const content = await this.readMetaJson('permissions', 'pikku-permissions-groups-meta');
247
297
  this.permissionsGroupsMetaCache = content
248
298
  ? JSON.parse(content)
249
- : { definitions: {}, httpGroups: {}, tagGroups: {} };
299
+ : { definitions: {} };
250
300
  return this.permissionsGroupsMetaCache;
251
301
  }
252
302
  async getAgentsMeta() {
@@ -0,0 +1,55 @@
1
+ import type { JobOptions, QueueService } from '../wirings/queue/queue.types.js';
2
+ import type { Logger } from './logger.js';
3
+ import { type SendWebhookInput, type SendWebhookResult, type WebhookJobData, WebhookService } from './webhook-service.js';
4
+ /**
5
+ * Default {@link WebhookService} implementation: serializes and signs the
6
+ * payload, then enqueues a delivery job onto the `pikku-outgoing-webhooks`
7
+ * queue. The signature is computed at enqueue time so the signing key never
8
+ * enters the queue payload; the actual HTTP POST happens in
9
+ * {@link pikkuWebhookWorkerFunc}, and the queue's `attempts`/`backoff`
10
+ * options drive retries.
11
+ */
12
+ export declare class QueueWebhookService extends WebhookService {
13
+ protected queueService: QueueService;
14
+ /**
15
+ * The queue is a constructor dependency rather than a `getSingletonServices()`
16
+ * lookup so that a project wiring up outgoing webhooks without a queue fails
17
+ * to compile, instead of throwing on the first `send()`.
18
+ */
19
+ constructor(queueService: QueueService);
20
+ send(input: SendWebhookInput): Promise<SendWebhookResult>;
21
+ /**
22
+ * Build the signed job payload and its retry options — the shared work a
23
+ * `send()` does before enqueueing. Factored out (and `protected`) so a
24
+ * store-backed subclass can persist a delivery row and attach its
25
+ * `deliveryId` without re-implementing signing or the retry policy.
26
+ */
27
+ protected prepareDelivery(input: SendWebhookInput): Promise<{
28
+ jobData: WebhookJobData;
29
+ options: JobOptions;
30
+ }>;
31
+ /**
32
+ * Resolve a delivery's retry policy into queue job options, mirroring the
33
+ * workflow service: per-call values win over `config.webhook` defaults, an
34
+ * explicitly-set `retries` (including 0) is always honored, and `attempts`
35
+ * is ALWAYS passed so the queue can never fall back to its own default.
36
+ * Backoff is exponential unless a concrete `retryDelay` selects a fixed one.
37
+ */
38
+ private resolveJobOptions;
39
+ }
40
+ /**
41
+ * Queue worker for the `pikku-outgoing-webhooks` queue: POSTs the delivery to its
42
+ * target URL. Any non-2xx response (or network error) throws so the queue
43
+ * retries the job according to the `attempts`/`backoff` set at enqueue time;
44
+ * the queue runner logs each failed attempt.
45
+ *
46
+ * A `deliveryId` is only present when a store-backed `webhookService` (e.g.
47
+ * `KyselyWebhookService`) enqueued the job, so each attempt (success or failure)
48
+ * is persisted via `webhookService.recordAttempt` before the throw — the console
49
+ * delivery history reflects every try, not just the outcome. The queue-only
50
+ * default never sets a `deliveryId`, so its base `recordAttempt` is never hit.
51
+ */
52
+ export declare function pikkuWebhookWorkerFunc(services: {
53
+ logger: Logger;
54
+ webhookService?: WebhookService;
55
+ }, { url, body, headers, deliveryId }: WebhookJobData): Promise<void>;
@@ -0,0 +1,136 @@
1
+ import { getSingletonServices } from '../pikku-state.js';
2
+ import { getDurationInMilliseconds } from '../time-utils.js';
3
+ import { safeFetch } from '../utils/safe-fetch.js';
4
+ import { DEFAULT_WEBHOOK_RETRIES, DEFAULT_WEBHOOK_SIGNATURE_HEADER, PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME, WebhookService, } from './webhook-service.js';
5
+ /** Cap on the response body captured on a failed attempt. */
6
+ const MAX_CAPTURED_RESPONSE_BODY = 2_000;
7
+ /**
8
+ * Default {@link WebhookService} implementation: serializes and signs the
9
+ * payload, then enqueues a delivery job onto the `pikku-outgoing-webhooks`
10
+ * queue. The signature is computed at enqueue time so the signing key never
11
+ * enters the queue payload; the actual HTTP POST happens in
12
+ * {@link pikkuWebhookWorkerFunc}, and the queue's `attempts`/`backoff`
13
+ * options drive retries.
14
+ */
15
+ export class QueueWebhookService extends WebhookService {
16
+ queueService;
17
+ /**
18
+ * The queue is a constructor dependency rather than a `getSingletonServices()`
19
+ * lookup so that a project wiring up outgoing webhooks without a queue fails
20
+ * to compile, instead of throwing on the first `send()`.
21
+ */
22
+ constructor(queueService) {
23
+ super();
24
+ this.queueService = queueService;
25
+ }
26
+ async send(input) {
27
+ const { jobData, options } = await this.prepareDelivery(input);
28
+ const jobId = await this.queueService.add(PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME, jobData, options);
29
+ return { jobId };
30
+ }
31
+ /**
32
+ * Build the signed job payload and its retry options — the shared work a
33
+ * `send()` does before enqueueing. Factored out (and `protected`) so a
34
+ * store-backed subclass can persist a delivery row and attach its
35
+ * `deliveryId` without re-implementing signing or the retry policy.
36
+ */
37
+ async prepareDelivery(input) {
38
+ const services = getSingletonServices();
39
+ const webhookConfig = services.config?.webhook;
40
+ const body = JSON.stringify(input.data);
41
+ const headers = {
42
+ 'Content-Type': 'application/json',
43
+ ...(input.event ? { 'X-Pikku-Event': input.event } : {}),
44
+ ...input.headers,
45
+ };
46
+ let secret = input.secret;
47
+ if (secret === undefined && webhookConfig?.secret) {
48
+ // Naive read — any caching is the secret service's concern, not ours.
49
+ secret = await services.secrets.getSecret(webhookConfig.secret);
50
+ if (!secret) {
51
+ services.logger.error(`Webhook signing secret '${webhookConfig.secret}' (config.webhook.secret) resolved to nothing — outgoing webhooks will be sent UNSIGNED.`);
52
+ }
53
+ }
54
+ if (secret) {
55
+ const signatureHeader = webhookConfig?.signatureHeader ?? DEFAULT_WEBHOOK_SIGNATURE_HEADER;
56
+ headers[signatureHeader] = this.sign(secret, body);
57
+ }
58
+ const jobData = {
59
+ url: input.url,
60
+ ...(input.event ? { event: input.event } : {}),
61
+ body,
62
+ headers,
63
+ };
64
+ return { jobData, options: this.resolveJobOptions(input) };
65
+ }
66
+ /**
67
+ * Resolve a delivery's retry policy into queue job options, mirroring the
68
+ * workflow service: per-call values win over `config.webhook` defaults, an
69
+ * explicitly-set `retries` (including 0) is always honored, and `attempts`
70
+ * is ALWAYS passed so the queue can never fall back to its own default.
71
+ * Backoff is exponential unless a concrete `retryDelay` selects a fixed one.
72
+ */
73
+ resolveJobOptions(input) {
74
+ const webhookConfig = getSingletonServices().config?.webhook;
75
+ const retries = input.retries ?? webhookConfig?.retries ?? DEFAULT_WEBHOOK_RETRIES;
76
+ const retryDelay = input.retryDelay ?? webhookConfig?.retryDelay;
77
+ const backoff = retryDelay !== undefined && retryDelay !== 'exponential'
78
+ ? { type: 'fixed', delay: getDurationInMilliseconds(retryDelay) }
79
+ : retries > 0 || retryDelay === 'exponential'
80
+ ? 'exponential'
81
+ : undefined;
82
+ return { attempts: retries + 1, ...(backoff ? { backoff } : {}) };
83
+ }
84
+ }
85
+ /**
86
+ * Queue worker for the `pikku-outgoing-webhooks` queue: POSTs the delivery to its
87
+ * target URL. Any non-2xx response (or network error) throws so the queue
88
+ * retries the job according to the `attempts`/`backoff` set at enqueue time;
89
+ * the queue runner logs each failed attempt.
90
+ *
91
+ * A `deliveryId` is only present when a store-backed `webhookService` (e.g.
92
+ * `KyselyWebhookService`) enqueued the job, so each attempt (success or failure)
93
+ * is persisted via `webhookService.recordAttempt` before the throw — the console
94
+ * delivery history reflects every try, not just the outcome. The queue-only
95
+ * default never sets a `deliveryId`, so its base `recordAttempt` is never hit.
96
+ */
97
+ export async function pikkuWebhookWorkerFunc(services, { url, body, headers, deliveryId }) {
98
+ let statusCode;
99
+ let responseBody;
100
+ let error;
101
+ let delivered = false;
102
+ let allowedHosts;
103
+ try {
104
+ allowedHosts = getSingletonServices().config?.webhook?.allowedHosts;
105
+ }
106
+ catch {
107
+ // Singleton services not initialised (e.g. a bare worker invocation) — fall
108
+ // back to the default private-host block with no allowlist.
109
+ }
110
+ try {
111
+ const response = await safeFetch(url, {
112
+ method: 'POST',
113
+ headers,
114
+ body,
115
+ signal: AbortSignal.timeout(30_000),
116
+ }, { allowedHosts });
117
+ statusCode = response.status;
118
+ delivered = response.status >= 200 && response.status < 300;
119
+ if (!delivered) {
120
+ responseBody = (await response.text().catch(() => ''))?.slice(0, MAX_CAPTURED_RESPONSE_BODY);
121
+ error = `Webhook delivery to ${url} failed with status ${response.status}`;
122
+ }
123
+ }
124
+ catch (e) {
125
+ error = e instanceof Error ? e.message : String(e);
126
+ }
127
+ if (deliveryId && services.webhookService) {
128
+ // Best-effort history: a store failure must not mask the delivery result.
129
+ await services.webhookService
130
+ .recordAttempt(deliveryId, { statusCode, responseBody, error, delivered })
131
+ .catch((storeError) => services.logger.error(`Failed to record webhook delivery attempt for ${deliveryId}`, storeError));
132
+ }
133
+ if (!delivered) {
134
+ throw new Error(error ?? `Webhook delivery to ${url} failed`);
135
+ }
136
+ }
@@ -0,0 +1,67 @@
1
+ import type { FlatScope } from '../wirings/scope/scope.types.js';
2
+ /** A role: a named, admin-composed bag of scopes. */
3
+ export interface Role {
4
+ name: string;
5
+ description?: string;
6
+ scopes: string[];
7
+ }
8
+ /**
9
+ * Resolves and administers the scopes granted to a user.
10
+ *
11
+ * Implementations are called at the **session boundary** — e.g. better-auth's
12
+ * `mapSession` — never by the function runner. The runner reads
13
+ * `session.scopes` and never performs I/O, which keeps it viable on Workers
14
+ * and Lambda. Because the session is rebuilt per request, a scope change takes
15
+ * effect on the next call with nothing cached and nothing to invalidate.
16
+ *
17
+ * `syncScopes` is deliberately additive: scopes are declared in code, so a
18
+ * removed declaration leaves an inert row rather than silently revoking a
19
+ * grant mid-deploy. Removal is an explicit operation (`pikku scopes prune`).
20
+ */
21
+ export interface ScopeService {
22
+ /**
23
+ * Registers the declared scope set. Additive — never deletes.
24
+ * Called once during startup with the generated scope list.
25
+ */
26
+ syncScopes(scopes: FlatScope[]): Promise<void>;
27
+ /**
28
+ * Every scope a user holds: the union of their role-derived scopes and any
29
+ * scopes granted to them directly.
30
+ */
31
+ resolveScopes(userId: string): Promise<string[]>;
32
+ /**
33
+ * The scope vocabulary in the store: everything a role can be composed from.
34
+ *
35
+ * `declared: false` marks a scope that is still present but no longer
36
+ * declared in code — inert (no function can require it) and awaiting
37
+ * `pikku scopes prune`.
38
+ */
39
+ listScopes(): Promise<Array<FlatScope & {
40
+ declared: boolean;
41
+ }>>;
42
+ createRole(role: Role): Promise<void>;
43
+ deleteRole(name: string): Promise<void>;
44
+ setRoleScopes(name: string, scopes: string[]): Promise<void>;
45
+ listRoles(): Promise<Role[]>;
46
+ addUserToRole(userId: string, role: string, grantedBy?: string): Promise<void>;
47
+ removeUserFromRole(userId: string, role: string): Promise<void>;
48
+ listUserRoles(userId: string): Promise<string[]>;
49
+ /**
50
+ * Grants a scope directly to a user, outside of any role. Additive: the
51
+ * resolved set is the union of role-derived and directly-granted scopes.
52
+ */
53
+ addScopeToUser(userId: string, scope: string, grantedBy?: string): Promise<void>;
54
+ removeScopeFromUser(userId: string, scope: string): Promise<void>;
55
+ /** Only the scopes granted directly, not those inherited from roles. */
56
+ listUserScopes(userId: string): Promise<string[]>;
57
+ /**
58
+ * Scopes present in the store that are no longer declared in code, with the
59
+ * roles that would lose them. Powers `pikku scopes audit`.
60
+ */
61
+ findStaleScopes(): Promise<Array<{
62
+ scope: string;
63
+ roles: string[];
64
+ }>>;
65
+ /** Removes undeclared scopes, cascading them out of roles. */
66
+ pruneScopes(): Promise<string[]>;
67
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -18,6 +18,14 @@ export type CredentialMeta = {
18
18
  export declare class TypedSecretService<TMap = Record<string, unknown>> implements SecretService {
19
19
  private secrets;
20
20
  private credentialsMeta;
21
+ /**
22
+ * In-process cache of resolved secrets, so callers can read naively without
23
+ * re-hitting the underlying service on every call. Only successful reads are
24
+ * cached (a miss throws and isn't stored); `setSecret`/`deleteSecret`
25
+ * invalidate the key. No TTL — a secret rotated out-of-band won't refresh
26
+ * until restart (see pikkujs/pikku#964).
27
+ */
28
+ private cache;
21
29
  constructor(secrets: SecretService, credentialsMeta: Record<string, CredentialMeta>);
22
30
  getSecret<K extends keyof TMap & string>(key: K): Promise<TMap[K]>;
23
31
  getSecret<T = string>(key: string): Promise<T>;
@@ -1,24 +1,59 @@
1
1
  export class TypedSecretService {
2
2
  secrets;
3
3
  credentialsMeta;
4
+ /**
5
+ * In-process cache of resolved secrets, so callers can read naively without
6
+ * re-hitting the underlying service on every call. Only successful reads are
7
+ * cached (a miss throws and isn't stored); `setSecret`/`deleteSecret`
8
+ * invalidate the key. No TTL — a secret rotated out-of-band won't refresh
9
+ * until restart (see pikkujs/pikku#964).
10
+ */
11
+ cache = new Map();
4
12
  constructor(secrets, credentialsMeta) {
5
13
  this.secrets = secrets;
6
14
  this.credentialsMeta = credentialsMeta;
7
15
  }
8
16
  async getSecret(key) {
9
- return this.secrets.getSecret(key);
17
+ if (this.cache.has(key)) {
18
+ return this.cache.get(key);
19
+ }
20
+ const value = await this.secrets.getSecret(key);
21
+ this.cache.set(key, value);
22
+ return value;
10
23
  }
11
24
  async hasSecret(key) {
25
+ if (this.cache.has(key)) {
26
+ return true;
27
+ }
12
28
  return this.secrets.hasSecret(key);
13
29
  }
14
30
  async setSecret(key, value) {
15
- return this.secrets.setSecret(key, value);
31
+ await this.secrets.setSecret(key, value);
32
+ this.cache.delete(key);
16
33
  }
17
34
  async deleteSecret(key) {
18
- return this.secrets.deleteSecret(key);
35
+ await this.secrets.deleteSecret(key);
36
+ this.cache.delete(key);
19
37
  }
20
38
  async getSecrets(keys) {
21
- return this.secrets.getSecrets(keys);
39
+ const result = {};
40
+ const missing = [];
41
+ for (const key of keys) {
42
+ if (this.cache.has(key)) {
43
+ result[key] = this.cache.get(key);
44
+ }
45
+ else {
46
+ missing.push(key);
47
+ }
48
+ }
49
+ if (missing.length > 0) {
50
+ const fetched = await this.secrets.getSecrets(missing);
51
+ for (const [key, value] of Object.entries(fetched)) {
52
+ this.cache.set(key, value);
53
+ result[key] = value;
54
+ }
55
+ }
56
+ return result;
22
57
  }
23
58
  async getAllStatus() {
24
59
  const results = [];
@@ -0,0 +1,140 @@
1
+ export interface SendWebhookInput {
2
+ /** The endpoint the webhook is delivered to via HTTP POST. */
3
+ url: string;
4
+ /** Optional event name, sent as the `X-Pikku-Event` header. */
5
+ event?: string;
6
+ /** The payload, JSON-serialized into the request body. */
7
+ data: unknown;
8
+ /** Extra headers merged over the defaults. */
9
+ headers?: Record<string, string>;
10
+ /**
11
+ * Raw HMAC signing key for this delivery. Overrides the config-level
12
+ * secret. When present, the body is signed into the signature header.
13
+ */
14
+ secret?: string;
15
+ /** Retry count for failed deliveries. Overrides `config.webhook.retries`. */
16
+ retries?: number;
17
+ /**
18
+ * Delay between retries (`30000` or `'30s'`). A concrete value selects a
19
+ * fixed backoff; when omitted, backoff is exponential.
20
+ */
21
+ retryDelay?: string | number;
22
+ /**
23
+ * Organization this delivery belongs to. Persisted by a store-backed
24
+ * implementation (e.g. `KyselyWebhookService`) so deliveries can be
25
+ * queried per org; ignored by the queue-only default.
26
+ */
27
+ organizationId?: string;
28
+ }
29
+ export interface SendWebhookResult {
30
+ jobId: string;
31
+ }
32
+ export interface WebhookServiceConfig {
33
+ /** Default retry count for failed deliveries. */
34
+ retries?: number;
35
+ /** Default delay between retries; omitted means exponential backoff. */
36
+ retryDelay?: string | number;
37
+ /**
38
+ * Name of a secret (resolved through the secret service) used as the
39
+ * default HMAC signing key for all outgoing webhooks.
40
+ */
41
+ secret?: string;
42
+ /**
43
+ * Header the body signature is sent in. Defaults to
44
+ * {@link DEFAULT_WEBHOOK_SIGNATURE_HEADER}.
45
+ */
46
+ signatureHeader?: string;
47
+ /**
48
+ * SSRF allowlist of hostnames outgoing webhooks may be delivered to. When set,
49
+ * a delivery target must be on this list. When omitted, obvious private and
50
+ * internal hosts (loopback, private ranges, cloud metadata) are blocked and
51
+ * all other public hosts are permitted.
52
+ */
53
+ allowedHosts?: string[];
54
+ }
55
+ export interface WebhookJobData {
56
+ url: string;
57
+ event?: string;
58
+ body: string;
59
+ headers: Record<string, string>;
60
+ /**
61
+ * Delivery-record id, present when a store-backed implementation enqueued
62
+ * the job. The worker keys its per-attempt records off this (it doubles as
63
+ * the queue `jobId`), so it's stable across retries.
64
+ */
65
+ deliveryId?: string;
66
+ }
67
+ /** Outcome of one delivery attempt, recorded against a delivery. */
68
+ export interface WebhookAttemptResult {
69
+ /** HTTP status of the response, absent if the request never completed. */
70
+ statusCode?: number;
71
+ /** Response body (truncated) — captured on failure for debugging. */
72
+ responseBody?: string;
73
+ /** Network/timeout error message, when the request threw. */
74
+ error?: string;
75
+ /** Whether this attempt was a 2xx success. */
76
+ delivered: boolean;
77
+ }
78
+ /** A persisted webhook delivery, as surfaced to the console. */
79
+ export interface WebhookDeliveryRecord {
80
+ deliveryId: string;
81
+ organizationId: string | null;
82
+ url: string;
83
+ event: string | null;
84
+ status: 'pending' | 'delivered' | 'failed';
85
+ attempts: number;
86
+ createdAt: Date;
87
+ updatedAt: Date;
88
+ deliveredAt: Date | null;
89
+ }
90
+ /** A single persisted delivery attempt. */
91
+ export interface WebhookAttemptRecord {
92
+ attemptId: string;
93
+ deliveryId: string;
94
+ attemptNumber: number;
95
+ statusCode: number | null;
96
+ responseBody: string | null;
97
+ error: string | null;
98
+ createdAt: Date;
99
+ }
100
+ /** A delivery with its full attempt history. */
101
+ export interface WebhookDeliveryWithAttempts {
102
+ delivery: WebhookDeliveryRecord;
103
+ attempts: WebhookAttemptRecord[];
104
+ }
105
+ export declare const PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME = "pikku-outgoing-webhooks";
106
+ export declare const DEFAULT_WEBHOOK_SIGNATURE_HEADER = "X-Pikku-Signature";
107
+ /** Fallback when neither the per-call nor `config.webhook.retries` is set. */
108
+ export declare const DEFAULT_WEBHOOK_RETRIES = 3;
109
+ /**
110
+ * Outgoing webhook delivery. {@link QueueWebhookService} is the default
111
+ * implementation; apps can extend this to deliver directly, or through a
112
+ * provider such as Svix.
113
+ */
114
+ export declare abstract class WebhookService {
115
+ abstract send(input: SendWebhookInput): Promise<SendWebhookResult>;
116
+ /**
117
+ * Sign a body with HMAC-SHA256, producing the signature header value
118
+ * (e.g. `sha256=abc123...`).
119
+ */
120
+ protected sign(secret: string, body: string): string;
121
+ /**
122
+ * Verify a signature produced by {@link WebhookService.sign} — for
123
+ * receivers, which share the signing scheme.
124
+ */
125
+ verify(secret: string, signature: string, body: string): boolean;
126
+ /**
127
+ * Record a delivery attempt and roll the delivery's status/attempt-count
128
+ * forward, keyed by the delivery id carried in {@link WebhookJobData}. The
129
+ * default queue-only service keeps no history, so this throws — a store-backed
130
+ * implementation (e.g. `KyselyWebhookService` in `@pikku/kysely`) overrides it.
131
+ */
132
+ recordAttempt(_deliveryId: string, _result: WebhookAttemptResult): Promise<void>;
133
+ /** List deliveries, most recent first, optionally scoped to an org. */
134
+ listDeliveries(_opts?: {
135
+ organizationId?: string;
136
+ limit?: number;
137
+ }): Promise<WebhookDeliveryRecord[]>;
138
+ /** A single delivery with its attempt history, or null if unknown. */
139
+ getDelivery(_deliveryId: string): Promise<WebhookDeliveryWithAttempts | null>;
140
+ }
@@ -0,0 +1,44 @@
1
+ import { NotImplementedError } from '../errors/errors.js';
2
+ import { hmacSha256Hex, timingSafeStringEqual } from '../utils/hmac.js';
3
+ export const PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME = 'pikku-outgoing-webhooks';
4
+ export const DEFAULT_WEBHOOK_SIGNATURE_HEADER = 'X-Pikku-Signature';
5
+ /** Fallback when neither the per-call nor `config.webhook.retries` is set. */
6
+ export const DEFAULT_WEBHOOK_RETRIES = 3;
7
+ /**
8
+ * Outgoing webhook delivery. {@link QueueWebhookService} is the default
9
+ * implementation; apps can extend this to deliver directly, or through a
10
+ * provider such as Svix.
11
+ */
12
+ export class WebhookService {
13
+ /**
14
+ * Sign a body with HMAC-SHA256, producing the signature header value
15
+ * (e.g. `sha256=abc123...`).
16
+ */
17
+ sign(secret, body) {
18
+ return `sha256=${hmacSha256Hex(secret, body)}`;
19
+ }
20
+ /**
21
+ * Verify a signature produced by {@link WebhookService.sign} — for
22
+ * receivers, which share the signing scheme.
23
+ */
24
+ verify(secret, signature, body) {
25
+ return timingSafeStringEqual(this.sign(secret, body), signature);
26
+ }
27
+ /**
28
+ * Record a delivery attempt and roll the delivery's status/attempt-count
29
+ * forward, keyed by the delivery id carried in {@link WebhookJobData}. The
30
+ * default queue-only service keeps no history, so this throws — a store-backed
31
+ * implementation (e.g. `KyselyWebhookService` in `@pikku/kysely`) overrides it.
32
+ */
33
+ recordAttempt(_deliveryId, _result) {
34
+ throw new NotImplementedError('webhook delivery persistence is not configured');
35
+ }
36
+ /** List deliveries, most recent first, optionally scoped to an org. */
37
+ listDeliveries(_opts) {
38
+ throw new NotImplementedError('webhook delivery persistence is not configured');
39
+ }
40
+ /** A single delivery with its attempt history, or null if unknown. */
41
+ getDelivery(_deliveryId) {
42
+ throw new NotImplementedError('webhook delivery persistence is not configured');
43
+ }
44
+ }
@@ -22,6 +22,13 @@ export interface WorkflowService {
22
22
  withRunLock<T>(id: string, fn: () => Promise<T>): Promise<T>;
23
23
  close(): Promise<void>;
24
24
  resumeWorkflow(runId: string): Promise<void>;
25
+ /**
26
+ * Record a human decision against a `workflow.approval()` gate and wake the
27
+ * run. The payload is stored as given and validated on replay, inside the
28
+ * workflow body — so an invalid one re-closes the gate rather than failing the
29
+ * run. `reason` addresses the gate's first reach.
30
+ */
31
+ approveStep(runId: string, reason: string, decision: unknown): Promise<void>;
25
32
  startWorkflow<I>(name: string, input: I, wire: WorkflowRunWire, rpcService: any, options?: {
26
33
  inline?: boolean;
27
34
  startNode?: string;
@@ -742,6 +742,43 @@ export function defineServiceTests(config) {
742
742
  const threads = await agentService.listThreads();
743
743
  assert.ok(Array.isArray(threads));
744
744
  });
745
+ if (services.aiStorageService) {
746
+ const storageFactory = services.aiStorageService;
747
+ // The `owners` constraint is what keeps the generated thread-management
748
+ // functions from leaking across tenants: a caller may only list threads
749
+ // owned by one of their session principals, matching the
750
+ // `principal:sub-partition` composition resolveOwnerResourceId writes.
751
+ test('listThreads scopes to the given owners, including sub-partitions', async () => {
752
+ const storage = await storageFactory();
753
+ await storage.createThread('owner-alice');
754
+ await storage.createThread('owner-alice:project-1');
755
+ await storage.createThread('owner-bob:secret');
756
+ const threads = await agentService.listThreads({
757
+ owners: ['owner-alice'],
758
+ });
759
+ const ids = threads.map((t) => t.resourceId);
760
+ assert.ok(ids.includes('owner-alice'));
761
+ assert.ok(ids.includes('owner-alice:project-1'));
762
+ assert.ok(!ids.some((id) => id.startsWith('owner-bob')), "another owner's threads must not be listed");
763
+ });
764
+ test('listThreads with an owner does not match a lookalike prefix', async () => {
765
+ const storage = await storageFactory();
766
+ await storage.createThread('owner-al');
767
+ await storage.createThread('owner-alice-evil:p');
768
+ const threads = await agentService.listThreads({
769
+ owners: ['owner-al'],
770
+ });
771
+ const ids = threads.map((t) => t.resourceId);
772
+ assert.ok(ids.includes('owner-al'));
773
+ assert.ok(!ids.includes('owner-alice-evil:p'));
774
+ });
775
+ test('listThreads with an empty owners list returns nothing', async () => {
776
+ const storage = await storageFactory();
777
+ await storage.createThread('owner-empty-check');
778
+ const threads = await agentService.listThreads({ owners: [] });
779
+ assert.deepEqual(threads, []);
780
+ });
781
+ }
745
782
  test('getThread returns null for missing', async () => {
746
783
  const thread = await agentService.getThread('missing-thread');
747
784
  assert.equal(thread, null);