From fe89dd1c066ea11b00642a8282b22091920f6ef6 Mon Sep 17 00:00:00 2001 From: lilleman Date: Tue, 16 Jun 2026 13:41:02 +0200 Subject: [PATCH] =?UTF-8?q?Add=20per-plugin=20view=20resolver=20(todo=20?= =?UTF-8?q?=C2=A72);=20render=20plugins//views/.ejs=20with=20nes?= =?UTF-8?q?ted=20names=20+=20traversal=20guard,=20core=20partials=20reacha?= =?UTF-8?q?ble=20via=20include()?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 ++++-- docs/plugin-contract.md | 14 +++++++------ src/app.test.ts | 9 +++++--- src/app.ts | 10 ++++----- src/view-resolver.test.ts | 44 +++++++++++++++++++++++++++++++++++++++ src/view-resolver.ts | 38 +++++++++++++++++++++++++++++++++ todo.md | 5 +++-- 7 files changed, 108 insertions(+), 18 deletions(-) create mode 100644 src/view-resolver.test.ts create mode 100644 src/view-resolver.ts diff --git a/README.md b/README.md index ba90d90..e2deb6b 100644 --- a/README.md +++ b/README.md @@ -253,8 +253,9 @@ and reproducible; mount a volume only to add plugins to an already-built image. > it (id, `apiVersion`, conflicts) — runs at boot (`src/discovery.ts`); a bad plugin stops > 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 response; a `view` result renders `plugins//views/.ejs` (`src/view-resolver.ts`), +> which may `include()` the core building-block partials. _(Planned, §2:)_ per-plugin static +> serving is next. The mount mechanics above are how the files get into the container either way. ## The menu system _(planned)_ @@ -435,6 +436,7 @@ src/paginate.ts paginate(total,page,pageSize): page model (counts, row wind 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) +src/view-resolver.ts renderPluginView(): render plugins//views/.ejs; plugin views can include() core partials (§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 6fbac52..40c0036 100644 --- a/docs/plugin-contract.md +++ b/docs/plugin-contract.md @@ -15,9 +15,10 @@ time, not in production. > **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. +> (`src/discovery.ts`), the **router** (`src/router.ts` — method+path match, `:name` params, +> permission gate, `RouteResult` → response), and the **per-plugin view resolver** +> (`src/view-resolver.ts` — a `view` result renders `plugins//views/`, with the core partials +> reachable via `include()`) are wired. **Per-plugin static serving** is the next §2 item. ## Anatomy of a plugin @@ -121,9 +122,10 @@ export async function listShifts(ctx: RequestContext) { } ``` -- **`view`** resolves against the plugin's own `views/` (the per-plugin view resolver, §2). The - template may `include()` the core building-block partials (app shell, nav tree, data table, …) - to render a full page — exactly as the built-in screens do. +- **`view`** resolves against the plugin's own `views/` (`src/view-resolver.ts`) — nested names + like `"shifts/edit"` work, and an out-of-bounds name is refused. The template may `include()` + the core building-block partials (app shell, nav tree, data table, …) and its own + partials/subfolders to render a full page — exactly as the built-in screens do. - The handler **fetches its own data** from upstream and renders it; plugins hold no state (see the README's *Stateless* section). The partials only need rows. - `default` status: `200` for `view`/`html`/`json`, `303` for `redirect`. diff --git a/src/app.test.ts b/src/app.test.ts index 5b8a20a..4d7ca5e 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -121,7 +121,8 @@ async function startApp(t: TestContext, plugins: Plugin[], pluginsDir?: string): 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 %>

"); + // The view also include()s a core building-block partial, proving plugin views reuse them. + writeFileSync(join(dir, "demo", "views", "page.ejs"), `

Hello <%= who %>

