Organize src/ into concern folders (http, auth, admin, plugin-host, ui); co-locate tests, move plugin-api barrel into plugin-host, sync docs + AGENTS layout

This commit is contained in:
2026-06-24 00:23:55 +02:00
parent 6d316c4888
commit de22f51c12
113 changed files with 282 additions and 265 deletions
+20 -20
View File
@@ -2,7 +2,7 @@
The authoritative reference for the plugin API — the product's main surface. A plugin is a
self-contained folder under `plugins/` that the host discovers at boot; there is no
registration step. The contract is **TypeScript** (`src/plugin.ts`), so the types here are the
registration step. The contract is **TypeScript** (`src/plugin-host/plugin.ts`), so the types here are the
single source of truth — this document explains them, the guarantees around them, and the rules
the host enforces.
@@ -14,13 +14,13 @@ crash-isolation (one bad plugin can't take the host down) is a *non-goal* — di
time, not in production.
> **Status.** This is the contract the host implements. The types and pure rules
> (`checkApiVersion`, `findConflicts`, `isValidPluginId`) live in `src/plugin.ts`; **discovery**
> (`src/discovery.ts`), the **router** (`src/router.ts` — method+path match, `:name` params,
> (`checkApiVersion`, `findConflicts`, `isValidPluginId`) live in `src/plugin-host/plugin.ts`; **discovery**
> (`src/plugin-host/discovery.ts`), the **router** (`src/plugin-host/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/<id>/views/`, with the core partials
> (`src/plugin-host/view-resolver.ts` — a `view` result renders `plugins/<id>/views/`, with the core partials
> reachable via `include()`), **per-plugin static serving** (`/public/<id>/` → the plugin's
> `public/`, `routePublic` in `src/static.ts`), and the **central menu override + branding**
> (`config/menu.ts`, loaded by `src/menu-config.ts`, with branding — name, logo, default theme —
> `public/`, `routePublic` in `src/http/static.ts`), and the **central menu override + branding**
> (`config/menu.ts`, loaded by `src/ui/menu-config.ts`, with branding — name, logo, default theme —
> rendered in the app shell) are wired and in use by the built-in screens and the reference plugin.
> Later phases extended this contract: the replaceable [landing pages](#the-landing-pages-home--dashboard)
> and [public pages & menu items](#public-pages--menu-items), both documented below.
@@ -56,20 +56,20 @@ Nothing else references it; the operator stays in control through the central me
## The manifest
A plugin imports its host surface from one module — `src/plugin-api.ts`, the **stable author
A plugin imports its host surface from one module — `src/plugin-host/plugin-api.ts`, the **stable author
barrel** (`definePlugin`, the manifest/handler types, `RequestContext`, the guards, and the
body/CSRF/list-query helpers). That barrel *is* the contract boundary; don't reach into deeper
`src/*` modules — the host may refactor those freely as long as the barrel holds.
```ts
import { definePlugin } from "../../src/plugin-api.ts";
import { definePlugin } from "../../src/plugin-host/plugin-api.ts";
import { listShifts, createShift } from "./shifts.ts";
export default definePlugin({
apiVersion: "1.0.0", // semver of the host contract this was built against (a literal — see Versioning)
// Nav fragment, merged into the global menu and permission-filtered per user.
// `icon` is a Lucide icon by its sprite id (src/icons.ts).
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
nav: [{
icon: "i-cal", id: "scheduling:root", label: "Scheduling",
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
@@ -101,7 +101,7 @@ there is **no `id` or `basePath`** in the manifest — both come from the folder
| `apiVersion` | yes | Semver the plugin was built against — a **literal**, not `HOST_API_VERSION`. 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). `icon` is a Lucide sprite id (`src/icons.ts`); node `id`s must be globally unique. |
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. |
| `permissions` | no | Tokens this plugin introduces; declared for docs, conflict detection, and bootstrap seeding (see [Nav & permissions](#nav--permissions)). |
| `routes` | no | See [Routes & handlers](#routes--handlers). |
| `hooks` | no | See [Hooks](#hooks). |
@@ -136,7 +136,7 @@ type RouteResult =
```ts
// shifts.ts
import { parseListQuery, type RequestContext } from "../../src/plugin-api.ts";
import { parseListQuery, type RequestContext } from "../../src/plugin-host/plugin-api.ts";
export async function listShifts(ctx: RequestContext) {
const q = parseListQuery(ctx.url);
@@ -145,13 +145,13 @@ export async function listShifts(ctx: RequestContext) {
}
```
- **`view`** resolves against the plugin's own `views/` (`src/view-resolver.ts`) — nested names
- **`view`** resolves against the plugin's own `views/` (`src/plugin-host/view-resolver.ts`) — nested names
like `"shifts/edit"` work, and an out-of-bounds name is refused. The template may `include()`
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. To load the
plugin's own CSS, pass its `/public/<id>/x.css` href in the shell's `styles` slot (an array of
extra stylesheet hrefs) — see the reference's `views/shifts.ejs`.
- **Finer authorization than the route `permission`** uses the guards from `src/plugin-api.ts`:
- **Finer authorization than the route `permission`** uses the guards from `src/plugin-host/plugin-api.ts`:
`requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect
to sign in), `can(ctx, role)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx,
{namespace, object, relation})` (a live Keto check for relationship rules — the subject is the
@@ -173,10 +173,10 @@ safety of the data it renders**:
names), so those are injection-safe. But a URL field — nav `href`, a table cell link, a menu
item, a breadcrumb, `brand.logo` — is emitted as-is inside the attribute: a `javascript:` or
`data:` URL from upstream/user data becomes live XSS. When a URL comes from data you don't
control, pass it through **`safeUrl()`** from `src/plugin-api.ts` first — it returns the URL when
control, pass it through **`safeUrl()`** from `src/plugin-host/plugin-api.ts` first — it returns the URL when
it's relative or `http(s):` and collapses anything else to `"#"`:
```ts
import { safeUrl } from "../../src/plugin-api.ts";
import { safeUrl } from "../../src/plugin-host/plugin-api.ts";
return { view: "list", data: { rows: rows.map((r) => ({ ...r, href: safeUrl(r.href) })) } };
```
@@ -190,7 +190,7 @@ The host has two replaceable landing slots, and a plugin may own either or both:
| `dashboard` | `/dashboard` | **signed-in session** (anonymous → `/login`, with `/dashboard` as `return_to`) | The built-in mock-data People list. |
```ts
import { definePlugin } from "../../src/plugin-api.ts";
import { definePlugin } from "../../src/plugin-host/plugin-api.ts";
import { landing, board } from "./pages.ts";
export default definePlugin({
@@ -220,7 +220,7 @@ route at all (route paths always carry the `/<id>` prefix).
## RequestContext
Every handler receives one argument, the `RequestContext` (`src/context.ts`), built once per
Every handler receives one argument, the `RequestContext` (`src/http/context.ts`), built once per
request:
```ts
@@ -275,12 +275,12 @@ from the JWT middleware and are `null`/`[]` until a session exists.
## Nav & permissions
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/nav.ts`), which
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
applies the central override and then **filters per user** by the roles in the session JWT — a
node shows iff it is `public`, declares no `permission`, or the user's roles include that token. Use
arbitrary depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` is a
**Lucide icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids
are `ICON_NAMES` in `src/icons.ts`, and adding one means registering its lucide name there.
are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there.
### Public pages & menu items
@@ -385,7 +385,7 @@ worked example: thin handlers bound to an injectable upstream client, unit-teste
1. **Unit-test handlers as pure functions.** Keep a handler thin: parse `ctx`, fetch upstream,
return a `RouteResult`. Test the data-shaping in isolation (mock `fetch`/upstream) with
`node --test`, exactly like `src/dashboard.test.ts` tests the dashboard model. No host needed.
`node --test`, exactly like `src/ui/dashboard.test.ts` tests the dashboard model. No host needed.
```bash
docker compose run --rm web npm test