@pikku/kysely 0.12.5 → 0.12.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.
@@ -38,9 +38,9 @@ export class KyselyChannelStore extends ChannelStore {
38
38
  await this.db
39
39
  .insertInto('channels')
40
40
  .values({
41
- channel_id: channelId,
42
- channel_name: channelName,
43
- opening_data: JSON.stringify(openingData || {}),
41
+ channelId: channelId,
42
+ channelName: channelName,
43
+ openingData: JSON.stringify(openingData || {}),
44
44
  })
45
45
  .execute();
46
46
  }
@@ -50,30 +50,30 @@ export class KyselyChannelStore extends ChannelStore {
50
50
  }
51
51
  await this.db
52
52
  .deleteFrom('channels')
53
- .where('channel_id', 'in', channelIds)
53
+ .where('channelId', 'in', channelIds)
54
54
  .execute();
55
55
  }
56
56
  async setUserSession(channelId, session) {
57
57
  await this.db
58
58
  .updateTable('channels')
59
- .set({ user_session: session ? JSON.stringify(session) : null })
60
- .where('channel_id', '=', channelId)
59
+ .set({ userSession: session ? JSON.stringify(session) : null })
60
+ .where('channelId', '=', channelId)
61
61
  .execute();
62
62
  }
63
63
  async getChannelAndSession(channelId) {
64
64
  const row = await this.db
65
65
  .selectFrom('channels')
66
- .select(['channel_id', 'channel_name', 'opening_data', 'user_session'])
67
- .where('channel_id', '=', channelId)
66
+ .select(['channelId', 'channelName', 'openingData', 'userSession'])
67
+ .where('channelId', '=', channelId)
68
68
  .executeTakeFirst();
69
69
  if (!row) {
70
70
  throw new Error(`Channel not found: ${channelId}`);
71
71
  }
72
72
  return {
73
- channelId: row.channel_id,
74
- channelName: row.channel_name,
75
- openingData: parseJson(row.opening_data) ?? {},
76
- session: (parseJson(row.user_session) ?? {}),
73
+ channelId: row.channelId,
74
+ channelName: row.channelName,
75
+ openingData: parseJson(row.openingData) ?? {},
76
+ session: (parseJson(row.userSession) ?? {}),
77
77
  };
78
78
  }
79
79
  async close() { }
