@pikku/kysely 0.13.24 → 0.13.28

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 (80) hide show
  1. package/CHANGELOG.md +271 -0
  2. package/dist/src/index.d.ts +4 -1
  3. package/dist/src/index.js +3 -1
  4. package/dist/src/kysely-analytics-service.d.ts +30 -0
  5. package/dist/src/kysely-analytics-service.js +80 -0
  6. package/dist/src/kysely-feature-flag-store.d.ts +37 -0
  7. package/dist/src/kysely-feature-flag-store.js +239 -0
  8. package/dist/src/kysely-scope-service.d.ts +1 -0
  9. package/dist/src/kysely-scope-service.js +29 -0
  10. package/dist/src/kysely-tables.d.ts +45 -0
  11. package/dist/src/schema/analytics.schema.d.ts +17 -0
  12. package/dist/src/schema/analytics.schema.js +48 -0
  13. package/dist/src/schema/flag.schema.d.ts +16 -0
  14. package/dist/src/schema/flag.schema.js +62 -0
  15. package/dist/src/schema/index.d.ts +2 -0
  16. package/dist/src/schema/index.js +6 -0
  17. package/package.json +11 -6
  18. package/run-tests.sh +9 -1
  19. package/bin/pikku-kysely-pure.ts +0 -59
  20. package/dist/src/kysely-lock-vault.d.ts +0 -34
  21. package/dist/src/kysely-lock-vault.js +0 -77
  22. package/dist/src/schema/data-lock.schema.d.ts +0 -15
  23. package/dist/src/schema/data-lock.schema.js +0 -27
  24. package/dist/tsconfig.tsbuildinfo +0 -1
  25. package/src/classification-crypto.test.ts +0 -143
  26. package/src/classification-crypto.ts +0 -163
  27. package/src/classification-plugin.pglite.test.ts +0 -226
  28. package/src/classification-plugin.test.ts +0 -265
  29. package/src/classification-plugin.ts +0 -249
  30. package/src/coercion-plugin.test.ts +0 -160
  31. package/src/coercion-plugin.ts +0 -187
  32. package/src/create-audited-kysely.test.ts +0 -560
  33. package/src/create-audited-kysely.ts +0 -254
  34. package/src/index.ts +0 -81
  35. package/src/kysely-agent-run-scores.ts +0 -47
  36. package/src/kysely-agent-run-service.ts +0 -244
  37. package/src/kysely-agent-run-state-service.ts +0 -200
  38. package/src/kysely-agent-storage-service.ts +0 -632
  39. package/src/kysely-audit-service.test.ts +0 -352
  40. package/src/kysely-audit-service.ts +0 -237
  41. package/src/kysely-channel-store.ts +0 -107
  42. package/src/kysely-credential-service.ts +0 -286
  43. package/src/kysely-deployment-service.ts +0 -163
  44. package/src/kysely-eventhub-store.ts +0 -53
  45. package/src/kysely-json.ts +0 -5
  46. package/src/kysely-scope-service.test.ts +0 -418
  47. package/src/kysely-scope-service.ts +0 -425
  48. package/src/kysely-secret-service.ts +0 -242
  49. package/src/kysely-services.test.ts +0 -405
  50. package/src/kysely-session-store.ts +0 -57
  51. package/src/kysely-tables.test.ts +0 -110
  52. package/src/kysely-tables.ts +0 -434
  53. package/src/kysely-virtual-user-run-store.test.ts +0 -369
  54. package/src/kysely-virtual-user-run-store.ts +0 -235
  55. package/src/kysely-virtual-user-schedule-store.test.ts +0 -187
  56. package/src/kysely-virtual-user-schedule-store.ts +0 -187
  57. package/src/kysely-webhook-service.test.ts +0 -153
  58. package/src/kysely-webhook-service.ts +0 -139
  59. package/src/kysely-workflow-mirror.test.ts +0 -259
  60. package/src/kysely-workflow-mirror.ts +0 -374
  61. package/src/kysely-workflow-run-service.ts +0 -305
  62. package/src/kysely-workflow-service.test.ts +0 -544
  63. package/src/kysely-workflow-service.ts +0 -820
  64. package/src/schema/agent.schema.ts +0 -162
  65. package/src/schema/audit.schema.ts +0 -57
  66. package/src/schema/channel.schema.ts +0 -38
  67. package/src/schema/credential.schema.ts +0 -58
  68. package/src/schema/deployment.schema.ts +0 -47
  69. package/src/schema/index.ts +0 -441
  70. package/src/schema/pikku-schema.types.ts +0 -169
  71. package/src/schema/schema.test.ts +0 -591
  72. package/src/schema/scope.schema.ts +0 -110
  73. package/src/schema/secret.schema.ts +0 -49
  74. package/src/schema/session.schema.ts +0 -20
  75. package/src/schema/virtual-user-schedule.schema.ts +0 -48
  76. package/src/schema/virtual-user.schema.ts +0 -106
  77. package/src/schema/webhook.schema.ts +0 -58
  78. package/src/schema/workflow.schema.ts +0 -150
  79. package/src/serialize-plugin.ts +0 -233
  80. package/tsconfig.json +0 -17
