From 8f1f5a8743cc10943538b76c61f32b2d1867b28e Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Sat, 15 Aug 2026 09:29:08 +0200 Subject: [PATCH] Gate admin menu and authoring with shared JWT --- .env.example | 1 + README.md | 11 ++- docker-compose.dev.yml | 3 +- docker-compose.prod.yml | 1 + e2e/board.smoke.spec.ts | 18 +++- package-lock.json | 165 ++++++++++++++++++++++++++++++- package.json | 4 + playwright.config.ts | 3 + scripts/importMysteryTemplate.ts | 18 ++-- server/api.integration.test.ts | 49 +++++---- server/auth.ts | 38 +++++++ server/e2eHarness.ts | 10 +- server/index.ts | 27 +++-- server/levelRepository.ts | 2 +- src/App.tsx | 67 +++++++++++-- src/styles.css | 1 + 16 files changed, 366 insertions(+), 52 deletions(-) create mode 100644 server/auth.ts diff --git a/.env.example b/.env.example index 2d323eb..e1bae26 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,5 @@ DATABASE_URL=postgres://osint:osint_secret@localhost:5433/osint_dev PORT=8787 CORS_ORIGIN=http://localhost:5173 LEVEL_EDITING_ENABLED=true +JWT_SECRET=osint-local-dev-secret MAX_DOCUMENT_BYTES=26214400 diff --git a/README.md b/README.md index 387190b..f589d99 100644 --- a/README.md +++ b/README.md @@ -76,17 +76,20 @@ The API surface is: - `POST /api/levels/:id/templates` (save a new immutable version; editor only) - `POST /api/levels/:id/documents` (editor only) - `GET /api/assets/:id` +- `GET /api/session` (verified session and admin capability summary) - `GET /api/health` The browser also keeps a local emergency copy so a network interruption does not lose an in-progress board. ## Level editor -Set `LEVEL_EDITING_ENABLED=true` and open `/?edit=1`. If the database is empty, this surface creates the first blank level. Editing and playing are the same operation against a mutable level. Authoring additionally exposes **Save Template** and **New From Template**. Saving creates a new immutable version; instantiating gives every exhibit a fresh ID while sharing immutable binary assets. Reset restores the exact template version from which a level was created. +Set `LEVEL_EDITING_ENABLED=true` and open `/?edit=1` while signed in with a JWT carrying `role: "admin"`. The shared `auth_token` cookie is verified with `JWT_SECRET`; the legacy `isAdmin: true` claim remains compatible. If the database is empty, this surface creates the first blank level. Editing and playing are the same operation against a mutable level. Authoring actions live under the **Admin** menu. Saving a template creates a new immutable version; instantiating gives every exhibit a fresh ID while sharing immutable binary assets. Reset restores the exact template version from which a level was created. + +For the standalone development Compose stack, visit `/api/dev/admin-session?returnTo=/?edit=1` once to receive a local signed admin cookie. This helper does not exist in production. In edit mode, files can be dragged from the desktop onto the board or selected with **Import Document**. Images, PDFs, and text files render inside document windows; unknown formats remain downloadable source files. Extracted evidence becomes an editable folder widget. Its editor controls the title, annotation, contained documents, and each source document's publication time. The default upload limit is 25 MB and can be changed with `MAX_DOCUMENT_BYTES`. -Production defaults editing to disabled. Set `LEVEL_EDITING_ENABLED=true` in `/opt/gu_common/.env.prod` only when the authoring surface should be available. This is a capability switch, not authentication; add authentication before exposing production editing to untrusted users. +Production defaults editing to disabled. Set `LEVEL_EDITING_ENABLED=true` in `/opt/gu_common/.env.prod` only when the authoring surface should be available. `JWT_SECRET` is inherited from that shared environment, and authoring endpoints additionally require a verified admin claim. ## POC exhibit and widget contract @@ -113,7 +116,7 @@ On desktop, an ordinary mouse wheel or two-finger trackpad scroll zooms the boar ## Deploy at osint.glitch.university -The production service depends on `gu_common`: it joins the external `gnommo` Docker network, uses `gnommo-db`, and is routed by the shared nginx container. Deploy `gu_common` after its nginx configuration changes, then deploy this repository: +The production service depends on `gu_common`: it joins the external `gnommo` Docker network, uses `gnommo-db`, shares its JWT secret and cross-subdomain authentication cookie, and is routed by the shared nginx container. Deploy `gu_common` after its nginx configuration changes, then deploy this repository: ```bash ./deploy.sh @@ -123,4 +126,4 @@ The deploy script builds and syncs the application, reads production database cr ## Deliberate POC boundaries -There are no accounts, arbitrary uploads, real-world web browsing, OCR, or collaboration yet. The server data model and provenance fields leave room for those later without making them part of the first playability test. +Authentication is supplied by the shared Glitch University account system. There is no OSINT-specific account model, real-world web browsing, OCR, or collaboration yet. The server data model and provenance fields leave room for those later without making them part of the first playability test. diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 88e1865..f0e25b8 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -23,10 +23,11 @@ services: dockerfile: Dockerfile container_name: osint-board-app environment: - NODE_ENV: production + NODE_ENV: development PORT: 8787 DATABASE_URL: postgres://osint:osint_secret@db:5432/osint_dev CORS_ORIGIN: http://localhost:8787 + JWT_SECRET: ${JWT_SECRET:-osint-local-dev-secret} LEVEL_EDITING_ENABLED: "true" MAX_DOCUMENT_BYTES: 26214400 ports: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index c2bced2..75b2ce5 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -10,6 +10,7 @@ services: PORT: 8787 DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@gnommo-db:5432/${POSTGRES_DB} CORS_ORIGIN: https://osint.${DOMAIN} + JWT_SECRET: ${JWT_SECRET} LEVEL_EDITING_ENABLED: ${LEVEL_EDITING_ENABLED:-false} MAX_DOCUMENT_BYTES: ${MAX_DOCUMENT_BYTES:-26214400} expose: diff --git a/e2e/board.smoke.spec.ts b/e2e/board.smoke.spec.ts index 9cbc33c..4f8f1a4 100644 --- a/e2e/board.smoke.spec.ts +++ b/e2e/board.smoke.spec.ts @@ -27,6 +27,8 @@ async function waitForSave(page: Page, action: () => Promise) { test('move, folder expansion, empty-board pan, desktop wheel zoom, mobile pinch, and reload persistence', async ({ page }) => { await page.goto('/?level=e2e-level&edit=1') await expect(page.getByRole('heading', { name: 'Browser Safety Test' })).toBeVisible() + await expect(page.getByRole('button', { name: 'ADMIN', exact: true })).toBeVisible() + expect((await page.locator('.menubar nav > button, .menubar nav > .admin-menu > button').allTextContents()).map(label => label.replace(/\d+$/, ''))).toEqual(['EVIDENCE', 'CASE BRIEF', 'TIMELINE', 'HELP', 'ADMIN']) const temporalLayering = await page.evaluate(() => ({ links: Number.parseInt(getComputedStyle(document.querySelector('.temporal-links')!).zIndex, 10), timeline: Number.parseInt(getComputedStyle(document.querySelector('.timeline')!).zIndex, 10), @@ -193,7 +195,8 @@ test('move, folder expansion, empty-board pan, desktop wheel zoom, mobile pinch, page.once('dialog', dialog => dialog.accept('Browser Template')) const templateSaved = page.waitForResponse(candidate => candidate.request().method() === 'POST' && candidate.url().includes('/templates?edit=1') && candidate.ok()) - await page.getByRole('button', { name: 'SAVE TEMPLATE', exact: true }).click() + await page.getByRole('button', { name: 'ADMIN', exact: true }).click() + await page.getByRole('menuitem', { name: 'SAVE AS TEMPLATE', exact: true }).click() await templateSaved await expect(page.locator('.terminal-status')).toContainText('VERSION 1') @@ -201,7 +204,8 @@ test('move, folder expansion, empty-board pan, desktop wheel zoom, mobile pinch, const prompts = ['browser-template', 'Browser Template Clone'] const promptHandler = (dialog: { accept(promptText?: string): Promise }) => dialog.accept(prompts[promptIndex++]) page.on('dialog', promptHandler) - await page.getByRole('button', { name: 'NEW FROM TEMPLATE', exact: true }).click() + await page.getByRole('button', { name: 'ADMIN', exact: true }).click() + await page.getByRole('menuitem', { name: 'NEW FROM TEMPLATE', exact: true }).click() await page.waitForURL(url => url.searchParams.get('level')?.startsWith('browser-template-') === true) page.off('dialog', promptHandler) await expect(page.getByRole('heading', { name: 'Browser Template Clone' })).toBeVisible() @@ -223,3 +227,13 @@ test('move, folder expansion, empty-board pan, desktop wheel zoom, mobile pinch, if (!landscapeToolbar) throw new Error('Landscape toolbar is not visible') expect(landscapeToolbar.width).toBeGreaterThan(landscapeToolbar.height * 2) }) + +test('hides the admin menu without a verified admin JWT', async ({ browser }) => { + const context = await browser.newContext({ baseURL: 'http://127.0.0.1:18788', viewport: { width: 1280, height: 720 }, extraHTTPHeaders: { Cookie: 'anonymous_session=1' } }) + const page = await context.newPage() + await page.goto('/?level=e2e-level&edit=1') + await expect(page.getByRole('heading', { name: 'Browser Safety Test' })).toBeVisible() + await expect(page.getByRole('button', { name: 'ADMIN', exact: true })).toHaveCount(0) + expect((await page.locator('.menubar nav > button').allTextContents()).map(label => label.replace(/\d+$/, ''))).toEqual(['EVIDENCE', 'CASE BRIEF', 'TIMELINE', 'HELP']) + await context.close() +}) diff --git a/package-lock.json b/package-lock.json index 6e81bf3..e258a8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,9 +8,11 @@ "name": "gupi-osint-board", "version": "0.1.0", "dependencies": { + "cookie-parser": "^1.4.7", "cors": "2.8.5", "dotenv": "16.5.0", "express": "5.1.0", + "jsonwebtoken": "^9.0.2", "lucide-react": "0.468.0", "multer": "^2.2.0", "pg": "8.16.3", @@ -20,8 +22,10 @@ }, "devDependencies": { "@playwright/test": "^1.62.1", + "@types/cookie-parser": "^1.4.9", "@types/cors": "2.8.18", "@types/express": "5.0.3", + "@types/jsonwebtoken": "^9.0.10", "@types/multer": "2.0.0", "@types/node": "22.15.30", "@types/pg": "8.15.4", @@ -34,7 +38,7 @@ "vitest": "^3.2.7" }, "engines": { - "node": ">=20" + "node": "^20.0.0 || >=22.0.0" } }, "node_modules/@babel/code-frame": { @@ -1252,6 +1256,16 @@ "@types/node": "*" } }, + "node_modules/@types/cookie-parser": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.9.tgz", + "integrity": "sha512-tGZiZ2Gtc4m3wIdLkZ8mkj1T6CEHb35+VApbL2T14Dew8HA7c+04dmKqsKRNC+8RJPm16JEK0tFSwdZqubfc4g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/express": "*" + } + }, "node_modules/@types/cors": { "version": "2.8.18", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.18.tgz", @@ -1308,6 +1322,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/multer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz", @@ -1670,6 +1702,12 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -1927,6 +1965,25 @@ "node": ">= 0.6" } }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, "node_modules/cookie-signature": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", @@ -2018,6 +2075,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2507,6 +2573,61 @@ "node": ">=6" } }, + "node_modules/jsonwebtoken": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", + "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", + "dependencies": { + "jws": "^3.2.2", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jwa": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", + "license": "MIT", + "dependencies": { + "jwa": "^1.4.2", + "safe-buffer": "^5.0.1" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -2514,6 +2635,48 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", diff --git a/package.json b/package.json index bd0dc75..ff7efca 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,11 @@ "test:e2e": "npm run build && playwright test" }, "dependencies": { + "cookie-parser": "^1.4.7", "cors": "2.8.5", "dotenv": "16.5.0", "express": "5.1.0", + "jsonwebtoken": "^9.0.2", "lucide-react": "0.468.0", "multer": "^2.2.0", "pg": "8.16.3", @@ -29,8 +31,10 @@ }, "devDependencies": { "@playwright/test": "^1.62.1", + "@types/cookie-parser": "^1.4.9", "@types/cors": "2.8.18", "@types/express": "5.0.3", + "@types/jsonwebtoken": "^9.0.10", "@types/multer": "2.0.0", "@types/node": "22.15.30", "@types/pg": "8.15.4", diff --git a/playwright.config.ts b/playwright.config.ts index cebceb8..ac3c2ec 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,6 +1,8 @@ import { defineConfig } from '@playwright/test' +import jwt from 'jsonwebtoken' const port = 18788 +const adminToken = jwt.sign({ sub: 'e2e-admin', role: 'admin' }, 'osint-e2e-jwt-secret') export default defineConfig({ testDir: './e2e', @@ -12,6 +14,7 @@ export default defineConfig({ baseURL: `http://127.0.0.1:${port}`, headless: true, viewport: { width: 1280, height: 720 }, + extraHTTPHeaders: { Cookie: `auth_token=${adminToken}` }, }, webServer: { command: 'npm run e2e:serve', diff --git a/scripts/importMysteryTemplate.ts b/scripts/importMysteryTemplate.ts index af37167..8993b48 100644 --- a/scripts/importMysteryTemplate.ts +++ b/scripts/importMysteryTemplate.ts @@ -33,29 +33,31 @@ function documentKind(type: SourceFileType) { return type === 'web_capture' ? 'WEB CAPTURE' : type.toUpperCase() } -async function uploadAsset(baseUrl: string, levelId: string, manifestDir: string, document: MysteryDocument) { +async function uploadAsset(baseUrl: string, levelId: string, manifestDir: string, document: MysteryDocument, authorization?: string) { if (!document.asset) return undefined const assetPath = path.resolve(manifestDir, document.asset) const form = new FormData() form.append('file', new Blob([await readFile(assetPath)]), path.basename(assetPath)) - const response = await requireOk(await fetch(`${baseUrl}/api/levels/${levelId}/documents?edit=1`, { method: 'POST', body: form }), `Upload ${document.asset}`) + const response = await requireOk(await fetch(`${baseUrl}/api/levels/${levelId}/documents?edit=1`, { method: 'POST', headers: authorization ? { authorization } : undefined, body: form }), `Upload ${document.asset}`) return await response.json() as CaseDocument } -export async function importMysteryTemplate(manifestPath: string, baseUrl = 'http://localhost:8787') { +export async function importMysteryTemplate(manifestPath: string, baseUrl = 'http://localhost:8787', adminJwt = process.env.OSINT_ADMIN_JWT) { const absoluteManifest = path.resolve(manifestPath) const manifest = JSON.parse(await readFile(absoluteManifest, 'utf8')) as MysteryManifest const manifestDir = path.dirname(absoluteManifest) const authoringId = `${manifest.slug}-authoring-${Date.now()}` + const authorization = adminJwt ? `Bearer ${adminJwt}` : undefined + const headers = { 'content-type': 'application/json', ...(authorization ? { authorization } : {}) } const createdResponse = await requireOk(await fetch(`${baseUrl}/api/levels`, { - method: 'POST', headers: { 'content-type': 'application/json' }, + method: 'POST', headers, body: JSON.stringify({ id: authoringId, title: manifest.title, subtitle: manifest.subtitle }), }), 'Create authoring level') const state = await createdResponse.json() as CaseState const documents = new Map() for (const source of manifest.documents) { - const uploaded = await uploadAsset(baseUrl, state.id, manifestDir, source) + const uploaded = await uploadAsset(baseUrl, state.id, manifestDir, source, authorization) documents.set(source.key, { id: uploaded?.id || randomUUID(), title: source.title, kind: documentKind(source.fileType), date: source.publishedAt.slice(0, 10), publishedAt: source.publishedAt, @@ -87,15 +89,15 @@ export async function importMysteryTemplate(manifestPath: string, baseUrl = 'htt state.viewport = { x: 0, y: 28, zoom: 0.7 } await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}?edit=1`, { - method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify(state), + method: 'PUT', headers, body: JSON.stringify(state), }), 'Save authored mystery') const templateResponse = await requireOk(await fetch(`${baseUrl}/api/levels/${state.id}/templates?edit=1`, { - method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slug: manifest.slug, name: manifest.name }), + method: 'POST', headers, body: JSON.stringify({ slug: manifest.slug, name: manifest.name }), }), 'Freeze mystery template') const template = await templateResponse.json() as { slug: string; currentVersion: number } const playableId = `${manifest.slug}-case-${Date.now()}` const playableResponse = await requireOk(await fetch(`${baseUrl}/api/templates/${manifest.slug}/levels?edit=1`, { - method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id: playableId, title: manifest.title }), + method: 'POST', headers, body: JSON.stringify({ id: playableId, title: manifest.title }), }), 'Instantiate playable mystery') const playable = await playableResponse.json() as CaseState return { manifest, template, authoringLevelId: state.id, playableLevel: playable } diff --git a/server/api.integration.test.ts b/server/api.integration.test.ts index 2fb9f6b..6799dc5 100644 --- a/server/api.integration.test.ts +++ b/server/api.integration.test.ts @@ -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 let appServer: Awaited['server'] let appPool: Awaited['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((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 }) diff --git a/server/auth.ts b/server/auth.ts new file mode 100644 index 0000000..4c5dd6b --- /dev/null +++ b/server/auth.ts @@ -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' }) +} diff --git a/server/e2eHarness.ts b/server/e2eHarness.ts index 93f65fe..8d6cccb 100644 --- a/server/e2eHarness.ts +++ b/server/e2eHarness.ts @@ -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) { diff --git a/server/index.ts b/server/index.ts index 2b9ab12..1875cdd 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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' }) diff --git a/server/levelRepository.ts b/server/levelRepository.ts index a3a8caa..f8f24fe 100644 --- a/server/levelRepository.ts +++ b/server/levelRepository.ts @@ -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 } } diff --git a/src/App.tsx b/src/App.tsx index 16a53b2..3860885 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,6 +25,7 @@ type TemporalItem = { id: string; sourceTemporalId: string; date: string; label: export function App() { const [caseState, setCaseState] = useState(null) + const [isAdmin, setIsAdmin] = useState(false) const [noLevels, setNoLevels] = useState(false) const [openDoc, setOpenDoc] = useState(null) const [selected, setSelected] = useState(null) @@ -32,6 +33,7 @@ export function App() { const [docsOpen, setDocsOpen] = useState(false) const [documentQuery, setDocumentQuery] = useState('') const [helpOpen, setHelpOpen] = useState(false) + const [adminMenuOpen, setAdminMenuOpen] = useState(false) const [status, setStatus] = useState('CONNECTING TO ARCHIVE…') const [clock, setClock] = useState('') const [draggingFiles, setDraggingFiles] = useState(false) @@ -52,10 +54,12 @@ export function App() { const saveTimer = useRef(undefined) const boardRef = useRef(null) const fileInputRef = useRef(null) + const adminMenuRef = useRef(null) const requestedEditMode = new URLSearchParams(window.location.search).get('edit') === '1' useEffect(() => { const params = new URLSearchParams(window.location.search) + fetch('/api/session').then(response => response.ok ? response.json() : null).then(session => setIsAdmin(Boolean(session?.isAdmin))).catch(() => setIsAdmin(false)) fetch('/api/levels').then(r => { if (!r.ok) throw new Error('Server unavailable') return r.json() @@ -80,6 +84,13 @@ export function App() { return () => clearInterval(timer) }, []) + useEffect(() => { + if (!adminMenuOpen) return + const close = (event: PointerEvent) => { if (!adminMenuRef.current?.contains(event.target as Node)) setAdminMenuOpen(false) } + document.addEventListener('pointerdown', close) + return () => document.removeEventListener('pointerdown', close) + }, [adminMenuOpen]) + useEffect(() => { if (!recentlyCreatedExhibitId) return const timer = window.setTimeout(() => setRecentlyCreatedExhibitId(null), 1400) @@ -277,6 +288,12 @@ export function App() { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) } + const enterLevelEditor = () => { + const params = new URLSearchParams(window.location.search) + params.set('edit', '1') + window.location.assign(`${window.location.pathname}?${params.toString()}`) + } + const uploadFiles = async (files: FileList | File[]) => { if (!caseState || !requestedEditMode || !caseState.editingAllowed) return const queue = Array.from(files) @@ -297,13 +314,14 @@ export function App() { } } - if (noLevels) return { setCaseState(level); setNoLevels(false); window.history.replaceState({}, '', `?level=${encodeURIComponent(level.id)}&edit=1`) }} /> + if (noLevels) return { window.location.assign(`?level=${encodeURIComponent(level.id)}&edit=1`) }} /> if (!caseState) return
GU

