diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index e19fe6b..631c2c7 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -28,6 +28,7 @@ services: DATABASE_URL: postgres://osint:osint_secret@db:5432/osint_dev CORS_ORIGIN: http://localhost:8787 JWT_SECRET: ${JWT_SECRET:-osint-local-dev-secret} + ADMIN_HANDLES: ${ADMIN_HANDLES:-} LEVEL_EDITING_ENABLED: "true" MAX_DOCUMENT_BYTES: 26214400 OCR_ENABLED: "true" diff --git a/mysteries/barricelli-files/mystery.json b/mysteries/barricelli-files/mystery.json index b3ca6fb..2cf0927 100644 --- a/mysteries/barricelli-files/mystery.json +++ b/mysteries/barricelli-files/mystery.json @@ -164,6 +164,7 @@ "label": "Scene 9 · The Barricelli Luggage", "x": 200, "y": 1320, + "componentKey": "barricelli-luggage", "awardsFlag": "barricelli_luggage", "terminals": [ { diff --git a/server/auth.ts b/server/auth.ts index edfe185..1fddcfa 100644 --- a/server/auth.ts +++ b/server/auth.ts @@ -52,11 +52,22 @@ export function requireAdmin(req: Request, res: Response, next: NextFunction) { next() } +// Operator-designated admins, by handle. Set ADMIN_HANDLES to a comma-separated list +// (e.g. `ADMIN_HANDLES=jens,ops`); those users receive an admin token at sign-in, so the +// /admin route and admin APIs open for them. Changing the list takes effect on next login. +export function isAdminHandle(handle: string) { + const handles = (process.env.ADMIN_HANDLES || '').split(',').map(entry => entry.trim().toLowerCase()).filter(Boolean) + return handles.includes(handle.trim().toLowerCase()) +} + // Mint a player token (path A: GUPI is the issuer for now). Verification is // issuer-agnostic — a glitch.university token with the same sub verifies identically. -export function signPlayerToken(user: { id: string; displayName: string }) { +// A handle listed in ADMIN_HANDLES is promoted to an admin token here at sign-in. +export function signPlayerToken(user: { id: string; displayName: string; handle: string }) { if (!process.env.JWT_SECRET) throw new Error('JWT_SECRET is required') - return jwt.sign({ sub: user.id, name: user.displayName, role: 'player' }, process.env.JWT_SECRET, { expiresIn: '30d' }) + const admin = isAdminHandle(user.handle) + const claims = { sub: user.id, name: user.displayName, preferred_username: user.handle, role: admin ? 'admin' : 'player', ...(admin ? { isAdmin: true } : {}) } + return jwt.sign(claims, process.env.JWT_SECRET, { expiresIn: '30d' }) } export function createDevelopmentAdminToken() { diff --git a/src/merits.tsx b/src/merits.tsx new file mode 100644 index 0000000..5fa8b3e --- /dev/null +++ b/src/merits.tsx @@ -0,0 +1,78 @@ +import type { FC } from 'react' +import * as THREE from 'three' +import { SpinningModel } from './model3d' + +// The Barricelli Luggage: Nils Aall Barricelli's patented suitcase-commode. Two wooden +// beams that taper upward carry three suitcases slung between them as drawers, so the +// whole thing reads as a rudimentary chest of drawers built from luggage. + +const WOOD = new THREE.MeshStandardMaterial({ color: 0x6b4a2f, roughness: 0.85, metalness: 0.04, flatShading: true }) +const BRASS = new THREE.MeshStandardMaterial({ color: 0xc9a24a, roughness: 0.45, metalness: 0.6, flatShading: true }) +const STRAP = new THREE.MeshStandardMaterial({ color: 0x2f2118, roughness: 0.8, flatShading: true }) +const LEATHERS = [0x8a5a34, 0x6f4632, 0x9a6b3f].map(color => new THREE.MeshStandardMaterial({ color, roughness: 0.8, metalness: 0.05, flatShading: true })) + +// One suitcase drawer: leather body, a lid seam, two wrap-around straps, brass latches, +// corner caps, and a front pull (it is a drawer now, so the handle faces the viewer). +function buildSuitcase(width: number, height: number, depth: number, leather: THREE.Material) { + const suitcase = new THREE.Group() + const body = new THREE.Mesh(new THREE.BoxGeometry(width, height, depth), leather); suitcase.add(body) + const seam = new THREE.Mesh(new THREE.BoxGeometry(width + 0.02, 0.04, depth + 0.02), STRAP); suitcase.add(seam) + for (const sign of [-1, 1]) { + const strap = new THREE.Mesh(new THREE.BoxGeometry(0.07, height + 0.02, depth + 0.04), STRAP) + strap.position.x = sign * width * 0.3; suitcase.add(strap) + const latch = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.07, 0.03), BRASS) + latch.position.set(sign * width * 0.3, 0.02, depth / 2 + 0.01); suitcase.add(latch) + } + for (const sx of [-1, 1]) for (const sy of [-1, 1]) { // brass corner caps on the front face + const cap = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.07, 0.06), BRASS) + cap.position.set(sx * (width / 2 - 0.035), sy * (height / 2 - 0.035), depth / 2) + suitcase.add(cap) + } + const pull = new THREE.Mesh(new THREE.BoxGeometry(0.26, 0.05, 0.05), BRASS) + pull.position.set(0, -height * 0.16, depth / 2 + 0.05); suitcase.add(pull) + for (const sign of [-1, 1]) { const stem = new THREE.Mesh(new THREE.BoxGeometry(0.03, 0.03, 0.05), BRASS); stem.position.set(sign * 0.1, -height * 0.16, depth / 2 + 0.02); suitcase.add(stem) } + return suitcase +} + +function buildBarricelliLuggage() { + const frame = new THREE.Group() + const postHeight = 2.1 + const suitcase = { w: 1.05, h: 0.44, d: 0.62 } + const levels = [0.62, 0, -0.62] // three stacked drawers + + for (const sign of [-1, 1]) { // two beams, each tapering upward, leaning slightly inward + const post = new THREE.Mesh(new THREE.CylinderGeometry(0.085, 0.12, postHeight, 4), WOOD) + post.rotation.y = Math.PI / 4 // square the prism to face front + post.rotation.z = -sign * 0.05 // splay the feet, converge the tops + post.position.x = sign * 0.66 + frame.add(post) + const foot = new THREE.Mesh(new THREE.BoxGeometry(0.24, 0.09, 0.32), WOOD) + foot.position.set(sign * 0.7, -postHeight / 2 - 0.02, 0); frame.add(foot) + } + // Top cap and a low stretcher tie the beams into a stand. + const cap = new THREE.Mesh(new THREE.BoxGeometry(1.44, 0.11, 0.5), WOOD); cap.position.y = postHeight / 2 + 0.02; frame.add(cap) + const stretcher = new THREE.Mesh(new THREE.BoxGeometry(1.4, 0.08, 0.18), WOOD); stretcher.position.y = -postHeight / 2 + 0.14; frame.add(stretcher) + + levels.forEach((y, i) => { + const drawer = buildSuitcase(suitcase.w, suitcase.h, suitcase.d, LEATHERS[i % LEATHERS.length]) + drawer.position.y = y; frame.add(drawer) + for (const sign of [-1, 1]) { // small wooden brackets mounting each case to the beams + const bracket = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.08, 0.3), WOOD) + bracket.position.set(sign * (suitcase.w / 2 + 0.05), y, 0); frame.add(bracket) + } + }) + + frame.traverse(obj => { if (obj instanceof THREE.Mesh) obj.castShadow = true }) + return frame +} + +export const BarricelliLuggageMerit: FC<{ label: string; onComplete: () => void }> = ({ label, onComplete }) => ( +