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:
2026-06-16 13:41:02 +02:00
parent 9b6684c653
commit fe89dd1c06
7 changed files with 108 additions and 18 deletions

View File

@@ -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 > 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>`, > 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. _(Planned, §2:)_ the per-plugin view resolver + static serving are next. The > the response; a `view` result renders `plugins/<id>/views/<view>.ejs` (`src/view-resolver.ts`),
> mount mechanics above are how the files get into the container either way. > 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)_ ## 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/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/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/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) 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) public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
config/menu.ts Central menu override + branding (planned) config/menu.ts Central menu override + branding (planned)

View File

@@ -15,9 +15,10 @@ time, not in production.
> **Status.** This is the contract the §2 host implements. The types and pure rules > **Status.** This is the contract the §2 host implements. The types and pure rules
> (`checkApiVersion`, `findConflicts`, `isValidPluginId`) live in `src/plugin.ts`; **discovery** > (`checkApiVersion`, `findConflicts`, `isValidPluginId`) live in `src/plugin.ts`; **discovery**
> (`src/discovery.ts`) and 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) are wired. The **per-plugin view resolver** (core > permission gate, `RouteResult` → response), and the **per-plugin view resolver**
> partials in plugin views) and **static serving** are the next §2 items. > (`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 ## 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 - **`view`** resolves against the plugin's own `views/` (`src/view-resolver.ts`) — nested names
template may `include()` the core building-block partials (app shell, nav tree, data table, …) like `"shifts/edit"` work, and an out-of-bounds name is refused. The template may `include()`
to render a full page — exactly as the built-in screens do. 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 - 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. (see the README's *Stateless* section). The partials only need rows.
- `default` status: `200` for `view`/`html`/`json`, `303` for `redirect`. - `default` status: `200` for `view`/`html`/`json`, `303` for `redirect`.

View File

@@ -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) => { 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 });
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 })); t.after(() => rmSync(dir, { force: true, recursive: true }));
const url = await startApp(t, [demoPlugin], dir); 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.status, 303);
assert.equal(go.headers.get("location"), "/demo/hello/world"); assert.equal(go.headers.get("location"), "/demo/hello/world");
// view rendered from the plugin's own views/ // view rendered from the plugin's own views/, including a core partial
assert.match(await (await fetch(url + "/demo/page")).text(), /Hello Plainpages/); 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 // gated route with no session → 403
assert.equal((await fetch(url + "/demo/secret")).status, 403); assert.equal((await fetch(url + "/demo/secret")).status, 403);

View File

@@ -8,6 +8,7 @@ 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 { serveStatic } from "./static.ts";
import { renderPluginView } from "./view-resolver.ts";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), ".."); 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> => const render = (view: string, data: Record<string, unknown>): Promise<string> =>
ejs.renderFile(join(viewsDir, `${view}.ejs`), data, { cache }); ejs.renderFile(join(viewsDir, `${view}.ejs`), data, { cache });
// A `view` RouteResult resolves against the plugin's own views/ (the richer per-plugin // A `view` RouteResult renders plugins/<id>/views/<view>.ejs; such views may include() the core
// resolver — core-partial includes, subfolders — is the next §2 item). // building-block partials (resolved from viewsDir) and their own partials/subfolders.
const renderPluginView = (plugin: Plugin) => (view: string, data: Record<string, unknown>): Promise<string> => const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
ejs.renderFile(join(pluginsDir, plugin.id, "views", `${view}.ejs`), data, { cache });
const sendHtml = (res: ServerResponse, status: number, html: string): void => { const sendHtml = (res: ServerResponse, status: number, html: string): void => {
res.writeHead(status, { "content-type": "text/html; charset=utf-8" }); res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
@@ -61,7 +61,7 @@ export function createApp(options: AppOptions = {}): Server {
return; return;
} }
const result = await match.route.handler(ctx); 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; return;
} }

44
src/view-resolver.test.ts Normal file
View 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
View 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 });
};
}

View File

@@ -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] **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] 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.
- [ ] 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>/`. - [ ] Per-plugin static serving: `plugins/<id>/public/``/public/<id>/`.
- [ ] `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.
@@ -136,3 +136,4 @@ everything via Docker.
## 10. User added stuff ## 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.