/** * 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) { 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(); 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 { 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" }));