Files
0pt/app/api/orders/import/route.ts
admin 1c87b82e70
CI (SIAX Cloud) / integration (push) Skipped
CI (SIAX Cloud) / sonar (push) Skipped
masterplan-lock / check (push) Successful in 6s
masterplan-lock / check (pull_request) Successful in 7s
CI (SIAX Cloud) / security (push) Failing after 27s
Security Scan (SIAX Cloud) / security (push) Failing after 1m27s
CI (SIAX Cloud) / quality (push) Successful in 1m28s
CI (SIAX Cloud) / quality (pull_request) Successful in 2m29s
CI (SIAX Cloud) / integration (pull_request) Successful in 1m15s
CI (SIAX Cloud) / security (pull_request) Failing after 16s
CI (SIAX Cloud) / sonar (pull_request) Skipped
Security Scan (SIAX Cloud) / security (pull_request) Failing after 17s
feat(import): ImportDispatchPolicy (DES1GN ADR D3 §31) — ett objekt, en sanning
- lib/import-dispatch-policy.ts: maxInlineRows=500 (inline-tak, oförändrat
  kontrakt), maxRows=5000 (N0D-dispatch-signal), maxBytes=1_000_000 (bytes,
  UTF-8), format-allowlist ['csv']; ren evaluateImportDispatch() med
  deterministisk precedens format > bytes > rows
- route: taken härstammar från policyn; bytes-koll (ersätter chars); rows >
  maxRows -> 413 n0d_dispatch_required med hint {capability_id:
  0pt.import_execute, job_type: 0pt.import_execute_job, status: UNVERIFIED}
  (descriptor-gate stängd: aldrig tyst trunkering, aldrig tyst dispatch)
- tester: 9 nya (verdict per kombination inkl. gränsvärden 500/501/5000/5001,
  bytes-gräns, allowlist, precedens; route-koppling 200/413/413) — 21/21 gröna
  i sviten; full N0D-worker-integration markeras UNVERIFIED
2026-09-30 21:48:16 +02:00

