feat: YOUreka Tools express app — landing page + fpagent enrollment wizard

- tools/: express host (hardcoded config, YOUreka Auth cookie session,
  OpenFGA domain filtering with 5-min cache, a2loft proxies)
- /fingerprint wizard: domain picker, two-finger capture via fpagent
  Socket.IO, OTP-validated lookup/save, domain refresh control
- node --test suite (27 tests, fake portal/PocketBase/a2loft upstreams)
- Dockerfile (node:22-alpine, non-root, healthcheck)
- README rewritten; node_modules gitignored

Field-verified end to end: portal login, permitted-domain filtering,
real U.are.U 4500 captures, cancel-preserves-capture, a2loft save.
This commit is contained in:
2026-09-30 23:12:51 +01:00
parent 109a69961a
commit f03a75b9d2
17 changed files with 2785 additions and 14 deletions
+101
View File
@@ -0,0 +1,101 @@
// YOUreka Auth cookie session → PocketBase client. Ported from
// one-school-chat (src/lib/server/yrka-auth.ts): the portal returns a
// PocketBase session cookie from `GET /api/web/auth/{sessionId}/cookie`; we
// store its encoded value under our own `session` cookie and reload it into a
// PocketBase SDK client (pb.itcass.net), refreshing when invalid or near
// expiry. `createPb` is injectable for tests; production always uses the
// hardcoded pbUrl default.
import PocketBase from 'pocketbase';
export const PB_URL = 'https://pb.itcass.net';
/** Refresh proactively when the token expires within this window. */
const REFRESH_BUFFER_MS = 5 * 60 * 1000;
/** The auth cookie name used by the YRKA-auth PocketBase instance. */
const PB_COOKIE_KEY = 'pb_auth';
/** Extract the bare `pb_auth` value from a Set-Cookie-style string. */
export function extractPbCookieValue(raw) {
const match = new RegExp(`${PB_COOKIE_KEY}=([^;]+)`).exec(raw);
if (match) return match[1];
// Already a bare value (no attributes / no name prefix).
return raw.split(';')[0];
}
/** Decode a JWT's `exp` (seconds) → ms, or 0 if unreadable. */
export function tokenExpiresAtMs(token) {
if (!token) return 0;
try {
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64url').toString('utf8'));
return typeof payload.exp === 'number' ? payload.exp * 1000 : 0;
} catch {
return 0;
}
}
function rebuildCookieHeader(encodedValue) {
return `${PB_COOKIE_KEY}=${encodedValue}`;
}
function recordCollectionName(pb) {
return pb.authStore.record?.collectionName ?? 'users';
}
function exportCookieValue(pb) {
return extractPbCookieValue(pb.authStore.exportToCookie({}, PB_COOKIE_KEY));
}
async function tryRefresh(pb) {
try {
await pb.collection(recordCollectionName(pb)).authRefresh();
return true;
} catch {
return false;
}
}
/**
* Load a YRKA-auth PocketBase session from the persisted cookie value,
* refreshing the token when it is expired or about to expire. Returns
* { pb, refreshed, cookie? } or null when the session cannot be recovered
* (caller treats the operator as anonymous).
*/
export async function loadYrkaSession(
encodedCookieValue,
{ pbUrl = PB_URL, createPb = (url) => new PocketBase(url) } = {}
) {
if (!encodedCookieValue) return null;
// Normalize: tolerate stale values that still carry a `pb_auth=` prefix.
const normalized = extractPbCookieValue(encodedCookieValue);
const pb = createPb(pbUrl);
pb.autoCancellation(false);
try {
pb.authStore.loadFromCookie(rebuildCookieHeader(normalized));
} catch {
return null;
}
if (!pb.authStore.token) return null;
// Invalid / expired → attempt one refresh (the stored token may redeem).
if (!pb.authStore.isValid) {
if (await tryRefresh(pb)) {
return { pb, refreshed: true, cookie: exportCookieValue(pb) };
}
return null;
}
// Valid but nearing expiry → refresh proactively and persist the new token.
const expiresAt = tokenExpiresAtMs(pb.authStore.token);
if (expiresAt > 0 && Date.now() + REFRESH_BUFFER_MS >= expiresAt) {
if (await tryRefresh(pb)) {
return { pb, refreshed: true, cookie: exportCookieValue(pb) };
}
}
return { pb, refreshed: false };
}