<%- include("partials/theme-switch") %>`); t.after(() => rmSync(dir, { force: true, recursive: true })); const url = await startApp(t, [demoPlugin], dir); @@ -140,8 +141,10 @@ test("mounts plugin routes: params, html/json/redirect/view results, and the per 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/); + // view rendered from the plugin's own views/, including a core partial + const page = await (await fetch(url + "/demo/page")).text(); + assert.match(page, /Hello Plainpages/); + assert.match(page, /role="radiogroup"/); // core partials/theme-switch resolved // gated route with no session → 403 assert.equal((await fetch(url + "/demo/secret")).status, 403); diff --git a/src/app.ts b/src/app.ts index 5777088..4c79fe2 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,6 +8,7 @@ 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 { renderPluginView } from "./view-resolver.ts"; const rootDir = join(dirname(fileURLToPath(import.meta.url)), ".."); @@ -31,10 +32,9 @@ export function createApp(options: AppOptions = {}): Server { 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 }); + // A `view` RouteResult renders plugins//views/.ejs; such views may include() the core + // building-block partials (resolved from viewsDir) and their own partials/subfolders. + const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir }); const sendHtml = (res: ServerResponse, status: number, html: string): void => { res.writeHead(status, { "content-type": "text/html; charset=utf-8" }); @@ -61,7 +61,7 @@ export function createApp(options: AppOptions = {}): Server { return; } const result = await match.route.handler(ctx); - await sendResult(res, result ?? null, renderPluginView(match.plugin)); + await sendResult(res, result ?? null, (view, data) => renderView(match.plugin.id, view, data)); return; } diff --git a/src/view-resolver.test.ts b/src/view-resolver.test.ts new file mode 100644 index 0000000..71e1acc --- /dev/null +++ b/src/view-resolver.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { test, type TestContext } from "node:test"; +import { fileURLToPath } from "node:url"; +import { renderPluginView, resolveViewPath } from "./view-resolver.ts"; + +const coreViewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "views"); + +test("resolveViewPath resolves names + nested subfolders within the plugin's views dir", () => { + const dir = "/srv/plugins"; + assert.equal(resolveViewPath(dir, "demo", "page"), "/srv/plugins/demo/views/page.ejs"); + assert.equal(resolveViewPath(dir, "demo", "shifts/edit"), "/srv/plugins/demo/views/shifts/edit.ejs"); + assert.equal(resolveViewPath(dir, "demo", "page.ejs"), "/srv/plugins/demo/views/page.ejs"); // extension not doubled +}); + +test("resolveViewPath rejects traversal and control chars", () => { + assert.equal(resolveViewPath("/srv/plugins", "demo", "../../secret"), null); + assert.equal(resolveViewPath("/srv/plugins", "demo", "a\x00b"), null); +}); + +test("renderPluginView: a (nested) view includes a core building-block partial and its own partial", async (t: TestContext) => { + const pluginsDir = mkdtempSync(join(tmpdir(), "pp-views-")); + t.after(() => rmSync(pluginsDir, { force: true, recursive: true })); + const views = join(pluginsDir, "demo", "views"); + mkdirSync(join(views, "partials"), { recursive: true }); + mkdirSync(join(views, "sub"), { recursive: true }); + writeFileSync(join(views, "partials", "local.ejs"), "<%= who %>"); + writeFileSync( + join(views, "sub", "page.ejs"), + `<%- include("partials/theme-switch") %><%- include("partials/local", { who }) %>`, + ); + + const render = renderPluginView({ cache: false, coreViewsDir, pluginsDir }); + const html = await render("demo", "sub/page", { who: "Plug" }); + assert.match(html, /role="radiogroup"/); // core partial, resolved from coreViewsDir + assert.match(html, /Plug<\/span>/); // the plugin's own partial, with data +}); + +test("renderPluginView throws on an out-of-bounds view name", async () => { + const render = renderPluginView({ cache: false, coreViewsDir, pluginsDir: "/srv/plugins" }); + await assert.rejects(render("demo", "../../etc/passwd", {}), /invalid view name/); +}); diff --git a/src/view-resolver.ts b/src/view-resolver.ts new file mode 100644 index 0000000..9ee4ba8 --- /dev/null +++ b/src/view-resolver.ts @@ -0,0 +1,38 @@ +// Per-plugin view resolver (todo §2): render plugins//views/.ejs and let a plugin view +// reuse the core building-block partials. EJS resolves an include() relative to the current file +// first, then against the `views` roots — so passing [plugin views, core views] makes both the +// plugin's own partials/subfolders and every core partial reachable (the plugin root first, so a +// plugin may deliberately shadow a core partial). The §2 router calls this for a `view` RouteResult. + +import { isAbsolute, join, relative } from "node:path"; +import * as ejs from "ejs"; + +const CONTROL_CHARS = /[\x00-\x1f]/; + +// Resolve a view name → absolute .ejs path within plugins//views, or null if it escapes that +// dir (traversal) or carries a control char. Names may be nested ("shifts/edit"); a missing +// extension defaults to .ejs. +export function resolveViewPath(pluginsDir: string, pluginId: string, view: string): string | null { + if (CONTROL_CHARS.test(view)) return null; + const viewsDir = join(pluginsDir, pluginId, "views"); + const file = join(viewsDir, view.endsWith(".ejs") ? view : `${view}.ejs`); + const rel = relative(viewsDir, file); + return rel.startsWith("..") || isAbsolute(rel) ? null : file; +} + +export interface PluginViewOptions { + cache: boolean; + coreViewsDir: string; // core views/ root — its partials become include() roots for plugin views + pluginsDir: string; +} + +// Bind the dirs/cache once; the returned fn renders a named view for a given plugin id. Rejects on +// an out-of-bounds view name (developer error — fail loud, like the rest of the host). +export function renderPluginView(options: PluginViewOptions) { + return async (pluginId: string, view: string, data: Record): Promise => { + const file = resolveViewPath(options.pluginsDir, pluginId, view); + if (file === null) throw new Error(`invalid view name "${view}" for plugin "${pluginId}"`); + const views = [join(options.pluginsDir, pluginId, "views"), options.coreViewsDir]; + return ejs.renderFile(file, data, { cache: options.cache, views }); + }; +} diff --git a/todo.md b/todo.md index e62d598..04959c6 100644 --- a/todo.md +++ b/todo.md @@ -48,7 +48,7 @@ everything via Docker. - [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. - [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. +- [x] Per-plugin view resolver (`plugins//views/*.ejs`) and also all possible partials for ejs in the views folder and sub folderes. → `src/view-resolver.ts` (`renderPluginView`/`resolveViewPath`), wired into `app.ts` for a `view` RouteResult (replaces the router's minimal stub). `resolveViewPath` (pure) maps a view name → `plugins//views/.ejs`, supports nested names (`shifts/edit`), defaults the `.ejs` extension, and refuses traversal/control-char names (same guard as `static.ts`). Rendering passes EJS `views: [/views, coreViewsDir]`: EJS resolves an `include()` relative to the current file first, then those roots — so a plugin view reaches **every core building-block partial** (shell, nav-tree, data-table, …) *and* its own partials/subfolders, plugin-root first so it can deliberately shadow a core partial. Out-of-bounds name ⇒ reject (fail loud). Tests-first: `view-resolver.test.ts` (resolve/nest/extension/traversal/control-char + a nested view that includes both a core partial and its own) + the `app.test.ts` plugin integration now asserts the live `view` page includes `partials/theme-switch`; typecheck + 102 units green. Per-plugin static serving is the next §2 item. - [ ] 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. @@ -135,4 +135,5 @@ everything via Docker. - [ ] 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 +- [ ] 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. +- [ ] Make some pages optionally available publicly. \ No newline at end of file