@pikku/redis 0.12.20 → 0.12.23

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,171 +0,0 @@
1
- import { buildRemoteHeaders } from '@pikku/core/remote'
2
- import type {
3
- DeploymentService,
4
- DeploymentServiceConfig,
5
- DeploymentConfig,
6
- } from '@pikku/core/services'
7
- import type { JWTService } from '@pikku/core/services'
8
- import type { SecretService } from '@pikku/core/services'
9
- import { getAllFunctionNames } from '@pikku/core/function'
10
- import { Redis, type RedisOptions } from 'ioredis'
11
-
12
- export class RedisDeploymentService implements DeploymentService {
13
- private redis: Redis
14
- private keyPrefix: string
15
- private ownsConnection: boolean
16
- private heartbeatTimer?: ReturnType<typeof setInterval>
17
- private deploymentConfig?: DeploymentConfig
18
- private heartbeatInterval: number
19
- private heartbeatTtl: number
20
-
21
- constructor(
22
- config: DeploymentServiceConfig,
23
- connectionOrConfig: Redis | RedisOptions | string,
24
- keyPrefix = 'pikku',
25
- private jwt?: JWTService,
26
- private secrets?: SecretService
27
- ) {
28
- this.heartbeatInterval = config.heartbeatInterval ?? 10000
29
- this.heartbeatTtl = config.heartbeatTtl ?? 30000
30
- this.keyPrefix = keyPrefix
31
-
32
- if (connectionOrConfig instanceof Redis) {
33
- this.redis = connectionOrConfig
34
- this.ownsConnection = false
35
- } else if (typeof connectionOrConfig === 'string') {
36
- this.redis = new Redis(connectionOrConfig)
37
- this.ownsConnection = true
38
- } else {
39
- this.redis = new Redis(connectionOrConfig)
40
- this.ownsConnection = true
41
- }
42
- }
43
-
44
- private deploymentKey(deploymentId: string): string {
45
- return `${this.keyPrefix}:deployment:${deploymentId}`
46
- }
47
-
48
- private functionsIndexKey(funcName: string): string {
49
- return `${this.keyPrefix}:fn:${funcName}`
50
- }
51
-
52
- public async init(): Promise<void> {}
53
-
54
- async start(config: DeploymentConfig): Promise<void> {
55
- const functions = config.functions ?? getAllFunctionNames()
56
- this.deploymentConfig = { ...config, functions }
57
- const ttlSeconds = Math.ceil(this.heartbeatTtl / 1000)
58
- const expiryScore = Date.now() + this.heartbeatTtl
59
-
60
- const pipeline = this.redis.pipeline()
61
-
62
- pipeline.hset(this.deploymentKey(config.deploymentId), {
63
- endpoint: config.endpoint,
64
- functions: JSON.stringify(functions),
65
- })
66
- pipeline.expire(this.deploymentKey(config.deploymentId), ttlSeconds)
67
-
68
- for (const fn of functions) {
69
- pipeline.zadd(
70
- this.functionsIndexKey(fn),
71
- expiryScore,
72
- config.deploymentId
73
- )
74
- }
75
-
76
- await pipeline.exec()
77
-
78
- this.heartbeatTimer = setInterval(
79
- () => this.sendHeartbeat(),
80
- this.heartbeatInterval
81
- )
82
- }
83
-
84
- async stop(): Promise<void> {
85
- if (this.heartbeatTimer) {
86
- clearInterval(this.heartbeatTimer)
87
- this.heartbeatTimer = undefined
88
- }
89
-
90
- if (this.deploymentConfig) {
91
- const { deploymentId, functions } = this.deploymentConfig
92
- const pipeline = this.redis.pipeline()
93
-
94
- pipeline.del(this.deploymentKey(deploymentId))
95
- for (const fn of functions!) {
96
- pipeline.zrem(this.functionsIndexKey(fn), deploymentId)
97
- }
98
-
99
- await pipeline.exec()
100
- }
101
-
102
- if (this.ownsConnection) {
103
- await this.redis.quit()
104
- }
105
- }
106
-
107
- async invoke(
108
- funcName: string,
109
- data: unknown,
110
- session?: unknown,
111
- traceId?: string
112
- ): Promise<unknown> {
113
- const headers = await buildRemoteHeaders(
114
- this.jwt,
115
- this.secrets,
116
- funcName,
117
- session,
118
- traceId
119
- )
120
- const indexKey = this.functionsIndexKey(funcName)
121
- const now = Date.now()
122
-
123
- await this.redis.zremrangebyscore(indexKey, '-inf', now)
124
- const deploymentIds = await this.redis.zrangebyscore(indexKey, now, '+inf')
125
-
126
- let endpoint: string | null = null
127
- for (const deploymentId of deploymentIds) {
128
- endpoint = await this.redis.hget(
129
- this.deploymentKey(deploymentId),
130
- 'endpoint'
131
- )
132
- if (endpoint) break
133
- }
134
-
135
- if (!endpoint) {
136
- throw new Error(`No deployment found for function '${funcName}'`)
137
- }
138
-
139
- const url = `${endpoint}/remote/rpc/${encodeURIComponent(funcName)}`
140
- const response = await fetch(url, {
141
- method: 'POST',
142
- headers,
143
- body: JSON.stringify({ data }),
144
- })
145
- if (!response.ok) {
146
- throw new Error(
147
- `Remote RPC call to '${funcName}' failed: ${response.status}`
148
- )
149
- }
150
- return response.json()
151
- }
152
-
153
- private async sendHeartbeat(): Promise<void> {
154
- if (!this.deploymentConfig) return
155
-
156
- const { deploymentId, functions } = this.deploymentConfig
157
- const ttlSeconds = Math.ceil(this.heartbeatTtl / 1000)
158
- const expiryScore = Date.now() + this.heartbeatTtl
159
-
160
- try {
161
- const pipeline = this.redis.pipeline()
162
- pipeline.expire(this.deploymentKey(deploymentId), ttlSeconds)
163
- for (const fn of functions!) {
164
- pipeline.zadd(this.functionsIndexKey(fn), expiryScore, deploymentId)
165
- }
166
- await pipeline.exec()
167
- } catch {
168
- // Heartbeat failed, will retry on next interval
169
- }
170
- }
171
- }
@@ -1,104 +0,0 @@
1
- import type { EventHubStore } from '@pikku/core/channel'
2
- import { Redis, type RedisOptions } from 'ioredis'
3
-
4
- /**
5
- * Redis-based implementation of EventHubStore
6
- *
7
- * Manages pub/sub topic subscriptions for channels in Redis.
8
- *
9
- * @example
10
- * ```typescript
11
- * const redis = new Redis({ host: 'localhost', port: 6379 })
12
- * const store = new RedisEventHubStore(redis, 'myapp')
13
- * await store.subscribe('user.123', channelId)
14
- * ```
15
- */
16
- export class RedisEventHubStore implements EventHubStore {
17
- private redis: Redis
18
- private keyPrefix: string
19
- private ownsConnection: boolean
20
-
21
- /**
22
- * @param connectionOrConfig - ioredis Redis instance or RedisOptions config
23
- * @param keyPrefix - Redis key prefix (default: 'pikku')
24
- */
25
- constructor(
26
- connectionOrConfig: Redis | RedisOptions | string,
27
- keyPrefix = 'pikku'
28
- ) {
29
- this.keyPrefix = keyPrefix
30
-
31
- // Check if it's a Redis instance or config options
32
- if (connectionOrConfig instanceof Redis) {
33
- this.redis = connectionOrConfig
34
- this.ownsConnection = false
35
- } else if (typeof connectionOrConfig === 'string') {
36
- // It's a connection string
37
- this.redis = new Redis(connectionOrConfig)
38
- this.ownsConnection = true
39
- } else {
40
- // It's a config object
41
- this.redis = new Redis(connectionOrConfig)
42
- this.ownsConnection = true
43
- }
44
- }
45
-
46
- /**
47
- * Initialize the store (no-op for Redis, always ready)
48
- */
49
- public async init(): Promise<void> {
50
- // Redis doesn't require schema initialization
51
- await this.redis.ping()
52
- }
53
-
54
- private topicKey(topic: string): string {
55
- return `${this.keyPrefix}:topic:${topic}`
56
- }
57
-
58
- private channelSubsKey(channelId: string): string {
59
- return `${this.keyPrefix}:subs:${channelId}`
60
- }
61
-
62
- public async getChannelIdsForTopic(topic: string): Promise<string[]> {
63
- const key = this.topicKey(topic)
64
- return await this.redis.smembers(key)
65
- }
66
-
67
- public async subscribe(topic: string, channelId: string): Promise<boolean> {
68
- const topicKey = this.topicKey(topic)
69
- const subsKey = this.channelSubsKey(channelId)
70
-
71
- // Add channelId to topic set and topic to channel's subscription set
72
- const pipeline = this.redis.pipeline()
73
- pipeline.sadd(topicKey, channelId)
74
- pipeline.sadd(subsKey, topic)
75
-
76
- await pipeline.exec()
77
-
78
- return true
79
- }
80
-
81
- public async unsubscribe(topic: string, channelId: string): Promise<boolean> {
82
- const topicKey = this.topicKey(topic)
83
- const subsKey = this.channelSubsKey(channelId)
84
-
85
- // Remove channelId from topic set and topic from channel's subscription set
86
- const pipeline = this.redis.pipeline()
87
- pipeline.srem(topicKey, channelId)
88
- pipeline.srem(subsKey, topic)
89
-
90
- const results = await pipeline.exec()
91
-
92
- // Check if the channel was actually in the topic set
93
- return (results![0]![1] as number) > 0
94
- }
95
-
96
- /**
97
- * Close the Redis connection if owned by this store
98
- */
99
- public async close(): Promise<void> {
100
- if (this.ownsConnection) {
101
- await this.redis.quit()
102
- }
103
- }
104
- }
@@ -1,122 +0,0 @@
1
- import { describe, test, before, after } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import Redis from 'ioredis-mock'
4
- import { defineServiceTests } from '@pikku/core/testing'
5
- import { RedisSecretService } from './redis-secret-service.js'
6
-
7
- describe('RedisSecretService', () => {
8
- let redis: InstanceType<typeof Redis>
9
-
10
- before(() => {
11
- redis = new Redis()
12
- })
13
-
14
- after(async () => {
15
- redis.disconnect()
16
- })
17
-
18
- defineServiceTests({
19
- name: 'Redis',
20
- services: {
21
- secretService: async (config) =>
22
- new RedisSecretService(redis as any, config),
23
- },
24
- })
25
-
26
- test('custom keyPrefix isolates secrets', async () => {
27
- const prefixRedis = new Redis()
28
- const kek = 'test-key-encryption-key-32chars!'
29
- const s1 = new RedisSecretService(prefixRedis as any, {
30
- key: kek,
31
- keyPrefix: 'app1',
32
- })
33
- const s2 = new RedisSecretService(prefixRedis as any, {
34
- key: kek,
35
- keyPrefix: 'app2',
36
- })
37
-
38
- await s1.setSecret('shared-name', { from: 'app1' })
39
- await s2.setSecret('shared-name', { from: 'app2' })
40
-
41
- const r1 = await s1.getSecret<{ from: string }>('shared-name')
42
- const r2 = await s2.getSecret<{ from: string }>('shared-name')
43
-
44
- assert.deepEqual(r1.reveal(), { from: 'app1' })
45
- assert.deepEqual(r2.reveal(), { from: 'app2' })
46
- prefixRedis.disconnect()
47
- })
48
-
49
- describe('decryption failures', () => {
50
- const kek = 'test-key-encryption-key-32chars!'
51
- const otherKek = 'a-totally-different-kek-32chars!'
52
-
53
- test('getSecrets throws naming a secret it cannot decrypt', async () => {
54
- const writer = new RedisSecretService(redis as any, { key: kek })
55
- await writer.setSecret('undecryptable', { token: 'abc' })
56
-
57
- const reader = new RedisSecretService(redis as any, { key: otherKek })
58
-
59
- await assert.rejects(
60
- () => reader.getSecrets(['undecryptable']),
61
- (error: Error) => {
62
- assert.equal(
63
- error.message,
64
- 'Failed to decrypt secret "undecryptable" (key_version 1): ' +
65
- 'the configured KEK does not match the key it was wrapped under'
66
- )
67
- assert.ok(error.cause, 'the underlying crypto failure is preserved')
68
- return true
69
- }
70
- )
71
- })
72
-
73
- test('getSecrets throws when no KEK is available for a stored key_version', async () => {
74
- const writer = new RedisSecretService(redis as any, {
75
- key: kek,
76
- keyVersion: 7,
77
- })
78
- await writer.setSecret('old-version', 'value')
79
-
80
- const reader = new RedisSecretService(redis as any, {
81
- key: kek,
82
- keyVersion: 8,
83
- })
84
-
85
- await assert.rejects(
86
- () => reader.getSecrets(['old-version']),
87
- (error: Error) => {
88
- assert.equal(
89
- error.message,
90
- 'Failed to decrypt secret "old-version" (key_version 7): ' +
91
- 'the configured KEK does not match the key it was wrapped under'
92
- )
93
- return true
94
- }
95
- )
96
- })
97
-
98
- test('getSecrets returns every secret when they all decrypt', async () => {
99
- const service = new RedisSecretService(redis as any, { key: kek })
100
- await service.setSecret('batch-a', { v: 1 })
101
- await service.setSecret('batch-b', 'two')
102
-
103
- const out = await service.getSecrets<{
104
- 'batch-a': { v: number }
105
- 'batch-b': string
106
- }>(['batch-a', 'batch-b'])
107
-
108
- assert.deepEqual(out['batch-a']!.reveal(), { v: 1 })
109
- assert.equal(out['batch-b']!.reveal(), 'two')
110
- })
111
-
112
- test('getSecrets omits keys that were never stored', async () => {
113
- const service = new RedisSecretService(redis as any, { key: kek })
114
- await service.setSecret('present', 'here')
115
-
116
- const out = await service.getSecrets(['present', 'never-stored'])
117
-
118
- assert.deepEqual(Object.keys(out), ['present'])
119
- assert.equal(out.present!.reveal(), 'here')
120
- })
121
- })
122
- })
@@ -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
- }