@pikku/kysely 0.13.6 → 0.13.10

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 (51) hide show
  1. package/CHANGELOG.md +505 -0
  2. package/dist/src/index.d.ts +4 -1
  3. package/dist/src/index.js +3 -1
  4. package/dist/src/kysely-audit-service.d.ts +34 -2
  5. package/dist/src/kysely-audit-service.js +163 -3
  6. package/dist/src/kysely-credential-service.d.ts +3 -0
  7. package/dist/src/kysely-credential-service.js +50 -11
  8. package/dist/src/kysely-scope-service.d.ts +38 -0
  9. package/dist/src/kysely-scope-service.js +125 -2
  10. package/dist/src/kysely-secret-service.d.ts +7 -3
  11. package/dist/src/kysely-secret-service.js +55 -13
  12. package/dist/src/kysely-tables.d.ts +49 -2
  13. package/dist/src/kysely-virtual-user-run-store.d.ts +43 -0
  14. package/dist/src/kysely-virtual-user-run-store.js +131 -0
  15. package/dist/src/kysely-workflow-service.d.ts +0 -1
  16. package/dist/src/kysely-workflow-service.js +0 -12
  17. package/dist/src/schema/audit.schema.d.ts +16 -0
  18. package/dist/src/schema/audit.schema.js +47 -0
  19. package/dist/src/schema/credential.schema.d.ts +3 -2
  20. package/dist/src/schema/credential.schema.js +9 -3
  21. package/dist/src/schema/index.d.ts +17 -4
  22. package/dist/src/schema/index.js +40 -6
  23. package/dist/src/schema/pikku-schema.types.d.ts +41 -5
  24. package/dist/src/schema/pikku-schema.types.js +27 -12
  25. package/dist/src/schema/scope.schema.js +12 -0
  26. package/dist/src/schema/secret.schema.js +5 -0
  27. package/dist/src/schema/virtual-user.schema.d.ts +19 -0
  28. package/dist/src/schema/virtual-user.schema.js +54 -0
  29. package/dist/tsconfig.tsbuildinfo +1 -1
  30. package/package.json +2 -2
  31. package/src/create-audited-kysely.test.ts +10 -10
  32. package/src/index.ts +7 -0
  33. package/src/kysely-audit-service.test.ts +360 -0
  34. package/src/kysely-audit-service.ts +181 -4
  35. package/src/kysely-credential-service.ts +57 -12
  36. package/src/kysely-scope-service.ts +151 -2
  37. package/src/kysely-secret-service.ts +70 -21
  38. package/src/kysely-services.test.ts +123 -1
  39. package/src/kysely-tables.test.ts +12 -1
  40. package/src/kysely-tables.ts +53 -2
  41. package/src/kysely-virtual-user-run-store.test.ts +212 -0
  42. package/src/kysely-virtual-user-run-store.ts +149 -0
  43. package/src/kysely-workflow-service.ts +0 -17
  44. package/src/schema/audit.schema.ts +55 -0
  45. package/src/schema/credential.schema.ts +14 -3
  46. package/src/schema/index.ts +59 -10
  47. package/src/schema/pikku-schema.types.ts +65 -10
  48. package/src/schema/schema.test.ts +82 -4
  49. package/src/schema/scope.schema.ts +14 -0
  50. package/src/schema/secret.schema.ts +9 -0
  51. package/src/schema/virtual-user.schema.ts +65 -0
