@pikku/redis 0.12.20 → 0.12.21

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.
@@ -1,210 +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 { Redis, type RedisOptions } from 'ioredis'
12
-
13
- export interface RedisSecretServiceConfig {
14
- key: string
15
- keyVersion?: number
16
- previousKey?: string
17
- keyPrefix?: string
18
- }
19
-
20
- export class RedisSecretService implements SecretService {
21
- private redis: Redis
22
- private ownsConnection: boolean
23
- private key: string
24
- private keyVersion: number
25
- private previousKey?: string
26
- private keyPrefix: string
27
- private kekSalts = new Map<number, string>()
28
- private keks = new Map<number, CryptoKey>()
29
-
30
- constructor(
31
- connectionOrConfig: Redis | RedisOptions | string,
32
- config: RedisSecretServiceConfig
33
- ) {
34
- if (
35
- typeof connectionOrConfig === 'object' &&
36
- 'hgetall' in connectionOrConfig &&
37
- 'hset' in connectionOrConfig
38
- ) {
39
- this.redis = connectionOrConfig as Redis
40
- this.ownsConnection = false
41
- } else if (typeof connectionOrConfig === 'string') {
42
- this.redis = new Redis(connectionOrConfig)
43
- this.ownsConnection = true
44
- } else {
45
- this.redis = new Redis(connectionOrConfig)
46
- this.ownsConnection = true
47
- }
48
-
49
- this.key = config.key
50
- this.keyVersion = config.keyVersion ?? 1
51
- this.previousKey = config.previousKey
52
- this.keyPrefix = config.keyPrefix ?? 'pikku'
53
- }
54
-
55
- private secretKey(key: string): string {
56
- return `${this.keyPrefix}:secret:${key}`
57
- }
58
-
59
- private saltKey(): string {
60
- return `${this.keyPrefix}:kek-salt`
61
- }
62
-
63
- private async getKEKSalt(version: number): Promise<string> {
64
- const cached = this.kekSalts.get(version)
65
- if (cached) return cached
66
-
67
- const field = String(version)
68
- let salt = await this.redis.hget(this.saltKey(), field)
69
- if (!salt) {
70
- await this.redis.hsetnx(this.saltKey(), field, generateKEKSalt())
71
- salt = await this.redis.hget(this.saltKey(), field)
72
- if (!salt) {
73
- throw new Error(
74
- `Failed to persist a KEK salt for key_version ${version}`
75
- )
76
- }
77
- }
78
-
79
- this.kekSalts.set(version, salt)
80
- return salt
81
- }
82
-
83
- private async getKEK(version: number): Promise<CryptoKey> {
84
- const cached = this.keks.get(version)
85
- if (cached) return cached
86
-
87
- const passphrase = version === this.keyVersion ? this.key : this.previousKey
88
- if (!passphrase) {
89
- throw new Error(`No KEK available for key_version ${version}`)
90
- }
91
-
92
- const kek = await deriveKEK(passphrase, await this.getKEKSalt(version))
93
- this.keks.set(version, kek)
94
- return kek
95
- }
96
-
97
- async getSecret<T = string>(key: string): Promise<SecretValue<T>> {
98
- const data = await this.redis.hgetall(this.secretKey(key))
99
- if (!data.ciphertext) throw new Error(`Requested secret not found: ${key}`)
100
-
101
- const kek = await this.getKEK(Number(data.key_version))
102
- return createSecretValue(
103
- await envelopeDecrypt<T>(kek, data.ciphertext!, data.wrapped_dek!)
104
- )
105
- }
106
-
107
- async hasSecret(key: string): Promise<boolean> {
108
- const exists = await this.redis.exists(this.secretKey(key))
109
- return exists === 1
110
- }
111
-
112
- async setSecret(key: string, value: unknown): Promise<void> {
113
- // Encrypting the wrapper would store its redaction, not the secret —
114
- // writing a secret back to the vault is exactly what this method is for.
115
- const { ciphertext, wrappedDEK } = await envelopeEncrypt(
116
- await this.getKEK(this.keyVersion),
117
- isSecretValue(value) ? value.reveal() : value
118
- )
119
-
120
- await this.redis.hset(this.secretKey(key), {
121
- ciphertext,
122
- wrapped_dek: wrappedDEK,
123
- key_version: this.keyVersion.toString(),
124
- })
125
- }
126
-
127
- async deleteSecret(key: string): Promise<void> {
128
- await this.redis.del(this.secretKey(key))
129
- }
130
-
131
- async getSecrets<T extends Record<string, unknown> = Record<string, unknown>>(
132
- keys: (keyof T & string)[]
133
- ): Promise<Partial<SecretValues<T>>> {
134
- const rows = await Promise.all(
135
- keys.map(async (key) => ({
136
- key,
137
- data: await this.redis.hgetall(this.secretKey(key)),
138
- }))
139
- )
140
-
141
- const out: Record<string, unknown> = {}
142
- for (const { key, data } of rows) {
143
- if (!data.ciphertext) continue
144
-
145
- const keyVersion = Number(data.key_version)
146
- try {
147
- const kek = await this.getKEK(keyVersion)
148
- out[key] = createSecretValue(
149
- await envelopeDecrypt(kek, data.ciphertext, data.wrapped_dek!)
150
- )
151
- } catch (cause) {
152
- throw new Error(
153
- `Failed to decrypt secret "${key}" (key_version ${keyVersion}): ` +
154
- `the configured KEK does not match the key it was wrapped under`,
155
- { cause }
156
- )
157
- }
158
- }
159
- return out as Partial<SecretValues<T>>
160
- }
161
-
162
- async rotateKEK(): Promise<number> {
163
- if (!this.previousKey) {
164
- throw new Error('No previousKey configured — nothing to rotate from')
165
- }
166
-
167
- const oldKEK = await this.getKEK(this.keyVersion - 1)
168
- const newKEK = await this.getKEK(this.keyVersion)
169
-
170
- const pattern = `${this.keyPrefix}:secret:*`
171
- let cursor = '0'
172
- let count = 0
173
-
174
- do {
175
- const [nextCursor, keys] = await this.redis.scan(
176
- cursor,
177
- 'MATCH',
178
- pattern,
179
- 'COUNT',
180
- 100
181
- )
182
- cursor = nextCursor
183
-
184
- for (const redisKey of keys) {
185
- const data = await this.redis.hgetall(redisKey)
186
- const version = Number(data.key_version)
187
- if (version >= this.keyVersion) continue
188
-
189
- const newWrappedDEK = await envelopeRewrap(
190
- oldKEK,
191
- newKEK,
192
- data.wrapped_dek!
193
- )
194
- await this.redis.hset(redisKey, {
195
- wrapped_dek: newWrappedDEK,
196
- key_version: this.keyVersion.toString(),
197
- })
198
- count++
199
- }
200
- } while (cursor !== '0')
201
-
202
- return count
203
- }
204
-
205
- async close(): Promise<void> {
206
- if (this.ownsConnection) {
207
- await this.redis.quit()
208
- }
209
- }
210
- }
@@ -1,71 +0,0 @@
1
- import type { SessionStore } from '@pikku/core/services'
2
- import type { CoreUserSession } from '@pikku/core/types'
3
- import { Redis, type RedisOptions } from 'ioredis'
4
-
5
- export class RedisSessionStore implements SessionStore {
6
- private redis: Redis
7
- private keyPrefix: string
8
- private ownsConnection: boolean
9
- private ttlSeconds?: number
10
-
11
- /**
12
- * @param connectionOrConfig - ioredis Redis instance or RedisOptions config
13
- * @param keyPrefix - Redis key prefix (default: 'pikku')
14
- * @param ttlSeconds - Optional TTL in seconds for session entries
15
- */
16
- constructor(
17
- connectionOrConfig: Redis | RedisOptions | string,
18
- keyPrefix = 'pikku',
19
- ttlSeconds?: number
20
- ) {
21
- this.keyPrefix = keyPrefix
22
- this.ttlSeconds = ttlSeconds
23
-
24
- if (connectionOrConfig instanceof Redis) {
25
- this.redis = connectionOrConfig
26
- this.ownsConnection = false
27
- } else if (typeof connectionOrConfig === 'string') {
28
- this.redis = new Redis(connectionOrConfig)
29
- this.ownsConnection = true
30
- } else {
31
- this.redis = new Redis(connectionOrConfig)
32
- this.ownsConnection = true
33
- }
34
- }
35
-
36
- public async init(): Promise<void> {
37
- await this.redis.ping()
38
- }
39
-
40
- private sessionKey(pikkuUserId: string): string {
41
- return `${this.keyPrefix}:session:${pikkuUserId}`
42
- }
43
-
44
- async get(pikkuUserId: string): Promise<CoreUserSession | undefined> {
45
- const data = await this.redis.get(this.sessionKey(pikkuUserId))
46
- if (!data) {
47
- return undefined
48
- }
49
- return JSON.parse(data) as CoreUserSession
50
- }
51
-
52
- async set(pikkuUserId: string, session: CoreUserSession): Promise<void> {
53
- const key = this.sessionKey(pikkuUserId)
54
- const value = JSON.stringify(session)
55
- if (this.ttlSeconds) {
56
- await this.redis.set(key, value, 'EX', this.ttlSeconds)
57
- } else {
58
- await this.redis.set(key, value)
59
- }
60
- }
61
-
62
- async clear(pikkuUserId: string): Promise<void> {
63
- await this.redis.del(this.sessionKey(pikkuUserId))
64
- }
65
-
66
- public async close(): Promise<void> {
67
- if (this.ownsConnection) {
68
- await this.redis.quit()
69
- }
70
- }
71
- }
@@ -1,231 +0,0 @@
1
- import type {
2
- WorkflowRun,
3
- StepState,
4
- WorkflowStatus,
5
- WorkflowRunService,
6
- } from '@pikku/core/workflow'
7
- import type { Redis } from 'ioredis'
8
-
9
- export class RedisWorkflowRunService implements WorkflowRunService {
10
- constructor(
11
- private redis: Redis,
12
- private keyPrefix = 'workflows'
13
- ) {}
14
-
15
- private runKey(runId: string): string {
16
- return `${this.keyPrefix}:run:${runId}`
17
- }
18
-
19
- private stepKeyPattern(runId: string): string {
20
- return `${this.keyPrefix}:step:${runId}:*`
21
- }
22
-
23
- private stepHistoryKey(stepId: string): string {
24
- return `${this.keyPrefix}:step-history:${stepId}`
25
- }
26
-
27
- private versionKey(name: string, graphHash: string): string {
28
- return `${this.keyPrefix}:version:${name}:${graphHash}`
29
- }
30
-
31
- async listRuns(options?: {
32
- workflowName?: string
33
- status?: string
34
- limit?: number
35
- offset?: number
36
- }): Promise<WorkflowRun[]> {
37
- const { workflowName, status, limit = 50, offset = 0 } = options ?? {}
38
-
39
- const runKeys = await this.scanKeys(`${this.keyPrefix}:run:*`)
40
-
41
- const runs: WorkflowRun[] = []
42
- for (const key of runKeys) {
43
- const data = await this.redis.hgetall(key)
44
- if (!data.id) continue
45
-
46
- if (workflowName && data.workflow !== workflowName) continue
47
- if (status && data.status !== status) continue
48
-
49
- runs.push(this.mapRunData(data))
50
- }
51
-
52
- runs.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
53
-
54
- return runs.slice(offset, offset + limit)
55
- }
56
-
57
- async getRun(id: string): Promise<WorkflowRun | null> {
58
- const data = await this.redis.hgetall(this.runKey(id))
59
- if (!data.id) return null
60
- return this.mapRunData(data)
61
- }
62
-
63
- async getRunSteps(
64
- runId: string
65
- ): Promise<
66
- Array<StepState & { stepName: string; rpcName?: string; data?: any }>
67
- > {
68
- const stepKeys = await this.scanKeys(this.stepKeyPattern(runId))
69
-
70
- const steps: Array<
71
- StepState & { stepName: string; rpcName?: string; data?: any }
72
- > = []
73
-
74
- for (const key of stepKeys) {
75
- const data = await this.redis.hgetall(key)
76
- if (!data.stepId) continue
77
-
78
- steps.push({
79
- stepId: data.stepId,
80
- stepName: this.extractStepName(key, runId),
81
- rpcName: data.rpcName || undefined,
82
- data: data.data ? JSON.parse(data.data) : undefined,
83
- status: data.status as any,
84
- result: data.result ? JSON.parse(data.result) : undefined,
85
- error: data.error ? JSON.parse(data.error) : undefined,
86
- attemptCount: Number(data.attemptCount || 1),
87
- retries: data.retries ? Number(data.retries) : undefined,
88
- retryDelay: data.retryDelay || undefined,
89
- createdAt: new Date(Number(data.createdAt)),
90
- updatedAt: new Date(Number(data.updatedAt)),
91
- })
92
- }
93
-
94
- steps.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
95
-
96
- return steps
97
- }
98
-
99
- async getRunHistory(
100
- runId: string
101
- ): Promise<Array<StepState & { stepName: string }>> {
102
- const stepKeys = await this.scanKeys(this.stepKeyPattern(runId))
103
-
104
- const allHistoryEntries: Array<StepState & { stepName: string }> = []
105
-
106
- for (const key of stepKeys) {
107
- const data = await this.redis.hgetall(key)
108
- if (!data.stepId) continue
109
-
110
- const historyEntries = await this.redis.zrange(
111
- this.stepHistoryKey(data.stepId),
112
- 0,
113
- '-1'
114
- )
115
-
116
- for (const entryStr of historyEntries) {
117
- const entry = JSON.parse(entryStr)
118
- allHistoryEntries.push({
119
- stepId: entry.stepId,
120
- stepName: entry.stepName,
121
- status: entry.status,
122
- result: entry.result ? JSON.parse(entry.result) : undefined,
123
- error: entry.error ? JSON.parse(entry.error) : undefined,
124
- attemptCount: entry.attemptCount,
125
- retries: entry.retries,
126
- retryDelay: entry.retryDelay,
127
- createdAt: new Date(entry.createdAt),
128
- updatedAt: new Date(entry.createdAt),
129
- runningAt: entry.runningAt ? new Date(entry.runningAt) : undefined,
130
- scheduledAt: entry.scheduledAt
131
- ? new Date(entry.scheduledAt)
132
- : undefined,
133
- succeededAt: entry.succeededAt
134
- ? new Date(entry.succeededAt)
135
- : undefined,
136
- failedAt: entry.failedAt ? new Date(entry.failedAt) : undefined,
137
- })
138
- }
139
- }
140
-
141
- return allHistoryEntries.sort(
142
- (a, b) => a.createdAt.getTime() - b.createdAt.getTime()
143
- )
144
- }
145
-
146
- async getDistinctWorkflowNames(): Promise<string[]> {
147
- const runKeys = await this.scanKeys(`${this.keyPrefix}:run:*`)
148
-
149
- const names = new Set<string>()
150
- for (const key of runKeys) {
151
- const workflow = await this.redis.hget(key, 'workflow')
152
- if (workflow) names.add(workflow)
153
- }
154
-
155
- return [...names].sort()
156
- }
157
-
158
- async getWorkflowVersion(
159
- name: string,
160
- graphHash: string
161
- ): Promise<{ graph: any; source: string } | null> {
162
- const data = await this.redis.hgetall(this.versionKey(name, graphHash))
163
- if (!data.graph) return null
164
- return {
165
- graph: JSON.parse(data.graph),
166
- source: data.source!,
167
- }
168
- }
169
-
170
- async deleteRun(id: string): Promise<boolean> {
171
- const exists = await this.redis.exists(this.runKey(id))
172
- if (!exists) return false
173
-
174
- const stepKeys = await this.scanKeys(this.stepKeyPattern(id))
175
-
176
- const keysToDelete = [this.runKey(id)]
177
-
178
- for (const stepKey of stepKeys) {
179
- const stepId = await this.redis.hget(stepKey, 'stepId')
180
- keysToDelete.push(stepKey)
181
- if (stepId) {
182
- keysToDelete.push(this.stepHistoryKey(stepId))
183
- }
184
- }
185
-
186
- await this.redis.del(...keysToDelete)
187
- return true
188
- }
189
-
190
- private mapRunData(data: Record<string, string>): WorkflowRun {
191
- return {
192
- id: data.id!,
193
- workflow: data.workflow!,
194
- status: data.status! as WorkflowStatus,
195
- input: JSON.parse(data.input!),
196
- output: data.output ? JSON.parse(data.output) : undefined,
197
- error: data.error ? JSON.parse(data.error) : undefined,
198
- inline: data.inline === 'true' ? true : undefined,
199
- graphHash: data.graphHash || undefined,
200
- deterministic: data.deterministic === 'true' ? true : undefined,
201
- plannedSteps: data.plannedSteps
202
- ? JSON.parse(data.plannedSteps)
203
- : undefined,
204
- wire: data.wire ? JSON.parse(data.wire) : { type: 'unknown' },
205
- createdAt: new Date(Number(data.createdAt!)),
206
- updatedAt: new Date(Number(data.updatedAt!)),
207
- }
208
- }
209
-
210
- private extractStepName(key: string, runId: string): string {
211
- const prefix = `${this.keyPrefix}:step:${runId}:`
212
- return key.slice(prefix.length)
213
- }
214
-
215
- private async scanKeys(pattern: string): Promise<string[]> {
216
- const keys: string[] = []
217
- let cursor = '0'
218
- do {
219
- const [newCursor, foundKeys] = await this.redis.scan(
220
- cursor,
221
- 'MATCH',
222
- pattern,
223
- 'COUNT',
224
- 100
225
- )
226
- cursor = newCursor
227
- keys.push(...foundKeys)
228
- } while (cursor !== '0')
229
- return keys
230
- }
231
- }
@@ -1,115 +0,0 @@
1
- import { describe, test, beforeEach, afterEach } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import Redis from 'ioredis-mock'
4
-
5
- import { RedisWorkflowService } from './redis-workflow-service.js'
6
-
7
- const KEY_PREFIX = 'workflows'
8
-
9
- let ws: RedisWorkflowService
10
- let redis: any
11
-
12
- const newRun = () =>
13
- ws.createRun('flow', {}, false, 'hash', { type: 'test' } as any)
14
-
15
- beforeEach(() => {
16
- redis = new Redis()
17
- ws = new RedisWorkflowService(redis, KEY_PREFIX)
18
- })
19
-
20
- afterEach(() => {
21
- redis.disconnect()
22
- })
23
-
24
- describe('workflow run state in redis', () => {
25
- test('two branches setting different keys both survive', async () => {
26
- const runId = await newRun()
27
-
28
- await Promise.all([
29
- ws.updateRunState(runId, 'left', 'a'),
30
- ws.updateRunState(runId, 'right', 'b'),
31
- ])
32
-
33
- assert.deepEqual(
34
- await ws.getRunState(runId),
35
- { left: 'a', right: 'b' },
36
- 'a parallel branch overwrote the other branch’s state'
37
- )
38
- })
39
-
40
- test('many concurrent writers all land', async () => {
41
- const runId = await newRun()
42
- const keys = Array.from({ length: 20 }, (_, i) => `k${i}`)
43
-
44
- await Promise.all(keys.map((k, i) => ws.updateRunState(runId, k, i)))
45
-
46
- const state = await ws.getRunState(runId)
47
- assert.equal(Object.keys(state).length, keys.length)
48
- keys.forEach((k, i) => assert.equal(state[k], i))
49
- })
50
-
51
- test('an empty array stays an array when another key is written', async () => {
52
- const runId = await newRun()
53
-
54
- await ws.updateRunState(runId, 'items', [])
55
- await ws.updateRunState(runId, 'count', 0)
56
-
57
- const state = await ws.getRunState(runId)
58
- assert.ok(
59
- Array.isArray(state.items),
60
- `an empty array came back as ${JSON.stringify(state.items)}`
61
- )
62
- assert.equal(state.count, 0)
63
- })
64
-
65
- test('values keep their types, including null and nested objects', async () => {
66
- const runId = await newRun()
67
-
68
- await ws.updateRunState(runId, 'nothing', null)
69
- await ws.updateRunState(runId, 'nested', { a: [1, { b: true }] })
70
- await ws.updateRunState(runId, 'flag', false)
71
-
72
- assert.deepEqual(await ws.getRunState(runId), {
73
- nothing: null,
74
- nested: { a: [1, { b: true }] },
75
- flag: false,
76
- })
77
- })
78
-
79
- test('a later write to the same key replaces it', async () => {
80
- const runId = await newRun()
81
-
82
- await ws.updateRunState(runId, 'attempt', 1)
83
- await ws.updateRunState(runId, 'attempt', 2)
84
-
85
- assert.deepEqual(await ws.getRunState(runId), { attempt: 2 })
86
- })
87
-
88
- test('the state of a run that has none is empty', async () => {
89
- assert.deepEqual(await ws.getRunState(await newRun()), {})
90
- })
91
-
92
- test('state written before the per-key layout is still readable', async () => {
93
- const runId = await newRun()
94
- const redis = (ws as any).redis
95
- await redis.hset(
96
- (ws as any).runKey(runId),
97
- 'state',
98
- JSON.stringify({ legacy: 'kept', shared: 'old' })
99
- )
100
-
101
- assert.deepEqual(await ws.getRunState(runId), {
102
- legacy: 'kept',
103
- shared: 'old',
104
- })
105
-
106
- await ws.updateRunState(runId, 'shared', 'new')
107
- await ws.updateRunState(runId, 'fresh', 1)
108
-
109
- assert.deepEqual(
110
- await ws.getRunState(runId),
111
- { legacy: 'kept', shared: 'new', fresh: 1 },
112
- 'a run in flight across the deploy lost the state it already had'
113
- )
114
- })
115
- })