# Plainpages A self-hostable **foundation for server-rendered web applications** — **public pages, access-controlled pages, or any mix**, built from a **zero-JS design system** with a **config-driven menu** and **optional authentication & authorization** baked in (any page can be public or gated). You add everything domain-specific by **dropping in plugin folders** — the admin UI for a webshop, a public service portal, a school scheduler, a water-treatment dashboard — without rebuilding auth, the menu, and the design system every time. > **True home: ** — development, issues, and PRs > live there. [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is a > read-only mirror, force-synced on every merge to `main`. ## Quick start > **Requirements:** **Docker** and **Docker Compose** — and nothing else. **1. Clone and start the whole stack.** ```bash git clone ssh://git@gitea.larvit.se:21022/larvit/plainpages.git cd plainpages docker compose up -d # http://localhost:3000, live-reloads on source changes ``` **2. Sign in.** Open and sign in as the seeded admin — **`admin@plainpages.local` / `admin`**. **3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups / OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/admin/*`: ```bash cp -r examples/plugins/admin plugins/admin docker compose up -d ``` The bootstrap grants the seeded admin every permission the installed plugins declare, so the **Admin** section now shows in the menu. Use `up -d`, not `restart web` — the seed runs in the one-shot `bootstrap` service, and only `up` re-runs it. See [`examples/plugins/admin/`](examples/plugins/admin/). **4. Add your first plugin.** The clone is bind-mounted into the container, so a new folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.ts`: ```ts import { definePlugin } from "@plainpages/plugin-api"; export default definePlugin({ apiVersion: "0.5.0", nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], routes: [ { method: "GET", path: "/", public: true, handler: () => ({ html: "

Hello from my plugin

