// YOUreka Tools — Express host for the tools landing page and the // /fingerprint enrollment wizard, whose captures run through the local // fpagent binary (Socket.IO on 127.0.0.1:9000). // // Every endpoint and the port are hardcoded in this file by explicit design // decision (openspec change fpagent-tools-ui): no env vars, no config files. // createApp() accepts overrides so tests can point the app at fake upstreams; // production always runs on the constants below. import express from 'express'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { loadYrkaSession, extractPbCookieValue } from './yrka-auth.js'; import { permittedDomains, stripPhonePrefix, CACHE_FRESH, DOMAIN_TTL_MS, cropPermission } from './permit.js'; import { createCropperRouter, buildCropperClients } from './cropper.js'; // ── Hardcoded configuration ────────────────────────────────────────────────── export const PORT = 4173; export const PORTAL_URL = 'https://portal.yrka.app'; // web-auth + openfga/verify export const PB_URL = 'https://pb.itcass.net'; // PocketBase session refresh export const A2LOFT_BASE = 'https://apis.a2loft.net'; // staff lookup + fingerprint save export const AGENT_URL = 'http://127.0.0.1:9000'; // fpagent Socket.IO export const DOMAINS = [ { code: 'uc', name: 'UNICAL' }, { code: 'mtr', name: 'Metropolitan Hotel' }, { code: 'ucth', name: 'UCTH' }, { code: 'uri', name: 'UNICAL Radio International' }, { code: 'yrka', name: 'YOUreka' }, ]; const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days const __dirname = path.dirname(fileURLToPath(import.meta.url)); // ── helpers ────────────────────────────────────────────────────────────────── // Express does not parse cookies; this is all we need. function cookie(req, name) { const header = req.headers.cookie; if (!header) return undefined; for (const part of header.split(';')) { const i = part.indexOf('='); if (i < 0) continue; if (part.slice(0, i).trim() === name) return decodeURIComponent(part.slice(i + 1).trim()); } return undefined; } function readProfile(raw) { if (!raw) return { firstName: '', lastName: '', phone: '' }; try { const p = JSON.parse(raw); return { firstName: typeof p.firstName === 'string' ? p.firstName : '', lastName: typeof p.lastName === 'string' ? p.lastName : '', phone: typeof p.phone === 'string' ? p.phone : '', }; } catch { return { firstName: '', lastName: '', phone: '' }; } } function errorPage(res, status, title, message) { res .status(status) .type('html') .send(` ${title} · YOUreka Tools
${ICONS.alert}

${title}

${message}

