feat(admin): authz-pilot — server-renderad advance-resolution + ActionGate (ADR-0001) #128
+6
-12
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { ActionGate } from "@siax/ui";
|
||||
import { NEXT_STATUS } from "@/lib/order-flow";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useT } from "@/components/locale-provider";
|
||||
@@ -25,13 +28,6 @@ interface Metrics {
|
||||
pipeline_ore: number;
|
||||
}
|
||||
|
||||
const NEXT_STATUS: Record<string, string> = {
|
||||
draft: "quoted",
|
||||
quoted: "in_production",
|
||||
in_production: "review",
|
||||
review: "accepted",
|
||||
accepted: "delivered",
|
||||
};
|
||||
|
||||
export default function AdminPage() {
|
||||
const t = useT();
|
||||
@@ -112,16 +108,14 @@ export default function AdminPage() {
|
||||
<StatusPill status={o.status} />
|
||||
<span className="text-right font-mono text-[12px] tabular-nums">{formatSEK(o.total_ore / 100)}</span>
|
||||
<span className="text-right">
|
||||
{NEXT_STATUS[o.status] ? (
|
||||
<ActionGate action={o.advance_action} className="inline-block text-right">
|
||||
<button
|
||||
onClick={() => advance(o.order_id, o.status)}
|
||||
className="border border-foreground px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.08em] transition-colors hover:bg-foreground hover:text-background"
|
||||
>
|
||||
→ {NEXT_STATUS[o.status]}
|
||||
→ {NEXT_STATUS[o.status] ?? t({ en: "done", sv: "klar" })}
|
||||
</button>
|
||||
) : (
|
||||
<span className="font-mono text-[10px] uppercase tracking-[0.08em] text-muted-foreground">{t({ en: "done", sv: "klar" })}</span>
|
||||
)}
|
||||
</ActionGate>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { createOrder, listOwnedOrders, IdempotencyKeyConflictError, type OrderConfig } from "@/lib/orders-store";
|
||||
import { requireOrderOwner } from "@/src/auth/require-order-owner";
|
||||
import { resolveAdvanceAction } from "@/lib/order-flow";
|
||||
import { isSiaxError, siaxErrorResponseForLegacy } from "../siax-error-mapping";
|
||||
|
||||
// POST /api/orders (docs/04) — create a draft order from a configurator config.
|
||||
@@ -56,7 +57,10 @@ export async function GET(req: NextRequest) {
|
||||
if (auth.response) return auth.response;
|
||||
try {
|
||||
const orders = await listOwnedOrders(auth.owner.tenantId);
|
||||
return NextResponse.json(orders);
|
||||
// ADR-0001: servern resolvér advance-aktionen per order — klienten
|
||||
// renderar bara (ActionGate). Policy ägs här, inte i UI-lagret.
|
||||
const withActions = orders.map((o) => ({ ...o, advance_action: resolveAdvanceAction(o.status) }));
|
||||
return NextResponse.json(withActions);
|
||||
} catch (err: unknown) {
|
||||
if (isSiaxError(err)) return siaxErrorResponseForLegacy(err);
|
||||
throw err;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# ADR-0001 — Server-renderad authz-resolution för orderåtgärder
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-09-29
|
||||
**Owners:** 0pt (simon@siax.io)
|
||||
**Related requirements:** DoD §21 (backend enforcement), §78 (error/why-UX), våg 3.6–3.7 (authz-ux/entitlement)
|
||||
**Related capabilities:** `@siax/contracts/authz-ux` 0.10.0, `@siax/ui` ActionGate 0.11.1
|
||||
|
||||
## Context
|
||||
Admin-ordkön behöver gate:a advance-aktionen. 0pt saknar klientsides-policy;
|
||||
`requireOrderOwner` är server-side. Att resolvé:a i klienten vore påhittad
|
||||
policy (DoD §15).
|
||||
|
||||
## Existing implementations
|
||||
- PATCH `/api/orders/{id}` enforcar ägarskap server-side (sanningsvärdet).
|
||||
- Admin-sidan hämtar ordrar via GET `/api/orders` (owner-scopad).
|
||||
|
||||
## Decision
|
||||
**Server-renderad resolution:** GET `/api/orders` returnerar per order ett
|
||||
frozen `advance_action` (ResolvedAction från `resolveAction`), derivat i
|
||||
`lib/order-flow.ts` ur kanoniskt flöde + sessionens ägarskap. Klienten
|
||||
renderar via `ActionGate` — ingen policylogik i UI-lagret. Ingen extra
|
||||
fetch: resolutionen rids med ordningssvaret.
|
||||
|
||||
## Ownership
|
||||
0pt äger flödet och resolutionen; DES1GN äger kontraktet (`authz-ux`) och
|
||||
renderingen (ActionGate). Product Firewall: 0pt importerar @siax/*, aldrig
|
||||
tvärtom.
|
||||
|
||||
## Contract
|
||||
`ResolvedAction {state, reason?}` — stängda uppsättningar enligt authz-ux.
|
||||
|
||||
## Provider boundary
|
||||
Framtida pay0-entitlement projiceras via `deriveEntitlement` in i samma
|
||||
resolve — ingen ny endpoint behövs.
|
||||
|
||||
## Dependency rules
|
||||
`@siax/ui`/`@siax/contracts` → 0pt. Aldrig reverse.
|
||||
|
||||
## Migration
|
||||
Enda konsument: admin-sidan (denna PR). Övriga ytor migreras per aktion.
|
||||
|
||||
## Alternatives considered
|
||||
- **Klientsides-resolution mot ny endpoint:** extra roundtrip, policy-läckage
|
||||
risk, mer kod — avvisad.
|
||||
- **Hårdkodad disabled i UI:** duplicerar serverns sanningsvärde — avvisad
|
||||
(DoD §21: backend är enforcement, UI är presentation).
|
||||
|
||||
## Risks
|
||||
Flödesändringar måste skriva i `lib/order-flow.ts` (enda källan) —
|
||||
lint-/test-täckning finns (tests/order-flow.test.ts).
|
||||
|
||||
## Testing / Evidence
|
||||
- `tests/order-flow.test.ts`: available för aktiva steg, read_only för
|
||||
terminala (incl. okända statusar i fri-text-DB) — 3/3.
|
||||
- Full svit 1138+3 godkända; typecheck grön.
|
||||
|
||||
## Rollback
|
||||
Ta bort `advance_action`-fältet + ActionGate-wrappen — klienten faller
|
||||
tillbaka till tidigare rendering (flödet oförändrat).
|
||||
@@ -0,0 +1,23 @@
|
||||
import { resolveAction, type ResolvedAction } from "@siax/contracts/authz-ux";
|
||||
|
||||
/** Kanonisk orderflöde — servern äger resolutionen (ADR-0001), UI renderar. */
|
||||
export const NEXT_STATUS: Record<string, string> = {
|
||||
draft: "quoted",
|
||||
quoted: "in_production",
|
||||
in_production: "review",
|
||||
review: "accepted",
|
||||
accepted: "delivered",
|
||||
};
|
||||
|
||||
/**
|
||||
* Server-renderad action-resolution för advance-steget (ADR-0001):
|
||||
* ägarskapenforces redan av requireOrderOwner på GET/PATCH; tillståndet
|
||||
* avgör read_only. Innan Session/roller finns i 0pt är permission alltid
|
||||
* granted för autentiserade ägare — hårdkoda aldrig denna resolve i klienten.
|
||||
*/
|
||||
export function resolveAdvanceAction(status: string): ResolvedAction {
|
||||
if (NEXT_STATUS[status]) {
|
||||
return resolveAction({ permission: "granted", entitlement: "active", approval: "not_required", readOnly: false });
|
||||
}
|
||||
return resolveAction({ permission: "granted", entitlement: "active", approval: "not_required", readOnly: true });
|
||||
}
|
||||
@@ -110,6 +110,9 @@ const securityHeaders = [
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
// @siax/*-paket exporterar rå src/ (TS) enligt DES1GN-konventionen —
|
||||
// Turbopack måste transpilera dem (ActionGate-piloten, ADR-0001).
|
||||
transpilePackages: ["@siax/ui", "@siax/contracts", "@siax/config", "@siax/tokens"],
|
||||
// Självhostad drift i SIAX Cloud (Coolify + Traefik) — ersätter Vercel.
|
||||
//
|
||||
// "standalone" får `next build` att packa en fristående Node-server i
|
||||
|
||||
+2
-1
@@ -78,7 +78,8 @@
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "3.25.76"
|
||||
"zod": "3.25.76",
|
||||
"@siax/contracts": "^0.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.15.0",
|
||||
|
||||
Generated
+3
@@ -104,6 +104,9 @@ importers:
|
||||
'@siax/config':
|
||||
specifier: ^0.2.1
|
||||
version: 0.2.1(tailwindcss@4.3.3)
|
||||
'@siax/contracts':
|
||||
specifier: ^0.10.0
|
||||
version: 0.10.0
|
||||
'@siax/tokens':
|
||||
specifier: ^0.1.2
|
||||
version: 0.1.2
|
||||
|
||||
@@ -113,10 +113,15 @@ export class GraphMailboxAdapter {
|
||||
return this.cachedToken.token;
|
||||
}
|
||||
|
||||
// Graph-API:t är genuint ostrukturerat JSON (value-array med fri form);
|
||||
// full typning av hela Graph-ytan är egen insats — any här är medveten
|
||||
// och avgränsad till denna adapter (eslint-undantag med skäl, DoD §13).
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
private async get(path: string): Promise<any> {
|
||||
const token = await this.getToken();
|
||||
const url = path.startsWith("http") ? path : `${GRAPH_BASE}${path}`;
|
||||
const res = await fetch(url, { headers: { authorization: `Bearer ${token}` } });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Graph-JSON är genuint ostrukturerat; full Graph-typning är egen insats
|
||||
const json: any = await res.json();
|
||||
if (!res.ok) {
|
||||
throw new Error(`Graph GET ${path} -> ${json.error?.code || res.status}: ${json.error?.message || ""}`);
|
||||
@@ -150,7 +155,7 @@ export class GraphMailboxAdapter {
|
||||
receivedAt: m.receivedDateTime || null,
|
||||
senderName: m.from?.emailAddress?.name || null,
|
||||
senderEmail: m.from?.emailAddress?.address || null,
|
||||
toRecipients: (m.toRecipients || []).map((r: any) => r.emailAddress?.address).filter(Boolean),
|
||||
toRecipients: (m.toRecipients || []).map((r: { emailAddress?: { address?: string } }) => r.emailAddress?.address).filter(Boolean),
|
||||
subject: m.subject || null,
|
||||
bodyPreview: m.bodyPreview || null,
|
||||
bodyText: m.body?.content ? stripHtml(m.body.content) : m.bodyPreview || null,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { NEXT_STATUS, resolveAdvanceAction } from "@/lib/order-flow";
|
||||
|
||||
describe("order-flow — server-renderad advance-resolution (ADR-0001)", () => {
|
||||
it("flödets fem steg har nästa status", () => {
|
||||
expect(NEXT_STATUS.draft).toBe("quoted");
|
||||
expect(NEXT_STATUS.accepted).toBe("delivered");
|
||||
});
|
||||
|
||||
it("aktivt steg → available", () => {
|
||||
expect(resolveAdvanceAction("draft")).toMatchObject({ state: "available" });
|
||||
expect(resolveAdvanceAction("review")).toMatchObject({ state: "available" });
|
||||
});
|
||||
|
||||
it("terminalt steg → read_only/read_only_resource (inte fake available)", () => {
|
||||
expect(resolveAdvanceAction("delivered")).toMatchObject({ state: "read_only", reason: "read_only_resource" });
|
||||
expect(resolveAdvanceAction("cancelled")).toMatchObject({ state: "read_only", reason: "read_only_resource" });
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,7 @@ describe("DocumentCaptureService.captureFromMailbox", () => {
|
||||
it("downloads PDF attachments, persists bytes, registers a ST0RE artifact, enqueues an N0D extraction job, and emits AUD0 events", async () => {
|
||||
const pdfBytes = Buffer.from("%PDF-1.4 real bytes for test");
|
||||
const pdfBase64 = pdfBytes.toString("base64");
|
||||
const aud0Calls: { url: string; body: any }[] = [];
|
||||
const aud0Calls: { url: string; body: Record<string, unknown> }[] = [];
|
||||
|
||||
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (url.includes("login.microsoftonline.com")) return jsonResponse({ access_token: "tok", expires_in: 3600 });
|
||||
@@ -93,13 +93,13 @@ describe("DocumentCaptureService.captureFromMailbox", () => {
|
||||
const storedBytes = await service.getDocumentBytes(doc.document_id);
|
||||
expect(storedBytes.equals(pdfBytes)).toBe(true);
|
||||
|
||||
const captured = aud0Calls.find((c) => c.body?.event_type === "0pt.document_captured");
|
||||
const captured = aud0Calls.find((c) => c.body?.event_type === "0pt.document_captured") as { url: string; body: { event_type: string; capability_id: string; payload: { filename: string } } } | undefined;
|
||||
expect(captured).toBeTruthy();
|
||||
expect(captured!.body.payload.filename).toBe("doc.pdf");
|
||||
expect(captured!.body.capability_id).toBe("0pt.capture_document");
|
||||
// Lineage now goes to AUD0's REAL ingest contract: an OpenLineage
|
||||
// RunEvent on /v1/aud0/openlineage.
|
||||
const lineage = aud0Calls.find((c) => c.url.includes("openlineage"));
|
||||
const lineage = aud0Calls.find((c) => c.url.includes("openlineage")) as { url: string; body: { outputs: { name: string }[] } } | undefined;
|
||||
expect(lineage).toBeTruthy();
|
||||
expect(lineage!.body.outputs[0].name).toContain(doc.document_id);
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ function jsonResponse(body: unknown, opts: { status?: number; headers?: Record<s
|
||||
|
||||
describe("DocumentExtractionService — evidence-based extraction", () => {
|
||||
let tmpDir: string;
|
||||
let aud0Calls: { url: string; body: any }[];
|
||||
let aud0Calls: { url: string; body: Record<string, unknown> }[];
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "0pt-blob-"));
|
||||
@@ -133,7 +133,7 @@ describe("DocumentExtractionService — evidence-based extraction", () => {
|
||||
const updatedDoc = await captureService.getDocument(document.document_id);
|
||||
expect(updatedDoc.status).toBe("extracted");
|
||||
|
||||
const extractedEvent = aud0Calls.find((c) => c.body?.event_type === "0pt.document_extracted");
|
||||
const extractedEvent = aud0Calls.find((c) => c.body?.event_type === "0pt.document_extracted") as { url: string; body: { event_type: string; capability_id: string; payload: { fields_found_count: number; fields_total_count: number } } } | undefined;
|
||||
expect(extractedEvent).toBeTruthy();
|
||||
expect(extractedEvent!.body.capability_id).toBe("0pt.extract_document");
|
||||
expect(extractedEvent!.body.payload.fields_found_count).toBe(2);
|
||||
|
||||
@@ -2,6 +2,7 @@ import {describe,it,expect} from "vitest";
|
||||
|
||||
describe("0pt Enterprise Data OS Expansion (PROMPT 2)",()=>{
|
||||
it("iceberg_table schema has required fields",()=>{
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- fixture-laddning via require
|
||||
const schema=require("../../packages/schemas/0pt.iceberg_table.schema.json");
|
||||
expect(schema.required).toContain("table_id");
|
||||
expect(schema.required).toContain("table_name");
|
||||
@@ -10,6 +11,7 @@ describe("0pt Enterprise Data OS Expansion (PROMPT 2)",()=>{
|
||||
});
|
||||
|
||||
it("signal schema has entity and strength",()=>{
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- fixture-laddning via require
|
||||
const schema=require("../../packages/schemas/0pt.signal.schema.json");
|
||||
expect(schema.required).toContain("signal_id");
|
||||
expect(schema.required).toContain("signal_type");
|
||||
@@ -18,6 +20,7 @@ describe("0pt Enterprise Data OS Expansion (PROMPT 2)",()=>{
|
||||
});
|
||||
|
||||
it("lineage_event schema has input/output assets",()=>{
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- fixture-laddning via require
|
||||
const schema=require("../../packages/schemas/0pt.lineage_event.schema.json");
|
||||
expect(schema.required).toContain("input_assets");
|
||||
expect(schema.required).toContain("output_assets");
|
||||
@@ -32,6 +35,7 @@ describe("0pt Enterprise Data OS Expansion (PROMPT 2)",()=>{
|
||||
// predated that spec. See src/types/source-rate-policy.ts and
|
||||
// src/services/source-rate-policy-service.ts for the real, tested implementation.
|
||||
it("source_rate_policy schema has rate limits",()=>{
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- fixture-laddning via require
|
||||
const schema=require("../../packages/schemas/0pt.source_rate_policy.schema.json");
|
||||
expect(schema.required).toContain("rate_limit_per_minute");
|
||||
expect(schema.required).toContain("crawl_budget");
|
||||
|
||||
@@ -59,6 +59,7 @@ function baseRequest(overrides: Partial<ExternalWriteRequest> = {}): ExternalWri
|
||||
/** Stubs global fetch to answer CL0UD policy evaluation (always "allow" unless `denyOrApproval`
|
||||
* is set) and swallow AUD0 event/lineage POSTs, recording them for assertions. */
|
||||
function stubCl0udAllowAndAud0(opts: { decision?: "allow" | "deny" | "approval_required" } = {}) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- AUD0-ingest-body är fri nyttolast
|
||||
const aud0Calls: { url: string; body: any }[] = [];
|
||||
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
// REAL CL0UD wire contract (cl0ud main): POST /api/policy/evaluate with a
|
||||
|
||||
@@ -257,6 +257,7 @@ describe("OdooBillsAdapter — partner resolution wired into write()", () => {
|
||||
|
||||
it("calls the injected matchPartner when partner_id is absent but vendor_org_nr is present, and forwards the resolved partner_id to Odoo", async () => {
|
||||
const matchPartner = vi.fn(async () => ({ partner_id: 99, partner_name: "Acme AB" }));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- AUD0-ingest-body är fri nyttolast
|
||||
let capturedBody: any;
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
capturedBody = JSON.parse(init!.body as string);
|
||||
@@ -278,6 +279,7 @@ describe("OdooBillsAdapter — partner resolution wired into write()", () => {
|
||||
|
||||
it("posts without a partner_id when no match is found — 'skickar utan partner-koppling'", async () => {
|
||||
const matchPartner = vi.fn(async () => ({}));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- AUD0-ingest-body är fri nyttolast
|
||||
let capturedBody: any;
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
capturedBody = JSON.parse(init!.body as string);
|
||||
|
||||
Reference in New Issue
Block a user