@pikku/kysely 0.13.7 → 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 (38) hide show
  1. package/CHANGELOG.md +180 -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-secret-service.d.ts +4 -3
  7. package/dist/src/kysely-secret-service.js +6 -3
  8. package/dist/src/kysely-tables.d.ts +31 -0
  9. package/dist/src/kysely-virtual-user-run-store.d.ts +43 -0
  10. package/dist/src/kysely-virtual-user-run-store.js +131 -0
  11. package/dist/src/schema/audit.schema.d.ts +16 -0
  12. package/dist/src/schema/audit.schema.js +47 -0
  13. package/dist/src/schema/credential.schema.d.ts +3 -2
  14. package/dist/src/schema/credential.schema.js +4 -3
  15. package/dist/src/schema/index.d.ts +17 -4
  16. package/dist/src/schema/index.js +40 -6
  17. package/dist/src/schema/pikku-schema.types.d.ts +41 -5
  18. package/dist/src/schema/pikku-schema.types.js +27 -12
  19. package/dist/src/schema/virtual-user.schema.d.ts +19 -0
  20. package/dist/src/schema/virtual-user.schema.js +54 -0
  21. package/dist/tsconfig.tsbuildinfo +1 -1
  22. package/package.json +2 -2
  23. package/src/create-audited-kysely.test.ts +10 -10
  24. package/src/index.ts +7 -0
  25. package/src/kysely-audit-service.test.ts +360 -0
  26. package/src/kysely-audit-service.ts +181 -4
  27. package/src/kysely-secret-service.ts +11 -10
  28. package/src/kysely-services.test.ts +3 -3
  29. package/src/kysely-tables.test.ts +12 -1
  30. package/src/kysely-tables.ts +32 -0
  31. package/src/kysely-virtual-user-run-store.test.ts +212 -0
  32. package/src/kysely-virtual-user-run-store.ts +149 -0
  33. package/src/schema/audit.schema.ts +55 -0
  34. package/src/schema/credential.schema.ts +5 -3
  35. package/src/schema/index.ts +59 -10
  36. package/src/schema/pikku-schema.types.ts +65 -10
  37. package/src/schema/schema.test.ts +82 -4
  38. 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
  }
@@ -1,4 +1,5 @@
1
- import type { SecretService } from '@pikku/core/services'
1
+ import { createSecretValue, isSecretValue, type SecretValue } from '@pikku/core'
2
+ import type { SecretService, SecretValues } from '@pikku/core/services'
2
3
  import type { Kysely } from 'kysely'
3
4
  import type { KyselyPikkuDB } from './kysely-tables.js'
