diff --git a/api/admin/middleware/github-auth.ts b/api/admin/middleware/github-auth.ts new file mode 100644 index 0000000..5e7b7c2 --- /dev/null +++ b/api/admin/middleware/github-auth.ts @@ -0,0 +1,153 @@ +import { OAuthApp } from '@octokit/oauth-app'; +import { Octokit } from '@octokit/rest'; +import { NextFunction, Request, Response, Router } from 'express'; + +/** + * GitHub OAuth middleware for admin panel authentication + */ + +// GitHub organization and team to verify +const GITHUB_ORG = 'DestinyItemManager'; +const GITHUB_TEAM = 'apiadmins'; + +// Initialize OAuth App +const oauthApp = new OAuthApp({ + clientType: 'oauth-app', + clientId: process.env.GITHUB_CLIENT_ID || 'test-client-id', + clientSecret: process.env.GITHUB_CLIENT_SECRET || 'test-client-secret', +}); + +// OAuth Routes +export const githubAuthRouter = Router(); + +/** + * Initiate GitHub OAuth flow + * GET /auth/login + */ +githubAuthRouter.get('/login', (_req, res) => { + const { url } = oauthApp.getWebFlowAuthorizationUrl({ + state: crypto.randomUUID(), + scopes: ['read:org'], + allowSignup: false, + }); + res.redirect(url); +}); + +/** + * Handle GitHub OAuth callback + * GET /auth/callback + */ +githubAuthRouter.get('/callback', async (req, res) => { + const { code } = req.query; + + if (!code || typeof code !== 'string') { + return res.status(400).send('Missing authorization code'); + } + + try { + // Exchange code for token + const { authentication } = await oauthApp.createToken({ + code, + }); + + // Create authenticated Octokit instance + const octokit = new Octokit({ + auth: authentication.token, + }); + + // Fetch user info + const { data: user } = await octokit.users.getAuthenticated(); + + // Verify team membership + let isTeamMember = false; + try { + await octokit.teams.getMembershipForUserInOrg({ + org: GITHUB_ORG, + team_slug: GITHUB_TEAM, + username: user.login, + }); + isTeamMember = true; + } catch (error) { + // 404 means user is not a team member + if ( + error && + typeof error === 'object' && + 'status' in error && + (error as { status: number }).status !== 404 + ) { + console.error('Error checking team membership:', error); + } + } + + // Store user info in session + req.session.user = { + id: user.id, + login: user.login, + name: user.name, + avatarUrl: user.avatar_url, + isTeamMember, + }; + + // Save session before redirect + req.session.save((err) => { + if (err) { + console.error('Error saving session:', err); + return res.status(500).send('Failed to create session'); + } + + // Redirect based on team membership + if (isTeamMember) { + res.redirect('/admin'); + } else { + const user = req.session.user; + if (!user) { + throw new Error('User should exist after OAuth'); + } + res.status(403).render('admin/views/403', { + user, + message: `Access denied. You must be a member of ${GITHUB_ORG}/${GITHUB_TEAM} to access the admin panel.`, + }); + } + }); + } catch (error) { + console.error('OAuth callback error:', error); + res.status(500).send('Authentication failed'); + } +}); + +/** + * Logout and destroy session + * GET /auth/logout + */ +githubAuthRouter.get('/logout', (req, res) => { + req.session.destroy((err) => { + if (err) { + console.error('Error destroying session:', err); + } + res.redirect('/admin'); + }); +}); + +/** + * Middleware to require authentication and team membership + * Use this to protect admin routes + */ +export function requireAuth(req: Request, res: Response, next: NextFunction) { + const user = req.session.user; + + if (!user) { + // Not logged in - redirect to login + return res.redirect('/admin/auth/login'); + } + + if (!user.isTeamMember) { + // Logged in but not a team member + return res.status(403).render('admin/views/403', { + user, + message: `Access denied. You must be a member of ${GITHUB_ORG}/${GITHUB_TEAM} to access the admin panel.`, + }); + } + + // User is authenticated and authorized + next(); +} diff --git a/api/admin/routes/add-app.test.ts b/api/admin/routes/add-app.test.ts new file mode 100644 index 0000000..9c40c01 --- /dev/null +++ b/api/admin/routes/add-app.test.ts @@ -0,0 +1,202 @@ +// Set required env vars BEFORE importing anything +process.env.GITHUB_CLIENT_ID = 'test-client-id'; +process.env.GITHUB_CLIENT_SECRET = 'test-client-secret'; +process.env.ADMIN_SESSION_SECRET = 'test-session-secret-for-testing-only'; + +import express from 'express'; +import { resolve } from 'node:path'; +import { makeFetch } from 'supertest-fetch'; +import { v4 as uuid } from 'uuid'; +import { closeDbPool, pool } from '../../db/index.js'; +import { addAppHandler } from './add-app.js'; + +// Create a simple test app that mounts the handler without auth middleware +const testApp = express(); +testApp.use(express.urlencoded({ extended: true, limit: '1mb' })); + +// Configure EJS +testApp.set('view engine', 'ejs'); +testApp.set('views', resolve(new URL('.', import.meta.url).pathname, '../..')); + +// Mock req.session.user for all requests +testApp.use((req, _res, next) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + req.session = { + user: { + id: 12345, + login: 'testadmin', + name: 'Test Admin', + avatarUrl: 'https://example.com/avatar.jpg', + isTeamMember: true, + }, + } as any; + next(); +}); + +// Mount the handler +testApp.post('/add-app', addAppHandler); + +const fetch = makeFetch(testApp); + +/** + * Helper to submit the add-app form with the given data + */ +function postAddApp(data: { appId: string; bungieApiKey: string; origin: string }) { + return fetch('/add-app', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams(data).toString(), + }); +} + +/** + * Helper to verify an error response + */ +async function expectError(response: Response, errorMessage: string, status = 400) { + const body = await response.text(); + expect(body).toContain('Error:'); + expect(body).toContain(errorMessage); + expect(response.status).toBe(status); +} + +/** + * Helper to verify a success response + */ +async function expectSuccess(response: Response, appId: string) { + const body = await response.text(); + expect(body).toContain('App Created Successfully!'); + expect(body).toContain(appId); + expect(body).toContain('DIM API Key:'); + expect(response.status).toBe(200); +} + +/** + * Helper to verify app was created in database + */ +async function expectAppInDatabase( + appId: string, + expectedData: { bungieApiKey: string; origin: string }, +) { + const result = await pool.query<{ bungie_api_key: string; origin: string }>( + 'SELECT * FROM apps WHERE id = $1', + [appId], + ); + expect(result.rows.length).toBe(1); + expect(result.rows[0].bungie_api_key).toBe(expectedData.bungieApiKey); + expect(result.rows[0].origin).toBe(expectedData.origin); +} + +describe('Add App Handler', () => { + beforeEach(async () => { + // Clean up any test apps + await pool.query('DELETE FROM apps WHERE id LIKE $1', ['test-app-%']); + }); + + afterAll(async () => { + await closeDbPool(); + }); + + describe('POST /add-app', () => { + it('should create a new app with valid data', async () => { + const testAppId = `test-app-${uuid().substring(0, 8)}`; + const response = await postAddApp({ + appId: testAppId, + bungieApiKey: 'test-bungie-key-123', + origin: 'https://example.com', + }); + + await expectSuccess(response, testAppId); + await expectAppInDatabase(testAppId, { + bungieApiKey: 'test-bungie-key-123', + origin: 'https://example.com', + }); + }); + + it('should reject app ID with uppercase letters', async () => { + const response = await postAddApp({ + appId: 'Invalid_AppID', + bungieApiKey: 'test-bungie-key-123', + origin: 'https://example.com', + }); + + await expectError(response, 'App ID must match'); + }); + + it('should reject app ID that is too short', async () => { + const response = await postAddApp({ + appId: 'ab', + bungieApiKey: 'test-bungie-key-123', + origin: 'https://example.com', + }); + + await expectError(response, 'App ID must match'); + }); + + it('should reject missing Bungie API key', async () => { + const response = await postAddApp({ + appId: 'test-app-valid', + bungieApiKey: '', + origin: 'https://example.com', + }); + + await expectError(response, 'Bungie API Key is required'); + }); + + it('should reject invalid origin URL', async () => { + const response = await postAddApp({ + appId: 'test-app-valid', + bungieApiKey: 'test-bungie-key-123', + origin: 'not-a-valid-url', + }); + + await expectError(response, 'Invalid origin URL'); + }); + + it('should reject origin with path', async () => { + const response = await postAddApp({ + appId: 'test-app-valid', + bungieApiKey: 'test-bungie-key-123', + origin: 'https://example.com/path', + }); + + await expectError(response, 'Origin must be a valid origin'); + }); + + it('should allow non-localhost origins (unlike public create-app)', async () => { + const testAppId = `test-app-${uuid().substring(0, 8)}`; + const response = await postAddApp({ + appId: testAppId, + bungieApiKey: 'test-bungie-key-123', + origin: 'https://production-app.com', + }); + + await expectSuccess(response, testAppId); + await expectAppInDatabase(testAppId, { + bungieApiKey: 'test-bungie-key-123', + origin: 'https://production-app.com', + }); + }); + + it('should reject duplicate app ID with different details', async () => { + const testAppId = `test-app-${uuid().substring(0, 8)}`; + + // Create app first time + await postAddApp({ + appId: testAppId, + bungieApiKey: 'test-bungie-key-123', + origin: 'https://example.com', + }); + + // Try to create again with different origin + const response = await postAddApp({ + appId: testAppId, + bungieApiKey: 'test-bungie-key-123', + origin: 'https://different-origin.com', + }); + + await expectError(response, 'duplicate key value violates unique constraint', 500); + }); + }); +}); diff --git a/api/admin/routes/add-app.ts b/api/admin/routes/add-app.ts new file mode 100644 index 0000000..50dca24 --- /dev/null +++ b/api/admin/routes/add-app.ts @@ -0,0 +1,96 @@ +import asyncHandler from 'express-async-handler'; +import { v4 as uuid } from 'uuid'; +import { insertApp as insertAppPostgres } from '../../db/apps-queries.js'; +import { transaction } from '../../db/index.js'; +import { ApiApp } from '../../shapes/app.js'; +import { insertApp as insertAppStately } from '../../stately/apps-queries.js'; + +interface AddAppFormData { + appId: string; + bungieApiKey: string; + origin: string; +} + +/** + * Handler for POST /admin/add-app + * Creates a new API app with the provided details. + * Unlike the public create-app endpoint, this allows any origin (not just localhost). + */ +export const addAppHandler = asyncHandler(async (req, res) => { + const formData = req.body as AddAppFormData; + + // Validate app ID format + if (!/^[a-z0-9-]{3,}$/.test(formData.appId)) { + res.status(400).render('admin/views/add-app', { + user: req.session.user, + error: 'App ID must match /^[a-z0-9-]{3,}$/', + formData, + }); + return; + } + + // Validate Bungie API key + if (!formData.bungieApiKey || formData.bungieApiKey.trim().length === 0) { + res.status(400).render('admin/views/add-app', { + user: req.session.user, + error: 'Bungie API Key is required', + formData, + }); + return; + } + + // Validate and normalize origin + let originUrl: URL; + try { + originUrl = new URL(formData.origin); + } catch { + res.status(400).render('admin/views/add-app', { + user: req.session.user, + error: 'Invalid origin URL', + formData, + }); + return; + } + + if (originUrl.origin !== formData.origin) { + res.status(400).render('admin/views/add-app', { + user: req.session.user, + error: 'Origin must be a valid origin (e.g., https://example.com)', + formData, + }); + return; + } + + // Create the app object + let app: ApiApp = { + id: formData.appId, + bungieApiKey: formData.bungieApiKey.trim(), + origin: originUrl.origin, + dimApiKey: uuid(), + }; + + try { + // Put it in StatelyDB + app = await insertAppStately(app); + + // Put it in Postgres + await transaction(async (client) => { + await insertAppPostgres(client, app); + }); + + // Render success page with app details + res.render('admin/views/add-app-success', { + user: req.session.user, + appId: app.id, + dimApiKey: app.dimApiKey, + origin: app.origin, + }); + } catch (e) { + const errorMessage = e instanceof Error ? e.message : 'Unknown error occurred'; + res.status(500).render('admin/views/add-app', { + user: req.session.user, + error: errorMessage, + formData, + }); + } +}); diff --git a/api/admin/server.test.ts b/api/admin/server.test.ts new file mode 100644 index 0000000..c5acef4 --- /dev/null +++ b/api/admin/server.test.ts @@ -0,0 +1,62 @@ +// Set required env vars BEFORE importing anything +process.env.GITHUB_CLIENT_ID = 'test-client-id'; +process.env.GITHUB_CLIENT_SECRET = 'test-client-secret'; +process.env.ADMIN_SESSION_SECRET = 'test-session-secret-for-testing-only'; + +import { makeFetch } from 'supertest-fetch'; +import { closeDbPool, pool } from '../db/index.js'; +import { app } from '../server.js'; + +const fetch = makeFetch(app); + +describe('Admin Panel', () => { + afterAll(async () => { + await closeDbPool(); + }); + + beforeEach(async () => { + // Clear sessions before each test + await pool.query('DELETE FROM session'); + }); + + describe('GET /admin', () => { + it('redirects to login when not authenticated', async () => { + const response = await fetch('/admin', { redirect: 'manual' }); + expect(response.status).toBe(302); + expect(response.headers.get('location')).toBe('/admin/auth/login'); + }); + }); + + describe('GET /admin/auth/login', () => { + it('redirects to GitHub OAuth authorization URL', async () => { + const response = await fetch('/admin/auth/login', { redirect: 'manual' }); + + expect(response.status).toBe(302); + const location = response.headers.get('location'); + expect(location).toContain('github.com/login/oauth/authorize'); + expect(location).toContain('client_id=test-client-id'); + }); + }); + + describe('GET /admin/auth/callback', () => { + it('returns 400 when code is missing', async () => { + const response = await fetch('/admin/auth/callback'); + + expect(response.status).toBe(400); + const body = await response.text(); + expect(body).toContain('Missing authorization code'); + }); + + // getLoadoutShareHandler: Testing successful OAuth callback would require mocking the GitHub API + // which is complex with ES modules in Jest. We test the routing and validation here. + }); + + describe('Session management', () => { + it('verifies session table exists and is accessible', async () => { + const result = await pool.query<{ count: string }>('SELECT COUNT(*) as count FROM session'); + // Should be able to query the session table (count could be 0) + expect(result.rows).toBeDefined(); + expect(result.rows[0]).toBeDefined(); + }); + }); +}); diff --git a/api/admin/server.ts b/api/admin/server.ts new file mode 100644 index 0000000..bf354eb --- /dev/null +++ b/api/admin/server.ts @@ -0,0 +1,68 @@ +import connectPgSimple from 'connect-pg-simple'; +import express, { Router } from 'express'; +import session from 'express-session'; +import { pool } from '../db/index.js'; +import { githubAuthRouter, requireAuth } from './middleware/github-auth.js'; +import { addAppHandler } from './routes/add-app.js'; + +declare module 'express-session' { + interface SessionData { + user?: { + id: number; + login: string; + name: string | null; + avatarUrl: string; + isTeamMember: boolean; + }; + } +} +/** DIM API Admin Panel Router */ + +const PgSession = connectPgSimple(session); + +export const adminRouter = Router(); + +// Session configuration - must be first + +adminRouter.use( + session({ + store: new PgSession({ + pool, // Reuse existing connection pool + tableName: 'session', + createTableIfMissing: false, + pruneSessionInterval: 60 * 15, // Auto-cleanup every 15 minutes + }), + secret: process.env.ADMIN_SESSION_SECRET || 'default-secret-for-testing', + resave: false, + saveUninitialized: false, + cookie: { + secure: process.env.NODE_ENV === 'production', + httpOnly: true, + maxAge: 24 * 60 * 60 * 1000, // 24 hours + sameSite: 'lax', + }, + name: 'dim-admin-session', + }), +); + +// Body parsing middleware +adminRouter.use(express.urlencoded({ extended: true, limit: '1mb' })); + +// GitHub OAuth authentication routes +adminRouter.use('/auth', githubAuthRouter); + +// Home/Dashboard - protected route +adminRouter.get('/', requireAuth, (req, res) => { + res.render('admin/views/index', { + user: req.session.user, + }); +}); + +// Add App tool routes - protected routes +adminRouter.get('/add-app', requireAuth, (req, res) => { + res.render('admin/views/add-app', { + user: req.session.user, + }); +}); + +adminRouter.post('/add-app', requireAuth, addAppHandler); diff --git a/api/admin/views/403.ejs b/api/admin/views/403.ejs new file mode 100644 index 0000000..5d00335 --- /dev/null +++ b/api/admin/views/403.ejs @@ -0,0 +1,119 @@ + + + + + + Access Denied - DIM Admin + + + +
+
403
+

