@qwickapps/server 1.5.1 → 1.6.0

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 (135) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/core/control-panel.d.ts.map +1 -1
  3. package/dist/core/control-panel.js +41 -0
  4. package/dist/core/control-panel.js.map +1 -1
  5. package/dist/core/guards.d.ts.map +1 -1
  6. package/dist/core/guards.js +77 -0
  7. package/dist/core/guards.js.map +1 -1
  8. package/dist/core/health-manager.d.ts +4 -0
  9. package/dist/core/health-manager.d.ts.map +1 -1
  10. package/dist/core/health-manager.js +6 -1
  11. package/dist/core/health-manager.js.map +1 -1
  12. package/dist/core/plugin-registry.d.ts +55 -5
  13. package/dist/core/plugin-registry.d.ts.map +1 -1
  14. package/dist/core/plugin-registry.js +57 -19
  15. package/dist/core/plugin-registry.js.map +1 -1
  16. package/dist/core/types.d.ts +2 -0
  17. package/dist/core/types.d.ts.map +1 -1
  18. package/dist/index.d.ts +2 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +3 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/plugins/api-keys/api-keys-plugin.d.ts +46 -0
  23. package/dist/plugins/api-keys/api-keys-plugin.d.ts.map +1 -0
  24. package/dist/plugins/api-keys/api-keys-plugin.js +329 -0
  25. package/dist/plugins/api-keys/api-keys-plugin.js.map +1 -0
  26. package/dist/plugins/api-keys/index.d.ts +14 -0
  27. package/dist/plugins/api-keys/index.d.ts.map +1 -0
  28. package/dist/plugins/api-keys/index.js +17 -0
  29. package/dist/plugins/api-keys/index.js.map +1 -0
  30. package/dist/plugins/api-keys/middleware/bearer-token-auth.d.ts +74 -0
  31. package/dist/plugins/api-keys/middleware/bearer-token-auth.d.ts.map +1 -0
  32. package/dist/plugins/api-keys/middleware/bearer-token-auth.js +201 -0
  33. package/dist/plugins/api-keys/middleware/bearer-token-auth.js.map +1 -0
  34. package/dist/plugins/api-keys/middleware/index.d.ts +7 -0
  35. package/dist/plugins/api-keys/middleware/index.d.ts.map +1 -0
  36. package/dist/plugins/api-keys/middleware/index.js +7 -0
  37. package/dist/plugins/api-keys/middleware/index.js.map +1 -0
  38. package/dist/plugins/api-keys/stores/index.d.ts +7 -0
  39. package/dist/plugins/api-keys/stores/index.d.ts.map +1 -0
  40. package/dist/plugins/api-keys/stores/index.js +7 -0
  41. package/dist/plugins/api-keys/stores/index.js.map +1 -0
  42. package/dist/plugins/api-keys/stores/postgres-store.d.ts +34 -0
  43. package/dist/plugins/api-keys/stores/postgres-store.d.ts.map +1 -0
  44. package/dist/plugins/api-keys/stores/postgres-store.js +360 -0
  45. package/dist/plugins/api-keys/stores/postgres-store.js.map +1 -0
  46. package/dist/plugins/api-keys/types.d.ts +268 -0
  47. package/dist/plugins/api-keys/types.d.ts.map +1 -0
  48. package/dist/plugins/api-keys/types.js +56 -0
  49. package/dist/plugins/api-keys/types.js.map +1 -0
  50. package/dist/plugins/auth/auth-plugin.d.ts.map +1 -1
  51. package/dist/plugins/auth/auth-plugin.js +17 -1
  52. package/dist/plugins/auth/auth-plugin.js.map +1 -1
  53. package/dist/plugins/auth/auth-plugin.test.js +133 -0
  54. package/dist/plugins/auth/auth-plugin.test.js.map +1 -1
  55. package/dist/plugins/auth/env-config.d.ts.map +1 -1
  56. package/dist/plugins/auth/env-config.js +6 -2
  57. package/dist/plugins/auth/env-config.js.map +1 -1
  58. package/dist/plugins/auth/types.d.ts +10 -0
  59. package/dist/plugins/auth/types.d.ts.map +1 -1
  60. package/dist/plugins/auth/types.js.map +1 -1
  61. package/dist/plugins/devices/__tests__/token-utils.test.js +4 -2
  62. package/dist/plugins/devices/__tests__/token-utils.test.js.map +1 -1
  63. package/dist/plugins/frontend-app-plugin.d.ts.map +1 -1
  64. package/dist/plugins/frontend-app-plugin.js +21 -4
  65. package/dist/plugins/frontend-app-plugin.js.map +1 -1
  66. package/dist/plugins/index.d.ts +2 -0
  67. package/dist/plugins/index.d.ts.map +1 -1
  68. package/dist/plugins/index.js +2 -0
  69. package/dist/plugins/index.js.map +1 -1
  70. package/dist/plugins/qwickbrain/index.d.ts +25 -0
  71. package/dist/plugins/qwickbrain/index.d.ts.map +1 -0
  72. package/dist/plugins/qwickbrain/index.js +24 -0
  73. package/dist/plugins/qwickbrain/index.js.map +1 -0
  74. package/dist/plugins/qwickbrain/qwickbrain-plugin.d.ts +23 -0
  75. package/dist/plugins/qwickbrain/qwickbrain-plugin.d.ts.map +1 -0
  76. package/dist/plugins/qwickbrain/qwickbrain-plugin.js +528 -0
  77. package/dist/plugins/qwickbrain/qwickbrain-plugin.js.map +1 -0
  78. package/dist/plugins/qwickbrain/types.d.ts +131 -0
  79. package/dist/plugins/qwickbrain/types.d.ts.map +1 -0
  80. package/dist/plugins/qwickbrain/types.js +9 -0
  81. package/dist/plugins/qwickbrain/types.js.map +1 -0
  82. package/dist/plugins/users/__tests__/postgres-store.test.js +1 -0
  83. package/dist/plugins/users/__tests__/postgres-store.test.js.map +1 -1
  84. package/dist/plugins/users/__tests__/users-plugin.test.js +3 -0
  85. package/dist/plugins/users/__tests__/users-plugin.test.js.map +1 -1
  86. package/dist/plugins/users/stores/postgres-store.d.ts.map +1 -1
  87. package/dist/plugins/users/stores/postgres-store.js +59 -1
  88. package/dist/plugins/users/stores/postgres-store.js.map +1 -1
  89. package/dist/plugins/users/types.d.ts +22 -0
  90. package/dist/plugins/users/types.d.ts.map +1 -1
  91. package/dist-ui/assets/index-5nX8fM1a.js +469 -0
  92. package/dist-ui/assets/index-5nX8fM1a.js.map +1 -0
  93. package/dist-ui/index.html +1 -1
  94. package/dist-ui-lib/api/controlPanelApi.d.ts +68 -0
  95. package/dist-ui-lib/components/index.d.ts +2 -1
  96. package/dist-ui-lib/index.js +2642 -2281
  97. package/dist-ui-lib/index.js.map +1 -1
  98. package/dist-ui-lib/pages/APIKeysPage.d.ts +13 -0
  99. package/dist-ui-lib/pages/AcceptInvitationPage.d.ts +28 -0
  100. package/package.json +3 -2
  101. package/src/core/control-panel.ts +47 -0
  102. package/src/core/guards.ts +89 -0
  103. package/src/core/health-manager.ts +6 -1
  104. package/src/core/plugin-registry.ts +123 -25
  105. package/src/core/types.ts +2 -0
  106. package/src/index.ts +11 -0
  107. package/src/plugins/api-keys/api-keys-plugin.ts +397 -0
  108. package/src/plugins/api-keys/index.ts +49 -0
  109. package/src/plugins/api-keys/middleware/bearer-token-auth.ts +250 -0
  110. package/src/plugins/api-keys/middleware/index.ts +12 -0
  111. package/src/plugins/api-keys/stores/index.ts +7 -0
  112. package/src/plugins/api-keys/stores/postgres-store.ts +487 -0
  113. package/src/plugins/api-keys/types.ts +243 -0
  114. package/src/plugins/auth/auth-plugin.test.ts +167 -0
  115. package/src/plugins/auth/auth-plugin.ts +17 -1
  116. package/src/plugins/auth/env-config.ts +6 -2
  117. package/src/plugins/auth/types.ts +10 -0
  118. package/src/plugins/devices/__tests__/token-utils.test.ts +4 -2
  119. package/src/plugins/frontend-app-plugin.ts +24 -4
  120. package/src/plugins/index.ts +15 -0
  121. package/src/plugins/qwickbrain/index.ts +33 -0
  122. package/src/plugins/qwickbrain/qwickbrain-plugin.ts +642 -0
  123. package/src/plugins/qwickbrain/types.ts +146 -0
  124. package/src/plugins/users/__tests__/postgres-store.test.ts +1 -0
  125. package/src/plugins/users/__tests__/users-plugin.test.ts +3 -0
  126. package/src/plugins/users/stores/postgres-store.ts +69 -0
  127. package/src/plugins/users/types.ts +25 -0
  128. package/ui/src/App.tsx +6 -1
  129. package/ui/src/api/controlPanelApi.ts +206 -37
  130. package/ui/src/components/index.ts +6 -0
  131. package/ui/src/pages/APIKeysPage.tsx +661 -0
  132. package/ui/src/pages/AcceptInvitationPage.tsx +169 -0
  133. package/ui/src/pages/UsersPage.tsx +225 -2
  134. package/dist-ui/assets/index-CynOqPkb.js +0 -469
  135. package/dist-ui/assets/index-CynOqPkb.js.map +0 -1
