Files
cfw-autumn/apps/scope-picker/vite-scope-picker-plugin.ts
2026-04-24 20:02:47 +01:00

101 lines
3.1 KiB
TypeScript

import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { Plugin } from "vite";
const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
/**
* Dev-only Vite plugin that exposes two endpoints used by the Scope Picker
* dashboard at /scope-picker in the UI:
*
* GET /api/scope-picker/decisions → reads scope-decisions.json from the repo root
* POST /api/scope-picker/decisions → overwrites scope-decisions.json
*
* The JSON file is intentionally stored in the repo root (one level above the
* vite/ directory) so it survives across tooling, can be git-tracked, and is
* the authoritative source of truth for the subsequent per-route migration.
*
* Payload shape is a map keyed by `${method}|${path}|${handlerName}`:
* {
* [key]: {
* decision: "read" | "write" | "skip" | "unknown";
* scopes: string[];
* resource: string | null;
* shape: "array" | "any" | "all" | "any-and-all";
* note?: string;
* decidedAt: string; // ISO timestamp
* }
* }
*/
export function scopePickerPlugin(): Plugin {
// vite/ → sirtenzin-autumn/ (monorepo root)
const DECISIONS_PATH = resolve(PLUGIN_DIR, "..", "scope-decisions.json");
const readDecisions = (): Record<string, unknown> => {
if (!existsSync(DECISIONS_PATH)) return {};
try {
return JSON.parse(readFileSync(DECISIONS_PATH, "utf8"));
} catch (err) {
console.error("[scope-picker] failed to read decisions file:", err);
return {};
}
};
const writeDecisions = (data: Record<string, unknown>): void => {
writeFileSync(DECISIONS_PATH, `${JSON.stringify(data, null, 2)}\n`);
};
return {
name: "scope-picker",
apply: "serve",
configureServer(server) {
server.middlewares.use("/api/scope-picker/decisions", async (req, res) => {
res.setHeader("Content-Type", "application/json");
try {
if (req.method === "GET") {
const data = readDecisions();
res.statusCode = 200;
res.end(JSON.stringify({ decisions: data, path: DECISIONS_PATH }));
return;
}
if (req.method === "POST" || req.method === "PUT") {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.from(chunk));
const bodyText = Buffer.concat(chunks).toString("utf8");
const body = bodyText ? JSON.parse(bodyText) : {};
const decisions = body?.decisions ?? {};
if (typeof decisions !== "object") {
res.statusCode = 400;
res.end(JSON.stringify({ error: "decisions must be an object" }));
return;
}
writeDecisions(decisions);
res.statusCode = 200;
res.end(
JSON.stringify({
ok: true,
count: Object.keys(decisions).length,
path: DECISIONS_PATH,
}),
);
return;
}
res.statusCode = 405;
res.end(JSON.stringify({ error: `method ${req.method} not allowed` }));
} catch (err) {
res.statusCode = 500;
res.end(
JSON.stringify({
error: err instanceof Error ? err.message : String(err),
}),
);
}
});
},
};
}