@lti-tool/postgresql 1.0.0 → 1.0.1

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 (33) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +294 -0
  3. package/dist/cacheConfig.d.ts +11 -0
  4. package/dist/cacheConfig.d.ts.map +1 -0
  5. package/dist/cacheConfig.js +14 -0
  6. package/dist/db/schema/clients.schema.d.ts +133 -0
  7. package/dist/db/schema/clients.schema.d.ts.map +1 -0
  8. package/dist/db/schema/clients.schema.js +13 -0
  9. package/dist/db/schema/deployments.schema.d.ts +97 -0
  10. package/dist/db/schema/deployments.schema.d.ts.map +1 -0
  11. package/dist/db/schema/deployments.schema.js +14 -0
  12. package/dist/db/schema/index.d.ts +6 -0
  13. package/dist/db/schema/index.d.ts.map +1 -0
  14. package/dist/db/schema/index.js +5 -0
  15. package/dist/db/schema/nonces.schema.d.ts +44 -0
  16. package/dist/db/schema/nonces.schema.d.ts.map +1 -0
  17. package/dist/db/schema/nonces.schema.js +5 -0
  18. package/dist/db/schema/registrationSessions.schema.d.ts +62 -0
  19. package/dist/db/schema/registrationSessions.schema.d.ts.map +1 -0
  20. package/dist/db/schema/registrationSessions.schema.js +8 -0
  21. package/dist/db/schema/sessions.schema.d.ts +62 -0
  22. package/dist/db/schema/sessions.schema.d.ts.map +1 -0
  23. package/dist/db/schema/sessions.schema.js +6 -0
  24. package/dist/index.d.ts +3 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +1 -0
  27. package/dist/interfaces/postgresStorageConfig.d.ts +35 -0
  28. package/dist/interfaces/postgresStorageConfig.d.ts.map +1 -0
  29. package/dist/interfaces/postgresStorageConfig.js +1 -0
  30. package/dist/postgresStorage.d.ts +53 -0
  31. package/dist/postgresStorage.d.ts.map +1 -0
  32. package/dist/postgresStorage.js +443 -0
  33. package/package.json +1 -1
