@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.
- package/CHANGELOG.md +147 -0
- package/dist/dev/hot-reload.js +13 -55
- package/dist/dev/module-runner.d.ts +14 -0
- package/dist/dev/module-runner.js +59 -0
- package/dist/dev/reload-meta.d.ts +13 -0
- package/dist/dev/reload-meta.js +22 -0
- package/dist/errors/errors.d.ts +12 -0
- package/dist/errors/errors.js +19 -0
- package/dist/function/function-runner.d.ts +5 -5
- package/dist/function/function-runner.js +39 -111
- package/dist/function/functions.types.d.ts +17 -7
- package/dist/index.d.ts +5 -1
- package/dist/index.js +3 -1
- package/dist/middleware/auth-apikey.d.ts +1 -0
- package/dist/middleware/auth-bearer.d.ts +1 -0
- package/dist/middleware/auth-cookie.d.ts +1 -0
- package/dist/middleware/cors.d.ts +1 -0
- package/dist/middleware/index.d.ts +1 -1
- package/dist/middleware/index.js +1 -1
- package/dist/middleware/remote-auth.d.ts +1 -0
- package/dist/middleware/telemetry.d.ts +2 -0
- package/dist/middleware/timeout.d.ts +1 -0
- package/dist/permissions.d.ts +32 -25
- package/dist/permissions.js +104 -185
- package/dist/pikku-state.js +0 -2
- package/dist/scopes.d.ts +17 -0
- package/dist/scopes.js +59 -0
- package/dist/services/ai-embedding-service.d.ts +31 -0
- package/dist/services/ai-embedding-service.js +1 -0
- package/dist/services/credential-wire-service.d.ts +3 -1
- package/dist/services/credential-wire-service.js +10 -4
- package/dist/services/index.d.ts +4 -0
- package/dist/services/index.js +2 -0
- package/dist/services/meta-service.d.ts +15 -2
- package/dist/services/meta-service.js +51 -1
- package/dist/services/queue-webhook-service.d.ts +55 -0
- package/dist/services/queue-webhook-service.js +136 -0
- package/dist/services/scope-service.d.ts +67 -0
- package/dist/services/scope-service.js +1 -0
- package/dist/services/typed-secret-service.d.ts +8 -0
- package/dist/services/typed-secret-service.js +39 -4
- package/dist/services/webhook-service.d.ts +140 -0
- package/dist/services/webhook-service.js +44 -0
- package/dist/services/workflow-service.d.ts +7 -0
- package/dist/types/core.types.d.ts +34 -6
- package/dist/types/state.types.d.ts +20 -2
- package/dist/utils/hmac.d.ts +16 -0
- package/dist/utils/hmac.js +26 -0
- package/dist/utils/safe-fetch.d.ts +51 -0
- package/dist/utils/safe-fetch.js +192 -0
- package/dist/wirings/ai-agent/ai-agent-agui.d.ts +84 -0
- package/dist/wirings/ai-agent/ai-agent-agui.js +305 -0
- package/dist/wirings/ai-agent/ai-agent-prepare.d.ts +41 -1
- package/dist/wirings/ai-agent/ai-agent-prepare.js +146 -6
- package/dist/wirings/ai-agent/ai-agent-runner.js +6 -1
- package/dist/wirings/ai-agent/ai-agent-stream.js +61 -10
- package/dist/wirings/ai-agent/ai-agent.types.d.ts +27 -1
- package/dist/wirings/ai-agent/index.d.ts +1 -0
- package/dist/wirings/ai-agent/index.js +1 -0
- package/dist/wirings/ai-agent/voice-input.d.ts +1 -0
- package/dist/wirings/ai-agent/voice-input.js +2 -38
- package/dist/wirings/ai-agent/voice-output.d.ts +1 -0
- package/dist/wirings/channel/channel-common.js +0 -1
- package/dist/wirings/channel/channel-handler.js +1 -4
- package/dist/wirings/channel/channel-middleware-runner.d.ts +12 -0
- package/dist/wirings/channel/channel-middleware-runner.js +34 -19
- package/dist/wirings/channel/channel.types.d.ts +2 -6
- package/dist/wirings/cli/cli-runner.js +0 -2
- package/dist/wirings/cli/cli.types.d.ts +1 -2
- package/dist/wirings/http/http-routes.js +0 -3
- package/dist/wirings/http/http-runner.d.ts +9 -29
- package/dist/wirings/http/http-runner.js +9 -34
- package/dist/wirings/http/http.types.d.ts +1 -9
- package/dist/wirings/mcp/mcp-runner.js +0 -2
- package/dist/wirings/mcp/mcp.types.d.ts +5 -11
- package/dist/wirings/oauth2/index.d.ts +0 -3
- package/dist/wirings/oauth2/index.js +1 -2
- package/dist/wirings/rpc/addon-runner.d.ts +28 -0
- package/dist/wirings/rpc/addon-runner.js +173 -0
- package/dist/wirings/rpc/index.d.ts +5 -1
- package/dist/wirings/rpc/index.js +3 -1
- package/dist/wirings/rpc/remote-addon-auth.d.ts +26 -0
- package/dist/wirings/rpc/remote-addon-auth.js +43 -0
- package/dist/wirings/rpc/rpc-runner.d.ts +19 -0
- package/dist/wirings/rpc/rpc-runner.js +109 -3
- package/dist/wirings/rpc/rpc-types.d.ts +15 -1
- package/dist/wirings/rpc/wire-addon.js +9 -0
- package/dist/wirings/rpc/wire-remote-addon.d.ts +47 -0
- package/dist/wirings/rpc/wire-remote-addon.js +19 -0
- package/dist/wirings/scope/index.d.ts +3 -0
- package/dist/wirings/scope/index.js +2 -0
- package/dist/wirings/scope/scope.types.d.ts +39 -0
- package/dist/wirings/scope/scope.types.js +1 -0
- package/dist/wirings/scope/validate-scope-definitions.d.ts +16 -0
- package/dist/wirings/scope/validate-scope-definitions.js +76 -0
- package/dist/wirings/scope/wire-scope.d.ts +33 -0
- package/dist/wirings/scope/wire-scope.js +32 -0
- package/dist/wirings/workflow/dsl/index.d.ts +1 -1
- package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +53 -1
- package/dist/wirings/workflow/graph/graph-node.d.ts +2 -0
- package/dist/wirings/workflow/graph/graph-runner.js +3 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.d.ts +6 -0
- package/dist/wirings/workflow/graph/wire-workflow-graph.js +1 -0
- package/dist/wirings/workflow/graph/workflow-graph.types.d.ts +5 -0
- package/dist/wirings/workflow/index.d.ts +2 -2
- package/dist/wirings/workflow/index.js +1 -1
- package/dist/wirings/workflow/pikku-workflow-service.d.ts +51 -1
- package/dist/wirings/workflow/pikku-workflow-service.js +171 -0
- package/dist/wirings/workflow/workflow.types.d.ts +1 -1
- package/package.json +5 -1
- package/run-tests.sh +1 -0
- package/src/dev/hot-reload.ts +13 -68
- package/src/dev/module-runner.test.ts +169 -0
- package/src/dev/module-runner.ts +103 -0
- package/src/dev/reload-meta.test.ts +31 -2
- package/src/dev/reload-meta.ts +26 -0
- package/src/errors/errors.ts +24 -0
- package/src/function/function-runner.test.ts +276 -142
- package/src/function/function-runner.ts +52 -145
- package/src/function/functions.types.ts +28 -38
- package/src/index.ts +8 -6
- package/src/middleware/index.ts +1 -5
- package/src/permissions.test.ts +153 -372
- package/src/permissions.ts +139 -259
- package/src/pikku-state.ts +0 -2
- package/src/scopes.test.ts +167 -0
- package/src/scopes.ts +69 -0
- package/src/services/ai-embedding-service.ts +31 -0
- package/src/services/credential-wire-service.ts +10 -4
- package/src/services/index.ts +20 -0
- package/src/services/meta-service.ts +68 -3
- package/src/services/queue-webhook-service.test.ts +408 -0
- package/src/services/queue-webhook-service.ts +182 -0
- package/src/services/scope-service.ts +75 -0
- package/src/services/typed-secret-service.test.ts +35 -0
- package/src/services/typed-secret-service.ts +39 -4
- package/src/services/webhook-service.ts +180 -0
- package/src/services/workflow-service.ts +7 -0
- package/src/types/core.types.ts +42 -14
- package/src/types/state.types.ts +23 -3
- package/src/utils/hmac.ts +27 -0
- package/src/utils/safe-fetch.test.ts +373 -0
- package/src/utils/safe-fetch.ts +213 -0
- package/src/wirings/ai-agent/ai-agent-agui.test.ts +1127 -0
- package/src/wirings/ai-agent/ai-agent-agui.ts +386 -0
- package/src/wirings/ai-agent/ai-agent-prepare.test.ts +308 -0
- package/src/wirings/ai-agent/ai-agent-prepare.ts +192 -8
- package/src/wirings/ai-agent/ai-agent-runner.test.ts +48 -1
- package/src/wirings/ai-agent/ai-agent-runner.ts +21 -0
- package/src/wirings/ai-agent/ai-agent-stream.test.ts +198 -10
- package/src/wirings/ai-agent/ai-agent-stream.ts +84 -10
- package/src/wirings/ai-agent/ai-agent.types.ts +28 -0
- package/src/wirings/ai-agent/index.ts +1 -0
- package/src/wirings/ai-agent/voice-input.ts +6 -35
- package/src/wirings/channel/channel-common.ts +0 -1
- package/src/wirings/channel/channel-handler.ts +1 -9
- package/src/wirings/channel/channel-middleware-runner.test.ts +28 -2
- package/src/wirings/channel/channel-middleware-runner.ts +39 -27
- package/src/wirings/channel/channel.types.ts +0 -6
- package/src/wirings/cli/cli-runner.ts +0 -2
- package/src/wirings/cli/cli.types.ts +0 -2
- package/src/wirings/http/http-routes.ts +0 -3
- package/src/wirings/http/http-runner.test.ts +1 -11
- package/src/wirings/http/http-runner.ts +16 -39
- package/src/wirings/http/http.types.ts +1 -10
- package/src/wirings/mcp/mcp-runner.ts +0 -2
- package/src/wirings/mcp/mcp.types.ts +3 -11
- package/src/wirings/oauth2/index.ts +0 -3
- package/src/wirings/rpc/addon-runner.ts +233 -0
- package/src/wirings/rpc/index.ts +17 -1
- package/src/wirings/rpc/remote-addon-auth.ts +69 -0
- package/src/wirings/rpc/rpc-runner.test.ts +319 -16
- package/src/wirings/rpc/rpc-runner.ts +155 -3
- package/src/wirings/rpc/rpc-types.ts +16 -5
- package/src/wirings/rpc/wire-addon.test.ts +6 -3
- package/src/wirings/rpc/wire-addon.ts +9 -0
- package/src/wirings/rpc/wire-remote-addon.ts +57 -0
- package/src/wirings/scope/index.ts +14 -0
- package/src/wirings/scope/scope.test.ts +135 -0
- package/src/wirings/scope/scope.types.ts +44 -0
- package/src/wirings/scope/validate-scope-definitions.ts +110 -0
- package/src/wirings/scope/wire-scope.ts +34 -0
- package/src/wirings/workflow/dsl/index.ts +4 -0
- package/src/wirings/workflow/dsl/workflow-dsl.types.ts +61 -0
- package/src/wirings/workflow/graph/graph-node.ts +2 -0
- package/src/wirings/workflow/graph/graph-runner.test.ts +58 -0
- package/src/wirings/workflow/graph/graph-runner.ts +3 -0
- package/src/wirings/workflow/graph/wire-workflow-graph.ts +7 -0
- package/src/wirings/workflow/graph/workflow-graph.types.ts +5 -0
- package/src/wirings/workflow/index.ts +5 -0
- package/src/wirings/workflow/pikku-workflow-service.test.ts +341 -0
- package/src/wirings/workflow/pikku-workflow-service.ts +236 -0
- package/src/wirings/workflow/workflow-step-session.test.ts +21 -5
- package/src/wirings/workflow/workflow.types.ts +4 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/src/wirings/oauth2/oauth2-client.test.ts +0 -930
- package/src/wirings/oauth2/oauth2-client.ts +0 -331
- 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 = {
|