@@ -0,0 +1,7 @@
1
+ /**
2
+ * API Keys Stores Index
3
+ *
4
+ * Copyright (c) 2025 QwickApps.com. All rights reserved.
5
+ */
6
+
7
+ export { postgresApiKeyStore } from './postgres-store.js';
@@ -0,0 +1,487 @@
1
+ /**
2
+ * PostgreSQL API Keys Store
3
+ *
4
+ * API key storage implementation using PostgreSQL with Row-Level Security (RLS).
5
+ * Uses SHA-256 for token hashing (high-entropy keys don't need bcrypt's slowness).
6
+ *
7
+ * RLS Context Pattern:
8
+ * Each operation uses an explicit transaction and sets `app.current_user_id`
9
+ * as a transaction-local configuration variable. The RLS policy checks this
10
+ * variable to enforce that users can only access their own API keys.
11
+ *
12
+ * Copyright (c) 2025 QwickApps.com. All rights reserved.
13
+ */
14
+
15
+ import crypto from 'crypto';
16
+ import { getLogger } from '@qwickapps/logging';
17
+ import type {
18
+ ApiKeyStore,
19
+ PostgresApiKeyStoreConfig,
20
+ CreateApiKeyParams,
21
+ UpdateApiKeyParams,
22
+ ApiKey,
23
+ ApiKeyWithPlaintext,
24
+ } from '../types.js';
25
+
26
+ // Pool interface (from pg package)
27
+ interface PgPool {
28
+ query(text: string, values?: unknown[]): Promise<{ rows: unknown[]; rowCount: number | null }>;
29
+ connect(): Promise<PgPoolClient>;
30
+ }
31
+
32
+ interface PgPoolClient {
33
+ query(text: string, values?: unknown[]): Promise<{ rows: unknown[]; rowCount: number | null }>;
34
+ release(): void;
35
+ }
36
+
37
+ // ============================================================================
38
+ // Logging
39
+ // ============================================================================
40
+
41
+ const logger = getLogger('api-keys');
42
+
43
+ // ============================================================================
44
+ // Token Generation and Hashing
45
+ // ============================================================================
46
+
47
+ /**
48
+ * Generate a cryptographically secure API key
49
+ *
50
+ * Token format: `qk_<env>_<32 bytes base64url>`
51
+ * - qk = QwickApps
52
+ * - env = live or test
53
+ * - 32 random bytes = high entropy secret
54
+ *
55
+ * @param isTest Whether this is a test key (default: false)
56
+ * @returns Object with plaintext key, hash, and prefix
57
+ */
58
+ function generateApiKey(isTest: boolean = false): { key: string; hash: string; prefix: string } {
59
+ const env = isTest ? 'test' : 'live';
60
+ const randomBytes = crypto.randomBytes(32);
61
+ const secret = randomBytes.toString('base64url');
62
+ const key = `qk_${env}_${secret}`;
63
+
64
+ // Hash the key for storage (SHA-256 is appropriate for high-entropy tokens)
65
+ const hash = crypto.createHash('sha256').update(key).digest('hex');
66
+
67
+ // Prefix for identification (first 12 characters)
68
+ const prefix = key.substring(0, 12);
69
+
70
+ return { key, hash, prefix };
71
+ }
72
+
73
+ /**
74
+ * Hash an API key using SHA-256
75
+ *
76
+ * We use SHA-256 instead of bcrypt because:
77
+ * 1. API keys are high-entropy (32 random bytes)
78
+ * 2. No need for slow hashing (not user passwords)
79
+ * 3. Faster verification for high-throughput API calls
80
+ *
81
+ * @param key Plaintext API key
82
+ * @returns Hex-encoded SHA-256 hash
83
+ */
84
+ function hashApiKey(key: string): string {
85
+ return crypto.createHash('sha256').update(key).digest('hex');
86
+ }
87
+
88
+ /**
89
+ * Verify an API key against its stored hash
90
+ *
91
+ * Uses constant-time comparison to prevent timing attacks.
92
+ *
93
+ * @param key Plaintext API key
94
+ * @param storedHash Hash from database
95
+ * @returns True if key matches hash
96
+ */
97
+ function verifyApiKey(key: string, storedHash: string): boolean {
98
+ const keyHash = hashApiKey(key);
99
+
100
+ // Constant-time comparison
101
+ return crypto.timingSafeEqual(
102
+ Buffer.from(keyHash, 'hex'),
103
+ Buffer.from(storedHash, 'hex')
104
+ );
105
+ }
106
+
107
+ /**
108
+ * Extract prefix from API key
109
+ *
110
+ * @param key Plaintext API key
111
+ * @returns Key prefix (first 12 characters)
112
+ */
113
+ function getKeyPrefix(key: string): string {
114
+ return key.substring(0, 12);
115
+ }
116
+
117
+ /**
118
+ * Validate API key format
119
+ *
120
+ * @param key Key to validate
121
+ * @returns True if format is valid
122
+ */
123
+ function isValidKeyFormat(key: string): boolean {
124
+ // Must start with qk_live_ or qk_test_
125
+ if (!key.startsWith('qk_live_') && !key.startsWith('qk_test_')) {
126
+ return false;
127
+ }
128
+
129
+ // Extract secret part
130
+ const parts = key.split('_');
131
+ if (parts.length !== 3) {
132
+ return false;
133
+ }
134
+
135
+ const secret = parts[2];
136
+
137
+ // Validate length (32 bytes base64url = 43 characters)
138
+ if (secret.length !== 43) {
139
+ return false;
140
+ }
141
+
142
+ // Validate characters (base64url: A-Za-z0-9_-)
143
+ const base64urlPattern = /^[A-Za-z0-9_-]+$/;
144
+ return base64urlPattern.test(secret);
145
+ }
146
+
147
+ // ============================================================================
148
+ // RLS Helper
149
+ // ============================================================================
150
+
151
+ /**
152
+ * Execute a function within an RLS-protected transaction
153
+ *
154
+ * This helper ensures that:
155
+ * 1. All queries run within the same transaction
156
+ * 2. The RLS context is set before any data access
157
+ * 3. The transaction is properly committed or rolled back
158
+ *
159
+ * @param pool PostgreSQL pool
160
+ * @param userId User ID to set as the RLS context
161
+ * @param callback Function to execute within the transaction
162
+ */
163
+ async function withRLSContext<T>(
164
+ pool: PgPool,
165
+ userId: string,
166
+ callback: (client: PgPoolClient) => Promise<T>
167
+ ): Promise<T> {
168
+ const client = await pool.connect();
169
+ try {
170
+ await client.query('BEGIN');
171
+ // Set transaction-local user context for RLS
172
+ await client.query(
173
+ "SELECT set_config('app.current_user_id', $1, true)",
174
+ [userId]
175
+ );
176
+ const result = await callback(client);
177
+ await client.query('COMMIT');
178
+ return result;
179
+ } catch (error) {
180
+ await client.query('ROLLBACK');
181
+ throw error;
182
+ } finally {
183
+ client.release();
184
+ }
185
+ }
186
+
187
+ // ============================================================================
188
+ // PostgreSQL Store Implementation
189
+ // ============================================================================
190
+
191
+ /**
192
+ * Create a PostgreSQL API keys store with RLS
193
+ *
194
+ * @param config Configuration including a pg Pool instance
195
+ * @returns ApiKeyStore implementation
196
+ *
197
+ * @example
198
+ * ```ts
199
+ * import { Pool } from 'pg';
200
+ * import { postgresApiKeyStore } from '@qwickapps/server';
201
+ *
202
+ * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
203
+ * const store = postgresApiKeyStore({ pool });
204
+ *
205
+ * // Or with lazy initialization:
206
+ * const store = postgresApiKeyStore({ pool: () => getPostgres().getPool() });
207
+ * ```
208
+ */
209
+ export function postgresApiKeyStore(config: PostgresApiKeyStoreConfig): ApiKeyStore {
210
+ const {
211
+ pool: poolOrFn,
212
+ tableName = 'api_keys',
213
+ schema = 'public',
214
+ autoCreateTables = true,
215
+ enableRLS = true,
216
+ defaultExpirationDays = 90,
217
+ environment = process.env.NODE_ENV === 'production' ? 'live' : 'test',
218
+ } = config;
219
+
220
+ // Validate environment configuration
221
+ if (environment !== 'test' && environment !== 'live') {
222
+ throw new Error(
223
+ `Invalid environment: "${environment}". Must be "test" or "live". ` +
224
+ `Check your PostgresApiKeyStoreConfig.environment setting.`
225
+ );
226
+ }
227
+
228
+ // Helper to get pool (supports lazy initialization via function)
229
+ const getPool = (): PgPool => {
230
+ const pool = typeof poolOrFn === 'function' ? poolOrFn() : poolOrFn;
231
+ if (!pool || typeof (pool as PgPool).query !== 'function') {
232
+ throw new Error('Invalid pool: must have query method');
233
+ }
234
+ return pool as PgPool;
235
+ };
236
+
237
+ const tableFullName = `"${schema}"."${tableName}"`;
238
+
239
+ return {
240
+ name: 'postgres',
241
+
242
+ async initialize(): Promise<void> {
243
+ if (!autoCreateTables) return;
244
+
245
+ const pool = getPool();
246
+
247
+ // Create table with foreign key to users
248
+ await pool.query(`
249
+ CREATE TABLE IF NOT EXISTS ${tableFullName} (
250
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
251
+ user_id UUID NOT NULL REFERENCES "public"."users"(id) ON DELETE CASCADE,
252
+ name VARCHAR(255) NOT NULL,
253
+ key_hash VARCHAR(64) NOT NULL,
254
+ key_prefix VARCHAR(12) NOT NULL,
255
+ key_type VARCHAR(10) NOT NULL CHECK (key_type IN ('m2m', 'pat')),
256
+ scopes TEXT[] NOT NULL DEFAULT '{}',
257
+ last_used_at TIMESTAMPTZ,
258
+ expires_at TIMESTAMPTZ,
259
+ is_active BOOLEAN NOT NULL DEFAULT true,
260
+ created_at TIMESTAMPTZ DEFAULT NOW(),
261
+ updated_at TIMESTAMPTZ DEFAULT NOW()
262
+ );
263
+
264
+ CREATE INDEX IF NOT EXISTS idx_${tableName}_user_id ON ${tableFullName}(user_id);
265
+ CREATE INDEX IF NOT EXISTS idx_${tableName}_key_prefix ON ${tableFullName}(key_prefix);
266
+ CREATE INDEX IF NOT EXISTS idx_${tableName}_key_hash ON ${tableFullName}(key_hash);
267
+ `);
268
+
269
+ // Enable RLS if configured
270
+ if (enableRLS) {
271
+ await pool.query(`
272
+ ALTER TABLE ${tableFullName} ENABLE ROW LEVEL SECURITY;
273
+ ALTER TABLE ${tableFullName} FORCE ROW LEVEL SECURITY;
274
+ `);
275
+
276
+ // Create or replace the RLS policy
277
+ await pool.query(`
278
+ DROP POLICY IF EXISTS "${tableName}_owner" ON ${tableFullName};
279
+ `);
280
+
281
+ // RLS policy: users can only access their own keys
282
+ await pool.query(`
283
+ CREATE POLICY "${tableName}_owner" ON ${tableFullName}
284
+ FOR ALL
285
+ USING (user_id::text = current_setting('app.current_user_id', true))
286
+ WITH CHECK (user_id::text = current_setting('app.current_user_id', true));
287
+ `);
288
+ }
289
+ },
290
+
291
+ async create(params: CreateApiKeyParams): Promise<ApiKeyWithPlaintext> {
292
+ const { user_id, name, key_type, scopes, expires_at } = params;
293
+
294
+ // Generate API key based on configured environment
295
+ const isTest = environment === 'test';
296
+ const { key: plaintextKey, hash, prefix } = generateApiKey(isTest);
297
+
298
+ // Calculate expiration if not provided
299
+ const expiration = expires_at || (
300
+ defaultExpirationDays !== null
301
+ ? new Date(Date.now() + defaultExpirationDays * 24 * 60 * 60 * 1000)
302
+ : null
303
+ );
304
+
305
+ return withRLSContext(getPool(), user_id, async (client) => {
306
+ const result = await client.query(
307
+ `INSERT INTO ${tableFullName}
308
+ (user_id, name, key_hash, key_prefix, key_type, scopes, expires_at)
309
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
310
+ RETURNING *`,
311
+ [user_id, name, hash, prefix, key_type, scopes, expiration]
312
+ );
313
+
314
+ const row = result.rows[0] as ApiKey;
315
+
316
+ logger.info('API key created', {
317
+ action: 'api_key.created',
318
+ user_id,
319
+ key_id: row.id,
320
+ key_prefix: row.key_prefix,
321
+ key_type: row.key_type,
322
+ scopes: row.scopes,
323
+ timestamp: new Date().toISOString(),
324
+ });
325
+
326
+ return {
327
+ ...row,
328
+ plaintext_key: plaintextKey,
329
+ };
330
+ });
331
+ },
332
+
333
+ async list(userId: string): Promise<ApiKey[]> {
334
+ return withRLSContext(getPool(), userId, async (client) => {
335
+ const result = await client.query(
336
+ `SELECT * FROM ${tableFullName} WHERE user_id = $1 ORDER BY created_at DESC`,
337
+ [userId]
338
+ );
339
+
340
+ return result.rows as ApiKey[];
341
+ });
342
+ },
343
+
344
+ async get(userId: string, keyId: string): Promise<ApiKey | null> {
345
+ return withRLSContext(getPool(), userId, async (client) => {
346
+ const result = await client.query(
347
+ `SELECT * FROM ${tableFullName} WHERE user_id = $1 AND id = $2`,
348
+ [userId, keyId]
349
+ );
350
+
351
+ if (result.rows.length === 0) {
352
+ return null;
353
+ }
354
+
355
+ return result.rows[0] as ApiKey;
356
+ });
357
+ },
358
+
359
+ async verify(plaintextKey: string): Promise<ApiKey | null> {
360
+ // Validate format first
361
+ if (!isValidKeyFormat(plaintextKey)) {
362
+ return null;
363
+ }
364
+
365
+ const pool = getPool();
366
+ const prefix = getKeyPrefix(plaintextKey);
367
+
368
+ // Find key by prefix (no RLS needed for verification)
369
+ const result = await pool.query(
370
+ `SELECT * FROM ${tableFullName}
371
+ WHERE key_prefix = $1 AND is_active = true`,
372
+ [prefix]
373
+ );
374
+
375
+ if (result.rows.length === 0) {
376
+ return null;
377
+ }
378
+
379
+ const key = result.rows[0] as ApiKey;
380
+
381
+ // Verify hash
382
+ if (!verifyApiKey(plaintextKey, key.key_hash)) {
383
+ return null;
384
+ }
385
+
386
+ // Check expiration
387
+ if (key.expires_at && new Date() > new Date(key.expires_at)) {
388
+ return null;
389
+ }
390
+
391
+ return key;
392
+ },
393
+
394
+ async update(userId: string, keyId: string, params: UpdateApiKeyParams): Promise<ApiKey | null> {
395
+ const updates: string[] = [];
396
+ const values: unknown[] = [userId, keyId];
397
+ let paramIndex = 3;
398
+
399
+ if (params.name !== undefined) {
400
+ updates.push(`name = $${paramIndex++}`);
401
+ values.push(params.name);
402
+ }
403
+
404
+ if (params.scopes !== undefined) {
405
+ updates.push(`scopes = $${paramIndex++}`);
406
+ values.push(params.scopes);
407
+ }
408
+
409
+ if (params.expires_at !== undefined) {
410
+ updates.push(`expires_at = $${paramIndex++}`);
411
+ values.push(params.expires_at);
412
+ }
413
+
414
+ if (params.is_active !== undefined) {
415
+ updates.push(`is_active = $${paramIndex++}`);
416
+ values.push(params.is_active);
417
+ }
418
+
419
+ if (updates.length === 0) {
420
+ // No updates, just return current key
421
+ return this.get(userId, keyId);
422
+ }
423
+
424
+ updates.push(`updated_at = NOW()`);
425
+
426
+ return withRLSContext(getPool(), userId, async (client) => {
427
+ const result = await client.query(
428
+ `UPDATE ${tableFullName}
429
+ SET ${updates.join(', ')}
430
+ WHERE user_id = $1 AND id = $2
431
+ RETURNING *`,
432
+ values
433
+ );
434
+
435
+ if (result.rows.length === 0) {
436
+ return null;
437
+ }
438
+
439
+ const updatedKey = result.rows[0] as ApiKey;
440
+
441
+ logger.info('API key updated', {
442
+ action: 'api_key.updated',
443
+ user_id: userId,
444
+ key_id: keyId,
445
+ changes: Object.keys(params),
446
+ timestamp: new Date().toISOString(),
447
+ });
448
+
449
+ return updatedKey;
450
+ });
451
+ },
452
+
453
+ async delete(userId: string, keyId: string): Promise<boolean> {
454
+ return withRLSContext(getPool(), userId, async (client) => {
455
+ const result = await client.query(
456
+ `DELETE FROM ${tableFullName} WHERE user_id = $1 AND id = $2`,
457
+ [userId, keyId]
458
+ );
459
+
460
+ const deleted = (result.rowCount ?? 0) > 0;
461
+
462
+ if (deleted) {
463
+ logger.info('API key deleted', {
464
+ action: 'api_key.deleted',
465
+ user_id: userId,
466
+ key_id: keyId,
467
+ timestamp: new Date().toISOString(),
468
+ });
469
+ }
470
+
471
+ return deleted;
472
+ });
473
+ },
474
+
475
+ async recordUsage(keyId: string): Promise<void> {
476
+ const pool = getPool();
477
+ await pool.query(
478
+ `UPDATE ${tableFullName} SET last_used_at = NOW() WHERE id = $1`,
479
+ [keyId]
480
+ );
481
+ },
482
+
483
+ async shutdown(): Promise<void> {
484
+ // Pool is managed externally, nothing to do here
485
+ },
486
+ };
487
+ }