GLITCH UNIVERSITY NETWORK TERMINAL

{status}
const documentById = new Map(caseState.documents.map(document => [document.id, document])) const normalizedDocumentQuery = documentQuery.trim().toLocaleLowerCase() const filteredDocuments = normalizedDocumentQuery ? caseState.documents.filter(document => documentSearchText(document).includes(normalizedDocumentQuery)) : caseState.documents const unresolvedConceptCount = caseState.brief.concepts.filter(concept => !concept.resolvedPartyExhibitId).length + const canAuthor = isAdmin && requestedEditMode && Boolean(caseState.editingAllowed) const containedDocumentIds = new Set(caseState.relations.filter(relation => relation.type === 'contains').map(relation => relation.toWidgetId)) const temporalItems: TemporalItem[] = [ ...caseState.evidence.flatMap(folder => folder.type !== 'folder' ? [] : containedIds(caseState, folder.id).flatMap(documentId => { @@ -324,7 +342,21 @@ export function App() {
GUOSINT BOARD / {requestedEditMode && caseState.editingAllowed ? 'LEVEL EDITOR' : 'CASE TERMINAL'}
{status}{clock}
@@ -333,7 +365,7 @@ export function App() { -
{ if (e.dataTransfer.types.includes('Files') && requestedEditMode && caseState.editingAllowed) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (requestedEditMode && caseState.editingAllowed) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}> +
{ if (e.dataTransfer.types.includes('Files') && canAuthor) { e.preventDefault(); setDraggingFiles(true) } }} onDragOver={e => { if (canAuthor) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy' } }} onDragLeave={e => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setDraggingFiles(false) }} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }}>
{requestedEditMode && caseState.editingAllowed ? 'AUTHORING LEVEL' : 'ACTIVE INVESTIGATION'}

{caseState.title}

{caseState.subtitle || caseState.id.toUpperCase()}

{requestedEditMode && caseState.editingAllowed ? 'DRAFT' : 'CASE'}
{caseState.levelStatus?.toUpperCase() || 'ACTIVE'}
setThreadDraft(connection)} onDiscardExhibit={removeExhibit} onOpenSource={id => setOpenDoc(caseState.documents.find(d => d.id === id) || null)} onUpdateDocumentCue={(id, cue) => update(state => ({ ...state, documents: state.documents.map(document => document.id === id ? { ...document, metadata: { ...document.metadata, memory_cue: cue } } : document) }))} onEditFolder={setEditingFolderId} onEditFile={setEditingFileId} onEditEvent={setEditingEventId} onEditParty={setEditingPartyId} /> {briefOpen && item.type === 'party')} recentlyCreatedExhibitId={recentlyCreatedExhibitId} - canEdit={requestedEditMode && Boolean(caseState.editingAllowed)} + canEdit={canAuthor} onClose={closeBrief} onEdit={() => setEditingBrief(true)} onClassify={classifyConcept} @@ -382,7 +414,30 @@ export function App() { `${e.id}:${e.x}:${e.y}:${String(e.config?.open)}`).join('|')}:${caseState.relations.map(r => `${r.id}:${String(r.config?.x)}:${String(r.config?.y)}`).join('|')}`}/> setEditingTimeline(true)} onSelect={item => { if (item.documentId) setOpenDoc(caseState.documents.find(doc => doc.id === item.documentId) || null); else if (item.evidenceId) focusEvidence(item.evidenceId) }}/> {openDoc && setOpenDoc(null)} onExtract={id => extract(openDoc, id)} extracted={caseState.evidence.filter(e => e.sourceDocumentId === openDoc.id).map(e => e.sourceRegionId)} />} - {editingFolderId && widget.id === editingFolderId)!} memberIds={containedIds(caseState, editingFolderId)} documents={caseState.documents} canManageContents={requestedEditMode && Boolean(caseState.editingAllowed)} onClose={() => setEditingFolderId(null)} onSave={(folder, members) => { update(state => ({ ...state, evidence: state.evidence.map(widget => widget.id === folder.id ? { ...folder, containedDocumentIds: members } : widget), relations: [...state.relations.filter(relation => relation.type !== 'contains' || relation.fromWidgetId !== folder.id), ...members.map((documentId, index) => { const existing = state.relations.find(relation => relation.type === 'contains' && relation.fromWidgetId === folder.id && relation.toWidgetId === documentId); const position = relationPosition(state, existing || { id: '', fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index }); return { id: existing?.id || `contains:${folder.id}:${documentId}`, fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index, config: existing?.config || position } })] })); setEditingFolderId(null); setStatus('FOLDER UPDATED') }}/>} + {editingFolderId && widget.id === editingFolderId)!} + memberIds={containedIds(caseState, editingFolderId)} + documents={caseState.documents} + canManageContents={canAuthor} + onClose={() => setEditingFolderId(null)} + onSave={(folder, members) => { + update(state => ({ + ...state, + evidence: state.evidence.map(widget => widget.id === folder.id ? { ...folder, containedDocumentIds: members } : widget), + relations: [ + ...state.relations.filter(relation => relation.type !== 'contains' || relation.fromWidgetId !== folder.id), + ...members.map((documentId, index) => { + const existing = state.relations.find(relation => relation.type === 'contains' && relation.fromWidgetId === folder.id && relation.toWidgetId === documentId) + const position = relationPosition(state, existing || { id: '', fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index }) + return { id: existing?.id || `contains:${folder.id}:${documentId}`, fromWidgetId: folder.id, toWidgetId: documentId, type: 'contains', sortOrder: index, config: existing?.config || position } + }), + ], + })) + setEditingFolderId(null) + setStatus('FOLDER UPDATED') + }} + />} {editingFileId && document.id === editingFileId)!} onClose={() => setEditingFileId(null)} onSave={document => { update(state => ({ ...state, documents: state.documents.map(candidate => candidate.id === document.id ? document : candidate) })); setEditingFileId(null); setStatus('FILE METADATA UPDATED') }}/>} {editingEventId && button { height: 100%; }.admin-menu-items { position: absolute; z-index: 30; top: calc(100% - 1px); right: 0; width: 235px; padding: 5px; display: grid; background: #c5c9c2; border: 2px outset #e5e8e2; box-shadow: 6px 8px 0 #020907aa; }.menubar nav .admin-menu-items button { min-height: 37px; padding: 0 12px; color: #263d36; border: 1px solid transparent; text-align: left; font-size: 9px; }.menubar nav .admin-menu-items button:hover, .menubar nav .admin-menu-items button:focus-visible { background: #173d34; border-color: #526c64; color: #f1f3ef; } .brief-count { display: inline-grid; min-width: 17px; height: 17px; margin-left: 7px; padding: 0 4px; place-items: center; border-radius: 9px; background: #b56d30; color: #fff3df; font: 600 9px IBM Plex Mono; } .terminal-status { font: 10px IBM Plex Mono; color: #769087; letter-spacing: .06em; display: flex; gap: 8px; align-items: center; white-space: nowrap; } .terminal-status i { width: 7px; height: 7px; background: #71c888; border-radius: 50%; box-shadow: 0 0 8px #71c888; }