pelagia-portal/EpfoService/src/index.ts
Hardik 1ef0c53ff0
All checks were successful
PR checks / checks (pull_request) Successful in 45s
PR checks / integration (pull_request) Successful in 30s
test(crewing): cover EPFO stub contract + /api/epfo permission gate
- Extract EpfoService's pure stub + validation logic into a dependency-free
  module (EpfoService/src/stub.ts); index.ts now uses it in its stub branches so
  the tested logic IS the production stub behaviour.
- epfo.test.ts (App integration): the deterministic stub contract
  (OTP 000000 → matched, UAN/OTP validation, session expiry) and the Next proxy
  routes' verify_bank_epf gate — 401 unauthenticated, 403 for the MPO, Accounts
  passes through to a mocked upstream, body validated before the upstream call.
  No EPFO_LIVE, no running service.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 23:56:14 +05:30

139 lines
6.1 KiB
TypeScript

/**
* EpfoService — EPFO / UAN assisted-lookup proxy (mirrors GstService).
*
* The EPFO member portal does not offer an anonymous lookup like the GST portal:
* the "Know your UAN" / member flow is gated by an **OTP to the member's
* registered mobile**. So the handshake is two steps:
* POST /otp { uan } → opens a session, requests the OTP
* POST /verify { sessionId, uan, otp } → submits the OTP, returns the member
* record (name, DOB, status, …)
*
* The real portal navigation is gated behind EPFO_LIVE=true. Until the live
* selectors/OTP are validated against a real session, the service runs in STUB
* mode (deterministic responses) so the app integration is exercisable in dev.
*
* Aadhaar verification is intentionally OUT OF SCOPE here — UIDAI restricts it to
* licensed AUA/KUA via consented e-KYC; it cannot be portal-scraped. Aadhaar
* stays assisted-manual in PPMS.
*/
import express from "express";
import type { Browser, BrowserContext, Page } from "playwright";
import { isUan, mobileHint, stubOtp, stubVerify } from "./stub";
const PORT = Number(process.env.PORT ?? 3004);
const SESSION_TTL_MS = Number(process.env.SESSION_TTL_MS ?? 5 * 60 * 1000); // 5 min
const LIVE = process.env.EPFO_LIVE === "true";
const PORTAL_URL = process.env.EPFO_PORTAL_URL ?? "https://unifiedportal-mem.epfindia.gov.in/memberinterface/";
function log(level: string, msg: string, ctx?: Record<string, unknown>) {
const line = JSON.stringify({ ts: new Date().toISOString(), level, msg, ...ctx });
(level === "ERROR" || level === "WARN" ? process.stderr : process.stdout).write(line + "\n");
}
// ── Sessions ───────────────────────────────────────────────────────────────────
interface Session {
uan: string;
createdAt: number;
context?: BrowserContext;
page?: Page;
}
const sessions = new Map<string, Session>();
let seq = 0;
const newSessionId = () => `epfo_${Date.now().toString(36)}_${(seq++).toString(36)}`;
setInterval(() => {
const now = Date.now();
let pruned = 0;
for (const [id, s] of sessions) {
if (now - s.createdAt > SESSION_TTL_MS) {
s.context?.close().catch(() => {});
sessions.delete(id);
pruned++;
}
}
if (pruned) log("INFO", "Pruned expired sessions", { pruned, remaining: sessions.size });
}, 60_000).unref();
// ── Browser (only launched in LIVE mode) ───────────────────────────────────────
let _browser: Browser | null = null;
async function getBrowser(): Promise<Browser> {
if (_browser?.isConnected()) return _browser;
const { chromium } = await import("playwright");
_browser = await chromium.launch({ headless: true, args: ["--no-sandbox", "--disable-setuid-sandbox"] });
_browser.on("disconnected", () => { _browser = null; });
return _browser;
}
// ── App ────────────────────────────────────────────────────────────────────────
const app = express();
app.use(express.json());
app.get("/health", (_req, res) => {
res.json({ status: "ok", mode: LIVE ? "live" : "stub", sessionCount: sessions.size });
});
/** POST /otp { uan } → { sessionId, mobileHint } — request an OTP to the member's mobile. */
app.post("/otp", async (req, res) => {
const { uan } = req.body ?? {};
const sessionId = newSessionId();
if (!LIVE) {
const r = stubOtp(uan, sessionId);
if (r.ok) {
sessions.set(sessionId, { uan, createdAt: Date.now() });
log("INFO", "OTP requested (stub)", { sessionId });
}
return res.status(r.status).json(r.body);
}
if (!isUan(uan)) return res.status(400).json({ error: "A 12-digit UAN is required" });
try {
const browser = await getBrowser();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto(PORTAL_URL, { waitUntil: "domcontentloaded", timeout: 30_000 });
// TODO(live): drive the member portal's "Know your UAN" OTP request:
// fill UAN, solve the on-page captcha, click "Get OTP", read the masked mobile.
// Selectors must be validated against a real session before enabling EPFO_LIVE.
sessions.set(sessionId, { uan, createdAt: Date.now(), context, page });
return res.json({ sessionId, mobileHint: mobileHint() });
} catch (e) {
log("ERROR", "POST /otp failed", { err: String(e) });
return res.status(502).json({ error: `EPFO portal error: ${String(e)}` });
}
});
/** POST /verify { sessionId, uan, otp } → { matched, name, status } — submit the OTP. */
app.post("/verify", async (req, res) => {
const { sessionId, uan, otp } = req.body ?? {};
const s = (sessionId && sessions.get(sessionId)) || undefined;
if (!LIVE) {
const r = stubVerify(s, uan, otp);
// A valid handshake consumes the session (one OTP per request).
if (r.ok && sessionId) sessions.delete(sessionId);
log("INFO", "Verify (stub)", { sessionId, matched: r.body.matched });
return res.status(r.status).json(r.body);
}
if (!s) return res.status(410).json({ error: "Session expired — request a new OTP" });
if (!isUan(uan) || s.uan !== uan) return res.status(400).json({ error: "UAN mismatch" });
if (typeof otp !== "string" || !/^\d{4,8}$/.test(otp)) return res.status(400).json({ error: "A valid OTP is required" });
try {
// TODO(live): submit the OTP and scrape the member record (name/DOB/status).
const result = { matched: false, name: null as string | null, status: null as string | null };
s.context?.close().catch(() => {});
sessions.delete(sessionId);
return res.json(result);
} catch (e) {
log("ERROR", "POST /verify failed", { err: String(e) });
return res.status(502).json({ error: `EPFO portal error: ${String(e)}` });
}
});
app.listen(PORT, () => log("INFO", `EpfoService listening`, { port: PORT, mode: LIVE ? "live" : "stub" }));