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
+35 -8
View File
@@ -1,20 +1,31 @@
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
import { getCfExtractStream } from "@/modules/parcel-sync/services/epay-storage";
import { requireCfAccess } from "@/core/auth/cf-access";
import JSZip from "jszip";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Cap per request so a "Descarcă tot" over hundreds of extracts can't
// balloon the in-memory ZIP buffer (V3). The UI batches above this.
const MAX_ZIP_IDS = 100;
/**
* GET /api/ancpi/download-zip?ids=id1,id2,id3
*
* Streams a ZIP file containing all requested CF extract PDFs.
* Files named: {index:02d}_Extras CF_{nrCadastral} - {DD-MM-YYYY}.pdf
* Index = position in the ids array (preserves list order).
* Guarded: PDFs contain owner PII — staff only, no portal accounts (C4).
*/
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 idsParam = url.searchParams.get("ids");
@@ -32,6 +43,12 @@ export async function GET(req: Request) {
{ status: 400 },
);
}
if (ids.length > MAX_ZIP_IDS) {
return NextResponse.json(
{ error: `Prea multe extrase într-o arhivă (max ${MAX_ZIP_IDS}).` },
{ status: 400 },
);
}
// Fetch all extract records
const extracts = await prisma.cfExtract.findMany({
@@ -90,21 +107,31 @@ export async function GET(req: Request) {
);
}
const zipBuffer = await zip.generateAsync({
type: "nodebuffer",
compression: "DEFLATE",
compressionOptions: { level: 6 },
});
const today = new Date();
const todayStr = `${String(today.getDate()).padStart(2, "0")}-${String(today.getMonth() + 1).padStart(2, "0")}-${today.getFullYear()}`;
const archiveName = `Extrase_CF_${filesAdded}_${todayStr}.zip`;
return new Response(new Uint8Array(zipBuffer), {
// Stream the ZIP out instead of materializing the whole archive in
// memory (V3) — pull from JSZip's internal stream into a Web stream,
// and drop Content-Length (unknown until the stream ends).
const nodeStream = zip.generateInternalStream({
type: "uint8array",
compression: "DEFLATE",
compressionOptions: { level: 6 },
});
const webStream = new ReadableStream<Uint8Array>({
start(controller) {
nodeStream.on("data", (chunk: Uint8Array) => controller.enqueue(chunk));
nodeStream.on("end", () => controller.close());
nodeStream.on("error", (err: Error) => controller.error(err));
nodeStream.resume();
},
});
return new Response(webStream, {
headers: {
"Content-Type": "application/zip",
"Content-Disposition": `attachment; filename="${encodeURIComponent(archiveName)}"`,
"Content-Length": String(zipBuffer.length),
},
});
} catch (error) {
+7 -1
View File
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
import { getCfExtractStream } from "@/modules/parcel-sync/services/epay-storage";
import { Readable } from "stream";
import { requireCfAccess } from "@/core/auth/cf-access";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -10,9 +10,15 @@ export const dynamic = "force-dynamic";
* GET /api/ancpi/download?id={extractId}
*
* Streams the CF extract PDF from MinIO with proper filename.
* Guarded: PDFs contain owner PII — staff only, no portal accounts (C4).
*/
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 id = url.searchParams.get("id");
+39 -7
View File
@@ -5,7 +5,8 @@ import {
enqueueBatch,
} from "@/modules/parcel-sync/services/epay-queue";
import type { CfExtractCreateInput } from "@/modules/parcel-sync/services/epay-types";
import { getAuthSession } from "@/core/auth/require-auth";
import { requireCfAccess } from "@/core/auth/cf-access";
import { prisma } from "@/core/storage/prisma";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -48,6 +49,13 @@ function cleanupNonceMap(): void {
*/
export async function POST(req: Request) {
try {
// C3: spending the company's credits requires a staff session — not
// merely a connected ePay session. Portal accounts are rejected.
const access = await requireCfAccess();
if (!access.ok) {
return NextResponse.json({ error: access.error }, { status: access.status });
}
const creds = getEpayCredentials();
if (!creds) {
return NextResponse.json(
@@ -96,14 +104,38 @@ export async function POST(req: Request) {
}
}
// C3: daily spend guardrail. ePay credits are real money; a runaway
// loop or fat-finger batch shouldn't be able to drain the account.
// Counts credit-consuming rows created today (UTC) and refuses the
// batch if it would push past the cap. Default 200/day, override via
// ANCPI_DAILY_CREDIT_CAP.
const dailyCap = parseInt(process.env.ANCPI_DAILY_CREDIT_CAP ?? "200", 10);
if (dailyCap > 0) {
const startOfDay = new Date();
startOfDay.setUTCHours(0, 0, 0, 0);
const spentToday = await prisma.cfExtract.count({
where: {
type: "epay",
createdAt: { gte: startOfDay },
status: { notIn: ["failed", "cancelled"] },
},
});
if (spentToday + parcels.length > dailyCap) {
return NextResponse.json(
{
error: `Plafon zilnic de credite atins (${spentToday}/${dailyCap}). Comanda de ${parcels.length} depășește limita.`,
spentToday,
dailyCap,
},
{ status: 429 },
);
}
}
// Stamp the orderer's session id on each enqueued row so CfExtract
// carries ownership info (was NULL before — see
// feedback_cfextract_schema_drift.md). Falls back to undefined when
// the route is hit without a session (dev tools / cron).
const session = await getAuthSession();
const userId =
((session?.user as { id?: string } | undefined)?.id ||
session?.user?.email) ?? undefined;
// feedback_cfextract_schema_drift.md).
const userId = access.actor.userId;
const stampedParcels: CfExtractCreateInput[] = parcels.map((p) => ({
...p,
userId: p.userId ?? userId,
+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";
}
}
+63 -21
View File
@@ -1,38 +1,79 @@
// GET /api/ancpi/recover?orderId=<id>
// GET /api/ancpi/recover?orderId=<id> — recover a whole ANCPI order
// GET /api/ancpi/recover?extractId=<id> — retry one row (resolves its order)
//
// Recovery for ePay orders that ANCPI processed even though our
// EditCartSubmit request timed out: the CfExtract rows sit on
// status "failed" / "timeout..." with no orderId while the credits are
// already spent (2026-06-04: order 10009605, 15 extracts, Feleacu).
// Recovery for ePay orders that ANCPI processed/finalized even though our
// pipeline lost them (EditCartSubmit timed out, or the container restarted
// mid-batch): the CfExtract rows sit on a non-terminal/failed status while
// the credits are ALREADY spent. This re-attaches the order and re-runs the
// shared poll → download → MinIO pipeline (epay-queue.recoverBatch). It does
// NOT place a new order or spend new credits — so it's idempotent and safe
// to re-run. Requires an active ePay session (connect in the ePay tab first).
//
// Picks up the recent failed-without-orderId rows, attaches the given
// orderId and runs the shared poll → download → MinIO pipeline
// (epay-queue.finalizeOrder via recoverBatch). Requires an active ePay
// session (connect in the ePay tab first). Idempotent: already-completed
// rows are not selected; re-running after a partial failure only retries
// the still-failed rows.
//
// GET (not POST) on purpose — it's an operator action triggered from the
// browser URL bar with the NextAuth session cookie.
// GET on purpose — operator action from the browser URL bar / a retry button
// with the NextAuth cookie. No new spend, so no CSRF exposure to credits;
// still staff-gated (C3).
import { NextResponse } from "next/server";
import { prisma } from "@/core/storage/prisma";
import { recoverBatch } from "@/modules/parcel-sync/services/epay-queue";
import { requireCfAccess } from "@/core/auth/cf-access";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
// Pre-submit / in-flight states that a crash can orphan. Widened from the
// original "failed only" so a batch killed mid-flight (orderId still null)
// can be attached to an operator-supplied orderId (C2/QW2).
const RECOVERABLE_STATES = [
"failed",
"queued",
"cart",
"searching",
"ordering",
"polling",
"downloading",
];
// Single-flight: poll+download for a batch takes minutes; a second click
// must not start a parallel run over the same rows.
const g = globalThis as { __epayRecoverRunning?: boolean };
export async function GET(req: Request) {
// Staff only — recovery completes/downloads orders with PII.
const access = await requireCfAccess();
if (!access.ok) {
return NextResponse.json({ error: access.error }, { status: access.status });
}
const url = new URL(req.url);
const orderId = url.searchParams.get("orderId")?.trim() ?? "";
let orderId = url.searchParams.get("orderId")?.trim() ?? "";
const extractId = url.searchParams.get("extractId")?.trim() ?? "";
// QW3: retry-by-row. Resolve the row's orderId server-side; recover the
// whole sibling set that shares it.
if (!orderId && extractId) {
const row = await prisma.cfExtract.findUnique({
where: { id: extractId },
select: { orderId: true, status: true },
});
if (!row) {
return NextResponse.json({ error: "Extras inexistent." }, { status: 404 });
}
if (!row.orderId) {
return NextResponse.json(
{
error:
"Extrasul nu are încă un orderId ANCPI — recuperează cu ?orderId=<id> de pe portalul ePay.",
},
{ status: 409 },
);
}
orderId = row.orderId;
}
if (!/^\d+$/.test(orderId)) {
return NextResponse.json(
{ error: "orderId lipsă sau invalid. Folosește ?orderId=<id ePay>." },
{ error: "orderId lipsă sau invalid. Folosește ?orderId=<id> sau ?extractId=<id>." },
{ status: 400 },
);
}
@@ -44,18 +85,19 @@ export async function GET(req: Request) {
);
}
// Candidate rows: recent paid-flow failures that never got an orderId,
// plus rows already attached to this order by a previous partial run.
// Candidate rows: anything already tagged with this order that isn't
// terminal, PLUS recent orphaned rows (orderId:null) in a recoverable
// state — the operator asserts they belong to this order.
const rows = await prisma.cfExtract.findMany({
where: {
type: "epay",
OR: [
{ orderId, status: { notIn: ["completed", "cancelled"] } },
{
status: "failed",
orderId: null,
status: { in: RECOVERABLE_STATES },
createdAt: { gte: new Date(Date.now() - 48 * 60 * 60 * 1000) },
},
{ orderId, status: { notIn: ["completed", "cancelled"] } },
],
},
select: {
@@ -73,7 +115,7 @@ export async function GET(req: Request) {
if (rows.length === 0) {
return NextResponse.json({
recovered: 0,
message: "Niciun extras failed (ultimele 48h) de recuperat.",
message: "Niciun extras de recuperat pentru această comandă.",
});
}
+8
View File
@@ -36,6 +36,14 @@ if (gTestDedup.__testOrderDedup === undefined) gTestDedup.__testOrderDedup = nul
* Zero discovery calls needed!
*/
export async function GET(req: Request) {
// SECURITY (C5): this is a GET with real side-effects — step=order spends
// 2 ePay credits on hardcoded parcels. A GET endpoint that spends money is
// CSRF-able (<img src=...?step=order>) from any page an authenticated
// operator visits. Disabled entirely outside development.
if (process.env.NODE_ENV === "production") {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const url = new URL(req.url);
const step = url.searchParams.get("step") ?? "login";
+7
View File
@@ -6,11 +6,18 @@
*/
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";
export async function GET(req: Request) {
// Guarded: returns extract id + download URL — staff only (C4).
const access = await requireCfAccess();
if (!access.ok) {
return NextResponse.json({ error: access.error }, { status: access.status });
}
const url = new URL(req.url);
const nrCad = url.searchParams.get("nrCad")?.trim();