@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.
- package/CHANGELOG.md +43 -0
- package/dist/core/control-panel.d.ts.map +1 -1
- package/dist/core/control-panel.js +41 -0
- package/dist/core/control-panel.js.map +1 -1
- package/dist/core/guards.d.ts.map +1 -1
- package/dist/core/guards.js +77 -0
- package/dist/core/guards.js.map +1 -1
- package/dist/core/health-manager.d.ts +4 -0
- package/dist/core/health-manager.d.ts.map +1 -1
- package/dist/core/health-manager.js +6 -1
- package/dist/core/health-manager.js.map +1 -1
- package/dist/core/plugin-registry.d.ts +55 -5
- package/dist/core/plugin-registry.d.ts.map +1 -1
- package/dist/core/plugin-registry.js +57 -19
- package/dist/core/plugin-registry.js.map +1 -1
- package/dist/core/types.d.ts +2 -0
- package/dist/core/types.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/plugins/api-keys/api-keys-plugin.d.ts +46 -0
- package/dist/plugins/api-keys/api-keys-plugin.d.ts.map +1 -0
- package/dist/plugins/api-keys/api-keys-plugin.js +329 -0
- package/dist/plugins/api-keys/api-keys-plugin.js.map +1 -0
- package/dist/plugins/api-keys/index.d.ts +14 -0
- package/dist/plugins/api-keys/index.d.ts.map +1 -0
- package/dist/plugins/api-keys/index.js +17 -0
- package/dist/plugins/api-keys/index.js.map +1 -0
- package/dist/plugins/api-keys/middleware/bearer-token-auth.d.ts +74 -0
- package/dist/plugins/api-keys/middleware/bearer-token-auth.d.ts.map +1 -0
- package/dist/plugins/api-keys/middleware/bearer-token-auth.js +201 -0
- package/dist/plugins/api-keys/middleware/bearer-token-auth.js.map +1 -0
- package/dist/plugins/api-keys/middleware/index.d.ts +7 -0
- package/dist/plugins/api-keys/middleware/index.d.ts.map +1 -0
- package/dist/plugins/api-keys/middleware/index.js +7 -0
- package/dist/plugins/api-keys/middleware/index.js.map +1 -0
- package/dist/plugins/api-keys/stores/index.d.ts +7 -0
- package/dist/plugins/api-keys/stores/index.d.ts.map +1 -0
- package/dist/plugins/api-keys/stores/index.js +7 -0
- package/dist/plugins/api-keys/stores/index.js.map +1 -0
- package/dist/plugins/api-keys/stores/postgres-store.d.ts +34 -0
- package/dist/plugins/api-keys/stores/postgres-store.d.ts.map +1 -0
- package/dist/plugins/api-keys/stores/postgres-store.js +360 -0
- package/dist/plugins/api-keys/stores/postgres-store.js.map +1 -0
- package/dist/plugins/api-keys/types.d.ts +268 -0
- package/dist/plugins/api-keys/types.d.ts.map +1 -0
- package/dist/plugins/api-keys/types.js +56 -0
- package/dist/plugins/api-keys/types.js.map +1 -0
- package/dist/plugins/auth/auth-plugin.d.ts.map +1 -1
- package/dist/plugins/auth/auth-plugin.js +17 -1
- package/dist/plugins/auth/auth-plugin.js.map +1 -1
- package/dist/plugins/auth/auth-plugin.test.js +133 -0
- package/dist/plugins/auth/auth-plugin.test.js.map +1 -1
- package/dist/plugins/auth/env-config.d.ts.map +1 -1
- package/dist/plugins/auth/env-config.js +6 -2
- package/dist/plugins/auth/env-config.js.map +1 -1
- package/dist/plugins/auth/types.d.ts +10 -0
- package/dist/plugins/auth/types.d.ts.map +1 -1
- package/dist/plugins/auth/types.js.map +1 -1
- package/dist/plugins/devices/__tests__/token-utils.test.js +4 -2
- package/dist/plugins/devices/__tests__/token-utils.test.js.map +1 -1
- package/dist/plugins/frontend-app-plugin.d.ts.map +1 -1
- package/dist/plugins/frontend-app-plugin.js +21 -4
- package/dist/plugins/frontend-app-plugin.js.map +1 -1
- package/dist/plugins/index.d.ts +2 -0
- package/dist/plugins/index.d.ts.map +1 -1
- package/dist/plugins/index.js +2 -0
- package/dist/plugins/index.js.map +1 -1
- package/dist/plugins/qwickbrain/index.d.ts +25 -0
- package/dist/plugins/qwickbrain/index.d.ts.map +1 -0
- package/dist/plugins/qwickbrain/index.js +24 -0
- package/dist/plugins/qwickbrain/index.js.map +1 -0
- package/dist/plugins/qwickbrain/qwickbrain-plugin.d.ts +23 -0
- package/dist/plugins/qwickbrain/qwickbrain-plugin.d.ts.map +1 -0
- package/dist/plugins/qwickbrain/qwickbrain-plugin.js +528 -0
- package/dist/plugins/qwickbrain/qwickbrain-plugin.js.map +1 -0
- package/dist/plugins/qwickbrain/types.d.ts +131 -0
- package/dist/plugins/qwickbrain/types.d.ts.map +1 -0
- package/dist/plugins/qwickbrain/types.js +9 -0
- package/dist/plugins/qwickbrain/types.js.map +1 -0
- package/dist/plugins/users/__tests__/postgres-store.test.js +1 -0
- package/dist/plugins/users/__tests__/postgres-store.test.js.map +1 -1
- package/dist/plugins/users/__tests__/users-plugin.test.js +3 -0
- package/dist/plugins/users/__tests__/users-plugin.test.js.map +1 -1
- package/dist/plugins/users/stores/postgres-store.d.ts.map +1 -1
- package/dist/plugins/users/stores/postgres-store.js +59 -1
- package/dist/plugins/users/stores/postgres-store.js.map +1 -1
- package/dist/plugins/users/types.d.ts +22 -0
- package/dist/plugins/users/types.d.ts.map +1 -1
- package/dist-ui/assets/index-5nX8fM1a.js +469 -0
- package/dist-ui/assets/index-5nX8fM1a.js.map +1 -0
- package/dist-ui/index.html +1 -1
- package/dist-ui-lib/api/controlPanelApi.d.ts +68 -0
- package/dist-ui-lib/components/index.d.ts +2 -1
- package/dist-ui-lib/index.js +2642 -2281
- package/dist-ui-lib/index.js.map +1 -1
- package/dist-ui-lib/pages/APIKeysPage.d.ts +13 -0
- package/dist-ui-lib/pages/AcceptInvitationPage.d.ts +28 -0
- package/package.json +3 -2
- package/src/core/control-panel.ts +47 -0
- package/src/core/guards.ts +89 -0
- package/src/core/health-manager.ts +6 -1
- package/src/core/plugin-registry.ts +123 -25
- package/src/core/types.ts +2 -0
- package/src/index.ts +11 -0
- package/src/plugins/api-keys/api-keys-plugin.ts +397 -0
- package/src/plugins/api-keys/index.ts +49 -0
- package/src/plugins/api-keys/middleware/bearer-token-auth.ts +250 -0
- package/src/plugins/api-keys/middleware/index.ts +12 -0
- package/src/plugins/api-keys/stores/index.ts +7 -0
- package/src/plugins/api-keys/stores/postgres-store.ts +487 -0
- package/src/plugins/api-keys/types.ts +243 -0
- package/src/plugins/auth/auth-plugin.test.ts +167 -0
- package/src/plugins/auth/auth-plugin.ts +17 -1
- package/src/plugins/auth/env-config.ts +6 -2
- package/src/plugins/auth/types.ts +10 -0
- package/src/plugins/devices/__tests__/token-utils.test.ts +4 -2
- package/src/plugins/frontend-app-plugin.ts +24 -4
- package/src/plugins/index.ts +15 -0
- package/src/plugins/qwickbrain/index.ts +33 -0
- package/src/plugins/qwickbrain/qwickbrain-plugin.ts +642 -0
- package/src/plugins/qwickbrain/types.ts +146 -0
- package/src/plugins/users/__tests__/postgres-store.test.ts +1 -0
- package/src/plugins/users/__tests__/users-plugin.test.ts +3 -0
- package/src/plugins/users/stores/postgres-store.ts +69 -0
- package/src/plugins/users/types.ts +25 -0
- package/ui/src/App.tsx +6 -1
- package/ui/src/api/controlPanelApi.ts +206 -37
- package/ui/src/components/index.ts +6 -0
- package/ui/src/pages/APIKeysPage.tsx +661 -0
- package/ui/src/pages/AcceptInvitationPage.tsx +169 -0
- package/ui/src/pages/UsersPage.tsx +225 -2
- package/dist-ui/assets/index-CynOqPkb.js +0 -469
- package/dist-ui/assets/index-CynOqPkb.js.map +0 -1
|
@@ -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
|
+
}
|