Refine plugin contract (todo §2); derive id/mount from folder (isValidPluginId), apiVersion literal not HOST_API_VERSION, nav icon = Lucide, drop redundant basePath
This commit is contained in:
13
README.md
13
README.md
@@ -170,7 +170,7 @@ hooks, and the dev/test story — is **[docs/plugin-contract.md](docs/plugin-con
|
|||||||
(`src/plugin.ts` holds the types). The sketch below is the shape.
|
(`src/plugin.ts` holds the types). The sketch below is the shape.
|
||||||
|
|
||||||
```
|
```
|
||||||
plugins/scheduling/
|
plugins/scheduling/ # folder name = the plugin id; mounted at /scheduling
|
||||||
plugin.ts # default export: the typed manifest (see below)
|
plugin.ts # default export: the typed manifest (see below)
|
||||||
views/ # EJS templates for this plugin's pages
|
views/ # EJS templates for this plugin's pages
|
||||||
shifts.ejs
|
shifts.ejs
|
||||||
@@ -179,19 +179,18 @@ plugins/scheduling/
|
|||||||
```
|
```
|
||||||
|
|
||||||
The manifest is **TypeScript** — typed, commented, no separate schema to keep in
|
The manifest is **TypeScript** — typed, commented, no separate schema to keep in
|
||||||
sync:
|
sync. The `id` and mount path are **derived from the folder name**, not declared:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { definePlugin } from "../../src/plugin.ts";
|
import { definePlugin } from "../../src/plugin.ts";
|
||||||
import { listShifts } from "./shifts.ts";
|
import { listShifts } from "./shifts.ts";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "1.0.0", // semver of the host contract this targets; mismatch fails loud at boot
|
apiVersion: "1.0.0", // semver of the host contract this was built against (a literal — see docs)
|
||||||
id: "scheduling",
|
|
||||||
basePath: "/scheduling",
|
|
||||||
|
|
||||||
// Nav fragment, composed into the global menu. Permission-gated via Keto:
|
// Nav fragment, composed into the global menu. Permission-gated via Keto:
|
||||||
// items the current user can't access are hidden. Arbitrary depth.
|
// items the current user can't access are hidden. Arbitrary depth.
|
||||||
|
// `icon` is a Lucide icon by its sprite id (src/icons.ts).
|
||||||
nav: [
|
nav: [
|
||||||
{
|
{
|
||||||
label: "Scheduling", icon: "i-cal",
|
label: "Scheduling", icon: "i-cal",
|
||||||
@@ -201,8 +200,8 @@ export default definePlugin({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
// Route handlers. The host's hand-rolled router mounts them under basePath
|
// Route handlers, mounted under the plugin's path (/scheduling). `permission`
|
||||||
// and enforces `permission` (a Keto check) before the handler runs.
|
// (a Keto check) is enforced before the handler runs.
|
||||||
routes: [
|
routes: [
|
||||||
{ method: "GET", path: "/shifts", permission: "scheduling:read", handler: listShifts },
|
{ method: "GET", path: "/shifts", permission: "scheduling:read", handler: listShifts },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -14,14 +14,15 @@ crash-isolation (one bad plugin can't take the host down) is a *non-goal* — di
|
|||||||
time, not in production.
|
time, not in production.
|
||||||
|
|
||||||
> **Status.** This is the contract the §2 host implements. The types and the pure rules
|
> **Status.** This is the contract the §2 host implements. The types and the pure rules
|
||||||
> (`checkApiVersion`, `findConflicts`) exist today in `src/plugin.ts`; discovery, the router,
|
> (`checkApiVersion`, `findConflicts`, `isValidPluginId`) exist today in `src/plugin.ts`;
|
||||||
> the per-plugin view resolver, and static serving are the next §2 items and wire this contract
|
> discovery, the router, the per-plugin view resolver, and static serving are the next §2 items
|
||||||
> to the filesystem and HTTP. Behaviour described as the host's is the target those items meet.
|
> and wire this contract to the filesystem and HTTP. Behaviour described as the host's is the
|
||||||
|
> target those items meet.
|
||||||
|
|
||||||
## Anatomy of a plugin
|
## Anatomy of a plugin
|
||||||
|
|
||||||
```
|
```
|
||||||
plugins/scheduling/
|
plugins/scheduling/ # folder name = the plugin id → mounted at /scheduling
|
||||||
plugin.ts # default export: the manifest (definePlugin(...))
|
plugin.ts # default export: the manifest (definePlugin(...))
|
||||||
shifts.ts # handlers, helpers — plain modules
|
shifts.ts # handlers, helpers — plain modules
|
||||||
views/ # EJS templates for this plugin's pages
|
views/ # EJS templates for this plugin's pages
|
||||||
@@ -30,6 +31,13 @@ plugins/scheduling/
|
|||||||
scheduling.css
|
scheduling.css
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Identity comes from the folder.** The folder name *is* the plugin `id`, and the mount path is
|
||||||
|
`/<id>` — neither is written in the manifest, so they can't drift or be claimed twice. The id
|
||||||
|
must be **kebab-case** (`isValidPluginId`: lowercase letters in dash-separated segments — no
|
||||||
|
digits, uppercase, or leading/trailing/double dashes); the host rejects a malformed folder name
|
||||||
|
at discovery. The id also namespaces the plugin's `views/`, its `/public/<id>/` assets, and (by
|
||||||
|
convention) its nav/permission tokens.
|
||||||
|
|
||||||
Installing a plugin is "drop the folder, restart." Removing one is "delete the folder, restart."
|
Installing a plugin is "drop the folder, restart." Removing one is "delete the folder, restart."
|
||||||
Nothing else references it; the operator stays in control through the central menu override
|
Nothing else references it; the operator stays in control through the central menu override
|
||||||
(`config/menu.ts`, §2).
|
(`config/menu.ts`, §2).
|
||||||
@@ -37,15 +45,14 @@ Nothing else references it; the operator stays in control through the central me
|
|||||||
## The manifest
|
## The manifest
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { definePlugin, HOST_API_VERSION } from "../../src/plugin.ts";
|
import { definePlugin } from "../../src/plugin.ts";
|
||||||
import { listShifts, createShift } from "./shifts.ts";
|
import { listShifts, createShift } from "./shifts.ts";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: HOST_API_VERSION, // the host contract this plugin targets
|
apiVersion: "1.0.0", // semver of the host contract this was built against (a literal — see Versioning)
|
||||||
basePath: "/scheduling", // unique mount prefix
|
|
||||||
id: "scheduling", // globally unique; namespaces views/static/tokens
|
|
||||||
|
|
||||||
// Nav fragment, merged into the global menu and permission-filtered per user.
|
// Nav fragment, merged into the global menu and permission-filtered per user.
|
||||||
|
// `icon` is a Lucide icon by its sprite id (src/icons.ts).
|
||||||
nav: [{
|
nav: [{
|
||||||
icon: "i-cal", id: "scheduling:root", label: "Scheduling",
|
icon: "i-cal", id: "scheduling:root", label: "Scheduling",
|
||||||
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
|
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
|
||||||
@@ -57,7 +64,7 @@ export default definePlugin({
|
|||||||
{ token: "scheduling:write", description: "Create and edit shifts" },
|
{ token: "scheduling:write", description: "Create and edit shifts" },
|
||||||
],
|
],
|
||||||
|
|
||||||
// Route handlers, mounted under basePath. `permission` gates before the handler runs.
|
// Route handlers, mounted under the plugin's path (/scheduling). `permission` gates first.
|
||||||
routes: [
|
routes: [
|
||||||
{ method: "GET", path: "/shifts", permission: "scheduling:read", handler: listShifts },
|
{ method: "GET", path: "/shifts", permission: "scheduling:read", handler: listShifts },
|
||||||
{ method: "POST", path: "/shifts", permission: "scheduling:write", handler: createShift },
|
{ method: "POST", path: "/shifts", permission: "scheduling:write", handler: createShift },
|
||||||
@@ -66,14 +73,15 @@ export default definePlugin({
|
|||||||
```
|
```
|
||||||
|
|
||||||
`definePlugin()` only types the object and returns it unchanged — a manifest may equally be a
|
`definePlugin()` only types the object and returns it unchanged — a manifest may equally be a
|
||||||
plain typed object. All validation happens at discovery.
|
plain typed object. It types the authored shape (`PluginManifest`); the host attaches the
|
||||||
|
folder-derived `id` to produce the loaded `Plugin`. All validation happens at discovery. Note
|
||||||
|
there is **no `id` or `basePath`** in the manifest — both come from the folder
|
||||||
|
([Anatomy](#anatomy-of-a-plugin)).
|
||||||
|
|
||||||
| Field | Required | Notes |
|
| Field | Required | Notes |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `apiVersion` | yes | Semver of the host contract the plugin targets (e.g. `"1.0.0"`) — see [Versioning](#contract-versioning). |
|
| `apiVersion` | yes | Semver the plugin was built against — a **literal**, not `HOST_API_VERSION`. See [Versioning](#contract-versioning). |
|
||||||
| `basePath` | yes | Absolute, no trailing slash (`/scheduling`). Unique; must not prefix-overlap another plugin's. |
|
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/icons.ts`); node `id`s must be globally unique. |
|
||||||
| `id` | yes | Globally unique slug. Namespaces `views/`, `/public/<id>/`, and (by convention) nav/permission tokens. |
|
|
||||||
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Node `id`s must be globally unique. |
|
|
||||||
| `permissions` | no | Tokens this plugin introduces; declared for documentation and seeding. |
|
| `permissions` | no | Tokens this plugin introduces; declared for documentation and seeding. |
|
||||||
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
||||||
| `hooks` | no | See [Hooks](#hooks). |
|
| `hooks` | no | See [Hooks](#hooks). |
|
||||||
@@ -82,10 +90,11 @@ A plugin may be routes-only, nav-only, or hooks-only — every collection field
|
|||||||
|
|
||||||
## Routes & handlers
|
## Routes & handlers
|
||||||
|
|
||||||
A route is `{ method, path, permission?, handler }`. `path` is **relative to `basePath`**; the
|
A route is `{ method, path, permission?, handler }`. `path` is **relative to the plugin's mount
|
||||||
host matches `method` + the resolved full path, extracts `:name` segments into
|
path `/<id>`** (so `/shifts` in the `scheduling` plugin serves `/scheduling/shifts`); the host
|
||||||
`ctx.params.name`, runs the `permission` gate (a coarse JWT-claim check — see the README), and
|
matches `method` + the resolved full path, extracts `:name` segments into `ctx.params.name`,
|
||||||
only then calls the handler with the [request context](#requestcontext).
|
runs the `permission` gate (a coarse JWT-claim check — see the README), and only then calls the
|
||||||
|
handler with the [request context](#requestcontext).
|
||||||
|
|
||||||
`method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`.
|
`method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`.
|
||||||
|
|
||||||
@@ -147,7 +156,9 @@ from the §4 JWT middleware and are `null`/`[]` until a session exists.
|
|||||||
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/nav.ts`), which
|
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/nav.ts`), which
|
||||||
applies the central override and then **filters per user** by the roles in the session JWT — a
|
applies the central override and then **filters per user** by the roles in the session JWT — a
|
||||||
node shows iff it declares no `permission` or the user's roles include that token. Use arbitrary
|
node shows iff it declares no `permission` or the user's roles include that token. Use arbitrary
|
||||||
depth, counts, and icons; see `composeNav` for the node shape.
|
depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` is a **Lucide
|
||||||
|
icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are
|
||||||
|
`ICON_NAMES` in `src/icons.ts`, and adding one means registering its lucide name there.
|
||||||
|
|
||||||
Permission tokens are a **shared global namespace** — that's deliberate, so an operator grants
|
Permission tokens are a **shared global namespace** — that's deliberate, so an operator grants
|
||||||
`scheduling:read` once in Keto and every plugin referencing it is gated consistently. Namespace
|
`scheduling:read` once in Keto and every plugin referencing it is gated consistently. Namespace
|
||||||
@@ -175,22 +186,26 @@ The plugin pins one exact version (no ranges — in keeping with the project's p
|
|||||||
dependency-free functions (the `semver` package's ranges/coercion/prerelease-precedence are more
|
dependency-free functions (the `semver` package's ranges/coercion/prerelease-precedence are more
|
||||||
than the contract needs).
|
than the contract needs).
|
||||||
|
|
||||||
|
**Write a literal, never `HOST_API_VERSION`.** `apiVersion` records the version the plugin was
|
||||||
|
*built against*. Importing the host's current constant would make every plugin always equal the
|
||||||
|
host — the check could never fire, and a future breaking change would slip through silently.
|
||||||
|
|
||||||
## Conflict rules
|
## Conflict rules
|
||||||
|
|
||||||
Plugins are independent folders, so the host detects collisions across all discovered manifests
|
Plugins are independent folders, so the host detects collisions across all discovered plugins
|
||||||
with `findConflicts` and resolves them **loudly — never last-write-wins**. `error` aborts boot;
|
with `findConflicts` and resolves them **loudly — never last-write-wins**. `error` aborts boot;
|
||||||
`warn` logs and continues.
|
`warn` logs and continues.
|
||||||
|
|
||||||
| Kind | Level | Rule |
|
| Kind | Level | Rule |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `id` | error | Two plugins share an `id`. Ids must be globally unique (they namespace views/static/tokens). |
|
| `id` | error | Two plugins share an `id` (folder name). Ids must be globally unique — they namespace the mount path, views/static, and the override target. |
|
||||||
| `basePath` | error | Two `basePath`s are equal, or one is a path-prefix of the other (`/x` vs `/x/y`) — routes would shadow. |
|
| `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. |
|
||||||
| `route` | error | Two routes resolve to the same `method` + full path (within or across plugins). |
|
|
||||||
| `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. |
|
| `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. |
|
||||||
| `permission` | warn | A permission token is declared by more than one plugin. Sharing is legitimate (shared role); namespace as `<id>:<action>` if unintended. |
|
| `permission` | warn | A permission token is declared by more than one plugin. Sharing is legitimate (shared role); namespace as `<id>:<action>` if unintended. |
|
||||||
|
|
||||||
`permission` is the one intentional overlap, so it warns rather than aborts; everything else is
|
There is **no separate `basePath` rule**: the mount path is the derived `/<id>`, so its
|
||||||
an error an author fixes before the host will start.
|
uniqueness follows from the id check. `permission` is the one intentional overlap, so it warns
|
||||||
|
rather than aborts; everything else is an error an author fixes before the host will start.
|
||||||
|
|
||||||
## Hooks
|
## Hooks
|
||||||
|
|
||||||
|
|||||||
@@ -5,17 +5,19 @@ import {
|
|||||||
definePlugin,
|
definePlugin,
|
||||||
findConflicts,
|
findConflicts,
|
||||||
HOST_API_VERSION,
|
HOST_API_VERSION,
|
||||||
|
isValidPluginId,
|
||||||
parseSemver,
|
parseSemver,
|
||||||
type Plugin,
|
type Plugin,
|
||||||
|
type PluginManifest,
|
||||||
} from "./plugin.ts";
|
} from "./plugin.ts";
|
||||||
|
|
||||||
// A representative manifest exercising every field — its existence type-checks the contract
|
// A representative manifest exercising every field — its existence type-checks the contract.
|
||||||
// (handler return variants, nav fragment, permission decls, hooks). The README example.
|
// `apiVersion` is a literal: a plugin pins the version it was built against, so importing
|
||||||
const scheduling: Plugin = definePlugin({
|
// HOST_API_VERSION would always equal the host and defeat the check. No `id`/`basePath` — the
|
||||||
apiVersion: HOST_API_VERSION,
|
// host derives both from the plugin's folder name.
|
||||||
basePath: "/scheduling",
|
const scheduling: PluginManifest = definePlugin({
|
||||||
|
apiVersion: "1.0.0",
|
||||||
hooks: { onBoot: () => {} },
|
hooks: { onBoot: () => {} },
|
||||||
id: "scheduling",
|
|
||||||
nav: [{
|
nav: [{
|
||||||
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
|
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
|
||||||
icon: "i-cal", id: "scheduling:root", label: "Scheduling",
|
icon: "i-cal", id: "scheduling:root", label: "Scheduling",
|
||||||
@@ -28,12 +30,19 @@ const scheduling: Plugin = definePlugin({
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
test("definePlugin returns the manifest unchanged — it only types; validation is at discovery (§2)", () => {
|
test("definePlugin returns the manifest unchanged — id/mount come from the folder, not the manifest", () => {
|
||||||
const m: Plugin = { apiVersion: "1.0.0", basePath: "/x", id: "x" };
|
const m: PluginManifest = { apiVersion: "1.0.0" };
|
||||||
assert.equal(definePlugin(m), m); // identity, not a copy
|
assert.equal(definePlugin(m), m); // identity, not a copy
|
||||||
assert.equal(scheduling.routes?.length, 3);
|
assert.equal(scheduling.routes?.length, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("isValidPluginId accepts kebab-case folder names and rejects everything else", () => {
|
||||||
|
for (const ok of ["scheduling", "people", "people-directory"]) assert.ok(isValidPluginId(ok), ok);
|
||||||
|
for (const bad of ["People", "people_dir", "people-", "-people", "people--dir", "people1", "", "a/b"]) {
|
||||||
|
assert.ok(!isValidPluginId(bad), bad);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("parseSemver follows the semver core, rejecting ranges, prefixes, leading zeros and missing parts", () => {
|
test("parseSemver follows the semver core, rejecting ranges, prefixes, leading zeros and missing parts", () => {
|
||||||
assert.deepEqual(parseSemver("1.2.3"), { major: 1, minor: 2, patch: 3 });
|
assert.deepEqual(parseSemver("1.2.3"), { major: 1, minor: 2, patch: 3 });
|
||||||
assert.deepEqual(parseSemver("1.2.3-rc.1+build.5"), { major: 1, minor: 2, patch: 3 }); // prerelease/build tolerated, ignored
|
assert.deepEqual(parseSemver("1.2.3-rc.1+build.5"), { major: 1, minor: 2, patch: 3 }); // prerelease/build tolerated, ignored
|
||||||
@@ -54,44 +63,37 @@ test("checkApiVersion: semver compat — equal/patch ok, older minor warns, newe
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Minimal valid plugin, overridable per case.
|
// A minimal discovered plugin (id = folder name; mount path is the derived `/<id>`), per case.
|
||||||
const p = (over: Partial<Plugin> & Pick<Plugin, "id" | "basePath">): Plugin =>
|
const p = (over: Partial<Plugin> & Pick<Plugin, "id">): Plugin => ({ apiVersion: "1.0.0", ...over });
|
||||||
definePlugin({ apiVersion: HOST_API_VERSION, ...over });
|
|
||||||
|
|
||||||
test("findConflicts: a clean set has none", () => {
|
test("findConflicts: a clean set has none", () => {
|
||||||
assert.deepEqual(findConflicts([p({ basePath: "/a", id: "a" }), p({ basePath: "/b", id: "b" })]), []);
|
assert.deepEqual(findConflicts([p({ id: "a" }), p({ id: "b" })]), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("findConflicts: duplicate id, overlapping basePath, and colliding route are loud errors", () => {
|
test("findConflicts: a duplicate id and a colliding route are loud errors", () => {
|
||||||
const dupId = findConflicts([p({ basePath: "/a", id: "a" }), p({ basePath: "/b", id: "a" })]);
|
const dupId = findConflicts([p({ id: "a" }), p({ id: "a" })]);
|
||||||
assert.ok(dupId.some((c) => c.kind === "id" && c.level === "error"));
|
assert.ok(dupId.some((c) => c.kind === "id" && c.level === "error"));
|
||||||
|
|
||||||
const sameBase = findConflicts([p({ basePath: "/x", id: "a" }), p({ basePath: "/x", id: "b" })]);
|
// Cross-plugin routes can't collide (unique `/<id>` prefix); two identical routes in one can.
|
||||||
assert.ok(sameBase.some((c) => c.kind === "basePath" && c.level === "error"));
|
|
||||||
|
|
||||||
// A basePath that is a path-prefix of another also overlaps (routes would shadow).
|
|
||||||
const prefix = findConflicts([p({ basePath: "/x", id: "a" }), p({ basePath: "/x/y", id: "b" })]);
|
|
||||||
assert.ok(prefix.some((c) => c.kind === "basePath" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
const dupRoute = findConflicts([p({
|
const dupRoute = findConflicts([p({
|
||||||
basePath: "/a", id: "a",
|
id: "a",
|
||||||
routes: [{ handler: noop, method: "GET", path: "/t" }, { handler: noop, method: "GET", path: "/t" }],
|
routes: [{ handler: noop, method: "GET", path: "/t" }, { handler: noop, method: "GET", path: "/t" }],
|
||||||
})]);
|
})]);
|
||||||
assert.ok(dupRoute.some((c) => c.kind === "route" && c.level === "error"));
|
assert.ok(dupRoute.some((c) => c.kind === "route" && c.level === "error" && c.message.includes("/a/t")));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("findConflicts: duplicate nav id is an error, a shared permission token only warns", () => {
|
test("findConflicts: duplicate nav id is an error, a shared permission token only warns", () => {
|
||||||
const navDup = findConflicts([
|
const navDup = findConflicts([
|
||||||
p({ basePath: "/a", id: "a", nav: [{ id: "dup", label: "A" }] }),
|
p({ id: "a", nav: [{ id: "dup", label: "A" }] }),
|
||||||
p({ basePath: "/b", id: "b", nav: [{ id: "dup", label: "B" }] }),
|
p({ id: "b", nav: [{ id: "dup", label: "B" }] }),
|
||||||
]);
|
]);
|
||||||
assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||||
|
|
||||||
// Sharing a permission across plugins is legitimate (shared role) → warn, not error.
|
// Sharing a permission across plugins is legitimate (shared role) → warn, not error.
|
||||||
const permDup = findConflicts([
|
const permDup = findConflicts([
|
||||||
p({ basePath: "/a", id: "a", permissions: [{ token: "shared:read" }] }),
|
p({ id: "a", permissions: [{ token: "shared:read" }] }),
|
||||||
p({ basePath: "/b", id: "b", permissions: [{ token: "shared:read" }] }),
|
p({ id: "b", permissions: [{ token: "shared:read" }] }),
|
||||||
]);
|
]);
|
||||||
assert.ok(permDup.some((c) => c.kind === "permission" && c.level === "warn"));
|
assert.ok(permDup.some((c) => c.kind === "permission" && c.level === "warn"));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
// It only declares types + pure rules; the §2 discovery/router wire them to the filesystem
|
// It only declares types + pure rules; the §2 discovery/router wire them to the filesystem
|
||||||
// and HTTP. Philosophy: a powerful, predictable, overload-friendly API that fails loud at
|
// and HTTP. Philosophy: a powerful, predictable, overload-friendly API that fails loud at
|
||||||
// boot/discovery rather than sandboxing at runtime.
|
// boot/discovery rather than sandboxing at runtime.
|
||||||
|
//
|
||||||
|
// A plugin's identity comes from its folder under plugins/: the folder name is the `id`
|
||||||
|
// (validated by isValidPluginId) and the mount path is `/<id>`. Neither is written in the
|
||||||
|
// manifest — the host derives them at discovery, so they can't drift or be claimed twice.
|
||||||
|
|
||||||
import type { RequestContext } from "./context.ts";
|
import type { RequestContext } from "./context.ts";
|
||||||
import type { NavNode } from "./nav.ts";
|
import type { NavNode } from "./nav.ts";
|
||||||
@@ -27,7 +31,7 @@ export type RouteHandler = (ctx: RequestContext) => Promise<RouteResult | void>
|
|||||||
export interface Route {
|
export interface Route {
|
||||||
handler: RouteHandler;
|
handler: RouteHandler;
|
||||||
method: HttpMethod;
|
method: HttpMethod;
|
||||||
path: string; // relative to basePath; ":name" segments become ctx.params.name
|
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
|
||||||
permission?: string; // coarse gate (a role token); checked before the handler runs
|
permission?: string; // coarse gate (a role token); checked before the handler runs
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,20 +49,35 @@ export interface PluginHooks {
|
|||||||
onResponse?: (ctx: RequestContext, result: RouteResult | null) => Promise<void> | void;
|
onResponse?: (ctx: RequestContext, result: RouteResult | null) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Plugin {
|
// The authored manifest — a plugin's `plugin.ts` default-exports this. No `id`/mount path: the
|
||||||
apiVersion: string; // semver of the host contract this plugin targets (e.g. HOST_API_VERSION)
|
// host derives them from the folder name at discovery (see Plugin).
|
||||||
basePath: string; // unique mount prefix, e.g. "/scheduling"; must not overlap another plugin's
|
export interface PluginManifest {
|
||||||
|
apiVersion: string; // semver of the host contract this targets — write a literal, NOT HOST_API_VERSION (see docs)
|
||||||
hooks?: PluginHooks;
|
hooks?: PluginHooks;
|
||||||
id: string; // globally unique; namespaces views, /public/<id>/, and nav/permission tokens
|
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/icons.ts), node ids must be globally unique
|
||||||
nav?: NavNode[]; // fragment merged into the global menu (composeNav); ids must be globally unique
|
|
||||||
permissions?: PermissionDecl[];
|
permissions?: PermissionDecl[];
|
||||||
routes?: Route[];
|
routes?: Route[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A discovered plugin: the manifest plus the `id` the host read from the folder name. Mounted
|
||||||
|
// at `/<id>`, with views/static namespaced under the id.
|
||||||
|
export interface Plugin extends PluginManifest {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Identity helper: types the manifest, returns it unchanged. Validation happens at discovery
|
// Identity helper: types the manifest, returns it unchanged. Validation happens at discovery
|
||||||
// (§2), so a plugin may equally be a plain typed object. Mirrors Vite's `defineConfig`.
|
// (§2), so a plugin may equally be a plain typed object. Mirrors Vite's `defineConfig`.
|
||||||
export function definePlugin(plugin: Plugin): Plugin {
|
export function definePlugin(manifest: PluginManifest): PluginManifest {
|
||||||
return plugin;
|
return manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plugin id (its folder name) — lowercase letters in dash-separated segments: no digits,
|
||||||
|
// uppercase, or leading/trailing/double dashes. Tight on purpose: the id forms the mount path
|
||||||
|
// `/<id>`, the view/static namespace, and the central-override target.
|
||||||
|
const PLUGIN_ID = /^[a-z]+(?:-[a-z]+)*$/;
|
||||||
|
|
||||||
|
export function isValidPluginId(id: string): boolean {
|
||||||
|
return PLUGIN_ID.test(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Semver {
|
export interface Semver {
|
||||||
@@ -112,15 +131,16 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginConflict {
|
export interface PluginConflict {
|
||||||
kind: "basePath" | "id" | "nav-id" | "permission" | "route";
|
kind: "id" | "nav-id" | "permission" | "route";
|
||||||
level: "error" | "warn";
|
level: "error" | "warn";
|
||||||
message: string;
|
message: string;
|
||||||
plugins: string[]; // unique ids involved
|
plugins: string[]; // unique ids involved
|
||||||
}
|
}
|
||||||
|
|
||||||
// The conflict rules: defined, loud resolution — never last-write-wins. Pure over the discovered
|
// The conflict rules: defined, loud resolution — never last-write-wins. Pure over the discovered
|
||||||
// manifests; discovery throws on any "error" and logs every "warn". Shared permission tokens are
|
// plugins; discovery throws on any "error" and logs every "warn". Mount-path (`/<id>`) uniqueness
|
||||||
// the one intentional overlap, so they warn rather than error.
|
// is structural — it follows from the id check, so it needs no rule of its own. Shared permission
|
||||||
|
// tokens are the one intentional overlap, so they warn rather than error.
|
||||||
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
||||||
const out: PluginConflict[] = [];
|
const out: PluginConflict[] = [];
|
||||||
|
|
||||||
@@ -130,18 +150,8 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
|||||||
if (n > 1) out.push({ kind: "id", level: "error", message: `${n} plugins share id "${id}"; ids must be globally unique`, plugins: [id] });
|
if (n > 1) out.push({ kind: "id", level: "error", message: `${n} plugins share id "${id}"; ids must be globally unique`, plugins: [id] });
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < plugins.length; i++) {
|
|
||||||
for (let j = i + 1; j < plugins.length; j++) {
|
|
||||||
const a = plugins[i] as Plugin;
|
|
||||||
const b = plugins[j] as Plugin;
|
|
||||||
if (basePathOverlap(a.basePath, b.basePath)) {
|
|
||||||
out.push({ kind: "basePath", level: "error", message: `basePath "${a.basePath}" (${a.id}) overlaps "${b.basePath}" (${b.id})`, plugins: uniq([a.id, b.id]) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
collect(plugins, (plugin, push) => {
|
collect(plugins, (plugin, push) => {
|
||||||
for (const route of plugin.routes ?? []) push(`${route.method} ${joinPath(plugin.basePath, route.path)}`);
|
for (const route of plugin.routes ?? []) push(`${route.method} ${fullPath(plugin.id, route.path)}`);
|
||||||
}).forEach((owners, key) => {
|
}).forEach((owners, key) => {
|
||||||
if (owners.length > 1) out.push({ kind: "route", level: "error", message: `${owners.length} routes resolve to "${key}"`, plugins: uniq(owners) });
|
if (owners.length > 1) out.push({ kind: "route", level: "error", message: `${owners.length} routes resolve to "${key}"`, plugins: uniq(owners) });
|
||||||
});
|
});
|
||||||
@@ -173,16 +183,9 @@ function collectNavIds(nodes: NavNode[] | undefined, push: (id: string) => void)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const trimSlash = (s: string): string => s.replace(/\/+$/, "");
|
// A route's full path = the plugin's mount path `/<id>` + the route path.
|
||||||
|
function fullPath(id: string, path: string): string {
|
||||||
function basePathOverlap(a: string, b: string): boolean {
|
return `/${id}${path.startsWith("/") ? path : `/${path}`}`;
|
||||||
const x = trimSlash(a);
|
|
||||||
const y = trimSlash(b);
|
|
||||||
return x === y || y.startsWith(`${x}/`) || x.startsWith(`${y}/`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function joinPath(basePath: string, path: string): string {
|
|
||||||
return `${trimSlash(basePath)}${path.startsWith("/") ? path : `/${path}`}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniq(xs: string[]): string[] {
|
function uniq(xs: string[]): string[] {
|
||||||
|
|||||||
2
todo.md
2
todo.md
@@ -45,7 +45,7 @@ everything via Docker.
|
|||||||
- [x] Add to principles that we should have full E2E coverage in the Playwright tests - make sure they can run in parallel to get up some speed. → Added **Full, parallel E2E** core principle (AGENTS.md §6 + README): every user-facing flow gets a Playwright test shipped with it, tests stay side-effect-free so the suite runs `fullyParallel` (already set; verified 7 tests / 7 workers). Led by example: added E2E coverage for the 404 page (the one user-facing gap). Fixed the documented run command to `--build` (the runner bakes in `e2e/`, so spec edits were silently ignored without it).
|
- [x] Add to principles that we should have full E2E coverage in the Playwright tests - make sure they can run in parallel to get up some speed. → Added **Full, parallel E2E** core principle (AGENTS.md §6 + README): every user-facing flow gets a Playwright test shipped with it, tests stay side-effect-free so the suite runs `fullyParallel` (already set; verified 7 tests / 7 workers). Led by example: added E2E coverage for the 404 page (the one user-facing gap). Fixed the documented run command to `--build` (the runner bakes in `e2e/`, so spec edits were silently ignored without it).
|
||||||
|
|
||||||
## 2. Plugin host
|
## 2. Plugin host
|
||||||
- [x] **Specify the plugin contract** (big job, do first — it's the product's main API surface). Write it down as the authoritative reference: the full manifest shape; the `RequestContext` handed to handlers and what's guaranteed stable; **contract versioning** (a `apiVersion`/`engines`-style field so a plugin declares the host it targets, and the host refuses or warns on mismatch); **conflict rules** (two plugins claiming the same `basePath`, nav slot, or `permission` name → defined, loud resolution, not last-write-wins); the **local dev/test story** (how an author runs + tests one plugin in isolation against the host). Audience is experienced devs: optimise for a powerful, predictable, clearly-documented API. Crash-isolation (a bad plugin can't take down the host) is a *nice-to-have*, not a blocker — fail loud at boot/discovery over sandboxing at runtime. It is a target that plugins should be able to overload as much as possible. Hooks on actions in the system is not bad either, if it is possible. → `src/plugin.ts` is the typed, machine-readable contract (single source of truth: manifest `Plugin`, `Route`/`RouteResult`/`RouteHandler`, `PermissionDecl`, `PluginHooks`, `definePlugin()`, `HOST_API_VERSION`) plus the pure rules the §2 host enforces — `checkApiVersion` (semver via `parseSemver`/official regex, no dep: same major+minor→ok, older minor→warn, newer minor/major-mismatch/malformed→refuse) and `findConflicts` (id/basePath-overlap/route = error, duplicate nav-id = error, shared permission token = warn; never last-write-wins). `docs/plugin-contract.md` is the prose reference (anatomy, manifest fields, handler/RouteResult, `RequestContext` stability guarantee, nav/permission namespacing, versioning, conflicts, hooks, dev/test story). README links it + example gained `apiVersion`. Tests-first (`plugin.test.ts`); typecheck + 80 units green. Discovery/router/view-resolver/static stay as the next §2 items that wire this to FS+HTTP.
|
- [x] **Specify the plugin contract** (big job, do first — it's the product's main API surface). Write it down as the authoritative reference: the full manifest shape; the `RequestContext` handed to handlers and what's guaranteed stable; **contract versioning** (a `apiVersion`/`engines`-style field so a plugin declares the host it targets, and the host refuses or warns on mismatch); **conflict rules** (two plugins claiming the same `basePath`, nav slot, or `permission` name → defined, loud resolution, not last-write-wins); the **local dev/test story** (how an author runs + tests one plugin in isolation against the host). Audience is experienced devs: optimise for a powerful, predictable, clearly-documented API. Crash-isolation (a bad plugin can't take down the host) is a *nice-to-have*, not a blocker — fail loud at boot/discovery over sandboxing at runtime. It is a target that plugins should be able to overload as much as possible. Hooks on actions in the system is not bad either, if it is possible. → `src/plugin.ts` is the typed, machine-readable contract (single source of truth: authored `PluginManifest` + folder-derived `Plugin`, `Route`/`RouteResult`/`RouteHandler`, `PermissionDecl`, `PluginHooks`, `definePlugin()`, `HOST_API_VERSION`) plus the pure rules the §2 host enforces — `isValidPluginId` (kebab-case folder name), `checkApiVersion` (semver via `parseSemver`/official regex, no dep: same major+minor→ok, older minor→warn, newer minor/major-mismatch/malformed→refuse) and `findConflicts` (id/route = error, duplicate nav-id = error, shared permission token = warn; never last-write-wins). Identity is the folder: id = folder name, mount = `/<id>` — neither is in the manifest, so mount-path uniqueness is structural (no basePath rule). `apiVersion` is a literal a plugin pins (never imports `HOST_API_VERSION`). nav `icon` = Lucide sprite id. `docs/plugin-contract.md` is the prose reference (anatomy/identity, manifest fields, handler/RouteResult, `RequestContext` stability guarantee, nav/permission namespacing, versioning, conflicts, hooks, dev/test story). README links it. Tests-first (`plugin.test.ts`); typecheck + 82 units green. Discovery/router/view-resolver/static stay as the next §2 items that wire this to FS+HTTP.
|
||||||
- [ ] Discovery: scan `plugins/`, import each `plugin.ts` default export, validate.
|
- [ ] Discovery: scan `plugins/`, import each `plugin.ts` default export, validate.
|
||||||
- [ ] Router: match method+path under `basePath`, resolve path params, run permission gate, call handler with context.
|
- [ ] Router: match method+path under `basePath`, resolve path params, run permission gate, call handler with context.
|
||||||
- [ ] Per-plugin view resolver (`plugins/<id>/views/*.ejs`).
|
- [ ] Per-plugin view resolver (`plugins/<id>/views/*.ejs`).
|
||||||
|
|||||||
Reference in New Issue
Block a user