4
5
  import {
@@ -112,7 +113,7 @@ export class KyselySecretService implements SecretService {
112
113
  return kek
113
114
  }
114
115
 
115
- async getSecret<T = string>(key: string): Promise<T> {
116
+ async getSecret<T = string>(key: string): Promise<SecretValue<T>> {
116
117
  const row = await this.db
117
118
  .selectFrom('secrets')
118
119
  .select(['ciphertext', 'wrappedDek', 'keyVersion'])
@@ -124,7 +125,7 @@ export class KyselySecretService implements SecretService {
124
125
  const kek = await this.getKEK(row.keyVersion)
125
126
  const result = await envelopeDecrypt<T>(kek, row.ciphertext, row.wrappedDek)
126
127
  await this.logAudit(key, 'read')
127
- return result
128
+ return createSecretValue(result)
128
129
  }
129
130
 
130
131
  async hasSecret(key: string): Promise<boolean> {
@@ -137,9 +138,11 @@ export class KyselySecretService implements SecretService {
137
138
  }
138
139
 
139
140
  async setSecret(key: string, value: unknown): Promise<void> {
141
+ // Encrypting the wrapper would store its redaction, not the secret —
142
+ // writing a secret back to the vault is exactly what this method is for.
140
143
  const { ciphertext, wrappedDEK } = await envelopeEncrypt(
141
144
  await this.getKEK(this.keyVersion),
142
- value
145
+ isSecretValue(value) ? value.reveal() : value
143
146
  )
144
147
  const now = new Date().toISOString()
145
148
 
@@ -173,7 +176,7 @@ export class KyselySecretService implements SecretService {
173
176
 
174
177
  async getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(
175
178
  keys: (keyof T & string)[]
176
- ): Promise<T> {
179
+ ): Promise<Partial<SecretValues<T>>> {
177
180
  const rows = await this.db
178
181
  .selectFrom('secrets')
179
182
  .select(['key', 'ciphertext', 'wrappedDek', 'keyVersion'])
@@ -184,10 +187,8 @@ export class KyselySecretService implements SecretService {
184
187
  for (const row of rows) {
185
188
  try {
186
189
  const kek = await this.getKEK(row.keyVersion)
187
- out[row.key] = await envelopeDecrypt(
188
- kek,
189
- row.ciphertext,
190
- row.wrappedDek
190
+ out[row.key] = createSecretValue(
191
+ await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek)
191
192
  )
192
193
  } catch (cause) {
193
194
  // A stored secret that fails to decrypt is never expected — it means the
@@ -203,7 +204,7 @@ export class KyselySecretService implements SecretService {
203
204
  )
204
205
  }
205
206
  }
206
- return out as T
207
+ return out as Partial<SecretValues<T>>
207
208
  }
208
209
 
209
210
  async rotateKEK(): Promise<number> {
@@ -199,7 +199,7 @@ function registerTests(
199
199
 
200
200
  const second = new KyselySecretService(getDb(), { key: kek })
201
201
  await second.init()
202
- assert.equal(await second.getSecret('shared-salt'), 'value')
202
+ assert.equal((await second.getSecret('shared-salt')).reveal(), 'value')
203
203
 
204
204
  const rows = await getDb()
205
205
  .selectFrom('secretKekSalts')
@@ -227,7 +227,7 @@ function registerTests(
227
227
  const elapsed = performance.now() - start
228
228
 
229
229
  assert.equal(Object.keys(out).length, 50)
230
- assert.equal(out['bulk-7'], 'value-7')
230
+ assert.equal(out['bulk-7']!.reveal(), 'value-7')
231
231
  assert.ok(
232
232
  elapsed < 400,
233
233
  `getSecrets over 50 secrets took ${elapsed.toFixed(0)}ms; one derivation per secret would cost seconds`
@@ -258,7 +258,7 @@ function registerTests(
258
258
  const elapsed = performance.now() - start
259
259
 
260
260
  assert.equal(rotated, 50)
261
- assert.equal(await rotator.getSecret('rotate-7'), 'value-7')
261
+ assert.equal((await rotator.getSecret('rotate-7')).reveal(), 'value-7')
262
262
  assert.ok(
263
263
  elapsed < 700,
264
264
  `rotating 50 secrets took ${elapsed.toFixed(0)}ms; two derivations per secret would cost seconds`
@@ -5,7 +5,15 @@ import { join } from 'node:path'
5
5
  import { Kysely, SqliteDialect } from 'kysely'
6
6
  import Database from 'better-sqlite3'
7
7
 
8
- import { applyPikkuSchemas } from './schema/index.js'
8
+ import { applyPikkuSchemas, ensurePikkuSchema } from './schema/index.js'
9
+ import { virtualUserSchema } from './schema/virtual-user.schema.js'
10
+
11
+ /**
12
+ * Opt-in schemas that still have an interface in `KyselyPikkuDB`, so the drift
13
+ * check covers them too. A table being opt-in says when it is created, not
14
+ * whether its type is allowed to disagree with its DDL.
15
+ */
16
+ const OPTIONAL_SCHEMAS = [virtualUserSchema]
9
17
 
10
18
  /**
11
19
  * `KyselyPikkuDB` and the schema declaration describe the same tables, and
@@ -65,6 +73,9 @@ const declaredSchema = async (): Promise<Map<string, Set<string>>> => {
65
73
  .addColumn('id', 'text', (col) => col.primaryKey())
66
74
  .execute()
67
75
  await applyPikkuSchemas(db)
76
+ for (const schema of OPTIONAL_SCHEMAS) {
77
+ await ensurePikkuSchema(db, schema)
78
+ }
68
79
 
69
80
  const tables = await db.introspection.getTables()
70
81
  return new Map(