@@ -0,0 +1,360 @@
1
+ import { describe, test, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import Database from 'better-sqlite3'
4
+ import { CamelCasePlugin, Kysely, SqliteDialect, sql } from 'kysely'
5
+ import type { AuditEvent } from '../../../core/src/index.js'
6
+
7
+ import { KyselyAuditService } from './kysely-audit-service.js'
8
+
9
+ /**
10
+ * The `audit` table as the docs specify it — all TEXT on every engine, so the
11
+ * service is exercised against the same shape a project would migrate to.
12
+ */
13
+ const CREATE_AUDIT_TABLE = `
14
+ CREATE TABLE audit (
15
+ audit_id TEXT NOT NULL PRIMARY KEY,
16
+ occurred_at TEXT NOT NULL,
17
+ type TEXT NOT NULL,
18
+ source TEXT NOT NULL DEFAULT 'auto',
19
+ outcome TEXT,
20
+ function_id TEXT,
21
+ wire_type TEXT,
22
+ trace_id TEXT,
23
+ transaction_id TEXT,
24
+ query_id TEXT,
25
+ user_id TEXT,
26
+ org_id TEXT,
27
+ pikku_user_id TEXT,
28
+ tables TEXT,
29
+ changed_cols TEXT,
30
+ event TEXT,
31
+ old TEXT,
32
+ data TEXT
33
+ )
34
+ `
35
+
36
+ let db: Kysely<any>
37
+ let service: KyselyAuditService
38
+
39
+ const event = (
40
+ overrides: Partial<AuditEvent> & Pick<AuditEvent, 'type' | 'occurredAt'>
41
+ ): AuditEvent => ({
42
+ source: 'explicit',
43
+ ...overrides,
44
+ })
45
+
46
+ beforeEach(async () => {
47
+ db = new Kysely<any>({
48
+ dialect: new SqliteDialect({ database: new Database(':memory:') }),
49
+ })
50
+ await sql.raw(CREATE_AUDIT_TABLE).execute(db)
51
+ service = new KyselyAuditService(db)
52
+ })
53
+
54
+ const seed = () =>
55
+ service.write([
56
+ event({
57
+ eventId: 'e1',
58
+ type: 'invoice.update',
59
+ occurredAt: '2026-01-01T00:00:00.000Z',
60
+ userIdentity: { userId: 'alice', orgId: 'org-a' },
61
+ metadata: { entity: 'invoice', entityId: 'inv-1' },
62
+ }),
63
+ event({
64
+ eventId: 'e2',
65
+ type: 'invoice.delete',
66
+ occurredAt: '2026-01-02T00:00:00.000Z',
67
+ userIdentity: { userId: 'bob', orgId: 'org-a' },
68
+ }),
69
+ event({
70
+ eventId: 'e3',
71
+ type: 'invoice.update',
72
+ occurredAt: '2026-01-03T00:00:00.000Z',
73
+ userIdentity: { userId: 'alice', orgId: 'org-b' },
74
+ }),
75
+ ])
76
+
77
+ describe('KyselyAuditService.query', () => {
78
+ test('returns the trail newest first', async () => {
79
+ await seed()
80
+ const { events, nextCursor } = await service.query()
81
+ assert.deepEqual(
82
+ events.map((e) => e.eventId),
83
+ ['e3', 'e2', 'e1']
84
+ )
85
+ assert.equal(nextCursor, null, 'a complete page has no next cursor')
86
+ })
87
+
88
+ test('round-trips the event, including parsed metadata', async () => {
89
+ await seed()
90
+ const { events } = await service.query({ types: ['invoice.update'] })
91
+ const first = events.find((e) => e.eventId === 'e1')!
92
+ assert.equal(first.type, 'invoice.update')
93
+ assert.equal(first.source, 'explicit')
94
+ assert.deepEqual(first.userIdentity, {
95
+ userId: 'alice',
96
+ orgId: 'org-a',
97
+ pikkuUserId: undefined,
98
+ })
99
+ assert.deepEqual(first.metadata, { entity: 'invoice', entityId: 'inv-1' })
100
+ })
101
+
102
+ test('keeps the wire identity of a caller who never signed in', async () => {
103
+ await service.write([
104
+ event({
105
+ eventId: 'e4',
106
+ type: 'invoice.view',
107
+ occurredAt: '2026-01-04T00:00:00.000Z',
108
+ userIdentity: { pikkuUserId: 'pk_9' },
109
+ }),
110
+ ])
111
+ const { events } = await service.query({ types: ['invoice.view'] })
112
+ assert.deepEqual(events[0]!.userIdentity, {
113
+ userId: undefined,
114
+ orgId: undefined,
115
+ pikkuUserId: 'pk_9',
116
+ })
117
+ })
118
+
119
+ test('filters by user', async () => {
120
+ await seed()
121
+ const { events } = await service.query({ userIds: ['alice'] })
122
+ assert.deepEqual(
123
+ events.map((e) => e.eventId),
124
+ ['e3', 'e1']
125
+ )
126
+ })
127
+
128
+ test('filters by type', async () => {
129
+ await seed()
130
+ const { events } = await service.query({ types: ['invoice.delete'] })
131
+ assert.deepEqual(
132
+ events.map((e) => e.eventId),
133
+ ['e2']
134
+ )
135
+ })
136
+
137
+ test('combines filters as a conjunction', async () => {
138
+ await seed()
139
+ const { events } = await service.query({
140
+ userIds: ['alice'],
141
+ types: ['invoice.update'],
142
+ orgId: 'org-b',
143
+ })
144
+ assert.deepEqual(
145
+ events.map((e) => e.eventId),
146
+ ['e3']
147
+ )
148
+ })
149
+
150
+ test('an empty filter array matches nothing rather than everything', async () => {
151
+ await seed()
152
+ const byUser = await service.query({ userIds: [] })
153
+ assert.deepEqual(byUser.events, [])
154
+ const byType = await service.query({ types: [] })
155
+ assert.deepEqual(byType.events, [])
156
+ })
157
+
158
+ test('bounds by occurredAt, `from` inclusive and `to` exclusive', async () => {
159
+ await seed()
160
+ const { events } = await service.query({
161
+ from: '2026-01-02T00:00:00.000Z',
162
+ to: '2026-01-03T00:00:00.000Z',
163
+ })
164
+ assert.deepEqual(
165
+ events.map((e) => e.eventId),
166
+ ['e2']
167
+ )
168
+ })
169
+
170
+ test('pages, reporting the next offset only while more remain', async () => {
171
+ await seed()
172
+ const first = await service.query({ limit: 2 })
173
+ assert.deepEqual(
174
+ first.events.map((e) => e.eventId),
175
+ ['e3', 'e2']
176
+ )
177
+ assert.equal(first.nextCursor, 2)
178
+
179
+ const second = await service.query({ limit: 2, offset: first.nextCursor! })
180
+ assert.deepEqual(
181
+ second.events.map((e) => e.eventId),
182
+ ['e1']
183
+ )
184
+ assert.equal(second.nextCursor, null)
185
+ })
186
+
187
+ // Without the audit_id tiebreak the engine may order a same-timestamp batch
188
+ // differently per query, which drops or repeats rows across a page boundary.
189
+ test('orders totally when timestamps tie', async () => {
190
+ const sameTime = '2026-02-01T00:00:00.000Z'
191
+ await service.write(
192
+ ['a', 'b', 'c', 'd'].map((id) =>
193
+ event({ eventId: id, type: 't', occurredAt: sameTime })
194
+ )
195
+ )
196
+ const first = await service.query({ limit: 2 })
197
+ const second = await service.query({ limit: 2, offset: 2 })
198
+ const seen = [...first.events, ...second.events].map((e) => e.eventId)
199
+ assert.deepEqual(seen, ['d', 'c', 'b', 'a'])
200
+ assert.equal(new Set(seen).size, 4, 'no row may appear on two pages')
201
+ })
202
+
203
+ test('caps an oversized limit', async () => {
204
+ await service.write(
205
+ Array.from({ length: 5 }, (_, i) =>
206
+ event({
207
+ eventId: `e${i}`,
208
+ type: 't',
209
+ occurredAt: `2026-03-0${i + 1}T00:00:00.000Z`,
210
+ })
211
+ )
212
+ )
213
+ const { events } = await service.query({ limit: 10_000 })
214
+ assert.equal(events.length, 5, 'the cap must not lose rows below it')
215
+ })
216
+
217
+ test('a malformed data column surfaces rather than dropping the event', async () => {
218
+ await sql
219
+ .raw(
220
+ `INSERT INTO audit (audit_id, occurred_at, type, source, data)
221
+ VALUES ('bad', '2026-04-01T00:00:00.000Z', 'legacy.write', 'auto', 'not json')`
222
+ )
223
+ .execute(db)
224
+ const { events } = await service.query()
225
+ assert.equal(events.length, 1)
226
+ assert.equal(events[0]!.metadata as unknown, 'not json')
227
+ })
228
+ })
229
+
230
+ /**
231
+ * The two connections a project actually hands this service. `CamelCasePlugin`
232
+ * is on almost every pikku Kysely instance, and it renames result columns on
233
+ * the way out — `audit_id` arrives as `auditId` — so a service that only reads
234
+ * the physical names returns a page of undefined-everything against the setup
235
+ * most projects have.
236
+ */
237
+ describe('KyselyAuditService against a CamelCasePlugin connection', () => {
238
+ let camelDb: Kysely<any>
239
+ let camelService: KyselyAuditService
240
+
241
+ beforeEach(async () => {
242
+ camelDb = new Kysely<any>({
243
+ dialect: new SqliteDialect({ database: new Database(':memory:') }),
244
+ plugins: [new CamelCasePlugin()],
245
+ })
246
+ await sql.raw(CREATE_AUDIT_TABLE).execute(camelDb)
247
+ camelService = new KyselyAuditService(camelDb)
248
+ })
249
+
250
+ test('reads back everything it wrote', async () => {
251
+ await camelService.write([
252
+ event({
253
+ eventId: 'e1',
254
+ type: 'invoice.update',
255
+ occurredAt: '2026-01-01T00:00:00.000Z',
256
+ outcome: 'success',
257
+ functionId: 'cancelInvoice',
258
+ wireType: 'http',
259
+ traceId: 'trace-1',
260
+ userIdentity: { userId: 'alice', orgId: 'org-a' },
261
+ metadata: { entity: 'invoice' },
262
+ }),
263
+ ])
264
+ const { events } = await camelService.query()
265
+ assert.equal(events.length, 1)
266
+ assert.deepEqual(events[0], {
267
+ eventId: 'e1',
268
+ occurredAt: '2026-01-01T00:00:00.000Z',
269
+ type: 'invoice.update',
270
+ source: 'explicit',
271
+ outcome: 'success',
272
+ functionId: 'cancelInvoice',
273
+ wireType: 'http',
274
+ traceId: 'trace-1',
275
+ transactionId: undefined,
276
+ queryId: undefined,
277
+ userIdentity: {
278
+ userId: 'alice',
279
+ orgId: 'org-a',
280
+ pikkuUserId: undefined,
281
+ },
282
+ metadata: { entity: 'invoice' },
283
+ })
284
+ })
285
+
286
+ test('still filters and facets by user', async () => {
287
+ await camelService.write([
288
+ event({
289
+ eventId: 'e1',
290
+ type: 'a',
291
+ occurredAt: '2026-01-01T00:00:00.000Z',
292
+ userIdentity: { userId: 'alice' },
293
+ }),
294
+ event({
295
+ eventId: 'e2',
296
+ type: 'b',
297
+ occurredAt: '2026-01-02T00:00:00.000Z',
298
+ userIdentity: { userId: 'bob' },
299
+ }),
300
+ ])
301
+ const { events } = await camelService.query({ userIds: ['bob'] })
302
+ assert.deepEqual(
303
+ events.map((e) => e.eventId),
304
+ ['e2']
305
+ )
306
+ const facets = await camelService.facets()
307
+ assert.deepEqual(facets.userIds, ['alice', 'bob'])
308
+ })
309
+ })
310
+
311
+ describe('KyselyAuditService.init', () => {
312
+ test('creates the audit table on a database that has none', async () => {
313
+ const fresh = new Kysely<any>({
314
+ dialect: new SqliteDialect({ database: new Database(':memory:') }),
315
+ plugins: [new CamelCasePlugin()],
316
+ })
317
+ const freshService = new KyselyAuditService(fresh)
318
+ await freshService.init()
319
+ await freshService.write([
320
+ event({ eventId: 'e1', type: 't', occurredAt: '2026-01-01' }),
321
+ ])
322
+ const { events } = await freshService.query()
323
+ assert.deepEqual(
324
+ events.map((e) => e.eventId),
325
+ ['e1']
326
+ )
327
+ })
328
+
329
+ // Two boots against one database, and the second must not wipe the first.
330
+ test('is idempotent and keeps what is already there', async () => {
331
+ await service.write([
332
+ event({ eventId: 'e1', type: 't', occurredAt: '2026-01-01' }),
333
+ ])
334
+ await service.init()
335
+ await service.init()
336
+ const { events } = await service.query()
337
+ assert.deepEqual(
338
+ events.map((e) => e.eventId),
339
+ ['e1']
340
+ )
341
+ })
342
+ })
343
+
344
+ describe('KyselyAuditService.facets', () => {
345
+ test('lists the distinct users and types across the whole trail', async () => {
346
+ await seed()
347
+ const facets = await service.facets()
348
+ assert.deepEqual(facets.userIds, ['alice', 'bob'])
349
+ assert.deepEqual(facets.types, ['invoice.delete', 'invoice.update'])
350
+ })
351
+
352
+ test('omits userless events from the user list', async () => {
353
+ await service.write([
354
+ event({ eventId: 'cron', type: 'cleanup.ran', occurredAt: '2026-05-01' }),
355
+ ])
356
+ const facets = await service.facets()
357
+ assert.deepEqual(facets.userIds, [])
358
+ assert.deepEqual(facets.types, ['cleanup.ran'])
359
+ })
360
+ })
@@ -1,9 +1,78 @@
1
- import type { AuditEvent, AuditEventBatch, AuditService } from '@pikku/core'
1
+ import type {
2
+ AuditEvent,
3
+ AuditEventBatch,
4
+ AuditFacets,
5
+ AuditQuery,
6
+ AuditQueryResult,
7
+ AuditService,
8
+ } from '@pikku/core'
2
9
  import type { Kysely } from 'kysely'
10
+ import { ensurePikkuSchema } from './schema/index.js'
11
+ import { auditSchema } from './schema/audit.schema.js'
3
12
 
4
13
  const jsonOrNull = (v: unknown): string | null =>
5
14
  v != null ? JSON.stringify(v) : null
6
15
 
16
+ const parseJson = (v: unknown): any => {
17
+ if (typeof v !== 'string') return v ?? undefined
18
+ try {
19
+ return JSON.parse(v)
20
+ } catch {
21
+ // A row written by something other than this service, or truncated in
22
+ // transit. Surfacing the raw text beats dropping the event: the row is
23
+ // still evidence that something happened, and swallowing it would make the
24
+ // trail quietly incomplete.
25
+ return v
26
+ }
27
+ }
28
+
29
+ /**
30
+ * A column by its physical name, whatever the connection renamed it to.
31
+ *
32
+ * `CamelCasePlugin` is on most pikku Kysely instances and rewrites result keys
33
+ * on the way out, so the same row arrives keyed `audit_id` or `auditId`
34
+ * depending on a plugin this service is never told about. Reading both is
35
+ * cheaper than demanding a particular connection — and quieter to get wrong,
36
+ * since the mismatch does not throw, it just returns a page of undefined.
37
+ */
38
+ const column = (row: Record<string, any>, name: string): any => {
39
+ const value = row[name]
40
+ if (value !== undefined) return value
41
+ return row[name.replace(/_(\w)/g, (_, char: string) => char.toUpperCase())]
42
+ }
43
+
44
+ /** Rows are TEXT on every engine, so the shape is whatever the driver returned. */
45
+ const rowToEvent = (row: Record<string, any>): AuditEvent => {
46
+ const userId = column(row, 'user_id')
47
+ const orgId = column(row, 'org_id')
48
+ const pikkuUserId = column(row, 'pikku_user_id')
49
+ return {
50
+ eventId: column(row, 'audit_id') ?? undefined,
51
+ occurredAt: column(row, 'occurred_at'),
52
+ type: row.type,
53
+ source: row.source ?? 'auto',
54
+ outcome: row.outcome ?? undefined,
55
+ functionId: column(row, 'function_id') ?? undefined,
56
+ wireType: column(row, 'wire_type') ?? undefined,
57
+ traceId: column(row, 'trace_id') ?? undefined,
58
+ transactionId: column(row, 'transaction_id') ?? undefined,
59
+ queryId: column(row, 'query_id') ?? undefined,
60
+ userIdentity:
61
+ userId || orgId || pikkuUserId
62
+ ? {
63
+ userId: userId ?? undefined,
64
+ orgId: orgId ?? undefined,
65
+ pikkuUserId: pikkuUserId ?? undefined,
66
+ }
67
+ : undefined,
68
+ metadata: parseJson(row.data),
69
+ }
70
+ }
71
+
72
+ /** The most a single page may return, whatever the caller asked for. */
73
+ const MAX_LIMIT = 200
74
+ const DEFAULT_LIMIT = 50
75
+
7
76
  // No global `crypto` is guaranteed across every runtime, and audit_id is the
8
77
  // PK — any collision is dropped by ON CONFLICT DO NOTHING.
9
78
  const fallbackId = (): string =>
@@ -15,14 +84,30 @@ const fallbackId = (): string =>
15
84
  *
16
85
  * The column mapping matches a platform audit-queue consumer, so a locally-run
17
86
  * project and a deployed stage write identical rows (the read side relies only
18
- * on `type`, `occurred_at`, `actor_user_id`, and the `data` JSON that holds
87
+ * on `type`, `occurred_at`, `user_id`, and the `data` JSON that holds
19
88
  * `metadata`). The `audit` table is an optional per-project migration (not in
20
89
  * the generated schema), so rows are inserted untyped; all columns are TEXT on
21
90
  * every engine and ON CONFLICT DO NOTHING keeps writes idempotent on retries.
22
91
  */
23
92
  export class KyselyAuditService implements AuditService {
93
+ private initialized = false
94
+
24
95
  constructor(private db: Kysely<any>) {}
25
96
 
97
+ /**
98
+ * Creates the `audit` table if the database has none.
99
+ *
100
+ * Optional — a project that migrates the table itself can skip it, and the
101
+ * declaration is written to match the documented shape either way. Calling it
102
+ * on a database that already has the table is a no-op, so two instances
103
+ * booting cold do not race each other into a half-applied schema.
104
+ */
105
+ public async init(): Promise<void> {
106
+ if (this.initialized) return
107
+ await ensurePikkuSchema(this.db, auditSchema)
108
+ this.initialized = true
109
+ }
110
+
26
111
  async audit(event: AuditEvent): Promise<void> {
27
112
  await this.write([event])
28
113
  }
@@ -44,8 +129,9 @@ export class KyselyAuditService implements AuditService {
44
129
  trace_id: e.traceId ?? null,
45
130
  transaction_id: e.transactionId ?? null,
46
131
  query_id: e.queryId ?? null,
47
- actor_user_id: e.actor?.userId ?? null,
48
- actor_org_id: e.actor?.orgId ?? null,
132
+ user_id: e.userIdentity?.userId ?? null,
133
+ org_id: e.userIdentity?.orgId ?? null,
134
+ pikku_user_id: e.userIdentity?.pikkuUserId ?? null,
49
135
  tables: jsonOrNull(metadata?.tables),
50
136
  changed_cols: jsonOrNull(metadata?.changedColumns),
51
137
  event: e.eventId ?? null,
@@ -59,4 +145,95 @@ export class KyselyAuditService implements AuditService {
59
145
  .onConflict((oc: any) => oc.doNothing())
60
146
  .execute()
61
147
  }
148
+
149
+ /**
150
+ * A page of the trail, newest first.
151
+ *
152
+ * Offset paging, matching the rest of the console. The trail is append-only
153
+ * and ordered by `occurred_at DESC`, so events written *while* a reader is
154
+ * paging shift the window and can repeat a row across pages. That is visible
155
+ * but harmless for browsing; anything that must not miss a row should page by
156
+ * a bounded `from`/`to` window instead of scrolling.
157
+ *
158
+ * Ties on `occurred_at` are broken by `audit_id` so the order is total —
159
+ * without it a batch flushed in one call shares a timestamp and the engine is
160
+ * free to order it differently per query, which drops or repeats rows at a
161
+ * page boundary.
162
+ */
163
+ async query(query: AuditQuery = {}): Promise<AuditQueryResult> {
164
+ const limit = Math.min(Math.max(query.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT)
165
+ const offset = Math.max(query.offset ?? 0, 0)
166
+
167
+ let builder = (this.db as any).selectFrom('audit').selectAll()
168
+ builder = this.applyFilters(builder, query)
169
+
170
+ const rows = await builder
171
+ .orderBy('occurred_at', 'desc')
172
+ .orderBy('audit_id', 'desc')
173
+ // One extra row is the cheapest honest way to know whether another page
174
+ // exists — a COUNT would be a second scan of the same filter.
175
+ .limit(limit + 1)
176
+ .offset(offset)
177
+ .execute()
178
+
179
+ const hasMore = rows.length > limit
180
+ return {
181
+ events: rows.slice(0, limit).map(rowToEvent),
182
+ nextCursor: hasMore ? offset + limit : null,
183
+ }
184
+ }
185
+
186
+ async facets(): Promise<AuditFacets> {
187
+ const [users, types] = await Promise.all([
188
+ (this.db as any)
189
+ .selectFrom('audit')
190
+ .select('user_id')
191
+ .distinct()
192
+ .where('user_id', 'is not', null)
193
+ .orderBy('user_id', 'asc')
194
+ .execute(),
195
+ (this.db as any)
196
+ .selectFrom('audit')
197
+ .select('type')
198
+ .distinct()
199
+ .orderBy('type', 'asc')
200
+ .execute(),
201
+ ])
202
+ return {
203
+ userIds: users.map((r: any) => column(r, 'user_id')),
204
+ types: types.map((r: any) => r.type).filter(Boolean),
205
+ }
206
+ }
207
+
208
+ /**
209
+ * An empty array means "match nothing" and is applied as such. Treating it as
210
+ * "no filter" would turn a reader's deselect-everything into a full-trail
211
+ * read, which is the opposite of what they asked for.
212
+ */
213
+ private applyFilters(builder: any, query: AuditQuery): any {
214
+ // `in ()` is a syntax error on Postgres and SQLite alike, so an empty
215
+ // selection is expressed as a predicate that cannot hold.
216
+ const matchNothing = (b: any) => b.where('audit_id', 'is', null)
217
+
218
+ if (query.userIds) {
219
+ builder = query.userIds.length
220
+ ? builder.where('user_id', 'in', query.userIds)
221
+ : matchNothing(builder)
222
+ }
223
+ if (query.types) {
224
+ builder = query.types.length
225
+ ? builder.where('type', 'in', query.types)
226
+ : matchNothing(builder)
227
+ }
228
+ if (query.orgId) {
229
+ builder = builder.where('org_id', '=', query.orgId)
230
+ }
231
+ if (query.from) {
232
+ builder = builder.where('occurred_at', '>=', query.from)
233
+ }
234
+ if (query.to) {
235
+ builder = builder.where('occurred_at', '<', query.to)
236
+ }
237
+ return builder
238
+ }
62
239
  }
@@ -2,6 +2,8 @@ import type { CredentialService } from '@pikku/core/services'
2
2
  import type { Kysely } from 'kysely'
3
3
  import type { KyselyPikkuDB } from './kysely-tables.js'
4
4
  import {
5
+ deriveKEK,
6
+ generateKEKSalt,
5
7
  envelopeEncrypt,
6
8
  envelopeDecrypt,
7
9
  envelopeRewrap,
@@ -19,6 +21,8 @@ export interface KyselyCredentialServiceConfig {
19
21
 
20
22
  export class KyselyCredentialService implements CredentialService {
21
23
  private initialized = false
24
+ private kekSalts = new Map<number, string>()
25
+ private keks = new Map<number, CryptoKey>()
22
26
  private key: string
23
27
  private keyVersion: number
24
28
  private previousKey?: string
@@ -62,10 +66,52 @@ export class KyselyCredentialService implements CredentialService {
62
66
  .execute()
63
67
  }
64
68
 
65
- private getKEK(version: number): string {
66
- if (version === this.keyVersion) return this.key
67
- if (this.previousKey) return this.previousKey
68
- throw new Error(`No KEK available for key_version ${version}`)
69
+ private async getKEKSalt(version: number): Promise<string> {
70
+ const cached = this.kekSalts.get(version)
71
+ if (cached) return cached
72
+
73
+ const existing = await this.db
74
+ .selectFrom('credentialKekSalts')
75
+ .select('salt')
76
+ .where('keyVersion', '=', version)
77
+ .executeTakeFirst()
78
+
79
+ let salt = existing?.salt
80
+ if (!salt) {
81
+ await this.db
82
+ .insertInto('credentialKekSalts')
83
+ .values({
84
+ keyVersion: version,
85
+ salt: generateKEKSalt(),
86
+ createdAt: new Date().toISOString() as unknown as Date,
87
+ })
88
+ .onConflict((oc) => oc.column('keyVersion').doNothing())
89
+ .execute()
90
+
91
+ const row = await this.db
92
+ .selectFrom('credentialKekSalts')
93
+ .select('salt')
94
+ .where('keyVersion', '=', version)
95
+ .executeTakeFirstOrThrow()
96
+ salt = row.salt
97
+ }
98
+
99
+ this.kekSalts.set(version, salt)
100
+ return salt
101
+ }
102
+
103
+ private async getKEK(version: number): Promise<CryptoKey> {
104
+ const cached = this.keks.get(version)
105
+ if (cached) return cached
106
+
107
+ const passphrase = version === this.keyVersion ? this.key : this.previousKey
108
+ if (!passphrase) {
109
+ throw new Error(`No KEK available for key_version ${version}`)
110
+ }
111
+
112
+ const kek = await deriveKEK(passphrase, await this.getKEKSalt(version))
113
+ this.keks.set(version, kek)
114
+ return kek
69
115
  }
70
116
 
71
117
  private whereUserId(qb: any, userId?: string) {
@@ -84,7 +130,7 @@ export class KyselyCredentialService implements CredentialService {
84
130
  const row = await qb.executeTakeFirst()
85
131
  if (!row) return null
86
132
 
87
- const kek = this.getKEK(row.keyVersion)
133
+ const kek = await this.getKEK(row.keyVersion)
88
134
  const plaintext = await envelopeDecrypt<string>(
89
135
  kek,
90
136
  row.ciphertext,
@@ -102,7 +148,7 @@ export class KyselyCredentialService implements CredentialService {
102
148
  async set(name: string, value: unknown, userId?: string): Promise<void> {
103
149
  const plaintext = JSON.stringify(value)
104
150
  const { ciphertext, wrappedDEK } = await envelopeEncrypt(
105
- this.key,
151
+ await this.getKEK(this.keyVersion),
106
152
  plaintext
107
153
  )
108
154
  const now = new Date().toISOString()
@@ -166,7 +212,7 @@ export class KyselyCredentialService implements CredentialService {
166
212
 
167
213
  const result: Record<string, unknown> = {}
168
214
  for (const row of rows) {
169
- const kek = this.getKEK(row.keyVersion)
215
+ const kek = await this.getKEK(row.keyVersion)
170
216
  const plaintext = await envelopeDecrypt<string>(
171
217
  kek,
172
218
  row.ciphertext,
@@ -216,12 +262,11 @@ export class KyselyCredentialService implements CredentialService {
216
262
  .where('keyVersion', '<', this.keyVersion)
217
263
  .execute()
218
264
 
265
+ const oldKEK = await this.getKEK(this.keyVersion - 1)
266
+ const newKEK = await this.getKEK(this.keyVersion)
267
+
219
268
  for (const row of rows) {
220
- const newWrappedDEK = await envelopeRewrap(
221
- this.previousKey,
222
- this.key,
223
- row.wrappedDek
224
- )
269
+ const newWrappedDEK = await envelopeRewrap(oldKEK, newKEK, row.wrappedDek)
225
270
  let qb = this.db
226
271
  .updateTable('credentials')
227
272
  .set({