Mount plugin routes via the router (todo §2); match method+path under /<id>, resolve :params, permission gate, RouteResult→response
This commit is contained in:
@@ -251,8 +251,10 @@ and reproducible; mount a volume only to add plugins to an already-built image.
|
|||||||
|
|
||||||
> Discovery — scanning `plugins/`, importing each `plugin.ts` default export, and validating
|
> Discovery — scanning `plugins/`, importing each `plugin.ts` default export, and validating
|
||||||
> it (id, `apiVersion`, conflicts) — runs at boot (`src/discovery.ts`); a bad plugin stops
|
> it (id, `apiVersion`, conflicts) — runs at boot (`src/discovery.ts`); a bad plugin stops
|
||||||
> startup with a precise message. _(Planned, §2:)_ the router that mounts the discovered routes
|
> startup with a precise message. The router (`src/router.ts`) then mounts each route at `/<id>`,
|
||||||
> is next. The mount mechanics above are how the files get into the container either way.
|
> resolves `:name` params, runs the permission gate, and turns the handler's `RouteResult` into
|
||||||
|
> the response. _(Planned, §2:)_ the per-plugin view resolver + static serving are next. The
|
||||||
|
> mount mechanics above are how the files get into the container either way.
|
||||||
|
|
||||||
## The menu system _(planned)_
|
## The menu system _(planned)_
|
||||||
|
|
||||||
@@ -430,8 +432,9 @@ src/icons.ts Used-icon registry + sprite builder from lucide-static (reg
|
|||||||
src/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize }
|
src/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize }
|
||||||
src/nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model
|
src/nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model
|
||||||
src/paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
|
src/paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
|
||||||
src/plugin.ts Plugin contract: manifest types, definePlugin(), version + conflict rules (router planned, §2)
|
src/plugin.ts Plugin contract: manifest types, definePlugin(), version + conflict rules + fullPath()
|
||||||
src/discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot (§2)
|
src/discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot (§2)
|
||||||
|
src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2)
|
||||||
views/ Core EJS templates (index = the app-shell People dashboard, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, menu/popover, theme switch, icon sprite)
|
views/ Core EJS templates (index = the app-shell People dashboard, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, menu/popover, theme switch, icon sprite)
|
||||||
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
|
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
|
||||||
config/menu.ts Central menu override + branding (planned)
|
config/menu.ts Central menu override + branding (planned)
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ manifest, a version mismatch, or a conflict stops startup with a clear message.
|
|||||||
crash-isolation (one bad plugin can't take the host down) is a *non-goal* — diagnose at deploy
|
crash-isolation (one bad plugin can't take the host down) is a *non-goal* — diagnose at deploy
|
||||||
time, not in production.
|
time, not in production.
|
||||||
|
|
||||||
> **Status.** This is the contract the §2 host implements. The types and the pure rules
|
> **Status.** This is the contract the §2 host implements. The types and pure rules
|
||||||
> (`checkApiVersion`, `findConflicts`, `isValidPluginId`) exist today in `src/plugin.ts`;
|
> (`checkApiVersion`, `findConflicts`, `isValidPluginId`) live in `src/plugin.ts`; **discovery**
|
||||||
> discovery, the router, the per-plugin view resolver, and static serving are the next §2 items
|
> (`src/discovery.ts`) and the **router** (`src/router.ts` — method+path match, `:name` params,
|
||||||
> and wire this contract to the filesystem and HTTP. Behaviour described as the host's is the
|
> permission gate, `RouteResult` → response) are wired. The **per-plugin view resolver** (core
|
||||||
> target those items meet.
|
> partials in plugin views) and **static serving** are the next §2 items.
|
||||||
|
|
||||||
## Anatomy of a plugin
|
## Anatomy of a plugin
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { cpSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { after, before, test } from "node:test";
|
import { after, before, test, type TestContext } from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as ejs from "ejs";
|
import * as ejs from "ejs";
|
||||||
import { createApp } from "./app.ts";
|
import { createApp } from "./app.ts";
|
||||||
|
import type { Plugin } from "./plugin.ts";
|
||||||
import { contentTypeFor, resolveStaticPath } from "./static.ts";
|
import { contentTypeFor, resolveStaticPath } from "./static.ts";
|
||||||
|
|
||||||
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "views");
|
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "views");
|
||||||
@@ -97,6 +98,61 @@ test("renders the 403 error page as HTML", async () => {
|
|||||||
assert.match(html, /styles\.css/);
|
assert.match(html, /styles\.css/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A test plugin exercising each RouteResult shape, a path param, and the permission gate.
|
||||||
|
const demoPlugin: Plugin = {
|
||||||
|
apiVersion: "1.0.0",
|
||||||
|
id: "demo",
|
||||||
|
routes: [
|
||||||
|
{ handler: (ctx) => ({ html: `<p>Hi ${ctx.params.name}</p>` }), method: "GET", path: "/hello/:name" },
|
||||||
|
{ handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" },
|
||||||
|
{ handler: () => ({ redirect: "/demo/hello/world" }), method: "POST", path: "/go" },
|
||||||
|
{ handler: () => ({ html: "secret" }), method: "GET", path: "/secret", permission: "demo:read" },
|
||||||
|
{ handler: () => ({ data: { who: "Plainpages" }, view: "page" }), method: "GET", path: "/page" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function startApp(t: TestContext, plugins: Plugin[], pluginsDir?: string): Promise<string> {
|
||||||
|
const app = createApp(pluginsDir ? { plugins, pluginsDir } : { plugins });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
return `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("mounts plugin routes: params, html/json/redirect/view results, and the permission gate", async (t) => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
|
||||||
|
mkdirSync(join(dir, "demo", "views"), { recursive: true });
|
||||||
|
writeFileSync(join(dir, "demo", "views", "page.ejs"), "<h1>Hello <%= who %></h1>");
|
||||||
|
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||||
|
const url = await startApp(t, [demoPlugin], dir);
|
||||||
|
|
||||||
|
// Path param + html
|
||||||
|
const hi = await fetch(url + "/demo/hello/world");
|
||||||
|
assert.equal(hi.status, 200);
|
||||||
|
assert.match(await hi.text(), /Hi world/);
|
||||||
|
|
||||||
|
// json
|
||||||
|
const data = await fetch(url + "/demo/data");
|
||||||
|
assert.match(data.headers.get("content-type") ?? "", /application\/json/);
|
||||||
|
assert.deepEqual(await data.json(), { ok: true });
|
||||||
|
|
||||||
|
// redirect (POST → 303 Location)
|
||||||
|
const go = await fetch(url + "/demo/go", { method: "POST", redirect: "manual" });
|
||||||
|
assert.equal(go.status, 303);
|
||||||
|
assert.equal(go.headers.get("location"), "/demo/hello/world");
|
||||||
|
|
||||||
|
// view rendered from the plugin's own views/
|
||||||
|
assert.match(await (await fetch(url + "/demo/page")).text(), /Hello Plainpages/);
|
||||||
|
|
||||||
|
// gated route with no session → 403
|
||||||
|
assert.equal((await fetch(url + "/demo/secret")).status, 403);
|
||||||
|
|
||||||
|
// known path + wrong method → 405 with Allow; unknown path → 404
|
||||||
|
const wrong = await fetch(url + "/demo/data", { method: "DELETE" });
|
||||||
|
assert.equal(wrong.status, 405);
|
||||||
|
assert.match(wrong.headers.get("allow") ?? "", /GET/);
|
||||||
|
assert.equal((await fetch(url + "/demo/nope")).status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
test("rejects unsafe static request paths (encoded traversal, NUL) with 403", async () => {
|
test("rejects unsafe static request paths (encoded traversal, NUL) with 403", async () => {
|
||||||
assert.equal((await fetch(base + "/public/..%2f..%2fapp.ts")).status, 403);
|
assert.equal((await fetch(base + "/public/..%2f..%2fapp.ts")).status, 403);
|
||||||
assert.equal((await fetch(base + "/public/%00")).status, 403);
|
assert.equal((await fetch(base + "/public/%00")).status, 403);
|
||||||
|
|||||||
67
src/app.ts
67
src/app.ts
@@ -4,6 +4,9 @@ import { fileURLToPath } from "node:url";
|
|||||||
import * as ejs from "ejs";
|
import * as ejs from "ejs";
|
||||||
import { buildContext } from "./context.ts";
|
import { buildContext } from "./context.ts";
|
||||||
import { buildDashboardModel } from "./dashboard.ts";
|
import { buildDashboardModel } from "./dashboard.ts";
|
||||||
|
import { PLUGINS_DIR } from "./discovery.ts";
|
||||||
|
import type { Plugin, RouteResult } from "./plugin.ts";
|
||||||
|
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
|
||||||
import { serveStatic } from "./static.ts";
|
import { serveStatic } from "./static.ts";
|
||||||
|
|
||||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..");
|
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
@@ -12,18 +15,27 @@ export interface AppOptions {
|
|||||||
// Cache compiled templates; caller decides (server passes config.cacheTemplates).
|
// Cache compiled templates; caller decides (server passes config.cacheTemplates).
|
||||||
// Off by default so edits show live; the app itself never inspects the environment.
|
// Off by default so edits show live; the app itself never inspects the environment.
|
||||||
cache?: boolean;
|
cache?: boolean;
|
||||||
|
plugins?: Plugin[]; // discovered manifests to mount (router); empty until §2 discovery runs
|
||||||
|
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
|
||||||
publicDir?: string;
|
publicDir?: string;
|
||||||
viewsDir?: string;
|
viewsDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createApp(options: AppOptions = {}): Server {
|
export function createApp(options: AppOptions = {}): Server {
|
||||||
const cache = options.cache ?? false;
|
const cache = options.cache ?? false;
|
||||||
|
const plugins = options.plugins ?? [];
|
||||||
|
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
||||||
const publicDir = options.publicDir ?? join(rootDir, "public");
|
const publicDir = options.publicDir ?? join(rootDir, "public");
|
||||||
const viewsDir = options.viewsDir ?? join(rootDir, "views");
|
const viewsDir = options.viewsDir ?? join(rootDir, "views");
|
||||||
|
|
||||||
const render = (view: string, data: Record<string, unknown>): Promise<string> =>
|
const render = (view: string, data: Record<string, unknown>): Promise<string> =>
|
||||||
ejs.renderFile(join(viewsDir, `${view}.ejs`), data, { cache });
|
ejs.renderFile(join(viewsDir, `${view}.ejs`), data, { cache });
|
||||||
|
|
||||||
|
// A `view` RouteResult resolves against the plugin's own views/ (the richer per-plugin
|
||||||
|
// resolver — core-partial includes, subfolders — is the next §2 item).
|
||||||
|
const renderPluginView = (plugin: Plugin) => (view: string, data: Record<string, unknown>): Promise<string> =>
|
||||||
|
ejs.renderFile(join(pluginsDir, plugin.id, "views", `${view}.ejs`), data, { cache });
|
||||||
|
|
||||||
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
|
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
|
||||||
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
||||||
res.end(html);
|
res.end(html);
|
||||||
@@ -31,27 +43,40 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
|
|
||||||
return createServer(async (req, res) => {
|
return createServer(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
const method = req.method ?? "GET";
|
||||||
res.writeHead(405, { "content-type": "text/plain; charset=utf-8" }).end("Method Not Allowed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The request shape handlers receive (§2/§4 router passes it on); routing
|
|
||||||
// reuses its parsed URL instead of building a throwaway.
|
|
||||||
const { url } = buildContext(req, res);
|
const { url } = buildContext(req, res);
|
||||||
const pathname = url.pathname;
|
const pathname = url.pathname;
|
||||||
|
|
||||||
if (pathname.startsWith("/public/")) {
|
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
|
||||||
await serveStatic(publicDir, pathname.slice("/public/".length), res, req.method === "HEAD");
|
await serveStatic(publicDir, pathname.slice("/public/".length), res, method === "HEAD");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname === "/") {
|
// Plugin routes (any method): gate on the route's permission, then run the handler.
|
||||||
// Mock data + no roles until the plugin host (§2) and auth (§4) land.
|
const match = matchRoute(plugins, method, pathname);
|
||||||
|
if (match) {
|
||||||
|
const ctx = buildContext(req, res, { params: match.params });
|
||||||
|
if (!isAuthorized(match.route, ctx.roles)) {
|
||||||
|
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = await match.route.handler(ctx);
|
||||||
|
await sendResult(res, result ?? null, renderPluginView(match.plugin));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === "/" && (method === "GET" || method === "HEAD")) {
|
||||||
|
// Mock data + no roles until auth (§4) lands.
|
||||||
sendHtml(res, 200, await render("index", { model: buildDashboardModel(url) }));
|
sendHtml(res, 200, await render("index", { model: buildDashboardModel(url) }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Known path, wrong method → 405 with Allow; otherwise nothing here → 404.
|
||||||
|
const allow = allowedMethods(plugins, pathname);
|
||||||
|
if (allow.length) {
|
||||||
|
res.writeHead(405, { allow: allow.join(", "), "content-type": "text/plain; charset=utf-8" }).end("Method Not Allowed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
sendHtml(res, 404, await render("404", { title: "Not found" }));
|
sendHtml(res, 404, await render("404", { title: "Not found" }));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -67,3 +92,23 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ViewRenderer = (view: string, data: Record<string, unknown>) => Promise<string>;
|
||||||
|
|
||||||
|
// Turn a handler's RouteResult into the HTTP response. `null` = the handler took over `ctx.res`
|
||||||
|
// itself (the void escape hatch). Author `headers` override the content-type default.
|
||||||
|
async function sendResult(res: ServerResponse, result: RouteResult | null, renderView: ViewRenderer): Promise<void> {
|
||||||
|
if (result == null || res.writableEnded) return;
|
||||||
|
if ("redirect" in result) {
|
||||||
|
res.writeHead(result.status ?? 303, { location: result.redirect }).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ("json" in result) {
|
||||||
|
res.writeHead(result.status ?? 200, { "content-type": "application/json; charset=utf-8", ...result.headers });
|
||||||
|
res.end(JSON.stringify(result.json));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = "html" in result ? result.html : await renderView(result.view, result.data ?? {});
|
||||||
|
res.writeHead(result.status ?? 200, { "content-type": "text/html; charset=utf-8", ...result.headers });
|
||||||
|
res.end(body); // Node suppresses the body for HEAD automatically
|
||||||
|
}
|
||||||
|
|||||||
@@ -183,8 +183,9 @@ function collectNavIds(nodes: NavNode[] | undefined, push: (id: string) => void)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A route's full path = the plugin's mount path `/<id>` + the route path.
|
// A route's full path = the plugin's mount path `/<id>` + the route path. The single source of
|
||||||
function fullPath(id: string, path: string): string {
|
// truth for both conflict detection (here) and the §2 router, so they can't disagree.
|
||||||
|
export function fullPath(id: string, path: string): string {
|
||||||
return `/${id}${path.startsWith("/") ? path : `/${path}`}`;
|
return `/${id}${path.startsWith("/") ? path : `/${path}`}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
65
src/router.test.ts
Normal file
65
src/router.test.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import type { Plugin, Route } from "./plugin.ts";
|
||||||
|
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
|
||||||
|
|
||||||
|
const noop: Route["handler"] = () => ({ html: "x" });
|
||||||
|
|
||||||
|
// Minimal discovered Plugin — only id + routes matter to the router.
|
||||||
|
function plugin(id: string, routes: Route[]): Plugin {
|
||||||
|
return { apiVersion: "1.0.0", id, routes };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("matchRoute matches method + full path under /<id>, resolves params, HEAD falls back to GET", () => {
|
||||||
|
const plugins = [
|
||||||
|
plugin("scheduling", [
|
||||||
|
{ handler: noop, method: "GET", path: "/shifts" },
|
||||||
|
{ handler: noop, method: "GET", path: "/shifts/:id" },
|
||||||
|
{ handler: noop, method: "POST", path: "/shifts" },
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
assert.equal(matchRoute(plugins, "GET", "/scheduling/shifts")?.route.method, "GET");
|
||||||
|
assert.deepEqual(matchRoute(plugins, "GET", "/scheduling/shifts/42")?.params, { id: "42" });
|
||||||
|
assert.equal(matchRoute(plugins, "POST", "/scheduling/shifts")?.route.method, "POST");
|
||||||
|
// HEAD is answered by the GET route; PUT (no route) and an unknown path miss.
|
||||||
|
assert.equal(matchRoute(plugins, "HEAD", "/scheduling/shifts")?.route.method, "GET");
|
||||||
|
assert.equal(matchRoute(plugins, "PUT", "/scheduling/shifts"), null);
|
||||||
|
assert.equal(matchRoute(plugins, "GET", "/scheduling/missing"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matchRoute decodes percent-encoded params and rejects malformed encoding", () => {
|
||||||
|
const plugins = [plugin("users", [{ handler: noop, method: "GET", path: "/:id" }])];
|
||||||
|
assert.deepEqual(matchRoute(plugins, "GET", "/users/john%40doe")?.params, { id: "john@doe" });
|
||||||
|
assert.equal(matchRoute(plugins, "GET", "/users/%ZZ"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("matchRoute prefers the most specific (fewest-param) pattern over a param catch-all", () => {
|
||||||
|
const plugins = [
|
||||||
|
plugin("users", [
|
||||||
|
{ handler: noop, method: "GET", path: "/:id" }, // declared first, still loses to the literal
|
||||||
|
{ handler: noop, method: "GET", path: "/new" },
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
assert.equal(matchRoute(plugins, "GET", "/users/new")?.route.path, "/new");
|
||||||
|
assert.equal(matchRoute(plugins, "GET", "/users/123")?.route.path, "/:id");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("allowedMethods lists methods at a path (GET implies HEAD); empty when the path is unknown", () => {
|
||||||
|
const plugins = [
|
||||||
|
plugin("x", [
|
||||||
|
{ handler: noop, method: "GET", path: "/a" },
|
||||||
|
{ handler: noop, method: "POST", path: "/a" },
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
assert.deepEqual(allowedMethods(plugins, "/x/a"), ["GET", "HEAD", "POST"]);
|
||||||
|
assert.deepEqual(allowedMethods(plugins, "/x/missing"), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isAuthorized: open routes pass; gated routes require the role token", () => {
|
||||||
|
const open: Route = { handler: noop, method: "GET", path: "/" };
|
||||||
|
const gated: Route = { handler: noop, method: "GET", path: "/", permission: "x:read" };
|
||||||
|
assert.equal(isAuthorized(open, []), true);
|
||||||
|
assert.equal(isAuthorized(gated, []), false);
|
||||||
|
assert.equal(isAuthorized(gated, ["x:read"]), true);
|
||||||
|
assert.equal(isAuthorized(gated, ["other"]), false);
|
||||||
|
});
|
||||||
83
src/router.ts
Normal file
83
src/router.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
// Router (todo §2): the pure core that maps an incoming method + pathname to a discovered
|
||||||
|
// plugin route. I/O-free — app.ts is the imperative shell that builds the context, runs the
|
||||||
|
// gate, calls the handler, and turns its RouteResult into an HTTP response. A route is mounted
|
||||||
|
// at `/<id>` + its path (fullPath, shared with conflict detection); `:name` segments become
|
||||||
|
// path params. Specificity: a literal segment beats a `:param`, so /users/new wins over
|
||||||
|
// /users/:id regardless of declaration order.
|
||||||
|
|
||||||
|
import { fullPath, type Plugin, type Route } from "./plugin.ts";
|
||||||
|
|
||||||
|
export interface RouteMatch {
|
||||||
|
params: Record<string, string>;
|
||||||
|
plugin: Plugin;
|
||||||
|
route: Route;
|
||||||
|
}
|
||||||
|
|
||||||
|
function segments(path: string): string[] {
|
||||||
|
return path.split("/").filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function paramCount(path: string): number {
|
||||||
|
return segments(path).filter((s) => s.startsWith(":")).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match a concrete pathname's segments against a route pattern's; return the params or null.
|
||||||
|
function matchSegments(pattern: string[], path: string[]): Record<string, string> | null {
|
||||||
|
if (pattern.length !== path.length) return null;
|
||||||
|
const params: Record<string, string> = {};
|
||||||
|
for (let i = 0; i < pattern.length; i++) {
|
||||||
|
const pat = pattern[i] as string;
|
||||||
|
const seg = path[i] as string;
|
||||||
|
if (pat.startsWith(":")) {
|
||||||
|
try {
|
||||||
|
params[pat.slice(1)] = decodeURIComponent(seg);
|
||||||
|
} catch {
|
||||||
|
return null; // malformed %-encoding → no match
|
||||||
|
}
|
||||||
|
} else if (pat !== seg) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every plugin route whose path pattern matches `pathname`, regardless of method, with params.
|
||||||
|
function matchPath(plugins: Plugin[], pathname: string): RouteMatch[] {
|
||||||
|
const path = segments(pathname);
|
||||||
|
const out: RouteMatch[] = [];
|
||||||
|
for (const plugin of plugins) {
|
||||||
|
for (const route of plugin.routes ?? []) {
|
||||||
|
const params = matchSegments(segments(fullPath(plugin.id, route.path)), path);
|
||||||
|
if (params) out.push({ params, plugin, route });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The single route for `method` + `pathname`, or null. A GET route also answers HEAD. Among
|
||||||
|
// matches the most specific (fewest `:param` segments) wins; ties keep discovery order (plugins
|
||||||
|
// sorted by id, routes as declared) — sort is stable.
|
||||||
|
export function matchRoute(plugins: Plugin[], method: string, pathname: string): RouteMatch | null {
|
||||||
|
const wanted = method.toUpperCase();
|
||||||
|
const candidates = matchPath(plugins, pathname).filter(
|
||||||
|
(m) => m.route.method === wanted || (wanted === "HEAD" && m.route.method === "GET"),
|
||||||
|
);
|
||||||
|
candidates.sort((a, b) => paramCount(a.route.path) - paramCount(b.route.path));
|
||||||
|
return candidates[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Methods allowed at `pathname` (for a 405 `Allow` header); empty when no route matches the path.
|
||||||
|
export function allowedMethods(plugins: Plugin[], pathname: string): string[] {
|
||||||
|
const methods = new Set<string>();
|
||||||
|
for (const m of matchPath(plugins, pathname)) {
|
||||||
|
methods.add(m.route.method);
|
||||||
|
if (m.route.method === "GET") methods.add("HEAD");
|
||||||
|
}
|
||||||
|
return [...methods].sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coarse permission gate: a route with no `permission` is open; otherwise the user's roles (from
|
||||||
|
// the session JWT, §4) must include the token. The same rule composeNav uses for the menu.
|
||||||
|
export function isAuthorized(route: Route, roles: string[]): boolean {
|
||||||
|
return route.permission == null || roles.includes(route.permission);
|
||||||
|
}
|
||||||
@@ -4,10 +4,10 @@ import { discoverPlugins } from "./discovery.ts";
|
|||||||
|
|
||||||
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
|
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
|
||||||
|
|
||||||
const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin (router wiring is next §2)
|
const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin
|
||||||
console.log(`Discovered ${plugins.length} plugin(s)${plugins.length ? `: ${plugins.map((p) => p.id).join(", ")}` : ""}`);
|
console.log(`Discovered ${plugins.length} plugin(s)${plugins.length ? `: ${plugins.map((p) => p.id).join(", ")}` : ""}`);
|
||||||
|
|
||||||
const server = createApp({ cache: config.cacheTemplates }).listen(config.port, () => {
|
const server = createApp({ cache: config.cacheTemplates, plugins }).listen(config.port, () => {
|
||||||
console.log(`Listening on http://localhost:${config.port}`);
|
console.log(`Listening on http://localhost:${config.port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
6
todo.md
6
todo.md
@@ -47,8 +47,8 @@ everything via Docker.
|
|||||||
## 2. Plugin host
|
## 2. Plugin host
|
||||||
- [x] **Specify the plugin contract** (big job, do first — it's the product's main API surface). Write it down as the authoritative reference: the full manifest shape; the `RequestContext` handed to handlers and what's guaranteed stable; **contract versioning** (a `apiVersion`/`engines`-style field so a plugin declares the host it targets, and the host refuses or warns on mismatch); **conflict rules** (two plugins claiming the same `basePath`, nav slot, or `permission` name → defined, loud resolution, not last-write-wins); the **local dev/test story** (how an author runs + tests one plugin in isolation against the host). Audience is experienced devs: optimise for a powerful, predictable, clearly-documented API. Crash-isolation (a bad plugin can't take down the host) is a *nice-to-have*, not a blocker — fail loud at boot/discovery over sandboxing at runtime. It is a target that plugins should be able to overload as much as possible. Hooks on actions in the system is not bad either, if it is possible. → `src/plugin.ts` is the typed, machine-readable contract (single source of truth: authored `PluginManifest` + folder-derived `Plugin`, `Route`/`RouteResult`/`RouteHandler`, `PermissionDecl`, `PluginHooks`, `definePlugin()`, `HOST_API_VERSION`) plus the pure rules the §2 host enforces — `isValidPluginId` (URL-safe folder name: lowercase/digits/dashes), `checkApiVersion` (semver via `parseSemver`/official regex, no dep: same major+minor→ok, older minor→warn, newer minor/major-mismatch/malformed→refuse) and `findConflicts` (id/route = error, duplicate nav-id = error, shared permission token = warn; never last-write-wins). Identity is the folder: id = folder name, mount = `/<id>` — neither is in the manifest, so mount-path uniqueness is structural (no basePath rule). `apiVersion` is a literal a plugin pins (never imports `HOST_API_VERSION`). nav `icon` = Lucide sprite id. `docs/plugin-contract.md` is the prose reference (anatomy/identity, manifest fields, handler/RouteResult, `RequestContext` stability guarantee, nav/permission namespacing, versioning, conflicts, hooks, dev/test story). README links it. Tests-first (`plugin.test.ts`); typecheck + 82 units green. Discovery/router/view-resolver/static stay as the next §2 items that wire this to FS+HTTP.
|
- [x] **Specify the plugin contract** (big job, do first — it's the product's main API surface). Write it down as the authoritative reference: the full manifest shape; the `RequestContext` handed to handlers and what's guaranteed stable; **contract versioning** (a `apiVersion`/`engines`-style field so a plugin declares the host it targets, and the host refuses or warns on mismatch); **conflict rules** (two plugins claiming the same `basePath`, nav slot, or `permission` name → defined, loud resolution, not last-write-wins); the **local dev/test story** (how an author runs + tests one plugin in isolation against the host). Audience is experienced devs: optimise for a powerful, predictable, clearly-documented API. Crash-isolation (a bad plugin can't take down the host) is a *nice-to-have*, not a blocker — fail loud at boot/discovery over sandboxing at runtime. It is a target that plugins should be able to overload as much as possible. Hooks on actions in the system is not bad either, if it is possible. → `src/plugin.ts` is the typed, machine-readable contract (single source of truth: authored `PluginManifest` + folder-derived `Plugin`, `Route`/`RouteResult`/`RouteHandler`, `PermissionDecl`, `PluginHooks`, `definePlugin()`, `HOST_API_VERSION`) plus the pure rules the §2 host enforces — `isValidPluginId` (URL-safe folder name: lowercase/digits/dashes), `checkApiVersion` (semver via `parseSemver`/official regex, no dep: same major+minor→ok, older minor→warn, newer minor/major-mismatch/malformed→refuse) and `findConflicts` (id/route = error, duplicate nav-id = error, shared permission token = warn; never last-write-wins). Identity is the folder: id = folder name, mount = `/<id>` — neither is in the manifest, so mount-path uniqueness is structural (no basePath rule). `apiVersion` is a literal a plugin pins (never imports `HOST_API_VERSION`). nav `icon` = Lucide sprite id. `docs/plugin-contract.md` is the prose reference (anatomy/identity, manifest fields, handler/RouteResult, `RequestContext` stability guarantee, nav/permission namespacing, versioning, conflicts, hooks, dev/test story). README links it. Tests-first (`plugin.test.ts`); typecheck + 82 units green. Discovery/router/view-resolver/static stay as the next §2 items that wire this to FS+HTTP.
|
||||||
- [x] Discovery: scan `plugins/`, import each `plugin.ts` default export, validate. → `src/discovery.ts` (`discoverPlugins`): the imperative shell over plugin.ts's pure rules. Scans `plugins/` (sorted, skips dotfiles/non-dirs; missing dir ⇒ `[]` for a clean clone), derives `id` from the folder, dynamically imports each `plugin.ts` default export and validates it — `isValidPluginId`, default-export-is-a-manifest, `checkApiVersion`, array-shape of nav/routes/permissions, then `findConflicts` across the set. Fails loud: every per-plugin problem + every error-level conflict is collected and thrown as one boot-stopping Error naming the plugin(s); warns (older-minor apiVersion, shared permission token) log and load continues. Wired into `server.ts` boot (logs the loaded ids). `discovery.test.ts` covers empty/happy/each failure mode + the warn path (temp-dir fixtures). Router/view-resolver/static are the next §2 items.
|
- [x] Discovery: scan `plugins/`, import each `plugin.ts` default export, validate. → `src/discovery.ts` (`discoverPlugins`): the imperative shell over plugin.ts's pure rules. Scans `plugins/` (sorted, skips dotfiles/non-dirs; missing dir ⇒ `[]` for a clean clone), derives `id` from the folder, dynamically imports each `plugin.ts` default export and validates it — `isValidPluginId`, default-export-is-a-manifest, `checkApiVersion`, array-shape of nav/routes/permissions, then `findConflicts` across the set. Fails loud: every per-plugin problem + every error-level conflict is collected and thrown as one boot-stopping Error naming the plugin(s); warns (older-minor apiVersion, shared permission token) log and load continues. Wired into `server.ts` boot (logs the loaded ids). `discovery.test.ts` covers empty/happy/each failure mode + the warn path (temp-dir fixtures). Router/view-resolver/static are the next §2 items.
|
||||||
- [ ] Router: match method+path under `basePath`, resolve path params, run permission gate, call handler with context.
|
- [x] Router: match method+path under `basePath`, resolve path params, run permission gate, call handler with context. → `src/router.ts`: the pure core (`matchRoute`/`allowedMethods`/`isAuthorized`), wired by `app.ts` (the imperative shell). A route mounts at `/<id>` + its path via the now-exported `fullPath` (shared with `findConflicts`, so they can't drift); `:name` segments → `ctx.params.name` (percent-decoded, malformed ⇒ no match). Specificity: a literal segment beats a `:param` (`/users/new` wins over `/users/:id` regardless of declaration order), ties keep discovery order. HEAD answers a GET route; known-path/wrong-method ⇒ 405 + `Allow`. `isAuthorized` = composeNav's gate (no `permission` ⇒ open, else `roles` must include it); fail-closed today since auth (§4) supplies no user yet (gated ⇒ 403). `app.ts` builds the context, gates, calls the handler, and maps `RouteResult` → response (`sendResult`: html/json/redirect/view/void; author headers override; the void escape hatch lets a handler own `ctx.res`); `view` renders the plugin's own `views/<view>.ejs` (the richer resolver — core-partial includes, subfolders — is the next §2 item). Dropped the global non-GET/HEAD 405 (plugins bring other methods). Wired into `server.ts` (`createApp({ plugins })`). Tests-first: `router.test.ts` (match/params/specificity/HEAD/methods/gate) + an `app.test.ts` integration mounting a demo plugin (every RouteResult shape + 403/405/404); typecheck + 98 units green.
|
||||||
- [ ] Per-plugin view resolver (`plugins/<id>/views/*.ejs`).
|
- [ ] Per-plugin view resolver (`plugins/<id>/views/*.ejs`) and also all possible partials for ejs in the views folder and sub folderes.
|
||||||
- [ ] Per-plugin static serving: `plugins/<id>/public/` → `/public/<id>/`.
|
- [ ] Per-plugin static serving: `plugins/<id>/public/` → `/public/<id>/`.
|
||||||
- [ ] `config/menu.ts` central override: reorder/rename/hide/group + branding (app name, logo, default theme).
|
- [ ] `config/menu.ts` central override: reorder/rename/hide/group + branding (app name, logo, default theme).
|
||||||
- [ ] Wire branding into the app shell.
|
- [ ] Wire branding into the app shell.
|
||||||
@@ -134,3 +134,5 @@ everything via Docker.
|
|||||||
- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff.
|
- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff.
|
||||||
- [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us.
|
- [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us.
|
||||||
|
|
||||||
|
## 10. User added stuff
|
||||||
|
- [ ] If no seeded default user is already added, add it so you get something to work with on a new installation. Should be a default initial account name and password in ENVs or a better suggestion.
|
||||||
Reference in New Issue
Block a user