Serve per-plugin static assets (todo §2); /public/<id>/ → plugins/<id>/public/ via routePublic, core public/ unaffected

This commit is contained in:
2026-06-16 15:18:20 +02:00
parent fe89dd1c06
commit 3cdefff233
6 changed files with 50 additions and 8 deletions

View File

@@ -254,8 +254,9 @@ and reproducible; mount a volume only to add plugins to an already-built image.
> startup with a precise message. The router (`src/router.ts`) then mounts each route at `/<id>`, > 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 > resolves `:name` params, runs the permission gate, and turns the handler's `RouteResult` into
> the response; a `view` result renders `plugins/<id>/views/<view>.ejs` (`src/view-resolver.ts`), > 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 > which may `include()` the core building-block partials. A plugin's `public/` assets are served
> serving is next. The mount mechanics above are how the files get into the container either way. > at `/public/<id>/` (`src/static.ts`). The mount mechanics above are how the files get into the
> container either way.
## The menu system _(planned)_ ## The menu system _(planned)_
@@ -423,7 +424,7 @@ mid-response, so container restarts are clean.
``` ```
src/server.ts Entry point — starts the HTTP server (reads PORT, default 3000) src/server.ts Entry point — starts the HTTP server (reads PORT, default 3000)
src/app.ts Request routing + EJS rendering src/app.ts Request routing + EJS rendering
src/static.ts Static file serving with path-traversal protection src/static.ts Static file serving (path-traversal protection) + routePublic(): /public/<id>/ → a plugin's public/
src/jwt.ts JWS signature verify via node:crypto, no jose; claims+JWKS are §4 src/jwt.ts JWS signature verify via node:crypto, no jose; claims+JWKS are §4
src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4) src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4)
src/context.ts RequestContext handed to handlers + buildContext() src/context.ts RequestContext handed to handlers + buildContext()

View File

@@ -18,7 +18,9 @@ time, not in production.
> (`src/discovery.ts`), the **router** (`src/router.ts` — method+path match, `:name` params, > (`src/discovery.ts`), the **router** (`src/router.ts` — method+path match, `:name` params,
> permission gate, `RouteResult` → response), and the **per-plugin view resolver** > 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 > (`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. > reachable via `include()`), and **per-plugin static serving** (`/public/<id>/` → the plugin's
> `public/`, `routePublic` in `src/static.ts`) are wired. The central menu override + branding
> (`config/menu.ts`) is the next §2 item.
## Anatomy of a plugin ## Anatomy of a plugin

View File

@@ -8,7 +8,7 @@ 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 type { Plugin } from "./plugin.ts";
import { contentTypeFor, resolveStaticPath } from "./static.ts"; import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "views"); const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "views");
@@ -121,8 +121,10 @@ 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) => { test("mounts plugin routes: params, html/json/redirect/view results, and the permission gate", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-")); const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
mkdirSync(join(dir, "demo", "views"), { recursive: true }); mkdirSync(join(dir, "demo", "views"), { recursive: true });
mkdirSync(join(dir, "demo", "public"), { recursive: true });
// The view also include()s a core building-block partial, proving plugin views reuse them. // 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") %>`); writeFileSync(join(dir, "demo", "views", "page.ejs"), `<h1>Hello <%= who %></h1><%- include("partials/theme-switch") %>`);
writeFileSync(join(dir, "demo", "public", "app.css"), ".demo{color:red}");
t.after(() => rmSync(dir, { force: true, recursive: true })); t.after(() => rmSync(dir, { force: true, recursive: true }));
const url = await startApp(t, [demoPlugin], dir); const url = await startApp(t, [demoPlugin], dir);
@@ -146,6 +148,13 @@ test("mounts plugin routes: params, html/json/redirect/view results, and the per
assert.match(page, /Hello Plainpages/); assert.match(page, /Hello Plainpages/);
assert.match(page, /role="radiogroup"/); // core partials/theme-switch resolved assert.match(page, /role="radiogroup"/); // core partials/theme-switch resolved
// static asset served from the plugin's own public/ at /public/<id>/
const css = await fetch(url + "/public/demo/app.css");
assert.equal(css.status, 200);
assert.match(css.headers.get("content-type") ?? "", /text\/css/);
assert.match(await css.text(), /\.demo/);
assert.equal((await fetch(url + "/public/demo/..%2f..%2fplugin.ts")).status, 403); // traversal still blocked
// gated route with no session → 403 // gated route with no session → 403
assert.equal((await fetch(url + "/demo/secret")).status, 403); assert.equal((await fetch(url + "/demo/secret")).status, 403);
@@ -171,3 +180,11 @@ test("contentTypeFor maps known and unknown extensions", () => {
assert.match(contentTypeFor("a.css"), /text\/css/); assert.match(contentTypeFor("a.css"), /text\/css/);
assert.equal(contentTypeFor("a.bin"), "application/octet-stream"); assert.equal(contentTypeFor("a.bin"), "application/octet-stream");
}); });
test("routePublic sends a plugin-id segment to its public/ dir, everything else to core", () => {
const ids = new Set(["scheduling"]);
assert.deepEqual(routePublic("scheduling/app.css", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "app.css" });
assert.deepEqual(routePublic("scheduling/img/logo.svg", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "img/logo.svg" });
assert.deepEqual(routePublic("scheduling", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "" }); // bare /public/<id>, no file
assert.deepEqual(routePublic("css/styles.css", "/core", "/plugins", ids), { dir: "/core", subPath: "css/styles.css" }); // not a plugin → core
});

