@pikku/kysely 0.13.5 → 0.13.7

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 (35) hide show
  1. package/CHANGELOG.md +406 -0
  2. package/LICENSE +21 -0
  3. package/README.md +29 -2
  4. package/dist/src/kysely-credential-service.d.ts +3 -0
  5. package/dist/src/kysely-credential-service.js +50 -11
  6. package/dist/src/kysely-scope-service.d.ts +38 -0
  7. package/dist/src/kysely-scope-service.js +125 -2
  8. package/dist/src/kysely-secret-service.d.ts +3 -0
  9. package/dist/src/kysely-secret-service.js +50 -11
  10. package/dist/src/kysely-tables.d.ts +26 -2
  11. package/dist/src/kysely-workflow-mirror.js +37 -25
  12. package/dist/src/kysely-workflow-run-service.d.ts +0 -5
  13. package/dist/src/kysely-workflow-run-service.js +4 -16
  14. package/dist/src/kysely-workflow-service.d.ts +35 -7
  15. package/dist/src/kysely-workflow-service.js +194 -146
  16. package/dist/src/schema/credential.schema.js +5 -0
  17. package/dist/src/schema/scope.schema.js +12 -0
  18. package/dist/src/schema/secret.schema.js +5 -0
  19. package/dist/src/schema/workflow.schema.d.ts +4 -2
  20. package/dist/src/schema/workflow.schema.js +28 -2
  21. package/package.json +3 -2
  22. package/src/kysely-credential-service.ts +57 -12
  23. package/src/kysely-scope-service.ts +151 -2
  24. package/src/kysely-secret-service.ts +60 -12
  25. package/src/kysely-services.test.ts +123 -1
  26. package/src/kysely-tables.ts +29 -2
  27. package/src/kysely-workflow-mirror.test.ts +1 -13
  28. package/src/kysely-workflow-mirror.ts +44 -20
  29. package/src/kysely-workflow-run-service.ts +7 -20
  30. package/src/kysely-workflow-service.test.ts +431 -0
  31. package/src/kysely-workflow-service.ts +212 -178
  32. package/src/schema/credential.schema.ts +9 -0
  33. package/src/schema/scope.schema.ts +14 -0
  34. package/src/schema/secret.schema.ts +9 -0
  35. package/src/schema/workflow.schema.ts +38 -2
