diff --git a/.dockerignore b/.dockerignore index b3c82df..4388e0c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,10 +1,14 @@ .git -# Load-bearing: a stray copy would bake in at /app/node_modules and shadow /node_modules. +# Load-bearing both ways: a stray copy would bake in at /app/node_modules and shadow /node_modules, +# and matching only the root one is what lets a baked plugin keep its own deps. Never `**/node_modules`. node_modules npm-debug.log *.log .DS_Store +# A plugin's .npmrc is where a private-registry token would sit — never in a shipped image. +plugins/**/.npmrc + e2e-tests/artifacts # Orchestration, not test code — keep them out of the runner image (COPY e2e-tests/ ./) e2e-tests/Dockerfile diff --git a/AGENTS.md b/AGENTS.md index 052e732..7ed9948 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,20 +67,30 @@ 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. +- **A plugin `package.json` without `"type": "module"` is refused, not warned.** Allowing it costs a + warning and a re-parse per file, not a break — Node detects module syntax, so even a `.js` helper + loads — and an operator on a read-only third-party mount cannot apply the remedy. Refused anyway + because the direction is safe: refuse→warn relaxes freely, warn→refuse breaks installed plugins. + **Valid while nothing is installed in the wild.** - **`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 +133,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 +215,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). @@ -323,6 +333,17 @@ one-time setup. A file-map or table row gets a clause, not a paragraph. and emitted markup are author-visible. Know the hole that leaves — discovery fails loud on a bad `apiVersion`, but `include("menu", { open: true })` silently ignores a dropped option. Promotion must cover the partial vocabulary too. +- **The frozen surface also includes the packaging promises** (README → Plugin dependencies): the + barrel is ambient at `/node_modules` with nothing for a plugin to declare, `"type": "module"` is + mandatory, and the host neither upgrades nor dedupes a plugin's dependencies. Same hole as the + partials — move the publish point, rename the package or start hoisting and every installed plugin + breaks with no version signal. Note the promise is deliberately *not* "your deps are yours alone": + build-time dedupe for baked images stays open, module-instance sharing stays unpromised. +- **Publishing `@plainpages/plugin-api` to a registry is deferred, not rejected.** Today it is + `private` and shaped as a shim — `index.ts` re-exports `../src/…`, so `npm pack` would ship a + broken tree. The trigger is the same as the freeze's: the first external plugin, which is also the + first author who cannot typecheck against a mounted host tree. Whoever does it must first make the + artifact self-contained (types-only `.d.ts`, or move the barrel into `plugin-api/`). - A plugin's `apiVersion` is a **hand-written literal** semver — the host version it was built against — bumped by hand on rebuild, **never** the host's `HOST_API_VERSION` constant. Importing the constant makes every plugin always equal the host, so `checkApiVersion` can never fire. diff --git a/Dockerfile b/Dockerfile index f077b3f..689ccfe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,9 @@ 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 because it re-exports /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..1f20f27 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,55 @@ 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" } +``` + +Add a `plugins/things/.npmrc` too. The root one does not reach a `--prefix`, so without it npm writes +ranges rather than the exact pins this project keeps everywhere: + +```ini +save-exact=true +``` + +Then install into the folder: + +```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 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. + +- **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 plugin's own `node_modules` would shadow it with a *second* instance of the host's + contract, turning a sign-in redirect into a 500, so discovery refuses one there at boot. +- **The host never upgrades or dedupes your dependencies.** Two plugins depending on the same package + each get their own copy at their own version, so neither can break the other by upgrading — and + keeping yours current, and audited, is yours to own. Renovate here watches the host's manifests + only. +- **Depend on packages that ship JavaScript.** Node refuses to strip types under `node_modules`, so a + dependency whose entry is `.ts` fails at import with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`. + +`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 **outside +`node_modules`** and point tsconfig `paths` at it — a stub inside is the shadowing copy discovery +refuses, and it would travel with the folder you mount. ### Local dev & test story @@ -801,13 +848,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 +883,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 ``` @@ -1099,6 +1146,11 @@ obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config. **Offboarding is not instant by default** — a revoked permission or deactivated identity lands within one token TTL, unless the [denylist](#instant-revoke-the-optional-denylist) is on. +**A plugin, and every package it depends on, runs with the host's full privileges** — in the process +holding the JWT signing key and `ctx.system`'s Ory admin clients, on the network that reaches the +unauthenticated Ory ports. Install only what you trust, and let the plugin's own lockfile +([Plugin dependencies](#plugin-dependencies)) pin the tree you audited. + Hardening a real deploy is `REQUIRE_SECURE_SECRETS=true`, `SECURE_COOKIES=true`, and replacing **every** committed dev secret ([what you must supply](#what-you-must-supply-the-only-manual-prep)). `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`; nothing fails loud if you ship Ory's, Postgres' @@ -1469,11 +1521,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 +1551,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..452617a --- /dev/null +++ b/plugin-api/package.json @@ -0,0 +1,6 @@ +{ + "name": "@plainpages/plugin-api", + "private": true, + "type": "module", + "exports": "./index.ts" +} diff --git a/src/http/context.ts b/src/http/context.ts index 653040b..befd0bf 100644 --- a/src/http/context.ts +++ b/src/http/context.ts @@ -31,8 +31,8 @@ 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 - // to build its own language picker; the host's own picker is already in the shell. + // Every installed locale, sorted. With `localeLabel` (from @plainpages/plugin-api) 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 // log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by diff --git a/src/plugin-host/discovery.test.ts b/src/plugin-host/discovery.test.ts index b1b8cd5..8bdca5a 100644 --- a/src/plugin-host/discovery.test.ts +++ b/src/plugin-host/discovery.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { test, type TestContext } from "node:test"; @@ -57,6 +57,13 @@ 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 shipping its own copy of the barrel", files: { "shadow/node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "shadow/plugin.ts": full("shadow") }, match: /shadow.*@plainpages\/plugin-api/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: "a plugin package.json holding null", files: { "nul/package.json": `null`, "nul/plugin.ts": full("nul") }, match: /nul.*"type": "module"/s }, + // `npm install --prefix plugins` — the documented command with one path segment dropped. + { name: "a package.json in the scan root itself", files: { "package.json": `{ "name": "oops" }`, "ok/plugin.ts": full("ok") }, match: /plugins\/package\.json must not exist/ }, + { name: "a node_modules in the scan root itself", files: { "node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "ok/plugin.ts": full("ok") }, match: /plugins\/node_modules must not exist/ }, { 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 +109,40 @@ test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) assert.equal(typeof plugins[0]?.dashboard, "function"); }); +// Host deps sit at /node_modules, above every plugin scope, so the barrel resolves from a folder +// that has its own package.json (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) }) }] });`, + }); + + const plugins = await discoverPlugins({ dir }); + + assert.deepEqual(plugins.map((p) => p.id), ["shop"]); + assert.deepEqual(await plugins[0]?.routes?.[0]?.handler(null as never), { html: "20 kr" }); +}); + +test("a plugin folder may be a symlink", async (t) => { + const ownRepo = scaffold(t, { "my-plugin/plugin.ts": full("my-plugin") }); + const dir = scaffold(t, {}); + symlinkSync(join(ownRepo, "my-plugin"), join(dir, "linked")); + + const plugins = await discoverPlugins({ dir }); + + assert.deepEqual(plugins.map((p) => p.id), ["linked"]); // the link name is the id, not the target's +}); + +test("a dangling plugin symlink fails loud rather than vanishing", async (t) => { + const dir = scaffold(t, {}); + symlinkSync(join(dir, "gone"), join(dir, "broken")); + + await assert.rejects(discoverPlugins({ dir }), /broken.*plugin\.ts/s); +}); + 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..fd36dd7 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"; @@ -27,6 +27,14 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise`: the package.json becomes the + // scope for every plugin below it, and the node_modules outranks the host's own — barrel included. + for (const stray of ["node_modules", "package.json"]) { + if (existsSync(join(dir, stray))) { + errors.push(`plugins/${stray} must not exist — it sits above every plugin and shadows the host's own; delete plugins/{node_modules,package.json,package-lock.json} and install into plugins/`); + } + } + for (const id of pluginFolders(dir)) { const fail = (msg: string): void => void errors.push(`plugins/${id}: ${msg}`); @@ -37,6 +45,8 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise