@lti-tool/postgresql 1.0.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.
@@ -0,0 +1,631 @@
1
+ import {
2
+ isServerlessEnvironment,
3
+ type LTIClient,
4
+ type LTIDeployment,
5
+ type LTIDynamicRegistrationSession,
6
+ type LTILaunchConfig,
7
+ type LTISession,
8
+ type LTIStorage,
9
+ } from '@lti-tool/core';
10
+ import { and, eq, gt, lt } from 'drizzle-orm';
11
+ import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js';
12
+ import type { Logger } from 'pino';
13
+ import postgres from 'postgres';
14
+
15
+ import {
16
+ LAUNCH_CONFIG_CACHE,
17
+ SESSION_CACHE,
18
+ SESSION_TTL,
19
+ undefinedLaunchConfigValue,
20
+ undefinedSessionValue,
21
+ } from './cacheConfig.js';
22
+ import * as schema from './db/schema/index.js';
23
+ import type { PostgresStorageConfig } from './interfaces/postgresStorageConfig.js';
24
+
25
+ /**
26
+ * PostgreSQL implementation of LTI storage interface.
27
+ *
28
+ * Stores clients, deployments, sessions, and nonces in PostgreSQL with LRU caching.
29
+ * Uses Drizzle ORM for type-safe database operations.
30
+ */
31
+ export class PostgresStorage implements LTIStorage {
32
+ private logger: Logger;
33
+ private db: PostgresJsDatabase<typeof schema>;
34
+ private sql: postgres.Sql;
35
+ private nonceExpirationSeconds: number;
36
+
37
+ constructor(config: PostgresStorageConfig) {
38
+ this.logger =
39
+ config?.logger ??
40
+ ({
41
+ debug: () => {},
42
+ info: () => {},
43
+ warn: () => {},
44
+ error: () => {},
45
+ } as unknown as Logger);
46
+
47
+ this.nonceExpirationSeconds = config.nonceExpirationSeconds ?? 600;
48
+
49
+ // Smart connection limit defaults
50
+ const isServerless = isServerlessEnvironment();
51
+ const defaultMax = isServerless ? 1 : 10;
52
+ const max = config.poolOptions?.max ?? defaultMax;
53
+
54
+ // Warn if high connection limit in serverless
55
+ if (isServerless && max > 5) {
56
+ this.logger.warn(
57
+ { max, environment: 'serverless' },
58
+ 'High connection limit detected in serverless environment. Consider using 1 connection per container to avoid wasting resources.',
59
+ );
60
+ }
61
+
62
+ // Create postgres.js connection
63
+ this.sql = postgres(config.connectionUrl, {
64
+ max,
65
+ idle_timeout: config.poolOptions?.idleTimeout ?? 20,
66
+ });
67
+
68
+ // Initialize Drizzle
69
+ this.db = drizzle(this.sql, { schema });
70
+
71
+ this.logger.debug(
72
+ {
73
+ max,
74
+ isServerless,
75
+ idleTimeout: config.poolOptions?.idleTimeout ?? 20,
76
+ },
77
+ 'PostgreSQL connection pool initialized',
78
+ );
79
+ }
80
+
81
+ async listClients(): Promise<Omit<LTIClient, 'deployments'>[]> {
82
+ this.logger.debug('listing all clients');
83
+
84
+ const clients = await this.db.select().from(schema.clientsTable);
85
+
86
+ this.logger.debug({ count: clients.length }, 'clients found');
87
+ return clients;
88
+ }
89
+
90
+ async getClientById(clientId: string): Promise<LTIClient | undefined> {
91
+ this.logger.debug({ clientId }, 'getting client by id');
92
+
93
+ const [client] = await this.db
94
+ .select()
95
+ .from(schema.clientsTable)
96
+ .where(eq(schema.clientsTable.id, clientId))
97
+ .limit(1);
98
+
99
+ if (!client) {
100
+ this.logger.warn({ clientId }, 'client not found');
101
+ return undefined;
102
+ }
103
+
104
+ // Get all deployments for this client
105
+ const deploymentsRaw = await this.db
106
+ .select()
107
+ .from(schema.deploymentsTable)
108
+ .where(eq(schema.deploymentsTable.clientId, clientId));
109
+
110
+ // Convert null to undefined for optional fields
111
+ const deployments = deploymentsRaw.map((d) => ({
112
+ ...d,
113
+ name: d.name ?? undefined,
114
+ description: d.description ?? undefined,
115
+ }));
116
+
117
+ return {
118
+ ...client,
119
+ deployments,
120
+ };
121
+ }
122
+
123
+ async addClient(client: Omit<LTIClient, 'id'>): Promise<string> {
124
+ this.logger.info({ client }, 'adding client');
125
+
126
+ // Filter out deployments from client data
127
+ const { deployments: _clientDeployments, ...clientWithoutDeployments } = client;
128
+
129
+ const [inserted] = await this.db
130
+ .insert(schema.clientsTable)
131
+ .values(clientWithoutDeployments)
132
+ .returning({ id: schema.clientsTable.id });
133
+
134
+ this.logger.debug({ clientId: inserted.id }, 'client added');
135
+ return inserted.id;
136
+ }
137
+
138
+ async updateClient(
139
+ clientId: string,
140
+ client: Partial<Omit<LTIClient, 'id'>>,
141
+ ): Promise<void> {
142
+ this.logger.info({ clientId, client }, 'updating client');
143
+
144
+ // Get existing client to validate it exists
145
+ const existing = await this.getClientById(clientId);
146
+ if (!existing) throw new Error('Client not found');
147
+
148
+ // Check if launch config keys would change
149
+ const issuerChanged = client.iss && client.iss !== existing.iss;
150
+ const lmsClientIdChanged = client.clientId && client.clientId !== existing.clientId;
151
+
152
+ if (issuerChanged || lmsClientIdChanged) {
153
+ // Clear affected launch configs from cache
154
+ for (const deployment of existing.deployments) {
155
+ const cacheKey = `${existing.iss}#${existing.clientId}#${deployment.deploymentId}`;
156
+ LAUNCH_CONFIG_CACHE.delete(cacheKey);
157
+ }
158
+ }
159
+
160
+ // Filter out deployments from client data
161
+ const { deployments: _clientDeployments, ...clientWithoutDeployments } = client;
162
+
163
+ // Update the client
164
+ await this.db
165
+ .update(schema.clientsTable)
166
+ .set(clientWithoutDeployments)
167
+ .where(eq(schema.clientsTable.id, clientId));
168
+
169
+ // Clear and rebuild launch config cache
170
+ await this.updateClientLaunchConfigs(clientId);
171
+
172
+ this.logger.debug({ clientId }, 'client updated');
173
+ }
174
+
175
+ async deleteClient(clientId: string): Promise<void> {
176
+ this.logger.info({ clientId }, 'deleting client');
177
+
178
+ // Get client data to extract details for cache cleanup
179
+ const existing = await this.getClientById(clientId);
180
+ if (!existing) {
181
+ this.logger.warn({ clientId }, 'client not found for deletion');
182
+ return;
183
+ }
184
+
185
+ // Clear launch config cache
186
+ for (const deployment of existing.deployments) {
187
+ const cacheKey = `${existing.iss}#${existing.clientId}#${deployment.deploymentId}`;
188
+ LAUNCH_CONFIG_CACHE.delete(cacheKey);
189
+ }
190
+
191
+ // Delete client and all deployments in a transaction
192
+ await this.db.transaction(async (tx) => {
193
+ // Delete all deployments first (child records)
194
+ await tx
195
+ .delete(schema.deploymentsTable)
196
+ .where(eq(schema.deploymentsTable.clientId, clientId));
197
+
198
+ this.logger.debug({ clientId }, 'deployments deleted');
199
+
200
+ // Then delete the client (parent record)
201
+ await tx.delete(schema.clientsTable).where(eq(schema.clientsTable.id, clientId));
202
+
203
+ this.logger.debug({ clientId }, 'client deleted');
204
+ });
205
+
206
+ this.logger.debug({ clientId }, 'client and all deployments deleted');
207
+ }
208
+
209
+ private async updateClientLaunchConfigs(clientId: string): Promise<void> {
210
+ this.logger.debug({ clientId }, 'updating client launch configs');
211
+
212
+ const client = await this.getClientById(clientId);
213
+ if (!client) {
214
+ this.logger.warn({ clientId }, 'client not found for launch config update');
215
+ return;
216
+ }
217
+
218
+ // Clear cache for all deployments (configs are derived on demand)
219
+ for (const deployment of client.deployments) {
220
+ const cacheKey = `${client.iss}#${client.clientId}#${deployment.deploymentId}`;
221
+ LAUNCH_CONFIG_CACHE.delete(cacheKey);
222
+ }
223
+
224
+ this.logger.debug(
225
+ { clientId, count: client.deployments.length },
226
+ 'client launch configs cache cleared',
227
+ );
228
+ }
229
+
230
+ async listDeployments(clientId: string): Promise<LTIDeployment[]> {
231
+ this.logger.debug({ clientId }, 'listing deployments for client');
232
+
233
+ const deploymentsRaw = await this.db
234
+ .select()
235
+ .from(schema.deploymentsTable)
236
+ .where(eq(schema.deploymentsTable.clientId, clientId));
237
+
238
+ // Convert null to undefined for optional fields
239
+ const deployments = deploymentsRaw.map((d) => ({
240
+ ...d,
241
+ name: d.name ?? undefined,
242
+ description: d.description ?? undefined,
243
+ }));
244
+
245
+ this.logger.debug({ clientId, count: deployments.length }, 'deployments found');
246
+ return deployments;
247
+ }
248
+
249
+ async getDeployment(
250
+ clientId: string,
251
+ deploymentId: string,
252
+ ): Promise<LTIDeployment | undefined> {
253
+ this.logger.debug({ clientId, deploymentId }, 'getting deployment by id');
254
+
255
+ const [deployment] = await this.db
256
+ .select()
257
+ .from(schema.deploymentsTable)
258
+ .where(
259
+ and(
260
+ eq(schema.deploymentsTable.clientId, clientId),
261
+ eq(schema.deploymentsTable.id, deploymentId),
262
+ ),
263
+ )
264
+ .limit(1);
265
+
266
+ if (!deployment) {
267
+ this.logger.warn({ clientId, deploymentId }, 'deployment not found');
268
+ return undefined;
269
+ }
270
+
271
+ // Convert null to undefined for optional fields
272
+ return {
273
+ ...deployment,
274
+ name: deployment.name ?? undefined,
275
+ description: deployment.description ?? undefined,
276
+ };
277
+ }
278
+
279
+ async addDeployment(
280
+ clientId: string,
281
+ deployment: Omit<LTIDeployment, 'id'>,
282
+ ): Promise<string> {
283
+ this.logger.info({ clientId, deployment }, 'adding deployment');
284
+
285
+ const [inserted] = await this.db
286
+ .insert(schema.deploymentsTable)
287
+ .values({
288
+ clientId,
289
+ ...deployment,
290
+ })
291
+ .returning({ id: schema.deploymentsTable.id });
292
+
293
+ this.logger.debug({ deploymentInternalId: inserted.id }, 'deployment added');
294
+ return inserted.id;
295
+ }
296
+
297
+ async updateDeployment(
298
+ clientId: string,
299
+ deploymentId: string,
300
+ deployment: Partial<LTIDeployment>,
301
+ ): Promise<void> {
302
+ this.logger.info({ clientId, deploymentId, deployment }, 'updating deployment');
303
+
304
+ // Get existing deployment to validate it exists
305
+ const existing = await this.getDeployment(clientId, deploymentId);
306
+ if (!existing) throw new Error('Deployment not found');
307
+
308
+ // Check if LMS deployment id changed (affects launch config cache)
309
+ const lmsDeploymentIdChanged =
310
+ deployment.deploymentId && deployment.deploymentId !== existing.deploymentId;
311
+
312
+ if (lmsDeploymentIdChanged) {
313
+ const client = await this.getClientById(clientId);
314
+ if (client) {
315
+ const cacheKey = `${client.iss}#${client.clientId}#${existing.deploymentId}`;
316
+ LAUNCH_CONFIG_CACHE.delete(cacheKey);
317
+ }
318
+ }
319
+
320
+ // Update deployment data
321
+ await this.db
322
+ .update(schema.deploymentsTable)
323
+ .set(deployment)
324
+ .where(eq(schema.deploymentsTable.id, deploymentId));
325
+
326
+ this.logger.debug({ deploymentId }, 'deployment updated');
327
+ }
328
+
329
+ async deleteDeployment(clientId: string, deploymentId: string): Promise<void> {
330
+ this.logger.info({ clientId, deploymentId }, 'deleting deployment');
331
+
332
+ // Get deployment and client data for cache cleanup
333
+ const existing = await this.getDeployment(clientId, deploymentId);
334
+ if (!existing) {
335
+ this.logger.warn({ clientId, deploymentId }, 'deployment not found for deletion');
336
+ return;
337
+ }
338
+
339
+ const client = await this.getClientById(clientId);
340
+ if (client) {
341
+ const cacheKey = `${client.iss}#${client.clientId}#${existing.deploymentId}`;
342
+ LAUNCH_CONFIG_CACHE.delete(cacheKey);
343
+ }
344
+
345
+ await this.db
346
+ .delete(schema.deploymentsTable)
347
+ .where(eq(schema.deploymentsTable.id, deploymentId));
348
+
349
+ this.logger.debug({ clientId, deploymentId }, 'deployment deleted');
350
+ }
351
+
352
+ // oxlint-disable-next-line no-unused-vars require-await
353
+ async storeNonce(nonce: string, expiresAt: Date): Promise<void> {
354
+ // Noop - the real work happens in validateNonce
355
+ this.logger.trace({ nonce, expiresAt }, 'nonce will be validated on use');
356
+ }
357
+
358
+ async validateNonce(nonce: string): Promise<boolean> {
359
+ this.logger.debug({ nonce }, 'validating nonce');
360
+
361
+ // 1. Check if nonce exists and is still valid (not expired)
362
+ const [existing] = await this.db
363
+ .select()
364
+ .from(schema.noncesTable)
365
+ .where(
366
+ and(
367
+ eq(schema.noncesTable.nonce, nonce),
368
+ gt(schema.noncesTable.expiresAt, new Date()), // expiresAt > NOW()
369
+ ),
370
+ )
371
+ .limit(1);
372
+
373
+ if (existing) {
374
+ this.logger.warn({ nonce }, 'nonce already used - replay attack detected');
375
+ return false; // Nonce exists and hasn't expired = replay attack
376
+ }
377
+
378
+ // 2. Try to insert the nonce
379
+ const expiresAt = new Date(Date.now() + this.nonceExpirationSeconds * 1000);
380
+
381
+ try {
382
+ await this.db.insert(schema.noncesTable).values({ nonce, expiresAt });
383
+ return true;
384
+ } catch (error) {
385
+ // Duplicate key error (race condition - another request inserted same nonce)
386
+ // PostgreSQL error code for unique_violation
387
+ if ((error as { code?: string }).code === '23505') {
388
+ this.logger.warn({ nonce }, 'nonce collision detected - replay attack');
389
+ return false;
390
+ }
391
+ throw error;
392
+ }
393
+ }
394
+
395
+ async getSession(sessionId: string): Promise<LTISession | undefined> {
396
+ this.logger.debug({ sessionId }, 'getting session');
397
+
398
+ // Check cache first
399
+ const cachedSession = SESSION_CACHE.get(sessionId);
400
+ if (cachedSession === undefinedSessionValue) {
401
+ return undefined;
402
+ }
403
+ if (cachedSession) {
404
+ this.logger.debug({ sessionId }, 'session found in cache');
405
+ return cachedSession;
406
+ }
407
+
408
+ // Query database
409
+ const [sessionRecord] = await this.db
410
+ .select()
411
+ .from(schema.sessionsTable)
412
+ .where(
413
+ and(
414
+ eq(schema.sessionsTable.id, sessionId),
415
+ gt(schema.sessionsTable.expiresAt, new Date()), // Not expired
416
+ ),
417
+ )
418
+ .limit(1);
419
+
420
+ if (!sessionRecord) {
421
+ this.logger.warn({ sessionId }, 'session not found');
422
+ SESSION_CACHE.set(sessionId, undefinedSessionValue);
423
+ return undefined;
424
+ }
425
+
426
+ const session: LTISession = {
427
+ id: sessionRecord.id,
428
+ ...sessionRecord.data,
429
+ };
430
+
431
+ SESSION_CACHE.set(sessionId, session);
432
+ return session;
433
+ }
434
+
435
+ async addSession(session: LTISession): Promise<string> {
436
+ this.logger.debug({ sessionId: session.id }, 'adding session');
437
+
438
+ const expiresAt = new Date(Date.now() + SESSION_TTL * 1000);
439
+ const { id, ...data } = session;
440
+
441
+ await this.db.insert(schema.sessionsTable).values({
442
+ id,
443
+ data,
444
+ expiresAt,
445
+ });
446
+
447
+ // Cache the session
448
+ SESSION_CACHE.set(session.id, session);
449
+ this.logger.debug({ sessionId: session.id }, 'session added');
450
+ return session.id;
451
+ }
452
+
453
+ // oxlint-disable-next-line max-lines-per-function
454
+ async getLaunchConfig(
455
+ iss: string,
456
+ clientId: string,
457
+ deploymentId: string,
458
+ ): Promise<LTILaunchConfig | undefined> {
459
+ this.logger.debug({ iss, clientId, deploymentId }, 'getting launch config');
460
+
461
+ // Check cache
462
+ const cacheKey = `${iss}#${clientId}#${deploymentId}`;
463
+ const cachedConfig = LAUNCH_CONFIG_CACHE.get(cacheKey);
464
+ if (cachedConfig === undefinedLaunchConfigValue) {
465
+ return undefined;
466
+ }
467
+ if (cachedConfig) {
468
+ this.logger.debug({ cachedConfig }, 'launch config found in cache');
469
+ return cachedConfig;
470
+ }
471
+
472
+ // Query for client and deployment
473
+ const [result] = await this.db
474
+ .select({
475
+ client: schema.clientsTable,
476
+ deployment: schema.deploymentsTable,
477
+ })
478
+ .from(schema.clientsTable)
479
+ .innerJoin(
480
+ schema.deploymentsTable,
481
+ eq(schema.deploymentsTable.clientId, schema.clientsTable.id),
482
+ )
483
+ .where(
484
+ and(
485
+ eq(schema.clientsTable.iss, iss),
486
+ eq(schema.clientsTable.clientId, clientId),
487
+ eq(schema.deploymentsTable.deploymentId, deploymentId),
488
+ ),
489
+ )
490
+ .limit(1);
491
+
492
+ if (!result) {
493
+ // Try with 'default' deployment (for dynamic registration)
494
+ if (deploymentId !== 'default') {
495
+ this.logger.debug({ deploymentId }, 'trying default deployment fallback');
496
+ return this.getLaunchConfig(iss, clientId, 'default');
497
+ }
498
+
499
+ this.logger.warn({ iss, clientId, deploymentId }, 'launch config not found');
500
+ LAUNCH_CONFIG_CACHE.set(cacheKey, undefinedLaunchConfigValue);
501
+ return undefined;
502
+ }
503
+
504
+ const launchConfig: LTILaunchConfig = {
505
+ iss: result.client.iss,
506
+ clientId: result.client.clientId,
507
+ deploymentId: result.deployment.deploymentId,
508
+ authUrl: result.client.authUrl,
509
+ tokenUrl: result.client.tokenUrl,
510
+ jwksUrl: result.client.jwksUrl,
511
+ };
512
+
513
+ LAUNCH_CONFIG_CACHE.set(cacheKey, launchConfig);
514
+ return launchConfig;
515
+ }
516
+
517
+ // oxlint-disable-next-line require-await no-unused-vars
518
+ async saveLaunchConfig(launchConfig: LTILaunchConfig): Promise<void> {
519
+ // PostgreSQL storage doesn't need to persist launch configs separately
520
+ // since they're derived from client + deployment data
521
+ this.logger.debug(
522
+ { launchConfig },
523
+ 'launch config would be saved (no-op in PostgreSQL)',
524
+ );
525
+ }
526
+
527
+ async setRegistrationSession(
528
+ sessionId: string,
529
+ session: LTIDynamicRegistrationSession,
530
+ ): Promise<void> {
531
+ this.logger.debug({ sessionId }, 'setting registration session');
532
+
533
+ const expiresAt = new Date(session.expiresAt);
534
+
535
+ await this.db.insert(schema.registrationSessionsTable).values({
536
+ id: sessionId,
537
+ data: session,
538
+ expiresAt,
539
+ });
540
+
541
+ this.logger.debug({ sessionId }, 'registration session stored');
542
+ }
543
+
544
+ async getRegistrationSession(
545
+ sessionId: string,
546
+ ): Promise<LTIDynamicRegistrationSession | undefined> {
547
+ this.logger.debug({ sessionId }, 'getting registration session');
548
+
549
+ const [record] = await this.db
550
+ .select()
551
+ .from(schema.registrationSessionsTable)
552
+ .where(
553
+ and(
554
+ eq(schema.registrationSessionsTable.id, sessionId),
555
+ gt(schema.registrationSessionsTable.expiresAt, new Date()),
556
+ ),
557
+ )
558
+ .limit(1);
559
+
560
+ if (!record) {
561
+ this.logger.warn({ sessionId }, 'registration session not found or expired');
562
+ return undefined;
563
+ }
564
+
565
+ return record.data;
566
+ }
567
+
568
+ async deleteRegistrationSession(sessionId: string): Promise<void> {
569
+ this.logger.debug({ sessionId }, 'deleting registration session');
570
+
571
+ await this.db
572
+ .delete(schema.registrationSessionsTable)
573
+ .where(eq(schema.registrationSessionsTable.id, sessionId));
574
+
575
+ this.logger.debug({ sessionId }, 'registration session deleted');
576
+ }
577
+
578
+ /**
579
+ * Clean up expired nonces, sessions, and registration sessions.
580
+ * Should be called periodically (e.g., every 30 minutes via EventBridge).
581
+ *
582
+ * @returns Object with counts of deleted items
583
+ */
584
+ async cleanup(): Promise<{
585
+ noncesDeleted: number;
586
+ sessionsDeleted: number;
587
+ registrationSessionsDeleted: number;
588
+ }> {
589
+ this.logger.info('starting cleanup of expired items');
590
+
591
+ const now = new Date();
592
+
593
+ // Delete expired nonces
594
+ const noncesResult = await this.db
595
+ .delete(schema.noncesTable)
596
+ .where(lt(schema.noncesTable.expiresAt, now))
597
+ .returning({ nonce: schema.noncesTable.nonce });
598
+
599
+ // Delete expired sessions
600
+ const sessionsResult = await this.db
601
+ .delete(schema.sessionsTable)
602
+ .where(lt(schema.sessionsTable.expiresAt, now))
603
+ .returning({ id: schema.sessionsTable.id });
604
+
605
+ // Delete expired registration sessions
606
+ const regSessionsResult = await this.db
607
+ .delete(schema.registrationSessionsTable)
608
+ .where(lt(schema.registrationSessionsTable.expiresAt, now))
609
+ .returning({ id: schema.registrationSessionsTable.id });
610
+
611
+ const result = {
612
+ noncesDeleted: noncesResult.length,
613
+ sessionsDeleted: sessionsResult.length,
614
+ registrationSessionsDeleted: regSessionsResult.length,
615
+ };
616
+
617
+ this.logger.info(result, 'cleanup completed');
618
+ return result;
619
+ }
620
+
621
+ /**
622
+ * Close the PostgreSQL connection pool.
623
+ * Should be called on graceful server shutdown or after tests.
624
+ * Not required for serverless environments (Lambda manages lifecycle).
625
+ */
626
+ async close(): Promise<void> {
627
+ this.logger.debug('closing PostgreSQL connection pool');
628
+ await this.sql.end();
629
+ this.logger.debug('PostgreSQL connection pool closed');
630
+ }
631
+ }