From 45301ef081e956778f8443d32fc341e585534903 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sun, 27 Sep 2026 11:39:27 +0530 Subject: [PATCH 1/3] feat(tpo): add pending verification evidence submission --- backend/routes/tpo.js | 66 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/backend/routes/tpo.js b/backend/routes/tpo.js index 6e630ddf..5b5ea7b7 100644 --- a/backend/routes/tpo.js +++ b/backend/routes/tpo.js @@ -234,6 +234,72 @@ router.post("/register", async (req, res) => { } }); +// ── TPO verification evidence ───────────────────────────────────────────── +// Evidence supplements the advisory email signal; it never grants TPO access. +// Only a pending TPO applicant can add evidence, and the email evidence entry +// created during registration remains system-controlled. +const TPO_EVIDENCE_KINDS = new Set(["invitation", "staff_id", "document", "manual_note"]); +const MAX_TPO_EVIDENCE_ITEMS = 10; + +router.post("/verification/evidence", requireRole("tpo"), async (req, res) => { + try { + const verification = req.userDoc?.tpoVerification; + if (!verification || verification.status !== "pending") { + return res.status(409).json({ error: "Only a pending TPO verification request can receive evidence." }); + } + + const input = req.body?.evidence; + if (!Array.isArray(input) || input.length === 0) { + return res.status(400).json({ error: "evidence must be a non-empty array." }); + } + + if (input.length > MAX_TPO_EVIDENCE_ITEMS) { + return res.status(400).json({ error: "A maximum of " + MAX_TPO_EVIDENCE_ITEMS + " evidence items is allowed." }); + } + + const now = new Date(); + const additions = []; + for (const item of input) { + const kind = String(item?.kind || "").trim().toLowerCase(); + const label = String(item?.label || "").trim(); + const reference = item?.reference == null ? null : String(item.reference).trim(); + const note = item?.note == null ? null : String(item.note).trim(); + + if (!TPO_EVIDENCE_KINDS.has(kind)) { + return res.status(400).json({ error: "Unsupported evidence kind: " + (kind || "unknown") + "." }); + } + if (!label || label.length > 120) { + return res.status(400).json({ error: "Each evidence label must be 1–120 characters." }); + } + if (reference && reference.length > 500) { + return res.status(400).json({ error: "Evidence reference must be at most 500 characters." }); + } + if (note && note.length > 1000) { + return res.status(400).json({ error: "Evidence note must be at most 1000 characters." }); + } + + additions.push({ kind, label, reference: reference || null, note: note || null, addedAt: now }); + } + + const existing = Array.isArray(verification.evidence) ? verification.evidence : []; + if (existing.length + additions.length > MAX_TPO_EVIDENCE_ITEMS) { + return res.status(400).json({ error: "A maximum of " + MAX_TPO_EVIDENCE_ITEMS + " evidence items is allowed in total." }); + } + + verification.evidence = [...existing, ...additions]; + await req.userDoc.save(); + + return res.status(200).json({ + success: true, + status: verification.status, + evidence: verification.evidence, + }); + } catch (err) { + (req.log || logger).error({ err, userId: req.userDoc?._id }, "[TPO] verification evidence update error"); + return res.status(500).json({ error: "Failed to update TPO verification evidence." }); + } +}); + // ── TPO-6 entitlement enforcement ───────────────────────────────────────── // Registration and billing-status must remain reachable without a paid plan. // Students using the college TPO directory are also unaffected because this From 36374569ad682e40a3b8c8cb4485be9fb798ad61 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sun, 27 Sep 2026 11:39:37 +0530 Subject: [PATCH 2/3] test(tpo): cover verification evidence workflow --- backend/routes/tpo.test.js | 109 +++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/backend/routes/tpo.test.js b/backend/routes/tpo.test.js index 5c2416aa..5c0ac656 100644 --- a/backend/routes/tpo.test.js +++ b/backend/routes/tpo.test.js @@ -924,3 +924,112 @@ describe("GET /college-directory", () => { expect(User.find).not.toHaveBeenCalled(); }); }); + +describe("POST /verification/evidence", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("appends applicant evidence while keeping the request pending", async () => { + const userDoc = { + role: "tpo", + tpoVerification: { + status: "pending", + emailRoleSignal: "student_candidate", + evidence: [ + { kind: "email", label: "Institutional sign-in email" }, + ], + }, + save: vi.fn().mockResolvedValue(true), + }; + + const res = await runRoute("post", "/verification/evidence", { + userDoc, + body: { + evidence: [ + { + kind: "staff_id", + label: "Staff ID reference", + reference: "STAFF-2026-123", + note: "Current institutional staff identifier.", + }, + ], + }, + }); + + expect(res.status).not.toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + success: true, + status: "pending", + evidence: expect.arrayContaining([ + expect.objectContaining({ kind: "email", label: "Institutional sign-in email" }), + expect.objectContaining({ + kind: "staff_id", + label: "Staff ID reference", + reference: "STAFF-2026-123", + }), + ]), + })); + expect(userDoc.save).toHaveBeenCalledOnce(); + }); + + it("rejects system-controlled email evidence submitted by the applicant", async () => { + const userDoc = { + role: "tpo", + tpoVerification: { status: "pending", evidence: [] }, + save: vi.fn(), + }; + + const res = await runRoute("post", "/verification/evidence", { + userDoc, + body: { evidence: [{ kind: "email", label: "Forged email evidence" }] }, + }); + + expect(res.status).toHaveBeenCalledWith(400); + expect(userDoc.save).not.toHaveBeenCalled(); + }); + + it("rejects evidence updates after verification is no longer pending", async () => { + const userDoc = { + role: "tpo", + tpoVerification: { status: "approved", evidence: [] }, + save: vi.fn(), + }; + + const res = await runRoute("post", "/verification/evidence", { + userDoc, + body: { evidence: [{ kind: "manual_note", label: "Extra context" }] }, + }); + + expect(res.status).toHaveBeenCalledWith(409); + expect(userDoc.save).not.toHaveBeenCalled(); + }); + + it("enforces the total evidence cap", async () => { + const userDoc = { + role: "tpo", + tpoVerification: { + status: "pending", + evidence: Array.from({ length: 9 }, (_, index) => ({ + kind: "manual_note", + label: "Existing " + index, + })), + }, + save: vi.fn(), + }; + + const res = await runRoute("post", "/verification/evidence", { + userDoc, + body: { + evidence: [ + { kind: "manual_note", label: "One more" }, + { kind: "manual_note", label: "Too many" }, + ], + }, + }); + + expect(res.status).toHaveBeenCalledWith(400); + expect(userDoc.save).not.toHaveBeenCalled(); + }); +}); + From 9294098bba70fc4fbf0d661c3d396214809586d8 Mon Sep 17 00:00:00 2001 From: Shiva Shankara Vara Prasad Date: Sun, 27 Sep 2026 11:39:45 +0530 Subject: [PATCH 3/3] docs(tpo): document verification evidence endpoint --- docs/api-contracts.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api-contracts.md b/docs/api-contracts.md index 218728cc..e1161625 100644 --- a/docs/api-contracts.md +++ b/docs/api-contracts.md @@ -178,5 +178,6 @@ College email-role patterns are institution-specific advisory evidence. They may | PATCH | `/api/admin/colleges/:collegeId/email-role-patterns` | Admin-only configuration of staff/student email patterns for a college. | | POST | `/api/admin/tpo-verification/:userId/approve` | Admin-only approval of an individual pending TPO request after the college is verified. | | POST | `/api/admin/tpo-verification/:userId/reject` | Admin-only rejection of an individual pending TPO request. | +| POST | `/api/tpo/verification/evidence` | Adds applicant-supplied verification evidence to a pending TPO request; evidence is advisory and does not grant access. | TPO registration keeps the requester pending even when the institution itself is already recognized. This separates **institution trust** from **individual TPO authorization**. Student/staff email patterns are evidence shown to the reviewer, not an authorization shortcut.