@@ -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({
@@ -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
  }
@@ -2,6 +2,8 @@ import type { SecretService } 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 KyselySecretServiceConfig {
19
21
 
20
22
  export class KyselySecretService implements SecretService {
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
@@ -60,10 +64,52 @@ export class KyselySecretService implements SecretService {
60
64
  .execute()
61
65
  }
62
66
 
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}`)
67
+ private async getKEKSalt(version: number): Promise<string> {
68
+ const cached = this.kekSalts.get(version)
69
+ if (cached) return cached
70
+
71
+ const existing = await this.db
72
+ .selectFrom('secretKekSalts')
73
+ .select('salt')
74
+ .where('keyVersion', '=', version)
75
+ .executeTakeFirst()
76
+
77
+ let salt = existing?.salt
78
+ if (!salt) {
79
+ await this.db
80
+ .insertInto('secretKekSalts')
81
+ .values({
82
+ keyVersion: version,
83
+ salt: generateKEKSalt(),
84
+ createdAt: new Date().toISOString() as unknown as Date,
85
+ })
86
+ .onConflict((oc) => oc.column('keyVersion').doNothing())
87
+ .execute()
88
+
89
+ const row = await this.db
90
+ .selectFrom('secretKekSalts')
91
+ .select('salt')
92
+ .where('keyVersion', '=', version)
93
+ .executeTakeFirstOrThrow()
94
+ salt = row.salt
95
+ }
96
+
97
+ this.kekSalts.set(version, salt)
98
+ return salt
99
+ }
100
+
101
+ private async getKEK(version: number): Promise<CryptoKey> {
102
+ const cached = this.keks.get(version)
103
+ if (cached) return cached
104
+
105
+ const passphrase = version === this.keyVersion ? this.key : this.previousKey
106
+ if (!passphrase) {
107
+ throw new Error(`No KEK available for key_version ${version}`)
108
+ }
109
+
110
+ const kek = await deriveKEK(passphrase, await this.getKEKSalt(version))
111
+ this.keks.set(version, kek)
112
+ return kek
67
113
  }
68
114
 
69
115
  async getSecret<T = string>(key: string): Promise<T> {
@@ -75,7 +121,7 @@ export class KyselySecretService implements SecretService {
75
121
 
76
122
  if (!row) throw new Error('Requested secret not found')
77
123
 
78
- const kek = this.getKEK(row.keyVersion)
124
+ const kek = await this.getKEK(row.keyVersion)
79
125
  const result = await envelopeDecrypt<T>(kek, row.ciphertext, row.wrappedDek)
80
126
  await this.logAudit(key, 'read')
81
127
  return result
@@ -91,7 +137,10 @@ export class KyselySecretService implements SecretService {
91
137
  }
92
138
 
93
139
  async setSecret(key: string, value: unknown): Promise<void> {
94
- const { ciphertext, wrappedDEK } = await envelopeEncrypt(this.key, value)
140
+ const { ciphertext, wrappedDEK } = await envelopeEncrypt(
141
+ await this.getKEK(this.keyVersion),
142
+ value
143
+ )
95
144
  const now = new Date().toISOString()
96
145
 
97
146
  await this.db
@@ -134,7 +183,7 @@ export class KyselySecretService implements SecretService {
134
183
  const out: Record<string, unknown> = {}
135
184
  for (const row of rows) {
136
185
  try {
137
- const kek = this.getKEK(row.keyVersion)
186
+ const kek = await this.getKEK(row.keyVersion)
138
187
  out[row.key] = await envelopeDecrypt(
139
188
  kek,
140
189
  row.ciphertext,
@@ -168,12 +217,11 @@ export class KyselySecretService implements SecretService {
168
217
  .where('keyVersion', '<', this.keyVersion)
169
218
  .execute()
170
219
 
220
+ const oldKEK = await this.getKEK(this.keyVersion - 1)
221
+ const newKEK = await this.getKEK(this.keyVersion)
222
+
171
223
  for (const row of rows) {
172
- const newWrappedDEK = await envelopeRewrap(
173
- this.previousKey,
174
- this.key,
175
- row.wrappedDek
176
- )
224
+ const newWrappedDEK = await envelopeRewrap(oldKEK, newKEK, row.wrappedDek)
177
225
  await this.db
178
226
  .updateTable('secrets')
179
227
  .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'), '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'], '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'), '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
 
@@ -52,6 +52,8 @@ export interface WorkflowStepTable {
52
52
  retries: number | null
53
53
  retryDelay: string | null
54
54
  fromStepName: string | null
55
+ /** The history attempt currently in flight; see WorkflowStepHistoryTable.attempt. */
56
+ currentAttempt: number | null
55
57
  createdAt: Generated<Date>
56
58
  updatedAt: Generated<Date>
57
59
  }
@@ -62,6 +64,12 @@ export interface WorkflowStepHistoryTable {
62
64
  status: StepStatus
63
65
  result: string | null
64
66
  error: string | null
67
+ /**
68
+ * Monotonic attempt number within the step, starting at 1. Orders attempts
69
+ * and identifies the live one without relying on `createdAt`, which cannot
70
+ * separate a retry from the attempt it replaces in the same millisecond.
71
+ */
72
+ attempt: number | null
65
73
  createdAt: Generated<Date>
66
74
  runningAt: Date | null
67
75
  scheduledAt: Date | null
@@ -123,7 +131,14 @@ export interface AIRunTable {
123
131
  agentName: string
124
132
  threadId: string
125
133
  resourceId: string
126
- 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
+ >
127
142
  errorMessage: string | null
128
143
  suspendReason: 'approval' | 'credential' | 'rpc-missing' | null
129
144
  missingRpcs: string | null
@@ -157,6 +172,12 @@ export interface SecretsTable {
157
172
  updatedAt: Generated<Date>
158
173
  }
159
174
 
175
+ export interface KekSaltsTable {
176
+ keyVersion: number
177
+ salt: string
178
+ createdAt: Generated<Date>
179
+ }
180
+
160
181
  export interface SecretsAuditTable {
161
182
  id: string
162
183
  secretKey: string
@@ -207,10 +228,14 @@ export interface PikkuScopesTable {
207
228
  declared: Generated<boolean>
208
229
  }
209
230
 
210
- /** A role: an admin-composed bag of scopes. */
231
+ /** A role: a bag of scopes, composed by an admin or declared in code. */
211
232
  export interface PikkuRolesTable {
212
233
  name: string
213
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>
214
239
  createdAt: Generated<Date>
215
240
  }
216
241
 
@@ -276,8 +301,10 @@ export interface KyselyPikkuDB {
276
301
  pikkuDeployments: PikkuDeploymentsTable
277
302
  pikkuDeploymentFunctions: PikkuDeploymentFunctionsTable
278
303
  secrets: SecretsTable
304
+ secretKekSalts: KekSaltsTable
279
305
  secretsAudit: SecretsAuditTable
280
306
  credentials: CredentialsTable
307
+ credentialKekSalts: KekSaltsTable
281
308
  credentialsAudit: CredentialsAuditTable
282
309
  pikkuUserSessions: UserSessionsTable
283
310
  webhookDelivery: WebhookDeliveryTable
@@ -1,6 +1,6 @@
1
1
  import { describe, test, beforeEach, afterEach } from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import { CamelCasePlugin, Kysely, SqliteDialect } from 'kysely'
3
+ import { CamelCasePlugin, Kysely, SqliteDialect, sql } from 'kysely'
4
4
  import { SerializePlugin } from './serialize-plugin.js'
5
5
  import Database from 'better-sqlite3'
6
6
 
@@ -237,18 +237,6 @@ describe('KyselyWorkflowMirror — workflow versions', () => {
237
237
  .executeTakeFirst()
238
238
  assert.equal(row?.status, 'archived')
239
239
  })
240
-
241
- test('AI-generated workflows surface via getAIGeneratedWorkflows', async () => {
242
- await mirror.upsertWorkflowVersion(
243
- 'ai:agent-x:foo',
244
- 'h1',
245
- { nodes: [] },
246
- 'dynamic-workflow'
247
- )
248
- const list = await runService.getAIGeneratedWorkflows('agent-x')
249
- assert.equal(list.length, 1)
250
- assert.equal(list[0]!.workflowName, 'ai:agent-x:foo')
251
- })
252
240
  })
253
241
 
254
242
  describe('KyselyWorkflowMirror — init', () => {