diff --git a/README.md b/README.md index 05e7a42..ba90d90 100644 --- a/README.md +++ b/README.md @@ -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 > 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 -> is next. The mount mechanics above are how the files get into the container either way. +> startup with a precise message. The router (`src/router.ts`) then mounts each route at `/`, +> 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)_ @@ -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/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/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/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) public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt) config/menu.ts Central menu override + branding (planned) diff --git a/docs/plugin-contract.md b/docs/plugin-contract.md index a4ce653..6fbac52 100644 --- a/docs/plugin-contract.md +++ b/docs/plugin-contract.md @@ -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 time, not in production. -> **Status.** This is the contract the §2 host implements. The types and the pure rules -> (`checkApiVersion`, `findConflicts`, `isValidPluginId`) exist today in `src/plugin.ts`; -> discovery, the router, the per-plugin view resolver, and static serving are the next §2 items -> and wire this contract to the filesystem and HTTP. Behaviour described as the host's is the -> target those items meet. +> **Status.** This is the contract the §2 host implements. The types and pure rules +> (`checkApiVersion`, `findConflicts`, `isValidPluginId`) live in `src/plugin.ts`; **discovery** +> (`src/discovery.ts`) and the **router** (`src/router.ts` — method+path match, `:name` params, +> permission gate, `RouteResult` → response) are wired. The **per-plugin view resolver** (core +> partials in plugin views) and **static serving** are the next §2 items. ## Anatomy of a plugin diff --git a/src/app.test.ts b/src/app.test.ts index dfc84b5..5b8a20a 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -1,12 +1,13 @@ 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 { tmpdir } from "node:os"; 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 * as ejs from "ejs"; import { createApp } from "./app.ts"; +import type { Plugin } from "./plugin.ts"; import { contentTypeFor, resolveStaticPath } from "./static.ts"; 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/); }); +// 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: `

Hi ${ctx.params.name}

` }), 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 { + const app = createApp(pluginsDir ? { plugins, pluginsDir } : { plugins }); + await new Promise((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"), "

Hello <%= who %>

"); + 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 () => { assert.equal((await fetch(base + "/public/..%2f..%2fapp.ts")).status, 403); assert.equal((await fetch(base + "/public/%00")).status, 403); diff --git a/src/app.ts b/src/app.ts index 3027b5c..5777088 100644 --- a/src/app.ts +++ b/src/app.ts @@ -4,6 +4,9 @@ import { fileURLToPath } from "node:url"; import * as ejs from "ejs"; import { buildContext } from "./context.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"; const rootDir = join(dirname(fileURLToPath(import.meta.url)), ".."); @@ -12,18 +15,27 @@ export interface AppOptions { // Cache compiled templates; caller decides (server passes config.cacheTemplates). // Off by default so edits show live; the app itself never inspects the environment. 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; viewsDir?: string; } export function createApp(options: AppOptions = {}): Server { const cache = options.cache ?? false; + const plugins = options.plugins ?? []; + const pluginsDir = options.pluginsDir ?? PLUGINS_DIR; const publicDir = options.publicDir ?? join(rootDir, "public"); const viewsDir = options.viewsDir ?? join(rootDir, "views"); const render = (view: string, data: Record): Promise => 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): Promise => + ejs.renderFile(join(pluginsDir, plugin.id, "views", `${view}.ejs`), data, { cache }); + const sendHtml = (res: ServerResponse, status: number, html: string): void => { res.writeHead(status, { "content-type": "text/html; charset=utf-8" }); res.end(html); @@ -31,27 +43,40 @@ export function createApp(options: AppOptions = {}): Server { return createServer(async (req, res) => { try { - if (req.method !== "GET" && req.method !== "HEAD") { - 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 method = req.method ?? "GET"; const { url } = buildContext(req, res); const pathname = url.pathname; - if (pathname.startsWith("/public/")) { - await serveStatic(publicDir, pathname.slice("/public/".length), res, req.method === "HEAD"); + if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) { + await serveStatic(publicDir, pathname.slice("/public/".length), res, method === "HEAD"); return; } - if (pathname === "/") { - // Mock data + no roles until the plugin host (§2) and auth (§4) land. + // Plugin routes (any method): gate on the route's permission, then run the handler. + 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) })); 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" })); } catch (err) { console.error(err); @@ -67,3 +92,23 @@ export function createApp(options: AppOptions = {}): Server { } }); } + +type ViewRenderer = (view: string, data: Record) => Promise; + +// 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 { + 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 +} diff --git a/src/plugin.ts b/src/plugin.ts index e06c094..e2461c3 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -183,8 +183,9 @@ function collectNavIds(nodes: NavNode[] | undefined, push: (id: string) => void) } } -// A route's full path = the plugin's mount path `/` + the route path. -function fullPath(id: string, path: string): string { +// A route's full path = the plugin's mount path `/` + the route path. The single source of +// 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}`}`; } diff --git a/src/router.test.ts b/src/router.test.ts new file mode 100644 index 0000000..9148c25 --- /dev/null +++ b/src/router.test.ts @@ -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 /, 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); +}); diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..6cef83d --- /dev/null +++ b/src/router.ts @@ -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 `/` + 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; + 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 | null { + if (pattern.length !== path.length) return null; + const params: Record = {}; + 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(); + 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); +} diff --git a/src/server.ts b/src/server.ts index 7e050cb..40c94eb 100644 --- a/src/server.ts +++ b/src/server.ts @@ -4,10 +4,10 @@ import { discoverPlugins } from "./discovery.ts"; 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(", ")}` : ""}`); -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}`); }); diff --git a/todo.md b/todo.md index c9f3152..e62d598 100644 --- a/todo.md +++ b/todo.md @@ -47,8 +47,8 @@ everything via Docker. ## 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 = `/` — 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. -- [ ] Router: match method+path under `basePath`, resolve path params, run permission gate, call handler with context. -- [ ] Per-plugin view resolver (`plugins//views/*.ejs`). +- [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 `/` + 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/.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//views/*.ejs`) and also all possible partials for ejs in the views folder and sub folderes. - [ ] Per-plugin static serving: `plugins//public/` → `/public//`. - [ ] `config/menu.ts` central override: reorder/rename/hide/group + branding (app name, logo, default theme). - [ ] 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 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. \ No newline at end of file