@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
@@ -1,5 +1,10 @@
1
1
  import type { Role, ScopeService } from '@pikku/core/services'
2
2
  import type { FlatScope } from '@pikku/core/scope'
3
+ import type { SystemRole } from '@pikku/core/role'
4
+ import {
5
+ SystemRoleImmutableError,
6
+ SystemRoleShadowedError,
7
+ } from '@pikku/core/errors'
3
8
  import type { Kysely } from 'kysely'
4
9
  import type { KyselyPikkuDB } from './kysely-tables.js'
5
10
  import { ensurePikkuSchema } from './schema/index.js'
@@ -105,11 +110,80 @@ export class KyselyScopeService implements ScopeService {
105
110
  }))
106
111
  }
107
112
 
113
+ /**
114
+ * Registers the roles declared with `defineSystemRole`.
115
+ *
116
+ * Additive on the same terms as `syncScopes` — a role whose declaration has
117
+ * gone is marked `declared = false` rather than deleted, so a rollback or a
118
+ * rolling deploy cannot strip everyone's grant. Its *scope set* is replaced
119
+ * outright, because that is the declaration's entire content: editing
120
+ * `defineSystemRole` is how you change what a role means, and the deploy is
121
+ * when it takes effect.
122
+ */
123
+ async syncSystemRoles(roles: SystemRole[]): Promise<void> {
124
+ await this.db.transaction().execute(async (trx) => {
125
+ for (const role of roles) {
126
+ await trx
127
+ .insertInto('pikkuRoles')
128
+ .values({
129
+ name: role.name,
130
+ description: role.description ?? null,
131
+ system: true,
132
+ declared: true,
133
+ })
134
+ .onConflict((oc) =>
135
+ oc.column('name').doUpdateSet((eb) => ({
136
+ description: eb.ref('excluded.description'),
137
+ system: true,
138
+ declared: true,
139
+ }))
140
+ )
141
+ .execute()
142
+
143
+ await trx
144
+ .deleteFrom('pikkuRoleScopes')
145
+ .where('role', '=', role.name)
146
+ .execute()
147
+ if (role.scopes.length > 0) {
148
+ await trx
149
+ .insertInto('pikkuRoleScopes')
150
+ .values(role.scopes.map((scope) => ({ role: role.name, scope })))
151
+ .execute()
152
+ }
153
+ }
154
+
155
+ // Only system rows are in scope here: an admin's own role is not
156
+ // undeclared, it was never declared, and marking it would take it out of
157
+ // the console for a reason nobody could act on.
158
+ const markStale = trx
159
+ .updateTable('pikkuRoles')
160
+ .set({ declared: false })
161
+ .where('system', '=', true)
162
+ await (
163
+ roles.length > 0
164
+ ? markStale.where(
165
+ 'name',
166
+ 'not in',
167
+ roles.map((role) => role.name)
168
+ )
169
+ : markStale
170
+ ).execute()
171
+ })
172
+ }
173
+
108
174
  async createRole(role: Role): Promise<void> {
175
+ if (await this.isSystemRole(role.name)) {
176
+ throw new SystemRoleShadowedError(role.name)
177
+ }
109
178
  await this.db.transaction().execute(async (trx) => {
110
179
  await trx
111
180
  .insertInto('pikkuRoles')
112
- .values({ name: role.name, description: role.description ?? null })
181
+ .values({
182
+ name: role.name,
183
+ description: role.description ?? null,
184
+ system: false,
185
+ declared: true,
186
+ })
113
187
  .execute()
114
188
 
115
189
  if (role.scopes.length > 0) {
@@ -122,10 +196,16 @@ export class KyselyScopeService implements ScopeService {
122
196
  }
123
197
 
124
198
  async deleteRole(name: string): Promise<void> {
199
+ if (await this.isSystemRole(name)) {
200
+ throw new SystemRoleImmutableError(name, 'delete')
201
+ }
125
202
  await this.db.deleteFrom('pikkuRoles').where('name', '=', name).execute()
126
203
  }
127
204
 
128
205
  async setRoleScopes(name: string, scopes: string[]): Promise<void> {
206
+ if (await this.isSystemRole(name)) {
207
+ throw new SystemRoleImmutableError(name, 're-scope')
208
+ }
129
209
  await this.db.transaction().execute(async (trx) => {
130
210
  await trx.deleteFrom('pikkuRoleScopes').where('role', '=', name).execute()
131
211
 
@@ -138,10 +218,26 @@ export class KyselyScopeService implements ScopeService {
138
218
  })
139
219
  }
140
220
 
221
+ /**
222
+ * Whether the store holds this name as a system role.
223
+ *
224
+ * Asked of the store rather than of the generated `SYSTEM_ROLES`, because the
225
+ * store is where a shadow would actually collide — and because a role whose
226
+ * declaration was deleted is still immutable until someone prunes it.
227
+ */
228
+ private async isSystemRole(name: string): Promise<boolean> {
229
+ const row = await this.db
230
+ .selectFrom('pikkuRoles')
231
+ .select('system')
232
+ .where('name', '=', name)
233
+ .executeTakeFirst()
234
+ return !!row?.system
235
+ }
236
+
141
237
  async listRoles(): Promise<Role[]> {
142
238
  const roles = await this.db
143
239
  .selectFrom('pikkuRoles')
144
- .select(['name', 'description'])
240
+ .select(['name', 'description', 'system', 'declared'])
145
241
  .execute()
146
242
 
147
243
  const scopeRows = await this.db
@@ -163,6 +259,8 @@ export class KyselyScopeService implements ScopeService {
163
259
  name: role.name,
164
260
  description: role.description ?? undefined,
165
261
  scopes: byRole.get(role.name) ?? [],
262
+ system: !!role.system,
263
+ declared: !!role.declared,
166
264
  }))
167
265
  }
168
266
 
@@ -270,4 +368,55 @@ export class KyselyScopeService implements ScopeService {
270
368
 
271
369
  return deleted.map((r) => r.name)
272
370
  }
371
+
372
+ /**
373
+ * System roles the last sync found no declaration for, with how many people
374
+ * still hold each. Powers `pikku roles audit`.
375
+ *
376
+ * The count is the whole point: "this role is gone from the code" and "this
377
+ * role is gone from the code and 40 people are standing on it" call for
378
+ * different decisions, and the number is what makes pruning a choice rather
379
+ * than a formality.
380
+ */
381
+ async findStaleSystemRoles(): Promise<Array<{ role: string; users: number }>> {
382
+ const stale = await this.db
383
+ .selectFrom('pikkuRoles')
384
+ .leftJoin('pikkuUserRole', 'pikkuUserRole.role', 'pikkuRoles.name')
385
+ .select(['pikkuRoles.name as role', 'pikkuUserRole.userId as userId'])
386
+ .where('pikkuRoles.system', '=', true)
387
+ .where('pikkuRoles.declared', '=', false)
388
+ .execute()
389
+
390
+ const byRole = new Map<string, number>()
391
+ for (const row of stale) {
392
+ byRole.set(row.role, (byRole.get(row.role) ?? 0) + (row.userId ? 1 : 0))
393
+ }
394
+
395
+ return [...byRole.entries()].map(([role, users]) => ({ role, users }))
396
+ }
397
+
398
+ /**
399
+ * Removes undeclared system roles, cascading them out of every user grant
400
+ * that holds them. This revokes access, so it is never run implicitly.
401
+ */
402
+ async pruneSystemRoles(): Promise<string[]> {
403
+ const stale = await this.findStaleSystemRoles()
404
+ if (stale.length === 0) {
405
+ return []
406
+ }
407
+
408
+ const deleted = await this.db
409
+ .deleteFrom('pikkuRoles')
410
+ .where(
411
+ 'name',
412
+ 'in',
413
+ stale.map((s) => s.role)
414
+ )
415
+ .where('system', '=', true)
416
+ .where('declared', '=', false)
417
+ .returning('name')
418
+ .execute()
419
+
420
+ return deleted.map((r) => r.name)
421
+ }
273
422
  }
@@ -1,7 +1,10 @@
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 {
6
+ deriveKEK,
7
+ generateKEKSalt,
5
8
  envelopeEncrypt,
6
9
  envelopeDecrypt,
7
10
  envelopeRewrap,
@@ -19,6 +22,8 @@ export interface KyselySecretServiceConfig {
19
22
 
20
23
  export class KyselySecretService implements SecretService {
21
24
  private initialized = false
25
+ private kekSalts = new Map<number, string>()
26
+ private keks = new Map<number, CryptoKey>()
22
27
  private key: string
23
28
  private keyVersion: number
24
29
  private previousKey?: string
@@ -60,13 +65,55 @@ export class KyselySecretService implements SecretService {
60
65
  .execute()
61
66
  }
62
67
 
63
- private getKEK(version: number): string {
64
- if (version === this.keyVersion) return this.key
65
- if (this.previousKey) return this.previousKey
66
- throw new Error(`No KEK available for key_version ${version}`)
68
+ private async getKEKSalt(version: number): Promise<string> {
69
+ const cached = this.kekSalts.get(version)
70
+ if (cached) return cached
71
+
72
+ const existing = await this.db
73
+ .selectFrom('secretKekSalts')
74
+ .select('salt')
75
+ .where('keyVersion', '=', version)
76
+ .executeTakeFirst()
77
+
78
+ let salt = existing?.salt
79
+ if (!salt) {
80
+ await this.db
81
+ .insertInto('secretKekSalts')
82
+ .values({
83
+ keyVersion: version,
84
+ salt: generateKEKSalt(),
85
+ createdAt: new Date().toISOString() as unknown as Date,
86
+ })
87
+ .onConflict((oc) => oc.column('keyVersion').doNothing())
88
+ .execute()
89
+
90
+ const row = await this.db
91
+ .selectFrom('secretKekSalts')
92
+ .select('salt')
93
+ .where('keyVersion', '=', version)
94
+ .executeTakeFirstOrThrow()
95
+ salt = row.salt
96
+ }
97
+
98
+ this.kekSalts.set(version, salt)
99
+ return salt
100
+ }
101
+
102
+ private async getKEK(version: number): Promise<CryptoKey> {
103
+ const cached = this.keks.get(version)
104
+ if (cached) return cached
105
+
106
+ const passphrase = version === this.keyVersion ? this.key : this.previousKey
107
+ if (!passphrase) {
108
+ throw new Error(`No KEK available for key_version ${version}`)
109
+ }
110
+
111
+ const kek = await deriveKEK(passphrase, await this.getKEKSalt(version))
112
+ this.keks.set(version, kek)
113
+ return kek
67
114
  }
68
115
 
69
- async getSecret<T = string>(key: string): Promise<T> {
116
+ async getSecret<T = string>(key: string): Promise<SecretValue<T>> {
70
117
  const row = await this.db
71
118
  .selectFrom('secrets')
72
119
  .select(['ciphertext', 'wrappedDek', 'keyVersion'])
@@ -75,10 +122,10 @@ export class KyselySecretService implements SecretService {
75
122
 
76
123
  if (!row) throw new Error('Requested secret not found')
77
124
 
78
- const kek = this.getKEK(row.keyVersion)
125
+ const kek = await this.getKEK(row.keyVersion)
79
126
  const result = await envelopeDecrypt<T>(kek, row.ciphertext, row.wrappedDek)
80
127
  await this.logAudit(key, 'read')
81
- return result
128
+ return createSecretValue(result)
82
129
  }
83
130
 
84
131
  async hasSecret(key: string): Promise<boolean> {
@@ -91,7 +138,12 @@ export class KyselySecretService implements SecretService {
91
138
  }
92
139
 
93
140
  async setSecret(key: string, value: unknown): Promise<void> {
94
- const { ciphertext, wrappedDEK } = await envelopeEncrypt(this.key, value)
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.
143
+ const { ciphertext, wrappedDEK } = await envelopeEncrypt(
144
+ await this.getKEK(this.keyVersion),
145
+ isSecretValue(value) ? value.reveal() : value
146
+ )
95
147
  const now = new Date().toISOString()
96
148
 
97
149
  await this.db
@@ -124,7 +176,7 @@ export class KyselySecretService implements SecretService {
124
176
 
125
177
  async getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(
126
178
  keys: (keyof T & string)[]
127
- ): Promise<T> {
179
+ ): Promise<Partial<SecretValues<T>>> {
128
180
  const rows = await this.db
129
181
  .selectFrom('secrets')
130
182
  .select(['key', 'ciphertext', 'wrappedDek', 'keyVersion'])
@@ -134,11 +186,9 @@ export class KyselySecretService implements SecretService {
134
186
  const out: Record<string, unknown> = {}
135
187
  for (const row of rows) {
136
188
  try {
137
- const kek = this.getKEK(row.keyVersion)
138
- out[row.key] = await envelopeDecrypt(
139
- kek,
140
- row.ciphertext,
141
- row.wrappedDek
189
+ const kek = await this.getKEK(row.keyVersion)
190
+ out[row.key] = createSecretValue(
191
+ await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek)
142
192
  )
143
193
  } catch (cause) {
144
194
  // A stored secret that fails to decrypt is never expected — it means the
@@ -154,7 +204,7 @@ export class KyselySecretService implements SecretService {
154
204
  )
155
205
  }
156
206
  }
157
- return out as T
207
+ return out as Partial<SecretValues<T>>
158
208
  }
159
209
 
160
210
  async rotateKEK(): Promise<number> {
@@ -168,12 +218,11 @@ export class KyselySecretService implements SecretService {
168
218
  .where('keyVersion', '<', this.keyVersion)
169
219
  .execute()
170
220
 
221
+ const oldKEK = await this.getKEK(this.keyVersion - 1)
222
+ const newKEK = await this.getKEK(this.keyVersion)
223
+
171
224
  for (const row of rows) {
172
- const newWrappedDEK = await envelopeRewrap(
173
- this.previousKey,
174
- this.key,
175
- row.wrappedDek
176
- )
225
+ const newWrappedDEK = await envelopeRewrap(oldKEK, newKEK, row.wrappedDek)
177
226
  await this.db
178
227
  .updateTable('secrets')
179
228
  .set({
@@ -1,4 +1,4 @@
1
- import { describe, test, before, after } from 'node:test'
1
+ import { describe, test, before, beforeEach, after } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import { CamelCasePlugin, Kysely, SqliteDialect } from 'kysely'
4
4
  import { SerializePlugin } from './serialize-plugin.js'
@@ -54,8 +54,10 @@ async function dropAllTables(db: Kysely<KyselyPikkuDB>): Promise<void> {
54
54
  'workflow_runs',
55
55
  'workflow_versions',
56
56
  'secrets_audit',
57
+ 'secret_kek_salts',
57
58
  'secrets',
58
59
  'credentials_audit',
60
+ 'credential_kek_salts',
59
61
  'credentials',
60
62
  ]
61
63
  for (const table of tables) {
@@ -166,6 +168,126 @@ function registerTests(
166
168
  })
167
169
  })
168
170
 
171
+ describe(`KyselySecretService KEK derivation [${dialectName}]`, () => {
172
+ const kek = 'test-key-encryption-key-32chars!'
173
+
174
+ beforeEach(async () => {
175
+ await getDb().deleteFrom('secrets').execute()
176
+ await getDb().deleteFrom('secretKekSalts').execute()
177
+ })
178
+
179
+ test('a KEK salt row is created once per key version', async () => {
180
+ const service = new KyselySecretService(getDb(), { key: kek })
181
+ await service.init()
182
+ await service.setSecret('salt-a', 'one')
183
+ await service.setSecret('salt-b', 'two')
184
+
185
+ const rows = await getDb()
186
+ .selectFrom('secretKekSalts')
187
+ .select(['keyVersion', 'salt'])
188
+ .execute()
189
+
190
+ assert.equal(rows.length, 1)
191
+ assert.equal(rows[0]!.keyVersion, 1)
192
+ assert.ok(rows[0]!.salt.length > 0)
193
+ })
194
+
195
+ test('a second instance reuses the stored salt rather than minting one', async () => {
196
+ const first = new KyselySecretService(getDb(), { key: kek })
197
+ await first.init()
198
+ await first.setSecret('shared-salt', 'value')
199
+
200
+ const second = new KyselySecretService(getDb(), { key: kek })
201
+ await second.init()
202
+ assert.equal((await second.getSecret('shared-salt')).reveal(), 'value')
203
+
204
+ const rows = await getDb()
205
+ .selectFrom('secretKekSalts')
206
+ .selectAll()
207
+ .execute()
208
+ assert.equal(rows.length, 1)
209
+ })
210
+
211
+ test('getSecrets over 50 secrets costs one KEK derivation, not 50', async () => {
212
+ const service = new KyselySecretService(getDb(), { key: kek })
213
+ await service.init()
214
+
215
+ const keys: string[] = []
216
+ for (let i = 0; i < 50; i++) {
217
+ const key = `bulk-${i}`
218
+ keys.push(key)
219
+ await service.setSecret(key, `value-${i}`)
220
+ }
221
+
222
+ const reader = new KyselySecretService(getDb(), { key: kek })
223
+ await reader.init()
224
+
225
+ const start = performance.now()
226
+ const out = await reader.getSecrets(keys)
227
+ const elapsed = performance.now() - start
228
+
229
+ assert.equal(Object.keys(out).length, 50)
230
+ assert.equal(out['bulk-7']!.reveal(), 'value-7')
231
+ assert.ok(
232
+ elapsed < 400,
233
+ `getSecrets over 50 secrets took ${elapsed.toFixed(0)}ms; one derivation per secret would cost seconds`
234
+ )
235
+ })
236
+
237
+ test('rotateKEK over 50 secrets derives two KEKs, not 100', async () => {
238
+ const previousKey = 'previous-key-encryption-key-32c!'
239
+ const writer = new KyselySecretService(getDb(), {
240
+ key: previousKey,
241
+ keyVersion: 1,
242
+ })
243
+ await writer.init()
244
+
245
+ for (let i = 0; i < 50; i++) {
246
+ await writer.setSecret(`rotate-${i}`, `value-${i}`)
247
+ }
248
+
249
+ const rotator = new KyselySecretService(getDb(), {
250
+ key: kek,
251
+ keyVersion: 2,
252
+ previousKey,
253
+ })
254
+ await rotator.init()
255
+
256
+ const start = performance.now()
257
+ const rotated = await rotator.rotateKEK()
258
+ const elapsed = performance.now() - start
259
+
260
+ assert.equal(rotated, 50)
261
+ assert.equal((await rotator.getSecret('rotate-7')).reveal(), 'value-7')
262
+ assert.ok(
263
+ elapsed < 700,
264
+ `rotating 50 secrets took ${elapsed.toFixed(0)}ms; two derivations per secret would cost seconds`
265
+ )
266
+
267
+ const salts = await getDb()
268
+ .selectFrom('secretKekSalts')
269
+ .select('keyVersion')
270
+ .orderBy('keyVersion', 'asc')
271
+ .execute()
272
+ assert.deepEqual(
273
+ salts.map((r) => r.keyVersion),
274
+ [1, 2]
275
+ )
276
+ })
277
+
278
+ test('a wrong KEK passphrase fails loudly rather than returning garbage', async () => {
279
+ const writer = new KyselySecretService(getDb(), { key: kek })
280
+ await writer.init()
281
+ await writer.setSecret('wrong-kek', 'value')
282
+
283
+ const reader = new KyselySecretService(getDb(), {
284
+ key: 'a-completely-different-kek-32ch!',
285
+ })
286
+ await reader.init()
287
+ await assert.rejects(() => reader.getSecret('wrong-kek'))
288
+ })
289
+ })
290
+
169
291
  describe(`KyselyCredentialService audit [${dialectName}]`, () => {
170
292
  const kek = 'test-key-encryption-key-32chars!'
171
293
 
@@ -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(
@@ -131,7 +131,14 @@ export interface AIRunTable {
131
131
  agentName: string
132
132
  threadId: string
133
133
  resourceId: string
134
- status: Generated<'running' | 'suspended' | 'completed' | 'failed'>
134
+ /**
135
+ * `interrupted` is distinct from `failed`: the run was stopped on purpose,
136
+ * usually because the user talked over it. Nothing went wrong, so it should
137
+ * not show up in error reporting.
138
+ */
139
+ status: Generated<
140
+ 'running' | 'suspended' | 'completed' | 'failed' | 'interrupted'
141
+ >
135
142
  errorMessage: string | null
136
143
  suspendReason: 'approval' | 'credential' | 'rpc-missing' | null
137
144
  missingRpcs: string | null
@@ -165,6 +172,12 @@ export interface SecretsTable {
165
172
  updatedAt: Generated<Date>
166
173
  }
167
174
 
175
+ export interface KekSaltsTable {
176
+ keyVersion: number
177
+ salt: string
178
+ createdAt: Generated<Date>
179
+ }
180
+
168
181
  export interface SecretsAuditTable {
169
182
  id: string
170
183
  secretKey: string
@@ -215,10 +228,14 @@ export interface PikkuScopesTable {
215
228
  declared: Generated<boolean>
216
229
  }
217
230
 
218
- /** A role: an admin-composed bag of scopes. */
231
+ /** A role: a bag of scopes, composed by an admin or declared in code. */
219
232
  export interface PikkuRolesTable {
220
233
  name: string
221
234
  description: string | null
235
+ /** Declared with `defineSystemRole`, and therefore not editable here. */
236
+ system: Generated<boolean>
237
+ /** False for a system role whose declaration has gone: held, but inert. */
238
+ declared: Generated<boolean>
222
239
  createdAt: Generated<Date>
223
240
  }
224
241
 
@@ -264,6 +281,37 @@ export interface WebhookDeliveryAttemptTable {
264
281
  createdAt: Generated<Date>
265
282
  }
266
283
 
284
+ /**
285
+ * One virtual-user run. The JSON columns (`goals`, `memory`, `findings`,
286
+ * `tally`) are text the store serialises itself, so the row is byte-identical
287
+ * on every engine.
288
+ */
289
+ export interface VirtualUserRunTable {
290
+ runId: string
291
+ persona: string
292
+ disposition: string
293
+ /**
294
+ * Read back as a string by some drivers — a BIGINT does not always fit a JS
295
+ * number — so the store narrows it rather than trusting the column type.
296
+ */
297
+ seed: string | number | bigint
298
+ status: Generated<'running' | 'completed' | 'failed'>
299
+ goals: Generated<string>
300
+ memory: Generated<string>
301
+ findings: Generated<string>
302
+ tally: string | null
303
+ stoppedBy: string | null
304
+ error: string | null
305
+ startedBy: string | null
306
+ /**
307
+ * Written as an ISO string and read back as whatever the driver returns — a
308
+ * bare SQLite driver cannot bind a `Date` at all, and the store normalises
309
+ * both ends.
310
+ */
311
+ createdAt: Generated<Date | string>
312
+ finishedAt: Date | string | null
313
+ }
314
+
267
315
  export interface KyselyPikkuDB {
268
316
  pikkuScopes: PikkuScopesTable
269
317
  pikkuRoles: PikkuRolesTable
@@ -284,10 +332,13 @@ export interface KyselyPikkuDB {
284
332
  pikkuDeployments: PikkuDeploymentsTable
285
333
  pikkuDeploymentFunctions: PikkuDeploymentFunctionsTable
286
334
  secrets: SecretsTable
335
+ secretKekSalts: KekSaltsTable
287
336
  secretsAudit: SecretsAuditTable
288
337
  credentials: CredentialsTable
338
+ credentialKekSalts: KekSaltsTable
289
339
  credentialsAudit: CredentialsAuditTable
290
340
  pikkuUserSessions: UserSessionsTable
291
341
  webhookDelivery: WebhookDeliveryTable
292
342
  webhookDeliveryAttempt: WebhookDeliveryAttemptTable
343
+ virtualUserRun: VirtualUserRunTable
293
344
  }