From 3cdefff233f39cee064f83b1ea8b030591ad94fb Mon Sep 17 00:00:00 2001 From: lilleman Date: Tue, 16 Jun 2026 15:18:20 +0200 Subject: [PATCH] =?UTF-8?q?Serve=20per-plugin=20static=20assets=20(todo=20?= =?UTF-8?q?=C2=A72);=20/public//=20=E2=86=92=20plugins//public/=20?= =?UTF-8?q?via=20routePublic,=20core=20public/=20unaffected?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 7 ++++--- docs/plugin-contract.md | 4 +++- src/app.test.ts | 19 ++++++++++++++++++- src/app.ts | 7 +++++-- src/static.ts | 18 ++++++++++++++++++ todo.md | 3 ++- 6 files changed, 50 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e2deb6b..bdaf258 100644 --- a/README.md +++ b/README.md @@ -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 `/`, > resolves `:name` params, runs the permission gate, and turns the handler's `RouteResult` into > 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. +> which may `include()` the core building-block partials. A plugin's `public/` assets are served +> at `/public//` (`src/static.ts`). The mount mechanics above are how the files get into the +> container either way. ## 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/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// → a plugin's public/ 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/context.ts RequestContext handed to handlers + buildContext() diff --git a/docs/plugin-contract.md b/docs/plugin-contract.md index 40c0036..62d44a0 100644 --- a/docs/plugin-contract.md +++ b/docs/plugin-contract.md @@ -18,7 +18,9 @@ time, not in production. > (`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. +> reachable via `include()`), and **per-plugin static serving** (`/public//` → 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 diff --git a/src/app.test.ts b/src/app.test.ts index 4d7ca5e..608fab0 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -8,7 +8,7 @@ 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"; +import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts"; 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) => { const dir = mkdtempSync(join(tmpdir(), "pp-plugins-")); 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. writeFileSync(join(dir, "demo", "views", "page.ejs"), `

Hello <%= who %>

<%- include("partials/theme-switch") %>`); + writeFileSync(join(dir, "demo", "public", "app.css"), ".demo{color:red}"); t.after(() => rmSync(dir, { force: true, recursive: true })); 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, /role="radiogroup"/); // core partials/theme-switch resolved + // static asset served from the plugin's own public/ at /public// + 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 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.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/, no file + assert.deepEqual(routePublic("css/styles.css", "/core", "/plugins", ids), { dir: "/core", subPath: "css/styles.css" }); // not a plugin → core +}); diff --git a/src/app.ts b/src/app.ts index 4c79fe2..e7e8665 100644 --- a/src/app.ts +++ b/src/app.ts @@ -7,7 +7,7 @@ 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 { routePublic, serveStatic } from "./static.ts"; import { renderPluginView } from "./view-resolver.ts"; const rootDir = join(dirname(fileURLToPath(import.meta.url)), ".."); @@ -25,6 +25,7 @@ export interface AppOptions { export function createApp(options: AppOptions = {}): Server { const cache = options.cache ?? false; const plugins = options.plugins ?? []; + const pluginIds = new Set(plugins.map((p) => p.id)); const pluginsDir = options.pluginsDir ?? PLUGINS_DIR; const publicDir = options.publicDir ?? join(rootDir, "public"); const viewsDir = options.viewsDir ?? join(rootDir, "views"); @@ -48,7 +49,9 @@ export function createApp(options: AppOptions = {}): Server { const pathname = url.pathname; if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) { - await serveStatic(publicDir, pathname.slice("/public/".length), res, method === "HEAD"); + // /public//… 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; } diff --git a/src/static.ts b/src/static.ts index c0fefc6..8425c93 100644 --- a/src/static.ts +++ b/src/static.ts @@ -31,6 +31,24 @@ export function resolveStaticPath(dir: string, requestedPath: string): string | return rel.startsWith("..") || isAbsolute(rel) ? null : filePath; } +export interface StaticRoute { + dir: string; + subPath: string; +} + +// Route a `/public/` request to a base dir + sub-path: a leading segment naming a discovered +// plugin serves from plugins//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): 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 { res.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(body); } diff --git a/todo.md b/todo.md index 04959c6..f4ebcf2 100644 --- a/todo.md +++ b/todo.md @@ -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] 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. - [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//`. +- [x] Per-plugin static serving: `plugins//public/` → `/public//`. → `routePublic` (pure, in `src/static.ts`), wired into `app.ts`'s existing `/public/` branch. A request `/public/` whose leading segment names a discovered plugin serves from `plugins//public/`; 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/`) + 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). - [ ] 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. @@ -87,6 +87,7 @@ everything via Docker. - [ ] Session re-mint on TTL expiry (re-read roles from Keto). - [ ] Logout: revoke Kratos session + clear cookie. - [ ] 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. - [ ] 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.