// 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)}
`));
}
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' ? '' : ''}
${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 = `
`;
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, 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}`);
});
}