@pikku/mongodb 0.12.21 → 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,109 +0,0 @@
1
- import { ChannelStore } from '@pikku/core/channel'
2
- import type { Channel } from '@pikku/core/channel'
3
- import type { Db, Collection } from 'mongodb'
4
-
5
- interface ChannelDoc {
6
- _id: string
7
- channelName: string
8
- createdAt: Date
9
- openingData: any
10
- pikkuUserId: string | null
11
- state?: unknown
12
- lastWire: Date
13
- }
14
-
15
- interface ChannelSubscriptionDoc {
16
- channelId: string
17
- topic: string
18
- }
19
-
20
- export class MongoDBChannelStore extends ChannelStore {
21
- private initialized = false
22
- private channels!: Collection<ChannelDoc>
23
- private subscriptions!: Collection<ChannelSubscriptionDoc>
24
-
25
- constructor(private db: Db) {
26
- super()
27
- }
28
-
29
- public async init(): Promise<void> {
30
- if (this.initialized) return
31
-
32
- this.channels = this.db.collection<ChannelDoc>('channels')
33
- this.subscriptions = this.db.collection<ChannelSubscriptionDoc>(
34
- 'channel_subscriptions'
35
- )
36
-
37
- await this.subscriptions.createIndex(
38
- { channelId: 1, topic: 1 },
39
- { unique: true }
40
- )
41
- await this.subscriptions.createIndex({ topic: 1 })
42
-
43
- this.initialized = true
44
- }
45
-
46
- public async addChannel({
47
- channelId,
48
- channelName,
49
- openingData,
50
- }: Channel): Promise<void> {
51
- await this.channels.insertOne({
52
- _id: channelId,
53
- channelName,
54
- openingData: openingData || {},
55
- pikkuUserId: null,
56
- createdAt: new Date(),
57
- lastWire: new Date(),
58
- })
59
- }
60
-
61
- public async removeChannels(channelIds: string[]): Promise<void> {
62
- if (channelIds.length === 0) return
63
-
64
- await this.channels.deleteMany({ _id: { $in: channelIds } })
65
- await this.subscriptions.deleteMany({ channelId: { $in: channelIds } })
66
- }
67
-
68
- public async setPikkuUserId(
69
- channelId: string,
70
- pikkuUserId: string | null
71
- ): Promise<void> {
72
- await this.channels.updateOne({ _id: channelId }, { $set: { pikkuUserId } })
73
- }
74
-
75
- public async getChannel(
76
- channelId: string
77
- ): Promise<Channel & { pikkuUserId?: string }> {
78
- const row = await this.channels.findOne({ _id: channelId })
79
-
80
- if (!row) {
81
- throw new Error(`Channel not found: ${channelId}`)
82
- }
83
-
84
- return {
85
- channelId: row._id,
86
- channelName: row.channelName,
87
- openingData: row.openingData ?? {},
88
- pikkuUserId: row.pikkuUserId ?? undefined,
89
- }
90
- }
91
-
92
- public async setState(channelId: string, state: unknown): Promise<void> {
93
- await this.channels.updateOne({ _id: channelId }, { $set: { state } })
94
- }
95
-
96
- public async getState(channelId: string): Promise<unknown | undefined> {
97
- const row = await this.channels.findOne(
98
- { _id: channelId },
99
- { projection: { state: 1 } }
100
- )
101
- return row?.state ?? undefined
102
- }
103
-
104
- public async clearState(channelId: string): Promise<void> {
105
- await this.channels.updateOne({ _id: channelId }, { $unset: { state: '' } })
106
- }
107
-
108
- public async close(): Promise<void> {}
109
- }
@@ -1,136 +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 type { Db, Collection } from 'mongodb'
11
-
12
- interface DeploymentDoc {
13
- _id: string
14
- endpoint: string
15
- lastHeartbeat: Date
16
- createdAt: Date
17
- functions: string[]
18
- }
19
-
20
- export class MongoDBDeploymentService implements DeploymentService {
21
- private initialized = false
22
- private heartbeatTimer?: ReturnType<typeof setInterval>
23
- private deploymentConfig?: DeploymentConfig
24
- private heartbeatInterval: number
25
- private heartbeatTtl: number
26
- private deployments!: Collection<DeploymentDoc>
27
-
28
- constructor(
29
- config: DeploymentServiceConfig,
30
- private db: Db,
31
- private jwt?: JWTService,
32
- private secrets?: SecretService
33
- ) {
34
- this.heartbeatInterval = config.heartbeatInterval ?? 10000
35
- this.heartbeatTtl = config.heartbeatTtl ?? 30000
36
- }
37
-
38
- public async init(): Promise<void> {
39
- if (this.initialized) return
40
-
41
- this.deployments = this.db.collection<DeploymentDoc>('pikku_deployments')
42
-
43
- await this.deployments.createIndex({ lastHeartbeat: 1 })
44
- await this.deployments.createIndex({ functions: 1 })
45
-
46
- this.initialized = true
47
- }
48
-
49
- async start(config: DeploymentConfig): Promise<void> {
50
- const functions = config.functions ?? getAllFunctionNames()
51
- this.deploymentConfig = { ...config, functions }
52
- const now = new Date()
53
-
54
- await this.deployments.updateOne(
55
- { _id: config.deploymentId },
56
- {
57
- $set: {
58
- endpoint: config.endpoint,
59
- lastHeartbeat: now,
60
- functions,
61
- },
62
- $setOnInsert: {
63
- _id: config.deploymentId,
64
- createdAt: now,
65
- },
66
- },
67
- { upsert: true }
68
- )
69
-
70
- this.heartbeatTimer = setInterval(
71
- () => this.sendHeartbeat(),
72
- this.heartbeatInterval
73
- )
74
- }
75
-
76
- async stop(): Promise<void> {
77
- if (this.heartbeatTimer) {
78
- clearInterval(this.heartbeatTimer)
79
- this.heartbeatTimer = undefined
80
- }
81
-
82
- if (this.deploymentConfig) {
83
- await this.deployments.deleteOne({
84
- _id: this.deploymentConfig.deploymentId,
85
- })
86
- }
87
- }
88
-
89
- async invoke(
90
- funcName: string,
91
- data: unknown,
92
- session?: unknown,
93
- traceId?: string
94
- ): Promise<unknown> {
95
- const headers = await buildRemoteHeaders(
96
- this.jwt,
97
- this.secrets,
98
- funcName,
99
- session,
100
- traceId
101
- )
102
- const cutoff = new Date(Date.now() - this.heartbeatTtl)
103
- const result = await this.deployments.findOne(
104
- { functions: funcName, lastHeartbeat: { $gt: cutoff } },
105
- { sort: { lastHeartbeat: -1 } }
106
- )
107
- if (!result) {
108
- throw new Error(`No deployment found for function '${funcName}'`)
109
- }
110
- const url = `${result.endpoint}/remote/rpc/${encodeURIComponent(funcName)}`
111
- const response = await fetch(url, {
112
- method: 'POST',
113
- headers: { 'Content-Type': 'application/json', ...headers },
114
- body: JSON.stringify({ data }),
115
- })
116
- if (!response.ok) {
117
- throw new Error(
118
- `Remote RPC call to '${funcName}' failed: ${response.status}`
119
- )
120
- }
121
- return response.json()
122
- }
123
-
124
- private async sendHeartbeat(): Promise<void> {
125
- if (!this.deploymentConfig) return
126
-
127
- try {
128
- await this.deployments.updateOne(
129
- { _id: this.deploymentConfig.deploymentId },
130
- { $set: { lastHeartbeat: new Date() } }
131
- )
132
- } catch {
133
- // Heartbeat failed, will retry on next interval
134
- }
135
- }
136
- }
@@ -1,52 +0,0 @@
1
- import { EventHubStore } from '@pikku/core/channel'
2
- import type { Db, Collection } from 'mongodb'
3
-
4
- interface ChannelSubscriptionDoc {
5
- channelId: string
6
- topic: string
7
- }
8
-
9
- export class MongoDBEventHubStore extends EventHubStore {
10
- private initialized = false
11
- private subscriptions!: Collection<ChannelSubscriptionDoc>
12
-
13
- constructor(private db: Db) {
14
- super()
15
- }
16
-
17
- public async init(): Promise<void> {
18
- if (this.initialized) return
19
- this.subscriptions = this.db.collection<ChannelSubscriptionDoc>(
20
- 'channel_subscriptions'
21
- )
22
- this.initialized = true
23
- }
24
-
25
- public async getChannelIdsForTopic(topic: string): Promise<string[]> {
26
- const result = await this.subscriptions
27
- .find({ topic })
28
- .project({ channelId: 1 })
29
- .toArray()
30
- return result.map((row) => row.channelId)
31
- }
32
-
33
- public async subscribe(topic: string, channelId: string): Promise<boolean> {
34
- try {
35
- await this.subscriptions.updateOne(
36
- { channelId, topic },
37
- { $setOnInsert: { channelId, topic } },
38
- { upsert: true }
39
- )
40
- return true
41
- } catch {
42
- return false
43
- }
44
- }
45
-
46
- public async unsubscribe(topic: string, channelId: string): Promise<boolean> {
47
- const result = await this.subscriptions.deleteOne({ channelId, topic })
48
- return result.deletedCount > 0
49
- }
50
-
51
- public async close(): Promise<void> {}
52
- }
@@ -1,232 +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 { Db, Collection } from 'mongodb'
12
-
13
- export interface MongoDBSecretServiceConfig {
14
- key: string
15
- keyVersion?: number
16
- previousKey?: string
17
- audit?: boolean
18
- auditReads?: boolean
19
- }
20
-
21
- interface SecretDoc {
22
- _id: string
23
- ciphertext: string
24
- wrappedDek: string
25
- keyVersion: number
26
- createdAt: Date
27
- updatedAt: Date
28
- }
29
-
30
- interface KekSaltDoc {
31
- _id: number
32
- salt: string
33
- createdAt: Date
34
- }
35
-
36
- interface SecretAuditDoc {
37
- _id: string
38
- secretKey: string
39
- action: string
40
- performedAt: Date
41
- }
42
-
43
- export class MongoDBSecretService implements SecretService {
44
- private initialized = false
45
- private key: string
46
- private keyVersion: number
47
- private previousKey?: string
48
- private audit: boolean
49
- private auditReads: boolean
50
- private secrets!: Collection<SecretDoc>
51
- private secretsAudit!: Collection<SecretAuditDoc>
52
- private kekSaltDocs!: Collection<KekSaltDoc>
53
- private kekSalts = new Map<number, string>()
54
- private keks = new Map<number, CryptoKey>()
55
-
56
- constructor(
57
- private db: Db,
58
- config: MongoDBSecretServiceConfig
59
- ) {
60
- this.key = config.key
61
- this.keyVersion = config.keyVersion ?? 1
62
- this.previousKey = config.previousKey
63
- this.audit = config.audit ?? false
64
- this.auditReads = config.auditReads ?? false
65
- }
66
-
67
- public async init(): Promise<void> {
68
- if (this.initialized) return
69
-
70
- this.secrets = this.db.collection<SecretDoc>('secrets')
71
- this.secretsAudit = this.db.collection<SecretAuditDoc>('secrets_audit')
72
- this.kekSaltDocs = this.db.collection<KekSaltDoc>('secret_kek_salts')
73
-
74
- await this.secrets.createIndex({ _id: 1 })
75
-
76
- if (this.audit) {
77
- await this.secretsAudit.createIndex({ secretKey: 1 })
78
- }
79
-
80
- this.initialized = true
81
- }
82
-
83
- private async logAudit(
84
- secretKey: string,
85
- action: 'read' | 'write' | 'delete' | 'rotate'
86
- ): Promise<void> {
87
- if (!this.audit) return
88
- if (action === 'read' && !this.auditReads) return
89
-
90
- await this.secretsAudit.insertOne({
91
- _id: crypto.randomUUID(),
92
- secretKey,
93
- action,
94
- performedAt: new Date(),
95
- })
96
- }
97
-
98
- private async getKEKSalt(version: number): Promise<string> {
99
- const cached = this.kekSalts.get(version)
100
- if (cached) return cached
101
-
102
- const doc = await this.kekSaltDocs.findOneAndUpdate(
103
- { _id: version },
104
- { $setOnInsert: { salt: generateKEKSalt(), createdAt: new Date() } },
105
- { upsert: true, returnDocument: 'after' }
106
- )
107
- if (!doc?.salt) {
108
- throw new Error(`Failed to persist a KEK salt for key_version ${version}`)
109
- }
110
-
111
- this.kekSalts.set(version, doc.salt)
112
- return doc.salt
113
- }
114
-
115
- private async getKEK(version: number): Promise<CryptoKey> {
116
- const cached = this.keks.get(version)
117
- if (cached) return cached
118
-
119
- const passphrase = version === this.keyVersion ? this.key : this.previousKey
120
- if (!passphrase) {
121
- throw new Error(`No KEK available for key_version ${version}`)
122
- }
123
-
124
- const kek = await deriveKEK(passphrase, await this.getKEKSalt(version))
125
- this.keks.set(version, kek)
126
- return kek
127
- }
128
-
129
- async getSecret<T = string>(key: string): Promise<SecretValue<T>> {
130
- const row = await this.secrets.findOne({ _id: key })
131
- if (!row) throw new Error(`Requested secret not found: ${key}`)
132
-
133
- const kek = await this.getKEK(row.keyVersion)
134
- const result = await envelopeDecrypt<T>(kek, row.ciphertext, row.wrappedDek)
135
- await this.logAudit(key, 'read')
136
- return createSecretValue(result)
137
- }
138
-
139
- async hasSecret(key: string): Promise<boolean> {
140
- const count = await this.secrets.countDocuments({ _id: key }, { limit: 1 })
141
- return count > 0
142
- }
143
-
144
- async setSecret(key: string, value: unknown): Promise<void> {
145
- // Encrypting the wrapper would store its redaction, not the secret —
146
- // writing a secret back to the vault is exactly what this method is for.
147
- const { ciphertext, wrappedDEK } = await envelopeEncrypt(
148
- await this.getKEK(this.keyVersion),
149
- isSecretValue(value) ? value.reveal() : value
150
- )
151
- const now = new Date()
152
-
153
- await this.secrets.updateOne(
154
- { _id: key },
155
- {
156
- $set: {
157
- ciphertext,
158
- wrappedDek: wrappedDEK,
159
- keyVersion: this.keyVersion,
160
- updatedAt: now,
161
- },
162
- $setOnInsert: {
163
- _id: key,
164
- createdAt: now,
165
- },
166
- },
167
- { upsert: true }
168
- )
169
-
170
- await this.logAudit(key, 'write')
171
- }
172
-
173
- async deleteSecret(key: string): Promise<void> {
174
- await this.secrets.deleteOne({ _id: key })
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.secrets
182
- .find({ _id: { $in: keys } } as any)
183
- .toArray()
184
- const out: Record<string, unknown> = {}
185
- for (const row of rows) {
186
- try {
187
- const kek = await this.getKEK(row.keyVersion)
188
- out[row._id] = createSecretValue(
189
- await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek)
190
- )
191
- } catch (cause) {
192
- throw new Error(
193
- `Failed to decrypt secret "${row._id}" (key_version ${row.keyVersion}): ` +
194
- `the configured KEK does not match the key it was wrapped under`,
195
- { cause }
196
- )
197
- }
198
- }
199
- return out as Partial<SecretValues<T>>
200
- }
201
-
202
- async rotateKEK(): Promise<number> {
203
- if (!this.previousKey) {
204
- throw new Error('No previousKey configured — nothing to rotate from')
205
- }
206
-
207
- const rows = await this.secrets
208
- .find({ keyVersion: { $lt: this.keyVersion } })
209
- .project({ _id: 1, wrappedDek: 1 })
210
- .toArray()
211
-
212
- const oldKEK = await this.getKEK(this.keyVersion - 1)
213
- const newKEK = await this.getKEK(this.keyVersion)
214
-
215
- for (const row of rows) {
216
- const newWrappedDEK = await envelopeRewrap(oldKEK, newKEK, row.wrappedDek)
217
- await this.secrets.updateOne(
218
- { _id: row._id },
219
- {
220
- $set: {
221
- wrappedDek: newWrappedDEK,
222
- keyVersion: this.keyVersion,
223
- updatedAt: new Date(),
224
- },
225
- }
226
- )
227
- await this.logAudit(row._id as string, 'rotate')
228
- }
229
-
230
- return rows.length
231
- }
232
- }