harden(epay): cart hygiene, auth/IDOR gates, single-page fetch, parallel downloads

Live-path hardening from the 2026-06-04 deep-dive (11 confirmed criticals).
ArchiTools-only; the legacy queue is still the sole fulfiller.

Security:
- requireCfAccess() — staff-only, portal accounts blocked, fail-closed
  in-route on download / download-zip / cf-status / orders (C4 IDOR/PII)
  and order / recover (C3). order also enforces a daily credit cap
  (ANCPI_DAILY_CREDIT_CAP, default 200) and stamps userId.
- /api/ancpi/test returns 404 in production — it was a GET that spends 2
  real credits, CSRF-able (C5).
- drop the token-metadata debug blob from the session (QW8).

Correctness / robustness:
- cart hygiene (C1): build the ePay cart under an invariant — the Nth add
  must report N items; any excess = pre-existing junk, so we wipe + abort
  (never submit a cart we didn't fully build). Pre-submit failures clean
  up our basket rows; post-submit we never touch the cart (recover owns
  it). metadata-less rows are deleted from the cart.
- getOrderStatus fetches the whole order in ONE page (itemsPerPage, QW4);
  navDir loop kept only as fallback. index-fallback matches are flagged
  'review' instead of silently 'completed' with a possibly-wrong PDF (R4).
- downloadDocument asserts %PDF magic bytes — a login page returned mid
  session no longer gets stored as a .pdf (R2). Session reuse TTL aligned
  under ANCPI's ~10min expiry.
- recover accepts ?extractId= and pre-submit states; retry buttons in the
  ePay tab re-run poll+download with no new charge (QW2/QW3).

Performance:
- parallel document downloads (V1, concurrency 4); poll writes only on
  status change via updateMany (QW5); getNextFileIndex scans the cadastral
  prefix instead of the whole bucket — and actually works now (it was
  ^-anchoring the full key, so every file got index 1) (V2); download-zip
  streams instead of buffering the whole archive, capped at 100 (V3).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude VM
2026-06-04 23:59:44 +03:00
parent f7f7c59d17
commit f49fdb1da0
13 changed files with 602 additions and 124 deletions
+26 -3
View File
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
import { requireCfAccess } from "@/core/auth/cf-access";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -21,6 +22,11 @@ export const dynamic = "force-dynamic";
*/
export async function GET(req: Request) {
try {
const access = await requireCfAccess();
if (!access.ok) {
return NextResponse.json({ error: access.error }, { status: access.status });
}
const url = new URL(req.url);
const nrCadastralParam = url.searchParams.get("nrCadastral") || undefined;
const status = url.searchParams.get("status") || undefined;
@@ -92,7 +98,25 @@ export async function GET(req: Request) {
}
}
// Also check for active (in-progress) orders
// QW6: surface terminal failure/review so the UI can flag them (a
// cadastral whose latest record failed used to show as "none"). Only
// applies where there's no valid extract — a fresh valid one wins.
const attentionRecords = await prisma.cfExtract.findMany({
where: {
nrCadastral: { in: cadastralNumbers },
status: { in: ["failed", "review"] },
},
orderBy: { createdAt: "desc" },
select: { nrCadastral: true, status: true },
});
for (const rec of attentionRecords) {
if (statusMap[rec.nrCadastral] === "none") {
statusMap[rec.nrCadastral] = rec.status; // "failed" | "review"
}
}
// Active (in-progress) orders take priority over none/failed/review/
// expired — an in-flight re-order should read as "processing".
const activeRecords = await prisma.cfExtract.findMany({
where: {
nrCadastral: { in: cadastralNumbers },
@@ -104,8 +128,7 @@ export async function GET(req: Request) {
});
for (const rec of activeRecords) {
// If there's an active order, mark as "processing" (takes priority over "none")
if (statusMap[rec.nrCadastral] === "none") {
if (statusMap[rec.nrCadastral] !== "valid") {
statusMap[rec.nrCadastral] = "processing";
}
}