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.
+
+
+
+
+`}) %>
\ 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 + '
' : ''}
+
+
+`}) %>
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
+
+
+
+
+
+
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'}