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
- 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
271 lines
12 KiB
TypeScript
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;
|
|
}
|
|
}
|