@@ -1,425 +0,0 @@
1
- import type { ScopeService } from '@pikku/core/services'
2
- import type { Role } from '@pikku/core/services'
3
- import type { FlatScope } from '@pikku/core/scope'
4
- import type { SystemRole } from '@pikku/core/role'
5
- import {
6
- SystemRoleImmutableError,
7
- SystemRoleShadowedError,
8
- } from '@pikku/core/errors'
9
- import type { Kysely } from 'kysely'
10
- import type { KyselyPikkuDB } from './kysely-tables.js'
11
- import { requirePikkuSchema } from './schema/index.js'
12
- import { scopeSchema } from './schema/scope.schema.js'
13
-
14
- /**
15
- * Resolves and administers user scopes against four self-created tables.
16
- *
17
- * Requires better-auth: `pikku_user_role.user_id` references its `user` table
18
- * with ON DELETE CASCADE, so deleting a user takes their grants with it. That
19
- * table is created by better-auth's own migrations, which `pikku db migrate`
20
- * hard-fails without, so it exists before `init()` runs.
21
- *
22
- * Scopes are declared in code and synced here; roles are data, composed by
23
- * admins at runtime. `pikku_role_scopes` FKs into `pikku_scopes`, so the
24
- * database itself refuses to grant a scope that was never declared.
25
- */
26
- export class KyselyScopeService implements ScopeService {
27
- private initialized = false
28
-
29
- constructor(private db: Kysely<KyselyPikkuDB>) {}
30
-
31
- public async init(): Promise<void> {
32
- if (this.initialized) return
33
- await requirePikkuSchema(this.db, scopeSchema)
34
- this.initialized = true
35
- }
36
-
37
- /**
38
- * Registers the declared scope set.
39
- *
40
- * Additive: rows are upserted and anything no longer declared is *marked*
41
- * (`declared = false`), never deleted. Marking is non-destructive, so a
42
- * rename, a rollback, or a rolling deploy where an older replica is still
43
- * serving cannot silently strip a grant. `pruneScopes` is the deliberate
44
- * removal path.
45
- */
46
- async syncScopes(scopes: FlatScope[]): Promise<void> {
47
- await this.db.transaction().execute(async (trx) => {
48
- if (scopes.length > 0) {
49
- await trx
50
- .insertInto('pikkuScopes')
51
- .values(
52
- scopes.map((scope) => ({
53
- name: scope.id,
54
- description: scope.description ?? null,
55
- declared: true,
56
- }))
57
- )
58
- .onConflict((oc) =>
59
- oc.column('name').doUpdateSet((eb) => ({
60
- description: eb.ref('excluded.description'),
61
- declared: true,
62
- }))
63
- )
64
- .execute()
65
- }
66
-
67
- const markStale = trx.updateTable('pikkuScopes').set({ declared: false })
68
- await (
69
- scopes.length > 0
70
- ? markStale.where(
71
- 'name',
72
- 'not in',
73
- scopes.map((s) => s.id)
74
- )
75
- : markStale
76
- ).execute()
77
- })
78
- }
79
-
80
- async resolveScopes(userId: string): Promise<string[]> {
81
- const roleScopes = this.db
82
- .selectFrom('pikkuUserRole')
83
- .innerJoin(
84
- 'pikkuRoleScopes',
85
- 'pikkuRoleScopes.role',
86
- 'pikkuUserRole.role'
87
- )
88
- .select('pikkuRoleScopes.scope')
89
- .where('pikkuUserRole.userId', '=', userId)
90
-
91
- const directScopes = this.db
92
- .selectFrom('pikkuUserScope')
93
- .select('pikkuUserScope.scope')
94
- .where('pikkuUserScope.userId', '=', userId)
95
-
96
- const rows = await roleScopes.union(directScopes).execute()
97
-
98
- return rows.map((row) => row.scope)
99
- }
100
-
101
- async listScopes(): Promise<Array<FlatScope & { declared: boolean }>> {
102
- const rows = await this.db
103
- .selectFrom('pikkuScopes')
104
- .select(['name', 'description', 'declared'])
105
- .execute()
106
-
107
- return rows.map((row) => ({
108
- id: row.name,
109
- description: row.description ?? undefined,
110
- declared: !!row.declared,
111
- }))
112
- }
113
-
114
- /**
115
- * Registers the roles declared with `defineSystemRole`.
116
- *
117
- * Additive on the same terms as `syncScopes` — a role whose declaration has
118
- * gone is marked `declared = false` rather than deleted, so a rollback or a
119
- * rolling deploy cannot strip everyone's grant. Its *scope set* is replaced
120
- * outright, because that is the declaration's entire content: editing
121
- * `defineSystemRole` is how you change what a role means, and the deploy is
122
- * when it takes effect.
123
- */
124
- async syncSystemRoles(roles: SystemRole[]): Promise<void> {
125
- await this.db.transaction().execute(async (trx) => {
126
- for (const role of roles) {
127
- await trx
128
- .insertInto('pikkuRoles')
129
- .values({
130
- name: role.name,
131
- description: role.description ?? null,
132
- system: true,
133
- declared: true,
134
- })
135
- .onConflict((oc) =>
136
- oc.column('name').doUpdateSet((eb) => ({
137
- description: eb.ref('excluded.description'),
138
- system: true,
139
- declared: true,
140
- }))
141
- )
142
- .execute()
143
-
144
- await trx
145
- .deleteFrom('pikkuRoleScopes')
146
- .where('role', '=', role.name)
147
- .execute()
148
- if (role.scopes.length > 0) {
149
- await trx
150
- .insertInto('pikkuRoleScopes')
151
- .values(role.scopes.map((scope) => ({ role: role.name, scope })))
152
- .execute()
153
- }
154
- }
155
-
156
- // Only system rows are in scope here: an admin's own role is not
157
- // undeclared, it was never declared, and marking it would take it out of
158
- // the console for a reason nobody could act on.
159
- const markStale = trx
160
- .updateTable('pikkuRoles')
161
- .set({ declared: false })
162
- .where('system', '=', true)
163
- await (
164
- roles.length > 0
165
- ? markStale.where(
166
- 'name',
167
- 'not in',
168
- roles.map((role) => role.name)
169
- )
170
- : markStale
171
- ).execute()
172
- })
173
- }
174
-
175
- async createRole(role: Role): Promise<void> {
176
- if (await this.isSystemRole(role.name)) {
177
- throw new SystemRoleShadowedError(role.name)
178
- }
179
- await this.db.transaction().execute(async (trx) => {
180
- await trx
181
- .insertInto('pikkuRoles')
182
- .values({
183
- name: role.name,
184
- description: role.description ?? null,
185
- system: false,
186
- declared: true,
187
- })
188
- .execute()
189
-
190
- if (role.scopes.length > 0) {
191
- await trx
192
- .insertInto('pikkuRoleScopes')
193
- .values(role.scopes.map((scope) => ({ role: role.name, scope })))
194
- .execute()
195
- }
196
- })
197
- }
198
-
199
- async deleteRole(name: string): Promise<void> {
200
- if (await this.isSystemRole(name)) {
201
- throw new SystemRoleImmutableError(name, 'delete')
202
- }
203
- await this.db.deleteFrom('pikkuRoles').where('name', '=', name).execute()
204
- }
205
-
206
- async setRoleScopes(name: string, scopes: string[]): Promise<void> {
207
- if (await this.isSystemRole(name)) {
208
- throw new SystemRoleImmutableError(name, 're-scope')
209
- }
210
- await this.db.transaction().execute(async (trx) => {
211
- await trx.deleteFrom('pikkuRoleScopes').where('role', '=', name).execute()
212
-
213
- if (scopes.length > 0) {
214
- await trx
215
- .insertInto('pikkuRoleScopes')
216
- .values(scopes.map((scope) => ({ role: name, scope })))
217
- .execute()
218
- }
219
- })
220
- }
221
-
222
- /**
223
- * Whether the store holds this name as a system role.
224
- *
225
- * Asked of the store rather than of the generated `SYSTEM_ROLES`, because the
226
- * store is where a shadow would actually collide — and because a role whose
227
- * declaration was deleted is still immutable until someone prunes it.
228
- */
229
- private async isSystemRole(name: string): Promise<boolean> {
230
- const row = await this.db
231
- .selectFrom('pikkuRoles')
232
- .select('system')
233
- .where('name', '=', name)
234
- .executeTakeFirst()
235
- return !!row?.system
236
- }
237
-
238
- async listRoles(): Promise<Role[]> {
239
- const roles = await this.db
240
- .selectFrom('pikkuRoles')
241
- .select(['name', 'description', 'system', 'declared'])
242
- .execute()
243
-
244
- const scopeRows = await this.db
245
- .selectFrom('pikkuRoleScopes')
246
- .select(['role', 'scope'])
247
- .execute()
248
-
249
- const byRole = new Map<string, string[]>()
250
- for (const row of scopeRows) {
251
- const existing = byRole.get(row.role)
252
- if (existing) {
253
- existing.push(row.scope)
254
- } else {
255
- byRole.set(row.role, [row.scope])
256
- }
257
- }
258
-
259
- return roles.map((role) => ({
260
- name: role.name,
261
- description: role.description ?? undefined,
262
- scopes: byRole.get(role.name) ?? [],
263
- system: !!role.system,
264
- declared: !!role.declared,
265
- }))
266
- }
267
-
268
- async addUserToRole(
269
- userId: string,
270
- role: string,
271
- grantedBy?: string
272
- ): Promise<void> {
273
- await this.db
274
- .insertInto('pikkuUserRole')
275
- .values({ userId, role, grantedBy: grantedBy ?? null })
276
- .onConflict((oc) => oc.columns(['userId', 'role']).doNothing())
277
- .execute()
278
- }
279
-
280
- async removeUserFromRole(userId: string, role: string): Promise<void> {
281
- await this.db
282
- .deleteFrom('pikkuUserRole')
283
- .where('userId', '=', userId)
284
- .where('role', '=', role)
285
- .execute()
286
- }
287
-
288
- async listUserRoles(userId: string): Promise<string[]> {
289
- const rows = await this.db
290
- .selectFrom('pikkuUserRole')
291
- .select('role')
292
- .where('userId', '=', userId)
293
- .execute()
294
-
295
- return rows.map((row) => row.role)
296
- }
297
-
298
- async addScopeToUser(
299
- userId: string,
300
- scope: string,
301
- grantedBy?: string
302
- ): Promise<void> {
303
- await this.db
304
- .insertInto('pikkuUserScope')
305
- .values({ userId, scope, grantedBy: grantedBy ?? null })
306
- .onConflict((oc) => oc.columns(['userId', 'scope']).doNothing())
307
- .execute()
308
- }
309
-
310
- async removeScopeFromUser(userId: string, scope: string): Promise<void> {
311
- await this.db
312
- .deleteFrom('pikkuUserScope')
313
- .where('userId', '=', userId)
314
- .where('scope', '=', scope)
315
- .execute()
316
- }
317
-
318
- async listUserScopes(userId: string): Promise<string[]> {
319
- const rows = await this.db
320
- .selectFrom('pikkuUserScope')
321
- .select('scope')
322
- .where('userId', '=', userId)
323
- .execute()
324
-
325
- return rows.map((row) => row.scope)
326
- }
327
-
328
- /**
329
- * Scopes marked undeclared by the last sync, with the roles that would lose
330
- * them. Powers `pikku scopes audit`.
331
- */
332
- async findStaleScopes(): Promise<Array<{ scope: string; roles: string[] }>> {
333
- const stale = await this.db
334
- .selectFrom('pikkuScopes')
335
- .leftJoin('pikkuRoleScopes', 'pikkuRoleScopes.scope', 'pikkuScopes.name')
336
- .select(['pikkuScopes.name as scope', 'pikkuRoleScopes.role as role'])
337
- .where('pikkuScopes.declared', '=', false)
338
- .execute()
339
-
340
- const byScope = new Map<string, string[]>()
341
- for (const row of stale) {
342
- const roles = byScope.get(row.scope) ?? []
343
- if (row.role) {
344
- roles.push(row.role)
345
- }
346
- byScope.set(row.scope, roles)
347
- }
348
-
349
- return [...byScope.entries()].map(([scope, roles]) => ({ scope, roles }))
350
- }
351
-
352
- /**
353
- * Removes undeclared scopes, cascading them out of every role that holds
354
- * them. This revokes access, so it is never run implicitly.
355
- */
356
- async pruneScopes(): Promise<string[]> {
357
- const stale = await this.findStaleScopes()
358
- if (stale.length === 0) {
359
- return []
360
- }
361
-
362
- const names = stale.map((s) => s.scope)
363
- const deleted = await this.db
364
- .deleteFrom('pikkuScopes')
365
- .where('name', 'in', names)
366
- .where('declared', '=', false)
367
- .returning('name')
368
- .execute()
369
-
370
- return deleted.map((r) => r.name)
371
- }
372
-
373
- /**
374
- * System roles the last sync found no declaration for, with how many people
375
- * still hold each. Powers `pikku roles audit`.
376
- *
377
- * The count is the whole point: "this role is gone from the code" and "this
378
- * role is gone from the code and 40 people are standing on it" call for
379
- * different decisions, and the number is what makes pruning a choice rather
380
- * than a formality.
381
- */
382
- async findStaleSystemRoles(): Promise<
383
- Array<{ role: string; users: number }>
384
- > {
385
- const stale = await this.db
386
- .selectFrom('pikkuRoles')
387
- .leftJoin('pikkuUserRole', 'pikkuUserRole.role', 'pikkuRoles.name')
388
- .select(['pikkuRoles.name as role', 'pikkuUserRole.userId as userId'])
389
- .where('pikkuRoles.system', '=', true)
390
- .where('pikkuRoles.declared', '=', false)
391
- .execute()
392
-
393
- const byRole = new Map<string, number>()
394
- for (const row of stale) {
395
- byRole.set(row.role, (byRole.get(row.role) ?? 0) + (row.userId ? 1 : 0))
396
- }
397
-
398
- return [...byRole.entries()].map(([role, users]) => ({ role, users }))
399
- }
400
-
401
- /**
402
- * Removes undeclared system roles, cascading them out of every user grant
403
- * that holds them. This revokes access, so it is never run implicitly.
404
- */
405
- async pruneSystemRoles(): Promise<string[]> {
406
- const stale = await this.findStaleSystemRoles()
407
- if (stale.length === 0) {
408
- return []
409
- }
410
-
411
- const deleted = await this.db
412
- .deleteFrom('pikkuRoles')
413
- .where(
414
- 'name',
415
- 'in',
416
- stale.map((s) => s.role)
417
- )
418
- .where('system', '=', true)
419
- .where('declared', '=', false)
420
- .returning('name')
421
- .execute()
422
-
423
- return deleted.map((r) => r.name)
424
- }
425
- }
@@ -1,242 +0,0 @@
1
- import {
2
- deriveKEK,
3
- envelopeDecrypt,
4
- envelopeEncrypt,
5
- envelopeRewrap,
6
- generateKEKSalt,
7
- } from '@pikku/core/crypto-utils'
8
- import { createSecretValue, isSecretValue } from '@pikku/core/classification'
9
- import type { SecretService, SecretValues } from '@pikku/core/services'
10
- import type { SecretValue } from '@pikku/core/classification'
11
- import type { Kysely } from 'kysely'
12
- import type { KyselyPikkuDB } from './kysely-tables.js'
13
- import { requirePikkuSchema } from './schema/index.js'
14
- import { secretSchema } from './schema/secret.schema.js'
15
-
16
- export interface KyselySecretServiceConfig {
17
- key: string
18
- keyVersion?: number
19
- previousKey?: string
20
- audit?: boolean
21
- auditReads?: boolean
22
- }
23
-
24
- export class KyselySecretService implements SecretService {
25
- private initialized = false
26
- private kekSalts = new Map<number, string>()
27
- private keks = new Map<number, CryptoKey>()
28
- private key: string
29
- private keyVersion: number
30
- private previousKey?: string
31
- private audit: boolean
32
- private auditReads: boolean
33
-
34
- constructor(
35
- private db: Kysely<KyselyPikkuDB>,
36
- config: KyselySecretServiceConfig
37
- ) {
38
- this.key = config.key
39
- this.keyVersion = config.keyVersion ?? 1
40
- this.previousKey = config.previousKey
41
- this.audit = config.audit ?? false
42
- this.auditReads = config.auditReads ?? false
43
- }
44
-
45
- public async init(): Promise<void> {
46
- if (this.initialized) return
47
- await requirePikkuSchema(this.db, secretSchema)
48
- this.initialized = true
49
- }
50
-
51
- private async logAudit(
52
- secretKey: string,
53
- action: 'read' | 'write' | 'delete' | 'rotate'
54
- ): Promise<void> {
55
- if (!this.audit) return
56
- if (action === 'read' && !this.auditReads) return
57
-
58
- await this.db
59
- .insertInto('secretsAudit')
60
- .values({
61
- id: crypto.randomUUID(),
62
- secretKey: secretKey,
63
- action,
64
- performedAt: new Date().toISOString() as unknown as Date,
65
- })
66
- .execute()
67
- }
68
-
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('secretKekSalts')
75
- .select('salt')
76
- .where('keyVersion', '=', version)
77
- .executeTakeFirst()
78
-
79
- let salt = existing?.salt
80
- if (!salt) {
81
- await this.db
82
- .insertInto('secretKekSalts')
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('secretKekSalts')
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
115
- }
116
-
117
- async getSecret<T = string>(key: string): Promise<SecretValue<T>> {
118
- const row = await this.db
119
- .selectFrom('secrets')
120
- .select(['ciphertext', 'wrappedDek', 'keyVersion'])
121
- .where('key', '=', key)
122
- .executeTakeFirst()
123
-
124
- if (!row) throw new Error(`Requested secret not found: ${key}`)
125
-
126
- const kek = await this.getKEK(row.keyVersion)
127
- const result = await envelopeDecrypt<T>(kek, row.ciphertext, row.wrappedDek)
128
- await this.logAudit(key, 'read')
129
- return createSecretValue(result)
130
- }
131
-
132
- async hasSecret(key: string): Promise<boolean> {
133
- const row = await this.db
134
- .selectFrom('secrets')
135
- .select('key')
136
- .where('key', '=', key)
137
- .executeTakeFirst()
138
- return !!row
139
- }
140
-
141
- async setSecret(key: string, value: unknown): Promise<void> {
142
- // Encrypting the wrapper would store its redaction, not the secret —
143
- // writing a secret back to the vault is exactly what this method is for.
144
- const { ciphertext, wrappedDEK } = await envelopeEncrypt(
145
- await this.getKEK(this.keyVersion),
146
- isSecretValue(value) ? value.reveal() : value
147
- )
148
- const now = new Date().toISOString()
149
-
150
- await this.db
151
- .insertInto('secrets')
152
- .values({
153
- key,
154
- ciphertext,
155
- wrappedDek: wrappedDEK,
156
- keyVersion: this.keyVersion,
157
- createdAt: now as unknown as Date,
158
- updatedAt: now as unknown as Date,
159
- })
160
- .onConflict((oc) =>
161
- oc.column('key').doUpdateSet({
162
- ciphertext,
163
- wrappedDek: wrappedDEK,
164
- keyVersion: this.keyVersion,
165
- updatedAt: now as unknown as Date,
166
- })
167
- )
168
- .execute()
169
-
170
- await this.logAudit(key, 'write')
171
- }
172
-
173
- async deleteSecret(key: string): Promise<void> {
174
- await this.db.deleteFrom('secrets').where('key', '=', key).execute()
175
- await this.logAudit(key, 'delete')
176
- }
177
-
178
- async getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(
179
- keys: (keyof T & string)[]
180
- ): Promise<Partial<SecretValues<T>>> {
181
- const rows = await this.db
182
- .selectFrom('secrets')
183
- .select(['key', 'ciphertext', 'wrappedDek', 'keyVersion'])
184
- .where('key', 'in', keys)
185
- .execute()
186
-
187
- const out: Record<string, unknown> = {}
188
- for (const row of rows) {
189
- try {
190
- const kek = await this.getKEK(row.keyVersion)
191
- out[row.key] = createSecretValue(
192
- await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek)
193
- )
194
- } catch (cause) {
195
- // A stored secret that fails to decrypt is never expected — it means the
196
- // KEK does not match what the value was wrapped under (mismatched
197
- // PIKKU_SECRET_KEK, or a missing previousKey for an older key_version).
198
- // Swallowing it produces an undefined secret and an opaque downstream
199
- // failure (e.g. an auth "server configuration" 500). Fail loud, naming
200
- // the key and key_version so the misconfiguration is diagnosable.
201
- throw new Error(
202
- `Failed to decrypt secret "${row.key}" (key_version ${row.keyVersion}): ` +
203
- `the configured KEK does not match the key it was wrapped under`,
204
- { cause }
205
- )
206
- }
207
- }
208
- return out as Partial<SecretValues<T>>
209
- }
210
-
211
- async rotateKEK(): Promise<number> {
212
- if (!this.previousKey) {
213
- throw new Error('No previousKey configured — nothing to rotate from')
214
- }
215
-
216
- const rows = await this.db
217
- .selectFrom('secrets')
218
- .select(['key', 'wrappedDek'])
219
- .where('keyVersion', '<', this.keyVersion)
220
- .execute()
221
-
222
- const oldKEK = await this.getKEK(this.keyVersion - 1)
223
- const newKEK = await this.getKEK(this.keyVersion)
224
-
225
- for (const row of rows) {
226
- const newWrappedDEK = await envelopeRewrap(oldKEK, newKEK, row.wrappedDek)
227
- await this.db
228
- .updateTable('secrets')
229
- .set({
230
- wrappedDek: newWrappedDEK,
231
- keyVersion: this.keyVersion,
232
- updatedAt: new Date().toISOString() as unknown as Date,
233
- })
234
- .where('key', '=', row.key)
235
- .execute()
236
-
237
- await this.logAudit(row.key, 'rotate')
238
- }
239
-
240
- return rows.length
241
- }
242
- }