feat(import): ImportDispatchPolicy (DES1GN D3 §31) — policy-objekt + route-koppling #148

Merged
siax-bot merged 5 commits from feat/import-dispatch-policy into main 2026-10-02 09:08:20 +00:00
3 changed files with 178 additions and 5 deletions
+26 -5
View File
@@ -5,6 +5,7 @@ import { isSiaxError, siaxErrorResponseForLegacy } from "../../siax-error-mappin
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
@@ -26,8 +27,12 @@ import { ENTITY_TYPES, FREQUENCIES, type EntityType, type Frequency } from "@/li
// boundary (durable outbox when DATABASE_URL is configured). Emission failure
// is logged, never blocks the import (the outbox pattern owns delivery).
export const MAX_IMPORT_ROWS = 500;
const MAX_CSV_CHARS = 1_000_000;
// 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;
@@ -187,15 +192,31 @@ export async function POST(req: NextRequest) {
if (typeof csv !== "string" || csv.trim() === "") {
return NextResponse.json({ error: { code: "bad_csv", message: "csv saknas eller är tom" } }, { status: 400 });
}
if (csv.length > MAX_CSV_CHARS) {
return NextResponse.json({ error: { code: "csv_too_large", message: `CSV större än ${MAX_CSV_CHARS} tecken` } }, { status: 413 });
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 });
}
if (rows.length > MAX_IMPORT_ROWS) {
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 });
}
+47
View File
@@ -0,0 +1,47 @@
// DES1GN ADR D3 (§31): bulk-import dispatch-policy — ETT objekt, en sanning.
//
// Import-routen (/api/orders/import) är inline-exekvering med hårt tak. Rader
// över maxRows är DES1GN D3:s signal för N0D-dispatch (job_type
// 0pt.import_execute_job / capability 0pt.import_execute i n0d:s
// JOB_TYPE_REGISTRY). TILLS descriptor-gaten släpper (N0D-worker
// UNVERIFIED) svarar routen 413 med n0d_dispatch_required + hint — aldrig
// tyst trunkering, aldrig tyst dispatch.
export const IMPORT_DISPATCH_POLICY = {
/** Inline-tak i import-routen (oförändrat beteendekontrakt, 0pt#53). */
maxInlineRows: 500,
/** N0D-dispatch-signal: rader över detta är ett N0D-jobb, inget HTTP-anrop. */
maxRows: 5000,
/** Byte-tak för hela CSV-kroppen (UTF-8). */
maxBytes: 1_000_000,
/** Format-allowlist — endast RFC 4180 CSV i dag. */
allowedFormats: ["csv"] as readonly string[],
} as const;
export type ImportDispatchPolicy = typeof IMPORT_DISPATCH_POLICY;
export type ImportDispatchVerdict =
| { verdict: "inline" }
| { verdict: "unsupported_format"; format: string }
| { verdict: "byte_cap_exceeded"; bytes: number; maxBytes: number }
| { verdict: "row_cap_exceeded"; rows: number; maxInlineRows: number }
| { verdict: "dispatch_n0d"; rows: number; maxRows: number };
/** Ren funktion — inga I/O, fullt deterministisk, testad per kombination. */
export function evaluateImportDispatch(
input: { format: string; bytes: number; rows: number },
policy: ImportDispatchPolicy = IMPORT_DISPATCH_POLICY,
): ImportDispatchVerdict {
if (!policy.allowedFormats.includes(input.format)) {
return { verdict: "unsupported_format", format: input.format };
}
if (input.bytes > policy.maxBytes) {
return { verdict: "byte_cap_exceeded", bytes: input.bytes, maxBytes: policy.maxBytes };
}
if (input.rows > policy.maxRows) {
return { verdict: "dispatch_n0d", rows: input.rows, maxRows: policy.maxRows };
}
if (input.rows > policy.maxInlineRows) {
return { verdict: "row_cap_exceeded", rows: input.rows, maxInlineRows: policy.maxInlineRows };
}
return { verdict: "inline" };
}
+105
View File
@@ -0,0 +1,105 @@
/**
* DES1GN ADR D3 (§31): ImportDispatchPolicy — ren policy-funktion, testad per
* kombination (format × bytes × rows, gränsvärden inkluderade), plus route-
* kopplingen: rows > policy.maxRows -> 413 n0d_dispatch_required med hint
* (descriptor-gate stängd), inline-taket oförändrat.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { IMPORT_DISPATCH_POLICY, evaluateImportDispatch } from "@/lib/import-dispatch-policy";
describe("evaluateImportDispatch — verdict per kombination", () => {
it("gränsvärde maxInlineRows: exakt 500 rader -> inline", () => {
expect(evaluateImportDispatch({ format: "csv", bytes: 100, rows: IMPORT_DISPATCH_POLICY.maxInlineRows })).toEqual({ verdict: "inline" });
});
it("maxInlineRows < rows <= maxRows -> row_cap_exceeded (inline-taket, oförändrat)", () => {
expect(evaluateImportDispatch({ format: "csv", bytes: 100, rows: IMPORT_DISPATCH_POLICY.maxInlineRows + 1 })).toEqual({
verdict: "row_cap_exceeded",
rows: 501,
maxInlineRows: 500,
});
expect(evaluateImportDispatch({ format: "csv", bytes: 100, rows: IMPORT_DISPATCH_POLICY.maxRows }).verdict).toBe("row_cap_exceeded");
});
it("rows > maxRows -> dispatch_n0d (D3-signalen)", () => {
expect(evaluateImportDispatch({ format: "csv", bytes: 100, rows: IMPORT_DISPATCH_POLICY.maxRows + 1 })).toEqual({
verdict: "dispatch_n0d",
rows: 5001,
maxRows: 5000,
});
});
it("bytes > maxBytes -> byte_cap_exceeded, oavsett rows", () => {
expect(evaluateImportDispatch({ format: "csv", bytes: IMPORT_DISPATCH_POLICY.maxBytes + 1, rows: 1 }).verdict).toBe("byte_cap_exceeded");
expect(evaluateImportDispatch({ format: "csv", bytes: IMPORT_DISPATCH_POLICY.maxBytes, rows: 1 }).verdict).toBe("inline");
});
it("format utanför allowlist -> unsupported_format", () => {
expect(evaluateImportDispatch({ format: "xlsx", bytes: 10, rows: 1 })).toEqual({ verdict: "unsupported_format", format: "xlsx" });
});
it("precedens: format slår bytes, bytes slår rows", () => {
expect(evaluateImportDispatch({ format: "json", bytes: 9e9, rows: 9e9 }).verdict).toBe("unsupported_format");
expect(evaluateImportDispatch({ format: "csv", bytes: 9e9, rows: 9e9 }).verdict).toBe("byte_cap_exceeded");
});
});
describe("route-koppling: POST /api/orders/import respekterar policyn", () => {
const { requireOrderOwnerMock, createOrderMock, ingestEventMock, mockOwner } = vi.hoisted(() => ({
requireOrderOwnerMock: vi.fn(),
createOrderMock: vi.fn(),
ingestEventMock: vi.fn().mockResolvedValue({}),
mockOwner: { owner: { tenantId: "tenant-test", subject: "buyer-test" } },
}));
const _unused = mockOwner;
vi.mock("@/src/auth/require-order-owner", () => ({
requireOrderOwner: (...args: unknown[]) => requireOrderOwnerMock(...args),
}));
vi.mock("@/lib/orders-store", () => ({ createOrder: (...args: unknown[]) => createOrderMock(...args) }));
vi.mock("@/src/clients/aud0-client", () => ({ createAud0Client: () => ({ ingestEvent: ingestEventMock }) }));
let POST: typeof import("@/app/api/orders/import/route").POST;
let MAX_IMPORT_ROWS: number;
beforeEach(async () => {
vi.clearAllMocks();
requireOrderOwnerMock.mockResolvedValue(mockOwner);
({ POST, MAX_IMPORT_ROWS } = await import("@/app/api/orders/import/route"));
});
function postReq(csv: string): NextRequest {
return new NextRequest(new URL("/api/orders/import", "http://localhost"), {
method: "POST",
body: JSON.stringify({ mode: "validate", csv }),
headers: { "content-type": "application/json" },
});
}
const line = "custom,icp,Bolag,10,Engångs,SE";
it("exakt maxInlineRows rader -> 200 (inline-beteende oförändrat)", async () => {
const csv = ["flow,service_type,type,volume,frequency,markets", ...Array.from({ length: MAX_IMPORT_ROWS }, () => line)].join("\n");
const res = await POST(postReq(csv));
expect(res.status).toBe(200);
});
it("maxRows < rows -> 413 n0d_dispatch_required med UNVERIFIED-hint, ingen skrivning", async () => {
const csv = ["flow,service_type,type,volume,frequency,markets", ...Array.from({ length: IMPORT_DISPATCH_POLICY.maxRows + 1 }, () => line)].join("\n");
const res = await POST(postReq(csv));
expect(res.status).toBe(413);
const body = (await res.json()) as { error: { code: string; hint: { capability_id: string; job_type: string; status: string } } };
expect(body.error.code).toBe("n0d_dispatch_required");
expect(body.error.hint).toEqual({ capability_id: "0pt.import_execute", job_type: "0pt.import_execute_job", status: "UNVERIFIED" });
expect(createOrderMock).not.toHaveBeenCalled();
expect(ingestEventMock).not.toHaveBeenCalled();
});
it("CSV > maxBytes -> 413 csv_too_large", async () => {
const csv = "flow,service_type,type,volume,frequency,markets\n" + "x".repeat(IMPORT_DISPATCH_POLICY.maxBytes);
const res = await POST(postReq(csv));
expect(res.status).toBe(413);
const body = (await res.json()) as { error: { code: string } };
expect(body.error.code).toBe("csv_too_large");
});
});