Gate admin menu and authoring with shared JWT

This commit is contained in:
2026-08-15 09:29:08 +02:00
parent eeaa4138fa
commit 8f1f5a8743
16 changed files with 366 additions and 52 deletions
+31 -18
View File
@@ -3,6 +3,7 @@ import { randomUUID } from 'node:crypto'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import pg from 'pg'
import jwt from 'jsonwebtoken'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import type { CaseState } from '../src/types.js'
import { runMigrations } from './migrations.js'
@@ -15,6 +16,13 @@ let adminClient: InstanceType<typeof Client>
let appServer: Awaited<typeof import('./index.js')>['server']
let appPool: Awaited<typeof import('./index.js')>['pool']
let baseUrl = ''
let adminAuthorization = ''
function adminFetch(url: string, init: RequestInit = {}) {
const headers = new Headers(init.headers)
headers.set('authorization', adminAuthorization)
return fetch(url, { ...init, headers })
}
async function availablePort() {
return new Promise<number>((resolve, reject) => {
@@ -44,11 +52,13 @@ suite('level persistence API', () => {
const port = await availablePort()
process.env.DATABASE_URL = databaseUrl
process.env.LEVEL_EDITING_ENABLED = 'true'
process.env.JWT_SECRET = 'osint-integration-jwt-secret'
process.env.PORT = String(port)
const serverModule = await import('./index.js')
appServer = serverModule.server
appPool = serverModule.pool
baseUrl = `http://127.0.0.1:${port}`
adminAuthorization = `Bearer ${jwt.sign({ sub: 'integration-admin', role: 'admin' }, process.env.JWT_SECRET)}`
})
afterAll(async () => {
@@ -60,7 +70,10 @@ suite('level persistence API', () => {
})
it('persists one normalized level across authoring and play views', async () => {
const createResponse = await fetch(`${baseUrl}/api/levels`, {
expect(await (await fetch(`${baseUrl}/api/session`)).json()).toEqual({ authenticated: false, isAdmin: false })
expect(await (await adminFetch(`${baseUrl}/api/session`)).json()).toEqual({ authenticated: true, isAdmin: true })
expect((await fetch(`${baseUrl}/api/levels`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).status).toBe(403)
const createResponse = await adminFetch(`${baseUrl}/api/levels`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ id: 'api-smoke-level', title: 'API Smoke Level' }),
@@ -88,14 +101,14 @@ suite('level persistence API', () => {
state.relations = [{ id: `contains:${folderId}:${documentId}`, fromWidgetId: folderId, toWidgetId: documentId, type: 'contains', sortOrder: 0, config: { x: 1051, y: 417 } }]
state.connections = [{ id: randomUUID(), fromEvidenceId: folderId, toEvidenceId: documentId, label: 'Primary source', tightness: 85, tagStyle: 'compact', tagPosition: 72, tagOffset: -12 }]
const saveResponse = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
const saveResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(state),
})
expect(await saveResponse.json()).toEqual({ ok: true, mode: 'author' })
const loaded = await (await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
const loaded = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
expect(loaded.viewport).toEqual(state.viewport)
expect(loaded.timelineRange).toEqual(state.timelineRange)
expect(loaded.evidence[0]).toMatchObject({ id: folderId, x: 685, y: 417, config: { open: true } })
@@ -115,25 +128,25 @@ suite('level persistence API', () => {
const upload = new FormData()
upload.append('file', new Blob(['OSINT smoke evidence'], { type: 'text/plain' }), 'smoke-evidence.txt')
const uploadResponse = await fetch(`${baseUrl}/api/levels/${state.id}/documents?edit=1`, { method: 'POST', body: upload })
const uploadResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/documents?edit=1`, { method: 'POST', body: upload })
expect(uploadResponse.status).toBe(201)
const uploaded = await uploadResponse.json() as CaseState['documents'][number]
expect(uploaded).toMatchObject({ title: 'smoke-evidence.txt', fileName: 'smoke-evidence.txt', mimeType: 'text/plain', fileType: 'text' })
expect(uploaded.assetId).toBeTruthy()
expect(await (await fetch(`${baseUrl}/api/assets/${uploaded.assetId}`)).text()).toBe('OSINT smoke evidence')
const withUpload = await (await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
const withUpload = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
const uploadedDocument = withUpload.documents.find(document => document.id === uploaded.id)!
uploadedDocument.title = 'Renamed smoke evidence'
uploadedDocument.publishedAt = '2022-06-15T10:30:00.000Z'
uploadedDocument.metadata = { witness: 'Integration test', confidence: 'high' }
const metadataSave = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
const metadataSave = await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(withUpload),
})
expect(metadataSave.ok).toBe(true)
const afterMetadataSave = await (await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
const afterMetadataSave = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
expect(afterMetadataSave.documents.find(document => document.id === uploaded.id)).toMatchObject({
title: 'Renamed smoke evidence',
publishedAt: '2022-06-15T10:30:00.000Z',
@@ -143,15 +156,15 @@ suite('level persistence API', () => {
const undatedState = structuredClone(afterMetadataSave)
const undatedEvent = undatedState.evidence.find(exhibit => exhibit.id === eventId)!
delete undatedEvent.eventDate
const undatedSave = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
const undatedSave = await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(undatedState),
})
expect(undatedSave.ok).toBe(true)
const loadedUndated = await (await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
const loadedUndated = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
expect(loadedUndated.evidence.find(exhibit => exhibit.id === eventId)?.eventDate).toBeUndefined()
expect((await appPool.query<{ occurred_at: Date | null }>('SELECT occurred_at FROM osint.event_exhibits WHERE exhibit_id=$1', [eventId])).rows[0].occurred_at).toBeNull()
loadedUndated.evidence.find(exhibit => exhibit.id === eventId)!.eventDate = '2021-04-18T14:30:00Z'
expect((await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
expect((await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(loadedUndated),
})).ok).toBe(true)
@@ -176,7 +189,7 @@ suite('level persistence API', () => {
const savedPlayerState = await (await fetch(`${baseUrl}/api/levels/${state.id}`)).json() as CaseState
expect(savedPlayerState.viewport).toEqual(playerState.viewport)
expect(savedPlayerState.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
const sameLevelInEditView = await (await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
const sameLevelInEditView = await (await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`)).json() as CaseState
expect(sameLevelInEditView.viewport).toEqual(playerState.viewport)
expect(sameLevelInEditView.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
@@ -205,7 +218,7 @@ suite('level persistence API', () => {
expect(resetState.timelineRange).toEqual(state.timelineRange)
expect(resetState.evidence[0]).toMatchObject({ id: folderId, x: 812, y: 533 })
const templateResponse = await fetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
const templateResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }),
})
expect(templateResponse.status).toBe(201)
@@ -217,10 +230,10 @@ suite('level persistence API', () => {
const changedSource = structuredClone(savedPlayerState)
changedSource.title = 'Changed after template freeze'
changedSource.evidence[0].x = 999
await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
await adminFetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(changedSource),
})
const cloneResponse = await fetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, {
const cloneResponse = await adminFetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'smoke-template-copy', title: 'Playable copy' }),
})
expect(cloneResponse.status).toBe(201)
@@ -242,7 +255,7 @@ suite('level persistence API', () => {
expect(clone.evidence.filter(item => item.type === 'party')).toHaveLength(2)
expect(clone.brief.concepts.every(concept => Boolean(concept.resolvedPartyExhibitId))).toBe(true)
expect(clone.brief.concepts.map(concept => concept.resolvedPartyExhibitId)).not.toContain(personPartyId)
const authoredClone = await (await fetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState
const authoredClone = await (await adminFetch(`${baseUrl}/api/levels/${clone.id}?edit=1`)).json() as CaseState
expect(authoredClone.brief.concepts.map(concept => concept.expectedPartyKind).sort()).toEqual(['organization', 'person'])
const clonedFolder = clone.evidence.find(item => item.type === 'folder')!
@@ -256,14 +269,14 @@ suite('level persistence API', () => {
expect(cloneReset.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 812, y: 533 })
expect(cloneReset.evidence.map(item => item.id)).not.toContain(clonedFolder.id)
const versionTwoResponse = await fetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
const versionTwoResponse = await adminFetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Smoke Template' }),
})
expect(await versionTwoResponse.json()).toMatchObject({ currentVersion: 2, versionCount: 2 })
const oldVersion = await (await fetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, {
const oldVersion = await (await adminFetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'old-version-copy', version: 1 }),
})).json() as CaseState
const currentVersion = await (await fetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, {
const currentVersion = await (await adminFetch(`${baseUrl}/api/templates/smoke-template/levels?edit=1`, {
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: 'current-version-copy' }),
})).json() as CaseState
expect(oldVersion.evidence.find(item => item.type === 'folder')).toMatchObject({ x: 812 })
+38
View File
@@ -0,0 +1,38 @@
import type { NextFunction, Request, Response } from 'express'
import jwt, { type JwtPayload } from 'jsonwebtoken'
export type OsintClaims = JwtPayload & { role?: string; isAdmin?: boolean }
declare global {
namespace Express {
interface Request { authClaims?: OsintClaims }
}
}
export function authenticateJwt(req: Request, _res: Response, next: NextFunction) {
const authorization = req.headers.authorization
const token = req.cookies?.auth_token || (authorization?.startsWith('Bearer ') ? authorization.slice(7) : undefined)
const secret = process.env.JWT_SECRET
if (token && secret) {
try {
const decoded = jwt.verify(token, secret)
if (typeof decoded !== 'string') req.authClaims = decoded as OsintClaims
} catch { /* An absent, expired, or invalid cookie is an anonymous session. */ }
}
next()
}
export function hasAdminClaim(req: Request) {
return req.authClaims?.role === 'admin' || req.authClaims?.isAdmin === true
}
export function requireAdmin(req: Request, res: Response, next: NextFunction) {
if (!hasAdminClaim(req)) return res.status(403).json({ error: 'Administrator claim required' })
next()
}
export function createDevelopmentAdminToken() {
if (process.env.NODE_ENV === 'production') throw new Error('Development sessions are disabled in production')
if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is required')
return jwt.sign({ sub: 'osint-local-admin', role: 'admin', isAdmin: true }, process.env.JWT_SECRET, { expiresIn: '7d' })
}
+7 -3
View File
@@ -2,6 +2,7 @@ import { once } from 'node:events'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import pg from 'pg'
import jwt from 'jsonwebtoken'
import type { CaseState } from '../src/types.js'
import { importMysteryTemplate } from '../scripts/importMysteryTemplate.js'
import { runMigrations } from './migrations.js'
@@ -26,17 +27,20 @@ await runMigrations(databaseUrl, migrationsDir, () => undefined)
const port = Number(process.env.E2E_PORT || 18788)
process.env.DATABASE_URL = databaseUrl
process.env.LEVEL_EDITING_ENABLED = 'true'
process.env.JWT_SECRET = 'osint-e2e-jwt-secret'
process.env.PORT = String(port)
process.env.OSINT_MANAGED_SERVER = 'true'
const { server, pool } = await import('./index.js')
if (!server.listening) await once(server, 'listening')
const baseUrl = `http://127.0.0.1:${port}`
const adminToken = jwt.sign({ sub: 'e2e-admin', role: 'admin' }, process.env.JWT_SECRET)
const adminHeaders = { 'content-type': 'application/json', authorization: `Bearer ${adminToken}` }
const documentId = '22222222-2222-4222-8222-222222222222'
const folderId = '11111111-1111-4111-8111-111111111111'
const created = await fetch(`${baseUrl}/api/levels`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
headers: adminHeaders,
body: JSON.stringify({ id: 'e2e-level', title: 'Browser Safety Test', subtitle: 'Disposable test level' }),
})
if (!created.ok) throw new Error(`Could not create browser test level: ${created.status}`)
@@ -61,12 +65,12 @@ state.connections = []
state.viewport = { x: 0, y: 28, zoom: 0.7 }
const saved = await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
headers: adminHeaders,
body: JSON.stringify(state),
})
if (!saved.ok) throw new Error(`Could not seed browser test level: ${saved.status}`)
await importMysteryTemplate(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'mysteries', 'glass-harbor', 'mystery.json'), baseUrl)
await importMysteryTemplate(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'mysteries', 'glass-harbor', 'mystery.json'), baseUrl, adminToken)
let shuttingDown = false
async function shutdown(exitCode: number) {
+19 -8
View File
@@ -1,5 +1,6 @@
import 'dotenv/config'
import cors from 'cors'
import cookieParser from 'cookie-parser'
import express from 'express'
import fs from 'node:fs'
import path from 'node:path'
@@ -7,6 +8,7 @@ import { fileURLToPath } from 'node:url'
import multer from 'multer'
import pg from 'pg'
import type { CaseState } from '../src/types.js'
import { authenticateJwt, createDevelopmentAdminToken, hasAdminClaim, requireAdmin } from './auth.js'
import { createLevelRepository } from './levelRepository.js'
const { Pool } = pg
@@ -21,7 +23,7 @@ const editingEnabled = process.env.LEVEL_EDITING_ENABLED === 'true'
const levels = createLevelRepository(pool, editingEnabled)
function wantsEdit(req: express.Request) {
return editingEnabled && req.query.edit === '1'
return editingEnabled && req.query.edit === '1' && hasAdminClaim(req)
}
function slug(value: unknown, fallback: string) {
return String(value || fallback).trim().toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '')
@@ -29,7 +31,9 @@ function slug(value: unknown, fallback: string) {
export const app = express()
app.disable('x-powered-by')
app.use(cors({ origin: process.env.CORS_ORIGIN || true }))
app.use(cors({ origin: process.env.CORS_ORIGIN || true, credentials: true }))
app.use(cookieParser())
app.use(authenticateJwt)
app.use(express.json({ limit: '2mb' }))
const upload = multer({
storage: multer.memoryStorage(),
@@ -40,6 +44,13 @@ app.get('/api/health', async (_req, res) => {
try { await pool.query('SELECT 1'); res.json({ ok: true, database: 'connected', schema: 'osint', editingEnabled }) }
catch { res.status(503).json({ ok: false, database: 'unavailable' }) }
})
app.get('/api/session', (req, res) => res.json({ authenticated: Boolean(req.authClaims), isAdmin: hasAdminClaim(req) }))
if (process.env.NODE_ENV !== 'production') app.get('/api/dev/admin-session', (req, res) => {
const requestedReturn = String(req.query.returnTo || '/')
const returnTo = requestedReturn.startsWith('/') && !requestedReturn.startsWith('//') ? requestedReturn : '/'
res.cookie('auth_token', createDevelopmentAdminToken(), { httpOnly: true, sameSite: 'lax', path: '/', maxAge: 7 * 24 * 60 * 60 * 1000 })
res.redirect(returnTo)
})
app.get('/api/levels', async (_req, res, next) => {
try { res.json(await levels.listLevels()) }
catch (error) { next(error) }
@@ -48,16 +59,16 @@ app.get('/api/templates', async (_req, res, next) => {
try { res.json(await levels.listTemplates()) }
catch (error) { next(error) }
})
app.post('/api/templates/:slug/levels', async (req, res, next) => {
app.post('/api/templates/:slug/levels', requireAdmin, async (req, res, next) => {
try {
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
const title = String(req.body?.title || '').trim() || undefined
const levelSlug = slug(req.body?.id, `${req.params.slug}-${Date.now()}`)
const level = await levels.instantiateTemplate(req.params.slug, { id: levelSlug, title, version: req.body?.version })
const level = await levels.instantiateTemplate(String(req.params.slug), { id: levelSlug, title, version: req.body?.version })
level ? res.status(201).json(level) : res.status(404).json({ error: 'Template version not found' })
} catch (error) { next(error) }
})
app.post('/api/levels', async (req, res, next) => {
app.post('/api/levels', requireAdmin, async (req, res, next) => {
try {
if (!editingEnabled) return res.status(403).json({ error: 'Level editing is disabled' })
const title = String(req.body?.title || 'Untitled Investigation').trim()
@@ -65,12 +76,12 @@ app.post('/api/levels', async (req, res, next) => {
res.status(201).json(await levels.createLevel({ id, title, subtitle: String(req.body?.subtitle || '') }))
} catch (error) { next(error) }
})
app.post('/api/levels/:id/templates', async (req, res, next) => {
app.post('/api/levels/:id/templates', requireAdmin, async (req, res, next) => {
try {
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
const name = String(req.body?.name || 'Untitled Template').trim()
const templateSlug = slug(req.body?.slug, name)
const template = await levels.saveLevelAsTemplate(req.params.id, { slug: templateSlug, name })
const template = await levels.saveLevelAsTemplate(String(req.params.id), { slug: templateSlug, name })
template ? res.status(201).json(template) : res.status(404).json({ error: 'Level not found' })
} catch (error) { next(error) }
})
@@ -86,7 +97,7 @@ app.get('/api/assets/:id', async (req, res, next) => {
res.send(asset.content)
} catch (error) { next(error) }
})
app.post('/api/levels/:id/documents', upload.single('file'), async (req, res, next) => {
app.post('/api/levels/:id/documents', requireAdmin, upload.single('file'), async (req, res, next) => {
try {
if (!wantsEdit(req)) return res.status(403).json({ error: 'Level editing is disabled' })
if (!req.file) return res.status(400).json({ error: 'A file is required' })
+1 -1
View File
@@ -160,7 +160,7 @@ export function createLevelRepository(pool: Pool, editingEnabled: boolean): Leve
tagPosition: row.tag_position_percent, tagOffset: row.tag_lateral_offset })),
viewport: { x: level.viewport_x, y: level.viewport_y, zoom: level.viewport_zoom }, updatedAt: level.updated_at.toISOString(),
timelineRange: timelineResult.rows[0] ? { start: timelineResult.rows[0].range_start, end: timelineResult.rows[0].range_end } : undefined,
brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled,
brief: { body: briefResult.rows[0]?.body || '', concepts }, levelStatus: level.status, editingAllowed: editingEnabled && authorMode,
sourceTemplateVersionId: level.source_template_version_id || undefined }
}