// 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 GITEA_URL = 'https://sources.yrka.app'; // fpagent release forge (private) export const FPAGENT_REPO = 'YOUreka/fingerprint-agent'; // The Gitea token used to read the private fpagent releases. The operator's // laptop never sees it: /fpagent/* proxies the bundles through this server. const GITEA_RELEASE_KEY = process.env.GITEA_RELEASE_INSTALL_KEY || ''; 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 || '') : ''; // Help and the auth action are icon-only global header actions. const headerAction = (href, label, svg, extra = '') => `${svg.replace('slot="title-icon"', 'slot="icon"')}${label}`; const headerAuth = session ? headerAction('/auth/logout', 'Sign out', SIDE_NAV_ICONS.logout, ' data-signout') : headerAction('/auth/login', 'Sign in', SIDE_NAV_ICONS.login); 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')}` : ''} ${active === 'fingerprint' ? 'Agent offline' : ''}
${headerAction('/help', 'Help', SIDE_NAV_ICONS.help)} ${headerAuth} ${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, giteaUrl: GITEA_URL, fpagentRepo: FPAGENT_REPO, giteaToken: GITEA_RELEASE_KEY, giteaFetch: fetch, 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') || filePath.endsWith('.sh') ? 'no-store' : 'public, max-age=31536000, immutable' ); }, }) ); // assets only; '/' is session-aware // ── fpagent installer: public proxy for the private release bundles ─────── // The install script (public/fpagent/install.sh) runs on a field laptop with // no Gitea credentials, so this server reads the private release with the // token and streams the bundle back. The token and forge host never leave // here. Registered before the session middleware: the installer is public. const giteaBase = () => cfg.giteaUrl.replace(/\/+$/, ''); const giteaHeaders = () => (cfg.giteaToken ? { authorization: `token ${cfg.giteaToken}` } : {}); const SAFE_ASSET = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; const installerNotConfigured = (res) => res.status(503).json({ ok: false, message: 'The Fingerprint Agent installer is not configured on this deployment.', }); const releasePayload = async (res, giteaPath) => { try { const r = await cfg.giteaFetch(`${giteaBase()}${giteaPath}`, { headers: giteaHeaders() }); if (!r.ok) { return res.status(502).json({ ok: false, message: 'Could not reach the Fingerprint Agent releases.' }); } const release = await r.json(); const tag = String(release?.tag_name || ''); const assets = (Array.isArray(release?.assets) ? release.assets : []) .map((a) => String(a?.name || '')) .filter((name) => SAFE_ASSET.test(name)) .map((name) => ({ name, url: `/fpagent/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`, })); if (!tag || assets.length === 0) { return res .status(502) .json({ ok: false, message: 'The Fingerprint Agent release has no downloadable bundles.' }); } res.set('Cache-Control', 'no-store').json({ ok: true, tag, assets }); } catch { res.status(502).json({ ok: false, message: 'Could not reach the Fingerprint Agent releases.' }); } }; app.get('/fpagent/releases/latest', (req, res) => { if (!cfg.giteaToken) return installerNotConfigured(res); return releasePayload(res, `/api/v1/repos/${cfg.fpagentRepo}/releases/latest`); }); app.get('/fpagent/releases/tags/:tag', (req, res) => { if (!cfg.giteaToken) return installerNotConfigured(res); if (!SAFE_ASSET.test(req.params.tag)) { return res.status(400).json({ ok: false, message: 'Unknown release tag.' }); } return releasePayload( res, `/api/v1/repos/${cfg.fpagentRepo}/releases/tags/${encodeURIComponent(req.params.tag)}` ); }); app.get('/fpagent/releases/download/:tag/:name', async (req, res) => { if (!cfg.giteaToken) return installerNotConfigured(res); const { tag, name } = req.params; if (!SAFE_ASSET.test(tag) || !SAFE_ASSET.test(name)) { return res.status(400).json({ ok: false, message: 'Unknown release asset.' }); } try { const r = await cfg.giteaFetch( `${giteaBase()}/${cfg.fpagentRepo}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`, { headers: giteaHeaders() } ); if (!r.ok) { return res .status(r.status === 404 ? 404 : 502) .json({ ok: false, message: 'Release asset not available.' }); } res .set('Content-Type', r.headers.get('content-type') || 'application/octet-stream') .set('Cache-Control', 'no-store') .send(Buffer.from(await r.arrayBuffer())); } catch { res.status(502).json({ ok: false, message: 'Could not reach the Fingerprint Agent releases.' }); } }); // ── 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, bearer token from the session ──────────── const a2loftUrl = (domain, reference) => `${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/${encodeURIComponent(reference)}`; const a2loftSaveUrl = (domain, reference) => `${cfg.a2loftBase.replace(/\/+$/, '')}/identity/${encodeURIComponent(domain)}/fingerprints/${encodeURIComponent(reference)}`; 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; } 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), { headers: { authorization: `Bearer ${req.session.token}` }, }); 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), { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${req.session.token}`, }, 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}`); }); }