@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.
- package/CHANGELOG.md +311 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/permissions.d.ts +12 -4
- package/dist/permissions.js +11 -32
- package/dist/scopes.d.ts +14 -0
- package/dist/scopes.js +39 -8
- package/dist/testing/service-tests.js +37 -0
- package/dist/types/core.types.d.ts +22 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +64 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.js +103 -5
- package/dist/wirings/ai-agent/ai-agent-runner.js +5 -1
- package/dist/wirings/ai-agent/ai-agent-stream.js +28 -7
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +29 -1
- package/dist/wirings/ai-agent/index.d.ts +1 -1
- package/dist/wirings/ai-agent/index.js +1 -1
- package/dist/wirings/ai-agent/voice-input.js +3 -3
- package/dist/wirings/cli/cli-runner.js +3 -0
- package/dist/wirings/cli/command-parser.d.ts +2 -0
- package/dist/wirings/cli/command-parser.js +59 -2
- package/dist/wirings/credential/credential.types.d.ts +14 -0
- package/dist/wirings/credential/validate-credential-definitions.js +1 -0
- package/dist/wirings/gateway/gateway-runner.js +100 -50
- package/dist/wirings/gateway/gateway.types.d.ts +8 -5
- package/dist/wirings/http/http.types.d.ts +3 -3
- package/dist/wirings/scope/validate-scope-definitions.d.ts +8 -0
- package/dist/wirings/scope/validate-scope-definitions.js +16 -1
- package/dist/wirings/secret/secret.types.d.ts +14 -0
- package/dist/wirings/secret/validate-secret-definitions.js +2 -0
- package/dist/wirings/variable/validate-variable-definitions.js +2 -0
- package/dist/wirings/variable/variable.types.d.ts +14 -0
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +36 -6
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +8 -0
- package/dist/wirings/workflow/pikku-workflow-service.js +16 -0
- package/dist/wirings/workflow/workflow.types.d.ts +0 -2
- package/package.json +2 -1
- package/src/index.ts +2 -1
- package/src/permissions.test.ts +14 -8
- package/src/permissions.ts +14 -36
- package/src/scopes.test.ts +37 -1
- package/src/scopes.ts +48 -9
- package/src/testing/service-tests.ts +49 -0
- package/src/types/core.types.ts +23 -0
- package/src/wirings/ai-agent/ai-agent-authorization.test.ts +204 -0
- package/src/wirings/ai-agent/ai-agent-prepare.test.ts +175 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +132 -5
- package/src/wirings/ai-agent/ai-agent-resume-authorization.test.ts +207 -0
- package/src/wirings/ai-agent/ai-agent-runner.ts +7 -0
- package/src/wirings/ai-agent/ai-agent-stream.test.ts +103 -0
- package/src/wirings/ai-agent/ai-agent-stream.ts +38 -6
- package/src/wirings/ai-agent/ai-agent.types.ts +29 -0
- package/src/wirings/ai-agent/index.ts +4 -0
- package/src/wirings/ai-agent/voice-input.test.ts +90 -0
- package/src/wirings/ai-agent/voice-input.ts +8 -10
- package/src/wirings/cli/cli-runner.ts +4 -0
- package/src/wirings/cli/command-parser.test.ts +130 -0
- package/src/wirings/cli/command-parser.ts +80 -2
- package/src/wirings/credential/credential.types.ts +14 -0
- package/src/wirings/credential/validate-credential-definitions.ts +1 -0
- package/src/wirings/gateway/gateway-authorization.test.ts +444 -0
- package/src/wirings/gateway/gateway-runner.ts +114 -68
- package/src/wirings/gateway/gateway.types.ts +7 -9
- package/src/wirings/http/http.types.ts +6 -4
- package/src/wirings/scope/scope.test.ts +25 -0
- package/src/wirings/scope/validate-scope-definitions.ts +16 -1
- package/src/wirings/secret/secret.types.ts +14 -0
- package/src/wirings/secret/validate-secret-definitions.ts +2 -0
- package/src/wirings/variable/validate-variable-definitions.ts +2 -0
- package/src/wirings/variable/variable.types.ts +14 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +36 -6
- package/src/wirings/workflow/pikku-workflow-service.ts +36 -0
- package/src/wirings/workflow/workflow-on-error.test.ts +154 -0
- package/src/wirings/workflow/workflow.types.ts +0 -2
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import { test, describe, beforeEach } from 'node:test'
|
|
2
|
+
import * as assert from 'assert'
|
|
3
|
+
import { wireGateway, createListenerMessageHandler } from './gateway-runner.js'
|
|
4
|
+
import { pikkuState, resetPikkuState } from '../../pikku-state.js'
|
|
5
|
+
import { httpRouter } from '../http/routers/http-router.js'
|
|
6
|
+
import { fetch } from '../http/http-runner.js'
|
|
7
|
+
import {
|
|
8
|
+
addGlobalPermission,
|
|
9
|
+
clearPermissionsCache,
|
|
10
|
+
} from '../../permissions.js'
|
|
11
|
+
import type {
|
|
12
|
+
GatewayAdapter,
|
|
13
|
+
GatewayInboundMessage,
|
|
14
|
+
GatewayOutboundMessage,
|
|
15
|
+
} from './gateway.types.js'
|
|
16
|
+
|
|
17
|
+
const createMockAdapter = (): GatewayAdapter & {
|
|
18
|
+
sentMessages: Array<{ senderId: string; message: GatewayOutboundMessage }>
|
|
19
|
+
} => {
|
|
20
|
+
const sentMessages: Array<{
|
|
21
|
+
senderId: string
|
|
22
|
+
message: GatewayOutboundMessage
|
|
23
|
+
}> = []
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
name: 'mock',
|
|
27
|
+
sentMessages,
|
|
28
|
+
parse(data: unknown): GatewayInboundMessage | null {
|
|
29
|
+
const d = data as Record<string, any>
|
|
30
|
+
if (!d?.text) return null
|
|
31
|
+
return { senderId: d.senderId ?? 'sender-1', text: d.text, raw: data }
|
|
32
|
+
},
|
|
33
|
+
send: async (senderId, message) => {
|
|
34
|
+
sentMessages.push({ senderId, message })
|
|
35
|
+
},
|
|
36
|
+
init: async () => {},
|
|
37
|
+
close: async () => {},
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const singletonServices = {
|
|
42
|
+
config: {},
|
|
43
|
+
logger: {
|
|
44
|
+
info: () => {},
|
|
45
|
+
warn: () => {},
|
|
46
|
+
error: () => {},
|
|
47
|
+
debug: () => {},
|
|
48
|
+
},
|
|
49
|
+
variables: {},
|
|
50
|
+
secrets: {},
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const setupState = () => {
|
|
54
|
+
resetPikkuState()
|
|
55
|
+
httpRouter.reset()
|
|
56
|
+
clearPermissionsCache()
|
|
57
|
+
pikkuState(null, 'package', 'singletonServices', singletonServices as any)
|
|
58
|
+
pikkuState(null, 'package', 'factories', {
|
|
59
|
+
createWireServices: async () => ({}),
|
|
60
|
+
} as any)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const seedCompiledMeta = () => {
|
|
64
|
+
const httpMeta = pikkuState(null, 'http', 'meta') as any
|
|
65
|
+
const funcMeta = pikkuState(null, 'function', 'meta') as any
|
|
66
|
+
for (const config of pikkuState(null, 'gateway', 'gateways').values()) {
|
|
67
|
+
if (config.type !== 'webhook' || !config.route) continue
|
|
68
|
+
for (const [method, funcId] of [
|
|
69
|
+
['post', `gateway__${config.name}__post`],
|
|
70
|
+
['get', `gateway__${config.name}__verify`],
|
|
71
|
+
] as const) {
|
|
72
|
+
httpMeta[method][config.route] = {
|
|
73
|
+
pikkuFuncId: funcId,
|
|
74
|
+
route: config.route,
|
|
75
|
+
method,
|
|
76
|
+
}
|
|
77
|
+
funcMeta[funcId] = {
|
|
78
|
+
pikkuFuncId: funcId,
|
|
79
|
+
inputSchemaName: null,
|
|
80
|
+
outputSchemaName: null,
|
|
81
|
+
sessionless: true,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const postMessage = async (route: string) => {
|
|
88
|
+
const request = new Request(`http://localhost${route}`, {
|
|
89
|
+
method: 'POST',
|
|
90
|
+
headers: { 'Content-Type': 'application/json' },
|
|
91
|
+
body: JSON.stringify({ senderId: 'user-1', text: 'hello' }),
|
|
92
|
+
})
|
|
93
|
+
return await fetch(request)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
describe('gateway handler authorization', () => {
|
|
97
|
+
beforeEach(() => {
|
|
98
|
+
setupState()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('webhook gateway', () => {
|
|
102
|
+
test('a denying permission on the handler blocks it', async () => {
|
|
103
|
+
const calls: string[] = []
|
|
104
|
+
|
|
105
|
+
wireGateway({
|
|
106
|
+
name: 'perm-deny',
|
|
107
|
+
type: 'webhook',
|
|
108
|
+
route: '/webhooks/perm-deny',
|
|
109
|
+
adapter: createMockAdapter(),
|
|
110
|
+
func: {
|
|
111
|
+
permissions: { denied: async () => false },
|
|
112
|
+
func: async () => {
|
|
113
|
+
calls.push('ran')
|
|
114
|
+
return { text: 'reply' }
|
|
115
|
+
},
|
|
116
|
+
} as any,
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
seedCompiledMeta()
|
|
120
|
+
httpRouter.initialize()
|
|
121
|
+
|
|
122
|
+
const response = await postMessage('/webhooks/perm-deny')
|
|
123
|
+
|
|
124
|
+
assert.equal(response.status, 403)
|
|
125
|
+
assert.deepEqual(calls, [], 'handler must not run when permissions deny')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
test('a passing permission on the handler allows it', async () => {
|
|
129
|
+
const calls: string[] = []
|
|
130
|
+
|
|
131
|
+
wireGateway({
|
|
132
|
+
name: 'perm-allow',
|
|
133
|
+
type: 'webhook',
|
|
134
|
+
route: '/webhooks/perm-allow',
|
|
135
|
+
adapter: createMockAdapter(),
|
|
136
|
+
func: {
|
|
137
|
+
permissions: { allowed: async () => true },
|
|
138
|
+
func: async () => {
|
|
139
|
+
calls.push('ran')
|
|
140
|
+
return { text: 'reply' }
|
|
141
|
+
},
|
|
142
|
+
} as any,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
seedCompiledMeta()
|
|
146
|
+
httpRouter.initialize()
|
|
147
|
+
|
|
148
|
+
const response = await postMessage('/webhooks/perm-allow')
|
|
149
|
+
|
|
150
|
+
assert.equal(response.status, 200)
|
|
151
|
+
assert.deepEqual(calls, ['ran'])
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
test('handler scopes are enforced and fail closed without a session', async () => {
|
|
155
|
+
const calls: string[] = []
|
|
156
|
+
|
|
157
|
+
wireGateway({
|
|
158
|
+
name: 'scope-gate',
|
|
159
|
+
type: 'webhook',
|
|
160
|
+
route: '/webhooks/scope-gate',
|
|
161
|
+
adapter: createMockAdapter(),
|
|
162
|
+
func: {
|
|
163
|
+
scopes: ['admin:webhooks'],
|
|
164
|
+
func: async () => {
|
|
165
|
+
calls.push('ran')
|
|
166
|
+
return { text: 'reply' }
|
|
167
|
+
},
|
|
168
|
+
} as any,
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
seedCompiledMeta()
|
|
172
|
+
httpRouter.initialize()
|
|
173
|
+
|
|
174
|
+
const response = await postMessage('/webhooks/scope-gate')
|
|
175
|
+
|
|
176
|
+
assert.equal(response.status, 403)
|
|
177
|
+
assert.deepEqual(calls, [], 'handler must not run without the scope')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('global permissions apply to gateway handlers', async () => {
|
|
181
|
+
const calls: string[] = []
|
|
182
|
+
|
|
183
|
+
addGlobalPermission([async () => false])
|
|
184
|
+
|
|
185
|
+
wireGateway({
|
|
186
|
+
name: 'global-deny',
|
|
187
|
+
type: 'webhook',
|
|
188
|
+
route: '/webhooks/global-deny',
|
|
189
|
+
adapter: createMockAdapter(),
|
|
190
|
+
func: {
|
|
191
|
+
func: async () => {
|
|
192
|
+
calls.push('ran')
|
|
193
|
+
return { text: 'reply' }
|
|
194
|
+
},
|
|
195
|
+
} as any,
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
seedCompiledMeta()
|
|
199
|
+
httpRouter.initialize()
|
|
200
|
+
|
|
201
|
+
const response = await postMessage('/webhooks/global-deny')
|
|
202
|
+
|
|
203
|
+
assert.equal(response.status, 403)
|
|
204
|
+
assert.deepEqual(calls, [], 'a denying global must block the handler')
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('a handler with no authorization declared still runs', async () => {
|
|
208
|
+
const calls: string[] = []
|
|
209
|
+
|
|
210
|
+
wireGateway({
|
|
211
|
+
name: 'open',
|
|
212
|
+
type: 'webhook',
|
|
213
|
+
route: '/webhooks/open',
|
|
214
|
+
adapter: createMockAdapter(),
|
|
215
|
+
func: {
|
|
216
|
+
func: async () => {
|
|
217
|
+
calls.push('ran')
|
|
218
|
+
return { text: 'reply' }
|
|
219
|
+
},
|
|
220
|
+
} as any,
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
seedCompiledMeta()
|
|
224
|
+
httpRouter.initialize()
|
|
225
|
+
|
|
226
|
+
const response = await postMessage('/webhooks/open')
|
|
227
|
+
|
|
228
|
+
assert.equal(response.status, 200)
|
|
229
|
+
assert.deepEqual(calls, ['ran'])
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test('gateway-level auth: true requires a session', async () => {
|
|
233
|
+
const calls: string[] = []
|
|
234
|
+
|
|
235
|
+
wireGateway({
|
|
236
|
+
name: 'gw-auth',
|
|
237
|
+
type: 'webhook',
|
|
238
|
+
route: '/webhooks/gw-auth',
|
|
239
|
+
adapter: createMockAdapter(),
|
|
240
|
+
auth: true,
|
|
241
|
+
func: {
|
|
242
|
+
func: async () => {
|
|
243
|
+
calls.push('ran')
|
|
244
|
+
return { text: 'reply' }
|
|
245
|
+
},
|
|
246
|
+
} as any,
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
seedCompiledMeta()
|
|
250
|
+
httpRouter.initialize()
|
|
251
|
+
|
|
252
|
+
const response = await postMessage('/webhooks/gw-auth')
|
|
253
|
+
|
|
254
|
+
assert.equal(response.status, 403)
|
|
255
|
+
assert.deepEqual(calls, [], 'auth: true must require a session')
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('handler-level auth: true requires a session', async () => {
|
|
259
|
+
const calls: string[] = []
|
|
260
|
+
|
|
261
|
+
wireGateway({
|
|
262
|
+
name: 'handler-auth',
|
|
263
|
+
type: 'webhook',
|
|
264
|
+
route: '/webhooks/handler-auth',
|
|
265
|
+
adapter: createMockAdapter(),
|
|
266
|
+
func: {
|
|
267
|
+
auth: true,
|
|
268
|
+
func: async () => {
|
|
269
|
+
calls.push('ran')
|
|
270
|
+
return { text: 'reply' }
|
|
271
|
+
},
|
|
272
|
+
} as any,
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
seedCompiledMeta()
|
|
276
|
+
httpRouter.initialize()
|
|
277
|
+
|
|
278
|
+
const response = await postMessage('/webhooks/handler-auth')
|
|
279
|
+
|
|
280
|
+
assert.equal(response.status, 403)
|
|
281
|
+
assert.deepEqual(calls, [], 'auth: true must require a session')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('the adapter still auto-sends the handler reply', async () => {
|
|
285
|
+
const adapter = createMockAdapter()
|
|
286
|
+
|
|
287
|
+
wireGateway({
|
|
288
|
+
name: 'reply',
|
|
289
|
+
type: 'webhook',
|
|
290
|
+
route: '/webhooks/reply',
|
|
291
|
+
adapter,
|
|
292
|
+
func: {
|
|
293
|
+
permissions: { allowed: async () => true },
|
|
294
|
+
func: async () => ({ text: 'pong' }),
|
|
295
|
+
} as any,
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
seedCompiledMeta()
|
|
299
|
+
httpRouter.initialize()
|
|
300
|
+
|
|
301
|
+
await postMessage('/webhooks/reply')
|
|
302
|
+
|
|
303
|
+
assert.equal(adapter.sentMessages.length, 1)
|
|
304
|
+
assert.equal(adapter.sentMessages[0].message.text, 'pong')
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test('a session set by gateway middleware satisfies the handler gate', async () => {
|
|
308
|
+
const seen: any[] = []
|
|
309
|
+
|
|
310
|
+
wireGateway({
|
|
311
|
+
name: 'mw-session',
|
|
312
|
+
type: 'webhook',
|
|
313
|
+
route: '/webhooks/mw-session',
|
|
314
|
+
adapter: createMockAdapter(),
|
|
315
|
+
auth: true,
|
|
316
|
+
middleware: [
|
|
317
|
+
async (_s: any, wire: any, next: any) => {
|
|
318
|
+
await wire.setSession({
|
|
319
|
+
userId: 'mapped-user',
|
|
320
|
+
scopes: ['gateway:inbound'],
|
|
321
|
+
})
|
|
322
|
+
await next()
|
|
323
|
+
},
|
|
324
|
+
] as any,
|
|
325
|
+
func: {
|
|
326
|
+
scopes: ['gateway:inbound'],
|
|
327
|
+
func: async (_s: any, _d: any, wire: any) => {
|
|
328
|
+
seen.push(wire.session)
|
|
329
|
+
return { text: 'ok' }
|
|
330
|
+
},
|
|
331
|
+
} as any,
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
seedCompiledMeta()
|
|
335
|
+
httpRouter.initialize()
|
|
336
|
+
|
|
337
|
+
const response = await postMessage('/webhooks/mw-session')
|
|
338
|
+
|
|
339
|
+
assert.equal(response.status, 200)
|
|
340
|
+
assert.equal(seen[0]?.userId, 'mapped-user')
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
test('handler middleware still runs, and only once', async () => {
|
|
344
|
+
const order: string[] = []
|
|
345
|
+
|
|
346
|
+
wireGateway({
|
|
347
|
+
name: 'mw',
|
|
348
|
+
type: 'webhook',
|
|
349
|
+
route: '/webhooks/mw',
|
|
350
|
+
adapter: createMockAdapter(),
|
|
351
|
+
func: {
|
|
352
|
+
middleware: [
|
|
353
|
+
async (_s: any, _w: any, next: any) => {
|
|
354
|
+
order.push('func-mw')
|
|
355
|
+
await next()
|
|
356
|
+
},
|
|
357
|
+
],
|
|
358
|
+
func: async () => {
|
|
359
|
+
order.push('handler')
|
|
360
|
+
return { text: 'ok' }
|
|
361
|
+
},
|
|
362
|
+
} as any,
|
|
363
|
+
middleware: [
|
|
364
|
+
async (_s: any, _w: any, next: any) => {
|
|
365
|
+
order.push('gateway-mw')
|
|
366
|
+
await next()
|
|
367
|
+
},
|
|
368
|
+
] as any,
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
seedCompiledMeta()
|
|
372
|
+
httpRouter.initialize()
|
|
373
|
+
|
|
374
|
+
await postMessage('/webhooks/mw')
|
|
375
|
+
|
|
376
|
+
assert.deepEqual(order, ['gateway-mw', 'func-mw', 'handler'])
|
|
377
|
+
})
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
describe('listener gateway', () => {
|
|
381
|
+
test('a denying permission on the handler blocks it', async () => {
|
|
382
|
+
const calls: string[] = []
|
|
383
|
+
const adapter = createMockAdapter()
|
|
384
|
+
|
|
385
|
+
const config = {
|
|
386
|
+
name: 'listen-deny',
|
|
387
|
+
type: 'listener' as const,
|
|
388
|
+
adapter,
|
|
389
|
+
func: {
|
|
390
|
+
permissions: { denied: async () => false },
|
|
391
|
+
func: async () => {
|
|
392
|
+
calls.push('ran')
|
|
393
|
+
return { text: 'reply' }
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
wireGateway(config as any)
|
|
399
|
+
|
|
400
|
+
const handler = createListenerMessageHandler(
|
|
401
|
+
config.name,
|
|
402
|
+
config as any,
|
|
403
|
+
singletonServices as any
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
await assert.rejects(
|
|
407
|
+
() => handler({ senderId: 'user-1', text: 'hello' }),
|
|
408
|
+
/Permission denied/
|
|
409
|
+
)
|
|
410
|
+
assert.deepEqual(calls, [], 'handler must not run when permissions deny')
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
test('a passing permission on the handler allows it', async () => {
|
|
414
|
+
const calls: string[] = []
|
|
415
|
+
const adapter = createMockAdapter()
|
|
416
|
+
|
|
417
|
+
const config = {
|
|
418
|
+
name: 'listen-allow',
|
|
419
|
+
type: 'listener' as const,
|
|
420
|
+
adapter,
|
|
421
|
+
func: {
|
|
422
|
+
permissions: { allowed: async () => true },
|
|
423
|
+
func: async () => {
|
|
424
|
+
calls.push('ran')
|
|
425
|
+
return { text: 'reply' }
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
wireGateway(config as any)
|
|
431
|
+
|
|
432
|
+
const handler = createListenerMessageHandler(
|
|
433
|
+
config.name,
|
|
434
|
+
config as any,
|
|
435
|
+
singletonServices as any
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
await handler({ senderId: 'user-1', text: 'hello' })
|
|
439
|
+
|
|
440
|
+
assert.deepEqual(calls, ['ran'])
|
|
441
|
+
assert.equal(adapter.sentMessages.length, 1)
|
|
442
|
+
})
|
|
443
|
+
})
|
|
444
|
+
})
|
|
@@ -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
|
import type {
|
|
@@ -17,6 +17,54 @@ import type {
|
|
|
17
17
|
*/
|
|
18
18
|
const resolvedAdapters = new WeakMap<CoreGateway, Promise<GatewayAdapter>>()
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* The generated function id a gateway's handler is registered under.
|
|
22
|
+
*/
|
|
23
|
+
const gatewayHandlerFuncId = (name: string) => `gateway__${name}__handler`
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Bridges a session established by gateway middleware onto the wire so the
|
|
27
|
+
* handler's gate can see it.
|
|
28
|
+
*
|
|
29
|
+
* Gateway middleware is the only place a webhook can acquire a session (e.g.
|
|
30
|
+
* mapping a verified platform sender to a user). Middleware that assigns
|
|
31
|
+
* `wire.session` needs nothing, but middleware using the idiomatic
|
|
32
|
+
* `wire.setSession()` writes to the enclosing wiring's session service, which
|
|
33
|
+
* the handler's own invocation does not read — without this the session would
|
|
34
|
+
* be silently invisible to `auth` and `scopes`.
|
|
35
|
+
*/
|
|
36
|
+
const bridgeMiddlewareSession = async (wire: PikkuRawWire): Promise<void> => {
|
|
37
|
+
if (wire.session || !wire.getSession) return
|
|
38
|
+
const session = await wire.getSession()
|
|
39
|
+
if (session) {
|
|
40
|
+
wire.session = session
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Registers a gateway's handler as a real pikku function so that invoking it
|
|
46
|
+
* goes through the function runner's gate. Without this the handler is called
|
|
47
|
+
* directly and its own `auth`, `scopes` and `permissions` are never evaluated.
|
|
48
|
+
*
|
|
49
|
+
* The handler is registered as sessionless: a gateway's inbound traffic is
|
|
50
|
+
* platform-authenticated (adapter signature verification), not session-bearing,
|
|
51
|
+
* so requiring a session by default would break every webhook. A handler that
|
|
52
|
+
* does need one declares `auth: true`, exactly like `pikkuSessionlessFunc`.
|
|
53
|
+
* `scopes` and `permissions` are always enforced when declared.
|
|
54
|
+
*/
|
|
55
|
+
const registerGatewayHandler = (config: CoreGateway): string => {
|
|
56
|
+
const funcId = gatewayHandlerFuncId(config.name)
|
|
57
|
+
const funcMeta = pikkuState(null, 'function', 'meta')
|
|
58
|
+
funcMeta[funcId] = {
|
|
59
|
+
pikkuFuncId: funcId,
|
|
60
|
+
inputSchemaName: null,
|
|
61
|
+
outputSchemaName: null,
|
|
62
|
+
sessionless: true,
|
|
63
|
+
}
|
|
64
|
+
addFunction(funcId, config.func as any)
|
|
65
|
+
return funcId
|
|
66
|
+
}
|
|
67
|
+
|
|
20
68
|
export const resolveGatewayAdapter = (
|
|
21
69
|
config: CoreGateway,
|
|
22
70
|
services: CoreSingletonServices
|
|
@@ -142,15 +190,13 @@ const wireWebhookGateway = (config: CoreGateway): void => {
|
|
|
142
190
|
* 2. Parse body via adapter → GatewayInboundMessage (or null to ignore)
|
|
143
191
|
* 3. Populate `wire.gateway`
|
|
144
192
|
* 4. Run user middleware (which can read `wire.gateway` for auth)
|
|
145
|
-
* 5.
|
|
193
|
+
* 5. Invoke the handler through the function runner, which enforces its
|
|
194
|
+
* `auth`/`scopes`/`permissions` before running it
|
|
146
195
|
* 6. Auto-send response via adapter if func returns outbound content
|
|
147
196
|
*/
|
|
148
197
|
const createWebhookPostHandler = (config: CoreGateway) => {
|
|
149
|
-
const { name,
|
|
150
|
-
const
|
|
151
|
-
func: Function
|
|
152
|
-
middleware?: CorePikkuMiddleware[]
|
|
153
|
-
}
|
|
198
|
+
const { name, middleware: userMiddleware } = config
|
|
199
|
+
const handlerFuncId = registerGatewayHandler(config)
|
|
154
200
|
|
|
155
201
|
return async (
|
|
156
202
|
services: CoreSingletonServices,
|
|
@@ -183,26 +229,32 @@ const createWebhookPostHandler = (config: CoreGateway) => {
|
|
|
183
229
|
}
|
|
184
230
|
;(wire as any).gateway = gateway
|
|
185
231
|
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
}
|
|
198
|
-
return { ok: true }
|
|
232
|
+
// Gateway middleware runs first and outside the gate, so it can establish
|
|
233
|
+
// the session the gate then checks. The handler is invoked through the
|
|
234
|
+
// function runner, which enforces its auth, scopes and permissions and
|
|
235
|
+
// applies the handler's own middleware.
|
|
236
|
+
const invoke = async () => {
|
|
237
|
+
await bridgeMiddlewareSession(wire as any)
|
|
238
|
+
return await runPikkuFunc('gateway', name, handlerFuncId, {
|
|
239
|
+
singletonServices: services,
|
|
240
|
+
data: () => parsed,
|
|
241
|
+
auth: config.auth,
|
|
242
|
+
wire: wire as any,
|
|
243
|
+
})
|
|
199
244
|
}
|
|
200
245
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
246
|
+
const gatewayMiddleware = userMiddleware as
|
|
247
|
+
| CorePikkuMiddleware[]
|
|
248
|
+
| undefined
|
|
249
|
+
const result: any = gatewayMiddleware?.length
|
|
250
|
+
? await runMiddleware(services, wire, gatewayMiddleware, invoke)
|
|
251
|
+
: await invoke()
|
|
204
252
|
|
|
205
|
-
|
|
253
|
+
// Auto-send response if the func returns outbound content
|
|
254
|
+
if (result && (result.text || result.richContent || result.attachments)) {
|
|
255
|
+
await adapter.send(parsed.senderId, result as GatewayOutboundMessage)
|
|
256
|
+
}
|
|
257
|
+
return { ok: true }
|
|
206
258
|
}
|
|
207
259
|
}
|
|
208
260
|
|
|
@@ -281,11 +333,8 @@ const wireWebsocketGateway = (config: CoreGateway): void => {
|
|
|
281
333
|
message: { pikkuFuncId: messageFuncId },
|
|
282
334
|
} as any
|
|
283
335
|
|
|
284
|
-
const userFuncConfig = config.func as {
|
|
285
|
-
func: Function
|
|
286
|
-
middleware?: CorePikkuMiddleware[]
|
|
287
|
-
}
|
|
288
336
|
const userMiddleware = config.middleware as CorePikkuMiddleware[] | undefined
|
|
337
|
+
const handlerFuncId = registerGatewayHandler(config)
|
|
289
338
|
|
|
290
339
|
// Register onConnect
|
|
291
340
|
addFunction(connectFuncId, {
|
|
@@ -321,25 +370,25 @@ const wireWebsocketGateway = (config: CoreGateway): void => {
|
|
|
321
370
|
}
|
|
322
371
|
;(wire as any).gateway = gateway
|
|
323
372
|
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
result &&
|
|
333
|
-
(result.text || result.richContent || result.attachments)
|
|
334
|
-
) {
|
|
335
|
-
wire.channel?.send(result)
|
|
336
|
-
}
|
|
373
|
+
const invoke = async () => {
|
|
374
|
+
await bridgeMiddlewareSession(wire as any)
|
|
375
|
+
return await runPikkuFunc('gateway', name, handlerFuncId, {
|
|
376
|
+
singletonServices: services,
|
|
377
|
+
data: () => parsed,
|
|
378
|
+
auth: config.auth,
|
|
379
|
+
wire: wire as any,
|
|
380
|
+
})
|
|
337
381
|
}
|
|
338
382
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
383
|
+
const gatewayMiddleware = userMiddleware as
|
|
384
|
+
| CorePikkuMiddleware[]
|
|
385
|
+
| undefined
|
|
386
|
+
const result: any = gatewayMiddleware?.length
|
|
387
|
+
? await runMiddleware(services, wire, gatewayMiddleware, invoke)
|
|
388
|
+
: await invoke()
|
|
389
|
+
|
|
390
|
+
if (result && (result.text || result.richContent || result.attachments)) {
|
|
391
|
+
wire.channel?.send(result)
|
|
343
392
|
}
|
|
344
393
|
},
|
|
345
394
|
} as any)
|
|
@@ -384,11 +433,8 @@ export const createListenerMessageHandler = (
|
|
|
384
433
|
config: CoreGateway,
|
|
385
434
|
singletonServices: CoreSingletonServices
|
|
386
435
|
): ((rawData: unknown) => Promise<void>) => {
|
|
387
|
-
const userFuncConfig = config.func as {
|
|
388
|
-
func: Function
|
|
389
|
-
middleware?: CorePikkuMiddleware[]
|
|
390
|
-
}
|
|
391
436
|
const userMiddleware = config.middleware as CorePikkuMiddleware[] | undefined
|
|
437
|
+
const handlerFuncId = registerGatewayHandler(config)
|
|
392
438
|
|
|
393
439
|
return async (rawData: unknown): Promise<void> => {
|
|
394
440
|
const adapter = await resolveGatewayAdapter(config, singletonServices)
|
|
@@ -404,27 +450,27 @@ export const createListenerMessageHandler = (
|
|
|
404
450
|
}
|
|
405
451
|
;(wire as any).gateway = gateway
|
|
406
452
|
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
await adapter.send(parsed.senderId, result as GatewayOutboundMessage)
|
|
416
|
-
}
|
|
453
|
+
const invoke = async () => {
|
|
454
|
+
await bridgeMiddlewareSession(wire)
|
|
455
|
+
return await runPikkuFunc('gateway', name, handlerFuncId, {
|
|
456
|
+
singletonServices,
|
|
457
|
+
data: () => parsed,
|
|
458
|
+
auth: config.auth,
|
|
459
|
+
wire,
|
|
460
|
+
})
|
|
417
461
|
}
|
|
418
462
|
|
|
419
|
-
|
|
420
|
-
await runMiddleware(
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
463
|
+
const result: any = userMiddleware?.length
|
|
464
|
+
? await runMiddleware(
|
|
465
|
+
singletonServices,
|
|
466
|
+
wire as any,
|
|
467
|
+
userMiddleware,
|
|
468
|
+
invoke
|
|
469
|
+
)
|
|
470
|
+
: await invoke()
|
|
471
|
+
|
|
472
|
+
if (result && (result.text || result.richContent || result.attachments)) {
|
|
473
|
+
await adapter.send(parsed.senderId, result as GatewayOutboundMessage)
|
|
428
474
|
}
|
|
429
475
|
}
|
|
430
476
|
}
|