Let a plugin carry its own package.json and npm dependencies #75
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
.git
|
.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
|
node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -328,6 +328,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
|
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
|
`apiVersion`, but `include("menu", { open: true })` silently ignores a dropped option. Promotion
|
||||||
must cover the partial vocabulary too.
|
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
|
- 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
|
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.
|
the constant makes every plugin always equal the host, so `checkApiVersion` can never fire.
|
||||||
|
|||||||
@@ -689,13 +689,18 @@ it, because that file (not the host's) is what tells Node how to parse everythin
|
|||||||
{ "name": "things", "version": "0.0.0", "type": "module" }
|
{ "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
|
Add a `plugins/things/.npmrc` too. The root one does not reach a `--prefix`, so without it npm writes
|
||||||
`--prefix`, so without it npm writes a range.
|
ranges rather than the exact pins this project keeps everywhere:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
save-exact=true
|
||||||
|
```
|
||||||
|
|
||||||
|
Then install into the folder:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# The uid keeps the files it writes yours rather than root's.
|
# The uid keeps the files it writes yours rather than root's.
|
||||||
docker compose run --rm --no-deps --user "$(id -u):$(id -g)" web \
|
docker compose run --rm --no-deps --user "$(id -u):$(id -g)" web npm install --prefix plugins/things ms
|
||||||
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`
|
A plugin in its own repo runs its own `npm ci` instead and mounts the result — `node_modules`
|
||||||
@@ -706,11 +711,15 @@ Two rules follow from how Node resolves:
|
|||||||
|
|
||||||
- **Never ship a copy of `@plainpages/plugin-api`.** The host publishes it into `/node_modules`,
|
- **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
|
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,
|
copy inside your own `node_modules` would shadow it with a *second* instance of the host's
|
||||||
and every `instanceof GuardError` a handler makes silently starts returning `false`. (A type stub
|
contract, turning a sign-in redirect into a 500, so discovery refuses one at boot. (A type stub for
|
||||||
for standalone typechecking is fine — keep it out of what you mount.)
|
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
|
- **The host never upgrades or dedupes your dependencies.** Two plugins depending on the same package
|
||||||
own copy at their own version, so neither can break the other by upgrading.
|
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
|
`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
|
`@types/…` in your plugin's `devDependencies`. Typechecking a plugin repo standalone still needs the
|
||||||
@@ -1138,6 +1147,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
|
**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.
|
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
|
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)).
|
**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'
|
`REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`; nothing fails loud if you ship Ory's, Postgres'
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "@plainpages/plugin-api",
|
"name": "@plainpages/plugin-api",
|
||||||
"version": "1.0.0",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": "./index.ts"
|
"exports": "./index.ts"
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ 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 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 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 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/ },
|
||||||
|
|||||||
@@ -89,9 +89,14 @@ function pluginFolders(dir: string): string[] {
|
|||||||
.sort();
|
.sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Without a `type`, which npm never writes, the plugin's own package.json leaves its folder
|
// The two ways a plugin's own packaging breaks it. A barrel copy resolves before the host's, and its
|
||||||
// CommonJS: a .js helper breaks outright and every .ts costs a re-parse.
|
// GuardError matches no `instanceof` here — the sign-in redirect silently becomes a 500. Without a
|
||||||
|
// `type`, which npm never writes, the folder is left CommonJS: a .js helper breaks, every .ts re-parses.
|
||||||
function packagingError(folder: string): string | null {
|
function packagingError(folder: string): string | null {
|
||||||
|
if (existsSync(join(folder, "node_modules", "@plainpages", "plugin-api"))) {
|
||||||
|
return "ships its own copy of @plainpages/plugin-api — remove it; the host provides the one instance";
|
||||||
|
}
|
||||||
|
|
||||||
const file = join(folder, "package.json");
|
const file = join(folder, "package.json");
|
||||||
if (!existsSync(file)) return null;
|
if (!existsSync(file)) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
## Unfinnished work
|
## Unfinnished work
|
||||||
|
|
||||||
- [ ] Add a way to configure plugins directly when installing. Most reasonable is an .env file in the plugin folder, I think, but I am open to suggestions.
|
- [ ] Add a way to configure plugins directly when installing. Most reasonable is an .env file in the plugin folder, I think, but I am open to suggestions.
|
||||||
|
- [ ] Decide Renovate's reach over plugin dependencies before the first example plugin takes one. `renovate.json` extends `config:recommended`, whose `:ignoreModulesAndTests` ignores `**/examples/**`, so an example plugin's `package.json` would get no update PRs and nobody would notice. Either narrow the ignorePath or state that plugin deps are the plugin owner's to update (README → Plugin dependencies already says so for external plugins). Raised by the architecture review 2026-08-17.
|
||||||
- [ ] Rename the plugin "admin" to something less generic, like "auth-admin" or "users-groups-admin".
|
- [ ] Rename the plugin "admin" to something less generic, like "auth-admin" or "users-groups-admin".
|
||||||
- [ ] Guard the group paths against self-lockout, or accept them explicitly. The self-revoke guard covers only your own *direct* grants on the Users screen; unticking a permission on a group you belong to, removing yourself from that group, or deleting it can all still strip your own effective access with no warning. Recorded in AGENTS.md as a known gap — the robust fix is a "last effective holder" check, which needs a reverse Keto query.
|
- [ ] Guard the group paths against self-lockout, or accept them explicitly. The self-revoke guard covers only your own *direct* grants on the Users screen; unticking a permission on a group you belong to, removing yourself from that group, or deleting it can all still strip your own effective access with no warning. Recorded in AGENTS.md as a known gap — the robust fix is a "last effective holder" check, which needs a reverse Keto query.
|
||||||
- [ ] The permission picker has no concurrency baseline, so two operators editing the same user/group silently discard each other's change. Sketch: post the rendered set as a hidden baseline; if it no longer matches Keto, re-render with "this changed while you had the page open" rather than applying.
|
- [ ] The permission picker has no concurrency baseline, so two operators editing the same user/group silently discard each other's change. Sketch: post the rendered set as a hidden baseline; if it no longer matches Keto, re-render with "this changed while you had the page open" rather than applying.
|
||||||
@@ -33,7 +34,7 @@ Prioritized. Overall verdict: architecture is sound; these are refinements.
|
|||||||
- [ ] **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.** `@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 — 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 and plugin count — per-plugin dependency isolation prices N dependency trees on disk and in RSS, and that number is what says whether the trade needs revisiting (build-time dedupe for baked images stays open).
|
||||||
|
|
||||||
## Finnished work
|
## Finnished work
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user