// 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 fs from 'node:fs'; 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, CROPPER_CREDENTIALS } 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)); // Bumped once per process start, so every deployment serves fresh asset URLs. export const DEPLOY_VERSION = Date.now().toString(36); /** * Append the per-deploy version to every /assets URL in a served page, so the * browser fetches fresh CSS/JS/images after a deploy instead of reusing cached * ones. Idempotent: URLs that already carry `?v=` are left alone. */ function versionAssets(html) { return html.replace(/(["'(])(\/assets\/[^"')\s?]+)/g, `$1$2?v=${DEPLOY_VERSION}`); } // ── 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, actions = {}) { const primary = actions.primary || { href: '/auth/login', label: 'Try again' }; const secondary = actions.secondary === null ? null : actions.secondary || { href: '/', label: 'Back to Tools' }; const icon = actions.icon || 'alert'; res .status(status) .type('html') .send(versionAssets(` ${esc(title)} · YOUreka Tools ${appHeader(null, null)}
${ICONS[icon] || ICONS.alert}

${esc(title)}

${esc(message)}

${esc(primary.label)} ${secondary ? `${esc(secondary.label)}` : ''}
`)); } const ICONS = { alert: '', lock: '', }; /** HTML-escape interpolated values — error pages may carry upstream text. */ function esc(value) { return String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }; /** * The application header and mobile side nav (Carbon ui-shell), shared by * every page. `session` is present when the operator is signed in; `active` * marks the current tool. Rendered server-side so pages need no client * session fetch and hold a single header definition. */ const appHeader = (session, active) => { const toolLink = (href, label, key) => `${label}`; const sideLink = (href, label, key, icon) => `${icon || ''}${label}`; const name = session ? (session.firstName || session.phone || '') : ''; // Auth is a menu item now, so the header's right side only carries identity. const headerAuth = session ? 'Sign out' : 'Sign in'; const sideAuth = session ? `${SIDE_NAV_ICONS.logout}Sign out` : `${SIDE_NAV_ICONS.login}Sign in`; return ` YOUreka Tools ${toolLink('/', 'Home', 'home')} ${session ? `${toolLink('/cropper', 'Image Cropper', 'cropper')} ${toolLink('/fingerprint', 'Fingerprint Agent', 'fingerprint')} ` : ''} ${toolLink('/help', 'Help', 'help')} ${headerAuth} ${active === 'fingerprint' ? 'Agent offline' : ''} ${session ? `
${esc(name.trim().slice(0, 2).toUpperCase())}${esc(name)}
` : ''}
YOUreka
${sideLink('/', 'Home', 'home', SIDE_NAV_ICONS.home)} ${session ? `${sideLink('/cropper', 'Image Cropper', 'cropper', SIDE_NAV_ICONS.crop)} ${sideLink('/fingerprint', 'Fingerprint Agent', 'fingerprint', SIDE_NAV_ICONS.fingerprint)} ` : ''} ${sideLink('/help', 'Help', 'help', SIDE_NAV_ICONS.help)} ${sideAuth}
${session ? ` Account Sign out of YOUreka Tools? You will need to authenticate again to use the tools. Cancel Sign out ` : ''} `; }; /** Carbon icons for the mobile side nav (inlined so they inherit the theme). */ const SIDE_NAV_ICONS = { crop: '', fingerprint: '', logout: '', login: '', help: '', home: '', }; /** * Landing-page body: the tool tiles when signed in, a sign-in call to action * when not. Both are injected server-side into index.html's `` * marker so the two states never share markup or flash the wrong one. */ const LANDING_TOOLS = `

Image Cropper

Review, crop and save identity photos submitted for YOUreka ID domain profiles.

Open Image Cropper

Fingerprint Agent

Enroll staff fingerprints with the scanner attached to this laptop. Captures are authorized per domain.

Open Fingerprint Agent
`; const LANDING_SIGNIN = `
${ICONS.lock}

Sign in required

Sign in with your YOUreka account to open the Image Cropper and Fingerprint Agent.

