@pikku/kysely 0.13.0 → 0.13.2
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 +117 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +2 -0
- package/dist/src/kysely-ai-agent-run-service.d.ts +1 -0
- package/dist/src/kysely-ai-agent-run-service.js +15 -1
- package/dist/src/kysely-scope-service.d.ts +59 -0
- package/dist/src/kysely-scope-service.js +268 -0
- package/dist/src/kysely-tables.d.ts +67 -0
- package/dist/src/kysely-webhook-service.d.ts +27 -0
- package/dist/src/kysely-webhook-service.js +150 -0
- package/dist/src/kysely-workflow-service.js +1 -5
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/run-tests.sh +4 -4
- package/src/index.ts +2 -0
- package/src/kysely-ai-agent-run-service.ts +33 -1
- package/src/kysely-scope-service.test.ts +416 -0
- package/src/kysely-scope-service.ts +350 -0
- package/src/kysely-tables.ts +74 -0
- package/src/kysely-webhook-service.test.ts +120 -0
- package/src/kysely-webhook-service.ts +200 -0
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import type { Role, ScopeService } from '@pikku/core/services'
|
|
2
|
+
import type { FlatScope } from '@pikku/core/scope'
|
|
3
|
+
import type { Kysely } from 'kysely'
|
|
4
|
+
import { sql } from 'kysely'
|
|
5
|
+
import type { KyselyPikkuDB } from './kysely-tables.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Resolves and administers user scopes against four self-created tables.
|
|
9
|
+
*
|
|
10
|
+
* Requires better-auth: `pikku_user_role.user_id` references its `user` table
|
|
11
|
+
* with ON DELETE CASCADE, so deleting a user takes their grants with it. That
|
|
12
|
+
* table is created by better-auth's own migrations, which `pikku db migrate`
|
|
13
|
+
* hard-fails without, so it exists before `init()` runs.
|
|
14
|
+
*
|
|
15
|
+
* Scopes are declared in code and synced here; roles are data, composed by
|
|
16
|
+
* admins at runtime. `pikku_role_scopes` FKs into `pikku_scopes`, so the
|
|
17
|
+
* database itself refuses to grant a scope that was never declared.
|
|
18
|
+
*/
|
|
19
|
+
export class KyselyScopeService implements ScopeService {
|
|
20
|
+
private initialized = false
|
|
21
|
+
|
|
22
|
+
constructor(private db: Kysely<KyselyPikkuDB>) {}
|
|
23
|
+
|
|
24
|
+
public async init(): Promise<void> {
|
|
25
|
+
if (this.initialized) {
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
await this.db.schema
|
|
30
|
+
.createTable('pikku_scopes')
|
|
31
|
+
.ifNotExists()
|
|
32
|
+
.addColumn('name', 'text', (col) => col.primaryKey())
|
|
33
|
+
.addColumn('description', 'text')
|
|
34
|
+
.addColumn('declared', 'boolean', (col) => col.defaultTo(true).notNull())
|
|
35
|
+
.execute()
|
|
36
|
+
|
|
37
|
+
await this.db.schema
|
|
38
|
+
.createTable('pikku_roles')
|
|
39
|
+
.ifNotExists()
|
|
40
|
+
.addColumn('name', 'text', (col) => col.primaryKey())
|
|
41
|
+
.addColumn('description', 'text')
|
|
42
|
+
.addColumn('created_at', 'timestamp', (col) =>
|
|
43
|
+
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
44
|
+
)
|
|
45
|
+
.execute()
|
|
46
|
+
|
|
47
|
+
await this.db.schema
|
|
48
|
+
.createTable('pikku_role_scopes')
|
|
49
|
+
.ifNotExists()
|
|
50
|
+
.addColumn('role', 'text', (col) =>
|
|
51
|
+
col.notNull().references('pikku_roles.name').onDelete('cascade')
|
|
52
|
+
)
|
|
53
|
+
.addColumn('scope', 'text', (col) =>
|
|
54
|
+
col.notNull().references('pikku_scopes.name').onDelete('cascade')
|
|
55
|
+
)
|
|
56
|
+
.addPrimaryKeyConstraint('pikku_role_scopes_pk', ['role', 'scope'])
|
|
57
|
+
.execute()
|
|
58
|
+
|
|
59
|
+
await this.db.schema
|
|
60
|
+
.createTable('pikku_user_role')
|
|
61
|
+
.ifNotExists()
|
|
62
|
+
.addColumn('user_id', 'text', (col) =>
|
|
63
|
+
col.notNull().references('user.id').onDelete('cascade')
|
|
64
|
+
)
|
|
65
|
+
.addColumn('role', 'text', (col) =>
|
|
66
|
+
col.notNull().references('pikku_roles.name').onDelete('cascade')
|
|
67
|
+
)
|
|
68
|
+
.addColumn('granted_by', 'text')
|
|
69
|
+
.addColumn('granted_at', 'timestamp', (col) =>
|
|
70
|
+
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
71
|
+
)
|
|
72
|
+
.addPrimaryKeyConstraint('pikku_user_role_pk', ['user_id', 'role'])
|
|
73
|
+
.execute()
|
|
74
|
+
|
|
75
|
+
await this.db.schema
|
|
76
|
+
.createTable('pikku_user_scope')
|
|
77
|
+
.ifNotExists()
|
|
78
|
+
.addColumn('user_id', 'text', (col) =>
|
|
79
|
+
col.notNull().references('user.id').onDelete('cascade')
|
|
80
|
+
)
|
|
81
|
+
.addColumn('scope', 'text', (col) =>
|
|
82
|
+
col.notNull().references('pikku_scopes.name').onDelete('cascade')
|
|
83
|
+
)
|
|
84
|
+
.addColumn('granted_by', 'text')
|
|
85
|
+
.addColumn('granted_at', 'timestamp', (col) =>
|
|
86
|
+
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
87
|
+
)
|
|
88
|
+
.addPrimaryKeyConstraint('pikku_user_scope_pk', ['user_id', 'scope'])
|
|
89
|
+
.execute()
|
|
90
|
+
|
|
91
|
+
await this.db.schema
|
|
92
|
+
.createIndex('pikku_role_scopes_scope_idx')
|
|
93
|
+
.ifNotExists()
|
|
94
|
+
.on('pikku_role_scopes')
|
|
95
|
+
.column('scope')
|
|
96
|
+
.execute()
|
|
97
|
+
|
|
98
|
+
await this.db.schema
|
|
99
|
+
.createIndex('pikku_user_role_role_idx')
|
|
100
|
+
.ifNotExists()
|
|
101
|
+
.on('pikku_user_role')
|
|
102
|
+
.column('role')
|
|
103
|
+
.execute()
|
|
104
|
+
|
|
105
|
+
this.initialized = true
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Registers the declared scope set.
|
|
110
|
+
*
|
|
111
|
+
* Additive: rows are upserted and anything no longer declared is *marked*
|
|
112
|
+
* (`declared = false`), never deleted. Marking is non-destructive, so a
|
|
113
|
+
* rename, a rollback, or a rolling deploy where an older replica is still
|
|
114
|
+
* serving cannot silently strip a grant. `pruneScopes` is the deliberate
|
|
115
|
+
* removal path.
|
|
116
|
+
*/
|
|
117
|
+
async syncScopes(scopes: FlatScope[]): Promise<void> {
|
|
118
|
+
await this.db.transaction().execute(async (trx) => {
|
|
119
|
+
if (scopes.length > 0) {
|
|
120
|
+
await trx
|
|
121
|
+
.insertInto('pikkuScopes')
|
|
122
|
+
.values(
|
|
123
|
+
scopes.map((scope) => ({
|
|
124
|
+
name: scope.id,
|
|
125
|
+
description: scope.description ?? null,
|
|
126
|
+
declared: true,
|
|
127
|
+
}))
|
|
128
|
+
)
|
|
129
|
+
.onConflict((oc) =>
|
|
130
|
+
oc.column('name').doUpdateSet((eb) => ({
|
|
131
|
+
description: eb.ref('excluded.description'),
|
|
132
|
+
declared: true,
|
|
133
|
+
}))
|
|
134
|
+
)
|
|
135
|
+
.execute()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const markStale = trx.updateTable('pikkuScopes').set({ declared: false })
|
|
139
|
+
await (
|
|
140
|
+
scopes.length > 0
|
|
141
|
+
? markStale.where(
|
|
142
|
+
'name',
|
|
143
|
+
'not in',
|
|
144
|
+
scopes.map((s) => s.id)
|
|
145
|
+
)
|
|
146
|
+
: markStale
|
|
147
|
+
).execute()
|
|
148
|
+
})
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async resolveScopes(userId: string): Promise<string[]> {
|
|
152
|
+
const roleScopes = this.db
|
|
153
|
+
.selectFrom('pikkuUserRole')
|
|
154
|
+
.innerJoin(
|
|
155
|
+
'pikkuRoleScopes',
|
|
156
|
+
'pikkuRoleScopes.role',
|
|
157
|
+
'pikkuUserRole.role'
|
|
158
|
+
)
|
|
159
|
+
.select('pikkuRoleScopes.scope')
|
|
160
|
+
.where('pikkuUserRole.userId', '=', userId)
|
|
161
|
+
|
|
162
|
+
const directScopes = this.db
|
|
163
|
+
.selectFrom('pikkuUserScope')
|
|
164
|
+
.select('pikkuUserScope.scope')
|
|
165
|
+
.where('pikkuUserScope.userId', '=', userId)
|
|
166
|
+
|
|
167
|
+
const rows = await roleScopes.union(directScopes).execute()
|
|
168
|
+
|
|
169
|
+
return rows.map((row) => row.scope)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async listScopes(): Promise<Array<FlatScope & { declared: boolean }>> {
|
|
173
|
+
const rows = await this.db
|
|
174
|
+
.selectFrom('pikkuScopes')
|
|
175
|
+
.select(['name', 'description', 'declared'])
|
|
176
|
+
.execute()
|
|
177
|
+
|
|
178
|
+
return rows.map((row) => ({
|
|
179
|
+
id: row.name,
|
|
180
|
+
description: row.description ?? undefined,
|
|
181
|
+
declared: !!row.declared,
|
|
182
|
+
}))
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async createRole(role: Role): Promise<void> {
|
|
186
|
+
await this.db.transaction().execute(async (trx) => {
|
|
187
|
+
await trx
|
|
188
|
+
.insertInto('pikkuRoles')
|
|
189
|
+
.values({ name: role.name, description: role.description ?? null })
|
|
190
|
+
.execute()
|
|
191
|
+
|
|
192
|
+
if (role.scopes.length > 0) {
|
|
193
|
+
await trx
|
|
194
|
+
.insertInto('pikkuRoleScopes')
|
|
195
|
+
.values(role.scopes.map((scope) => ({ role: role.name, scope })))
|
|
196
|
+
.execute()
|
|
197
|
+
}
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async deleteRole(name: string): Promise<void> {
|
|
202
|
+
await this.db.deleteFrom('pikkuRoles').where('name', '=', name).execute()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async setRoleScopes(name: string, scopes: string[]): Promise<void> {
|
|
206
|
+
await this.db.transaction().execute(async (trx) => {
|
|
207
|
+
await trx.deleteFrom('pikkuRoleScopes').where('role', '=', name).execute()
|
|
208
|
+
|
|
209
|
+
if (scopes.length > 0) {
|
|
210
|
+
await trx
|
|
211
|
+
.insertInto('pikkuRoleScopes')
|
|
212
|
+
.values(scopes.map((scope) => ({ role: name, scope })))
|
|
213
|
+
.execute()
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async listRoles(): Promise<Role[]> {
|
|
219
|
+
const roles = await this.db
|
|
220
|
+
.selectFrom('pikkuRoles')
|
|
221
|
+
.select(['name', 'description'])
|
|
222
|
+
.execute()
|
|
223
|
+
|
|
224
|
+
const scopeRows = await this.db
|
|
225
|
+
.selectFrom('pikkuRoleScopes')
|
|
226
|
+
.select(['role', 'scope'])
|
|
227
|
+
.execute()
|
|
228
|
+
|
|
229
|
+
const byRole = new Map<string, string[]>()
|
|
230
|
+
for (const row of scopeRows) {
|
|
231
|
+
const existing = byRole.get(row.role)
|
|
232
|
+
if (existing) {
|
|
233
|
+
existing.push(row.scope)
|
|
234
|
+
} else {
|
|
235
|
+
byRole.set(row.role, [row.scope])
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return roles.map((role) => ({
|
|
240
|
+
name: role.name,
|
|
241
|
+
description: role.description ?? undefined,
|
|
242
|
+
scopes: byRole.get(role.name) ?? [],
|
|
243
|
+
}))
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async addUserToRole(
|
|
247
|
+
userId: string,
|
|
248
|
+
role: string,
|
|
249
|
+
grantedBy?: string
|
|
250
|
+
): Promise<void> {
|
|
251
|
+
await this.db
|
|
252
|
+
.insertInto('pikkuUserRole')
|
|
253
|
+
.values({ userId, role, grantedBy: grantedBy ?? null })
|
|
254
|
+
.onConflict((oc) => oc.columns(['userId', 'role']).doNothing())
|
|
255
|
+
.execute()
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async removeUserFromRole(userId: string, role: string): Promise<void> {
|
|
259
|
+
await this.db
|
|
260
|
+
.deleteFrom('pikkuUserRole')
|
|
261
|
+
.where('userId', '=', userId)
|
|
262
|
+
.where('role', '=', role)
|
|
263
|
+
.execute()
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async listUserRoles(userId: string): Promise<string[]> {
|
|
267
|
+
const rows = await this.db
|
|
268
|
+
.selectFrom('pikkuUserRole')
|
|
269
|
+
.select('role')
|
|
270
|
+
.where('userId', '=', userId)
|
|
271
|
+
.execute()
|
|
272
|
+
|
|
273
|
+
return rows.map((row) => row.role)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
async addScopeToUser(
|
|
277
|
+
userId: string,
|
|
278
|
+
scope: string,
|
|
279
|
+
grantedBy?: string
|
|
280
|
+
): Promise<void> {
|
|
281
|
+
await this.db
|
|
282
|
+
.insertInto('pikkuUserScope')
|
|
283
|
+
.values({ userId, scope, grantedBy: grantedBy ?? null })
|
|
284
|
+
.onConflict((oc) => oc.columns(['userId', 'scope']).doNothing())
|
|
285
|
+
.execute()
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async removeScopeFromUser(userId: string, scope: string): Promise<void> {
|
|
289
|
+
await this.db
|
|
290
|
+
.deleteFrom('pikkuUserScope')
|
|
291
|
+
.where('userId', '=', userId)
|
|
292
|
+
.where('scope', '=', scope)
|
|
293
|
+
.execute()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async listUserScopes(userId: string): Promise<string[]> {
|
|
297
|
+
const rows = await this.db
|
|
298
|
+
.selectFrom('pikkuUserScope')
|
|
299
|
+
.select('scope')
|
|
300
|
+
.where('userId', '=', userId)
|
|
301
|
+
.execute()
|
|
302
|
+
|
|
303
|
+
return rows.map((row) => row.scope)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Scopes marked undeclared by the last sync, with the roles that would lose
|
|
308
|
+
* them. Powers `pikku scopes audit`.
|
|
309
|
+
*/
|
|
310
|
+
async findStaleScopes(): Promise<Array<{ scope: string; roles: string[] }>> {
|
|
311
|
+
const stale = await this.db
|
|
312
|
+
.selectFrom('pikkuScopes')
|
|
313
|
+
.leftJoin('pikkuRoleScopes', 'pikkuRoleScopes.scope', 'pikkuScopes.name')
|
|
314
|
+
.select(['pikkuScopes.name as scope', 'pikkuRoleScopes.role as role'])
|
|
315
|
+
.where('pikkuScopes.declared', '=', false)
|
|
316
|
+
.execute()
|
|
317
|
+
|
|
318
|
+
const byScope = new Map<string, string[]>()
|
|
319
|
+
for (const row of stale) {
|
|
320
|
+
const roles = byScope.get(row.scope) ?? []
|
|
321
|
+
if (row.role) {
|
|
322
|
+
roles.push(row.role)
|
|
323
|
+
}
|
|
324
|
+
byScope.set(row.scope, roles)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return [...byScope.entries()].map(([scope, roles]) => ({ scope, roles }))
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Removes undeclared scopes, cascading them out of every role that holds
|
|
332
|
+
* them. This revokes access, so it is never run implicitly.
|
|
333
|
+
*/
|
|
334
|
+
async pruneScopes(): Promise<string[]> {
|
|
335
|
+
const stale = await this.findStaleScopes()
|
|
336
|
+
if (stale.length === 0) {
|
|
337
|
+
return []
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const names = stale.map((s) => s.scope)
|
|
341
|
+
const deleted = await this.db
|
|
342
|
+
.deleteFrom('pikkuScopes')
|
|
343
|
+
.where('name', 'in', names)
|
|
344
|
+
.where('declared', '=', false)
|
|
345
|
+
.returning('name')
|
|
346
|
+
.execute()
|
|
347
|
+
|
|
348
|
+
return deleted.map((r) => r.name)
|
|
349
|
+
}
|
|
350
|
+
}
|
package/src/kysely-tables.ts
CHANGED
|
@@ -187,7 +187,79 @@ export interface UserSessionsTable {
|
|
|
187
187
|
updatedAt: Generated<Date>
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
/**
|
|
191
|
+
* The declared scope set, synced from generated SCOPES.
|
|
192
|
+
*
|
|
193
|
+
* Additive: a scope removed from code leaves an inert row rather than
|
|
194
|
+
* cascading a grant away mid-deploy. `pikku scopes prune` removes them
|
|
195
|
+
* deliberately.
|
|
196
|
+
*/
|
|
197
|
+
export interface PikkuScopesTable {
|
|
198
|
+
name: string
|
|
199
|
+
description: string | null
|
|
200
|
+
/**
|
|
201
|
+
* False once a scope is no longer declared in code. Marking is
|
|
202
|
+
* non-destructive — grants survive until `pikku scopes prune` removes them
|
|
203
|
+
* deliberately.
|
|
204
|
+
*/
|
|
205
|
+
declared: Generated<boolean>
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** A role: an admin-composed bag of scopes. */
|
|
209
|
+
export interface PikkuRolesTable {
|
|
210
|
+
name: string
|
|
211
|
+
description: string | null
|
|
212
|
+
createdAt: Generated<Date>
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export interface PikkuRoleScopesTable {
|
|
216
|
+
role: string
|
|
217
|
+
scope: string
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface PikkuUserRoleTable {
|
|
221
|
+
userId: string
|
|
222
|
+
role: string
|
|
223
|
+
grantedBy: string | null
|
|
224
|
+
grantedAt: Generated<Date>
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** A scope granted directly to a user, outside of any role. */
|
|
228
|
+
export interface PikkuUserScopeTable {
|
|
229
|
+
userId: string
|
|
230
|
+
scope: string
|
|
231
|
+
grantedBy: string | null
|
|
232
|
+
grantedAt: Generated<Date>
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export interface WebhookDeliveryTable {
|
|
236
|
+
deliveryId: string
|
|
237
|
+
organizationId: string | null
|
|
238
|
+
url: string
|
|
239
|
+
event: string | null
|
|
240
|
+
status: Generated<'pending' | 'delivered' | 'failed'>
|
|
241
|
+
attempts: Generated<number>
|
|
242
|
+
createdAt: Generated<Date>
|
|
243
|
+
updatedAt: Generated<Date>
|
|
244
|
+
deliveredAt: Date | null
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface WebhookDeliveryAttemptTable {
|
|
248
|
+
attemptId: string
|
|
249
|
+
deliveryId: string
|
|
250
|
+
attemptNumber: number
|
|
251
|
+
statusCode: number | null
|
|
252
|
+
responseBody: string | null
|
|
253
|
+
error: string | null
|
|
254
|
+
createdAt: Generated<Date>
|
|
255
|
+
}
|
|
256
|
+
|
|
190
257
|
export interface KyselyPikkuDB {
|
|
258
|
+
pikkuScopes: PikkuScopesTable
|
|
259
|
+
pikkuRoles: PikkuRolesTable
|
|
260
|
+
pikkuRoleScopes: PikkuRoleScopesTable
|
|
261
|
+
pikkuUserRole: PikkuUserRoleTable
|
|
262
|
+
pikkuUserScope: PikkuUserScopeTable
|
|
191
263
|
channels: ChannelsTable
|
|
192
264
|
channelSubscriptions: ChannelSubscriptionsTable
|
|
193
265
|
workflowRuns: WorkflowRunsTable
|
|
@@ -206,4 +278,6 @@ export interface KyselyPikkuDB {
|
|
|
206
278
|
credentials: CredentialsTable
|
|
207
279
|
credentialsAudit: CredentialsAuditTable
|
|
208
280
|
pikkuUserSessions: UserSessionsTable
|
|
281
|
+
webhookDelivery: WebhookDeliveryTable
|
|
282
|
+
webhookDeliveryAttempt: WebhookDeliveryAttemptTable
|
|
209
283
|
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, test, beforeEach } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { CamelCasePlugin, Kysely, SqliteDialect } from 'kysely'
|
|
4
|
+
import Database from 'better-sqlite3'
|
|
5
|
+
import { setSingletonServices } from '@pikku/core'
|
|
6
|
+
|
|
7
|
+
import type { KyselyPikkuDB } from './kysely-tables.js'
|
|
8
|
+
import { SerializePlugin } from './serialize-plugin.js'
|
|
9
|
+
import { KyselyWebhookService } from './kysely-webhook-service.js'
|
|
10
|
+
|
|
11
|
+
const noopLogger = { error() {}, info() {}, warn() {}, debug() {} }
|
|
12
|
+
|
|
13
|
+
function createDb(): Kysely<KyselyPikkuDB> {
|
|
14
|
+
return new Kysely<KyselyPikkuDB>({
|
|
15
|
+
dialect: new SqliteDialect({ database: new Database(':memory:') }),
|
|
16
|
+
plugins: [new CamelCasePlugin(), new SerializePlugin()],
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const createQueue = () => {
|
|
21
|
+
const added: { data: any; options: any }[] = []
|
|
22
|
+
return {
|
|
23
|
+
added,
|
|
24
|
+
queue: {
|
|
25
|
+
supportsResults: false,
|
|
26
|
+
add: async (_name: string, data: any, options: any) => {
|
|
27
|
+
added.push({ data, options })
|
|
28
|
+
return options?.jobId ?? 'job-1'
|
|
29
|
+
},
|
|
30
|
+
getJob: async () => null,
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// send() resolves config/secrets through the singleton services.
|
|
36
|
+
const setServices = () =>
|
|
37
|
+
setSingletonServices({
|
|
38
|
+
logger: noopLogger,
|
|
39
|
+
config: {},
|
|
40
|
+
secrets: {
|
|
41
|
+
getSecret: async (key: string) => {
|
|
42
|
+
throw new Error(`Secret not found: ${key}`)
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
} as any)
|
|
46
|
+
|
|
47
|
+
describe('KyselyWebhookService', () => {
|
|
48
|
+
beforeEach(setServices)
|
|
49
|
+
|
|
50
|
+
test('send() inserts a pending delivery and enqueues with deliveryId === jobId', async () => {
|
|
51
|
+
const db = createDb()
|
|
52
|
+
const { added, queue } = createQueue()
|
|
53
|
+
const service = new KyselyWebhookService(queue as any, db)
|
|
54
|
+
await service.init()
|
|
55
|
+
|
|
56
|
+
const { jobId } = await service.send({
|
|
57
|
+
url: 'https://example.com/hook',
|
|
58
|
+
event: 'user.created',
|
|
59
|
+
data: { id: 'u1' },
|
|
60
|
+
organizationId: 'org-1',
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// jobId is the delivery id and rides in the payload.
|
|
64
|
+
assert.equal(added.length, 1)
|
|
65
|
+
assert.equal(added[0]!.options.jobId, jobId)
|
|
66
|
+
assert.equal(added[0]!.data.deliveryId, jobId)
|
|
67
|
+
|
|
68
|
+
const delivery = await service.getDelivery(jobId)
|
|
69
|
+
assert.ok(delivery)
|
|
70
|
+
assert.equal(delivery!.delivery.status, 'pending')
|
|
71
|
+
assert.equal(delivery!.delivery.organizationId, 'org-1')
|
|
72
|
+
assert.equal(delivery!.delivery.url, 'https://example.com/hook')
|
|
73
|
+
assert.equal(delivery!.attempts.length, 0)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('recordAttempt() appends attempts and rolls status forward', async () => {
|
|
77
|
+
const db = createDb()
|
|
78
|
+
const { queue } = createQueue()
|
|
79
|
+
const service = new KyselyWebhookService(queue as any, db)
|
|
80
|
+
await service.init()
|
|
81
|
+
|
|
82
|
+
const { jobId } = await service.send({
|
|
83
|
+
url: 'https://example.com/hook',
|
|
84
|
+
data: {},
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
await service.recordAttempt(jobId, {
|
|
88
|
+
statusCode: 500,
|
|
89
|
+
error: 'boom',
|
|
90
|
+
delivered: false,
|
|
91
|
+
})
|
|
92
|
+
await service.recordAttempt(jobId, { statusCode: 200, delivered: true })
|
|
93
|
+
|
|
94
|
+
const result = await service.getDelivery(jobId)
|
|
95
|
+
assert.equal(result!.attempts.length, 2)
|
|
96
|
+
assert.equal(result!.attempts[0]!.attemptNumber, 1)
|
|
97
|
+
assert.equal(result!.attempts[0]!.statusCode, 500)
|
|
98
|
+
assert.equal(result!.attempts[1]!.attemptNumber, 2)
|
|
99
|
+
assert.equal(result!.delivery.status, 'delivered')
|
|
100
|
+
assert.equal(result!.delivery.attempts, 2)
|
|
101
|
+
assert.ok(result!.delivery.deliveredAt)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
test('listDeliveries() filters by organization', async () => {
|
|
105
|
+
const db = createDb()
|
|
106
|
+
const { queue } = createQueue()
|
|
107
|
+
const service = new KyselyWebhookService(queue as any, db)
|
|
108
|
+
await service.init()
|
|
109
|
+
|
|
110
|
+
await service.send({ url: 'https://a.com', data: {}, organizationId: 'o1' })
|
|
111
|
+
await service.send({ url: 'https://b.com', data: {}, organizationId: 'o2' })
|
|
112
|
+
|
|
113
|
+
const o1 = await service.listDeliveries({ organizationId: 'o1' })
|
|
114
|
+
assert.equal(o1.length, 1)
|
|
115
|
+
assert.equal(o1[0]!.url, 'https://a.com')
|
|
116
|
+
|
|
117
|
+
const all = await service.listDeliveries()
|
|
118
|
+
assert.equal(all.length, 2)
|
|
119
|
+
})
|
|
120
|
+
})
|