From 932bd36e66b712f8e9b343b970410df83e7cff34 Mon Sep 17 00:00:00 2001 From: Ben Hollis Date: Tue, 6 Jan 2026 21:30:15 -0800 Subject: [PATCH 1/9] Session table migration --- .../20260107052805-session-table.js | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 api/migrations/20260107052805-session-table.js 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, +}; From 6f4547e7857bd8688ec3822c378da1b4de066fd4 Mon Sep 17 00:00:00 2001 From: Ben Hollis Date: Tue, 6 Jan 2026 22:22:09 -0800 Subject: [PATCH 2/9] Basic express setup --- api/admin/@types/express-session.d.ts | 13 ++ api/admin/server.ts | 80 ++++++++ api/admin/views/add-app.ejs | 61 ++++++ api/admin/views/index.ejs | 16 ++ api/admin/views/layout.ejs | 101 ++++++++++ api/server.ts | 8 + package.json | 6 + pnpm-lock.yaml | 266 ++++++++++++++++++++++++++ 8 files changed, 551 insertions(+) create mode 100644 api/admin/@types/express-session.d.ts create mode 100644 api/admin/server.ts create mode 100644 api/admin/views/add-app.ejs create mode 100644 api/admin/views/index.ejs create mode 100644 api/admin/views/layout.ejs diff --git a/api/admin/@types/express-session.d.ts b/api/admin/@types/express-session.d.ts new file mode 100644 index 0000000..535ab4c --- /dev/null +++ b/api/admin/@types/express-session.d.ts @@ -0,0 +1,13 @@ +import 'express-session'; + +declare module 'express-session' { + interface SessionData { + user?: { + id: number; + login: string; + name: string | null; + avatarUrl: string; + isTeamMember: boolean; + }; + } +} diff --git a/api/admin/server.ts b/api/admin/server.ts new file mode 100644 index 0000000..67e0cce --- /dev/null +++ b/api/admin/server.ts @@ -0,0 +1,80 @@ +import express, { Router } from 'express'; +import session from 'express-session'; +import connectPgSimple from 'connect-pg-simple'; +import { pool } from '../db/index.js'; + +/** 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: pool, // Reuse existing connection pool + tableName: 'session', + createTableIfMissing: false, + pruneSessionInterval: 60 * 15, // Auto-cleanup every 15 minutes + }), + secret: process.env.ADMIN_SESSION_SECRET!, + 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' })); + +// Basic routes structure + +// Home/Dashboard +adminRouter.get('/', (req, res) => { + // TODO: Add authentication check + res.render('admin/index', { + user: req.session.user, + }); +}); + +// Authentication routes (placeholders for now) +adminRouter.get('/auth/login', (_req, res) => { + // TODO: Implement GitHub OAuth login + res.send('GitHub OAuth login - to be implemented'); +}); + +adminRouter.get('/auth/callback', (_req, res) => { + // TODO: Implement GitHub OAuth callback + res.send('GitHub OAuth callback - to be implemented'); +}); + +adminRouter.get('/auth/logout', (req, res) => { + req.session.destroy((err) => { + if (err) { + console.error('Error destroying session:', err); + } + res.redirect('/admin'); + }); +}); + +// Add App tool routes (placeholders for now) +adminRouter.get('/add-app', (req, res) => { + // TODO: Add authentication check + res.render('admin/add-app', { + user: req.session.user, + error: req.query.error, + success: req.query.success, + }); +}); + +adminRouter.post('/add-app', (req, res) => { + // TODO: Implement add app logic + res.redirect('/admin/add-app?success=1'); +}); diff --git a/api/admin/views/add-app.ejs b/api/admin/views/add-app.ejs new file mode 100644 index 0000000..f42eb63 --- /dev/null +++ b/api/admin/views/add-app.ejs @@ -0,0 +1,61 @@ +<% const user = locals.user; %> +<% const error = locals.error; %> +<% const success = locals.success; %> +<%- include('layout', { body: ` +

Add API App

+ + ${success ? '
App created successfully!
' : ''} + ${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..c5255b0 --- /dev/null +++ b/api/admin/views/index.ejs @@ -0,0 +1,16 @@ +<% const user = locals.user; %> +<%- include('layout', { body: ` +

Admin Dashboard

+ + ${!user ? ` +

Please login with GitHub to access the admin panel.

+ ` : !user.isTeamMember ? ` +

Access denied. You must be a member of the DestinyItemManager/developers team to access this panel.

+ ` : ` +

Welcome, ${user.login}!

+

Available Tools:

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

DIM Admin Panel

+ <% if (locals.user) { %> + + <% } else { %> + + <% } %> +
+ +
+ <% if (locals.user && user.isTeamMember) { %> + + <% } %> + +
+ <%- body %> +
+
+ + 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'} From 29b1d768c443746519fbc4cf58d0758891b7ba02 Mon Sep 17 00:00:00 2001 From: Ben Hollis Date: Tue, 6 Jan 2026 23:23:29 -0800 Subject: [PATCH 3/9] Github auth --- api/admin/@types/express-session.d.ts | 13 --- api/admin/middleware/github-auth.ts | 153 ++++++++++++++++++++++++++ api/admin/server.ts | 58 +++++----- api/admin/views/403.ejs | 119 ++++++++++++++++++++ api/admin/views/add-app.ejs | 41 ++++--- api/admin/views/index.ejs | 25 ++--- api/admin/views/layout.ejs | 12 +- 7 files changed, 334 insertions(+), 87 deletions(-) delete mode 100644 api/admin/@types/express-session.d.ts create mode 100644 api/admin/middleware/github-auth.ts create mode 100644 api/admin/views/403.ejs diff --git a/api/admin/@types/express-session.d.ts b/api/admin/@types/express-session.d.ts deleted file mode 100644 index 535ab4c..0000000 --- a/api/admin/@types/express-session.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -import 'express-session'; - -declare module 'express-session' { - interface SessionData { - user?: { - id: number; - login: string; - name: string | null; - avatarUrl: string; - isTeamMember: boolean; - }; - } -} diff --git a/api/admin/middleware/github-auth.ts b/api/admin/middleware/github-auth.ts new file mode 100644 index 0000000..6d7c45d --- /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 = 'developers'; + +// Initialize OAuth App +const oauthApp = new OAuthApp({ + clientType: 'oauth-app', + clientId: process.env.GITHUB_CLIENT_ID!, + clientSecret: process.env.GITHUB_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/server.ts b/api/admin/server.ts index 67e0cce..51fcf27 100644 --- a/api/admin/server.ts +++ b/api/admin/server.ts @@ -1,8 +1,20 @@ +import connectPgSimple from 'connect-pg-simple'; import express, { Router } from 'express'; import session from 'express-session'; -import connectPgSimple from 'connect-pg-simple'; import { pool } from '../db/index.js'; +import { githubAuthRouter, requireAuth } from './middleware/github-auth.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); @@ -10,10 +22,11 @@ const PgSession = connectPgSimple(session); export const adminRouter = Router(); // Session configuration - must be first + adminRouter.use( session({ store: new PgSession({ - pool: pool, // Reuse existing connection pool + pool, // Reuse existing connection pool tableName: 'session', createTableIfMissing: false, pruneSessionInterval: 60 * 15, // Auto-cleanup every 15 minutes @@ -34,47 +47,28 @@ adminRouter.use( // Body parsing middleware adminRouter.use(express.urlencoded({ extended: true, limit: '1mb' })); -// Basic routes structure +// GitHub OAuth authentication routes +adminRouter.use('/auth', githubAuthRouter); -// Home/Dashboard -adminRouter.get('/', (req, res) => { - // TODO: Add authentication check - res.render('admin/index', { +// Home/Dashboard - protected route +adminRouter.get('/', requireAuth, (req, res) => { + res.render('admin/views/index', { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment user: req.session.user, }); }); -// Authentication routes (placeholders for now) -adminRouter.get('/auth/login', (_req, res) => { - // TODO: Implement GitHub OAuth login - res.send('GitHub OAuth login - to be implemented'); -}); - -adminRouter.get('/auth/callback', (_req, res) => { - // TODO: Implement GitHub OAuth callback - res.send('GitHub OAuth callback - to be implemented'); -}); - -adminRouter.get('/auth/logout', (req, res) => { - req.session.destroy((err) => { - if (err) { - console.error('Error destroying session:', err); - } - res.redirect('/admin'); - }); -}); - -// Add App tool routes (placeholders for now) -adminRouter.get('/add-app', (req, res) => { - // TODO: Add authentication check - res.render('admin/add-app', { +// Add App tool routes - protected routes +adminRouter.get('/add-app', requireAuth, (req, res) => { + res.render('admin/views/add-app', { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment user: req.session.user, error: req.query.error, success: req.query.success, }); }); -adminRouter.post('/add-app', (req, res) => { +adminRouter.post('/add-app', requireAuth, (_req, res) => { // TODO: Implement add app logic res.redirect('/admin/add-app?success=1'); }); 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) { %> + + <% } %> + + Visit DestinyItemManager on GitHub + +
+ Log out +
+ + diff --git a/api/admin/views/add-app.ejs b/api/admin/views/add-app.ejs index f42eb63..067a151 100644 --- a/api/admin/views/add-app.ejs +++ b/api/admin/views/add-app.ejs @@ -1,58 +1,57 @@ -<% const user = locals.user; %> <% const error = locals.error; %> <% const success = locals.success; %> <%- include('layout', { body: `

Add API App

- + ${success ? '
App created successfully!
' : ''} ${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 index c5255b0..3a829fb 100644 --- a/api/admin/views/index.ejs +++ b/api/admin/views/index.ejs @@ -1,16 +1,15 @@ -<% const user = locals.user; %> <%- include('layout', { body: `

Admin Dashboard

- - ${!user ? ` -

Please login with GitHub to access the admin panel.

- ` : !user.isTeamMember ? ` -

Access denied. You must be a member of the DestinyItemManager/developers team to access this panel.

- ` : ` -

Welcome, ${user.login}!

-

Available Tools:

- - `} + +

Welcome, ${locals.user.login}!

+ +

Available Tools:

+ `}) %> diff --git a/api/admin/views/layout.ejs b/api/admin/views/layout.ejs index 05d9455..534e805 100644 --- a/api/admin/views/layout.ejs +++ b/api/admin/views/layout.ejs @@ -75,24 +75,20 @@

DIM Admin Panel

<% if (locals.user) { %> - <% } else { %> - <% } %> - +
- <% if (locals.user && user.isTeamMember) { %> + <% if (locals.user) { %> <% } %> - +
<%- body %>
From c3bc873a1ef9940bc8a28733c62671da85032878 Mon Sep 17 00:00:00 2001 From: Ben Hollis Date: Wed, 7 Jan 2026 21:56:47 -0800 Subject: [PATCH 4/9] Mild testing --- api/admin/middleware/github-auth.ts | 4 +- api/admin/server.test.ts | 62 +++++++++++++++++++++++++++++ api/admin/server.ts | 4 +- 3 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 api/admin/server.test.ts diff --git a/api/admin/middleware/github-auth.ts b/api/admin/middleware/github-auth.ts index 6d7c45d..27380cf 100644 --- a/api/admin/middleware/github-auth.ts +++ b/api/admin/middleware/github-auth.ts @@ -13,8 +13,8 @@ const GITHUB_TEAM = 'developers'; // Initialize OAuth App const oauthApp = new OAuthApp({ clientType: 'oauth-app', - clientId: process.env.GITHUB_CLIENT_ID!, - clientSecret: process.env.GITHUB_CLIENT_SECRET!, + clientId: process.env.GITHUB_CLIENT_ID || 'test-client-id', + clientSecret: process.env.GITHUB_CLIENT_SECRET || 'test-client-secret', }); // OAuth Routes 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 index 51fcf27..2543892 100644 --- a/api/admin/server.ts +++ b/api/admin/server.ts @@ -31,7 +31,7 @@ adminRouter.use( createTableIfMissing: false, pruneSessionInterval: 60 * 15, // Auto-cleanup every 15 minutes }), - secret: process.env.ADMIN_SESSION_SECRET!, + secret: process.env.ADMIN_SESSION_SECRET || 'default-secret-for-testing', resave: false, saveUninitialized: false, cookie: { @@ -53,7 +53,6 @@ adminRouter.use('/auth', githubAuthRouter); // Home/Dashboard - protected route adminRouter.get('/', requireAuth, (req, res) => { res.render('admin/views/index', { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment user: req.session.user, }); }); @@ -61,7 +60,6 @@ adminRouter.get('/', requireAuth, (req, res) => { // Add App tool routes - protected routes adminRouter.get('/add-app', requireAuth, (req, res) => { res.render('admin/views/add-app', { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment user: req.session.user, error: req.query.error, success: req.query.success, From f98111288f2a0eae0c3ffd56302d544a97380d6b Mon Sep 17 00:00:00 2001 From: Ben Hollis Date: Wed, 7 Jan 2026 22:44:49 -0800 Subject: [PATCH 5/9] Add-app handler --- api/admin/routes/add-app.test.ts | 197 ++++++++++++++++++++ api/admin/routes/add-app.ts | 96 ++++++++++ api/admin/server.ts | 8 +- api/admin/views/add-app.ejs | 44 ++--- api/admin/views/index.ejs | 10 +- api/admin/views/layout.ejs | 306 ++++++++++++++++++++++--------- 6 files changed, 542 insertions(+), 119 deletions(-) create mode 100644 api/admin/routes/add-app.test.ts create mode 100644 api/admin/routes/add-app.ts diff --git a/api/admin/routes/add-app.test.ts b/api/admin/routes/add-app.test.ts new file mode 100644 index 0000000..be3cce9 --- /dev/null +++ b/api/admin/routes/add-app.test.ts @@ -0,0 +1,197 @@ +// 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 { 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', '/Users/brh/Documents/oss/dim-api/api'); + +// Mock req.session.user for all requests +testApp.use((req, _res, next) => { + 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('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.ts b/api/admin/server.ts index 2543892..bf354eb 100644 --- a/api/admin/server.ts +++ b/api/admin/server.ts @@ -3,6 +3,7 @@ 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 { @@ -61,12 +62,7 @@ adminRouter.get('/', requireAuth, (req, res) => { adminRouter.get('/add-app', requireAuth, (req, res) => { res.render('admin/views/add-app', { user: req.session.user, - error: req.query.error, - success: req.query.success, }); }); -adminRouter.post('/add-app', requireAuth, (_req, res) => { - // TODO: Implement add app logic - res.redirect('/admin/add-app?success=1'); -}); +adminRouter.post('/add-app', requireAuth, addAppHandler); diff --git a/api/admin/views/add-app.ejs b/api/admin/views/add-app.ejs index 067a151..8c11614 100644 --- a/api/admin/views/add-app.ejs +++ b/api/admin/views/add-app.ejs @@ -1,14 +1,13 @@ <% const error = locals.error; %> -<% const success = locals.success; %> +<% const formData = locals.formData || {}; %> <%- include('layout', { body: `

Add API App

- ${success ? '
App created successfully!
' : ''} - ${error ? '
Error: ' + error + '
' : ''} + ${error ? '
Error: ' + error + '
' : ''} - -
- + +
+ - Lowercase letters, numbers, and hyphens only. Minimum 3 characters. + Lowercase letters, numbers, and hyphens only. Minimum 3 characters.
-
- +
+
-
- +
+ - The full URL origin (e.g., https://example.com) + The full URL origin (e.g., https://example.com)
-
- - Cancel +
+ + Cancel
`}) %> diff --git a/api/admin/views/index.ejs b/api/admin/views/index.ejs index 3a829fb..01d1202 100644 --- a/api/admin/views/index.ejs +++ b/api/admin/views/index.ejs @@ -4,11 +4,11 @@

Welcome, ${locals.user.login}!

Available Tools:

-
    -
  • - - Add API App - Create a new DIM API application + diff --git a/api/admin/views/layout.ejs b/api/admin/views/layout.ejs index 534e805..9a52045 100644 --- a/api/admin/views/layout.ejs +++ b/api/admin/views/layout.ejs @@ -1,97 +1,235 @@ - - - - DIM Admin Panel - - - -
    -

    DIM Admin Panel

    - <% if (locals.user) { %> + + + + DIM Admin Panel + + + +
    +

    DIM Admin Panel

    - <% } %> -
    +
    -
    - <% if (locals.user) { %> +
    - <% } %> -
    - <%- body %> +
    <%- body %>
    -
    - + From ce8ebb2cbac45865b1ff0947b9f1d0c28ab42dcb Mon Sep 17 00:00:00 2001 From: Ben Hollis Date: Wed, 7 Jan 2026 22:45:57 -0800 Subject: [PATCH 6/9] Team --- api/admin/middleware/github-auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/admin/middleware/github-auth.ts b/api/admin/middleware/github-auth.ts index 27380cf..5e7b7c2 100644 --- a/api/admin/middleware/github-auth.ts +++ b/api/admin/middleware/github-auth.ts @@ -8,7 +8,7 @@ import { NextFunction, Request, Response, Router } from 'express'; // GitHub organization and team to verify const GITHUB_ORG = 'DestinyItemManager'; -const GITHUB_TEAM = 'developers'; +const GITHUB_TEAM = 'apiadmins'; // Initialize OAuth App const oauthApp = new OAuthApp({ From 309abba398b4782d2041380c76fd68023dc5902f Mon Sep 17 00:00:00 2001 From: Ben Hollis Date: Wed, 7 Jan 2026 22:51:45 -0800 Subject: [PATCH 7/9] Lint --- api/admin/routes/add-app.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/api/admin/routes/add-app.test.ts b/api/admin/routes/add-app.test.ts index be3cce9..572e399 100644 --- a/api/admin/routes/add-app.test.ts +++ b/api/admin/routes/add-app.test.ts @@ -19,6 +19,7 @@ testApp.set('views', '/Users/brh/Documents/oss/dim-api/api'); // 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, @@ -77,7 +78,10 @@ async function expectAppInDatabase( appId: string, expectedData: { bungieApiKey: string; origin: string }, ) { - const result = await pool.query('SELECT * FROM apps WHERE id = $1', [appId]); + 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); From d34f0ab67724e42e2a5d4a0150f137c18063c2b1 Mon Sep 17 00:00:00 2001 From: Ben Hollis Date: Wed, 7 Jan 2026 22:57:31 -0800 Subject: [PATCH 8/9] Forgot a view --- api/admin/views/add-app-success.ejs | 36 +++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 api/admin/views/add-app-success.ejs 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 From ec069556d5c853b6a7b961db4f78d12a36a388ec Mon Sep 17 00:00:00 2001 From: Ben Hollis Date: Wed, 7 Jan 2026 23:05:36 -0800 Subject: [PATCH 9/9] Come on --- api/admin/routes/add-app.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/api/admin/routes/add-app.test.ts b/api/admin/routes/add-app.test.ts index 572e399..9c40c01 100644 --- a/api/admin/routes/add-app.test.ts +++ b/api/admin/routes/add-app.test.ts @@ -4,6 +4,7 @@ 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'; @@ -15,7 +16,7 @@ testApp.use(express.urlencoded({ extended: true, limit: '1mb' })); // Configure EJS testApp.set('view engine', 'ejs'); -testApp.set('views', '/Users/brh/Documents/oss/dim-api/api'); +testApp.set('views', resolve(new URL('.', import.meta.url).pathname, '../..')); // Mock req.session.user for all requests testApp.use((req, _res, next) => {