Access Denied

+ +
<%= message %>
+ + <% if (user) { %> +
+

Logged in as: <%= user.login %>

+ <% if (user.name) { %> +

Name: <%= user.name %>

+ <% } %> +
+ <% } %> + + Visit DestinyItemManager on GitHub + +
+ Log out +
+ + diff --git a/api/admin/views/add-app-success.ejs b/api/admin/views/add-app-success.ejs new file mode 100644 index 0000000..56b1d0f --- /dev/null +++ b/api/admin/views/add-app-success.ejs @@ -0,0 +1,36 @@ +<% const appId = locals.appId; %> +<% const dimApiKey = locals.dimApiKey; %> +<% const origin = locals.origin; %> +<%- include('layout', { body: ` +

App Created Successfully!

+ +
+
✓ Your API app has been created
+ +
+
+ App ID: + ${appId} +
+ +
+ DIM API Key: + ${dimApiKey} +
+ +
+ Origin: + ${origin} +
+
+ +
+ ⚠️ Important: Make sure to save the DIM API Key securely. It won't be shown again. +
+
+ +
+ Add Another App + Back to Dashboard +
+`}) %> \ No newline at end of file diff --git a/api/admin/views/add-app.ejs b/api/admin/views/add-app.ejs new file mode 100644 index 0000000..8c11614 --- /dev/null +++ b/api/admin/views/add-app.ejs @@ -0,0 +1,56 @@ +<% const error = locals.error; %> +<% const formData = locals.formData || {}; %> +<%- include('layout', { body: ` +

Add API App

+ + ${error ? '
Error: ' + error + '
' : ''} + +
+
+ + + Lowercase letters, numbers, and hyphens only. Minimum 3 characters. +
+ +
+ + +
+ +
+ + + The full URL origin (e.g., https://example.com) +
+ +
+ + Cancel +
+
+`}) %> diff --git a/api/admin/views/index.ejs b/api/admin/views/index.ejs new file mode 100644 index 0000000..01d1202 --- /dev/null +++ b/api/admin/views/index.ejs @@ -0,0 +1,15 @@ +<%- include('layout', { body: ` +

Admin Dashboard

+ +

Welcome, ${locals.user.login}!

+ +

Available Tools:

+ +`}) %> diff --git a/api/admin/views/layout.ejs b/api/admin/views/layout.ejs new file mode 100644 index 0000000..9a52045 --- /dev/null +++ b/api/admin/views/layout.ejs @@ -0,0 +1,235 @@ + + + + + + DIM Admin Panel + + + +
+