271 lines
12 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { createOrder, type OrderConfig, type OrderOwner } from "@/lib/orders-store";
import { requireOrderOwner } from "@/src/auth/require-order-owner";
import { isSiaxError, siaxErrorResponseForLegacy } from "../../siax-error-mapping";
import { createAud0Client } from "@/src/clients/aud0-client";
import { validateRows, defineMapping, type RowError } from "@siax/contracts/import-export";
import { serviceById, type Flow } from "@/lib/services";
import { IMPORT_DISPATCH_POLICY, evaluateImportDispatch } from "@/lib/import-dispatch-policy";
import { ENTITY_TYPES, FREQUENCIES, type EntityType, type Frequency } from "@/lib/data";
// C8: CSV-import of orders — the symmetric counterpart of GET /api/orders/export
// (våg 3.4). The server is the ONLY authority: the raw CSV is parsed here
// (RFC 4180, no eval), re-validated here via the frozen @siax/contracts kernel
// (validateRows) plus host field rules, and executed through the EXISTING order
// creation logic (createOrder) so imported rows are real tenant-owned orders.
// Client-side wizard parsing is presentation entropy, never trusted.
//
// Fail-closed properties:
// - No principal (requireOrderOwner) → 401, no parse, no writes.
// - Unknown header column → 400 (never silently dropped).
// - More than MAX_IMPORT_ROWS data rows → 413 (cap, never truncate silently).
// - execute() re-validates and imports only rows that pass; failing rows are
// reported per row (row/field/code) and never created. Each order insert is
// individually atomic via createOrder (per-row transactional semantics);
// a failing row can never corrupt a passing one.
// - Audit: every successful execute emits an AUD0 event via 0pt's sole producer
// boundary (durable outbox when DATABASE_URL is configured). Emission failure
// is logged, never blocks the import (the outbox pattern owns delivery).
// DES1GN ADR D3 (§31): taken härstammar från ImportDispatchPolicy — ett objekt,
// en sanning. Inline-taket är oförändrat (500); rader > policy.maxRows (5000)
// är N0D-dispatch-signalen (descriptor-gate: svarar 413 + hint tills N0D-workern
// är verifierad — aldrig tyst trunkering, aldrig tyst dispatch).
export const MAX_IMPORT_ROWS = IMPORT_DISPATCH_POLICY.maxInlineRows;
const MAX_CSV_CHARS = IMPORT_DISPATCH_POLICY.maxBytes;
const IMPORT_COLUMNS = ["flow", "service_type", "type", "volume", "frequency", "markets"] as const;
const FLOWS: readonly Flow[] = ["catalog", "custom", "subscription"];
const MAX_VOLUME = 1_000_000;
interface ImportRowError extends RowError {
message: string;
}
interface ParsedCsv {
rows: Record<string, string>[];
headerError?: string;
}
/** Minimal RFC 4180 parser: quoted fields, doubled quotes, CRLF or LF. No eval. */
export function parseCsv(text: string): ParsedCsv {
const rows: string[][] = [];
let field = "";
let row: string[] = [];
let inQuotes = false;
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (inQuotes) {
if (ch === '"') {
if (text[i + 1] === '"') {
field += '"';
i++;
} else {
inQuotes = false;
}
} else {
field += ch;
}
} else if (ch === '"') {
inQuotes = true;
} else if (ch === ",") {
row.push(field);
field = "";
} else if (ch === "\n") {
row.push(field);
field = "";
rows.push(row);
row = [];
} else if (ch === "\r") {
// consumed by the following \n (or ignored for bare CR)
} else {
field += ch;
}
}
if (field.length > 0 || row.length > 0) {
row.push(field);
rows.push(row);
}
const nonEmpty = rows.filter((r) => r.length > 1 || (r[0] ?? "").trim() !== "");
if (nonEmpty.length === 0) return { rows: [] };
const header = nonEmpty[0].map((h) => h.trim().toLowerCase());
const unknown = header.filter((h) => h !== "" && !(IMPORT_COLUMNS as readonly string[]).includes(h));
if (unknown.length > 0) {
return { rows: [], headerError: `Okänd kolumn: ${unknown.join(", ")} (tillåtna: ${IMPORT_COLUMNS.join(", ")})` };
}
return {
rows: nonEmpty.slice(1).map((cells) => {
const obj: Record<string, string> = {};
header.forEach((h, idx) => {
if (h) obj[h] = (cells[idx] ?? "").trim();
});
return obj;
}),
};
}
/** Host field rules on top of the kernel's required/type checks. */
function hostValidate(rows: Record<string, string>[]): ImportRowError[] {
const errors: ImportRowError[] = [];
rows.forEach((r, idx) => {
const rowNo = idx + 1;
const flow = (r.flow ?? "").trim() as Flow;
if (flow && !FLOWS.includes(flow)) {
errors.push({ row: rowNo, field: "flow", code: "bad_type", message: `Ogiltigt flow "${r.flow}" (tillåtna: ${FLOWS.join(", ")})` });
}
const st = (r.service_type ?? "").trim();
if (st && !serviceById(st)) {
errors.push({ row: rowNo, field: "service_type", code: "bad_type", message: `Okänd service_type "${st}"` });
}
const type = (r.type ?? "").trim();
if (type && !ENTITY_TYPES.some((t) => t.id === type)) {
errors.push({ row: rowNo, field: "type", code: "bad_type", message: `Ogiltig typ "${type}" (tillåtna: ${ENTITY_TYPES.map((t) => t.id).join(", ")})` });
}
const volume = (r.volume ?? "").trim();
if (volume === "") {
// optional — order logic applies flow defaults
} else if (!/^\d+$/.test(volume) || Number(volume) < 1 || Number(volume) > MAX_VOLUME) {
errors.push({ row: rowNo, field: "volume", code: "bad_type", message: `volume måste vara ett positivt heltal ≤ ${MAX_VOLUME}` });
}
const freq = (r.frequency ?? "").trim();
if (freq && !FREQUENCIES.includes(freq as Frequency)) {
errors.push({ row: rowNo, field: "frequency", code: "bad_type", message: `Ogiltig frequency "${freq}" (tillåtna: ${FREQUENCIES.join(", ")})` });
}
});
return errors;
}
function buildConfig(r: Record<string, string>): OrderConfig {
const config: OrderConfig = {};
if ((r.service_type ?? "").trim()) config.service_type = r.service_type.trim();
if ((r.type ?? "").trim()) config.type = r.type.trim() as EntityType;
if ((r.volume ?? "").trim()) config.volume = Number(r.volume.trim());
if ((r.frequency ?? "").trim()) config.frequency = r.frequency.trim() as Frequency;
if ((r.markets ?? "").trim()) {
config.markets = r.markets.split(";").map((m) => m.trim()).filter(Boolean);
}
return config;
}
export interface ImportReport {
totalRows: number;
validRows: number;
errorCount: number;
errors: ImportRowError[];
}
/** Server-authoritative validation: kernel required/type checks + host rules. */
export function validateImport(csv: string): { report: ImportReport; rows: Record<string, string>[]; headerError?: string } {
const { rows, headerError } = parseCsv(csv);
if (headerError) return { report: { totalRows: 0, validRows: 0, errorCount: 1, errors: [{ row: 0, field: "header", code: "format", message: headerError }] }, rows: [], headerError };
const mapping = defineMapping({ columns: IMPORT_COLUMNS.map((c) => ({ source: c, target: c, required: c === "flow" })) });
const kernel = validateRows(rows, mapping);
const host = hostValidate(rows);
const kernelErrors: ImportRowError[] = kernel.errors.map((e) => ({ ...e, message: e.message ?? `Valideringsfel (${e.code})` }));
const errors = [...kernelErrors, ...host];
return {
report: {
totalRows: rows.length,
validRows: rows.length - new Set(errors.map((e) => e.row)).size,
errorCount: errors.length,
errors,
},
rows,
};
}
export async function POST(req: NextRequest) {
const auth = await requireOrderOwner(req);
if (auth.response) return auth.response;
const owner: OrderOwner = auth.owner;
let body: { mode?: string; csv?: string };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: { code: "bad_json", message: "Invalid JSON" } }, { status: 400 });
}
const mode = body.mode;
const csv = body.csv;
if (!mode || !["validate", "dry_run", "execute"].includes(mode)) {
return NextResponse.json({ error: { code: "bad_mode", message: "mode måste vara validate | dry_run | execute" } }, { status: 400 });
}
if (typeof csv !== "string" || csv.trim() === "") {
return NextResponse.json({ error: { code: "bad_csv", message: "csv saknas eller är tom" } }, { status: 400 });
}
const csvBytes = Buffer.byteLength(csv, "utf8");
if (evaluateImportDispatch({ format: "csv", bytes: csvBytes, rows: 0 }).verdict === "byte_cap_exceeded") {
return NextResponse.json({ error: { code: "csv_too_large", message: `CSV större än ${MAX_CSV_CHARS} bytes` } }, { status: 413 });
}
try {
const { report, rows, headerError } = validateImport(csv);
if (headerError) {
return NextResponse.json({ error: { code: "bad_header", message: headerError }, report }, { status: 400 });
}
const dispatchVerdict = evaluateImportDispatch({ format: "csv", bytes: csvBytes, rows: rows.length });
if (dispatchVerdict.verdict === "dispatch_n0d") {
// DES1GN D3-signalen: den här volymen ÄR ett N0D-jobb (0pt.import_execute_job).
// Descriptor-gaten är stängd (N0D-worker UNVERIFIED) -> fail-closed 413 med hint.
return NextResponse.json(
{
error: {
code: "n0d_dispatch_required",
message: `Import med ${rows.length} rader ska köras som N0D-jobb (0pt.import_execute_job); dispatch är ännu inte aktiverad`,
hint: { capability_id: "0pt.import_execute", job_type: "0pt.import_execute_job", status: "UNVERIFIED" },
},
},
{ status: 413 },
);
}
if (dispatchVerdict.verdict === "row_cap_exceeded") {
return NextResponse.json({ error: { code: "row_cap_exceeded", message: `Max ${MAX_IMPORT_ROWS} rader per import` } }, { status: 413 });
}
if (mode === "validate" || mode === "dry_run") {
// No writes — dry_run is exactly validate plus an explicit rehearsal count.
return NextResponse.json({ mode, ...report, would_import: report.validRows });
}
// mode === "execute": server re-validates and imports ONLY passing rows.
const failedRows = new Set(report.errors.map((e) => e.row));
const orderIds: string[] = [];
let imported = 0;
for (let idx = 0; idx < rows.length; idx++) {
if (failedRows.has(idx + 1)) continue;
const r = rows[idx];
try {
const { order } = await createOrder((r.flow ?? "").trim(), buildConfig(r), undefined, owner);
orderIds.push(order.order_id);
imported++;
} catch (err: unknown) {
report.errors.push({ row: idx + 1, field: "row", code: "format", message: `Skapning misslyckades: ${err instanceof Error ? err.message : "okänt fel"}` });
}
}
// AUD0 audit — 0pt's sole producer boundary; durable outbox under DATABASE_URL.
try {
const aud0 = createAud0Client();
await aud0.ingestEvent({
event_id: (globalThis.crypto?.randomUUID?.() ?? `evt-${Date.now()}`),
event_type: "0pt.orders.import",
tenant_id: owner.tenantId,
project_id: "0pt-orders",
actor_id: owner.subject,
app_id: "0pt",
capability_id: "orders.import",
resource_type: "order",
resource_id: orderIds[0] ?? "none",
risk_level: "L2",
payload: { imported, total_rows: report.totalRows, error_count: report.errors.length, order_ids: orderIds },
created_at: new Date().toISOString(),
});
} catch (auditErr: unknown) {
console.error("[0pt-orders-import] AUD0 emission failed (import not rolled back):", auditErr);
}
return NextResponse.json({ mode, imported, totalRows: report.totalRows, validRows: report.validRows, errorCount: report.errors.length, errors: report.errors.slice(0, 100), order_ids: orderIds });
} catch (err: unknown) {
if (isSiaxError(err)) return siaxErrorResponseForLegacy(err);
throw err;
}
}