@pikku/core 0.12.63 → 0.12.64

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 (198) hide show
  1. package/CHANGELOG.md +147 -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 +32 -25
  24. package/dist/permissions.js +104 -185
  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/types/core.types.d.ts +34 -6
  46. package/dist/types/state.types.d.ts +20 -2
  47. package/dist/utils/hmac.d.ts +16 -0
  48. package/dist/utils/hmac.js +26 -0
  49. package/dist/utils/safe-fetch.d.ts +51 -0
  50. package/dist/utils/safe-fetch.js +192 -0
  51. package/dist/wirings/ai-agent/ai-agent-agui.d.ts +84 -0
  52. package/dist/wirings/ai-agent/ai-agent-agui.js +305 -0
  53. package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +41 -1
  54. package/dist/wirings/ai-agent/ai-agent-prepare.js +146 -6
  55. package/dist/wirings/ai-agent/ai-agent-runner.js +6 -1
  56. package/dist/wirings/ai-agent/ai-agent-stream.js +61 -10
  57. package/dist/wirings/ai-agent/ai-agent.types.d.ts +27 -1
  58. package/dist/wirings/ai-agent/index.d.ts +1 -0
  59. package/dist/wirings/ai-agent/index.js +1 -0
  60. package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
  61. package/dist/wirings/ai-agent/voice-input.js +2 -38
  62. package/dist/wirings/ai-agent/voice-output.d.ts +1 -0
  63. package/dist/wirings/channel/channel-common.js +0 -1
  64. package/dist/wirings/channel/channel-handler.js +1 -4
  65. package/dist/wirings/channel/channel-middleware-runner.d.ts +12 -0
  66. package/dist/wirings/channel/channel-middleware-runner.js +34 -19
  67. package/dist/wirings/channel/channel.types.d.ts +2 -6
  68. package/dist/wirings/cli/cli-runner.js +0 -2
  69. package/dist/wirings/cli/cli.types.d.ts +1 -2
  70. package/dist/wirings/http/http-routes.js +0 -3
  71. package/dist/wirings/http/http-runner.d.ts +9 -29
  72. package/dist/wirings/http/http-runner.js +9 -34
  73. package/dist/wirings/http/http.types.d.ts +1 -9
  74. package/dist/wirings/mcp/mcp-runner.js +0 -2
  75. package/dist/wirings/mcp/mcp.types.d.ts +5 -11
  76. package/dist/wirings/oauth2/index.d.ts +0 -3
  77. package/dist/wirings/oauth2/index.js +1 -2
  78. package/dist/wirings/rpc/addon-runner.d.ts +28 -0
  79. package/dist/wirings/rpc/addon-runner.js +173 -0
  80. package/dist/wirings/rpc/index.d.ts +5 -1
  81. package/dist/wirings/rpc/index.js +3 -1
  82. package/dist/wirings/rpc/remote-addon-auth.d.ts +26 -0
  83. package/dist/wirings/rpc/remote-addon-auth.js +43 -0
  84. package/dist/wirings/rpc/rpc-runner.d.ts +19 -0
  85. package/dist/wirings/rpc/rpc-runner.js +109 -3
  86. package/dist/wirings/rpc/rpc-types.d.ts +15 -1
  87. package/dist/wirings/rpc/wire-addon.js +9 -0
  88. package/dist/wirings/rpc/wire-remote-addon.d.ts +47 -0
  89. package/dist/wirings/rpc/wire-remote-addon.js +19 -0
  90. package/dist/wirings/scope/index.d.ts +3 -0
  91. package/dist/wirings/scope/index.js +2 -0
  92. package/dist/wirings/scope/scope.types.d.ts +39 -0
  93. package/dist/wirings/scope/scope.types.js +1 -0
  94. package/dist/wirings/scope/validate-scope-definitions.d.ts +16 -0
  95. package/dist/wirings/scope/validate-scope-definitions.js +76 -0
  96. package/dist/wirings/scope/wire-scope.d.ts +33 -0
  97. package/dist/wirings/scope/wire-scope.js +32 -0
  98. package/dist/wirings/workflow/dsl/index.d.ts +1 -1
  99. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +53 -1
  100. package/dist/wirings/workflow/graph/graph-node.d.ts +2 -0
  101. package/dist/wirings/workflow/graph/graph-runner.js +3 -0
  102. package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +6 -0
  103. package/dist/wirings/workflow/graph/wire-workflow-graph.js +1 -0
  104. package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +5 -0
  105. package/dist/wirings/workflow/index.d.ts +2 -2
  106. package/dist/wirings/workflow/index.js +1 -1
  107. package/dist/wirings/workflow/pikku-workflow-service.d.ts +51 -1
  108. package/dist/wirings/workflow/pikku-workflow-service.js +171 -0
  109. package/dist/wirings/workflow/workflow.types.d.ts +1 -1
  110. package/package.json +5 -1
  111. package/run-tests.sh +1 -0
  112. package/src/dev/hot-reload.ts +13 -68
  113. package/src/dev/module-runner.test.ts +169 -0
  114. package/src/dev/module-runner.ts +103 -0
  115. package/src/dev/reload-meta.test.ts +31 -2
  116. package/src/dev/reload-meta.ts +26 -0
  117. package/src/errors/errors.ts +24 -0
  118. package/src/function/function-runner.test.ts +276 -142
  119. package/src/function/function-runner.ts +52 -145
  120. package/src/function/functions.types.ts +28 -38
  121. package/src/index.ts +8 -6
  122. package/src/middleware/index.ts +1 -5
  123. package/src/permissions.test.ts +153 -372
  124. package/src/permissions.ts +139 -259
  125. package/src/pikku-state.ts +0 -2
  126. package/src/scopes.test.ts +167 -0
  127. package/src/scopes.ts +69 -0
  128. package/src/services/ai-embedding-service.ts +31 -0
  129. package/src/services/credential-wire-service.ts +10 -4
  130. package/src/services/index.ts +20 -0
  131. package/src/services/meta-service.ts +68 -3
  132. package/src/services/queue-webhook-service.test.ts +408 -0
  133. package/src/services/queue-webhook-service.ts +182 -0
  134. package/src/services/scope-service.ts +75 -0
  135. package/src/services/typed-secret-service.test.ts +35 -0
  136. package/src/services/typed-secret-service.ts +39 -4
  137. package/src/services/webhook-service.ts +180 -0
  138. package/src/services/workflow-service.ts +7 -0
  139. package/src/types/core.types.ts +42 -14
  140. package/src/types/state.types.ts +23 -3
  141. package/src/utils/hmac.ts +27 -0
  142. package/src/utils/safe-fetch.test.ts +373 -0
  143. package/src/utils/safe-fetch.ts +213 -0
  144. package/src/wirings/ai-agent/ai-agent-agui.test.ts +1127 -0
  145. package/src/wirings/ai-agent/ai-agent-agui.ts +386 -0
  146. package/src/wirings/ai-agent/ai-agent-prepare.test.ts +308 -0
  147. package/src/wirings/ai-agent/ai-agent-prepare.ts +192 -8
  148. package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
  149. package/src/wirings/ai-agent/ai-agent-runner.ts +21 -0
  150. package/src/wirings/ai-agent/ai-agent-stream.test.ts +198 -10
  151. package/src/wirings/ai-agent/ai-agent-stream.ts +84 -10
  152. package/src/wirings/ai-agent/ai-agent.types.ts +28 -0
  153. package/src/wirings/ai-agent/index.ts +1 -0
  154. package/src/wirings/ai-agent/voice-input.ts +6 -35
  155. package/src/wirings/channel/channel-common.ts +0 -1
  156. package/src/wirings/channel/channel-handler.ts +1 -9
  157. package/src/wirings/channel/channel-middleware-runner.test.ts +28 -2
  158. package/src/wirings/channel/channel-middleware-runner.ts +39 -27
  159. package/src/wirings/channel/channel.types.ts +0 -6
  160. package/src/wirings/cli/cli-runner.ts +0 -2
  161. package/src/wirings/cli/cli.types.ts +0 -2
  162. package/src/wirings/http/http-routes.ts +0 -3
  163. package/src/wirings/http/http-runner.test.ts +1 -11
  164. package/src/wirings/http/http-runner.ts +16 -39
  165. package/src/wirings/http/http.types.ts +1 -10
  166. package/src/wirings/mcp/mcp-runner.ts +0 -2
  167. package/src/wirings/mcp/mcp.types.ts +3 -11
  168. package/src/wirings/oauth2/index.ts +0 -3
  169. package/src/wirings/rpc/addon-runner.ts +233 -0
  170. package/src/wirings/rpc/index.ts +17 -1
  171. package/src/wirings/rpc/remote-addon-auth.ts +69 -0
  172. package/src/wirings/rpc/rpc-runner.test.ts +319 -16
  173. package/src/wirings/rpc/rpc-runner.ts +155 -3
  174. package/src/wirings/rpc/rpc-types.ts +16 -5
  175. package/src/wirings/rpc/wire-addon.test.ts +6 -3
  176. package/src/wirings/rpc/wire-addon.ts +9 -0
  177. package/src/wirings/rpc/wire-remote-addon.ts +57 -0
  178. package/src/wirings/scope/index.ts +14 -0
  179. package/src/wirings/scope/scope.test.ts +135 -0
  180. package/src/wirings/scope/scope.types.ts +44 -0
  181. package/src/wirings/scope/validate-scope-definitions.ts +110 -0
  182. package/src/wirings/scope/wire-scope.ts +34 -0
  183. package/src/wirings/workflow/dsl/index.ts +4 -0
  184. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +61 -0
  185. package/src/wirings/workflow/graph/graph-node.ts +2 -0
  186. package/src/wirings/workflow/graph/graph-runner.test.ts +58 -0
  187. package/src/wirings/workflow/graph/graph-runner.ts +3 -0
  188. package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
  189. package/src/wirings/workflow/graph/workflow-graph.types.ts +5 -0
  190. package/src/wirings/workflow/index.ts +5 -0
  191. package/src/wirings/workflow/pikku-workflow-service.test.ts +341 -0
  192. package/src/wirings/workflow/pikku-workflow-service.ts +236 -0
  193. package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
  194. package/src/wirings/workflow/workflow.types.ts +4 -0
  195. package/tsconfig.tsbuildinfo +1 -1
  196. package/src/wirings/oauth2/oauth2-client.test.ts +0 -930
  197. package/src/wirings/oauth2/oauth2-client.ts +0 -331
  198. package/src/wirings/oauth2/oauth2-routes.ts +0 -234
