Let a plugin carry its own package.json and npm dependencies

This commit is contained in:
2026-08-17 22:23:53 +02:00
parent 1ba6dbdc51
commit af974cfa36
30 changed files with 173 additions and 66 deletions
+17 -12
View File
@@ -67,20 +67,25 @@ Revisit only if the stated reason stops holding.
with `server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` at the root; tests are with `server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` at the root; tests are
co-located. Add a new module to the folder owning its concern. The core ships **no domain co-located. Add a new module to the folder owning its concern. The core ships **no domain
screens** — even the admin GUI is a drop-in plugin (`examples/plugins/admin/`). screens** — even the admin GUI is a drop-in plugin (`examples/plugins/admin/`).
- **Plugins and config import the host only via package.json `imports`** — `#plugin-api` - **Plugins and config import the host only through a barrel** — `@plainpages/plugin-api`
`src/plugin-host/plugin-api.ts`, `#menu-config``src/ui/menu-config.ts`, never a relative `plugin-api/index.ts``src/plugin-host/plugin-api.ts`, `#menu-config``src/ui/menu-config.ts`,
`../../src/*` path. These two barrels are the whole contract surface; don't "fix" a `#`-import never a relative `../../src/*` path. These two barrels are the whole contract surface; don't "fix"
back to a relative path. Two consequences: either back to a relative path. Three consequences:
- `#plugin-api` re-exports the Ory client types (`KratosAdmin`/`KetoClient`/`HydraAdmin` + their - `@plainpages/plugin-api` re-exports the Ory client types (`KratosAdmin`/`KetoClient`/`HydraAdmin` + their
DTOs and error classes), so those shapes are **contract-visible** — changing them needs a major DTOs and error classes), so those shapes are **contract-visible** — changing them needs a major
`apiVersion` bump, not a free refactor. `apiVersion` bump, not a free refactor.
- **A plugin/config folder must stay a plain folder — no `package.json` of its own**, which would - **The barrel is a package, not a `#`-import, so a plugin folder may carry its own
become its own scope and stop `#`-specifiers resolving. A plugin kept in its own repo therefore `package.json`** and depend on npm packages (README → Plugin dependencies). The Dockerfile links
typechecks against the barrel only when mounted under the host tree (or with a vendored stub). it into `/node_modules`, above every plugin scope. Never let a copy reach a plugin's own
`node_modules`: two instances of the barrel break `instanceof` across the boundary, which
`plugin-api.test.ts` guards by asserting both paths reach one module.
- **`config/` is still a plain dir — no `package.json` of its own**, or `#menu-config` resolves
against that instead and boot fails loud. An operator's menu override has no use for
dependencies; if that changes, it needs the same package treatment.
- **`examples/` mirrors the drop-in mount dirs** — `examples/plugins/<id>/` copies to - **`examples/` mirrors the drop-in mount dirs** — `examples/plugins/<id>/` copies to
`plugins/<id>/`, `examples/config/menu.ts` to `config/menu.ts`. Both mirrors are in `plugins/<id>/`, `examples/config/menu.ts` to `config/menu.ts`. Both mirrors are in
`tsconfig.include` and resolve the host via `#`-imports, so each typechecks in place *and* copies `tsconfig.include` and resolve the host through the barrels, so each typechecks in place *and*
across unchanged. Never commit real plugins/config into the root mount dirs — they ship empty. copies across unchanged. Never commit real plugins/config into the root mount dirs — they ship empty.
- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the base request - **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the base request
context. It protects the I/O-free hot path on the public, bot-hit landing (`/`). context. It protects the I/O-free hot path on the public, bot-hit landing (`/`).
- **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`, - **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`,
@@ -123,7 +128,7 @@ Revisit only if the stated reason stops holding.
with the e2e stacks, which bind individual plugins *inside* `/app/plugins` (a nested mount into a with the e2e stacks, which bind individual plugins *inside* `/app/plugins` (a nested mount into a
read-only parent is EROFS and the container never starts). Valid while bootstrap is the only read-only parent is EROFS and the container never starts). Valid while bootstrap is the only
writer of grants. writer of grants.
- **`actionForMethod` is plugin-local and must not migrate into `#plugin-api`.** Inside the admin - **`actionForMethod` is plugin-local and must not migrate into `@plainpages/plugin-api`.** Inside the admin
example it keeps the route table and the in-handler guard deriving from one function, so 29 routes example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
× 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport × 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
verb — a route table must answer "what does this need?" on its own. verb — a route table must answer "what does this need?" on its own.
@@ -205,7 +210,7 @@ Revisit only if the stated reason stops holding.
profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
the two in step. the two in step.
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it - **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it
is deliberately not re-exported from `#plugin-api`. The palette may narrow when the last reference is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference
to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an
unknown sprite id renders blank instead of failing loud (the `every icon <use> resolves` e2e test unknown sprite id renders blank instead of failing loud (the `every icon <use> resolves` e2e test
catches anything reaching the nav). catches anything reaching the nav).
+4
View File
@@ -7,6 +7,10 @@ FROM node:24.19.0-alpine3.24
COPY package.json package-lock.json .npmrc /deps/ COPY package.json package-lock.json .npmrc /deps/
RUN cd /deps && npm ci && mv node_modules /node_modules && rm -rf /deps RUN cd /deps && npm ci && mv node_modules /node_modules && rm -rf /deps
# The barrel as a package, so a plugin folder can own a package.json. Linked, not copied — it
# re-exports source under /app.
RUN mkdir -p /node_modules/@plainpages && ln -s /app/plugin-api /node_modules/@plainpages/plugin-api
WORKDIR /app WORKDIR /app
COPY . . COPY . .
+1 -1
View File
@@ -178,7 +178,7 @@ Everything domain-specific is a plugin folder — the compose above mounts `./pl
into the app. Create `plugins/hello/plugin.ts`: into the app. Create `plugins/hello/plugin.ts`:
```ts ```ts
import { definePlugin } from "#plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({ export default definePlugin({
apiVersion: "1.0.0", apiVersion: "1.0.0",
+65 -24
View File
@@ -44,7 +44,7 @@ one-shot `bootstrap` service, and only `up` re-runs it. See
folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.ts`: folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.ts`:
```ts ```ts
import { definePlugin } from "#plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({ export default definePlugin({
apiVersion: "1.0.0", apiVersion: "1.0.0",
@@ -88,6 +88,7 @@ From here, render real pages against the app shell and fetch upstream data — s
- [conflict rules](#conflict-rules) - [conflict rules](#conflict-rules)
- [hooks](#hooks) - [hooks](#hooks)
- [where they live & mounting](#where-plugins-live-and-how-to-mount-them) - [where they live & mounting](#where-plugins-live-and-how-to-mount-them)
- [dependencies](#plugin-dependencies)
- [local dev & test](#local-dev--test-story) - [local dev & test](#local-dev--test-story)
- [The menu system](#the-menu-system) - [The menu system](#the-menu-system)
- [Building blocks](#building-blocks) - [Building blocks](#building-blocks)
@@ -312,6 +313,8 @@ plugins/things/ # the plugin folder — its name is the id AND the moun
en-US.ts # the baseline; sv-SE.ts et al are written against its type 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 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 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 **Only `plugin.ts` is required.** `views/`, `public/` and `i18n/` are fixed folder *names* the host
@@ -332,15 +335,15 @@ Installing a plugin is "drop the folder, restart"; removing one is "delete the f
### The manifest ### The manifest
A plugin imports its host surface from one module — **`#plugin-api`**, a Node [subpath A plugin imports its host surface from one module — **`@plainpages/plugin-api`** (`definePlugin`, the
import](https://nodejs.org/api/packages.html#subpath-imports) mapped to `src/plugin-host/plugin-api.ts` manifest/handler types, `RequestContext`, the guards, and the body/CSRF/list-query helpers). The host
in the root `package.json` (`definePlugin`, the manifest/handler types, `RequestContext`, the guards, publishes it as a package, so it resolves from any depth and from a plugin folder that has a
and the body/CSRF/list-query helpers). That barrel **is** the contract boundary — never a relative `package.json` of its own ([Plugin dependencies](#plugin-dependencies)). That barrel **is** the
`../../src/...` path; the host refactors everything behind it freely. Keep your plugin a plain folder contract boundary — never a relative `../../src/...` path; the host refactors everything behind it
with no `package.json` of its own, or `#plugin-api` resolves against that instead. freely.
```ts ```ts
import { definePlugin } from "#plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
import { listThings, createThings } from "./handlers.ts"; import { listThings, createThings } from "./handlers.ts";
export default definePlugin({ export default definePlugin({
@@ -412,7 +415,7 @@ type RouteResult =
```ts ```ts
// handlers.ts // handlers.ts
import { parseListQuery, type RequestContext } from "#plugin-api"; import { parseListQuery, type RequestContext } from "@plainpages/plugin-api";
export async function listThings(ctx: RequestContext) { export async function listThings(ctx: RequestContext) {
const q = parseListQuery(ctx.url); const q = parseListQuery(ctx.url);
@@ -425,7 +428,7 @@ export async function listThings(ctx: RequestContext) {
nested names like `"things/edit"` work, out-of-bounds names are refused. The template may 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 `include()` the core building-block partials and its own. To load the plugin's own CSS, pass its
`/public/<id>/x.css` href in the shell's `styles` slot — see the reference's `views/shifts.ejs`. `/public/<id>/x.css` href in the shell's `styles` slot — see the reference's `views/shifts.ejs`.
- **Finer authorization than the route `permission`** uses the guards from `#plugin-api`: - **Finer authorization than the route `permission`** uses the guards from `@plainpages/plugin-api`:
`requireSession(ctx)`, `can(ctx, permission)` (coarse JWT-claim check, zero I/O), and `requireSession(ctx)`, `can(ctx, permission)` (coarse JWT-claim check, zero I/O), and
`check(keto, ctx, {namespace, object, relation})` (a live Keto check; anonymous ⇒ denied). Throw `check(keto, ctx, {namespace, object, relation})` (a live Keto check; anonymous ⇒ denied). Throw
`new GuardError(403, …)` after a failed `can`/`check` to render the 403 page. `new GuardError(403, …)` after a failed `can`/`check` to render the 403 page.
@@ -443,9 +446,9 @@ The host does not sandbox plugin output, so a handler **owns the safety of the d
- **Text is auto-escaped; URLs are not scheme-checked.** A URL field — nav `href`, a table cell - **Text is auto-escaped; URLs are not scheme-checked.** A URL field — nav `href`, a table cell
link, a menu item, a breadcrumb, `brand.logo` — is emitted as-is inside the attribute, so a link, a menu item, a breadcrumb, `brand.logo` — is emitted as-is inside the attribute, so a
`javascript:` or `data:` URL from upstream data becomes live XSS. Pass any URL you don't control `javascript:` or `data:` URL from upstream data becomes live XSS. Pass any URL you don't control
through **`safeUrl()`** from `#plugin-api`; it collapses anything but relative/`http(s):` to `"#"`: through **`safeUrl()`** from `@plainpages/plugin-api`; it collapses anything but relative/`http(s):` to `"#"`:
```ts ```ts
import { safeUrl } from "#plugin-api"; import { safeUrl } from "@plainpages/plugin-api";
return { view: "list", data: { rows: rows.map((r) => ({ ...r, href: safeUrl(r.href) })) } }; return { view: "list", data: { rows: rows.map((r) => ({ ...r, href: safeUrl(r.href) })) } };
``` ```
@@ -459,7 +462,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. | | `dashboard` | `/dashboard` | **signed-in session** (anonymous → `/login`, with `/dashboard` as `return_to`) | The built-in mock-data People list. |
```ts ```ts
import { definePlugin } from "#plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
import { landing, board } from "./pages.ts"; import { landing, board } from "./pages.ts";
export default definePlugin({ export default definePlugin({
@@ -537,7 +540,7 @@ ones may be added within it. `req`/`res` are the raw Node escape hatch — prefe
Most plugins fetch their own data from an upstream service they configure. A **system plugin** — one Most plugins fetch their own data from an upstream service they configure. A **system plugin** — one
that administers *Plainpages' own* identity stack — needs the host's Ory admin clients and the that administers *Plainpages' own* identity stack — needs the host's Ory admin clients and the
instant-revoke hook instead. The host exposes those on **`ctx.system`**, and re-exports the client instant-revoke hook instead. The host exposes those on **`ctx.system`**, and re-exports the client
types + their error classes from `#plugin-api`: types + their error classes from `@plainpages/plugin-api`:
```ts ```ts
interface SystemCapabilities { // every field optional — present only when the host wired it interface SystemCapabilities { // every field optional — present only when the host wired it
@@ -671,11 +674,47 @@ docker compose -f compose.yml -f compose.plugins.yml up -d
A named volume works the same way (target `/app/plugins/<id>`). For a **baked** production image, A named volume works the same way (target `/app/plugins/<id>`). For a **baked** production image,
keep the plugin in the build context and it is `COPY`'d in at build time. keep the plugin in the build context and it is `COPY`'d in at build time.
`#plugin-api` resolves against the *nearest* `package.json`, which at runtime must be the host's at A plugin kept in its own repo mounts whole, `package.json` and all — see below.
`/app` — so a mounted `plugins/<id>/` must **not** contain a `package.json` of its own, or boot fails
loud. A plugin kept in its own repo therefore mounts as just its subfolder, its `package.json` left ### Plugin dependencies
outside the mount. To typecheck it there, typecheck it mounted under the host tree, or vendor a type
stub of the barrel and map `#plugin-api` to that. A plugin may depend on npm packages. It owns them completely: its `package.json`, its lockfile and
its `node_modules` live in the plugin folder, and nothing about them reaches the host's — installing
a plugin is still just getting its folder to `/app/plugins/<id>`.
Write the manifest yourself — `"type": "module"` is required, and the host refuses a plugin without
it, because that file (not the host's) is what tells Node how to parse everything beside it:
```json
{ "name": "things", "version": "0.0.0", "type": "module" }
```
Then install into the folder. `--save-exact` pins the version: the root `.npmrc` does not reach a
`--prefix`, so without it npm writes a range.
```bash
# The uid keeps the files it writes yours rather than root's.
docker compose run --rm --no-deps --user "$(id -u):$(id -g)" web \
npm install --prefix plugins/things --save-exact ms
```
A plugin in its own repo runs its own `npm ci` instead and mounts the result — `node_modules`
included, since the plugin folder *is* the repo. A baked image needs no extra step: the plugin's
`node_modules` is part of the build context and is `COPY`'d in with the rest of the folder.
Two rules follow from how Node resolves:
- **Never ship a copy of `@plainpages/plugin-api`.** The host publishes it into `/node_modules`,
above every plugin, and a plugin resolves it from there — nothing to declare, just import it. A
copy inside your own `node_modules` shadows it with a *second* instance of the host's contract,
and every `instanceof GuardError` a handler makes silently starts returning `false`. (A type stub
for standalone typechecking is fine — keep it out of what you mount.)
- **Your dependencies are yours alone.** Two plugins depending on the same package each get their
own copy at their own version, so neither can break the other by upgrading.
`npm run typecheck` covers `plugins/`, so a dependency shipping no types of its own needs its
`@types/` in your plugin's `devDependencies`. Typechecking a plugin repo standalone still needs the
barrel's types on disk: typecheck it mounted under the host tree, or vendor a type stub.
### Local dev & test story ### Local dev & test story
@@ -801,13 +840,13 @@ points the picker at this path when it answers GET, else the page the form was s
**Writing a catalog.** `en-US.ts` exports the object and its type; every other locale is written **Writing a catalog.** `en-US.ts` exports the object and its type; every other locale is written
against that type, so a missing or misspelled key is a type error before the app ever boots. For a against that type, so a missing or misspelled key is a type error before the app ever boots. For a
language of your own: copy `src/i18n/locales/en-US.ts` into `locales/<tag>.ts`, type it language of your own: copy `src/i18n/locales/en-US.ts` into `locales/<tag>.ts`, type it
`CoreMessages` (from `#plugin-api`), and translate. The `as PluralMessage` cast below is required — `CoreMessages` (from `@plainpages/plugin-api`), and translate. The `as PluralMessage` cast below is required —
without it the inferred type pins the plural forms to English's two, and a locale that selects more without it the inferred type pins the plural forms to English's two, and a locale that selects more
(Polish, Arabic) becomes unwritable: (Polish, Arabic) becomes unwritable:
```ts ```ts
// plugins/shop/i18n/en-US.ts // plugins/shop/i18n/en-US.ts
import type { PluralMessage } from "#plugin-api"; import type { PluralMessage } from "@plainpages/plugin-api";
const messages = { const messages = {
"shop.title": "Shop", "shop.title": "Shop",
@@ -836,7 +875,7 @@ render in `en-US`), never one the host doesn't have.
return { data: { title: ctx.t("shop.title"), lead: ctx.t("shop.greeting", { name }) }, view: "shop" }; return { data: { title: ctx.t("shop.title"), lead: ctx.t("shop.greeting", { name }) }, view: "shop" };
// a pure view model built outside a request (its unit test) defaults to the plugin's own English: // a pure view model built outside a request (its unit test) defaults to the plugin's own English:
import { englishTranslator, type Translate } from "#plugin-api"; import { englishTranslator, type Translate } from "@plainpages/plugin-api";
import enUS from "./i18n/en-US.ts"; import enUS from "./i18n/en-US.ts";
const EN: Translate = englishTranslator(enUS); // your catalog, then the host's const EN: Translate = englishTranslator(enUS); // your catalog, then the host's
``` ```
@@ -1469,11 +1508,12 @@ src/ The app — strict tsc, no build step. *.test.ts sit beside
fetch-timeout) fetch-timeout)
i18n/ catalog (parity rules) · locale (resolution) · translate · load · runtime · i18n/ catalog (parity rules) · locale (resolution) · translate · load · runtime ·
english · view-locals · locales/ (the core en-US + sv-SE catalogs) english · view-locals · locales/ (the core en-US + sv-SE catalogs)
plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `#plugin-api` barrel) · system.ts plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `@plainpages/plugin-api` barrel) · system.ts
(ctx.system) · discovery · router · hooks · view-resolver (ctx.system) · discovery · router · hooks · view-resolver
ui/ chrome (the one global menu) · shell-context · dashboard · nav (composeNav) · ui/ chrome (the one global menu) · shell-context · dashboard · nav (composeNav) ·
menu-config (`#menu-config`) · icons (lucide sprite builder) · list-query · paginate menu-config (`#menu-config`) · icons (lucide sprite builder) · list-query · paginate
plugin-api/ The `@plainpages/plugin-api` package — the author barrel, linked into /node_modules
views/ Core EJS in the one app shell: home, index, auth, oauth-consent, error, 403/404/500/503, views/ Core EJS in the one app shell: home, index, auth, oauth-consent, error, 403/404/500/503,
and partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, and partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card,
alert, menu/popover, theme switch, language picker, icon sprite). Domain screens live alert, menu/popover, theme switch, language picker, icon sprite). Domain screens live
@@ -1498,7 +1538,8 @@ README-dockerhub.md The Docker Hub repository description, pasted over by hand
- **New page in a plugin:** add a route + handler to the plugin manifest and a template in - **New page in a plugin:** add a route + handler to the plugin manifest and a template in
its `views/`. its `views/`.
- **Static asset:** drop it in the plugin's `public/`; served at `/public/<plugin>/<path>`. - **Static asset:** drop it in the plugin's `public/`; served at `/public/<plugin>/<path>`.
- **New dependency:** deps live in the image, so update the manifest + lockfile and rebuild — - **New dependency in a plugin:** the plugin owns it — see [Plugin dependencies](#plugin-dependencies).
- **New dependency in the core:** deps live in the image, so update the manifest + lockfile and rebuild —
`--package-lock-only` writes nothing into the checkout, `--user` keeps the two files yours. `--package-lock-only` writes nothing into the checkout, `--user` keeps the two files yours.
Keep deps minimal — prefer the Node standard library, and an Ory REST call over an SDK. Keep deps minimal — prefer the Node standard library, and an Ory REST call over an SDK.
+1 -1
View File
@@ -5,7 +5,7 @@ across (or bind-mount your own) and restart.
| Path | Copy into | Example of | | Path | Copy into | Example of |
| --- | --- | --- | | --- | --- | --- |
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `#plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). | | [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `@plainpages/plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). | | [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). | | [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. | | [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. |
+1 -1
View File
@@ -5,7 +5,7 @@
// PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin // PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded. // per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
import { can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api"; import { can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts"; import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts"; import type { FieldConfig } from "./admin-users.ts";
+1 -1
View File
@@ -2,7 +2,7 @@
// two screens render from it. The Keto writes and the HTTP round trip are covered in app.test.ts. // two screens render from it. The Keto writes and the HTTP round trip are covered in app.test.ts.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import type { PermissionDecl } from "#plugin-api"; import type { PermissionDecl } from "@plainpages/plugin-api";
import { buildPermissionPicker, grantDiff, grantTuple, groupSubject, userSubject } from "./admin-grants.ts"; import { buildPermissionPicker, grantDiff, grantTuple, groupSubject, userSubject } from "./admin-grants.ts";
const declared: PermissionDecl[] = [ const declared: PermissionDecl[] = [
+1 -1
View File
@@ -6,7 +6,7 @@
// what the installed plugins declare in code. Nothing here invents a name, which is why the old // what the installed plugins declare in code. Nothing here invents a name, which is why the old
// Permissions screen is gone: a grant is a property of a user or a group, edited where they are. // Permissions screen is gone: a grant is a property of a user or a group, edited where they are.
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "#plugin-api"; import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api";
const PERMISSION_NS = "Permission"; const PERMISSION_NS = "Permission";
const GRANTED = "granted"; const GRANTED = "granted";
+1 -1
View File
@@ -14,7 +14,7 @@ import {
memberView, memberView,
parseSubject, parseSubject,
} from "./admin-groups.ts"; } from "./admin-groups.ts";
import type { RelationTuple } from "#plugin-api"; import type { RelationTuple } from "@plainpages/plugin-api";
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`; const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const userTuple = (group: string, n: number): RelationTuple => const userTuple = (group: string, n: number): RelationTuple =>
+1 -1
View File
@@ -6,7 +6,7 @@
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded, // per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
// each returning a RouteResult. // each returning a RouteResult.
import { can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "#plugin-api"; import { can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD } from "./admin-grants.ts"; import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts"; import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts"; import type { FieldConfig } from "./admin-users.ts";
+2 -2
View File
@@ -1,12 +1,12 @@
// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical // Direct units for the admin plugin's shared nav + auth helpers. They're security-critical
// (requirePermission/guardedForm gate every admin write) and reused across all three screens, so pin the // (requirePermission/guardedForm gate every admin write) and reused across all three screens, so pin the
// contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts. // contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts.
// Import only from the #plugin-api barrel — the same contract boundary the plugin code uses. // Import only from the @plainpages/plugin-api barrel — the same contract boundary the plugin code uses.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { test } from "node:test"; import { test } from "node:test";
import { GuardError, isValidPermissionName, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api"; import { GuardError, isValidPermissionName, type Log, type PageChrome, type RequestContext, type User } from "@plainpages/plugin-api";
import { ADMIN_EN, ADMIN_NAV, ADMIN_USERS_BASE, actionForMethod, buildConfirmModel, guardedForm, permissionName, requirePermission } from "./admin-shared.ts"; import { ADMIN_EN, ADMIN_NAV, ADMIN_USERS_BASE, actionForMethod, buildConfirmModel, guardedForm, permissionName, requirePermission } from "./admin-shared.ts";
const reader: User = { email: "ada@x.io", id: "u1", permissions: ["users:read"] }; const reader: User = { email: "ada@x.io", id: "u1", permissions: ["users:read"] };
+2 -2
View File
@@ -1,8 +1,8 @@
// Shared plumbing for the admin example plugin: the section nav fragment, the screen gate, the // Shared plumbing for the admin example plugin: the section nav fragment, the screen gate, the
// CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers. // CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers.
// Everything imports the host only through the #plugin-api barrel. // Everything imports the host only through the @plainpages/plugin-api barrel.
import { can, CSRF_FIELD, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "#plugin-api"; import { can, CSRF_FIELD, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import enUS from "./i18n/en-US.ts"; import enUS from "./i18n/en-US.ts";
// This plugin's English — its catalog, then the host's — for a view model built outside a request, // This plugin's English — its catalog, then the host's — for a view model built outside a request,
+1 -1
View File
@@ -2,7 +2,7 @@
// routing/gate/CSRF + live Kratos calls are exercised over HTTP in src/http/app.test.ts. // routing/gate/CSRF + live Kratos calls are exercised over HTTP in src/http/app.test.ts.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import type { Identity } from "#plugin-api"; import type { Identity } from "@plainpages/plugin-api";
import { import {
buildUserFormModel, buildUserFormModel,
buildUsersListModel, buildUsersListModel,
+1 -1
View File
@@ -3,7 +3,7 @@
// into building-block view models; below them are thin per-route handlers keyed on ctx.params, over // into building-block view models; below them are thin per-route handlers keyed on ctx.params, over
// a shared `withUser` gate. // a shared `withUser` gate.
import { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api"; import { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD, userSubject } from "./admin-grants.ts"; import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD, userSubject } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts"; import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
+1 -1
View File
@@ -3,7 +3,7 @@
// with nothing in the logs to explain it. Pin the two halves against each other here. // with nothing in the logs to explain it. Pin the two halves against each other here.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { isValidPermissionName } from "#plugin-api"; import { isValidPermissionName } from "@plainpages/plugin-api";
import manifest from "./plugin.ts"; import manifest from "./plugin.ts";
const routes = manifest.routes ?? []; const routes = manifest.routes ?? [];
+1 -1
View File
@@ -4,7 +4,7 @@
// It is a *system* plugin: its handlers reach the host's Ory admin clients and the instant-revoke // It is a *system* plugin: its handlers reach the host's Ory admin clients and the instant-revoke
// hook via ctx.system. Where a capability is absent the screen degrades to a themed 503. // hook via ctx.system. Where a capability is absent the screen degrades to a themed 503.
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api"; import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "@plainpages/plugin-api";
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts"; import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts"; import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts";
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts"; import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
+1 -1
View File
@@ -2,7 +2,7 @@
// looked up here first and fall back to the host's, so a plugin owns its words without prefixing // looked up here first and fall back to the host's, so a plugin owns its words without prefixing
// them, and `shifts.count` shows the plural form (host: README → Translating). // them, and `shifts.count` shows the plural form (host: README → Translating).
import type { PluralMessage } from "#plugin-api"; import type { PluralMessage } from "@plainpages/plugin-api";
const messages = { const messages = {
"scheduling.field.assignee": "Assignee", "scheduling.field.assignee": "Assignee",
+1 -1
View File
@@ -2,7 +2,7 @@
// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this // data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins. // folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
import { definePlugin } from "#plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts"; import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
// The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is // The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is
+2 -2
View File
@@ -2,9 +2,9 @@ import assert from "node:assert/strict";
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import test from "node:test"; import test from "node:test";
// Import only from the #plugin-api barrel — the same contract boundary shifts.ts uses (the host may // Import only from the @plainpages/plugin-api barrel — the same contract boundary shifts.ts uses (the host may
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches. // refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "#plugin-api"; import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "@plainpages/plugin-api";
import enUS from "./i18n/en-US.ts"; import enUS from "./i18n/en-US.ts";
import { import {
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput, assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
+2 -2
View File
@@ -5,8 +5,8 @@
// Handlers are factories bound to a ShiftsUpstream, and `fetch` is injectable, so they unit-test as // Handlers are factories bound to a ShiftsUpstream, and `fetch` is injectable, so they unit-test as
// pure functions against a mock upstream with no network (README.md → Local dev & test story). // pure functions against a mock upstream with no network (README.md → Local dev & test story).
// One import from the host's #plugin-api barrel — the stable author surface (see README.md → Building plugins). // One import from the host's @plainpages/plugin-api barrel — the stable author surface (see README.md → Building plugins).
import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, type Translate, tracedFetch } from "#plugin-api"; import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, type Translate, tracedFetch } from "@plainpages/plugin-api";
import enUS from "./i18n/en-US.ts"; import enUS from "./i18n/en-US.ts";
// The plugin's own English (its catalog, then the host's), for a view model built outside a request: // The plugin's own English (its catalog, then the host's), for a view model built outside a request:
+1 -2
View File
@@ -7,8 +7,7 @@
"node": ">=24" "node": ">=24"
}, },
"imports": { "imports": {
"#menu-config": "./src/ui/menu-config.ts", "#menu-config": "./src/ui/menu-config.ts"
"#plugin-api": "./src/plugin-host/plugin-api.ts"
}, },
"scripts": { "scripts": {
"start": "node src/server.ts", "start": "node src/server.ts",
+2
View File
@@ -0,0 +1,2 @@
// Re-export rather than the surface itself: a package's `exports` target may not escape its folder.
export * from "../src/plugin-host/plugin-api.ts";
+7
View File
@@ -0,0 +1,7 @@
{
"name": "@plainpages/plugin-api",
"version": "1.0.0",
"private": true,
"type": "module",
"exports": "./index.ts"
}
+1 -1
View File
@@ -31,7 +31,7 @@ export interface RequestContext {
// on off-site URLs. The host already does this for the chrome and its own redirects; a plugin // on off-site URLs. The host already does this for the chrome and its own redirects; a plugin
// wraps the hrefs it builds itself. // wraps the hrefs it builds itself.
localeHref(href: string): string; localeHref(href: string): string;
// Every installed locale, sorted. With `localeLabel` (from #plugin-api) it is what a plugin needs // Every installed locale, sorted. With `localeLabel` (from the barrel) it is what a plugin needs
// to build its own language picker; the host's own picker is already in the shell. // to build its own language picker; the host's own picker is already in the shell.
locales: string[]; locales: string[];
// Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to // Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to
+20
View File
@@ -57,6 +57,8 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
{ name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s }, { name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s },
{ name: "a nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*admin.*<resource>:<action>/s }, { name: "a nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*admin.*<resource>:<action>/s },
{ name: "a declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "1.0.0", permissions: [{ name: "admin" }] };` }, match: /baredecl.*admin.*<resource>:<action>/s }, { name: "a declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "1.0.0", permissions: [{ name: "admin" }] };` }, match: /baredecl.*admin.*<resource>:<action>/s },
{ name: "a plugin package.json that forgets type: module", files: { "cjs/package.json": `{ "name": "cjs" }`, "cjs/plugin.ts": full("cjs") }, match: /cjs.*"type": "module"/s },
{ name: "a plugin package.json that is not valid JSON", files: { "bent/package.json": `{`, "bent/plugin.ts": full("bent") }, match: /bent.*package\.json.*JSON/s },
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ }, { name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ },
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ }, { name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ },
]; ];
@@ -102,6 +104,24 @@ test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard)
assert.equal(typeof plugins[0]?.dashboard, "function"); assert.equal(typeof plugins[0]?.dashboard, "function");
}); });
// The barrel still resolves from a folder holding its own package.json because host deps sit at
// /node_modules, above every plugin scope (README → Plugin dependencies).
test("a plugin may carry its own package.json, node_modules and dependencies", async (t) => {
const dir = scaffold(t, {
"shop/package.json": `{ "name": "shop", "version": "0.0.0", "type": "module", "dependencies": { "price-tag": "1.0.0" } }`,
"shop/node_modules/price-tag/package.json": `{ "name": "price-tag", "version": "1.0.0", "type": "module", "exports": "./index.js" }`,
"shop/node_modules/price-tag/index.js": `export default (n) => \`\${n} kr\`;`,
"shop/plugin.ts": `import { definePlugin } from "@plainpages/plugin-api";\nimport price from "price-tag";\n` +
`export default definePlugin({ apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", handler: () => ({ html: price(20) }) }] });`,
"node_modules/hoisted/index.js": `export default 1;`,
});
const plugins = await discoverPlugins({ dir });
assert.deepEqual(plugins.map((p) => p.id), ["shop"]); // node_modules is not a plugin folder
assert.deepEqual(await plugins[0]?.routes?.[0]?.handler(null as never), { html: "20 kr" });
});
test("a shared permission name only warns — both plugins still load", async (t) => { test("a shared permission name only warns — both plugins still load", async (t) => {
const shared = `export default { apiVersion: "1.0.0", permissions: [{ name: "shared:read" }] };`; const shared = `export default { apiVersion: "1.0.0", permissions: [{ name: "shared:read" }] };`;
const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared }); const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared });
+23 -3
View File
@@ -4,7 +4,7 @@
// error-level conflict is collected into one boot-stopping Error; warn-level diagnostics // error-level conflict is collected into one boot-stopping Error; warn-level diagnostics
// (older-minor apiVersion, shared permission name) log and load continues. Folder name = id. // (older-minor apiVersion, shared permission name) log and load continues. Folder name = id.
import { existsSync, readdirSync } from "node:fs"; import { existsSync, readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts"; import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
@@ -37,6 +37,8 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
if (RESERVED_PLUGIN_IDS.has(id)) { fail(`"${id}" is a reserved id — it would shadow a built-in host route`); continue; } if (RESERVED_PLUGIN_IDS.has(id)) { fail(`"${id}" is a reserved id — it would shadow a built-in host route`); continue; }
const file = join(dir, id, "plugin.ts"); const file = join(dir, id, "plugin.ts");
if (!existsSync(file)) { fail("no plugin.ts found"); continue; } if (!existsSync(file)) { fail("no plugin.ts found"); continue; }
const packaging = packagingError(join(dir, id));
if (packaging) { fail(packaging); continue; }
let mod: { default?: unknown }; let mod: { default?: unknown };
try { try {
@@ -78,14 +80,32 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
} }
// Subfolders of plugins/, sorted for deterministic load order + stable conflict messages. Hidden // Subfolders of plugins/, sorted for deterministic load order + stable conflict messages. Hidden
// entries (.git, .DS_Store, …) and non-directories are skipped — only folders are plugins. // entries (.git, .DS_Store, …) and non-directories are skipped — only folders are plugins. So is
// node_modules, which npm leaves here when a dependency install is pointed at plugins/ itself.
function pluginFolders(dir: string): string[] { function pluginFolders(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }) return readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith(".")) .filter((e) => e.isDirectory() && !e.name.startsWith(".") && e.name !== "node_modules")
.map((e) => e.name) .map((e) => e.name)
.sort(); .sort();
} }
// Without a `type`, which npm never writes, the plugin's own package.json leaves its folder
// CommonJS: a .js helper breaks outright and every .ts costs a re-parse.
function packagingError(folder: string): string | null {
const file = join(folder, "package.json");
if (!existsSync(file)) return null;
let manifest: { type?: unknown };
try {
manifest = JSON.parse(readFileSync(file, "utf8")) as { type?: unknown };
} catch (err) {
return `package.json is not valid JSON — ${messageOf(err)}`;
}
return manifest.type === "module"
? null
: `package.json must set "type": "module" — npm writes no type, which leaves the folder CommonJS`;
}
function asManifest(value: unknown): PluginManifest | null { function asManifest(value: unknown): PluginManifest | null {
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as PluginManifest) : null; return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as PluginManifest) : null;
} }
+9
View File
@@ -5,6 +5,15 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import * as api from "./plugin-api.ts"; import * as api from "./plugin-api.ts";
// A plugin with its own package.json reaches the barrel only as a package; a second copy landing
// there would fail every `instanceof GuardError` a handler makes.
test("the barrel resolves by package name to this same module", async () => {
const asPackage = await import("@plainpages/plugin-api");
assert.equal(asPackage.GuardError, api.GuardError);
assert.equal(asPackage.definePlugin, api.definePlugin);
});
test("plugin-api re-exports the stable author value surface", () => { test("plugin-api re-exports the stable author value surface", () => {
for (const name of ["definePlugin", "can", "check", "GuardError", "requireSession", "parseListQuery", "readFormBody", "CSRF_FIELD", "tracedFetch", "Log", "safeUrl"]) { for (const name of ["definePlugin", "can", "check", "GuardError", "requireSession", "parseListQuery", "readFormBody", "CSRF_FIELD", "tracedFetch", "Log", "safeUrl"]) {
assert.ok(name in api && api[name as keyof typeof api] !== undefined, `missing export: ${name}`); assert.ok(name in api && api[name as keyof typeof api] !== undefined, `missing export: ${name}`);
+1 -1
View File
@@ -1,6 +1,6 @@
// System capabilities: privileged host services a first-party/system plugin (the built-in admin // System capabilities: privileged host services a first-party/system plugin (the built-in admin
// screens are the reference consumer) needs but an ordinary domain plugin does not — the Ory admin // screens are the reference consumer) needs but an ordinary domain plugin does not — the Ory admin
// clients and the instant-revoke hook. Exposed on ctx.system and re-exported via #plugin-api. // clients and the instant-revoke hook. Exposed on ctx.system and re-exported via @plainpages/plugin-api.
// //
// Every field is optional: it is present only when the host wired that dependency (Ory configured, // Every field is optional: it is present only when the host wired that dependency (Ory configured,
// denylist enabled), and ctx.system itself is undefined when the host wired none. A plugin must // denylist enabled), and ctx.system itself is undefined when the host wired none. A plugin must
+1 -1
View File
@@ -32,7 +32,7 @@ Prioritized. Overall verdict: architecture is sound; these are refinements.
- [ ] **MEDIUM→LOW — Add a list-page view-model helper in `src/ui/`.** Every list screen hand-rewrites the same ~40 lines bridging `parseListQuery`/`paginate` to the EJS partials; at minimum a `buildPaginationModel(page, hrefFor)` block. - [ ] **MEDIUM→LOW — Add a list-page view-model helper in `src/ui/`.** Every list screen hand-rewrites the same ~40 lines bridging `parseListQuery`/`paginate` to the EJS partials; at minimum a `buildPaginationModel(page, hrefFor)` block.
- [ ] **LOW→MEDIUM — Retire `src/ui/shell-context.ts`.** `ShellModel`/`buildShellContext` has one consumer left (dashboard) and duplicates `PageChrome` on almost every field. Fold the dashboard onto `ctx.chrome` + title/breadcrumbs; keep `shellUser` as the shared primitive. - [ ] **LOW→MEDIUM — Retire `src/ui/shell-context.ts`.** `ShellModel`/`buildShellContext` has one consumer left (dashboard) and duplicates `PageChrome` on almost every field. Fold the dashboard onto `ctx.chrome` + title/breadcrumbs; keep `shellUser` as the shared primitive.
- [ ] **LOW — Fix stale doc references to removed `docs/plugin-contract.md`** in `views/index.ejs` (user-visible dashboard text; also links /scheduling as if pre-installed) and `examples/plugins/scheduling/views/shifts.ejs`. - [ ] **LOW — Fix stale doc references to removed `docs/plugin-contract.md`** in `views/index.ejs` (user-visible dashboard text; also links /scheduling as if pre-installed) and `examples/plugins/scheduling/views/shifts.ejs`.
- [ ] **LOW — Decide (once) on a `ctx.system` facade.** `#plugin-api` exposes raw Ory client shapes, so an Ory client refactor is a major `apiVersion` bump. AGENTS.md accepts this; revisit only if external plugin authors appear. - [ ] **LOW — Decide (once) on a `ctx.system` facade.** `@plainpages/plugin-api` exposes raw Ory client shapes, so an Ory client refactor is a major `apiVersion` bump. AGENTS.md accepts this; revisit only if external plugin authors appear.
- [ ] **LOW — README/AGENTS.md gaps:** state the intended lifetime/horizon explicitly, add a short domain glossary (host, manifest, chrome, nav fragment, permission token, system plugin, denylist…), and note the expected plugin-author population. - [ ] **LOW — README/AGENTS.md gaps:** state the intended lifetime/horizon explicitly, add a short domain glossary (host, manifest, chrome, nav fragment, permission token, system plugin, denylist…), and note the expected plugin-author population.
## Finnished work ## Finnished work
+1 -1
View File
@@ -24,5 +24,5 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["auto-release", "config", "examples/config", "examples/plugins", "plugins", "registry-cleanup", "src"] "include": ["auto-release", "config", "examples/config", "examples/plugins", "plugin-api", "plugins", "registry-cleanup", "src"]
} }