Sign in with YOUreka
`; // ── 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 // cropper pictures travel as data URLs (large); everything else stays small app.use('/cropper', express.json({ limit: '50mb' })); app.use(express.json({ limit: '1mb' })); 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, }); // ── static assets (no session work per asset) ───────────────────────────── // Versioned asset URLs are safe to cache hard; the .html templates that also // live under /assets must never be cached (they are read server-side anyway). app.use( express.static(path.join(__dirname, 'public'), { index: false, setHeaders: (res, filePath) => { res.setHeader( 'Cache-Control', filePath.endsWith('.html') ? 'no-store' : 'public, max-age=31536000, immutable' ); }, }) ); // assets only; '/' is session-aware // ── 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 ────────────────────────────────────────── // ── 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('/'); } 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, }); const sessionPayload = async (req, { withDomains = false } = {}) => { const base = { phone: req.session.phone, firstName: req.session.firstName, lastName: req.session.lastName, }; if (!withDomains) return base; const { codes, checkedAt } = await domainContext(req); return { ...base, token: req.session.token, agentUrl: cfg.agentUrl, domainCacheTtlMs: cfg.ttlMs, ...domainPayload(codes, checkedAt), }; }; /** * Render a page with the operator's session injected server-side, so the * client never has to fetch it (and never flashes a loading state). * Per-user content — must not be cached. */ const renderPage = (res, file, session, active) => { fs.readFile(path.join(__dirname, 'public', 'assets', file), 'utf8', (error, html) => { if (error) return errorPage(res, 500, 'Something went wrong', 'The page could not be loaded.'); const data = session ? JSON.stringify(session).replace(/', appHeader(session, active)) .replace('', ` \n`) ) ); }); }; app.get('/', (req, res, next) => { fs.readFile(path.join(__dirname, 'public', 'index.html'), 'utf8', (error, html) => { if (error) return next(error); res .set('Cache-Control', 'no-store') .set('Vary', 'Cookie') .type('html') .send( versionAssets( html .replace('', appHeader(req.session, 'home')) .replace('', req.session ? LANDING_TOOLS : LANDING_SIGNIN) ) ); }); }); app.get('/help', (req, res) => { renderPage(res, 'help.html', req.session, 'help'); }); 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, async (req, res, next) => { try { renderPage(res, 'fp/index.html', await sessionPayload(req, { withDomains: true }), 'fingerprint'); } catch (e) { next(e); } }); // ── 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, 'No access', 'Your account doesn\u2019t have permission to crop images. Ask a domain administrator to grant you access.', { icon: 'lock', primary: { href: '/', label: 'Back to Tools' }, secondary: null } ); } next(); }; app.get('/cropper', requirePage, pageCropGate, (req, res, next) => { if (!cropperClients) { return errorPage( res, 503, 'Cropper unavailable', 'The image cropper is not configured on this deployment. Contact your administrator.' ); } renderPage( res, 'cropper/index.html', { phone: req.session.phone, firstName: req.session.firstName, lastName: req.session.lastName }, 'cropper' ); }); const cropperClients = cfg.cropperClients !== undefined ? cfg.cropperClients : buildCropperClients(); if (!cropperClients) { const missing = CROPPER_CREDENTIALS.filter((name) => !process.env[name]); console.warn( `[cropper] not configured — missing ${missing.join(', ')}; /cropper will answer 503` ); } if (cropperClients) { app.use( '/cropper', createCropperRouter({ ...cropperClients, a2loftBase: cfg.a2loftBase, domainCodes, 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.' }); } }); // ── error handler (async handlers are wrapped; this catches next(err)) ───── // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { const status = err.status || err.statusCode || 500; if (status >= 500) console.error('[error]', req.method, req.path, err && err.message); if (res.headersSent) return next(err); const message = status === 413 ? 'The request is too large.' : status >= 500 ? 'An unexpected error occurred. Please try again.' : err.message || 'The request could not be processed.'; if (req.path === '/cropper' || req.path.startsWith('/auth/')) { return errorPage(res, status, status === 413 ? 'Request too large' : 'Something went wrong', message); } res.status(status).json({ ok: false, message }); }); 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}`); }); }