@@ -0,0 +1,408 @@
1
+ import { describe, test, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { createHmac } from 'node:crypto'
4
+
5
+ import {
6
+ QueueWebhookService,
7
+ pikkuWebhookWorkerFunc,
8
+ } from './queue-webhook-service.js'
9
+ import {
10
+ PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME,
11
+ type WebhookJobData,
12
+ } from './webhook-service.js'
13
+ import { InMemoryQueueService } from './in-memory-queue-service.js'
14
+ import { wireQueueWorker } from '../wirings/queue/queue-runner.js'
15
+ import { resetPikkuState, pikkuState } from '../pikku-state.js'
16
+ import type { JobOptions } from '../wirings/queue/queue.types.js'
17
+
18
+ const noopLogger = { error() {}, info() {}, warn() {}, debug() {} }
19
+
20
+ interface CapturedAdd {
21
+ queueName: string
22
+ data: WebhookJobData
23
+ options?: JobOptions
24
+ }
25
+
26
+ const setupServices = (overrides: Record<string, unknown> = {}) => {
27
+ const added: CapturedAdd[] = []
28
+ const queueService = {
29
+ supportsResults: false,
30
+ add: async (
31
+ queueName: string,
32
+ data: WebhookJobData,
33
+ options?: JobOptions
34
+ ) => {
35
+ added.push({ queueName, data, options })
36
+ return 'job-1'
37
+ },
38
+ getJob: async () => null,
39
+ }
40
+ pikkuState(null, 'package', 'singletonServices', {
41
+ logger: noopLogger,
42
+ config: {},
43
+ secrets: {
44
+ getSecret: async (key: string) => {
45
+ throw new Error(`Secret not found: ${key}`)
46
+ },
47
+ },
48
+ queueService,
49
+ ...overrides,
50
+ } as any)
51
+ return { added, queueService }
52
+ }
53
+
54
+ beforeEach(() => {
55
+ resetPikkuState()
56
+ })
57
+
58
+ describe('QueueWebhookService.send', () => {
59
+ test('enqueues with defaults: pikku-outgoing-webhooks queue, 3 retries, exponential backoff', async () => {
60
+ const { added, queueService } = setupServices()
61
+ const webhookService = new QueueWebhookService(queueService as any)
62
+
63
+ const result = await webhookService.send({
64
+ url: 'https://example.com/hook',
65
+ event: 'user.created',
66
+ data: { id: 'u1' },
67
+ })
68
+
69
+ assert.equal(result.jobId, 'job-1')
70
+ assert.equal(added.length, 1)
71
+ const { queueName, data, options } = added[0]!
72
+ assert.equal(queueName, PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME)
73
+ assert.equal(queueName, 'pikku-outgoing-webhooks')
74
+ assert.equal(data.url, 'https://example.com/hook')
75
+ assert.equal(data.body, JSON.stringify({ id: 'u1' }))
76
+ assert.equal(data.headers['Content-Type'], 'application/json')
77
+ assert.equal(data.headers['X-Pikku-Event'], 'user.created')
78
+ assert.equal(options?.attempts, 4)
79
+ assert.equal(options?.backoff, 'exponential')
80
+ })
81
+
82
+ test('applies config defaults for retries and fixed retryDelay', async () => {
83
+ const { added, queueService } = setupServices({
84
+ config: { webhook: { retries: 1, retryDelay: '30s' } },
85
+ })
86
+ const webhookService = new QueueWebhookService(queueService as any)
87
+
88
+ await webhookService.send({ url: 'https://example.com/hook', data: {} })
89
+
90
+ const { options } = added[0]!
91
+ assert.equal(options?.attempts, 2)
92
+ assert.deepEqual(options?.backoff, { type: 'fixed', delay: 30000 })
93
+ })
94
+
95
+ test('per-call retries override config defaults', async () => {
96
+ const { added, queueService } = setupServices({
97
+ config: { webhook: { retries: 5 } },
98
+ })
99
+ const webhookService = new QueueWebhookService(queueService as any)
100
+
101
+ await webhookService.send({
102
+ url: 'https://example.com/hook',
103
+ data: {},
104
+ retries: 0,
105
+ })
106
+
107
+ const { options } = added[0]!
108
+ assert.equal(options?.attempts, 1)
109
+ assert.equal(options?.backoff, undefined)
110
+ })
111
+
112
+ test('per-call secret signs the body and never enters the job data', async () => {
113
+ const { added, queueService } = setupServices()
114
+ const webhookService = new QueueWebhookService(queueService as any)
115
+
116
+ await webhookService.send({
117
+ url: 'https://example.com/hook',
118
+ data: { id: 'u1' },
119
+ secret: 'shhh',
120
+ })
121
+
122
+ const { data } = added[0]!
123
+ const expected =
124
+ 'sha256=' + createHmac('sha256', 'shhh').update(data.body).digest('hex')
125
+ assert.equal(data.headers['X-Pikku-Signature'], expected)
126
+ assert.ok(!JSON.stringify(data).includes('shhh'))
127
+ })
128
+
129
+ test('config secret is a secret NAME resolved through the secrets service', async () => {
130
+ const { added, queueService } = setupServices({
131
+ config: { webhook: { secret: 'WEBHOOK_SIGNING_KEY' } },
132
+ secrets: {
133
+ getSecret: async (key: string) => {
134
+ assert.equal(key, 'WEBHOOK_SIGNING_KEY')
135
+ return 'resolved-key'
136
+ },
137
+ },
138
+ })
139
+ const webhookService = new QueueWebhookService(queueService as any)
140
+
141
+ await webhookService.send({ url: 'https://example.com/hook', data: {} })
142
+
143
+ const { data } = added[0]!
144
+ assert.ok(
145
+ webhookService.verify(
146
+ 'resolved-key',
147
+ data.headers['X-Pikku-Signature']!,
148
+ data.body
149
+ )
150
+ )
151
+ })
152
+
153
+ test('config.webhook.signatureHeader overrides the default header', async () => {
154
+ const { added, queueService } = setupServices({
155
+ config: { webhook: { signatureHeader: 'X-Acme-Signature' } },
156
+ })
157
+ const webhookService = new QueueWebhookService(queueService as any)
158
+
159
+ await webhookService.send({
160
+ url: 'https://example.com/hook',
161
+ data: {},
162
+ secret: 'shhh',
163
+ })
164
+
165
+ const { data } = added[0]!
166
+ assert.ok(
167
+ webhookService.verify(
168
+ 'shhh',
169
+ data.headers['X-Acme-Signature']!,
170
+ data.body
171
+ )
172
+ )
173
+ assert.equal(data.headers['X-Pikku-Signature'], undefined)
174
+ })
175
+
176
+ test('no secret anywhere means no signature header', async () => {
177
+ const { added, queueService } = setupServices()
178
+ const webhookService = new QueueWebhookService(queueService as any)
179
+
180
+ await webhookService.send({ url: 'https://example.com/hook', data: {} })
181
+
182
+ assert.equal(added[0]!.data.headers['X-Pikku-Signature'], undefined)
183
+ })
184
+ })
185
+
186
+ describe('WebhookService signing', () => {
187
+ test('sign/verify round-trips and rejects tampering', () => {
188
+ const { queueService } = setupServices()
189
+ // sign() is protected — reach it the way a subclass would.
190
+ const webhookService = new (class extends QueueWebhookService {
191
+ public signBody = (secret: string, body: string) =>
192
+ this.sign(secret, body)
193
+ })(queueService as any)
194
+
195
+ const signature = webhookService.signBody('key', '{"a":1}')
196
+
197
+ assert.ok(signature.startsWith('sha256='))
198
+ assert.equal(webhookService.verify('key', signature, '{"a":1}'), true)
199
+ assert.equal(webhookService.verify('key', signature, '{"a":2}'), false)
200
+ assert.equal(webhookService.verify('other', signature, '{"a":1}'), false)
201
+ assert.equal(webhookService.verify('key', 'sha256=nope', '{"a":1}'), false)
202
+ })
203
+ })
204
+
205
+ describe('pikkuWebhookWorkerFunc', () => {
206
+ const originalFetch = globalThis.fetch
207
+
208
+ const stubFetch = (
209
+ handler: (url: string, init: RequestInit) => { status: number }
210
+ ) => {
211
+ const requests: { url: string; init: RequestInit }[] = []
212
+ globalThis.fetch = (async (url: any, init: any) => {
213
+ requests.push({ url: String(url), init })
214
+ const { status } = handler(String(url), init)
215
+ return new Response(null, { status })
216
+ }) as typeof fetch
217
+ return requests
218
+ }
219
+
220
+ const restoreFetch = () => {
221
+ globalThis.fetch = originalFetch
222
+ }
223
+
224
+ test('POSTs the stored body and headers, resolves on 2xx', async (t) => {
225
+ t.after(restoreFetch)
226
+ const requests = stubFetch(() => ({ status: 204 }))
227
+
228
+ await pikkuWebhookWorkerFunc({} as any, {
229
+ url: 'https://example.com/hook',
230
+ body: '{"id":"u1"}',
231
+ headers: { 'Content-Type': 'application/json', 'X-Pikku-Event': 'e' },
232
+ })
233
+
234
+ assert.equal(requests.length, 1)
235
+ assert.equal(requests[0]!.url, 'https://example.com/hook')
236
+ assert.equal(requests[0]!.init.method, 'POST')
237
+ assert.equal(requests[0]!.init.body, '{"id":"u1"}')
238
+ assert.equal(
239
+ (requests[0]!.init.headers as Record<string, string>)['X-Pikku-Event'],
240
+ 'e'
241
+ )
242
+ })
243
+
244
+ test('refuses to deliver to a private/internal host without fetching it (SSRF)', async (t) => {
245
+ t.after(restoreFetch)
246
+ const requests = stubFetch(() => ({ status: 200 }))
247
+
248
+ await assert.rejects(
249
+ pikkuWebhookWorkerFunc({} as any, {
250
+ url: 'http://169.254.169.254/latest/meta-data/',
251
+ body: '{}',
252
+ headers: {},
253
+ }),
254
+ /private\/internal host/
255
+ )
256
+ assert.equal(requests.length, 0, 'must never issue the request')
257
+ })
258
+
259
+ test('records a delivered attempt via the store when a deliveryId is present', async (t) => {
260
+ t.after(restoreFetch)
261
+ stubFetch(() => ({ status: 200 }))
262
+ const recorded: any[] = []
263
+ const services = {
264
+ logger: noopLogger,
265
+ webhookService: {
266
+ recordAttempt: async (id: string, result: any) =>
267
+ recorded.push({ id, result }),
268
+ },
269
+ }
270
+
271
+ await pikkuWebhookWorkerFunc(services as any, {
272
+ url: 'https://example.com/hook',
273
+ body: '{}',
274
+ headers: {},
275
+ deliveryId: 'del-1',
276
+ })
277
+
278
+ assert.equal(recorded.length, 1)
279
+ assert.equal(recorded[0].id, 'del-1')
280
+ assert.equal(recorded[0].result.delivered, true)
281
+ assert.equal(recorded[0].result.statusCode, 200)
282
+ })
283
+
284
+ test('records a failed attempt (with status) before throwing', async (t) => {
285
+ t.after(restoreFetch)
286
+ stubFetch(() => ({ status: 500 }))
287
+ const recorded: any[] = []
288
+ const services = {
289
+ logger: noopLogger,
290
+ webhookService: {
291
+ recordAttempt: async (id: string, result: any) =>
292
+ recorded.push({ id, result }),
293
+ },
294
+ }
295
+
296
+ await assert.rejects(
297
+ pikkuWebhookWorkerFunc(services as any, {
298
+ url: 'https://example.com/hook',
299
+ body: '{}',
300
+ headers: {},
301
+ deliveryId: 'del-2',
302
+ }),
303
+ /500/
304
+ )
305
+
306
+ assert.equal(recorded.length, 1)
307
+ assert.equal(recorded[0].result.delivered, false)
308
+ assert.equal(recorded[0].result.statusCode, 500)
309
+ })
310
+
311
+ test('skips the store when no deliveryId is present', async (t) => {
312
+ t.after(restoreFetch)
313
+ stubFetch(() => ({ status: 200 }))
314
+ let called = false
315
+ const services = {
316
+ logger: noopLogger,
317
+ webhookService: {
318
+ recordAttempt: async () => {
319
+ called = true
320
+ },
321
+ },
322
+ }
323
+
324
+ await pikkuWebhookWorkerFunc(services as any, {
325
+ url: 'https://example.com/hook',
326
+ body: '{}',
327
+ headers: {},
328
+ })
329
+
330
+ assert.equal(called, false)
331
+ })
332
+
333
+ test('throws on non-2xx statuses so the queue retries', async (t) => {
334
+ t.after(restoreFetch)
335
+
336
+ stubFetch(() => ({ status: 500 }))
337
+ await assert.rejects(
338
+ pikkuWebhookWorkerFunc({} as any, {
339
+ url: 'https://example.com/hook',
340
+ body: '{}',
341
+ headers: {},
342
+ }),
343
+ /500/
344
+ )
345
+
346
+ stubFetch(() => ({ status: 302 }))
347
+ await assert.rejects(
348
+ pikkuWebhookWorkerFunc({} as any, {
349
+ url: 'https://example.com/hook',
350
+ body: '{}',
351
+ headers: {},
352
+ }),
353
+ /302/
354
+ )
355
+ })
356
+
357
+ test('delivers through the in-memory queue with retries until success', async (t) => {
358
+ t.after(restoreFetch)
359
+ resetPikkuState()
360
+
361
+ let attempts = 0
362
+ stubFetch(() => {
363
+ attempts++
364
+ return { status: attempts <= 2 ? 500 : 200 }
365
+ })
366
+
367
+ const queueService = new InMemoryQueueService()
368
+ pikkuState(null, 'package', 'singletonServices', {
369
+ logger: noopLogger,
370
+ config: {},
371
+ queueService,
372
+ } as any)
373
+ pikkuState(null, 'package', 'factories', {
374
+ createWireServices: async () => ({}),
375
+ } as any)
376
+
377
+ const funcId = `queue_${PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME}`
378
+ pikkuState(null, 'queue', 'meta')[PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME] = {
379
+ pikkuFuncId: funcId,
380
+ name: PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME,
381
+ }
382
+ pikkuState(null, 'function', 'meta')[funcId] = {
383
+ pikkuFuncId: funcId,
384
+ inputSchemaName: null,
385
+ outputSchemaName: null,
386
+ sessionless: true,
387
+ } as any
388
+ wireQueueWorker({
389
+ name: PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME,
390
+ func: { auth: false, func: pikkuWebhookWorkerFunc },
391
+ } as any)
392
+
393
+ const webhookService = new QueueWebhookService(queueService)
394
+ await webhookService.send({
395
+ url: 'https://example.com/hook',
396
+ data: { id: 'u1' },
397
+ retries: 3,
398
+ retryDelay: 1,
399
+ })
400
+
401
+ const deadline = Date.now() + 2000
402
+ while (attempts < 3 && Date.now() < deadline) {
403
+ await new Promise((resolve) => setTimeout(resolve, 10))
404
+ }
405
+ await new Promise((resolve) => setTimeout(resolve, 150))
406
+ assert.equal(attempts, 3, 'should retry twice then stop after success')
407
+ })
408
+ })
@@ -0,0 +1,182 @@
1
+ import { getSingletonServices } from '../pikku-state.js'
2
+ import { getDurationInMilliseconds } from '../time-utils.js'
3
+ import { safeFetch } from '../utils/safe-fetch.js'
4
+ import type { JobOptions, QueueService } from '../wirings/queue/queue.types.js'
5
+ import type { Logger } from './logger.js'
6
+ import {
7
+ DEFAULT_WEBHOOK_RETRIES,
8
+ DEFAULT_WEBHOOK_SIGNATURE_HEADER,
9
+ PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME,
10
+ type SendWebhookInput,
11
+ type SendWebhookResult,
12
+ type WebhookJobData,
13
+ WebhookService,
14
+ } from './webhook-service.js'
15
+
16
+ /** Cap on the response body captured on a failed attempt. */
17
+ const MAX_CAPTURED_RESPONSE_BODY = 2_000
18
+
19
+ /**
20
+ * Default {@link WebhookService} implementation: serializes and signs the
21
+ * payload, then enqueues a delivery job onto the `pikku-outgoing-webhooks`
22
+ * queue. The signature is computed at enqueue time so the signing key never
23
+ * enters the queue payload; the actual HTTP POST happens in
24
+ * {@link pikkuWebhookWorkerFunc}, and the queue's `attempts`/`backoff`
25
+ * options drive retries.
26
+ */
27
+ export class QueueWebhookService extends WebhookService {
28
+ /**
29
+ * The queue is a constructor dependency rather than a `getSingletonServices()`
30
+ * lookup so that a project wiring up outgoing webhooks without a queue fails
31
+ * to compile, instead of throwing on the first `send()`.
32
+ */
33
+ constructor(protected queueService: QueueService) {
34
+ super()
35
+ }
36
+
37
+ public async send(input: SendWebhookInput): Promise<SendWebhookResult> {
38
+ const { jobData, options } = await this.prepareDelivery(input)
39
+ const jobId = await this.queueService.add(
40
+ PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME,
41
+ jobData,
42
+ options
43
+ )
44
+ return { jobId }
45
+ }
46
+
47
+ /**
48
+ * Build the signed job payload and its retry options — the shared work a
49
+ * `send()` does before enqueueing. Factored out (and `protected`) so a
50
+ * store-backed subclass can persist a delivery row and attach its
51
+ * `deliveryId` without re-implementing signing or the retry policy.
52
+ */
53
+ protected async prepareDelivery(
54
+ input: SendWebhookInput
55
+ ): Promise<{ jobData: WebhookJobData; options: JobOptions }> {
56
+ const services = getSingletonServices()
57
+ const webhookConfig = services.config?.webhook
58
+
59
+ const body = JSON.stringify(input.data)
60
+ const headers: Record<string, string> = {
61
+ 'Content-Type': 'application/json',
62
+ ...(input.event ? { 'X-Pikku-Event': input.event } : {}),
63
+ ...input.headers,
64
+ }
65
+
66
+ let secret = input.secret
67
+ if (secret === undefined && webhookConfig?.secret) {
68
+ // Naive read — any caching is the secret service's concern, not ours.
69
+ secret = await services.secrets.getSecret(webhookConfig.secret)
70
+ if (!secret) {
71
+ services.logger.error(
72
+ `Webhook signing secret '${webhookConfig.secret}' (config.webhook.secret) resolved to nothing — outgoing webhooks will be sent UNSIGNED.`
73
+ )
74
+ }
75
+ }
76
+ if (secret) {
77
+ const signatureHeader =
78
+ webhookConfig?.signatureHeader ?? DEFAULT_WEBHOOK_SIGNATURE_HEADER
79
+ headers[signatureHeader] = this.sign(secret, body)
80
+ }
81
+
82
+ const jobData: WebhookJobData = {
83
+ url: input.url,
84
+ ...(input.event ? { event: input.event } : {}),
85
+ body,
86
+ headers,
87
+ }
88
+
89
+ return { jobData, options: this.resolveJobOptions(input) }
90
+ }
91
+
92
+ /**
93
+ * Resolve a delivery's retry policy into queue job options, mirroring the
94
+ * workflow service: per-call values win over `config.webhook` defaults, an
95
+ * explicitly-set `retries` (including 0) is always honored, and `attempts`
96
+ * is ALWAYS passed so the queue can never fall back to its own default.
97
+ * Backoff is exponential unless a concrete `retryDelay` selects a fixed one.
98
+ */
99
+ private resolveJobOptions(input: SendWebhookInput): JobOptions {
100
+ const webhookConfig = getSingletonServices().config?.webhook
101
+ const retries =
102
+ input.retries ?? webhookConfig?.retries ?? DEFAULT_WEBHOOK_RETRIES
103
+ const retryDelay = input.retryDelay ?? webhookConfig?.retryDelay
104
+ const backoff =
105
+ retryDelay !== undefined && retryDelay !== 'exponential'
106
+ ? { type: 'fixed', delay: getDurationInMilliseconds(retryDelay) }
107
+ : retries > 0 || retryDelay === 'exponential'
108
+ ? 'exponential'
109
+ : undefined
110
+ return { attempts: retries + 1, ...(backoff ? { backoff } : {}) }
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Queue worker for the `pikku-outgoing-webhooks` queue: POSTs the delivery to its
116
+ * target URL. Any non-2xx response (or network error) throws so the queue
117
+ * retries the job according to the `attempts`/`backoff` set at enqueue time;
118
+ * the queue runner logs each failed attempt.
119
+ *
120
+ * A `deliveryId` is only present when a store-backed `webhookService` (e.g.
121
+ * `KyselyWebhookService`) enqueued the job, so each attempt (success or failure)
122
+ * is persisted via `webhookService.recordAttempt` before the throw — the console
123
+ * delivery history reflects every try, not just the outcome. The queue-only
124
+ * default never sets a `deliveryId`, so its base `recordAttempt` is never hit.
125
+ */
126
+ export async function pikkuWebhookWorkerFunc(
127
+ services: { logger: Logger; webhookService?: WebhookService },
128
+ { url, body, headers, deliveryId }: WebhookJobData
129
+ ): Promise<void> {
130
+ let statusCode: number | undefined
131
+ let responseBody: string | undefined
132
+ let error: string | undefined
133
+ let delivered = false
134
+
135
+ let allowedHosts: string[] | undefined
136
+ try {
137
+ allowedHosts = getSingletonServices().config?.webhook?.allowedHosts
138
+ } catch {
139
+ // Singleton services not initialised (e.g. a bare worker invocation) — fall
140
+ // back to the default private-host block with no allowlist.
141
+ }
142
+
143
+ try {
144
+ const response = await safeFetch(
145
+ url,
146
+ {
147
+ method: 'POST',
148
+ headers,
149
+ body,
150
+ signal: AbortSignal.timeout(30_000),
151
+ },
152
+ { allowedHosts }
153
+ )
154
+ statusCode = response.status
155
+ delivered = response.status >= 200 && response.status < 300
156
+ if (!delivered) {
157
+ responseBody = (await response.text().catch(() => ''))?.slice(
158
+ 0,
159
+ MAX_CAPTURED_RESPONSE_BODY
160
+ )
161
+ error = `Webhook delivery to ${url} failed with status ${response.status}`
162
+ }
163
+ } catch (e) {
164
+ error = e instanceof Error ? e.message : String(e)
165
+ }
166
+
167
+ if (deliveryId && services.webhookService) {
168
+ // Best-effort history: a store failure must not mask the delivery result.
169
+ await services.webhookService
170
+ .recordAttempt(deliveryId, { statusCode, responseBody, error, delivered })
171
+ .catch((storeError) =>
172
+ services.logger.error(
173
+ `Failed to record webhook delivery attempt for ${deliveryId}`,
174
+ storeError
175
+ )
176
+ )
177
+ }
178
+
179
+ if (!delivered) {
180
+ throw new Error(error ?? `Webhook delivery to ${url} failed`)
181
+ }
182
+ }
@@ -0,0 +1,75 @@
1
+ import type { FlatScope } from '../wirings/scope/scope.types.js'
2
+
3
+ /** A role: a named, admin-composed bag of scopes. */
4
+ export interface Role {
5
+ name: string
6
+ description?: string
7
+ scopes: string[]
8
+ }
9
+
10
+ /**
11
+ * Resolves and administers the scopes granted to a user.
12
+ *
13
+ * Implementations are called at the **session boundary** — e.g. better-auth's
14
+ * `mapSession` — never by the function runner. The runner reads
15
+ * `session.scopes` and never performs I/O, which keeps it viable on Workers
16
+ * and Lambda. Because the session is rebuilt per request, a scope change takes
17
+ * effect on the next call with nothing cached and nothing to invalidate.
18
+ *
19
+ * `syncScopes` is deliberately additive: scopes are declared in code, so a
20
+ * removed declaration leaves an inert row rather than silently revoking a
21
+ * grant mid-deploy. Removal is an explicit operation (`pikku scopes prune`).
22
+ */
23
+ export interface ScopeService {
24
+ /**
25
+ * Registers the declared scope set. Additive — never deletes.
26
+ * Called once during startup with the generated scope list.
27
+ */
28
+ syncScopes(scopes: FlatScope[]): Promise<void>
29
+
30
+ /**
31
+ * Every scope a user holds: the union of their role-derived scopes and any
32
+ * scopes granted to them directly.
33
+ */
34
+ resolveScopes(userId: string): Promise<string[]>
35
+
36
+ /**
37
+ * The scope vocabulary in the store: everything a role can be composed from.
38
+ *
39
+ * `declared: false` marks a scope that is still present but no longer
40
+ * declared in code — inert (no function can require it) and awaiting
41
+ * `pikku scopes prune`.
42
+ */
43
+ listScopes(): Promise<Array<FlatScope & { declared: boolean }>>
44
+
45
+ createRole(role: Role): Promise<void>
46
+ deleteRole(name: string): Promise<void>
47
+ setRoleScopes(name: string, scopes: string[]): Promise<void>
48
+ listRoles(): Promise<Role[]>
49
+
50
+ addUserToRole(userId: string, role: string, grantedBy?: string): Promise<void>
51
+ removeUserFromRole(userId: string, role: string): Promise<void>
52
+ listUserRoles(userId: string): Promise<string[]>
53
+
54
+ /**
55
+ * Grants a scope directly to a user, outside of any role. Additive: the
56
+ * resolved set is the union of role-derived and directly-granted scopes.
57
+ */
58
+ addScopeToUser(
59
+ userId: string,
60
+ scope: string,
61
+ grantedBy?: string
62
+ ): Promise<void>
63
+ removeScopeFromUser(userId: string, scope: string): Promise<void>
64
+ /** Only the scopes granted directly, not those inherited from roles. */
65
+ listUserScopes(userId: string): Promise<string[]>
66
+
67
+ /**
68
+ * Scopes present in the store that are no longer declared in code, with the
69
+ * roles that would lose them. Powers `pikku scopes audit`.
70
+ */
71
+ findStaleScopes(): Promise<Array<{ scope: string; roles: string[] }>>
72
+
73
+ /** Removes undeclared scopes, cascading them out of roles. */
74
+ pruneScopes(): Promise<string[]>
75
+ }
@@ -78,6 +78,41 @@ describe('TypedSecretService', () => {
78
78
  assert.deepStrictEqual(ghStatus.oauth2, { tokenSecretId: 'GH_TOKEN' })
79
79
  })
80
80
 
81
+ test('should cache getSecret and not re-hit the underlying service', async () => {
82
+ let calls = 0
83
+ const inner = {
84
+ getSecret: async <T = string>(key: string): Promise<T> => {
85
+ calls++
86
+ return `v-${key}` as unknown as T
87
+ },
88
+ hasSecret: async () => true,
89
+ setSecret: async () => {},
90
+ deleteSecret: async () => {},
91
+ }
92
+ const service = new TypedSecretService(inner, {})
93
+ assert.strictEqual(await service.getSecret('K'), 'v-K')
94
+ assert.strictEqual(await service.getSecret('K'), 'v-K')
95
+ assert.strictEqual(calls, 1)
96
+ })
97
+
98
+ test('should invalidate the cache on setSecret and deleteSecret', async () => {
99
+ const store = new Map([['K', '"a"']])
100
+ const service = new TypedSecretService(createMockSecrets(store), {})
101
+ assert.strictEqual(await service.getSecret('K'), 'a')
102
+ await service.setSecret('K', 'b')
103
+ assert.strictEqual(await service.getSecret('K'), 'b')
104
+ await service.deleteSecret('K')
105
+ await assert.rejects(() => service.getSecret('K'))
106
+ })
107
+
108
+ test('should not cache a getSecret miss', async () => {
109
+ const store = new Map<string, string>()
110
+ const service = new TypedSecretService(createMockSecrets(store), {})
111
+ await assert.rejects(() => service.getSecret('K'))
112
+ store.set('K', '"now-here"')
113
+ assert.strictEqual(await service.getSecret('K'), 'now-here')
114
+ })
115
+
81
116
  test('should get missing credentials', async () => {
82
117
  const store = new Map([['STRIPE_KEY', '"sk-123"']])
83
118
  const meta = {