@@ -0,0 +1,30 @@
1
+ import type { CredentialService } from '@pikku/core/services';
2
+ import type { Kysely } from 'kysely';
3
+ import type { KyselyPikkuDB } from './kysely-tables.js';
4
+ export interface KyselyCredentialServiceConfig {
5
+ key: string;
6
+ keyVersion?: number;
7
+ previousKey?: string;
8
+ audit?: boolean;
9
+ auditReads?: boolean;
10
+ }
11
+ export declare class KyselyCredentialService implements CredentialService {
12
+ private db;
13
+ private initialized;
14
+ private key;
15
+ private keyVersion;
16
+ private previousKey?;
17
+ private audit;
18
+ private auditReads;
19
+ constructor(db: Kysely<KyselyPikkuDB>, config: KyselyCredentialServiceConfig);
20
+ init(): Promise<void>;
21
+ private logAudit;
22
+ private getKEK;
23
+ private whereUserId;
24
+ get<T = unknown>(name: string, userId?: string): Promise<T | null>;
25
+ set(name: string, value: unknown, userId?: string): Promise<void>;
26
+ delete(name: string, userId?: string): Promise<void>;
27
+ has(name: string, userId?: string): Promise<boolean>;
28
+ getAll(userId: string): Promise<Record<string, unknown>>;
29
+ rotateKEK(): Promise<number>;
30
+ }
@@ -0,0 +1,188 @@
1
+ import { sql } from 'kysely';
2
+ import { envelopeEncrypt, envelopeDecrypt, envelopeRewrap, } from '@pikku/core/crypto-utils';
3
+ export class KyselyCredentialService {
4
+ db;
5
+ initialized = false;
6
+ key;
7
+ keyVersion;
8
+ previousKey;
9
+ audit;
10
+ auditReads;
11
+ constructor(db, config) {
12
+ this.db = db;
13
+ this.key = config.key;
14
+ this.keyVersion = config.keyVersion ?? 1;
15
+ this.previousKey = config.previousKey;
16
+ this.audit = config.audit ?? false;
17
+ this.auditReads = config.auditReads ?? false;
18
+ }
19
+ async init() {
20
+ if (this.initialized)
21
+ return;
22
+ await this.db.schema
23
+ .createTable('credentials')
24
+ .ifNotExists()
25
+ .addColumn('name', 'varchar(255)', (col) => col.notNull())
26
+ .addColumn('user_id', 'varchar(255)')
27
+ .addColumn('ciphertext', 'text', (col) => col.notNull())
28
+ .addColumn('wrapped_dek', 'text', (col) => col.notNull())
29
+ .addColumn('key_version', 'integer', (col) => col.notNull())
30
+ .addColumn('created_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
31
+ .addColumn('updated_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
32
+ .execute();
33
+ await sql `CREATE UNIQUE INDEX IF NOT EXISTS credentials_name_user_id_unique ON credentials (name, COALESCE(user_id, ''))`.execute(this.db);
34
+ if (this.audit) {
35
+ await this.db.schema
36
+ .createTable('credentials_audit')
37
+ .ifNotExists()
38
+ .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
39
+ .addColumn('credential_name', 'varchar(255)', (col) => col.notNull())
40
+ .addColumn('user_id', 'varchar(255)')
41
+ .addColumn('action', 'varchar(20)', (col) => col.notNull())
42
+ .addColumn('performed_at', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
43
+ .execute();
44
+ }
45
+ this.initialized = true;
46
+ }
47
+ async logAudit(name, userId, action) {
48
+ if (!this.audit)
49
+ return;
50
+ if (action === 'read' && !this.auditReads)
51
+ return;
52
+ await this.db
53
+ .insertInto('credentialsAudit')
54
+ .values({
55
+ id: crypto.randomUUID(),
56
+ credentialName: name,
57
+ userId: userId ?? null,
58
+ action,
59
+ performedAt: new Date().toISOString(),
60
+ })
61
+ .execute();
62
+ }
63
+ getKEK(version) {
64
+ if (version === this.keyVersion)
65
+ return this.key;
66
+ if (this.previousKey)
67
+ return this.previousKey;
68
+ throw new Error(`No KEK available for key_version ${version}`);
69
+ }
70
+ whereUserId(qb, userId) {
71
+ return userId
72
+ ? qb.where('userId', '=', userId)
73
+ : qb.where('userId', 'is', null);
74
+ }
75
+ async get(name, userId) {
76
+ let qb = this.db
77
+ .selectFrom('credentials')
78
+ .select(['ciphertext', 'wrappedDek', 'keyVersion'])
79
+ .where('name', '=', name);
80
+ qb = this.whereUserId(qb, userId);
81
+ const row = await qb.executeTakeFirst();
82
+ if (!row)
83
+ return null;
84
+ const kek = this.getKEK(row.keyVersion);
85
+ const plaintext = await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek);
86
+ await this.logAudit(name, userId, 'read');
87
+ try {
88
+ return JSON.parse(plaintext);
89
+ }
90
+ catch {
91
+ throw new Error(`Credential '${name}' contains invalid data`);
92
+ }
93
+ }
94
+ async set(name, value, userId) {
95
+ const plaintext = JSON.stringify(value);
96
+ const { ciphertext, wrappedDEK } = await envelopeEncrypt(this.key, plaintext);
97
+ const now = new Date().toISOString();
98
+ const exists = await this.has(name, userId);
99
+ if (exists) {
100
+ let qb = this.db
101
+ .updateTable('credentials')
102
+ .set({
103
+ ciphertext,
104
+ wrappedDek: wrappedDEK,
105
+ keyVersion: this.keyVersion,
106
+ updatedAt: now,
107
+ })
108
+ .where('name', '=', name);
109
+ qb = this.whereUserId(qb, userId);
110
+ await qb.execute();
111
+ }
112
+ else {
113
+ await this.db
114
+ .insertInto('credentials')
115
+ .values({
116
+ name,
117
+ userId: userId ?? null,
118
+ ciphertext,
119
+ wrappedDek: wrappedDEK,
120
+ keyVersion: this.keyVersion,
121
+ createdAt: now,
122
+ updatedAt: now,
123
+ })
124
+ .execute();
125
+ }
126
+ await this.logAudit(name, userId, 'write');
127
+ }
128
+ async delete(name, userId) {
129
+ let qb = this.db.deleteFrom('credentials').where('name', '=', name);
130
+ qb = this.whereUserId(qb, userId);
131
+ await qb.execute();
132
+ await this.logAudit(name, userId, 'delete');
133
+ }
134
+ async has(name, userId) {
135
+ let qb = this.db
136
+ .selectFrom('credentials')
137
+ .select('name')
138
+ .where('name', '=', name);
139
+ qb = this.whereUserId(qb, userId);
140
+ const row = await qb.executeTakeFirst();
141
+ return !!row;
142
+ }
143
+ async getAll(userId) {
144
+ const rows = await this.db
145
+ .selectFrom('credentials')
146
+ .select(['name', 'ciphertext', 'wrappedDek', 'keyVersion'])
147
+ .where('userId', '=', userId)
148
+ .execute();
149
+ const result = {};
150
+ for (const row of rows) {
151
+ const kek = this.getKEK(row.keyVersion);
152
+ const plaintext = await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek);
153
+ try {
154
+ result[row.name] = JSON.parse(plaintext);
155
+ }
156
+ catch {
157
+ throw new Error(`Credential '${row.name}' contains invalid data`);
158
+ }
159
+ await this.logAudit(row.name, userId, 'read');
160
+ }
161
+ return result;
162
+ }
163
+ async rotateKEK() {
164
+ if (!this.previousKey) {
165
+ throw new Error('No previousKey configured — nothing to rotate from');
166
+ }
167
+ const rows = await this.db
168
+ .selectFrom('credentials')
169
+ .select(['name', 'userId', 'wrappedDek'])
170
+ .where('keyVersion', '<', this.keyVersion)
171
+ .execute();
172
+ for (const row of rows) {
173
+ const newWrappedDEK = await envelopeRewrap(this.previousKey, this.key, row.wrappedDek);
174
+ let qb = this.db
175
+ .updateTable('credentials')
176
+ .set({
177
+ wrappedDek: newWrappedDEK,
178
+ keyVersion: this.keyVersion,
179
+ updatedAt: new Date().toISOString(),
180
+ })
181
+ .where('name', '=', row.name);
182
+ qb = this.whereUserId(qb, row.userId ?? undefined);
183
+ await qb.execute();
184
+ await this.logAudit(row.name, row.userId ?? undefined, 'rotate');
185
+ }
186
+ return rows.length;
187
+ }
188
+ }
@@ -54,27 +54,27 @@ export class KyselyDeploymentService {
54
54
  this.deploymentConfig = { ...config, functions };
55
55
  await this.db.transaction().execute(async (trx) => {
56
56
  await trx
57
- .insertInto('pikku_deployments')
57
+ .insertInto('pikkuDeployments')
58
58
  .values({
59
- deployment_id: config.deploymentId,
59
+ deploymentId: config.deploymentId,
60
60
  endpoint: config.endpoint,
61
- last_heartbeat: new Date(),
61
+ lastHeartbeat: new Date(),
62
62
  })
63
- .onConflict((oc) => oc.column('deployment_id').doUpdateSet({
63
+ .onConflict((oc) => oc.column('deploymentId').doUpdateSet({
64
64
  endpoint: config.endpoint,
65
- last_heartbeat: new Date(),
65
+ lastHeartbeat: new Date(),
66
66
  }))
67
67
  .execute();
68
68
  await trx
69
- .deleteFrom('pikku_deployment_functions')
70
- .where('deployment_id', '=', config.deploymentId)
69
+ .deleteFrom('pikkuDeploymentFunctions')
70
+ .where('deploymentId', '=', config.deploymentId)
71
71
  .execute();
72
72
  if (functions.length > 0) {
73
73
  await trx
74
- .insertInto('pikku_deployment_functions')
74
+ .insertInto('pikkuDeploymentFunctions')
75
75
  .values(functions.map((fn) => ({
76
- deployment_id: config.deploymentId,
77
- function_name: fn,
76
+ deploymentId: config.deploymentId,
77
+ functionName: fn,
78
78
  })))
79
79
  .execute();
80
80
  }
@@ -88,8 +88,8 @@ export class KyselyDeploymentService {
88
88
  }
89
89
  if (this.deploymentConfig) {
90
90
  await this.db
91
- .deleteFrom('pikku_deployments')
92
- .where('deployment_id', '=', this.deploymentConfig.deploymentId)
91
+ .deleteFrom('pikkuDeployments')
92
+ .where('deploymentId', '=', this.deploymentConfig.deploymentId)
93
93
  .execute();
94
94
  }
95
95
  }
@@ -97,15 +97,15 @@ export class KyselyDeploymentService {
97
97
  const ttlMs = this.heartbeatTtl;
98
98
  const cutoff = new Date(Date.now() - ttlMs);
99
99
  const result = await this.db
100
- .selectFrom('pikku_deployments as d')
101
- .innerJoin('pikku_deployment_functions as f', 'f.deployment_id', 'd.deployment_id')
102
- .select(['d.deployment_id', 'd.endpoint'])
103
- .where('f.function_name', '=', name)
104
- .where('d.last_heartbeat', '>', cutoff)
105
- .orderBy('d.last_heartbeat', 'desc')
100
+ .selectFrom('pikkuDeployments as d')
101
+ .innerJoin('pikkuDeploymentFunctions as f', 'f.deploymentId', 'd.deploymentId')
102
+ .select(['d.deploymentId', 'd.endpoint'])
103
+ .where('f.functionName', '=', name)
104
+ .where('d.lastHeartbeat', '>', cutoff)
105
+ .orderBy('d.lastHeartbeat', 'desc')
106
106
  .execute();
107
107
  return result.map((row) => ({
108
- deploymentId: row.deployment_id,
108
+ deploymentId: row.deploymentId,
109
109
  endpoint: row.endpoint,
110
110
  }));
111
111
  }
@@ -128,9 +128,9 @@ export class KyselyDeploymentService {
128
128
  return;
129
129
  try {
130
130
  await this.db
131
- .updateTable('pikku_deployments')
132
- .set({ last_heartbeat: new Date() })
133
- .where('deployment_id', '=', this.deploymentConfig.deploymentId)
131
+ .updateTable('pikkuDeployments')
132
+ .set({ lastHeartbeat: new Date() })
133
+ .where('deploymentId', '=', this.deploymentConfig.deploymentId)
134
134
  .execute();
135
135
  }
136
136
  catch {
@@ -14,18 +14,18 @@ export class KyselyEventHubStore extends EventHubStore {
14
14
  }
15
15
  async getChannelIdsForTopic(topic) {
16
16
  const result = await this.db
17
- .selectFrom('channel_subscriptions')
18
- .select('channel_id')
17
+ .selectFrom('channelSubscriptions')
18
+ .select('channelId')
19
19
  .where('topic', '=', topic)
20
20
  .execute();
21
- return result.map((row) => row.channel_id);
21
+ return result.map((row) => row.channelId);
22
22
  }
23
23
  async subscribe(topic, channelId) {
24
24
  try {
25
25
  await this.db
26
- .insertInto('channel_subscriptions')
27
- .values({ channel_id: channelId, topic: topic })
28
- .onConflict((oc) => oc.columns(['channel_id', 'topic']).doNothing())
26
+ .insertInto('channelSubscriptions')
27
+ .values({ channelId: channelId, topic: topic })
28
+ .onConflict((oc) => oc.columns(['channelId', 'topic']).doNothing())
29
29
  .execute();
30
30
  return true;
31
31
  }
@@ -35,8 +35,8 @@ export class KyselyEventHubStore extends EventHubStore {
35
35
  }
36
36
  async unsubscribe(topic, channelId) {
37
37
  const result = await this.db
38
- .deleteFrom('channel_subscriptions')
39
- .where('channel_id', '=', channelId)
38
+ .deleteFrom('channelSubscriptions')
39
+ .where('channelId', '=', channelId)
40
40
  .where('topic', '=', topic)
41
41
  .executeTakeFirst();
42
42
  return BigInt(result.numDeletedRows) > 0n;
@@ -47,12 +47,12 @@ export class KyselySecretService {
47
47
  if (action === 'read' && !this.auditReads)
48
48
  return;
49
49
  await this.db
50
- .insertInto('secrets_audit')
50
+ .insertInto('secretsAudit')
51
51
  .values({
52
52
  id: crypto.randomUUID(),
53
- secret_key: secretKey,
53
+ secretKey: secretKey,
54
54
  action,
55
- performed_at: new Date().toISOString(),
55
+ performedAt: new Date().toISOString(),
56
56
  })
57
57
  .execute();
58
58
  }
@@ -66,13 +66,13 @@ export class KyselySecretService {
66
66
  async getSecret(key) {
67
67
  const row = await this.db
68
68
  .selectFrom('secrets')
69
- .select(['ciphertext', 'wrapped_dek', 'key_version'])
69
+ .select(['ciphertext', 'wrappedDek', 'keyVersion'])
70
70
  .where('key', '=', key)
71
71
  .executeTakeFirst();
72
72
  if (!row)
73
73
  throw new Error('Requested secret not found');
74
- const kek = this.getKEK(row.key_version);
75
- const result = await envelopeDecrypt(kek, row.ciphertext, row.wrapped_dek);
74
+ const kek = this.getKEK(row.keyVersion);
75
+ const result = await envelopeDecrypt(kek, row.ciphertext, row.wrappedDek);
76
76
  await this.logAudit(key, 'read');
77
77
  return result;
78
78
  }
@@ -97,16 +97,16 @@ export class KyselySecretService {
97
97
  .values({
98
98
  key,
99
99
  ciphertext,
100
- wrapped_dek: wrappedDEK,
101
- key_version: this.keyVersion,
102
- created_at: now,
103
- updated_at: now,
100
+ wrappedDek: wrappedDEK,
101
+ keyVersion: this.keyVersion,
102
+ createdAt: now,
103
+ updatedAt: now,
104
104
  })
105
105
  .onConflict((oc) => oc.column('key').doUpdateSet({
106
106
  ciphertext,
107
- wrapped_dek: wrappedDEK,
108
- key_version: this.keyVersion,
109
- updated_at: now,
107
+ wrappedDek: wrappedDEK,
108
+ keyVersion: this.keyVersion,
109
+ updatedAt: now,
110
110
  }))
111
111
  .execute();
112
112
  await this.logAudit(key, 'write');
@@ -121,17 +121,17 @@ export class KyselySecretService {
121
121
  }
122
122
  const rows = await this.db
123
123
  .selectFrom('secrets')
124
- .select(['key', 'wrapped_dek'])
125
- .where('key_version', '<', this.keyVersion)
124
+ .select(['key', 'wrappedDek'])
125
+ .where('keyVersion', '<', this.keyVersion)
126
126
  .execute();
127
127
  for (const row of rows) {
128
- const newWrappedDEK = await envelopeRewrap(this.previousKey, this.key, row.wrapped_dek);
128
+ const newWrappedDEK = await envelopeRewrap(this.previousKey, this.key, row.wrappedDek);
129
129
  await this.db
130
130
  .updateTable('secrets')
131
131
  .set({
132
- wrapped_dek: newWrappedDEK,
133
- key_version: this.keyVersion,
134
- updated_at: new Date().toISOString(),
132
+ wrappedDek: newWrappedDEK,
133
+ keyVersion: this.keyVersion,
134
+ updatedAt: new Date().toISOString(),
135
135
  })
136
136
  .where('key', '=', row.key)
137
137
  .execute();