DIM Admin Panel

+
+ <%= user.login %> + Logout +
+
+ +
+ + +
<%- body %>
+
+ + diff --git a/api/migrations/20260107052805-session-table.js b/api/migrations/20260107052805-session-table.js new file mode 100644 index 0000000..7a8725c --- /dev/null +++ b/api/migrations/20260107052805-session-table.js @@ -0,0 +1,42 @@ +'use strict'; + +var dbm; +var type; +var seed; + +/** + * We receive the dbmigrate dependency from dbmigrate initially. + * This enables us to not have to rely on NODE_PATH. + */ +exports.setup = function (options, seedLink) { + dbm = options.dbmigrate; + type = dbm.dataType; + seed = seedLink; +}; + +/** + * The session table stores server-side sessions for the admin panel. + * This is used by connect-pg-simple to store express-session data. + */ +exports.up = function (db, callback) { + db.runSql( + `CREATE TABLE "session" ( + "sid" varchar NOT NULL COLLATE "default", + "sess" json NOT NULL, + "expire" timestamp(6) NOT NULL, + CONSTRAINT "session_pkey" PRIMARY KEY ("sid") + )`, + function (err) { + if (err) return callback(err); + db.addIndex('session', 'IDX_session_expire', ['expire'], callback); + }, + ); +}; + +exports.down = function (db, callback) { + db.dropTable('session', callback); +}; + +exports._meta = { + version: 1, +}; diff --git a/api/server.ts b/api/server.ts index b80eff0..7650335 100644 --- a/api/server.ts +++ b/api/server.ts @@ -3,6 +3,7 @@ import cors from 'cors'; import express, { ErrorRequestHandler } from 'express'; import { expressjwt as jwt } from 'express-jwt'; import { type JwtPayload } from 'jsonwebtoken'; +import { adminRouter } from './admin/server.js'; import { apiKey, isAppOrigin } from './apps/index.js'; import expressStatsd from './metrics/express.js'; import { metrics } from './metrics/index.js'; @@ -20,6 +21,10 @@ import { UserInfo } from './shapes/user.js'; export const app = express(); +// View engine setup for admin panel +app.set('view engine', 'ejs'); +app.set('views', './api'); + app.use(expressStatsd({ client: metrics, prefix: 'http' })); // metrics app.use(express.json({ limit: '2mb' })); // for parsing application/json @@ -41,6 +46,9 @@ app.post('/new_app', permissiveCors, createAppHandler); // Get a shared loadout app.get('/loadout_share', permissiveCors, getLoadoutShareHandler); +// Admin panel routes (before API key middleware) +app.use('/admin', adminRouter); + /* ****** API KEY REQUIRED ****** */ /* Any routes declared below this will require an API Key in X-API-Key header */ diff --git a/package.json b/package.json index dfdec26..df4c93c 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,10 @@ "@sentry/cli": "^2.58.2", "@stately-cloud/cli": "^0.97.0", "@stately-cloud/schema": "^0.34.4", + "@types/connect-pg-simple": "^7.0.3", "@types/cors": "^2.8.19", "@types/express": "^4.17.25", + "@types/express-session": "^1.18.1", "@types/jest": "^29.5.14", "@types/jsonwebtoken": "^9.0.10", "@types/morgan": "^1.9.10", @@ -72,10 +74,13 @@ "dependencies": { "@bufbuild/protobuf": "^2.10.1", "@godaddy/terminus": "^4.12.1", + "@octokit/oauth-app": "^7.1.3", + "@octokit/rest": "^21.0.2", "@sentry/node": "^7.120.4", "@sentry/tracing": "^7.120.4", "@stately-cloud/client": "^0.37.0", "bungie-api-ts": "^5.10.0", + "connect-pg-simple": "^9.0.1", "cors": "^2.8.5", "dotenv": "^16.6.1", "ejs": "^3.1.10", @@ -83,6 +88,7 @@ "express": "^4.22.1", "express-async-handler": "^1.2.0", "express-hot-shots": "^1.0.2", + "express-session": "^1.18.1", "express-jwt": "^8.5.1", "hi-base32": "^0.5.1", "hot-shots": "^10.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2de29d5..d98873e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,12 @@ dependencies: '@godaddy/terminus': specifier: ^4.12.1 version: 4.12.1 + '@octokit/oauth-app': + specifier: ^7.1.3 + version: 7.1.6 + '@octokit/rest': + specifier: ^21.0.2 + version: 21.1.1 '@sentry/node': specifier: ^7.120.4 version: 7.120.4 @@ -23,6 +29,9 @@ dependencies: bungie-api-ts: specifier: ^5.10.0 version: 5.10.0 + connect-pg-simple: + specifier: ^9.0.1 + version: 9.0.1 cors: specifier: ^2.8.5 version: 2.8.5 @@ -47,6 +56,9 @@ dependencies: express-jwt: specifier: ^8.5.1 version: 8.5.1 + express-session: + specifier: ^1.18.1 + version: 1.18.2 hi-base32: specifier: ^0.5.1 version: 0.5.1 @@ -112,12 +124,18 @@ devDependencies: '@stately-cloud/schema': specifier: ^0.34.4 version: 0.34.4 + '@types/connect-pg-simple': + specifier: ^7.0.3 + version: 7.0.3 '@types/cors': specifier: ^2.8.19 version: 2.8.19 '@types/express': specifier: ^4.17.25 version: 4.17.25 + '@types/express-session': + specifier: ^1.18.1 + version: 1.18.2 '@types/jest': specifier: ^29.5.14 version: 29.5.14 @@ -2097,6 +2115,187 @@ packages: '@jridgewell/sourcemap-codec': 1.5.5 dev: true + /@octokit/auth-oauth-app@8.1.4: + resolution: {integrity: sha512-71iBa5SflSXcclk/OL3lJzdt4iFs56OJdpBGEBl1wULp7C58uiswZLV6TdRaiAzHP1LT8ezpbHlKuxADb+4NkQ==} + engines: {node: '>= 18'} + dependencies: + '@octokit/auth-oauth-device': 7.1.5 + '@octokit/auth-oauth-user': 5.1.6 + '@octokit/request': 9.2.4 + '@octokit/types': 14.1.0 + universal-user-agent: 7.0.3 + dev: false + + /@octokit/auth-oauth-device@7.1.5: + resolution: {integrity: sha512-lR00+k7+N6xeECj0JuXeULQ2TSBB/zjTAmNF2+vyGPDEFx1dgk1hTDmL13MjbSmzusuAmuJD8Pu39rjp9jH6yw==} + engines: {node: '>= 18'} + dependencies: + '@octokit/oauth-methods': 5.1.5 + '@octokit/request': 9.2.4 + '@octokit/types': 14.1.0 + universal-user-agent: 7.0.3 + dev: false + + /@octokit/auth-oauth-user@5.1.6: + resolution: {integrity: sha512-/R8vgeoulp7rJs+wfJ2LtXEVC7pjQTIqDab7wPKwVG6+2v/lUnCOub6vaHmysQBbb45FknM3tbHW8TOVqYHxCw==} + engines: {node: '>= 18'} + dependencies: + '@octokit/auth-oauth-device': 7.1.5 + '@octokit/oauth-methods': 5.1.5 + '@octokit/request': 9.2.4 + '@octokit/types': 14.1.0 + universal-user-agent: 7.0.3 + dev: false + + /@octokit/auth-token@5.1.2: + resolution: {integrity: sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw==} + engines: {node: '>= 18'} + dev: false + + /@octokit/auth-unauthenticated@6.1.3: + resolution: {integrity: sha512-d5gWJla3WdSl1yjbfMpET+hUSFCE15qM0KVSB0H1shyuJihf/RL1KqWoZMIaonHvlNojkL9XtLFp8QeLe+1iwA==} + engines: {node: '>= 18'} + dependencies: + '@octokit/request-error': 6.1.8 + '@octokit/types': 14.1.0 + dev: false + + /@octokit/core@6.1.6: + resolution: {integrity: sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==} + engines: {node: '>= 18'} + dependencies: + '@octokit/auth-token': 5.1.2 + '@octokit/graphql': 8.2.2 + '@octokit/request': 9.2.4 + '@octokit/request-error': 6.1.8 + '@octokit/types': 14.1.0 + before-after-hook: 3.0.2 + universal-user-agent: 7.0.3 + dev: false + + /@octokit/endpoint@10.1.4: + resolution: {integrity: sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==} + engines: {node: '>= 18'} + dependencies: + '@octokit/types': 14.1.0 + universal-user-agent: 7.0.3 + dev: false + + /@octokit/graphql@8.2.2: + resolution: {integrity: sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA==} + engines: {node: '>= 18'} + dependencies: + '@octokit/request': 9.2.4 + '@octokit/types': 14.1.0 + universal-user-agent: 7.0.3 + dev: false + + /@octokit/oauth-app@7.1.6: + resolution: {integrity: sha512-OMcMzY2WFARg80oJNFwWbY51TBUfLH4JGTy119cqiDawSFXSIBujxmpXiKbGWQlvfn0CxE6f7/+c6+Kr5hI2YA==} + engines: {node: '>= 18'} + dependencies: + '@octokit/auth-oauth-app': 8.1.4 + '@octokit/auth-oauth-user': 5.1.6 + '@octokit/auth-unauthenticated': 6.1.3 + '@octokit/core': 6.1.6 + '@octokit/oauth-authorization-url': 7.1.1 + '@octokit/oauth-methods': 5.1.5 + '@types/aws-lambda': 8.10.159 + universal-user-agent: 7.0.3 + dev: false + + /@octokit/oauth-authorization-url@7.1.1: + resolution: {integrity: sha512-ooXV8GBSabSWyhLUowlMIVd9l1s2nsOGQdlP2SQ4LnkEsGXzeCvbSbCPdZThXhEFzleGPwbapT0Sb+YhXRyjCA==} + engines: {node: '>= 18'} + dev: false + + /@octokit/oauth-methods@5.1.5: + resolution: {integrity: sha512-Ev7K8bkYrYLhoOSZGVAGsLEscZQyq7XQONCBBAl2JdMg7IT3PQn/y8P0KjloPoYpI5UylqYrLeUcScaYWXwDvw==} + engines: {node: '>= 18'} + dependencies: + '@octokit/oauth-authorization-url': 7.1.1 + '@octokit/request': 9.2.4 + '@octokit/request-error': 6.1.8 + '@octokit/types': 14.1.0 + dev: false + + /@octokit/openapi-types@24.2.0: + resolution: {integrity: sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg==} + dev: false + + /@octokit/openapi-types@25.1.0: + resolution: {integrity: sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==} + dev: false + + /@octokit/plugin-paginate-rest@11.6.0(@octokit/core@6.1.6): + resolution: {integrity: sha512-n5KPteiF7pWKgBIBJSk8qzoZWcUkza2O6A0za97pMGVrGfPdltxrfmfF5GucHYvHGZD8BdaZmmHGz5cX/3gdpw==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '>=6' + dependencies: + '@octokit/core': 6.1.6 + '@octokit/types': 13.10.0 + dev: false + + /@octokit/plugin-request-log@5.3.1(@octokit/core@6.1.6): + resolution: {integrity: sha512-n/lNeCtq+9ofhC15xzmJCNKP2BWTv8Ih2TTy+jatNCCq/gQP/V7rK3fjIfuz0pDWDALO/o/4QY4hyOF6TQQFUw==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '>=6' + dependencies: + '@octokit/core': 6.1.6 + dev: false + + /@octokit/plugin-rest-endpoint-methods@13.5.0(@octokit/core@6.1.6): + resolution: {integrity: sha512-9Pas60Iv9ejO3WlAX3maE1+38c5nqbJXV5GrncEfkndIpZrJ/WPMRd2xYDcPPEt5yzpxcjw9fWNoPhsSGzqKqw==} + engines: {node: '>= 18'} + peerDependencies: + '@octokit/core': '>=6' + dependencies: + '@octokit/core': 6.1.6 + '@octokit/types': 13.10.0 + dev: false + + /@octokit/request-error@6.1.8: + resolution: {integrity: sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==} + engines: {node: '>= 18'} + dependencies: + '@octokit/types': 14.1.0 + dev: false + + /@octokit/request@9.2.4: + resolution: {integrity: sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA==} + engines: {node: '>= 18'} + dependencies: + '@octokit/endpoint': 10.1.4 + '@octokit/request-error': 6.1.8 + '@octokit/types': 14.1.0 + fast-content-type-parse: 2.0.1 + universal-user-agent: 7.0.3 + dev: false + + /@octokit/rest@21.1.1: + resolution: {integrity: sha512-sTQV7va0IUVZcntzy1q3QqPm/r8rWtDCqpRAmb8eXXnKkjoQEtFe3Nt5GTVsHft+R6jJoHeSiVLcgcvhtue/rg==} + engines: {node: '>= 18'} + dependencies: + '@octokit/core': 6.1.6 + '@octokit/plugin-paginate-rest': 11.6.0(@octokit/core@6.1.6) + '@octokit/plugin-request-log': 5.3.1(@octokit/core@6.1.6) + '@octokit/plugin-rest-endpoint-methods': 13.5.0(@octokit/core@6.1.6) + dev: false + + /@octokit/types@13.10.0: + resolution: {integrity: sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA==} + dependencies: + '@octokit/openapi-types': 24.2.0 + dev: false + + /@octokit/types@14.1.0: + resolution: {integrity: sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==} + dependencies: + '@octokit/openapi-types': 25.1.0 + dev: false + /@rollup/plugin-babel@6.1.0(@babel/core@7.28.5)(rollup@4.53.3): resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==} engines: {node: '>=14.0.0'} @@ -2539,6 +2738,10 @@ packages: resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} dev: true + /@types/aws-lambda@8.10.159: + resolution: {integrity: sha512-SAP22WSGNN12OQ8PlCzGzRCZ7QDCwI85dQZbmpz7+mAk+L7j+wI7qnvmdKh+o7A5LaOp6QnOZ2NJphAZQTTHQg==} + dev: false + /@types/babel__core@7.20.5: resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} dependencies: @@ -2575,6 +2778,14 @@ packages: '@types/node': 24.10.1 dev: true + /@types/connect-pg-simple@7.0.3: + resolution: {integrity: sha512-NGCy9WBlW2bw+J/QlLnFZ9WjoGs6tMo3LAut6mY4kK+XHzue//lpNVpAvYRpIwM969vBRAM2Re0izUvV6kt+NA==} + dependencies: + '@types/express': 4.17.25 + '@types/express-session': 1.18.2 + '@types/pg': 8.15.6 + dev: true + /@types/connect@3.4.38: resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} dependencies: @@ -2600,6 +2811,12 @@ packages: '@types/send': 1.2.1 dev: true + /@types/express-session@1.18.2: + resolution: {integrity: sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==} + dependencies: + '@types/express': 4.17.25 + dev: true + /@types/express@4.17.25: resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==} dependencies: @@ -3133,6 +3350,10 @@ packages: tweetnacl: 0.14.5 dev: true + /before-after-hook@3.0.2: + resolution: {integrity: sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==} + dev: false + /binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -3355,6 +3576,15 @@ packages: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} dev: true + /connect-pg-simple@9.0.1: + resolution: {integrity: sha512-BuwWJH3K3aLpONkO9s12WhZ9ceMjIBxIJAh0JD9x4z1Y9nShmWqZvge5PG/+4j2cIOcguUoa2PSQ4HO/oTsrVg==} + engines: {node: '>=16.0.0'} + dependencies: + pg: 8.13.1 + transitivePeerDependencies: + - pg-native + dev: false + /content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -3896,6 +4126,22 @@ packages: jsonwebtoken: 9.0.3 dev: false + /express-session@1.18.2: + resolution: {integrity: sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==} + engines: {node: '>= 0.8.0'} + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + on-headers: 1.1.0 + parseurl: 1.3.3 + safe-buffer: 5.2.1 + uid-safe: 2.1.5 + transitivePeerDependencies: + - supports-color + dev: false + /express-unless@2.1.3: resolution: {integrity: sha512-wj4tLMyCVYuIIKHGt0FhCtIViBcwzWejX0EjNxveAa6dG+0XBCQhMbx+PnkLkFCxLC69qoFrxds4pIyL88inaQ==} dev: false @@ -3944,6 +4190,10 @@ packages: engines: {node: '> 0.1.90'} dev: true + /fast-content-type-parse@2.0.1: + resolution: {integrity: sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==} + dev: false + /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: true @@ -5584,6 +5834,11 @@ packages: side-channel: 1.1.0 dev: false + /random-bytes@1.0.0: + resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==} + engines: {node: '>= 0.8'} + dev: false + /range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -6317,6 +6572,13 @@ packages: dev: true optional: true + /uid-safe@2.1.5: + resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==} + engines: {node: '>= 0.8'} + dependencies: + random-bytes: 1.0.0 + dev: false + /undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -6343,6 +6605,10 @@ packages: engines: {node: '>=4'} dev: true + /universal-user-agent@7.0.3: + resolution: {integrity: sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==} + dev: false + /unix-dgram@2.0.7: resolution: {integrity: sha512-pWaQorcdxEUBFIKjCqqIlQaOoNVmchyoaNAJ/1LwyyfK2XSxcBhgJNiSE8ZRhR0xkNGyk4xInt1G03QPoKXY5A==} engines: {node: '>=0.10.48'}