Serve per-plugin static assets (todo §2); /public/<id>/ → plugins/<id>/public/ via routePublic, core public/ unaffected
This commit is contained in:
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
3
todo.md
3
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] 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user