- 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>
139 lines
6.1 KiB
TypeScript
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" }));
|