View File

@@ -7,7 +7,7 @@ import { buildDashboardModel } from "./dashboard.ts";
import { PLUGINS_DIR } from "./discovery.ts"; import { PLUGINS_DIR } from "./discovery.ts";
import type { Plugin, RouteResult } from "./plugin.ts"; import type { Plugin, RouteResult } from "./plugin.ts";
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts"; import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
import { serveStatic } from "./static.ts"; import { routePublic, serveStatic } from "./static.ts";
import { renderPluginView } from "./view-resolver.ts"; import { renderPluginView } from "./view-resolver.ts";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), ".."); const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..");
@@ -25,6 +25,7 @@ export interface AppOptions {
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 plugins = options.plugins ?? [];
const pluginIds = new Set(plugins.map((p) => p.id));
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR; 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");
@@ -48,7 +49,9 @@ export function createApp(options: AppOptions = {}): Server {
const pathname = url.pathname; const pathname = url.pathname;
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) { if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
await serveStatic(publicDir, pathname.slice("/public/".length), res, method === "HEAD"); // /public/<id>/… serves a plugin's public/; everything else the core public/.
const { dir, subPath } = routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds);
await serveStatic(dir, subPath, res, method === "HEAD");
return; return;
} }

View File

@@ -31,6 +31,24 @@ export function resolveStaticPath(dir: string, requestedPath: string): string |
return rel.startsWith("..") || isAbsolute(rel) ? null : filePath; return rel.startsWith("..") || isAbsolute(rel) ? null : filePath;
} }
export interface StaticRoute {
dir: string;
subPath: string;
}
// Route a `/public/<rest>` request to a base dir + sub-path: a leading segment naming a discovered
// plugin serves from plugins/<id>/public/, anything else from the core public/. Plugin ids are
// URL-safe (no %-encoding), so the raw segment compares directly to the id set; serveStatic decodes
// and traversal-guards the sub-path as before.
export function routePublic(restPath: string, publicDir: string, pluginsDir: string, pluginIds: Set<string>): StaticRoute {
const slash = restPath.indexOf("/");
const first = slash === -1 ? restPath : restPath.slice(0, slash);
if (pluginIds.has(first)) {
return { dir: join(pluginsDir, first, "public"), subPath: slash === -1 ? "" : restPath.slice(slash + 1) };
}
return { dir: publicDir, subPath: restPath };
}
function plain(res: ServerResponse, status: number, body: string): void { function plain(res: ServerResponse, status: number, body: string): void {
res.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(body); res.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(body);
} }

View File

@@ -49,7 +49,7 @@ everything via Docker.
- [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.
- [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. - [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.
- [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. - [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>/`. - [x] Per-plugin static serving: `plugins/<id>/public/``/public/<id>/`.`routePublic` (pure, in `src/static.ts`), wired into `app.ts`'s existing `/public/` branch. A request `/public/<rest>` whose leading segment names a discovered plugin serves from `plugins/<id>/public/<rest>`; anything else (e.g. `css/styles.css`) stays on the core `public/`. Disambiguates by the discovered plugin-id set, so only mounted plugins expose assets and core paths are unaffected; plugin ids are URL-safe so the raw segment compares directly (no decode needed). Reuses `serveStatic` unchanged, so the sub-path keeps its decode + traversal/control-char guard (encoded `..` ⇒ 403) and HEAD support; a missing `public/` or file ⇒ 404. Tests-first: a `routePublic` unit (plugin/core split, nested asset, bare `/public/<id>`) + the `app.test.ts` plugin integration now serves a real `demo/public/app.css` (200 + `text/css`) and still 403s a traversal; typecheck + 103 units green. `config/menu.ts` central override is the next §2 item.
- [ ] `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.
- [ ] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues. - [ ] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues.
@@ -87,6 +87,7 @@ everything via Docker.
- [ ] Session re-mint on TTL expiry (re-read roles from Keto). - [ ] Session re-mint on TTL expiry (re-read roles from Keto).
- [ ] Logout: revoke Kratos session + clear cookie. - [ ] Logout: revoke Kratos session + clear cookie.
- [ ] Secure cookie flags; CSRF for our own POST forms. - [ ] Secure cookie flags; CSRF for our own POST forms.
- [ ] Make sure we have E2E tests for token timeouts and refresh (maybe by shorten the token lifetime to very low or something).
- [ ] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues. - [ ] Run the architecture _and_ the stability reviewer agents on the _whole_ project, not just the latest changes, and address their issues.
- [ ] 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.