Add per-plugin view resolver (todo §2); render plugins/<id>/views/<view>.ejs with nested names + traversal guard, core partials reachable via include()
This commit is contained in:
@@ -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 `/<id>`,
|
||||
> 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/<id>/views/<view>.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/<id>/views/<view>.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)
|
||||
|
||||
@@ -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/<id>/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`.
|
||||
|
||||
@@ -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"), "<h1>Hello <%= who %></h1>");
|
||||
// The view also include()s a core building-block partial, proving plugin views reuse them.
|
||||
writeFileSync(join(dir, "demo", "views", "page.ejs"), `<h1>Hello <%= who %></h1><%- 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);
|
||||
|
||||
10
src/app.ts
10
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<string, unknown>): Promise<string> =>
|
||||
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 });
|
||||
// A `view` RouteResult renders plugins/<id>/views/<view>.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;
|
||||
}
|
||||
|
||||
|
||||
44
src/view-resolver.test.ts
Normal file
44
src/view-resolver.test.ts
Normal file
@@ -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"), "<span class=local><%= who %></span>");
|
||||
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, /<span class=local>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/);
|
||||
});
|
||||
38
src/view-resolver.ts
Normal file
38
src/view-resolver.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// Per-plugin view resolver (todo §2): render plugins/<id>/views/<view>.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/<id>/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<string, unknown>): Promise<string> => {
|
||||
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 });
|
||||
};
|
||||
}
|
||||
5
todo.md
5
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 = `/<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] 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`) and also all possible partials for ejs in the views folder and sub folderes.
|
||||
- [x] Per-plugin view resolver (`plugins/<id>/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/<id>/views/<view>.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: [<plugin>/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/<id>/public/` → `/public/<id>/`.
|
||||
- [ ] `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.
|
||||
- [ ] 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.
|
||||
Reference in New Issue
Block a user