" }) }, ], }); ``` ```bash docker compose restart web ``` Visit — the page is mounted at `/hello` (the folder name is the plugin id *and* the mount path) and "Hello" is in the menu. That's the whole loop: **drop a folder in `plugins/`, restart, it's live.** A plugin that declares `permissions` needs `docker compose up -d` instead, so the seed re-runs and grants them (as in step 3). From here, render real pages against the app shell and fetch upstream data — see [Building plugins](#building-plugins) and the runnable reference in [`examples/plugins/scheduling/`](examples/plugins/scheduling/). **Without a clone**, the image runs a trial stack on its own. Save the `compose.yml` the [Docker Hub page](https://hub.docker.com/r/larvit/plainpages) shows, then extract the Ory config the image ships and start, with `X.Y.Z` the release that page names. The page goes on to a first plugin: ```bash docker run --rm larvit/plainpages:X.Y.Z tar -cf - ory | tar -xf - mkdir -p plugins docker compose up -d ``` ## Contents - [Goals](#goals) - [Audience](#audience) - [Overview](#overview) - [Glossary](#glossary) - [Users, groups & permissions](#users-groups--permissions) - [naming a permission](#naming-a-permission) - [a worked example](#a-worked-example) - [granting a permission](#granting-a-permission) - [fine-grained, per-row access](#fine-grained-per-row-access) - [Building plugins](#building-plugins) - [anatomy](#anatomy-of-a-plugin) - [the manifest](#the-manifest) - [routes & handlers](#routes--handlers) - [landing pages](#the-landing-pages-home--dashboard) - [RequestContext](#requestcontext) - [system capabilities (ctx.system)](#system-capabilities-the-ctxsystem-surface) - [nav & permission gates](#nav--permission-gates) - [versioning](#contract-versioning) - [conflict rules](#conflict-rules) - [hooks](#hooks) - [where they live & mounting](#where-plugins-live-and-how-to-mount-them) - [dependencies](#plugin-dependencies) - [settings](#plugin-settings) - [storage](#plugin-storage) - [local dev & test](#local-dev--test-story) - [The menu system](#the-menu-system) - [Building blocks](#building-blocks) - [Interactivity: zero-JS spine](#interactivity-zero-js-spine) - [Languages (i18n)](#languages-i18n) - [Configuration](#configuration) - [canonical host](#canonical-host-one-public-url) - [what you must supply](#what-you-must-supply-the-only-manual-prep) - [SSO](#social-sign-in-sso) - [Auth, sessions & access](#auth-sessions--access) - [login & the session JWT](#login-and-the-session-jwt) - [instant revoke](#instant-revoke-the-optional-denylist) - [three tiers](#three-tiers-of-may-i) - [OAuth2 (Hydra)](#oauth2-provider-hydra) - [security model](#security-model) - [Email](#email) - [Architecture](#architecture) - [Stateless core](#stateless-core) - [Testing](#testing) - [end-to-end](#end-to-end-playwright) - [the full gate](#the-full-gate-one-command) - [CI/CD](#cicd) - [one-time setup](#one-time-ci-setup) - [Production & deployment](#production--deployment) - [Upgrading](#upgrading) - [Observability](#observability) - [JWT signing key & rotation](#jwt-signing-key--rotation) - [Project layout](#project-layout) - [Extending the core](#extending-the-core) ## Goals 1. **Achieve:** Less boilerplate for a server-rendered web GUI: a new project starts at its own domain, not at sign-in, a menu or a table. - Signal: a new plugin reaches a working, gated page with a menu, tables, filters and forms built only from the core building blocks. - Counter-signal: the core grows features only one project needs. - Why: boilerplate removed by absorbing a project's domain only moves it into the core. 2. **Achieve:** Every building block works for the person using it: a non-technical user gets what they meant on the first try, and can't hurt themselves by accident. - Signal: bugs users hit in a shipped flow get fixed and stay fixed, such as a pasted code being refused, a double click acting twice, or an admin locking themselves out. - Counter-signal: a fix adds client-side JavaScript, or a setting every plugin has to know about. - Why: a fix that breaks zero-JS or simplicity moves the irritation from users to every plugin author. 3. **Maintain:** Plainpages solves only the parts every project shares: auth, the GUI building blocks (menu, tables, filters, forms) and the plugin structure. Everything else is a plugin. 4. **Maintain:** Every page is a server-rendered document that works with zero JavaScript, on old and embedded browsers too. A newer browser only makes it nicer. 5. **Maintain:** State lives in the URL. Only state the URL can't carry lives in the database, never in local storage or JavaScript. A cookie carries only what must be bound to the browser: the session and its CSRF token. 6. **Maintain:** The frontend is semantic: the HTML says what each thing is, and the CSS styles meaning, not looks. That keeps every page accessible. 7. **Achieve:** Hackable: a plugin can change anything, and Plainpages stays out of its way. - Signal: a plugin replaces any page, partial, menu entry or string without forking the host, and fewer plugins need a host change to get unblocked. - Counter-signal: plugins break on upgrade with no version signal, or a plugin mistake shows up at runtime instead of at boot. - Why: freedom that breaks silently costs more than the boilerplate it saved. 8. **Achieve:** Speed: pages render fast and stay small. - Signal: server time per page and bytes sent per page fall. - Counter-signal: a building block loses a feature or accessibility to save bytes. - Why: a fast page that does less only moves the work back into each plugin. 9. **Achieve:** LLM-friendly: an LLM builds a correct plugin from the README and the examples alone. - Signal: an agent given only those writes a plugin that passes discovery and its E2E test on the first try. - Counter-signal: the README grows to restate what the code already shows. - Why: docs that pass the test by growing cost every later reader, human or LLM. 10. **Maintain:** Every behaviour is an explicit configuration choice, the same in every environment. ## Audience - **Developers starting a web GUI project, and the LLMs that build plugins with them.** They may rely on `@plainpages/plugin-api`, the core partials and the packaging promises, all versioned by `apiVersion`. A change that breaks a plugin takes a major bump. - **Operators** who self-host Plainpages. They may rely on semver-tagged images, on configuration through environment variables and the drop-in mounts (`plugins/`, `config/`, `locales/`), and on upgrades within a minor version that need no change on their side. - **Visitors** to a site built on Plainpages, on any browser, including the older engines in TVs, kiosks, thin clients and embedded panels. They may rely on pages that load fast and work without JavaScript, with a keyboard and a screen reader. ## Overview Plainpages gives you the boring-but-hard parts of a web app — a design system, a menu, sessions, and access control — and stays out of your domain logic. **Any page can be public or gated**, so the same foundation serves a public site, a locked-down internal tool, or a public front with an authenticated area behind it. - **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design system + app shell, the config-driven menu, sessions, and access control. No domain screens. - **Opt-in admin plugin:** the **users, groups, and OAuth2-clients** screens ship as [`examples/plugins/admin/`](examples/plugins/admin/) — an ordinary plugin, reaching Ory through the privileged [`ctx.system`](#system-capabilities-the-ctxsystem-surface) surface. - **You add:** everything else domain-specific, as **plugins** — built from the same building blocks the admin plugin uses. Identity, sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar services. The shape doesn't change as it grows: every plugin is the same self-contained folder, the hot path is the same I/O-free JWT check, and the host stores nothing of its own. **Plugins are the extension model.** The plugin API is the product's main surface: powerful, predictable, and overloadable — a plugin can take over as much of a page as it wants. The host **fails loud at boot/discovery** (bad manifest, version mismatch, conflict) rather than sandboxing at runtime; crash-isolation is a deliberate non-goal. See [Building plugins](#building-plugins). **Zero JavaScript**, so pages stay fast on low-end hardware and flaky links. Where a modern CSS feature removes the need for JS (theme switching, popovers, disclosure) we use it — the trade we avoid is shipping a client-side runtime, not using the platform. Markup is semantic and accessible (see [AGENTS.md](AGENTS.md)). ## Glossary - **Host** — the `web` container: the Node process that discovers plugins, serves every page and owns no state of its own. Everything outside it (a plugin's upstream, Ory) is reached over the network. - **Plugin** — a folder under `plugins/` that adds domain features. - **Manifest** — the object a plugin's `plugin.ts` exports as its default: its routes, nav, permissions and hooks. The host checks it at boot and refuses a bad one ([The manifest](#the-manifest)). - **Chrome** — everything on a page that isn't the page's own content: brand, menu, user, theme and CSRF token. A handler gets it as `ctx.chrome` ([RequestContext](#requestcontext)). - **Nav fragment** — the `nav` entries one plugin contributes. The host merges every plugin's fragment into the one global menu and filters it per user ([The menu system](#the-menu-system)). - **Permission name** — a `:` string such as `scheduling:write`, granted in Keto and carried in the session JWT's `permissions` claim ([Naming a permission](#naming-a-permission)). - **System plugin** — a plugin that administers Plainpages' own identity stack, through the privileged `ctx.system` ([System capabilities](#system-capabilities-the-ctxsystem-surface)). - **Denylist** — the optional in-memory list, per instance, of subjects revoked just now, so a deactivation or a user's own permission change lands before the JWT expires ([Instant revoke](#instant-revoke-the-optional-denylist)). ## Users, groups & permissions Authorization here is two hops: a **user** — directly, or through a **group** — is granted a **permission**, and that permission's *name* is exactly the string a plugin gates on. Grants are Keto relation tuples: `Permission:#granted@user:`, or `@Group:#members`. | Entity | Lives in | Answers | Example | | --- | --- | --- | --- | | **User** | Kratos | who you are | `user:0198f2c1-…` | | **Group** | Keto | who — a reusable set | `Group:support` | | **Permission** | Keto | what you may do | `Permission:scheduling:read` | | **Resource** | Keto | which specific row | `Resource:shift-4471` | Keto's whole model is one primitive — `namespace:object#relation@subject` — so those four namespaces are *ours*, declared in `ory/keto/namespaces.keto.ts`; Keto resolves them, transitively through nested groups. The app stores none of it — it is [stateless](#stateless). > **There is no `Role`.** A route gates on a single operation, so it gates on a **permission**. For > a bundle ("IT Support staff"), make a group and grant it several — groups nest. > > **Ory calls a user an "identity"** (its own docs use the terms interchangeably). Plainpages says > **user** everywhere; you meet Ory's spelling only in the Kratos API and the `Identity` type in > `src/auth/kratos-admin.ts` that mirrors it. ### Naming a permission **Every permission name is `:`** — `scheduling:read`, `users:write`, `oauth2-clients:read`. Both halves are lowercase letters, digits, dashes and underscores; discovery refuses a plugin that breaks the rule, so it holds for every installed plugin. - **``** names the thing acted on, not the plugin that owns it — names are one **global namespace**, so an operator grants `scheduling:read` once and every plugin referencing it is gated consistently. Pick one no other plugin would claim: `oauth2-clients`, not `clients`. - **``** names the operation. `read`/`write` cover most screens; use a specific verb when the operation really is distinct (`invoices:approve`). A bare word like `admin` says *who someone is*, not *what they may do* — that is a role, and roles are **groups** here. Split by resource and action, then bundle with a group: ``` Group:it-support ──> Permission:users:read, Permission:users:write, Permission:groups:read, … ``` Declaring a permission stays optional, so two plugins may deliberately share a name. > **A `:write` is not a small grant.** The split contains the **read** half — `users:read` alone is a > safe helpdesk grant — but `groups:write` lets someone add themselves to a group holding every > permission, and `users:write` lets them mint a recovery code for any account. Treat both as full > administrative access. ### A worked example Alice works support and leads scheduling; Bob works support; Carol administers the system. ``` people groups permissions ────── ────── ─────────── alice ──┬─────────> Group:support ────┐ │ ├──> Group:staff ──> Permission:scheduling:read bob ────┘ │ │ alice ────────────> Group:sched-leads ┴──> Permission:scheduling:write carol ────────────> Group:it-support ─┬──> Permission:users:read └──> Permission:users:write ``` At login the host asks Keto which permissions the user holds, walking those arrows transitively, and bakes the answer into the session JWT (see [Login and the session JWT](#login-and-the-session-jwt)) — alice gets `scheduling:read` + `scheduling:write`, bob `scheduling:read`, carol `users:read` + `users:write`. **Permissions do not nest and there is no superuser**: carol's Users grant buys nothing on Groups or `/scheduling`. Against the reference plugins' actual routes: | Request | Gate | alice | bob | carol | anonymous | | --- | --- | --- | --- | --- | --- | | `GET /scheduling` | `public: true` | ✅ | ✅ | ✅ | ✅ | | `GET /scheduling/mine` | `session: true` | ✅ | ✅ | ✅ | → `/login` | | `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` | | `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` | | `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` | | `GET /admin/users` | `users:read` | 403 | 403 | ✅ | → `/login` | | `POST /admin/users` | `users:write` | 403 | 403 | ✅ | → `/login` | | `GET /admin/groups` | `groups:read` | 403 | 403 | 403 | → `/login` | Bob reaches the shifts list with no direct grant — `support` → `staff` → `scheduling:read`, two hops resolved by Keto at his login. An anonymous visitor gets a **redirect** carrying `return_to`, not a 403; a signed-in user who merely lacks the permission gets the 403 page, since there is nothing to sign in *as* that would help. The menu is filtered by the same permissions, so nobody is shown a door they cannot open. ### Granting a permission Write the tuple. The admin plugin's **Users** and **Groups** screens do exactly this — each offers the declared permissions as a checkbox list — or use Keto's write API directly: ```bash # everyone in sched-leads may write shifts curl -X PUT http://keto:4467/admin/relation-tuples -H 'content-type: application/json' -d '{ "namespace": "Permission", "object": "scheduling:write", "relation": "granted", "subject_set": { "namespace": "Group", "object": "sched-leads", "relation": "members" } }' ``` **A permission's name is authored in plugin code; only its *grants* live in Keto.** A plugin declares the permissions it gates on (`permissions:` in the manifest) and the host collects them into one catalog — `ctx.declaredPermissions` — which is the fixed list the admin screens offer. Nothing in a GUI invents a name, and a tuple naming something no installed plugin declares gates nothing. A change takes effect on the user's **next login or JWT re-mint** (~10 min) — see [Instant revoke](#instant-revoke-the-optional-denylist) when you need it sooner. ### Fine-grained, per-row access The `Resource` namespace covers what a coarse permission cannot express: *this* row, shared with *this* person. A `Resource` carries Keto `permits` (`view`, `edit`, `delete`, nesting as `owner` ⊇ `editor` ⊇ `viewer`) and never appears in the JWT. **A per-row grant never widens a coarse gate** — the route's `permission` is checked before the handler runs. Gate the route on something they hold, then narrow inside the handler: ```ts { method: "POST", path: "/shifts/:id", permission: READ, handler: editShift } async function editShift(ctx) { if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id, relation: "editors" }))) throw new GuardError(403, "not an editor of this shift"); … } ``` Reserve this tier for relationship rules (sharing, delegation, inheritance). Ownership and tenant rules belong in the upstream service that holds the row — see [Three tiers of "may I?"](#three-tiers-of-may-i). ## Building plugins A plugin is a self-contained folder under `plugins/` that the host discovers at boot — no registration step, no central wiring. Each plugin carries its own nav, routes, views, and CSS. The contract is **TypeScript** (`src/plugin-host/plugin.ts`); the types there are the source of truth and the sections below explain them and the rules the host enforces. A runnable example lives in **[`examples/plugins/scheduling/`](examples/plugins/scheduling/)** — a public overview page, a permission-gated list page over an upstream service, a CSRF-guarded form, and a mix of public + gated nav. `plugins/` ships empty, so copy it in to run it (`cp -r examples/plugins/scheduling plugins/scheduling`, then restart); the dev compose already points `SCHEDULING_UPSTREAM` at its mock backend (`examples/shifts-upstream/`). ### Anatomy of a plugin ``` plugins/things/ # the plugin folder — its name is the id AND the mount path (→ /things) plugin.ts # REQUIRED — the one fixed filename; default-exports the manifest (definePlugin(...)) views/ # fixed name, optional — EJS the host renders for a { view } result things.ejs # your view files; a handler picks one with { view: "things" } public/ # fixed name, optional — static assets, served at /public/things/ things.css # your asset files i18n/ # fixed name, optional — this plugin's own catalogs (see Languages) en-US.ts # the baseline; sv-SE.ts et al are written against its type handlers.ts # your code, any names/layout — host never looks here; plugin.ts imports it service.ts # e.g. route handlers, upstream calls, domain helpers — design as you wish package.json # optional — only if you depend on npm packages (see Plugin dependencies) node_modules/ # yours, installed from your own lockfile ``` **Only `plugin.ts` is required.** `views/`, `public/` and `i18n/` are fixed folder *names* the host resolves against, but the files inside are yours to name. Everything else — handlers, upstream clients, their layout — the host never sees; `plugin.ts` simply imports it. **Identity comes from the folder.** The folder name *is* the plugin `id` and the mount path is `/`; neither is in the manifest, so they can't drift or be claimed twice. The id must be URL/path-safe (`isValidPluginId`: lowercase `a–z`, digits, dashes) and also namespaces the plugin's `views/`, its `/public//` assets, and by convention its nav/permission names. `RESERVED_PLUGIN_IDS` are refused at discovery — the gated `dashboard`, the Kratos auth flows (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`) and their `error` page, the `oauth2` provider routes, and `public` — since plugin routes resolve first and a folder claiming one would silently shadow a built-in. `admin` is **not** reserved: the admin screens are themselves a plugin. Installing a plugin is "drop the folder, restart"; removing one is "delete the folder, restart". ### The manifest A plugin imports its host surface from one module — **`@plainpages/plugin-api`** (`definePlugin`, the manifest/handler types, `RequestContext`, the guards, and the body/CSRF/list-query helpers). The host publishes it as a package, so it resolves from any depth and from a plugin folder that has a `package.json` of its own ([Plugin dependencies](#plugin-dependencies)). That barrel **is** the contract boundary — never a relative `../../src/...` path; the host refactors everything behind it freely. ```ts import { definePlugin } from "@plainpages/plugin-api"; import { listThings, createThings } from "./handlers.ts"; export default definePlugin({ apiVersion: "0.5.0", // semver string of the host contract this plugin was built against (see Versioning) // Nav fragment, merged into the global menu and gate-filtered per user. // `icon` is a Lucide icon by its sprite id (src/ui/icons.ts). nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }], // Permissions this plugin gates on. Optional — see Nav & permission gates. permissions: [ { description: "View things", name: "things:read" }, { description: "Create and edit things", name: "things:write" }, ], // Route handlers, mounted under the plugin's path (/things). The gate runs first. routes: [ { method: "GET", path: "/", permission: "things:read", handler: listThings }, { method: "POST", path: "/", permission: "things:write", handler: createThings }, ], }); ``` `definePlugin()` only types the object (`PluginManifest`) and returns it unchanged — a manifest may equally be a plain typed object. All validation happens at discovery, and the host attaches the folder-derived `id` to produce the loaded `Plugin`. | Field | Required | Notes | | --- | --- | --- | | `apiVersion` | yes | Semver string of the host contract the plugin was built against. See [Versioning](#contract-versioning). | | `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | | `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | | `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Every node names [exactly one gate](#public-pages--menu-items). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. | | `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). | | `profileMenu` | no | `ProfileMenuEntry[]`: `{ href, label, icon?, …gate }` links added above Sign out in the profile dropdown, shown only to a signed-in visitor. Each names [exactly one gate](#public-pages--menu-items); `icon` and `label` follow the same rules as a `nav` node. The host hands the visitor's entries to the shell as `chrome.user.menu`. | | `routes` | no | See [Routes & handlers](#routes--handlers). | | `hooks` | no | See [Hooks](#hooks). | | `settings` | no | Configuration this plugin accepts, one `PLUGIN_SETTING__` variable per key, resolved and validated at boot and handed to `onBoot`. See [Plugin settings](#plugin-settings). | | `storage` | no | `true` ⇒ the host provisions a Postgres database and login role for this plugin and hands the credentials to `onBoot`. See [Plugin storage](#plugin-storage). | A plugin may be routes-only, nav-only, or hooks-only — every collection field is optional. ### Routes & handlers A route is `{ method, path, handler }` plus [exactly one gate](#public-pages--menu-items) — `permission`, `public: true` or `session: true`. `path` is **relative to the plugin's mount path `/`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host matches `method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, runs the gate ([a coarse JWT-claim check](#nav--permission-gates)), then calls the handler with the [request context](#requestcontext). A failed gate redirects an **anonymous** visitor to `/login` with the page as `return_to`; a **signed-in** user lacking the permission gets the **403** page. `method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`. A handler returns a **`RouteResult`** (or a `Promise` of one); the host turns it into the HTTP response. Returning `void` is the escape hatch — the handler wrote to `ctx.res` itself. ```ts // Optional on every variant below: status (HTTP status code) and headers (extra response headers). type ResponseMeta = { status?: number; headers?: Record }; type RouteResult = // Render the plugin's own view (plugins//views/.ejs) with `data`. | ResponseMeta & { view: string; data?: Record } // Pre-rendered HTML, sent as-is. | ResponseMeta & { html: string } // JSON body | ResponseMeta & { json: unknown } // Redirect to a URL (takes only status, no headers). | { redirect: string; status?: number }; ``` ```ts // handlers.ts import { parseListQuery, type RequestContext } from "@plainpages/plugin-api"; export async function listThings(ctx: RequestContext) { const q = parseListQuery(ctx.url); const rows = await fetch(`${upstream}/things?${ctx.url.searchParams}`).then((r) => r.json()); return { view: "things", data: { rows, q } }; // renders plugins/things/views/things.ejs } ``` - **`view`** resolves against the plugin's own `views/` (`src/plugin-host/view-resolver.ts`) — nested names like `"things/edit"` work, out-of-bounds names are refused. The template may `include()` the core building-block partials and its own. To load the plugin's own CSS, pass its `/public//x.css` href in the shell's `styles` slot — see the reference's `views/shifts.ejs`. The host adds the file's content hash to every `/public/` href the shell renders, so browsers and any cache in front of Plainpages keep the file for a year. Get the same for a script or image your own markup links by wrapping its href: `