Try again Back to Tools
`); } const ICONS = { alert: '', }; // ── app factory ────────────────────────────────────────────────────────────── export function createApp(overrides = {}) { const cfg = { portalUrl: PORTAL_URL, pbUrl: PB_URL, a2loftBase: A2LOFT_BASE, agentUrl: AGENT_URL, domains: DOMAINS, ttlMs: DOMAIN_TTL_MS, fetch: fetch, now: Date.now, ...overrides, }; const domainCodes = new Set(cfg.domains.map((d) => d.code)); const domainCache = new Map(); // phone → { codes, checkedAt } const cropCache = cfg.cropCache || new Map(); // phone → { allowed, checkedAt } const app = express(); app.disable('x-powered-by'); app.set('trust proxy', 1); // https termination upstream → correct Secure-cookie flag app.use(express.json({ limit: '50mb' })); // cropper pictures travel as data URLs const setSessionCookie = (req, res, value) => res.cookie('session', value, { path: '/', httpOnly: true, secure: req.secure, // https in production; plain http dev on localhost still works sameSite: 'lax', maxAge: SESSION_MAX_AGE_MS, }); const setProfileCookie = (req, res, profile) => res.cookie('user_profile', JSON.stringify(profile), { path: '/', httpOnly: true, secure: req.secure, sameSite: 'lax', maxAge: SESSION_MAX_AGE_MS, }); // ── session middleware: recover / refresh the YOUreka Auth session ──────── app.use(async (req, res, next) => { req.session = null; const raw = cookie(req, 'session'); if (raw) { try { const yrka = await loadYrkaSession(raw, { pbUrl: cfg.pbUrl }); if (!yrka) { res.clearCookie('session', { path: '/' }); res.clearCookie('user_profile', { path: '/' }); } else { if (yrka.refreshed && yrka.cookie) setSessionCookie(req, res, yrka.cookie); req.session = { token: yrka.pb.authStore.token, ...readProfile(cookie(req, 'user_profile')), }; } } catch { req.session = null; } } next(); }); const requirePage = (req, res, next) => { if (!req.session) return res.redirect('/auth/login'); next(); }; const requireApi = (req, res, next) => { if (!req.session) return res.status(401).json({ ok: false, message: 'Authentication required.' }); next(); }; // ── static assets & landing page ────────────────────────────────────────── app.use(express.static(path.join(__dirname, 'public'))); // ── auth: YOUreka portal web-auth flow ──────────────────────────────────── app.get('/auth/login', async (req, res) => { const returnUrl = `${req.protocol}://${req.get('host')}/auth/callback`; try { const r = await cfg.fetch(`${cfg.portalUrl.replace(/\/+$/, '')}/api/web/auth`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ returnUrl }), }); const data = await r.json().catch(() => null); if (!r.ok || !data?.ok || !data.loginUrl) { return errorPage(res, 502, 'Login unavailable', data?.message || 'Could not start the YOUreka login flow. Please try again.'); } res.redirect(data.loginUrl); } catch { errorPage(res, 502, 'Login unavailable', 'Could not reach the YOUreka portal. Please try again.'); } }); app.get('/auth/callback', async (req, res) => { const { sessionId } = req.query; if (!sessionId) return errorPage(res, 401, 'Login failed', 'Missing login session. Please start again.'); try { const r = await cfg.fetch( `${cfg.portalUrl.replace(/\/+$/, '')}/api/web/auth/${encodeURIComponent(sessionId)}/cookie` ); const data = await r.json().catch(() => null); if (!r.ok || !data?.ok) { return errorPage(res, 401, 'Login failed', 'Authentication failed. The link may have expired — please try again.'); } if (data.status === 'Initialized' || data.status === 'TimedOut') { return errorPage( res, 401, 'Login failed', data.status === 'TimedOut' ? 'Your login session timed out. Please start again.' : 'Verification incomplete. Please complete the login flow.' ); } const sessionValue = extractPbCookieValue(data.cookie || ''); if (!sessionValue) { return errorPage(res, 401, 'Login failed', 'The portal did not return a usable session. Please try again.'); } setSessionCookie(req, res, sessionValue); setProfileCookie(req, res, { firstName: data.firstName ?? '', lastName: data.lastName ?? '', phone: data.phone ?? '', }); res.redirect('/fingerprint'); } catch { errorPage(res, 401, 'Login failed', 'Could not reach the YOUreka portal. Please try again.'); } }); app.get('/auth/logout', (req, res) => { res.clearCookie('session', { path: '/' }); res.clearCookie('user_profile', { path: '/' }); res.redirect('/'); }); // ── session context + permitted domains ─────────────────────────────────── const domainContext = async (req, { force = false } = {}) => permittedDomains({ domains: cfg.domains, phone: stripPhonePrefix(req.session.phone), portalUrl: cfg.portalUrl, fetchImpl: cfg.fetch, cache: domainCache, ttlMs: cfg.ttlMs, now: cfg.now, force, }); const domainPayload = (codes, checkedAt) => ({ domains: cfg.domains.filter((d) => codes.includes(d.code)), domainsCheckedAt: checkedAt, }); app.get('/auth/session', requireApi, async (req, res, next) => { try { const { codes, checkedAt } = await domainContext(req); res.json({ ok: true, token: req.session.token, phone: req.session.phone, firstName: req.session.firstName, lastName: req.session.lastName, agentUrl: cfg.agentUrl, domainCacheTtlMs: cfg.ttlMs, ...domainPayload(codes, checkedAt), }); } catch (e) { next(e); } }); app.post('/auth/domains/refresh', requireApi, async (req, res, next) => { try { const { codes, checkedAt } = await domainContext(req, { force: true }); res.json({ ok: true, ...domainPayload(codes, checkedAt) }); } catch (e) { if (e?.code === CACHE_FRESH) { return res .status(409) .json({ ok: false, message: 'Permissions were checked recently. The refresh option unlocks when the cache expires.' }); } next(e); } }); // ── wizard page (session required) ──────────────────────────────────────── app.get('/fingerprint', requirePage, (req, res) => { res.sendFile(path.join(__dirname, 'public', 'assets', 'fp', 'index.html')); }); // ── cropper page + namespaced API (session + crop_image required) ──────── const cropGateContext = { portalUrl: cfg.portalUrl, fetchImpl: cfg.fetch, cache: cropCache, ttlMs: cfg.ttlMs, now: cfg.now, }; const pageCropGate = async (req, res, next) => { let allowed = false; try { ({ allowed } = await cropPermission({ phone: stripPhonePrefix(req.session.phone), ...cropGateContext, })); } catch { allowed = false; // fail closed } if (!allowed) { return errorPage( res, 403, 'Not permitted', 'You are not permitted to crop images. Contact your administrator if you believe this is a mistake.' ); } next(); }; app.get('/cropper', requirePage, pageCropGate, (req, res) => { res.sendFile(path.join(__dirname, 'public', 'assets', 'cropper', 'index.html')); }); const cropperClients = cfg.cropperClients !== undefined ? cfg.cropperClients : buildCropperClients(); if (cropperClients) { app.use( '/cropper', createCropperRouter({ ...cropperClients, a2loftBase: cfg.a2loftBase, removebgUrl: cfg.removebgUrl, noderedBase: cfg.noderedBase, ...cropGateContext, requireApi, }) ); } else { app.use('/cropper', (req, res) => res.status(503).json({ ok: false, message: 'The image cropper is not configured on this deployment.' }) ); } // ── a2loft proxies: same-origin, phone injected from the session ────────── const a2loftUrl = (domain, reference, otp, phone) => { const q = new URLSearchParams({ phone, otp }); return `${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/${encodeURIComponent( reference )}?${q.toString()}`; }; const a2loftSaveUrl = (domain, reference, otp, phone) => { const q = new URLSearchParams({ phone, otp }); return `${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/fingerprints/${encodeURIComponent( reference )}?${q.toString()}`; }; const validateProxy = (req, res) => { const { domain, reference } = req.params; if (!domainCodes.has(domain)) { res.status(400).json({ ok: false, message: 'Unknown domain.' }); return false; } if (!reference) { res.status(400).json({ ok: false, message: 'Missing staff reference.' }); return false; } if (!req.query.otp) { res.status(400).json({ ok: false, message: 'OTP is required.' }); return false; } if (!req.session.phone) { res.status(400).json({ ok: false, message: 'The signed-in account has no phone number.' }); return false; } return true; }; app.post('/api/:domain/staff/:reference', requireApi, async (req, res) => { if (!validateProxy(req, res)) return; try { const r = await cfg.fetch(a2loftUrl(req.params.domain, req.params.reference, req.query.otp, req.session.phone)); res.status(r.status).type('json').send(await r.text()); } catch { res.status(502).json({ ok: false, message: 'Could not reach the identity service.' }); } }); app.post('/api/:domain/staff/:reference/fingerprints', requireApi, async (req, res) => { if (!validateProxy(req, res)) return; if (!Array.isArray(req.body?.fingerprints) || req.body.fingerprints.length === 0) { return res.status(400).json({ ok: false, message: 'A fingerprints array is required.' }); } try { const r = await cfg.fetch(a2loftSaveUrl(req.params.domain, req.params.reference, req.query.otp, req.session.phone), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(req.body) }); res.status(r.status).type('json').send(await r.text()); } catch { res.status(502).json({ ok: false, message: 'Could not reach the identity service.' }); } }); return app; } const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; if (isMain) { createApp().listen(PORT, () => { console.log(`YOUreka Tools listening on http://localhost:${PORT}`); }); }