From fee4fe632b15a3242ee5aa59cb1e6ccf89dbb0ec Mon Sep 17 00:00:00 2001 From: lilleman Date: Mon, 17 Aug 2026 22:23:53 +0200 Subject: [PATCH] Let a plugin carry its own package.json and npm dependencies --- AGENTS.md | 29 ++++--- Dockerfile | 4 + README-dockerhub.md | 2 +- README.md | 89 +++++++++++++++------ examples/README.md | 2 +- examples/plugins/admin/admin-clients.ts | 2 +- examples/plugins/admin/admin-grants.test.ts | 2 +- examples/plugins/admin/admin-grants.ts | 2 +- examples/plugins/admin/admin-groups.test.ts | 2 +- examples/plugins/admin/admin-groups.ts | 2 +- examples/plugins/admin/admin-shared.test.ts | 4 +- examples/plugins/admin/admin-shared.ts | 4 +- examples/plugins/admin/admin-users.test.ts | 2 +- examples/plugins/admin/admin-users.ts | 2 +- examples/plugins/admin/plugin.test.ts | 2 +- examples/plugins/admin/plugin.ts | 2 +- examples/plugins/scheduling/i18n/en-US.ts | 2 +- examples/plugins/scheduling/plugin.ts | 2 +- examples/plugins/scheduling/shifts.test.ts | 4 +- examples/plugins/scheduling/shifts.ts | 4 +- package.json | 3 +- plugin-api/index.ts | 2 + plugin-api/package.json | 7 ++ src/http/context.ts | 2 +- src/plugin-host/discovery.test.ts | 20 +++++ src/plugin-host/discovery.ts | 26 +++++- src/plugin-host/plugin-api.test.ts | 9 +++ src/plugin-host/system.ts | 2 +- todo.md | 2 +- tsconfig.json | 2 +- 30 files changed, 173 insertions(+), 66 deletions(-) create mode 100644 plugin-api/index.ts create mode 100644 plugin-api/package.json diff --git a/AGENTS.md b/AGENTS.md index 052e732..d6c0385 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 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/`). -- **Plugins and config import the host only via package.json `imports`** — `#plugin-api` → - `src/plugin-host/plugin-api.ts`, `#menu-config` → `src/ui/menu-config.ts`, never a relative - `../../src/*` path. These two barrels are the whole contract surface; don't "fix" a `#`-import - back to a relative path. Two consequences: - - `#plugin-api` re-exports the Ory client types (`KratosAdmin`/`KetoClient`/`HydraAdmin` + their +- **Plugins and config import the host only through a barrel** — `@plainpages/plugin-api` → + `plugin-api/index.ts` → `src/plugin-host/plugin-api.ts`, `#menu-config` → `src/ui/menu-config.ts`, + never a relative `../../src/*` path. These two barrels are the whole contract surface; don't "fix" + either back to a relative path. Three consequences: + - `@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 `apiVersion` bump, not a free refactor. - - **A plugin/config folder must stay a plain folder — no `package.json` of its own**, which would - become its own scope and stop `#`-specifiers resolving. A plugin kept in its own repo therefore - typechecks against the barrel only when mounted under the host tree (or with a vendored stub). + - **The barrel is a package, not a `#`-import, so a plugin folder may carry its own + `package.json`** and depend on npm packages (README → Plugin dependencies). The Dockerfile links + 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//` copies to `plugins//`, `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 - across unchanged. Never commit real plugins/config into the root mount dirs — they ship empty. + `tsconfig.include` and resolve the host through the barrels, so each typechecks in place *and* + 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 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`, @@ -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 read-only parent is EROFS and the container never starts). Valid while bootstrap is the only 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 × 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. @@ -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 the two in step. - **`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 unknown sprite id renders blank instead of failing loud (the `every icon resolves` e2e test catches anything reaching the nav). diff --git a/Dockerfile b/Dockerfile index f077b3f..59b6dd8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,10 @@ FROM node:24.19.0-alpine3.24 COPY package.json package-lock.json .npmrc /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 COPY . . diff --git a/README-dockerhub.md b/README-dockerhub.md index 6847430..3daac1a 100644 --- a/README-dockerhub.md +++ b/README-dockerhub.md @@ -178,7 +178,7 @@ Everything domain-specific is a plugin folder — the compose above mounts `./pl into the app. Create `plugins/hello/plugin.ts`: ```ts -import { definePlugin } from "#plugin-api"; +import { definePlugin } from "@plainpages/plugin-api"; export default definePlugin({ apiVersion: "1.0.0", diff --git a/README.md b/README.md index 002ee93..892335b 100644 --- a/README.md +++ b/README.md @@ -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`: ```ts -import { definePlugin } from "#plugin-api"; +import { definePlugin } from "@plainpages/plugin-api"; export default definePlugin({ 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) - [hooks](#hooks) - [where they live & mounting](#where-plugins-live-and-how-to-mount-them) + - [dependencies](#plugin-dependencies) - [local dev & test](#local-dev--test-story) - [The menu system](#the-menu-system) - [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 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 @@ -332,15 +335,15 @@ Installing a plugin is "drop the folder, restart"; removing one is "delete the f ### The manifest -A plugin imports its host surface from one module — **`#plugin-api`**, a Node [subpath -import](https://nodejs.org/api/packages.html#subpath-imports) mapped to `src/plugin-host/plugin-api.ts` -in the root `package.json` (`definePlugin`, the manifest/handler types, `RequestContext`, the guards, -and the body/CSRF/list-query helpers). That barrel **is** the contract boundary — never a relative -`../../src/...` path; the host refactors everything behind it freely. Keep your plugin a plain folder -with no `package.json` of its own, or `#plugin-api` resolves against that instead. +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 "#plugin-api"; +import { definePlugin } from "@plainpages/plugin-api"; import { listThings, createThings } from "./handlers.ts"; export default definePlugin({ @@ -412,7 +415,7 @@ type RouteResult = ```ts // handlers.ts -import { parseListQuery, type RequestContext } from "#plugin-api"; +import { parseListQuery, type RequestContext } from "@plainpages/plugin-api"; export async function listThings(ctx: RequestContext) { 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 `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`. -- **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 `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. @@ -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 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 - 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 - import { safeUrl } from "#plugin-api"; + import { safeUrl } from "@plainpages/plugin-api"; 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. | ```ts -import { definePlugin } from "#plugin-api"; +import { definePlugin } from "@plainpages/plugin-api"; import { landing, board } from "./pages.ts"; 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 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 -types + their error classes from `#plugin-api`: +types + their error classes from `@plainpages/plugin-api`: ```ts 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/`). For a **baked** production image, 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 -`/app` — so a mounted `plugins//` 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 -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 kept in its own repo mounts whole, `package.json` and all — see below. + +### Plugin dependencies + +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/`. + +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 @@ -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 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/.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 (Polish, Arabic) becomes unwritable: ```ts // plugins/shop/i18n/en-US.ts -import type { PluralMessage } from "#plugin-api"; +import type { PluralMessage } from "@plainpages/plugin-api"; const messages = { "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" }; // 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"; 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) i18n/ catalog (parity rules) · locale (resolution) · translate · load · runtime · 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 ui/ chrome (the one global menu) · shell-context · dashboard · nav (composeNav) · 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, 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 @@ -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 its `views/`. - **Static asset:** drop it in the plugin's `public/`; served at `/public//`. -- **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. Keep deps minimal — prefer the Node standard library, and an Ory REST call over an SDK. diff --git a/examples/README.md b/examples/README.md index bbc3ff3..2bee729 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,7 +5,7 @@ across (or bind-mount your own) and restart. | 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). | | [`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. | diff --git a/examples/plugins/admin/admin-clients.ts b/examples/plugins/admin/admin-clients.ts index 0ba324d..302c72a 100644 --- a/examples/plugins/admin/admin-clients.ts +++ b/examples/plugins/admin/admin-clients.ts @@ -5,7 +5,7 @@ // 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. -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 type { FieldConfig } from "./admin-users.ts"; diff --git a/examples/plugins/admin/admin-grants.test.ts b/examples/plugins/admin/admin-grants.test.ts index 71cb978..ce01cdc 100644 --- a/examples/plugins/admin/admin-grants.test.ts +++ b/examples/plugins/admin/admin-grants.test.ts @@ -2,7 +2,7 @@ // 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 { 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"; const declared: PermissionDecl[] = [ diff --git a/examples/plugins/admin/admin-grants.ts b/examples/plugins/admin/admin-grants.ts index d7bd671..ab7219e 100644 --- a/examples/plugins/admin/admin-grants.ts +++ b/examples/plugins/admin/admin-grants.ts @@ -6,7 +6,7 @@ // 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. -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 GRANTED = "granted"; diff --git a/examples/plugins/admin/admin-groups.test.ts b/examples/plugins/admin/admin-groups.test.ts index f1962ca..2f49f5e 100644 --- a/examples/plugins/admin/admin-groups.test.ts +++ b/examples/plugins/admin/admin-groups.test.ts @@ -14,7 +14,7 @@ import { memberView, parseSubject, } 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 userTuple = (group: string, n: number): RelationTuple => diff --git a/examples/plugins/admin/admin-groups.ts b/examples/plugins/admin/admin-groups.ts index 6a40d55..67d89f0 100644 --- a/examples/plugins/admin/admin-groups.ts +++ b/examples/plugins/admin/admin-groups.ts @@ -6,7 +6,7 @@ // per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded, // 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 { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts"; import type { FieldConfig } from "./admin-users.ts"; diff --git a/examples/plugins/admin/admin-shared.test.ts b/examples/plugins/admin/admin-shared.test.ts index bc13d0d..283e0fd 100644 --- a/examples/plugins/admin/admin-shared.test.ts +++ b/examples/plugins/admin/admin-shared.test.ts @@ -1,12 +1,12 @@ // 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 // 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 type { IncomingMessage, ServerResponse } from "node:http"; import { Readable } from "node:stream"; 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"; const reader: User = { email: "ada@x.io", id: "u1", permissions: ["users:read"] }; diff --git a/examples/plugins/admin/admin-shared.ts b/examples/plugins/admin/admin-shared.ts index 839651e..ccb4517 100644 --- a/examples/plugins/admin/admin-shared.ts +++ b/examples/plugins/admin/admin-shared.ts @@ -1,8 +1,8 @@ // 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. -// 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"; // This plugin's English — its catalog, then the host's — for a view model built outside a request, diff --git a/examples/plugins/admin/admin-users.test.ts b/examples/plugins/admin/admin-users.test.ts index c168bcc..bafce03 100644 --- a/examples/plugins/admin/admin-users.test.ts +++ b/examples/plugins/admin/admin-users.test.ts @@ -2,7 +2,7 @@ // routing/gate/CSRF + live Kratos calls are exercised over HTTP in src/http/app.test.ts. import assert from "node:assert/strict"; import { test } from "node:test"; -import type { Identity } from "#plugin-api"; +import type { Identity } from "@plainpages/plugin-api"; import { buildUserFormModel, buildUsersListModel, diff --git a/examples/plugins/admin/admin-users.ts b/examples/plugins/admin/admin-users.ts index 3fef3de..00e3d73 100644 --- a/examples/plugins/admin/admin-users.ts +++ b/examples/plugins/admin/admin-users.ts @@ -3,7 +3,7 @@ // into building-block view models; below them are thin per-route handlers keyed on ctx.params, over // 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 { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts"; diff --git a/examples/plugins/admin/plugin.test.ts b/examples/plugins/admin/plugin.test.ts index 9c24248..955f33d 100644 --- a/examples/plugins/admin/plugin.test.ts +++ b/examples/plugins/admin/plugin.test.ts @@ -3,7 +3,7 @@ // with nothing in the logs to explain it. Pin the two halves against each other here. import assert from "node:assert/strict"; import { test } from "node:test"; -import { isValidPermissionName } from "#plugin-api"; +import { isValidPermissionName } from "@plainpages/plugin-api"; import manifest from "./plugin.ts"; const routes = manifest.routes ?? []; diff --git a/examples/plugins/admin/plugin.ts b/examples/plugins/admin/plugin.ts index d73abd3..ac1f009 100644 --- a/examples/plugins/admin/plugin.ts +++ b/examples/plugins/admin/plugin.ts @@ -4,7 +4,7 @@ // 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. -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 { 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"; diff --git a/examples/plugins/scheduling/i18n/en-US.ts b/examples/plugins/scheduling/i18n/en-US.ts index e3120e7..1b59bd7 100644 --- a/examples/plugins/scheduling/i18n/en-US.ts +++ b/examples/plugins/scheduling/i18n/en-US.ts @@ -2,7 +2,7 @@ // 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). -import type { PluralMessage } from "#plugin-api"; +import type { PluralMessage } from "@plainpages/plugin-api"; const messages = { "scheduling.field.assignee": "Assignee", diff --git a/examples/plugins/scheduling/plugin.ts b/examples/plugins/scheduling/plugin.ts index c779323..61753d4 100644 --- a/examples/plugins/scheduling/plugin.ts +++ b/examples/plugins/scheduling/plugin.ts @@ -2,7 +2,7 @@ // 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. -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"; // The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is diff --git a/examples/plugins/scheduling/shifts.test.ts b/examples/plugins/scheduling/shifts.test.ts index 44937a3..50441fc 100644 --- a/examples/plugins/scheduling/shifts.test.ts +++ b/examples/plugins/scheduling/shifts.test.ts @@ -2,9 +2,9 @@ import assert from "node:assert/strict"; import type { IncomingMessage, ServerResponse } from "node:http"; import { Readable } from "node:stream"; 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. -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 { assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput, diff --git a/examples/plugins/scheduling/shifts.ts b/examples/plugins/scheduling/shifts.ts index 2db2540..7df69a0 100644 --- a/examples/plugins/scheduling/shifts.ts +++ b/examples/plugins/scheduling/shifts.ts @@ -5,8 +5,8 @@ // 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). -// One import from the host's #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"; +// 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 "@plainpages/plugin-api"; 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: diff --git a/package.json b/package.json index 81b53d2..9e046d1 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,7 @@ "node": ">=24" }, "imports": { - "#menu-config": "./src/ui/menu-config.ts", - "#plugin-api": "./src/plugin-host/plugin-api.ts" + "#menu-config": "./src/ui/menu-config.ts" }, "scripts": { "start": "node src/server.ts", diff --git a/plugin-api/index.ts b/plugin-api/index.ts new file mode 100644 index 0000000..8e748f8 --- /dev/null +++ b/plugin-api/index.ts @@ -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"; diff --git a/plugin-api/package.json b/plugin-api/package.json new file mode 100644 index 0000000..59333a5 --- /dev/null +++ b/plugin-api/package.json @@ -0,0 +1,7 @@ +{ + "name": "@plainpages/plugin-api", + "version": "1.0.0", + "private": true, + "type": "module", + "exports": "./index.ts" +} diff --git a/src/http/context.ts b/src/http/context.ts index 653040b..ce00524 100644 --- a/src/http/context.ts +++ b/src/http/context.ts @@ -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 // wraps the hrefs it builds itself. 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. locales: string[]; // Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to diff --git a/src/plugin-host/discovery.test.ts b/src/plugin-host/discovery.test.ts index b1b8cd5..dad0887 100644 --- a/src/plugin-host/discovery.test.ts +++ b/src/plugin-host/discovery.test.ts @@ -57,6 +57,8 @@ const badCases: Array<{ name: string; files: Record; 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.*:/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.*:/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.*:/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 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"); }); +// 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) => { const shared = `export default { apiVersion: "1.0.0", permissions: [{ name: "shared:read" }] };`; const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared }); diff --git a/src/plugin-host/discovery.ts b/src/plugin-host/discovery.ts index 3623ccc..9ea9aa8 100644 --- a/src/plugin-host/discovery.ts +++ b/src/plugin-host/discovery.ts @@ -4,7 +4,7 @@ // 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. -import { existsSync, readdirSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; 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