@@ -0,0 +1,443 @@
1
+ import { isServerlessEnvironment, } from '@lti-tool/core';
2
+ import { and, eq, gt, lt } from 'drizzle-orm';
3
+ import { drizzle } from 'drizzle-orm/postgres-js';
4
+ import postgres from 'postgres';
5
+ import { LAUNCH_CONFIG_CACHE, SESSION_CACHE, SESSION_TTL, undefinedLaunchConfigValue, undefinedSessionValue, } from './cacheConfig.js';
6
+ import * as schema from './db/schema/index.js';
7
+ /**
8
+ * PostgreSQL implementation of LTI storage interface.
9
+ *
10
+ * Stores clients, deployments, sessions, and nonces in PostgreSQL with LRU caching.
11
+ * Uses Drizzle ORM for type-safe database operations.
12
+ */
13
+ export class PostgresStorage {
14
+ logger;
15
+ db;
16
+ sql;
17
+ nonceExpirationSeconds;
18
+ constructor(config) {
19
+ this.logger =
20
+ config?.logger ??
21
+ {
22
+ debug: () => { },
23
+ info: () => { },
24
+ warn: () => { },
25
+ error: () => { },
26
+ };
27
+ this.nonceExpirationSeconds = config.nonceExpirationSeconds ?? 600;
28
+ // Smart connection limit defaults
29
+ const isServerless = isServerlessEnvironment();
30
+ const defaultMax = isServerless ? 1 : 10;
31
+ const max = config.poolOptions?.max ?? defaultMax;
32
+ // Warn if high connection limit in serverless
33
+ if (isServerless && max > 5) {
34
+ this.logger.warn({ max, environment: 'serverless' }, 'High connection limit detected in serverless environment. Consider using 1 connection per container to avoid wasting resources.');
35
+ }
36
+ // Create postgres.js connection
37
+ this.sql = postgres(config.connectionUrl, {
38
+ max,
39
+ idle_timeout: config.poolOptions?.idleTimeout ?? 20,
40
+ });
41
+ // Initialize Drizzle
42
+ this.db = drizzle(this.sql, { schema });
43
+ this.logger.debug({
44
+ max,
45
+ isServerless,
46
+ idleTimeout: config.poolOptions?.idleTimeout ?? 20,
47
+ }, 'PostgreSQL connection pool initialized');
48
+ }
49
+ async listClients() {
50
+ this.logger.debug('listing all clients');
51
+ const clients = await this.db.select().from(schema.clientsTable);
52
+ this.logger.debug({ count: clients.length }, 'clients found');
53
+ return clients;
54
+ }
55
+ async getClientById(clientId) {
56
+ this.logger.debug({ clientId }, 'getting client by id');
57
+ const [client] = await this.db
58
+ .select()
59
+ .from(schema.clientsTable)
60
+ .where(eq(schema.clientsTable.id, clientId))
61
+ .limit(1);
62
+ if (!client) {
63
+ this.logger.warn({ clientId }, 'client not found');
64
+ return undefined;
65
+ }
66
+ // Get all deployments for this client
67
+ const deploymentsRaw = await this.db
68
+ .select()
69
+ .from(schema.deploymentsTable)
70
+ .where(eq(schema.deploymentsTable.clientId, clientId));
71
+ // Convert null to undefined for optional fields
72
+ const deployments = deploymentsRaw.map((d) => ({
73
+ ...d,
74
+ name: d.name ?? undefined,
75
+ description: d.description ?? undefined,
76
+ }));
77
+ return {
78
+ ...client,
79
+ deployments,
80
+ };
81
+ }
82
+ async addClient(client) {
83
+ this.logger.info({ client }, 'adding client');
84
+ // Filter out deployments from client data
85
+ const { deployments: _clientDeployments, ...clientWithoutDeployments } = client;
86
+ const [inserted] = await this.db
87
+ .insert(schema.clientsTable)
88
+ .values(clientWithoutDeployments)
89
+ .returning({ id: schema.clientsTable.id });
90
+ this.logger.debug({ clientId: inserted.id }, 'client added');
91
+ return inserted.id;
92
+ }
93
+ async updateClient(clientId, client) {
94
+ this.logger.info({ clientId, client }, 'updating client');
95
+ // Get existing client to validate it exists
96
+ const existing = await this.getClientById(clientId);
97
+ if (!existing)
98
+ throw new Error('Client not found');
99
+ // Check if launch config keys would change
100
+ const issuerChanged = client.iss && client.iss !== existing.iss;
101
+ const lmsClientIdChanged = client.clientId && client.clientId !== existing.clientId;
102
+ if (issuerChanged || lmsClientIdChanged) {
103
+ // Clear affected launch configs from cache
104
+ for (const deployment of existing.deployments) {
105
+ const cacheKey = `${existing.iss}#${existing.clientId}#${deployment.deploymentId}`;
106
+ LAUNCH_CONFIG_CACHE.delete(cacheKey);
107
+ }
108
+ }
109
+ // Filter out deployments from client data
110
+ const { deployments: _clientDeployments, ...clientWithoutDeployments } = client;
111
+ // Update the client
112
+ await this.db
113
+ .update(schema.clientsTable)
114
+ .set(clientWithoutDeployments)
115
+ .where(eq(schema.clientsTable.id, clientId));
116
+ // Clear and rebuild launch config cache
117
+ await this.updateClientLaunchConfigs(clientId);
118
+ this.logger.debug({ clientId }, 'client updated');
119
+ }
120
+ async deleteClient(clientId) {
121
+ this.logger.info({ clientId }, 'deleting client');
122
+ // Get client data to extract details for cache cleanup
123
+ const existing = await this.getClientById(clientId);
124
+ if (!existing) {
125
+ this.logger.warn({ clientId }, 'client not found for deletion');
126
+ return;
127
+ }
128
+ // Clear launch config cache
129
+ for (const deployment of existing.deployments) {
130
+ const cacheKey = `${existing.iss}#${existing.clientId}#${deployment.deploymentId}`;
131
+ LAUNCH_CONFIG_CACHE.delete(cacheKey);
132
+ }
133
+ // Delete client and all deployments in a transaction
134
+ await this.db.transaction(async (tx) => {
135
+ // Delete all deployments first (child records)
136
+ await tx
137
+ .delete(schema.deploymentsTable)
138
+ .where(eq(schema.deploymentsTable.clientId, clientId));
139
+ this.logger.debug({ clientId }, 'deployments deleted');
140
+ // Then delete the client (parent record)
141
+ await tx.delete(schema.clientsTable).where(eq(schema.clientsTable.id, clientId));
142
+ this.logger.debug({ clientId }, 'client deleted');
143
+ });
144
+ this.logger.debug({ clientId }, 'client and all deployments deleted');
145
+ }
146
+ async updateClientLaunchConfigs(clientId) {
147
+ this.logger.debug({ clientId }, 'updating client launch configs');
148
+ const client = await this.getClientById(clientId);
149
+ if (!client) {
150
+ this.logger.warn({ clientId }, 'client not found for launch config update');
151
+ return;
152
+ }
153
+ // Clear cache for all deployments (configs are derived on demand)
154
+ for (const deployment of client.deployments) {
155
+ const cacheKey = `${client.iss}#${client.clientId}#${deployment.deploymentId}`;
156
+ LAUNCH_CONFIG_CACHE.delete(cacheKey);
157
+ }
158
+ this.logger.debug({ clientId, count: client.deployments.length }, 'client launch configs cache cleared');
159
+ }
160
+ async listDeployments(clientId) {
161
+ this.logger.debug({ clientId }, 'listing deployments for client');
162
+ const deploymentsRaw = await this.db
163
+ .select()
164
+ .from(schema.deploymentsTable)
165
+ .where(eq(schema.deploymentsTable.clientId, clientId));
166
+ // Convert null to undefined for optional fields
167
+ const deployments = deploymentsRaw.map((d) => ({
168
+ ...d,
169
+ name: d.name ?? undefined,
170
+ description: d.description ?? undefined,
171
+ }));
172
+ this.logger.debug({ clientId, count: deployments.length }, 'deployments found');
173
+ return deployments;
174
+ }
175
+ async getDeployment(clientId, deploymentId) {
176
+ this.logger.debug({ clientId, deploymentId }, 'getting deployment by id');
177
+ const [deployment] = await this.db
178
+ .select()
179
+ .from(schema.deploymentsTable)
180
+ .where(and(eq(schema.deploymentsTable.clientId, clientId), eq(schema.deploymentsTable.id, deploymentId)))
181
+ .limit(1);
182
+ if (!deployment) {
183
+ this.logger.warn({ clientId, deploymentId }, 'deployment not found');
184
+ return undefined;
185
+ }
186
+ // Convert null to undefined for optional fields
187
+ return {
188
+ ...deployment,
189
+ name: deployment.name ?? undefined,
190
+ description: deployment.description ?? undefined,
191
+ };
192
+ }
193
+ async addDeployment(clientId, deployment) {
194
+ this.logger.info({ clientId, deployment }, 'adding deployment');
195
+ const [inserted] = await this.db
196
+ .insert(schema.deploymentsTable)
197
+ .values({
198
+ clientId,
199
+ ...deployment,
200
+ })
201
+ .returning({ id: schema.deploymentsTable.id });
202
+ this.logger.debug({ deploymentInternalId: inserted.id }, 'deployment added');
203
+ return inserted.id;
204
+ }
205
+ async updateDeployment(clientId, deploymentId, deployment) {
206
+ this.logger.info({ clientId, deploymentId, deployment }, 'updating deployment');
207
+ // Get existing deployment to validate it exists
208
+ const existing = await this.getDeployment(clientId, deploymentId);
209
+ if (!existing)
210
+ throw new Error('Deployment not found');
211
+ // Check if LMS deployment id changed (affects launch config cache)
212
+ const lmsDeploymentIdChanged = deployment.deploymentId && deployment.deploymentId !== existing.deploymentId;
213
+ if (lmsDeploymentIdChanged) {
214
+ const client = await this.getClientById(clientId);
215
+ if (client) {
216
+ const cacheKey = `${client.iss}#${client.clientId}#${existing.deploymentId}`;
217
+ LAUNCH_CONFIG_CACHE.delete(cacheKey);
218
+ }
219
+ }
220
+ // Update deployment data
221
+ await this.db
222
+ .update(schema.deploymentsTable)
223
+ .set(deployment)
224
+ .where(eq(schema.deploymentsTable.id, deploymentId));
225
+ this.logger.debug({ deploymentId }, 'deployment updated');
226
+ }
227
+ async deleteDeployment(clientId, deploymentId) {
228
+ this.logger.info({ clientId, deploymentId }, 'deleting deployment');
229
+ // Get deployment and client data for cache cleanup
230
+ const existing = await this.getDeployment(clientId, deploymentId);
231
+ if (!existing) {
232
+ this.logger.warn({ clientId, deploymentId }, 'deployment not found for deletion');
233
+ return;
234
+ }
235
+ const client = await this.getClientById(clientId);
236
+ if (client) {
237
+ const cacheKey = `${client.iss}#${client.clientId}#${existing.deploymentId}`;
238
+ LAUNCH_CONFIG_CACHE.delete(cacheKey);
239
+ }
240
+ await this.db
241
+ .delete(schema.deploymentsTable)
242
+ .where(eq(schema.deploymentsTable.id, deploymentId));
243
+ this.logger.debug({ clientId, deploymentId }, 'deployment deleted');
244
+ }
245
+ // oxlint-disable-next-line no-unused-vars require-await
246
+ async storeNonce(nonce, expiresAt) {
247
+ // Noop - the real work happens in validateNonce
248
+ this.logger.trace({ nonce, expiresAt }, 'nonce will be validated on use');
249
+ }
250
+ async validateNonce(nonce) {
251
+ this.logger.debug({ nonce }, 'validating nonce');
252
+ // 1. Check if nonce exists and is still valid (not expired)
253
+ const [existing] = await this.db
254
+ .select()
255
+ .from(schema.noncesTable)
256
+ .where(and(eq(schema.noncesTable.nonce, nonce), gt(schema.noncesTable.expiresAt, new Date())))
257
+ .limit(1);
258
+ if (existing) {
259
+ this.logger.warn({ nonce }, 'nonce already used - replay attack detected');
260
+ return false; // Nonce exists and hasn't expired = replay attack
261
+ }
262
+ // 2. Try to insert the nonce
263
+ const expiresAt = new Date(Date.now() + this.nonceExpirationSeconds * 1000);
264
+ try {
265
+ await this.db.insert(schema.noncesTable).values({ nonce, expiresAt });
266
+ return true;
267
+ }
268
+ catch (error) {
269
+ // Duplicate key error (race condition - another request inserted same nonce)
270
+ // PostgreSQL error code for unique_violation
271
+ if (error.code === '23505') {
272
+ this.logger.warn({ nonce }, 'nonce collision detected - replay attack');
273
+ return false;
274
+ }
275
+ throw error;
276
+ }
277
+ }
278
+ async getSession(sessionId) {
279
+ this.logger.debug({ sessionId }, 'getting session');
280
+ // Check cache first
281
+ const cachedSession = SESSION_CACHE.get(sessionId);
282
+ if (cachedSession === undefinedSessionValue) {
283
+ return undefined;
284
+ }
285
+ if (cachedSession) {
286
+ this.logger.debug({ sessionId }, 'session found in cache');
287
+ return cachedSession;
288
+ }
289
+ // Query database
290
+ const [sessionRecord] = await this.db
291
+ .select()
292
+ .from(schema.sessionsTable)
293
+ .where(and(eq(schema.sessionsTable.id, sessionId), gt(schema.sessionsTable.expiresAt, new Date())))
294
+ .limit(1);
295
+ if (!sessionRecord) {
296
+ this.logger.warn({ sessionId }, 'session not found');
297
+ SESSION_CACHE.set(sessionId, undefinedSessionValue);
298
+ return undefined;
299
+ }
300
+ const session = {
301
+ id: sessionRecord.id,
302
+ ...sessionRecord.data,
303
+ };
304
+ SESSION_CACHE.set(sessionId, session);
305
+ return session;
306
+ }
307
+ async addSession(session) {
308
+ this.logger.debug({ sessionId: session.id }, 'adding session');
309
+ const expiresAt = new Date(Date.now() + SESSION_TTL * 1000);
310
+ const { id, ...data } = session;
311
+ await this.db.insert(schema.sessionsTable).values({
312
+ id,
313
+ data,
314
+ expiresAt,
315
+ });
316
+ // Cache the session
317
+ SESSION_CACHE.set(session.id, session);
318
+ this.logger.debug({ sessionId: session.id }, 'session added');
319
+ return session.id;
320
+ }
321
+ // oxlint-disable-next-line max-lines-per-function
322
+ async getLaunchConfig(iss, clientId, deploymentId) {
323
+ this.logger.debug({ iss, clientId, deploymentId }, 'getting launch config');
324
+ // Check cache
325
+ const cacheKey = `${iss}#${clientId}#${deploymentId}`;
326
+ const cachedConfig = LAUNCH_CONFIG_CACHE.get(cacheKey);
327
+ if (cachedConfig === undefinedLaunchConfigValue) {
328
+ return undefined;
329
+ }
330
+ if (cachedConfig) {
331
+ this.logger.debug({ cachedConfig }, 'launch config found in cache');
332
+ return cachedConfig;
333
+ }
334
+ // Query for client and deployment
335
+ const [result] = await this.db
336
+ .select({
337
+ client: schema.clientsTable,
338
+ deployment: schema.deploymentsTable,
339
+ })
340
+ .from(schema.clientsTable)
341
+ .innerJoin(schema.deploymentsTable, eq(schema.deploymentsTable.clientId, schema.clientsTable.id))
342
+ .where(and(eq(schema.clientsTable.iss, iss), eq(schema.clientsTable.clientId, clientId), eq(schema.deploymentsTable.deploymentId, deploymentId)))
343
+ .limit(1);
344
+ if (!result) {
345
+ // Try with 'default' deployment (for dynamic registration)
346
+ if (deploymentId !== 'default') {
347
+ this.logger.debug({ deploymentId }, 'trying default deployment fallback');
348
+ return this.getLaunchConfig(iss, clientId, 'default');
349
+ }
350
+ this.logger.warn({ iss, clientId, deploymentId }, 'launch config not found');
351
+ LAUNCH_CONFIG_CACHE.set(cacheKey, undefinedLaunchConfigValue);
352
+ return undefined;
353
+ }
354
+ const launchConfig = {
355
+ iss: result.client.iss,
356
+ clientId: result.client.clientId,
357
+ deploymentId: result.deployment.deploymentId,
358
+ authUrl: result.client.authUrl,
359
+ tokenUrl: result.client.tokenUrl,
360
+ jwksUrl: result.client.jwksUrl,
361
+ };
362
+ LAUNCH_CONFIG_CACHE.set(cacheKey, launchConfig);
363
+ return launchConfig;
364
+ }
365
+ // oxlint-disable-next-line require-await no-unused-vars
366
+ async saveLaunchConfig(launchConfig) {
367
+ // PostgreSQL storage doesn't need to persist launch configs separately
368
+ // since they're derived from client + deployment data
369
+ this.logger.debug({ launchConfig }, 'launch config would be saved (no-op in PostgreSQL)');
370
+ }
371
+ async setRegistrationSession(sessionId, session) {
372
+ this.logger.debug({ sessionId }, 'setting registration session');
373
+ const expiresAt = new Date(session.expiresAt);
374
+ await this.db.insert(schema.registrationSessionsTable).values({
375
+ id: sessionId,
376
+ data: session,
377
+ expiresAt,
378
+ });
379
+ this.logger.debug({ sessionId }, 'registration session stored');
380
+ }
381
+ async getRegistrationSession(sessionId) {
382
+ this.logger.debug({ sessionId }, 'getting registration session');
383
+ const [record] = await this.db
384
+ .select()
385
+ .from(schema.registrationSessionsTable)
386
+ .where(and(eq(schema.registrationSessionsTable.id, sessionId), gt(schema.registrationSessionsTable.expiresAt, new Date())))
387
+ .limit(1);
388
+ if (!record) {
389
+ this.logger.warn({ sessionId }, 'registration session not found or expired');
390
+ return undefined;
391
+ }
392
+ return record.data;
393
+ }
394
+ async deleteRegistrationSession(sessionId) {
395
+ this.logger.debug({ sessionId }, 'deleting registration session');
396
+ await this.db
397
+ .delete(schema.registrationSessionsTable)
398
+ .where(eq(schema.registrationSessionsTable.id, sessionId));
399
+ this.logger.debug({ sessionId }, 'registration session deleted');
400
+ }
401
+ /**
402
+ * Clean up expired nonces, sessions, and registration sessions.
403
+ * Should be called periodically (e.g., every 30 minutes via EventBridge).
404
+ *
405
+ * @returns Object with counts of deleted items
406
+ */
407
+ async cleanup() {
408
+ this.logger.info('starting cleanup of expired items');
409
+ const now = new Date();
410
+ // Delete expired nonces
411
+ const noncesResult = await this.db
412
+ .delete(schema.noncesTable)
413
+ .where(lt(schema.noncesTable.expiresAt, now))
414
+ .returning({ nonce: schema.noncesTable.nonce });
415
+ // Delete expired sessions
416
+ const sessionsResult = await this.db
417
+ .delete(schema.sessionsTable)
418
+ .where(lt(schema.sessionsTable.expiresAt, now))
419
+ .returning({ id: schema.sessionsTable.id });
420
+ // Delete expired registration sessions
421
+ const regSessionsResult = await this.db
422
+ .delete(schema.registrationSessionsTable)
423
+ .where(lt(schema.registrationSessionsTable.expiresAt, now))
424
+ .returning({ id: schema.registrationSessionsTable.id });
425
+ const result = {
426
+ noncesDeleted: noncesResult.length,
427
+ sessionsDeleted: sessionsResult.length,
428
+ registrationSessionsDeleted: regSessionsResult.length,
429
+ };
430
+ this.logger.info(result, 'cleanup completed');
431
+ return result;
432
+ }
433
+ /**
434
+ * Close the PostgreSQL connection pool.
435
+ * Should be called on graceful server shutdown or after tests.
436
+ * Not required for serverless environments (Lambda manages lifecycle).
437
+ */
438
+ async close() {
439
+ this.logger.debug('closing PostgreSQL connection pool');
440
+ await this.sql.end();
441
+ this.logger.debug('PostgreSQL connection pool closed');
442
+ }
443
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lti-tool/postgresql",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "PostgreSQL storage for LTI 1.3 @lti-tool",
5
5
  "keywords": [
6
6
  "lti",