59 Commits

Author SHA1 Message Date
lilleman cf48b3014c Merge branch 'main' into renovate/renovate-renovate-44.x
CI / full-gate (push) Successful in 2m50s
Mirror / github-mirror (push) Successful in 4s
2026-08-25 15:09:02 +02:00
lilleman 43bb004ae7 Keep the control-choosing rule where a caller picks one
CI / full-gate (push) Successful in 2m50s
Mirror / github-mirror (push) Successful in 3s
2026-08-25 09:38:33 +02:00
lilleman f4693af3df Let a multi-select say what a capped list left out, inside the panel
CI / full-gate (push) Successful in 2m52s
2026-08-25 09:15:30 +02:00
lilleman c702a347dc Offer a long filter list as a popover multi-select instead of a wall of chips
CI / full-gate (push) Successful in 2m51s
2026-08-25 09:10:23 +02:00
renovate-bot fb480973b7 Update renovate/renovate Docker tag to v44.41.1
CI / full-gate (push) Successful in 2m51s
2026-08-25 04:18:24 +00:00
renovate-bot 26ba278eb3 Update dependency lucide-static to v1.34.0
CI / full-gate (push) Successful in 3m0s
Mirror / github-mirror (push) Successful in 3s
Release-Bump: minor
2026-08-25 04:18:15 +00:00
renovate-bot 952af3f107 Update renovate/renovate Docker tag to v44.39.3
CI / full-gate (push) Successful in 2m50s
Mirror / github-mirror (push) Successful in 2s
2026-08-24 05:41:22 +00:00
lilleman 83c9fa68a9 Merge branch 'main' into plugin-settings
Release / retag-image (push) Failing after 4s
Release / publish-overview (push) Has been skipped
Mirror / github-mirror (push) Successful in 6s
2026-08-23 16:23:14 +02:00
lilleman fa7cad1d65 Declare plugin settings in the manifest, resolve them from the environment 2026-08-23 13:17:03 +02:00
renovate-bot 3f74bf8832 Update renovate/renovate Docker tag to v44.39.2 2026-08-23 04:18:02 +00:00
lilleman aff47c8b90 Merge branch 'main' into auto-release-only-when-affected
Mirror / github-mirror (push) Successful in 5s
Release / retag-image (push) Failing after 4s
Release / publish-overview (push) Has been skipped
2026-08-22 22:16:22 +02:00
lilleman 9a1bfcc69d Follow mailpit to v1.31.0 in the published quick start 2026-08-22 21:58:14 +02:00
lilleman 075cd2f090 Merge branch 'main' into renovate/renovate-renovate-44.x 2026-08-22 21:54:19 +02:00
lilleman fd32bea28e Merge branch 'main' into auto-release-only-when-affected 2026-08-22 21:53:53 +02:00
lilleman 47f4498fff Use one Docker Hub credential for images and the overview 2026-08-22 14:27:31 +02:00
lilleman bcff5967ad Name the scope the overview actually needs 2026-08-22 14:21:14 +02:00
lilleman cbf55bebae Give each version mismatch its own remedy, and stop publishing a moving bare-major tag 2026-08-22 12:27:43 +02:00
lilleman 9e0cb26b3e Refuse a 0.x minor mismatch, and republish the overview from the named release's tree 2026-08-22 12:13:41 +02:00
lilleman 02617a935e Keep the proven always() guard on the overview job 2026-08-22 12:03:18 +02:00
lilleman 8fd492e544 Scope the sidecar trailer by package so a mixed branch cannot lose it 2026-08-22 12:01:35 +02:00
lilleman 6c5eddf0c9 Accept the README-only gate skip, and say why it is safe 2026-08-22 11:58:28 +02:00
lilleman f416abf936 Manage the published quick start's pins, and fail closed on every release-tooling edge 2026-08-22 11:45:46 +02:00
lilleman e44b86f532 Gate the overview's manual trigger on the same contract check as a tag 2026-08-22 11:27:35 +02:00
lilleman bfcf4ed072 Trim the prose to what is true now 2026-08-22 11:24:16 +02:00
lilleman a3d9a3df5f Keep the release at v0.1.0 — nothing consumed the old contract 2026-08-22 11:21:42 +02:00
renovate-bot 5befa2775a Update renovate/renovate Docker tag to v44.39.1 2026-08-22 04:18:35 +00:00
renovate-bot d3b227eef8 Update axllent/mailpit Docker tag to v1.31.0 2026-08-22 04:18:30 +00:00
renovate-bot a96a2b8abd Update renovate/renovate Docker tag to v44.37.1 2026-08-21 04:18:10 +00:00
lilleman d545445ea8 Release this as v0.2.0, and give the Hub overview its own job, token and template 2026-08-20 23:32:10 +02:00
lilleman c35ba3fb4e Make the plugin contract version the release version, and publish the Docker Hub overview from CI 2026-08-20 23:16:11 +02:00
lilleman 27a8cdc385 Release nothing when no Renovate commit reached the app 2026-08-20 23:02:21 +02:00
lilleman 4b48bc2416 Scope Release-Bump to the surfaces that ship, and drop the inert package.json versions 2026-08-20 22:43:36 +02:00
lilleman ef96ebd1f4 Rewrap the auto-release paragraph 2026-08-20 22:04:23 +02:00
lilleman ecf33733a1 Lift the pre-release freeze — releases resume and the plugin contract goes live 2026-08-20 22:02:29 +02:00
lilleman c16fb2449b Merge branch 'main' into renovate/renovate-renovate-44.x 2026-08-20 21:52:27 +02:00
lilleman d823f830c3 Merge remote-tracking branch 'origin/main' into plugin-storage
# Conflicts:
#	package-lock.json
#	package.json
2026-08-20 21:47:22 +02:00
renovate-bot 1a1aad90e3 Update dependency lucide-static to v1.33.0
Release-Bump: minor
2026-08-20 04:17:56 +00:00
lilleman 45b3cfc010 Merge remote-tracking branch 'origin/main' into plugin-storage
# Conflicts:
#	package-lock.json
#	package.json
2026-08-19 20:34:30 +02:00
lilleman a3cb1f4840 Merge branch 'main' into renovate/renovate-renovate-44.x 2026-08-19 20:29:09 +02:00
renovate-bot 0f40d06f3b Update renovate/renovate Docker tag to v44.33.2
Release-Bump: minor
2026-08-19 04:18:20 +00:00
renovate-bot b39defe39d Update dependency lucide-static to v1.32.0
Release-Bump: minor
2026-08-19 04:18:11 +00:00
lilleman e0046e5068 Warn rather than refuse on a storage URL mismatch, and scrub the provisioning DSN before discovery 2026-08-19 01:03:40 +02:00
lilleman 5589472e25 Keep role re-assertion within a non-superuser provisioner's rights, and test the second boot 2026-08-19 00:44:49 +02:00
lilleman e66a8a3e89 Isolate the storage CI stack, prove least-privilege provisioning, drop the secret before discovery 2026-08-19 00:20:17 +02:00
lilleman 060535c8ab Confine the Postgres driver to bootstrap, bound plugin connections, and gate the storage DDL 2026-08-19 00:08:35 +02:00
lilleman d2211cf75a Refuse a throwaway plugin storage secret in bootstrap, before any role is created 2026-08-18 23:24:34 +02:00
lilleman c7013be2f0 Give a plugin a Postgres database of its own 2026-08-18 23:12:13 +02:00
lilleman 9ff8f57509 Todo: note the code-field hint landed, the paste fix did not 2026-08-18 22:07:21 +02:00
lilleman 8f3fc7414a Hint the code field's digits-only rule, so the browser's refusal isn't bare 2026-08-18 22:07:12 +02:00
lilleman 552cc6bd97 Todo: record the flow-POST proxy findings for the verification-code fix 2026-08-18 22:00:31 +02:00
lilleman 52a3228503 Todo: record the manifest-over-.env decision for plugin config 2026-08-18 21:55:35 +02:00
lilleman ee7b78b94d Let Renovate reach the example plugins' manifests 2026-08-18 21:55:35 +02:00
lilleman 65e76b69fd Refuse a stray package.json or node_modules in config/ by name 2026-08-18 21:55:32 +02:00
lilleman e00dad8ed7 Merge branch 'main' into plugin-dependencies 2026-08-18 18:33:24 +02:00
lilleman cb59eee76d Refuse a node_modules at the plugins/ root, where it outranks the host's 2026-08-18 08:14:21 +02:00
lilleman 82af77356f Follow symlinked plugin folders, and keep a plugin .npmrc out of the image 2026-08-18 07:49:51 +02:00
lilleman 94dc581593 Fail loud on a null package.json and a stray plugins/package.json 2026-08-17 22:50:29 +02:00
lilleman 453058c67b Refuse a shadowing barrel copy, and record the packaging contract 2026-08-17 22:34:56 +02:00
lilleman af974cfa36 Let a plugin carry its own package.json and npm dependencies 2026-08-17 22:23:53 +02:00
42 changed files with 913 additions and 108 deletions
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
-e RENOVATE_PLATFORM=gitea \ -e RENOVATE_PLATFORM=gitea \
-e RENOVATE_REPOSITORIES=${{ github.repository }} \ -e RENOVATE_REPOSITORIES=${{ github.repository }} \
-e RENOVATE_TOKEN \ -e RENOVATE_TOKEN \
renovate/renovate:44.37.1 renovate/renovate:44.41.1
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the # After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
# last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the # last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
+10
View File
@@ -93,6 +93,16 @@ Revisit only if the stated reason stops holding.
plugin cannot destroy data — boot logs the orphans instead. Because the host's copy sits in the plugin cannot destroy data — boot logs the orphans instead. Because the host's copy sits in the
ambient `/node_modules`, a plugin can `import "postgres"` without declaring it — incidental, not a ambient `/node_modules`, a plugin can `import "postgres"` without declaring it — incidental, not a
packaging promise, and a plugin must still depend on its own driver. packaging promise, and a plugin must still depend on its own driver.
- **Plugin settings are declared, not discovered** (README → Plugin settings). `settings.ts` is pure and
takes the env as an argument, so the whole matrix unit-tests without a stack. Four rules carry the
design: the prefix is `PLUGIN_SETTING_`, never bare `PLUGIN_`, because a plugin id `db` with key
`url` would otherwise name the host's own `PLUGIN_DB_URL`; keys are camelCase so the
`camelCase → SNAKE_CASE` mapping is total and no two keys collide, with the residual cross-plugin
collision caught by `findConflicts`; `required` and `default` are mutually exclusive, which is what
lets `SettingsOf` type a declared key as present rather than `T | undefined`, so no plugin author
casts; and a secret's value reaches the plugin but never a log, an error or `ctx.declaredSettings`
— not even as a mask or a length. An author mistake is refused at discovery, a bad operator value
refuses the boot, and a stray `PLUGIN_SETTING_` variable only warns (the orphan-database precedent).
- **The trust boundary is the `web` process, not the plugin.** Per-plugin databases and roles bound - **The trust boundary is the `web` process, not the plugin.** Per-plugin databases and roles bound
*accidents*, not hostile plugins: `PLUGIN_DB_SECRET` is in `web`'s environment during `onBoot`, and *accidents*, not hostile plugins: `PLUGIN_DB_SECRET` is in `web`'s environment during `onBoot`, and
a plugin already holds `ctx.system`'s Ory admin clients — so cross-plugin DB isolation is a plugin already holds `ctx.system`'s Ory admin clients — so cross-plugin DB isolation is
+58 -5
View File
@@ -47,7 +47,7 @@ folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.
import { definePlugin } from "@plainpages/plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({ export default definePlugin({
apiVersion: "0.1.0", apiVersion: "0.3.0",
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
routes: [ routes: [
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) }, { method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
@@ -89,6 +89,7 @@ From here, render real pages against the app shell and fetch upstream data — s
- [hooks](#hooks) - [hooks](#hooks)
- [where they live & mounting](#where-plugins-live-and-how-to-mount-them) - [where they live & mounting](#where-plugins-live-and-how-to-mount-them)
- [dependencies](#plugin-dependencies) - [dependencies](#plugin-dependencies)
- [settings](#plugin-settings)
- [storage](#plugin-storage) - [storage](#plugin-storage)
- [local dev & test](#local-dev--test-story) - [local dev & test](#local-dev--test-story)
- [The menu system](#the-menu-system) - [The menu system](#the-menu-system)
@@ -348,7 +349,7 @@ import { definePlugin } from "@plainpages/plugin-api";
import { listThings, createThings } from "./handlers.ts"; import { listThings, createThings } from "./handlers.ts";
export default definePlugin({ export default definePlugin({
apiVersion: "0.1.0", // semver string of the host contract this plugin was built against (see Versioning) apiVersion: "0.3.0", // semver string of the host contract this plugin was built against (see Versioning)
// 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/ui/icons.ts). // `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
@@ -381,6 +382,7 @@ folder-derived `id` to produce the loaded `Plugin`.
| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). | | `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). |
| `routes` | no | See [Routes & handlers](#routes--handlers). | | `routes` | no | See [Routes & handlers](#routes--handlers). |
| `hooks` | no | See [Hooks](#hooks). | | `hooks` | no | See [Hooks](#hooks). |
| `settings` | no | Configuration this plugin accepts, one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, resolved and validated at boot and handed to `onBoot`. See [Plugin settings](#plugin-settings). |
| `storage` | no | `true` ⇒ the host provisions a Postgres database and login role for this plugin and hands the credentials to `onBoot`. See [Plugin storage](#plugin-storage). | | `storage` | no | `true` ⇒ the host provisions a Postgres database and login role for this plugin and hands the credentials to `onBoot`. See [Plugin storage](#plugin-storage). |
A plugin may be routes-only, nav-only, or hooks-only — every collection field is optional. A plugin may be routes-only, nav-only, or hooks-only — every collection field is optional.
@@ -468,7 +470,7 @@ import { definePlugin } from "@plainpages/plugin-api";
import { landing, board } from "./pages.ts"; import { landing, board } from "./pages.ts";
export default definePlugin({ export default definePlugin({
apiVersion: "0.1.0", apiVersion: "0.3.0",
home: landing, // owns "/" — the public front page home: landing, // owns "/" — the public front page
dashboard: board, // owns "/dashboard" — the post-login app home dashboard: board, // owns "/dashboard" — the post-login app home
}); });
@@ -736,6 +738,56 @@ barrel's types on disk: typecheck it mounted under the host tree, or vendor a ty
`node_modules`** and point tsconfig `paths` at it — a stub inside is the shadowing copy discovery `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. refuses, and it would travel with the folder you mount.
### Plugin settings
A plugin declares the configuration it accepts, and the host resolves it from the environment at
boot. Each key becomes one variable — `PLUGIN_SETTING_<ID>_<KEY>`, the id's dashes and the key's
camel humps both becoming underscores — so `upstream` on the `scheduling` plugin is set by
`PLUGIN_SETTING_SCHEDULING_UPSTREAM`.
```ts
export default definePlugin({
apiVersion: "0.3.0",
settings: [
{ key: "upstream", type: "url", required: true, description: "Base URL of the backend" },
{ key: "pageSize", type: "number", default: 25 },
{ key: "mode", type: "enum", values: ["strict", "lenient"], default: "strict" },
{ key: "apiKey", type: "string", secret: true, default: "dev-insecure-key" },
],
hooks: {
onBoot: ({ settings }) => {
settings.upstream; // string — required, so the boot already refused without it
settings.pageSize; // number — defaulted, so always present
start(settings);
},
},
});
```
`type` is one of `string`, `number`, `boolean`, `enum` (with `values`) or `url`. A declared type is
coerced and checked at boot, so a mistyped value names the plugin, the key and the variable instead
of surfacing later as a broken page.
**`required` and `default` are mutually exclusive** — a default means the setting can never fail, so
declaring both is refused at discovery. That leaves three cases, and the type `onBoot` receives
follows them exactly: `required: true` is always present, a `default` is always present, and a
setting with neither is `T | undefined`, so the plugin has to handle its absence.
**Secrets.** `secret: true` marks a value the host reads but never renders — not in a boot log, not
in an error, not on the admin screen, which shows only whether it resolved and from where. With
`REQUIRE_SECURE_SECRETS=true` a secret that is unset, or still equal to its declared default, refuses
the boot — the same rule the host applies to its own secrets.
**Where it fails, and where it warns.** A malformed declaration is refused at discovery; a missing
`required` value or a value that will not coerce refuses the boot. A `PLUGIN_SETTING_` variable no
installed plugin declares is only *reported* — it is usually a typo in the one the operator meant to
set, and naming it turns two unrelated-looking errors into one. Declaring settings without an
`onBoot` warns too: they resolve, but nothing receives them.
**Reading what a deployment is configured with.** The admin plugin's **Plugin settings** screen
(`plugin-settings:read`) lists every installed plugin, its declared keys, the variable that sets
each, and whether the value came from the environment or the declared default.
### Plugin storage ### Plugin storage
A plugin that needs to keep data sets `storage: true`. The host then provisions a Postgres A plugin that needs to keep data sets `storage: true`. The host then provisions a Postgres
@@ -749,7 +801,7 @@ import { definePlugin } from "@plainpages/plugin-api";
let sql: ReturnType<typeof postgres>; let sql: ReturnType<typeof postgres>;
export default definePlugin({ export default definePlugin({
apiVersion: "0.1.0", apiVersion: "0.3.0",
storage: true, storage: true,
hooks: { hooks: {
onBoot: async (boot) => { onBoot: async (boot) => {
@@ -1030,7 +1082,7 @@ The app is **environment-agnostic**: no `NODE_ENV`, every behaviour its own expl
| `PORT` | `3000` | web listen port | | `PORT` | `3000` | web listen port |
| `CACHE_TEMPLATES` | `false` | cache compiled EJS templates (`true` in prod) | | `CACHE_TEMPLATES` | `false` | cache compiled EJS templates (`true` in prod) |
| `SECURE_COOKIES` | `false` | mark our session/CSRF cookies `Secure` (`true` in prod https; off in dev http) | | `SECURE_COOKIES` | `false` | mark our session/CSRF cookies `Secure` (`true` in prod https; off in dev http) |
| `REQUIRE_SECURE_SECRETS` | `false` | when `true`, `CSRF_SECRET` — and `PLUGIN_DB_SECRET` once storage is configured — must be supplied and differ from the dev throwaway | | `REQUIRE_SECURE_SECRETS` | `false` | when `true`, `CSRF_SECRET` — and `PLUGIN_DB_SECRET` once storage is configured, and every plugin setting declared `secret` — must be supplied and differ from the dev throwaway |
| `LOG_LEVEL` | `info` | min severity logged: `error`/`warn`/`info`/`verbose`/`debug`/`silly`/`none` | | `LOG_LEVEL` | `info` | min severity logged: `error`/`warn`/`info`/`verbose`/`debug`/`silly`/`none` |
| `LOG_FORMAT` | `text` | log line format: `text` (human-readable, dev) or `json` (structured, prod) | | `LOG_FORMAT` | `text` | log line format: `text` (human-readable, dev) or `json` (structured, prod) |
| `SERVICE_NAME` | `plainpages` | OTLP `service.name` on every log + span — brand it as your own deployment | | `SERVICE_NAME` | `plainpages` | OTLP `service.name` on every log + span — brand it as your own deployment |
@@ -1047,6 +1099,7 @@ The app is **environment-agnostic**: no `NODE_ENV`, every behaviour its own expl
| `REVOCATION_TTL_SEC` | `900` | how long a revoke entry lives; keep ≥ tokenizer TTL (10m) + clock skew | | `REVOCATION_TTL_SEC` | `900` | how long a revoke entry lives; keep ≥ tokenizer TTL (10m) + clock skew |
| `CSRF_SECRET` | dev throwaway | signs our double-submit CSRF token; enforced by `REQUIRE_SECURE_SECRETS` | | `CSRF_SECRET` | dev throwaway | signs our double-submit CSRF token; enforced by `REQUIRE_SECURE_SECRETS` |
| `PLUGIN_DB_URL` | _unset_ (dev: `postgres://postgres:5432`) | credential-free Postgres base URL for [plugin storage](#plugin-storage); unset ⇒ storage off, and a plugin declaring it aborts boot | | `PLUGIN_DB_URL` | _unset_ (dev: `postgres://postgres:5432`) | credential-free Postgres base URL for [plugin storage](#plugin-storage); unset ⇒ storage off, and a plugin declaring it aborts boot |
| `PLUGIN_SETTING_<ID>_<KEY>` | per declaration | one variable per key a plugin declares in `settings`; see [Plugin settings](#plugin-settings) |
| `PLUGIN_DB_ADMIN_URL` | _unset_ (dev: the bundled superuser) | the DSN that provisions each plugin's database and role — read by the one-shot `bootstrap` service **only**, never by `web` | | `PLUGIN_DB_ADMIN_URL` | _unset_ (dev: the bundled superuser) | the DSN that provisions each plugin's database and role — read by the one-shot `bootstrap` service **only**, never by `web` |
| `PLUGIN_DB_SECRET` | dev throwaway | derives each plugin's database password; `REQUIRE_SECURE_SECRETS` enforces it in `web` once `PLUGIN_DB_URL` is set, and in `bootstrap` whenever a plugin declares storage | | `PLUGIN_DB_SECRET` | dev throwaway | derives each plugin's database password; `REQUIRE_SECURE_SECRETS` enforces it in `web` once `PLUGIN_DB_URL` is set, and in `bootstrap` whenever a plugin declares storage |
| `PLUGIN_DB_CONNECTION_LIMIT` | `10` | per-role Postgres connection ceiling, so one plugin's pools cannot exhaust the server Ory shares; read by `bootstrap` when provisioning | | `PLUGIN_DB_CONNECTION_LIMIT` | `10` | per-role Postgres connection ceiling, so one plugin's pools cannot exhaust the server Ory shares; read by `bootstrap` when provisioning |
+3 -3
View File
@@ -22,7 +22,7 @@ services:
PLUGIN_DB_URL: *plugin-db-url PLUGIN_DB_URL: *plugin-db-url
REQUIRE_SECURE_SECRETS: "false" REQUIRE_SECURE_SECRETS: "false"
SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent
SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # backs the reference plugin once you copy it into plugins/ PLUGIN_SETTING_SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # backs the reference plugin once you copy it into plugins/
volumes: volumes:
- .:/app - .:/app
# Mount your own menu/branding override into the empty config/ dir (defaults apply otherwise): # Mount your own menu/branding override into the empty config/ dir (defaults apply otherwise):
@@ -46,8 +46,8 @@ services:
# Mock backend ready for the reference plugin (examples/plugins/scheduling): plugins/ ships empty, so # Mock backend ready for the reference plugin (examples/plugins/scheduling): plugins/ ships empty, so
# the plugin is opt-in — `cp -r examples/plugins/scheduling plugins/scheduling`, restart, and this # the plugin is opt-in — `cp -r examples/plugins/scheduling plugins/scheduling`, restart, and this
# backs it (SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service — # backs it (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
# stdlib-only, in-memory, no auth. Prod points SCHEDULING_UPSTREAM at the real backend instead. # stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead.
shifts-upstream: shifts-upstream:
image: node:24.19.0-alpine3.24 image: node:24.19.0-alpine3.24
command: node /srv/server.ts command: node /srv/server.ts
+11
View File
@@ -195,6 +195,17 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream
}); });
test("plugin settings: the screen names the variable that sets each declared key", async () => {
await page.goto("/admin/plugin-settings");
await expect(page.locator("h1")).toHaveText("Plugin settings");
// The reference plugin's one declared setting, and the variable an operator would set for it.
const scheduling = page.locator("table").filter({ hasText: "PLUGIN_SETTING_SCHEDULING_UPSTREAM" });
await expect(scheduling).toContainText("upstream");
await expect(scheduling).toContainText("http://shifts-upstream:4000"); // resolved, and its source shown
// Every installed plugin gets a section, so "declares none" is distinguishable from "not installed".
await expect(page.locator("h2", { hasText: "admin" })).toHaveCount(1);
});
test("logout: signing out ends the session and returns to the login page", async () => { test("logout: signing out ends the session and returns to the login page", async () => {
await page.goto("/dashboard"); await page.goto("/dashboard");
await page.locator("button.profile").click(); // open the profile dropdown await page.locator("button.profile").click(); // open the profile dropdown
+1 -1
View File
@@ -8,4 +8,4 @@ across (or bind-mount your own) and restart.
| [`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/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `@plainpages/plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). | | [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). | | [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. | | [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `PLUGIN_SETTING_SCHEDULING_UPSTREAM` at the real thing instead. |
@@ -0,0 +1,55 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { PageChrome, PluginSettings } from "@plainpages/plugin-api";
import { buildPluginSettingsModel } from "./admin-plugin-settings.ts";
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
const CATALOG: readonly PluginSettings[] = [
{
pluginId: "scheduling",
settings: [
{ description: "Where shifts come from", envName: "PLUGIN_SETTING_SCHEDULING_UPSTREAM", key: "upstream", required: true, secret: false, source: "env", type: "url", value: "https://shifts.test" },
{ envName: "PLUGIN_SETTING_SCHEDULING_MODE", key: "mode", required: false, secret: false, source: "default", type: "enum", value: "strict", values: ["strict", "lenient"] },
{ envName: "PLUGIN_SETTING_SCHEDULING_NOTE", key: "note", required: false, secret: false, source: "unset", type: "string" },
],
},
{ pluginId: "quiet", settings: [] },
];
test("a row carries the variable to set and where the value came from", () => {
const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG });
const rows = model.groups[0]?.table.rows ?? [];
assert.deepEqual(rows.map((r) => r.name), ["upstream", "mode", "note"]);
assert.deepEqual(rows[0]?.cells, [
{ rowHeader: { text: "upstream" } }, "Where shifts come from", "url", "Yes", "PLUGIN_SETTING_SCHEDULING_UPSTREAM", "Environment", "https://shifts.test",
]);
assert.equal(rows[1]?.cells[2], "enum (strict, lenient)"); // the choices are the useful half of the type
assert.equal(rows[2]?.cells[5], "Not set");
});
test("a plugin declaring nothing still gets a section, so it is visibly installed", () => {
const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG });
assert.deepEqual(model.groups.map((g) => g.pluginId), ["scheduling", "quiet"]);
assert.deepEqual(model.groups[1]?.table.rows, []);
assert.match(model.groups[1]?.emptyText ?? "", /no settings/i);
});
test("a secret renders as set-or-not, never as a value, a mask or a length", () => {
const settings: readonly PluginSettings[] = [{
pluginId: "billing",
settings: [
{ envName: "PLUGIN_SETTING_BILLING_API_KEY", key: "apiKey", required: false, secret: true, source: "env", type: "string" },
{ envName: "PLUGIN_SETTING_BILLING_WEBHOOK_KEY", key: "webhookKey", required: false, secret: true, source: "unset", type: "string" },
],
}];
const rows = buildPluginSettingsModel({ chrome: CHROME, settings }).groups[0]?.table.rows ?? [];
assert.equal(rows[0]?.cells[6], "Secret — set");
assert.equal(rows[1]?.cells[6], "Secret — not set");
});
test("two tables on one page need distinct row-action id stems", () => {
const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG });
const stems = model.groups.map((g) => g.table.actionsId);
assert.equal(new Set(stems).size, stems.length);
});
@@ -0,0 +1,75 @@
// Plugin settings admin screen: what each installed plugin declares it can be configured with, the
// variable that sets it, and how each key resolved. Read-only — the host reads settings from the
// environment at boot, so changing one is a deploy, not a form.
import { type PageChrome, type PluginSettings, type RouteHandler, type SettingSummary, type Translate } from "@plainpages/plugin-api";
import { ADMIN_EN, requirePermission } from "./admin-shared.ts";
interface SettingsGroup {
emptyText: string;
pluginId: string;
table: {
actionsId: string;
caption: string;
columns: { label: string }[];
rows: { cells: (string | { rowHeader: { text: string } })[]; name: string }[];
};
}
// One group per installed plugin, including those declaring nothing — an operator who cannot find
// their plugin here has not installed it, which is the other half of what this screen answers.
export function buildPluginSettingsModel(opts: { chrome: PageChrome; settings: readonly PluginSettings[]; t?: Translate }) {
const t = opts.t ?? ADMIN_EN;
return {
breadcrumbs: [{ label: t("admin.pluginSettings.title") }],
chrome: opts.chrome,
groups: opts.settings.map((plugin): SettingsGroup => ({
emptyText: t("admin.pluginSettings.none"),
pluginId: plugin.pluginId,
table: {
actionsId: `settings-${plugin.pluginId}`, // two tables share this page, so the stem must differ
caption: t("admin.pluginSettings.caption", { plugin: plugin.pluginId }),
columns: [
{ label: t("admin.pluginSettings.column.key") },
{ label: t("admin.pluginSettings.column.description") },
{ label: t("admin.pluginSettings.column.type") },
{ label: t("admin.pluginSettings.column.required") },
{ label: t("admin.pluginSettings.column.variable") },
{ label: t("admin.pluginSettings.column.source") },
{ label: t("admin.pluginSettings.column.value") },
],
rows: plugin.settings.map((setting) => ({
cells: [
{ rowHeader: { text: setting.key } },
setting.description ?? "",
typeLabel(setting),
t(setting.required ? "admin.pluginSettings.yes" : "admin.pluginSettings.no"),
setting.envName,
t(`admin.pluginSettings.source.${setting.source}`),
valueLabel(setting, t),
],
name: setting.key,
})),
},
})),
title: t("admin.pluginSettings.title"),
};
}
// An enum's choices are the useful half of its type — they are what the operator must pick from.
function typeLabel(setting: SettingSummary): string {
return setting.type === "enum" && setting.values ? `${setting.type} (${setting.values.join(", ")})` : setting.type;
}
// A secret never renders its value — not the value, not a mask of it, not its length. Whether it
// resolved and from where is what an operator needs, and the source column already says the rest.
function valueLabel(setting: SettingSummary, t: Translate): string {
if (setting.secret) return t(setting.source === "unset" ? "admin.pluginSettings.secretUnset" : "admin.pluginSettings.secretSet");
return setting.value ?? t("admin.pluginSettings.unset");
}
// GET /admin/plugin-settings
export const pluginSettingsList: RouteHandler = (ctx) => {
requirePermission(ctx, "plugin-settings");
return { data: { chrome: ctx.chrome, model: buildPluginSettingsModel({ chrome: ctx.chrome, settings: ctx.declaredSettings, t: ctx.t }) }, view: "plugin-settings" };
};
+7 -7
View File
@@ -19,7 +19,7 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
req.method = opts.method ?? "GET"; req.method = opts.method ?? "GET";
return { return {
chrome: CHROME, declaredPermissions: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {}, chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {},
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url, query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url,
verifyCsrf: opts.verifyCsrf ?? (() => true), verifyCsrf: opts.verifyCsrf ?? (() => true),
}; };
@@ -27,21 +27,21 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
// ---- nav fragment ---- // ---- nav fragment ----
test("ADMIN_NAV: an ungated Admin header whose three screens each gate on their own read permission", () => { test("ADMIN_NAV: an ungated Admin header whose screens each gate on their own read permission", () => {
assert.equal(ADMIN_NAV.id, "admin"); assert.equal(ADMIN_NAV.id, "admin");
// No gate on the header: a user may hold one screen's permission and not another's. composeNav // No gate on the header: a user may hold one screen's permission and not another's. composeNav
// drops a header left with no visible children, so holding none of the three hides the section. // drops a header left with no visible children, so holding none of them hides the section.
// Both halves matter — give the header an `href` and it survives the filter as a visible leaf, // Both halves matter — give the header an `href` and it survives the filter as a visible leaf,
// ungated, for anonymous visitors included. // ungated, for anonymous visitors included.
assert.equal(ADMIN_NAV.permission, undefined); assert.equal(ADMIN_NAV.permission, undefined);
assert.equal(ADMIN_NAV.href, undefined); assert.equal(ADMIN_NAV.href, undefined);
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/clients"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/clients", "/admin/plugin-settings"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read", "plugin-settings:read"]);
// Labels are catalog keys; the host translates them with this plugin's catalog when it composes // Labels are catalog keys; the host translates them with this plugin's catalog when it composes
// the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys. // the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys.
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.clients"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.clients", "admin.nav.pluginSettings"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "OAuth2 clients"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "OAuth2 clients", "Plugin settings"]);
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined)); assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined));
}); });
+3 -1
View File
@@ -12,12 +12,13 @@ export const ADMIN_EN: Translate = englishTranslator(enUS);
export const ADMIN_USERS_BASE = "/admin/users"; export const ADMIN_USERS_BASE = "/admin/users";
export const ADMIN_GROUPS_BASE = "/admin/groups"; export const ADMIN_GROUPS_BASE = "/admin/groups";
export const ADMIN_CLIENTS_BASE = "/admin/clients"; export const ADMIN_CLIENTS_BASE = "/admin/clients";
export const ADMIN_PLUGIN_SETTINGS_BASE = "/admin/plugin-settings";
// One resource per screen — the `<resource>` half of every permission this plugin gates on. // One resource per screen — the `<resource>` half of every permission this plugin gates on.
// `oauth2-clients` rather than `clients` because permission names are one global namespace. // `oauth2-clients` rather than `clients` because permission names are one global namespace.
// There is no `permissions` resource: permissions are declared in plugin code, not created here, so // There is no `permissions` resource: permissions are declared in plugin code, not created here, so
// holding a grant is a property of a user or a group and is edited on those two screens. // holding a grant is a property of a user or a group and is edited on those two screens.
export type AdminResource = "groups" | "oauth2-clients" | "users"; export type AdminResource = "groups" | "oauth2-clients" | "plugin-settings" | "users";
export type AdminAction = "read" | "write"; export type AdminAction = "read" | "write";
@@ -43,6 +44,7 @@ export const ADMIN_NAV: NavNode = {
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") }, { href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") },
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups", permission: permissionName("groups", "read") }, { href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups", permission: permissionName("groups", "read") },
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients", permission: permissionName("oauth2-clients", "read") }, { href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients", permission: permissionName("oauth2-clients", "read") },
{ href: ADMIN_PLUGIN_SETTINGS_BASE, icon: "i-sliders", id: "plugin-settings", label: "admin.nav.pluginSettings", permission: permissionName("plugin-settings", "read") },
], ],
icon: "i-shield", icon: "i-shield",
id: "admin", id: "admin",
+20
View File
@@ -83,9 +83,29 @@ const messages = {
"admin.nav.clients": "OAuth2 clients", "admin.nav.clients": "OAuth2 clients",
"admin.nav.groups": "Groups", "admin.nav.groups": "Groups",
"admin.nav.pluginSettings": "Plugin settings",
"admin.nav.section": "Admin", "admin.nav.section": "Admin",
"admin.nav.users": "Users", "admin.nav.users": "Users",
"admin.pluginSettings.caption": "Settings declared by {{plugin}}",
"admin.pluginSettings.column.description": "Description",
"admin.pluginSettings.column.key": "Key",
"admin.pluginSettings.column.required": "Required",
"admin.pluginSettings.column.source": "Source",
"admin.pluginSettings.column.type": "Type",
"admin.pluginSettings.column.value": "Value",
"admin.pluginSettings.column.variable": "Variable",
"admin.pluginSettings.no": "No",
"admin.pluginSettings.none": "This plugin declares no settings.",
"admin.pluginSettings.secretSet": "Secret — set",
"admin.pluginSettings.secretUnset": "Secret — not set",
"admin.pluginSettings.source.default": "Default",
"admin.pluginSettings.source.env": "Environment",
"admin.pluginSettings.source.unset": "Not set",
"admin.pluginSettings.title": "Plugin settings",
"admin.pluginSettings.unset": "—",
"admin.pluginSettings.yes": "Yes",
"admin.notFound.message": "That item doesn't exist.", "admin.notFound.message": "That item doesn't exist.",
"admin.notFound.title": "Not found", "admin.notFound.title": "Not found",
+20
View File
@@ -83,9 +83,29 @@ const messages: AdminMessages = {
"admin.nav.clients": "OAuth2-klienter", "admin.nav.clients": "OAuth2-klienter",
"admin.nav.groups": "Grupper", "admin.nav.groups": "Grupper",
"admin.nav.pluginSettings": "Tilläggsinställningar",
"admin.nav.section": "Administration", "admin.nav.section": "Administration",
"admin.nav.users": "Användare", "admin.nav.users": "Användare",
"admin.pluginSettings.caption": "Inställningar som {{plugin}} deklarerar",
"admin.pluginSettings.column.description": "Beskrivning",
"admin.pluginSettings.column.key": "Nyckel",
"admin.pluginSettings.column.required": "Obligatorisk",
"admin.pluginSettings.column.source": "Källa",
"admin.pluginSettings.column.type": "Typ",
"admin.pluginSettings.column.value": "Värde",
"admin.pluginSettings.column.variable": "Variabel",
"admin.pluginSettings.no": "Nej",
"admin.pluginSettings.none": "Det här tillägget deklarerar inga inställningar.",
"admin.pluginSettings.secretSet": "Hemlighet — satt",
"admin.pluginSettings.secretUnset": "Hemlighet — inte satt",
"admin.pluginSettings.source.default": "Standardvärde",
"admin.pluginSettings.source.env": "Miljövariabel",
"admin.pluginSettings.source.unset": "Inte satt",
"admin.pluginSettings.title": "Tilläggsinställningar",
"admin.pluginSettings.unset": "—",
"admin.pluginSettings.yes": "Ja",
"admin.notFound.message": "Objektet finns inte.", "admin.notFound.message": "Objektet finns inte.",
"admin.notFound.title": "Hittades inte", "admin.notFound.title": "Hittades inte",
+7 -4
View File
@@ -35,17 +35,20 @@ test("every nav permission is one the manifest declares", () => {
} }
}; };
walk(manifest.nav); walk(manifest.nav);
assert.equal(navPermissions.length, 3); assert.equal(navPermissions.length, 4);
for (const name of navPermissions) assert.ok(declared.includes(name), `nav gates on undeclared ${name}`); for (const name of navPermissions) assert.ok(declared.includes(name), `nav gates on undeclared ${name}`);
}); });
test("every declared permission is <resource>:<action>, and reads and writes are split per resource", () => { test("every declared permission is <resource>:<action>, and reads and writes are split per resource", () => {
for (const name of declared) assert.ok(isValidPermissionName(name), name); // the host's rule, not a copy of it for (const name of declared) assert.ok(isValidPermissionName(name), name); // the host's rule, not a copy of it
// Three screens × read/write. There is deliberately no `permissions:` pair: permissions are // Three CRUD screens × read/write, plus read-only plugin settings — a screen that never writes
// declared in plugin code, so holding one is edited on the user or group that holds it. // declares no `:write`, since a permission nothing gates on is one an operator can only mis-grant.
// There is deliberately no `permissions:` pair either: permissions are declared in plugin code, so
// holding one is edited on the user or group that holds it.
assert.deepEqual([...declared].sort(), [ assert.deepEqual([...declared].sort(), [
"groups:read", "groups:write", "groups:read", "groups:write",
"oauth2-clients:read", "oauth2-clients:write", "oauth2-clients:read", "oauth2-clients:write",
"plugin-settings:read",
"users:read", "users:write", "users:read", "users:write",
]); ]);
}); });
@@ -58,5 +61,5 @@ test("GET routes gate on read and mutations on write, so a reader can open a scr
const action = route.method === "GET" && !writeIntent(route.path) ? "read" : "write"; const action = route.method === "GET" && !writeIntent(route.path) ? "read" : "write";
assert.ok(route.permission?.endsWith(`:${action}`), `${route.method} ${route.path}${route.permission}`); assert.ok(route.permission?.endsWith(`:${action}`), `${route.method} ${route.path}${route.permission}`);
} }
assert.equal(routes.filter((r) => r.method === "GET" && writeIntent(r.path)).length, 6); // 2 per screen assert.equal(routes.filter((r) => r.method === "GET" && writeIntent(r.path)).length, 6); // 2 per CRUD screen; plugin settings has none
}); });
+6 -1
View File
@@ -7,6 +7,7 @@
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "@plainpages/plugin-api"; import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "@plainpages/plugin-api";
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts"; import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts"; import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts";
import { pluginSettingsList } from "./admin-plugin-settings.ts";
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts"; import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
import { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } from "./admin-shared.ts"; import { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } from "./admin-shared.ts";
@@ -24,9 +25,10 @@ const on = (resource: AdminResource) => (method: HttpMethod, path: string, handl
const users = on("users"); const users = on("users");
const groups = on("groups"); const groups = on("groups");
const clients = on("oauth2-clients"); const clients = on("oauth2-clients");
const pluginSettings = on("plugin-settings");
export default definePlugin({ export default definePlugin({
apiVersion: "0.1.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "0.3.0", // the host contract this was built against — a literal, never HOST_API_VERSION
nav: [ADMIN_NAV], nav: [ADMIN_NAV],
@@ -37,6 +39,7 @@ export default definePlugin({
{ description: "Create and delete groups, and change their members and permissions", name: "groups:write" }, { description: "Create and delete groups, and change their members and permissions", name: "groups:write" },
{ description: "View OAuth2 clients", name: "oauth2-clients:read" }, { description: "View OAuth2 clients", name: "oauth2-clients:read" },
{ description: "Register and delete OAuth2 clients", name: "oauth2-clients:write" }, { description: "Register and delete OAuth2 clients", name: "oauth2-clients:write" },
{ description: "View the settings each installed plugin declares, and how they resolved", name: "plugin-settings:read" },
], ],
routes: [ routes: [
@@ -68,5 +71,7 @@ export default definePlugin({
clients("GET", "/clients/:id", clientsDetail), clients("GET", "/clients/:id", clientsDetail),
clients("GET", "/clients/:id/delete", clientsDeleteConfirm, "write"), clients("GET", "/clients/:id/delete", clientsDeleteConfirm, "write"),
clients("POST", "/clients/:id/delete", clientsDelete), clients("POST", "/clients/:id/delete", clientsDelete),
// Plugin settings — read-only, so no :write route and no write-intent GET.
pluginSettings("GET", "/plugin-settings", pluginSettingsList),
], ],
}); });
@@ -0,0 +1,24 @@
<%#
Plugin settings admin list: one section per installed plugin, each a table of what it declares
and how each key resolved (admin-plugin-settings.ts). Read-only — no actions, no forms.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
let body = "";
for (const group of model.groups) {
// A plugin id is the folder name, which discovery constrains to [a-z0-9-] — no escaping needed.
body += '<h2 class="h2">' + group.pluginId + "</h2>";
body += group.table.rows.length === 0
? '<p class="muted">' + group.emptyText + "</p>"
: include("partials/data-table", group.table);
}
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
+1 -1
View File
@@ -27,7 +27,7 @@ The plugin holds **no state** — data lives upstream (README → *Stateless*).
## Upstream ## Upstream
Set `SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory Set `PLUGIN_SETTING_SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory
mock (`examples/shifts-upstream/`) so `docker compose up` shows the plugin working out of the box. mock (`examples/shifts-upstream/`) so `docker compose up` shows the plugin working out of the box.
A malformed/non-http URL fails the boot loudly (the plugin's `onBoot` hook). A malformed/non-http URL fails the boot loudly (the plugin's `onBoot` hook).
+20 -8
View File
@@ -3,19 +3,20 @@
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins. // folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
import { definePlugin } from "@plainpages/plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts"; import { createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
// The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is // The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is
// stateless). Configure via env; the dev compose points it at a tiny mock (examples/shifts-upstream). // stateless). Its URL is a declared setting, so it is resolved and validated before onBoot hands it
const upstreamUrl = process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstream:4000"; // over — which is after this manifest is built, hence the getter.
const upstream = createUpstream(upstreamUrl); let upstreamUrl = "";
const upstream = createUpstream(() => upstreamUrl);
export default definePlugin({ export default definePlugin({
apiVersion: "0.1.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "0.3.0", // the host contract this was built against — a literal, never HOST_API_VERSION
// onBoot runs after discovery, before the server listens: validate the plugin's own config so a // onBoot runs after discovery, before the server listens — where a plugin receives its resolved
// typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later. // settings. A malformed URL already failed the boot by then; the host validated the declared type.
hooks: { onBoot: () => assertHttpUrl(upstreamUrl, "SCHEDULING_UPSTREAM") }, hooks: { onBoot: ({ settings }) => { upstreamUrl = settings.upstream; } },
// Merged into the global menu + filtered per user. Labels are keys in this plugin's own catalog // Merged into the global menu + filtered per user. Labels are keys in this plugin's own catalog
// (i18n/<locale>.ts) — a plain string works too, it just isn't translated. "Overview" is `public`, so the "Scheduling" // (i18n/<locale>.ts) — a plain string works too, it just isn't translated. "Overview" is `public`, so the "Scheduling"
@@ -45,4 +46,15 @@ export default definePlugin({
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE }, { handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE }, { handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },
], ],
// Operator-supplied config: one PLUGIN_SETTING_SCHEDULING_UPSTREAM variable, validated as a URL at
// boot. The default points at the mock backend the dev compose runs (examples/shifts-upstream).
settings: [
{
default: "http://shifts-upstream:4000",
description: "Base URL of the backend this plugin reads shifts from and writes them to",
key: "upstream",
type: "url",
},
],
}); });
+21 -23
View File
@@ -7,7 +7,7 @@ import test from "node:test";
import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "@plainpages/plugin-api"; import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "@plainpages/plugin-api";
import enUS from "./i18n/en-US.ts"; import enUS from "./i18n/en-US.ts";
import { import {
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate, SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
} from "./shifts.ts"; } from "./shifts.ts";
@@ -18,7 +18,7 @@ function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; ve
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts"); const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
return { return {
chrome: CHROME, declaredPermissions: [], user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {}, chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url, query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
verifyCsrf: opts.verifyCsrf ?? (() => true), verifyCsrf: opts.verifyCsrf ?? (() => true),
}; };
@@ -35,27 +35,25 @@ const asView = (r: RouteResult | void) => {
return r as { data: Record<string, unknown>; status?: number; view: string }; return r as { data: Record<string, unknown>; status?: number; view: string };
}; };
// ---- upstream config validation (the onBoot hook) ---- // ---- the upstream URL as a declared setting ----
test("assertHttpUrl accepts http(s) and fails loud on a malformed or non-http upstream URL", () => { test("the manifest declares its upstream as a URL setting the host validates", async () => {
assert.doesNotThrow(() => assertHttpUrl("http://shifts-upstream:4000", "SCHEDULING_UPSTREAM")); const manifest = (await import("./plugin.ts")).default;
assert.doesNotThrow(() => assertHttpUrl("https://api.example.com/v1", "SCHEDULING_UPSTREAM")); assert.deepEqual(manifest.settings?.map((s) => s.key), ["upstream"]);
assert.throws(() => assertHttpUrl("not a url", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*valid URL/); // unparseable assert.equal(manifest.settings?.[0]?.type, "url"); // so a typo'd URL fails the boot, not every request
assert.throws(() => assertHttpUrl("shifts-upstream:4000", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // missing // → parsed as a bogus scheme assert.equal(manifest.settings?.[0]?.default, "http://shifts-upstream:4000"); // the dev compose's mock
assert.throws(() => assertHttpUrl("ftp://host/x", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // wrong scheme assert.equal(typeof manifest.hooks?.onBoot, "function"); // without it the resolved value never arrives
}); });
test("the manifest's onBoot hook validates SCHEDULING_UPSTREAM (the binding, not just the helper)", async () => { test("the client re-reads its base URL, so onBoot can bind it after the manifest is built", async () => {
const prev = process.env["SCHEDULING_UPSTREAM"]; let baseUrl = "http://first:4000";
process.env["SCHEDULING_UPSTREAM"] = "nope://bad"; // read at import time below const seen: string[] = [];
try { const http = (async (url) => { seen.push(String(url)); return new Response("[]", { status: 200 }); }) as typeof fetch;
const manifest = (await import("./plugin.ts")).default; const upstream = createUpstream(() => baseUrl, http);
assert.equal(typeof manifest.hooks?.onBoot, "function"); await upstream.list();
assert.throws(() => manifest.hooks!.onBoot!({}), /SCHEDULING_UPSTREAM/); // bad upstream → boot fails loud baseUrl = "http://second:4000";
} finally { await upstream.list();
if (prev === undefined) delete process.env["SCHEDULING_UPSTREAM"]; assert.deepEqual(seen, ["http://first:4000/shifts", "http://second:4000/shifts"]);
else process.env["SCHEDULING_UPSTREAM"] = prev;
}
}); });
// ---- upstream client (fetch injected) ---- // ---- upstream client (fetch injected) ----
@@ -67,21 +65,21 @@ test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", as
assert.equal((init?.headers as Record<string, string>).accept, "application/json"); assert.equal((init?.headers as Record<string, string>).accept, "application/json");
return new Response(JSON.stringify([{ assignee: "A", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 }); return new Response(JSON.stringify([{ assignee: "A", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 });
}) as typeof fetch; }) as typeof fetch;
const shifts = await createUpstream("http://up:4000/", http).list(); // trailing slash trimmed const shifts = await createUpstream(() => "http://up:4000/", http).list(); // trailing slash trimmed
assert.equal(seen, "http://up:4000/shifts"); assert.equal(seen, "http://up:4000/shifts");
assert.deepEqual(shifts, [{ assignee: "A", end: "2", id: "x", start: "1", title: "T" }]); assert.deepEqual(shifts, [{ assignee: "A", end: "2", id: "x", start: "1", title: "T" }]);
}); });
test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => { test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => {
const http = (async () => new Response("nope", { status: 503 })) as typeof fetch; const http = (async () => new Response("nope", { status: 503 })) as typeof fetch;
await assert.rejects(createUpstream("http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503); await assert.rejects(createUpstream(() => "http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503);
}); });
test("createUpstream.create POSTs the input as JSON", async () => { test("createUpstream.create POSTs the input as JSON", async () => {
let body: unknown, method = ""; let body: unknown, method = "";
const http = (async (_url, init) => { method = init?.method ?? ""; body = JSON.parse(String(init?.body)); return new Response(null, { status: 201 }); }) as typeof fetch; const http = (async (_url, init) => { method = init?.method ?? ""; body = JSON.parse(String(init?.body)); return new Response(null, { status: 201 }); }) as typeof fetch;
const input: ShiftInput = { assignee: "A", end: "2", start: "1", title: "T" }; const input: ShiftInput = { assignee: "A", end: "2", start: "1", title: "T" };
await createUpstream("http://up:4000", http).create(input); await createUpstream(() => "http://up:4000", http).create(input);
assert.equal(method, "POST"); assert.equal(method, "POST");
assert.deepEqual(body, input); assert.deepEqual(body, input);
}); });
+6 -16
View File
@@ -49,26 +49,16 @@ export interface ShiftsUpstream {
list(): Promise<Shift[]>; list(): Promise<Shift[]>;
} }
// Fail loud at boot (the plugin's onBoot hook) on a malformed/non-http upstream URL — a config
// typo surfaces at startup, not as a degraded page later. Reachability stays a runtime concern.
export function assertHttpUrl(value: string, name: string): void {
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error(`${name} is not a valid URL: ${JSON.stringify(value)}`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${name} must be an http(s) URL: ${JSON.stringify(value)}`);
}
// REST client over the upstream service (a stand-in for the customer's real backend). `fetch` // REST client over the upstream service (a stand-in for the customer's real backend). `fetch`
// defaults to the host's tracedFetch, so each upstream call joins the request's trace (a client // defaults to the host's tracedFetch, so each upstream call joins the request's trace (a client
// span + a propagated traceparent); it's injectable so handlers unit-test against a mock, no network. // span + a propagated traceparent); it's injectable so handlers unit-test against a mock, no network.
export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream { // `baseUrl` is read per call: the plugin's settings arrive on onBoot, after the manifest that binds
const base = baseUrl.replace(/\/+$/, ""); // these handlers has already been built.
export function createUpstream(baseUrl: () => string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream {
const base = (): string => baseUrl().replace(/\/+$/, "");
return { return {
async create(input) { async create(input) {
const res = await fetchImpl(`${base}/shifts`, { const res = await fetchImpl(`${base()}/shifts`, {
body: JSON.stringify(input), body: JSON.stringify(input),
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
method: "POST", method: "POST",
@@ -76,7 +66,7 @@ export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = traced
if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status); if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status);
}, },
async list() { async list() {
const res = await fetchImpl(`${base}/shifts`, { headers: { accept: "application/json" } }); const res = await fetchImpl(`${base()}/shifts`, { headers: { accept: "application/json" } });
if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status); if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status);
const data: unknown = await res.json(); const data: unknown = await res.json();
return Array.isArray(data) ? data.map(toShift) : []; return Array.isArray(data) ? data.map(toShift) : [];
+1 -1
View File
@@ -1,6 +1,6 @@
// Dev-only mock upstream for the reference plugin (examples/plugins/scheduling) — a stand-in for the // Dev-only mock upstream for the reference plugin (examples/plugins/scheduling) — a stand-in for the
// customer's real backend, ready for when you copy the reference plugin into plugins/. NOT part // customer's real backend, ready for when you copy the reference plugin into plugins/. NOT part
// of the app: stdlib only, in-memory (state resets on restart), no auth. Point SCHEDULING_UPSTREAM // of the app: stdlib only, in-memory (state resets on restart), no auth. Point PLUGIN_SETTING_SCHEDULING_UPSTREAM
// at your real service in production. // at your real service in production.
// //
// GET /shifts → 200 [ { id, title, assignee, start, end }, … ] // GET /shifts → 200 [ { id, title, assignee, start, end }, … ]
+4 -4
View File
@@ -8,7 +8,7 @@
"dependencies": { "dependencies": {
"@larvit/log": "2.3.0", "@larvit/log": "2.3.0",
"ejs": "6.0.1", "ejs": "6.0.1",
"lucide-static": "1.33.0", "lucide-static": "1.34.0",
"postgres": "3.4.9" "postgres": "3.4.9"
}, },
"devDependencies": { "devDependencies": {
@@ -399,9 +399,9 @@
} }
}, },
"node_modules/lucide-static": { "node_modules/lucide-static": {
"version": "1.33.0", "version": "1.34.0",
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.33.0.tgz", "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.34.0.tgz",
"integrity": "sha512-jNGgvTNcLUfVRX4N9PH9pVVTJzoph/BmYmgU838bYBQodkUJL4nAThkuymFz1x3OUYMhJxPndC7rdg1sxOPYKg==", "integrity": "sha512-pSUvFhfhvDnhXN1fXerZEMgKLQQ3DneKwFYlqB5ji8OEAN0iPi/qgwQvCkcL519QgMeR66IpS/pT342VyT/g4g==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/postgres": { "node_modules/postgres": {
+1 -1
View File
@@ -18,7 +18,7 @@
"dependencies": { "dependencies": {
"@larvit/log": "2.3.0", "@larvit/log": "2.3.0",
"ejs": "6.0.1", "ejs": "6.0.1",
"lucide-static": "1.33.0", "lucide-static": "1.34.0",
"postgres": "3.4.9" "postgres": "3.4.9"
}, },
"devDependencies": { "devDependencies": {
+7 -1
View File
@@ -316,6 +316,11 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.btn-primary:hover { filter: brightness(1.06); } .btn-primary:hover { filter: brightness(1.06); }
.btn-ghost { background: transparent; border-color: transparent; } .btn-ghost { background: transparent; border-color: transparent; }
.btn-ghost:hover { background: var(--surface-2); } .btn-ghost:hover { background: var(--surface-2); }
.btn-menu::after {
content: ""; width: 7px; height: 7px; margin: -2px 1px 0 1px;
border-right: 1.5px solid var(--text-faint); border-bottom: 1.5px solid var(--text-faint);
transform: rotate(45deg);
}
.icon-btn { .icon-btn {
width: 30px; height: 30px; padding: 0; justify-content: center; width: 30px; height: 30px; padding: 0; justify-content: center;
color: var(--text-muted); color: var(--text-muted);
@@ -494,7 +499,8 @@ span.nav-self { cursor: default; } /* static / non-clickable */
position-anchor: auto; position-anchor: auto;
position-try-fallbacks: flip-block, flip-inline; position-try-fallbacks: flip-block, flip-inline;
top: anchor(bottom); right: anchor(right); top: anchor(bottom); right: anchor(right);
min-width: 210px; padding: 6px; min-width: 210px; max-width: min(320px, 92vw); padding: 6px;
max-height: 60vh; overflow-y: auto; overflow-wrap: anywhere;
background: var(--surface); color: var(--text); background: var(--surface); color: var(--text);
border: 1px solid var(--border-2); border-radius: var(--radius); border: 1px solid var(--border-2); border-radius: var(--radius);
box-shadow: 0 8px 28px rgba(0,0,0,.16); box-shadow: 0 8px 28px rgba(0,0,0,.16);
+1 -1
View File
@@ -12,7 +12,7 @@ test("readHostApiVersion pulls the constant out of the real source, and returns
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => { test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
// Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that // Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
// makes an accidental edit fail here rather than at release time. // makes an accidental edit fail here rather than at release time.
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.1.0"); assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.3.0");
}); });
test("every author-facing apiVersion sample matches the shipped contract", () => { test("every author-facing apiVersion sample matches the shipped contract", () => {
+1 -1
View File
@@ -182,7 +182,7 @@ into the app. Create `plugins/hello/plugin.ts`:
import { definePlugin } from "@plainpages/plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({ export default definePlugin({
apiVersion: "0.1.0", apiVersion: "0.3.0",
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
routes: [ routes: [
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) }, { method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
+2
View File
@@ -56,6 +56,7 @@ export interface Config {
pluginDbSecret: string; // derives each plugin's database password (src/plugin-host/storage.ts) pluginDbSecret: string; // derives each plugin's database password (src/plugin-host/storage.ts)
pluginDbUrl: string | undefined; // credential-free Postgres base URL; unset ⇒ plugin storage is off pluginDbUrl: string | undefined; // credential-free Postgres base URL; unset ⇒ plugin storage is off
port: number; port: number;
requireSecureSecrets: boolean; // enforce real secrets — the host's own, and every plugin's declared `secret`
revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
secureCookies: boolean; secureCookies: boolean;
@@ -188,6 +189,7 @@ export function loadConfig(env: Env = process.env): Config {
pluginDbSecret: resolvePluginDbSecret(env, requireSecure && Boolean(env["PLUGIN_DB_URL"])), pluginDbSecret: resolvePluginDbSecret(env, requireSecure && Boolean(env["PLUGIN_DB_URL"])),
pluginDbUrl: readCredentiallessUrl(env, "PLUGIN_DB_URL"), pluginDbUrl: readCredentiallessUrl(env, "PLUGIN_DB_URL"),
port: readPort(env), port: readPort(env),
requireSecureSecrets: requireSecure,
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission // Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m // change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
// tokenizer TTL + skew, so it outlasts any pre-revoke token). // tokenizer TTL + skew, so it outlasts any pre-revoke token).
+5 -2
View File
@@ -26,6 +26,7 @@ import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts"
import { remintSession } from "../auth/login.ts"; import { remintSession } from "../auth/login.ts";
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts"; import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts";
import type { PluginSettings } from "../plugin-host/settings.ts";
import type { SystemCapabilities } from "../plugin-host/system.ts"; import type { SystemCapabilities } from "../plugin-host/system.ts";
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts"; import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
import { buildAuthRoutes } from "../auth/routes.ts"; import { buildAuthRoutes } from "../auth/routes.ts";
@@ -54,6 +55,7 @@ export interface AppOptions {
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/ pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
publicDir?: string; publicDir?: string;
secureCookies?: boolean; // set Secure on our session/CSRF cookies (config.secureCookies; off in dev http) secureCookies?: boolean; // set Secure on our session/CSRF cookies (config.secureCookies; off in dev http)
settingsCatalog?: readonly PluginSettings[]; // resolved at boot (server.ts, needs the env); → ctx.declaredSettings
viewsDir?: string; viewsDir?: string;
} }
@@ -88,6 +90,7 @@ export function createApp(options: AppOptions = {}): Server {
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function"); const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function");
const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function"); const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function");
const permissionCatalog = declaredPermissions(plugins); const permissionCatalog = declaredPermissions(plugins);
const settingsCatalog = options.settingsCatalog ?? [];
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free). // Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest); const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse); const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
@@ -259,9 +262,9 @@ export function createApp(options: AppOptions = {}): Server {
// Base context (no route params), for the built-in routes. Every plugin-owned render — a // Base context (no route params), for the built-in routes. Every plugin-owned render — a
// landing slot, a hook short-circuit, a plugin route — gets `contextFor(id)` instead. // landing slot, a hook short-circuit, a plugin route — gets `contextFor(id)` instead.
const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) }); const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext => const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) }); buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
renderPage = viewsFor(ctx); renderPage = viewsFor(ctx);
// Plugin onRequest hooks run before routing and may short-circuit the request. // Plugin onRequest hooks run before routing and may short-circuit the request.
+6
View File
@@ -1,6 +1,7 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle
import type { PermissionDecl } from "../plugin-host/plugin.ts"; // type-only import type { PermissionDecl } from "../plugin-host/plugin.ts"; // type-only
import type { PluginSettings } from "../plugin-host/settings.ts"; // type-only
import type { SystemCapabilities } from "../plugin-host/system.ts"; // type-only import type { SystemCapabilities } from "../plugin-host/system.ts"; // type-only
import { DEFAULT_LOCALE } from "../i18n/catalog.ts"; import { DEFAULT_LOCALE } from "../i18n/catalog.ts";
import { ENGLISH } from "../i18n/english.ts"; import { ENGLISH } from "../i18n/english.ts";
@@ -42,6 +43,9 @@ export interface RequestContext {
// screen offers when granting one. Pairs with `permissions` below: this is what *exists*, that is // screen offers when granting one. Pairs with `permissions` below: this is what *exists*, that is
// what *this user holds*. Empty when no installed plugin declares any. // what *this user holds*. Empty when no installed plugin declares any.
declaredPermissions: readonly PermissionDecl[]; declaredPermissions: readonly PermissionDecl[];
// What each installed plugin declares it can be configured with, and how each key resolved — one
// entry per plugin, including those declaring nothing. A secret's value is never carried here.
declaredSettings: readonly PluginSettings[];
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id } params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q") query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
@@ -67,6 +71,7 @@ export interface BuildContextOptions {
// The host's factory is memoised, so the menu composes at most once per request across contexts. // The host's factory is memoised, so the menu composes at most once per request across contexts.
chrome?: () => PageChrome; chrome?: () => PageChrome;
declaredPermissions?: readonly PermissionDecl[]; declaredPermissions?: readonly PermissionDecl[];
declaredSettings?: readonly PluginSettings[];
user?: User | null; user?: User | null;
locale?: string; locale?: string;
localeHref?: (href: string) => string; localeHref?: (href: string) => string;
@@ -96,6 +101,7 @@ export function buildContext(
return { return {
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); }, get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
declaredPermissions: options.declaredPermissions ?? [], declaredPermissions: options.declaredPermissions ?? [],
declaredSettings: options.declaredSettings ?? [],
user, user,
locale: options.locale ?? DEFAULT_LOCALE, locale: options.locale ?? DEFAULT_LOCALE,
localeHref: options.localeHref ?? ((href) => href), localeHref: options.localeHref ?? ((href) => href),
+1
View File
@@ -100,6 +100,7 @@ const messages = {
"filter.remove": "Remove {{label}} filter", "filter.remove": "Remove {{label}} filter",
"filter.reset": "Reset", "filter.reset": "Reset",
"filter.search": "Search", "filter.search": "Search",
"filter.selected": "{{label}}, {{count}} selected",
"filter.to": "To", "filter.to": "To",
"filter.toSeparator": "to", "filter.toSeparator": "to",
+1
View File
@@ -91,6 +91,7 @@ const messages: CoreMessages = {
"filter.remove": "Ta bort filtret {{label}}", "filter.remove": "Ta bort filtret {{label}}",
"filter.reset": "Återställ", "filter.reset": "Återställ",
"filter.search": "Sök", "filter.search": "Sök",
"filter.selected": "{{label}}, {{count}} valda",
"filter.to": "Till", "filter.to": "Till",
"filter.toSeparator": "till", "filter.toSeparator": "till",
+5
View File
@@ -8,6 +8,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts"; import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
import { settingsDeclError } from "./settings.ts";
import { isValidStoragePluginId, MAX_STORAGE_PLUGIN_ID_LENGTH } from "./storage.ts"; import { isValidStoragePluginId, MAX_STORAGE_PLUGIN_ID_LENGTH } from "./storage.ts";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
@@ -141,6 +142,10 @@ function shapeError(manifest: PluginManifest): string | null {
} }
// A truthy non-boolean (a DSN, say) must not quietly read as "provision me one". // A truthy non-boolean (a DSN, say) must not quietly read as "provision me one".
if (manifest.storage !== undefined && typeof manifest.storage !== "boolean") return `"storage" must be a boolean`; if (manifest.storage !== undefined && typeof manifest.storage !== "boolean") return `"storage" must be a boolean`;
if (manifest.settings !== undefined) {
const settings = settingsDeclError(manifest.settings);
if (settings) return settings;
}
// `public` and `permission` are contradictory on the same route/nav node — "open to all" vs // `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
// "needs this permission". Refuse rather than silently pick one, so the author's intent is unambiguous. // "needs this permission". Refuse rather than silently pick one, so the author's intent is unambiguous.
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) { for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
+1
View File
@@ -8,6 +8,7 @@ export { definePlugin, isValidPermissionName } from "./plugin.ts";
export type { BootContext, HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts"; export type { BootContext, HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
// A plugin's own database, handed to onBoot when the manifest sets `storage`. Credentials, not a // A plugin's own database, handed to onBoot when the manifest sets `storage`. Credentials, not a
// client — the plugin depends on whichever driver it prefers (README → Plugin storage). // client — the plugin depends on whichever driver it prefers (README → Plugin storage).
export type { PluginSettings, SettingDecl, SettingSummary, SettingType, SettingValue } from "./settings.ts";
export type { StorageCredentials } from "./storage.ts"; export type { StorageCredentials } from "./storage.ts";
export type { RequestContext, User } from "../http/context.ts"; export type { RequestContext, User } from "../http/context.ts";
export type { PageChrome } from "../ui/chrome.ts"; export type { PageChrome } from "../ui/chrome.ts";
+29 -9
View File
@@ -6,10 +6,11 @@
import type { RequestContext } from "../http/context.ts"; import type { RequestContext } from "../http/context.ts";
import type { NavNode } from "../ui/nav.ts"; import type { NavNode } from "../ui/nav.ts";
import { envName, type SettingDecl, type SettingsOf } from "./settings.ts";
import type { StorageCredentials } from "./storage.ts"; import type { StorageCredentials } from "./storage.ts";
// The Plainpages release this contract ships in — see README → Contract versioning. // The Plainpages release this contract ships in — see README → Contract versioning.
export const HOST_API_VERSION = "0.1.0"; export const HOST_API_VERSION = "0.3.0";
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT"; export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
@@ -62,30 +63,39 @@ export function declaredPermissions(plugins: Plugin[]): PermissionDecl[] {
} }
// What onBoot receives. A hook declaring no parameter stays valid, so this may grow additively. // What onBoot receives. A hook declaring no parameter stays valid, so this may grow additively.
export interface BootContext { export type BootContext<S extends readonly SettingDecl[] = readonly SettingDecl[]> = {
storage?: StorageCredentials; // this plugin's own database; present iff the manifest declared `storage` storage?: StorageCredentials; // this plugin's own database; present iff the manifest declared `storage`
} } & SettingsSlot<S>;
// Required once the manifest declares settings, so that plugin reads `settings.key` without a guard
// for the case it just ruled out; optional for a manifest that declared none.
type SettingsSlot<S extends readonly SettingDecl[]> = readonly [] extends S
? { settings?: SettingsOf<S> }
: { settings: SettingsOf<S> };
// Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud. // Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud.
export interface PluginHooks { export interface PluginHooks<S extends readonly SettingDecl[] = readonly SettingDecl[]> {
onBoot?: (host: BootContext) => Promise<void> | void; // after discovery, before the server listens onBoot?: (host: BootContext<S>) => Promise<void> | void; // after discovery, before the server listens
onRequest?: (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void; // may short-circuit onRequest?: (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void; // may short-circuit
onResponse?: (ctx: RequestContext, result: RouteResult | null) => Promise<void> | void; onResponse?: (ctx: RequestContext, result: RouteResult | null) => Promise<void> | void;
} }
// The authored manifest — a plugin's `plugin.ts` default-exports this. No `id`/mount path: the // The authored manifest — a plugin's `plugin.ts` default-exports this. No `id`/mount path: the
// host derives them from the folder name at discovery (see Plugin). // host derives them from the folder name at discovery (see Plugin).
export interface PluginManifest { export interface PluginManifest<S extends readonly SettingDecl[] = readonly SettingDecl[]> {
apiVersion: string; // semver of the host contract this targets — write a literal, NOT HOST_API_VERSION (see docs) apiVersion: string; // semver of the host contract this targets — write a literal, NOT HOST_API_VERSION (see docs)
// Take over "/dashboard"; the host gates it to a signed-in session first. At most one plugin may // Take over "/dashboard"; the host gates it to a signed-in session first. At most one plugin may
// declare it (findConflicts → error, never last-write-wins). // declare it (findConflicts → error, never last-write-wins).
dashboard?: RouteHandler; dashboard?: RouteHandler;
// Take over the ungated public landing "/". At most one plugin may declare it. // Take over the ungated public landing "/". At most one plugin may declare it.
home?: RouteHandler; home?: RouteHandler;
hooks?: PluginHooks; hooks?: PluginHooks<S>;
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique
permissions?: PermissionDecl[]; permissions?: PermissionDecl[];
routes?: Route[]; routes?: Route[];
// Operator-supplied configuration, one PLUGIN_SETTING_<ID>_<KEY> variable per key; the resolved
// values arrive on onBoot's BootContext, typed from these declarations (settings.ts).
settings?: S;
// Ask for a Postgres database of this plugin's own; its credentials arrive on onBoot's BootContext. // Ask for a Postgres database of this plugin's own; its credentials arrive on onBoot's BootContext.
// The host provisions and locks it down but owns no schema inside it, and never drops it. // The host provisions and locks it down but owns no schema inside it, and never drops it.
storage?: boolean; storage?: boolean;
@@ -99,7 +109,9 @@ export interface Plugin extends PluginManifest {
// Types the manifest and returns it unchanged; validation happens at discovery, so a plugin may // Types the manifest and returns it unchanged; validation happens at discovery, so a plugin may
// equally be a plain typed object. // equally be a plain typed object.
export function definePlugin(manifest: PluginManifest): PluginManifest { // The `const` parameter captures the literal `settings`, so onBoot receives each key at its declared
// type instead of a union every plugin author would have to narrow with a cast.
export function definePlugin<const S extends readonly SettingDecl[]>(manifest: PluginManifest<S>): PluginManifest<S> {
return manifest; return manifest;
} }
@@ -168,7 +180,7 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO
} }
export interface PluginConflict { export interface PluginConflict {
kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route"; kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route" | "setting";
level: "error" | "warn"; level: "error" | "warn";
message: string; message: string;
plugins: string[]; // unique ids involved plugins: string[]; // unique ids involved
@@ -209,6 +221,14 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; pick a more specific "<resource>" unless shared on purpose`, plugins: uniq(owners) }); if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; pick a more specific "<resource>" unless shared on purpose`, plugins: uniq(owners) });
}); });
// Both the id's dashes and the key's camel humps become underscores, so plugin "a-b" key "c" and
// plugin "a" key "bC" name one variable — one plugin would silently read the other's value.
collect(plugins, (plugin, push) => {
for (const decl of plugin.settings ?? []) push(envName(plugin.id, decl.key));
}).forEach((owners, name) => {
if (owners.length > 1) out.push({ kind: "setting", level: "error", message: `${owners.length} settings resolve to "${name}"; rename a key or a plugin folder`, plugins: uniq(owners) });
});
return out; return out;
} }
+177
View File
@@ -0,0 +1,177 @@
// Guards the plugin-settings rules: the env name a declaration maps to, per-type coercion, the
// required/default/secret resolution, and what the admin catalog is allowed to carry.
import { test } from "node:test";
import assert from "node:assert/strict";
import type { Plugin } from "./plugin.ts";
import {
ENV_PREFIX,
envName,
isValidSettingKey,
resolveSettings,
settingsDeclError,
settingsEnvNames,
strayNames,
type SettingDecl,
} from "./settings.ts";
function pluginWith(id: string, settings: SettingDecl[]): Plugin {
return { apiVersion: "0.2.0", id, settings };
}
test("a key becomes one SCREAMING_SNAKE segment under the plugin's own", () => {
assert.equal(envName("scheduling", "timezone"), "PLUGIN_SETTING_SCHEDULING_TIMEZONE");
assert.equal(envName("scheduling", "maxShiftHours"), "PLUGIN_SETTING_SCHEDULING_MAX_SHIFT_HOURS");
assert.equal(envName("my-plugin", "apiBase"), "PLUGIN_SETTING_MY_PLUGIN_API_BASE");
assert.equal(ENV_PREFIX, "PLUGIN_SETTING_"); // never bare PLUGIN_ — the host owns PLUGIN_DB_*
});
test("the host's own PLUGIN_DB_* variables are unreachable from a declaration", () => {
// A plugin id "db" with key "url" is exactly the collision the longer prefix rules out.
assert.notEqual(envName("db", "url"), "PLUGIN_DB_URL");
assert.equal(envName("db", "url"), "PLUGIN_SETTING_DB_URL");
});
test("a key is camelCase, so the env name is derivable and no two keys collide", () => {
assert.ok(isValidSettingKey("timezone"));
assert.ok(isValidSettingKey("maxShiftHours"));
assert.ok(!isValidSettingKey("max_shift_hours")); // would collide with maxShiftHours
assert.ok(!isValidSettingKey("MaxShiftHours"));
assert.ok(!isValidSettingKey("2fa"));
assert.ok(!isValidSettingKey(""));
});
test("a declaration is refused when it cannot mean what it says", () => {
const why = (settings: unknown): string => settingsDeclError(settings) ?? "";
assert.equal(settingsDeclError([{ key: "a", type: "string" }]), null);
assert.match(why("nope"), /must be an array/);
assert.match(why([{ key: "max_hours", type: "number" }]), /max_hours.*camelCase/);
assert.match(why([{ key: "a", type: "date" }]), /"date".*string, number, boolean, enum, url/);
assert.match(why([{ key: "a", type: "string" }, { key: "a", type: "number" }]), /declared twice/);
// required means "boot fails without it", so a default would make the flag a lie.
assert.match(why([{ default: "x", key: "a", required: true, type: "string" }]), /required.*default.*mutually exclusive/);
assert.match(why([{ default: 8, key: "a", type: "string" }]), /default.*string/);
assert.match(why([{ key: "a", type: "enum" }]), /enum.*values/);
assert.match(why([{ key: "a", type: "enum", values: [] }]), /enum.*values/);
assert.match(why([{ default: "c", key: "a", type: "enum", values: ["a", "b"] }]), /default "c".*a, b/);
assert.match(why([{ key: "a", type: "string", values: ["a"] }]), /values.*only.*enum/);
});
test("an unset optional setting resolves to undefined, not to a stand-in", () => {
const result = resolveSettings([pluginWith("p", [{ key: "a", type: "string" }])], {});
assert.deepEqual(result.errors, []);
assert.equal(result.values.get("p")?.["a"], undefined);
});
test("a default fills in, and an env value overrides it", () => {
const plugins = [pluginWith("p", [{ default: 8, key: "maxHours", type: "number" }])];
assert.equal(resolveSettings(plugins, {}).values.get("p")?.["maxHours"], 8);
assert.equal(resolveSettings(plugins, { PLUGIN_SETTING_P_MAX_HOURS: "12" }).values.get("p")?.["maxHours"], 12);
});
test("an empty variable reads as unset — compose passes an unset one through as \"\"", () => {
const plugins = [pluginWith("p", [{ default: "fallback", key: "a", type: "string" }])];
assert.equal(resolveSettings(plugins, { PLUGIN_SETTING_P_A: "" }).values.get("p")?.["a"], "fallback");
const required = [pluginWith("p", [{ key: "a", required: true, type: "string" }])];
assert.match(resolveSettings(required, { PLUGIN_SETTING_P_A: "" }).errors.join("\n"), /must be set/);
});
test("a missing required setting is an error naming the plugin, the key and the variable", () => {
const result = resolveSettings([pluginWith("scheduling", [{ key: "timezone", required: true, type: "string" }])], {});
assert.equal(result.errors.length, 1);
assert.match(result.errors[0] ?? "", /scheduling/);
assert.match(result.errors[0] ?? "", /timezone/);
assert.match(result.errors[0] ?? "", /PLUGIN_SETTING_SCHEDULING_TIMEZONE/);
});
test("each type coerces from the environment, and a bad value fails loud", () => {
const decls: SettingDecl[] = [
{ key: "text", type: "string" },
{ key: "count", type: "number" },
{ key: "flag", type: "boolean" },
{ key: "mode", type: "enum", values: ["strict", "lenient"] },
{ key: "base", type: "url" },
];
const ok = resolveSettings([pluginWith("p", decls)], {
PLUGIN_SETTING_P_BASE: "https://example.com/v1",
PLUGIN_SETTING_P_COUNT: "42",
PLUGIN_SETTING_P_FLAG: "true",
PLUGIN_SETTING_P_MODE: "strict",
PLUGIN_SETTING_P_TEXT: "hello",
});
assert.deepEqual(ok.errors, []);
assert.deepEqual(ok.values.get("p"), { base: "https://example.com/v1", count: 42, flag: true, mode: "strict", text: "hello" });
const bad = resolveSettings([pluginWith("p", decls)], {
PLUGIN_SETTING_P_BASE: "not a url",
PLUGIN_SETTING_P_COUNT: "twelve",
PLUGIN_SETTING_P_FLAG: "yes",
PLUGIN_SETTING_P_MODE: "loose",
});
assert.equal(bad.errors.length, 4);
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_COUNT.*number/);
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_FLAG.*"true".*"false"/);
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_MODE.*strict, lenient/);
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_BASE.*URL/);
});
test("a boolean is only \"true\"/\"false\" — a typo never degrades to false", () => {
const plugins = [pluginWith("p", [{ default: true, key: "flag", type: "boolean" }])];
assert.equal(resolveSettings(plugins, { PLUGIN_SETTING_P_FLAG: "false" }).values.get("p")?.["flag"], false);
assert.equal(resolveSettings(plugins, { PLUGIN_SETTING_P_FLAG: "0" }).errors.length, 1);
});
test("REQUIRE_SECURE_SECRETS refuses an unset secret and one still on its dev default", () => {
const decls: SettingDecl[] = [{ default: "dev-insecure", key: "apiKey", secret: true, type: "string" }];
const plugins = [pluginWith("p", decls)];
assert.deepEqual(resolveSettings(plugins, {}).errors, []); // off: the dev default boots a clean clone
assert.match(resolveSettings(plugins, {}, { requireSecureSecrets: true }).errors.join("\n"), /apiKey.*must be set/);
assert.match(
resolveSettings(plugins, { PLUGIN_SETTING_P_API_KEY: "dev-insecure" }, { requireSecureSecrets: true }).errors.join("\n"),
/apiKey.*dev/,
);
assert.deepEqual(resolveSettings(plugins, { PLUGIN_SETTING_P_API_KEY: "real" }, { requireSecureSecrets: true }).errors, []);
});
test("a secret's value reaches the plugin but never the catalog", () => {
const plugins = [pluginWith("p", [{ key: "apiKey", secret: true, type: "string" }])];
const result = resolveSettings(plugins, { PLUGIN_SETTING_P_API_KEY: "s3cr3t" });
assert.equal(result.values.get("p")?.["apiKey"], "s3cr3t");
const entry = result.catalog[0]?.settings[0];
assert.equal(entry?.secret, true);
assert.equal(entry?.source, "env");
assert.equal(entry?.value, undefined); // not the value, not its length, not a mask of it
assert.ok(!JSON.stringify(result.catalog).includes("s3cr3t"));
});
test("the catalog carries every installed plugin, so \"declares none\" is not \"not installed\"", () => {
const plugins = [pluginWith("with", [{ default: "x", key: "a", type: "string" }]), { apiVersion: "0.2.0", id: "without" }];
const catalog = resolveSettings(plugins, {}).catalog;
assert.deepEqual(catalog.map((entry) => entry.pluginId), ["with", "without"]);
assert.deepEqual(catalog[1]?.settings, []);
});
test("a catalog entry carries the variable to set and where the value came from", () => {
const decls: SettingDecl[] = [
{ description: "Where shifts come from", key: "upstream", required: true, type: "url" },
{ default: 8, key: "maxHours", type: "number" },
{ key: "note", type: "string" },
];
const catalog = resolveSettings([pluginWith("scheduling", decls)], { PLUGIN_SETTING_SCHEDULING_UPSTREAM: "https://x.test" }).catalog;
assert.deepEqual(catalog[0]?.settings, [
{ description: "Where shifts come from", envName: "PLUGIN_SETTING_SCHEDULING_UPSTREAM", key: "upstream", required: true, secret: false, source: "env", type: "url", value: "https://x.test" },
{ envName: "PLUGIN_SETTING_SCHEDULING_MAX_HOURS", key: "maxHours", required: false, secret: false, source: "default", type: "number", value: "8" },
{ envName: "PLUGIN_SETTING_SCHEDULING_NOTE", key: "note", required: false, secret: false, source: "unset", type: "string" },
]);
});
test("a variable no plugin declares is reported, never acted on", () => {
const declared = settingsEnvNames([pluginWith("scheduling", [{ key: "timezone", type: "string" }])]);
const strays = strayNames(
{ PATH: "/usr/bin", PLUGIN_DB_URL: "postgres://x", PLUGIN_SETTING_GONE_KEY: "x", PLUGIN_SETTING_SCHEDULING_TIMEZOME: "UTC", PLUGIN_SETTING_SCHEDULING_TIMEZONE: "UTC" },
declared,
);
assert.deepEqual(strays, ["PLUGIN_SETTING_GONE_KEY", "PLUGIN_SETTING_SCHEDULING_TIMEZOME"]); // sorted; the host's own untouched
});
+259
View File
@@ -0,0 +1,259 @@
// Per-plugin settings: the declaration shape, the env name it maps to, and the resolution rules
// (README → Plugin settings). Pure — server.ts passes `process.env` in, so the whole matrix is
// unit-testable without a stack.
import type { Plugin } from "./plugin.ts";
// `PLUGIN_` alone would let a plugin id "db" with key "url" produce the host's own PLUGIN_DB_URL.
export const ENV_PREFIX = "PLUGIN_SETTING_";
export const SETTING_TYPES = ["string", "number", "boolean", "enum", "url"] as const;
export type SettingType = (typeof SETTING_TYPES)[number];
export type SettingValue = boolean | number | string;
// What a manifest declares. `required` and `default` are mutually exclusive: a default means the
// setting can never fail resolution, which is the opposite of what required asserts.
export interface SettingDecl {
default?: SettingValue;
description?: string;
key: string;
required?: boolean;
secret?: boolean; // value reaches the plugin, never a log, an error or the catalog
type: SettingType;
values?: readonly string[]; // enum only — the accepted choices
}
interface SettingTypeMap {
boolean: boolean;
enum: string;
number: number;
string: string;
url: string;
}
type ValueOfDecl<D> = D extends { type: "enum"; values: readonly (infer V extends string)[] }
? V
: D extends { type: infer T extends keyof SettingTypeMap }
? SettingTypeMap[T]
: never;
// The resolved shape a plugin's onBoot receives, inferred from its own declarations so no caller
// narrows with a cast. Only a required or defaulted setting is guaranteed present.
export type SettingsOf<D extends readonly SettingDecl[]> = {
[K in D[number] as K["key"]]: K extends { required: true }
? ValueOfDecl<K>
: K extends { default: SettingValue }
? ValueOfDecl<K>
: ValueOfDecl<K> | undefined;
};
export type SettingsValues = Record<string, SettingValue | undefined>;
// One row of the admin catalog. `value` is a display string and is absent for a secret and for an
// unset setting — a secret's length is a disclosure too, so nothing stands in for it.
export interface SettingSummary {
description?: string;
envName: string;
key: string;
required: boolean;
secret: boolean;
source: "default" | "env" | "unset";
type: SettingType;
value?: string;
values?: readonly string[];
}
export interface PluginSettings {
pluginId: string;
settings: SettingSummary[];
}
export interface ResolveResult {
catalog: PluginSettings[];
errors: string[];
values: Map<string, SettingsValues>;
}
export interface ResolveOptions {
requireSecureSecrets?: boolean;
}
type Env = Record<string, string | undefined>;
const SETTING_KEY = /^[a-z][a-zA-Z0-9]*$/;
export function isValidSettingKey(key: unknown): boolean {
return typeof key === "string" && SETTING_KEY.test(key);
}
export function envName(pluginId: string, key: string): string {
const plugin = pluginId.replaceAll("-", "_").toUpperCase();
return `${ENV_PREFIX}${plugin}_${camelToSnake(key)}`;
}
function camelToSnake(key: string): string {
return key.replaceAll(/(?<=[a-z0-9])(?=[A-Z])/g, "_").toUpperCase();
}
// Discovery-time shape check: the author's mistakes, refused before any value is read.
export function settingsDeclError(settings: unknown): string | null {
if (!Array.isArray(settings)) return `"settings" must be an array`;
const seen = new Set<string>();
for (const decl of settings as SettingDecl[]) {
const key = decl?.key;
if (!isValidSettingKey(key)) {
return `setting "${String(key)}" — a key must be camelCase (${SETTING_KEY.source}) so its variable name is derivable`;
}
if (seen.has(key)) return `setting "${key}" is declared twice`;
seen.add(key);
if (!(SETTING_TYPES as readonly string[]).includes(decl.type)) {
return `setting "${key}" has type "${String(decl.type)}"; one of ${SETTING_TYPES.join(", ")}`;
}
if (decl.required === true && decl.default !== undefined) {
return `setting "${key}" sets both required and default — they are mutually exclusive, a default means it can never fail`;
}
if (decl.type === "enum") {
if (!Array.isArray(decl.values) || decl.values.length === 0 || decl.values.some((v) => typeof v !== "string")) {
return `setting "${key}" has type enum, so it must declare a non-empty values array of strings`;
}
} else if (decl.values !== undefined) {
return `setting "${key}" declares values, which only an enum type may do`;
}
const typeError = defaultTypeError(decl);
if (typeError) return typeError;
}
return null;
}
function defaultTypeError(decl: SettingDecl): string | null {
if (decl.default === undefined) return null;
if (decl.type === "enum") {
const values = decl.values ?? [];
return values.includes(String(decl.default))
? null
: `setting "${decl.key}" has default "${String(decl.default)}", which is not one of ${values.join(", ")}`;
}
const expected = decl.type === "number" ? "number" : decl.type === "boolean" ? "boolean" : "string";
return typeof decl.default === expected
? null
: `setting "${decl.key}": default must be a ${expected} (type ${decl.type}), got ${typeof decl.default}`;
}
// Every variable the installed plugins answer to — the set a stray is measured against.
export function settingsEnvNames(plugins: Plugin[]): Set<string> {
const names = new Set<string>();
for (const plugin of plugins) {
for (const decl of plugin.settings ?? []) names.add(envName(plugin.id, decl.key));
}
return names;
}
// A PLUGIN_SETTING_ variable no installed plugin declares — usually a typo in the one the operator
// meant to set, or a plugin they removed. Reported, never acted on (the orphan-database precedent).
export function strayNames(env: Env, declared: ReadonlySet<string>): string[] {
return Object.keys(env)
.filter((name) => name.startsWith(ENV_PREFIX) && !declared.has(name))
.sort();
}
export function resolveSettings(plugins: Plugin[], env: Env, options: ResolveOptions = {}): ResolveResult {
const catalog: PluginSettings[] = [];
const errors: string[] = [];
const values = new Map<string, SettingsValues>();
for (const plugin of plugins) {
const decls = plugin.settings ?? [];
const resolved: SettingsValues = {};
const summaries: SettingSummary[] = [];
for (const decl of decls) {
const name = envName(plugin.id, decl.key);
const raw = env[name] || undefined; // compose passes an unset variable through as ""
const fail = (message: string): void => void errors.push(`plugin "${plugin.id}": ${message}`);
let value: SettingValue | undefined;
let source: SettingSummary["source"] = "unset";
if (raw !== undefined) {
const coerced = coerce(decl, raw, name);
if (typeof coerced === "string") fail(coerced);
else {
value = coerced.value;
source = "env";
}
} else if (decl.default !== undefined) {
value = decl.default;
source = "default";
} else if (decl.required === true) {
fail(`setting "${decl.key}" must be set — ${name} (type ${decl.type}, no default)`);
}
const secretError = secretPolicyError(decl, raw, options.requireSecureSecrets === true, name);
if (secretError) fail(secretError);
resolved[decl.key] = value;
summaries.push(summarize(decl, name, source, value));
}
if (decls.length > 0) values.set(plugin.id, resolved);
catalog.push({ pluginId: plugin.id, settings: summaries });
}
return { catalog, errors, values };
}
// The host's own rule for a secret (readSecret), reaching plugins: enforced, neither unset nor the
// declared dev throwaway is accepted.
function secretPolicyError(decl: SettingDecl, raw: string | undefined, enforce: boolean, name: string): string | null {
if (!enforce || decl.secret !== true) return null;
if (raw === undefined) return `setting "${decl.key}" must be set when REQUIRE_SECURE_SECRETS=true — ${name}`;
if (decl.default !== undefined && raw === String(decl.default)) {
return `setting "${decl.key}" must not be its dev default when REQUIRE_SECURE_SECRETS=true — ${name}`;
}
return null;
}
function summarize(decl: SettingDecl, name: string, source: SettingSummary["source"], value: SettingValue | undefined): SettingSummary {
const showValue = decl.secret !== true && value !== undefined;
return {
...(decl.description !== undefined ? { description: decl.description } : {}),
envName: name,
key: decl.key,
required: decl.required === true,
secret: decl.secret === true,
source,
type: decl.type,
...(showValue ? { value: String(value) } : {}),
...(decl.values !== undefined ? { values: decl.values } : {}),
};
}
// A coerced value, or the boot error naming the variable and what it accepts.
function coerce(decl: SettingDecl, raw: string, name: string): { value: SettingValue } | string {
switch (decl.type) {
case "boolean":
if (raw === "true") return { value: true };
if (raw === "false") return { value: false };
return `${name} must be "true" or "false", got "${raw}"`;
case "enum":
return (decl.values ?? []).includes(raw)
? { value: raw }
: `${name} must be one of ${(decl.values ?? []).join(", ")}, got "${raw}"`;
case "number": {
const value = Number(raw);
return Number.isFinite(value) ? { value } : `${name} must be a number, got "${raw}"`;
}
case "url":
try {
new URL(raw);
} catch {
return `${name} is not a valid URL: ${raw}`;
}
return { value: raw };
case "string":
return { value: raw };
}
}
+21 -5
View File
@@ -14,6 +14,7 @@ import { createKratosAdmin } from "./auth/kratos-admin.ts";
import { createKratosPublic } from "./auth/kratos-public.ts"; import { createKratosPublic } from "./auth/kratos-public.ts";
import { createLogger, tracedFetch } from "./logger.ts"; import { createLogger, tracedFetch } from "./logger.ts";
import { loadMenuConfig } from "./ui/menu-config.ts"; import { loadMenuConfig } from "./ui/menu-config.ts";
import { resolveSettings, settingsEnvNames, strayNames } from "./plugin-host/settings.ts";
import { buildCredentials, storagePluginIds, type StorageCredentials } from "./plugin-host/storage.ts"; import { buildCredentials, storagePluginIds, type StorageCredentials } from "./plugin-host/storage.ts";
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
@@ -62,15 +63,29 @@ const storageCredentials = new Map<string, StorageCredentials>();
if (pluginDbUrl !== undefined) { if (pluginDbUrl !== undefined) {
for (const id of declaresStorage) storageCredentials.set(id, buildCredentials(pluginDbUrl, id, config.pluginDbSecret)); for (const id of declaresStorage) storageCredentials.set(id, buildCredentials(pluginDbUrl, id, config.pluginDbSecret));
} }
// onBoot is the only way credentials are handed over, so without one the database is provisioned // Operator-supplied plugin settings, resolved against the environment the manifests declared. A bad
// and unreachable. A warning, not a refusal — the plugin still works, it just cannot store anything. // or missing value is refused here rather than at that plugin's first use, hours later.
const unreachable = plugins.filter((plugin) => plugin.storage && !plugin.hooks?.onBoot).map((plugin) => plugin.id); const settings = resolveSettings(plugins, process.env, { requireSecureSecrets: config.requireSecureSecrets });
if (unreachable.length > 0) log.warn("plugins declare storage but have no onBoot to receive it", { plugins: unreachable.join(", ") }); if (settings.errors.length > 0) throw new Error(`Plugin settings:\n${settings.errors.map((e) => ` - ${e}`).join("\n")}`);
// A stray is usually a typo in the very variable the operator meant to set — naming it turns two
// unrelated-looking errors into one. Reported, never acted on.
const strays = strayNames(process.env, settingsEnvNames(plugins));
if (strays.length > 0) log.warn("settings variables no installed plugin declares", { variables: strays.join(", ") });
// onBoot is the only way storage credentials and settings are handed over, so without one they are
// resolved and undeliverable. A warning, not a refusal — the plugin still works, it just gets neither.
for (const [what, ids] of [
["settings", plugins.filter((plugin) => plugin.settings?.length && !plugin.hooks?.onBoot)],
["storage", plugins.filter((plugin) => plugin.storage && !plugin.hooks?.onBoot)],
] as const) {
if (ids.length > 0) log.warn(`plugins declare ${what} but have no onBoot to receive it`, { plugins: ids.map((plugin) => plugin.id).join(", ") });
}
// plugin onBoot — after discovery, before listen; a throw aborts boot. // plugin onBoot — after discovery, before listen; a throw aborts boot.
await runBootHooks(plugins, (plugin) => { await runBootHooks(plugins, (plugin) => {
const storage = storageCredentials.get(plugin.id); const storage = storageCredentials.get(plugin.id);
return storage ? { storage } : {}; const values = settings.values.get(plugin.id);
return { ...(values ? { settings: values } : {}), ...(storage ? { storage } : {}) };
}); });
const server = createApp({ const server = createApp({
@@ -91,6 +106,7 @@ const server = createApp({
menu, menu,
plugins, plugins,
secureCookies: config.secureCookies, secureCookies: config.secureCookies,
settingsCatalog: settings.catalog,
}).listen(config.port, () => { }).listen(config.port, () => {
log.info("listening", { apiVersion: HOST_API_VERSION, port: config.port, url: config.appUrl ?? `http://localhost:${config.port}` }); log.info("listening", { apiVersion: HOST_API_VERSION, port: config.port, url: config.appUrl ?? `http://localhost:${config.port}` });
}); });
+17
View File
@@ -37,6 +37,15 @@ const config = {
options: [{ value: "engineering", label: "Engineering" }, { value: "design", label: "Design" }, { value: "oncall", label: "On-call" }], options: [{ value: "engineering", label: "Engineering" }, { value: "design", label: "Design" }, { value: "oncall", label: "On-call" }],
}, },
{ type: "daterange", legend: "Joined", from: { name: "joined_from", value: "2026-01-01", label: "Joined from" }, to: { name: "joined_to", value: "2026-06-14", label: "Joined to" } }, { type: "daterange", legend: "Joined", from: { name: "joined_from", value: "2026-01-01", label: "Joined from" }, to: { name: "joined_to", value: "2026-06-14", label: "Joined to" } },
{
type: "multiselect",
name: "owner",
legend: "Owner",
note: "30 of 45",
value: ["ann"],
options: [{ value: "ann", label: "Ann Berg" }, { value: "bo", label: "Bo Falk" }],
},
{ type: "multiselect", name: "room", legend: "Room", options: [{ value: "lab", label: "Lab" }] },
], ],
], ],
pills: [{ label: "Team", value: "Engineering", remove: "?tag=oncall" }], pills: [{ label: "Team", value: "Engineering", remove: "?tag=oncall" }],
@@ -64,6 +73,14 @@ test("filter-bar renders a GET form with every control type, reflecting current
assert.match(html, /<input type="checkbox" name="tag" value="oncall" checked>On-call/); assert.match(html, /<input type="checkbox" name="tag" value="oncall" checked>On-call/);
assert.match(html, /<input type="checkbox" name="tag" value="design">Design/); assert.match(html, /<input type="checkbox" name="tag" value="design">Design/);
// multiselect — a disclosure: the trigger names the filter and counts what is chosen, the options
// are checkboxes in a fieldset the popover holds, so a long list costs one line of the bar.
assert.match(html, /<div class="menu"><button class="btn btn-menu" type="button" popovertarget="f-owner-menu" aria-label="Owner, 1 selected">Owner<span class="badge">1<\/span><\/button><div id="f-owner-menu" class="menu-pop left" popover>/);
assert.match(html, /popover><div class="menu-head">30 of 45<\/div><fieldset class="menu-field"><legend class="menu-head">Owner<\/legend><label class="menu-check"><input type="checkbox" name="owner" value="ann" checked>Ann Berg<\/label><label class="menu-check"><input type="checkbox" name="owner" value="bo">Bo Falk<\/label><\/fieldset>/);
// Nothing chosen — no badge and no count in the accessible name.
assert.match(html, /<button class="btn btn-menu" type="button" popovertarget="f-room-menu">Room<\/button>/);
// daterange — calendar icon + two date inputs with values. // daterange — calendar icon + two date inputs with values.
assert.match(html, /<div class="daterange"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"\s*\/?><\/svg>.*?<input type="date" id="f-joined_from" name="joined_from" value="2026-01-01">.*?<span class="to" aria-hidden="true">to<\/span>.*?<input type="date" id="f-joined_to" name="joined_to" value="2026-06-14">/); assert.match(html, /<div class="daterange"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"\s*\/?><\/svg>.*?<input type="date" id="f-joined_from" name="joined_from" value="2026-01-01">.*?<span class="to" aria-hidden="true">to<\/span>.*?<input type="date" id="f-joined_to" name="joined_to" value="2026-06-14">/);
+2 -2
View File
@@ -12,7 +12,7 @@ const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, "
test("menu renders trigger, positioning, the item matrix and check groups", async () => { test("menu renders trigger, positioning, the item matrix and check groups", async () => {
const html = flat(await render({ const html = flat(await render({
id: "cols-menu", id: "cols-menu",
trigger: { icon: "i-cols", text: "Columns", label: "Column settings" }, trigger: { count: 3, icon: "i-cols", text: "Columns", label: "Column settings" },
align: "left", up: true, width: 240, align: "left", up: true, width: 240,
items: [ items: [
{ head: "Actions" }, { head: "Actions" },
@@ -30,7 +30,7 @@ test("menu renders trigger, positioning, the item matrix and check groups", asyn
// Trigger: icon + text + aria-label, wired to the panel by id; popover carries align/up + width. // Trigger: icon + text + aria-label, wired to the panel by id; popover carries align/up + width.
// The panel is the trigger's next sibling inside the wrapper — the CSS open state reads that. // The panel is the trigger's next sibling inside the wrapper — the CSS open state reads that.
assert.match(html, /<div class="menu"><button class="btn" type="button" popovertarget="cols-menu" aria-label="Column settings"><svg class="ico ico-sm"><use href="#i-cols"\s*\/?><\/svg>Columns<\/button><div id="cols-menu" class="menu-pop left up" popover style="min-width:240px">/); assert.match(html, /<div class="menu"><button class="btn" type="button" popovertarget="cols-menu" aria-label="Column settings"><svg class="ico ico-sm"><use href="#i-cols"\s*\/?><\/svg>Columns<span class="badge">3<\/span><\/button><div id="cols-menu" class="menu-pop left up" popover style="min-width:240px">/);
// Item matrix: head, button-with-icon, link, separator, danger button. // Item matrix: head, button-with-icon, link, separator, danger button.
assert.match(html, /<div class="menu-head">Actions<\/div>/); assert.match(html, /<div class="menu-head">Actions<\/div>/);
+1 -1
View File
@@ -2,7 +2,6 @@
## Unfinnished work ## Unfinnished work
- [ ] Add a way to configure plugins directly when installing. **Decided: the manifest declares it, not an `.env`** — a declared schema is validatable at boot, so a missing or mistyped setting fails loud and named the way a stray `package.json` now does, and the picker/docs can be generated from the declaration. Open: where the operator *supplies* the values (env var per key, a `config/` file, or both), and whether a secret may be declared at all.
- [ ] 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.
@@ -36,6 +35,7 @@ Prioritized. Overall verdict: architecture is sound; these are refinements.
## Finnished work ## Finnished work
- [x] Configure a plugin at install time: the manifest declares `settings`, the operator sets one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, and the resolved values arrive on `onBoot` typed from the declaration. Read-only admin screen at `/admin/plugin-settings`.
- [x] Give a plugin persistent storage: `storage: true` provisions a Postgres database + login role named `plugin_<id>`, credentials arrive on `onBoot`, passwords are derived from `PLUGIN_DB_SECRET` rather than stored. - [x] Give a plugin persistent storage: `storage: true` provisions a Postgres database + login role named `plugin_<id>`, credentials arrive on `onBoot`, passwords are derived from `PLUGIN_DB_SECRET` rather than stored.
- [x] Refuse a stray `package.json`/`node_modules` in `config/` by name, as plugin folders already are. - [x] Refuse a stray `package.json`/`node_modules` in `config/` by name, as plugin folders already are.
- [x] Let Renovate reach the example plugins' manifests (`ignorePaths` overrides `config:recommended`). - [x] Let Renovate reach the example plugins' manifests (`ignorePaths` overrides `config:recommended`).
+1 -1
View File
@@ -14,7 +14,7 @@
<p>${t("dashboard.starter.intro")}</p> <p>${t("dashboard.starter.intro")}</p>
<p>${t("dashboard.starter.replace")}</p> <p>${t("dashboard.starter.replace")}</p>
<pre class="code-block"><code>export default definePlugin({ <pre class="code-block"><code>export default definePlugin({
apiVersion: "0.1.0", apiVersion: "0.3.0",
// view names plugins/&lt;id&gt;/views/&lt;view&gt;.ejs, rendered in this same shell // view names plugins/&lt;id&gt;/views/&lt;view&gt;.ejs, rendered in this same shell
dashboard: (ctx) =&gt; ({ view: "dashboard", data: { /* … */ } }), dashboard: (ctx) =&gt; ({ view: "dashboard", data: { /* … */ } }),
});</code></pre> });</code></pre>
+14 -1
View File
@@ -2,12 +2,15 @@
Filter bar: a real GET form so filtering is server-side and zero-JS. Config: Filter bar: a real GET form so filtering is server-side and zero-JS. Config:
rows: Control[][] rows of controls, laid out left→right rows: Control[][] rows of controls, laid out left→right
pills, clearHref, label, action, applyLabel pills, clearHref, label, action, applyLabel
Control.type ∈ search | segmented | select | chips | daterange | spacer. Control.type ∈ search | segmented | select | chips | multiselect | daterange | spacer.
search { name, placeholder?, value?, label? } search { name, placeholder?, value?, label? }
segmented { name, legend?, value?, options:{value,label,count?}[] } (radios) segmented { name, legend?, value?, options:{value,label,count?}[] } (radios)
select { name, label, value?, options:{value,label}[] } select { name, label, value?, options:{value,label}[] }
chips { name, legend?, value?:string[], options:{value,label}[] } (checkboxes) chips { name, legend?, value?:string[], options:{value,label}[] } (checkboxes)
multiselect { name, legend?, note?, value?:string[], options:{value,label}[] } (checkboxes in a popover)
daterange { legend?, from:{name,value?,label?}, to:{name,value?,label?} } daterange { legend?, from:{name,value?,label?}, to:{name,value?,label?} }
chips and multiselect are the same checkboxes on the same parameter: on the bar, or behind a
button once the list is too long to lay there.
The form is a GET, which replaces the whole query string — so the visitor's chosen language rides The form is a GET, which replaces the whole query string — so the visitor's chosen language rides
along as a hidden input, and every href here (pills, clear) is run through localeHref. along as a hidden input, and every href here (pills, clear) is run through localeHref.
%><% %><%
@@ -34,6 +37,16 @@
<span class="filter"><label class="sr-only" for="f-<%= c.name %>"><%= c.label %></label><span class="select"><select id="f-<%= c.name %>" name="<%= c.name %>"><% c.options.forEach((o) => { %><option value="<%= o.value %>"<% if (eq(c.value, o.value)) { %> selected<% } %>><%= o.label %></option><% }) %></select></span></span> <span class="filter"><label class="sr-only" for="f-<%= c.name %>"><%= c.label %></label><span class="select"><select id="f-<%= c.name %>" name="<%= c.name %>"><% c.options.forEach((o) => { %><option value="<%= o.value %>"<% if (eq(c.value, o.value)) { %> selected<% } %>><%= o.label %></option><% }) %></select></span></span>
<% } else if (c.type === "chips") { -%> <% } else if (c.type === "chips") { -%>
<fieldset class="filter-field"><legend class="sr-only"><%= c.legend || c.name %></legend><span class="filter-legend" aria-hidden="true"><%= c.legend || c.name %></span><div class="chips"><% (c.options).forEach((o) => { const on = (c.value || []).map(String).includes(String(o.value)); %><label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="<%= c.name %>" value="<%= o.value %>"<% if (on) { %> checked<% } %>><%= o.label %></label><% }) %></div></fieldset> <fieldset class="filter-field"><legend class="sr-only"><%= c.legend || c.name %></legend><span class="filter-legend" aria-hidden="true"><%= c.legend || c.name %></span><div class="chips"><% (c.options).forEach((o) => { const on = (c.value || []).map(String).includes(String(o.value)); %><label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="<%= c.name %>" value="<%= o.value %>"<% if (on) { %> checked<% } %>><%= o.label %></label><% }) %></div></fieldset>
<% } else if (c.type === "multiselect") { const legend = c.legend || c.name; const on = (c.value || []).map(String); -%>
<%- include("menu", {
id: "f-" + c.name + "-menu",
align: "left", kebab: false, up: false, width: null, // explicit: EJS would otherwise inherit the page's own
trigger: { class: "btn btn-menu", count: on.length || null, label: on.length ? t("filter.selected", { count: on.length, label: legend }) : null, text: legend },
items: [
...(c.note ? [{ head: c.note }] : []),
{ group: { legend, name: c.name, options: c.options.map((o) => ({ checked: on.includes(String(o.value)), label: o.label, value: o.value })) } },
],
}) %>
<% } else if (c.type === "daterange") { -%> <% } else if (c.type === "daterange") { -%>
<fieldset class="filter-field"><legend class="sr-only"><%= c.legend || t("filter.dateRange") %></legend><span class="filter-legend" aria-hidden="true"><%= c.legend || t("filter.dateRange") %></span><div class="daterange"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"/></svg><label class="sr-only" for="f-<%= c.from.name %>"><%= c.from.label || t("filter.from") %></label><input type="date" id="f-<%= c.from.name %>" name="<%= c.from.name %>"<% if (c.from.value) { %> value="<%= c.from.value %>"<% } %>><span class="to" aria-hidden="true"><%= t("filter.toSeparator") %></span><label class="sr-only" for="f-<%= c.to.name %>"><%= c.to.label || t("filter.to") %></label><input type="date" id="f-<%= c.to.name %>" name="<%= c.to.name %>"<% if (c.to.value) { %> value="<%= c.to.value %>"<% } %>></div></fieldset> <fieldset class="filter-field"><legend class="sr-only"><%= c.legend || t("filter.dateRange") %></legend><span class="filter-legend" aria-hidden="true"><%= c.legend || t("filter.dateRange") %></span><div class="daterange"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"/></svg><label class="sr-only" for="f-<%= c.from.name %>"><%= c.from.label || t("filter.from") %></label><input type="date" id="f-<%= c.from.name %>" name="<%= c.from.name %>"<% if (c.from.value) { %> value="<%= c.from.value %>"<% } %>><span class="to" aria-hidden="true"><%= t("filter.toSeparator") %></span><label class="sr-only" for="f-<%= c.to.name %>"><%= c.to.label || t("filter.to") %></label><input type="date" id="f-<%= c.to.name %>" name="<%= c.to.name %>"<% if (c.to.value) { %> value="<%= c.to.value %>"<% } %>></div></fieldset>
<% } else if (c.type === "spacer") { -%> <% } else if (c.type === "spacer") { -%>
+2 -2
View File
@@ -7,7 +7,7 @@
Config: Config:
id string REQUIRED — the panel's id and the trigger's popovertarget. Name it for what id string REQUIRED — the panel's id and the trigger's popovertarget. Name it for what
the menu is (`locale-menu`); it must be unique on the page. the menu is (`locale-menu`); it must be unique on the page.
trigger { class?(="btn", "" ⇒ none) · label?(aria-label) · icon? · text? · html?(raw inner, wins) } trigger { class?(="btn", "" ⇒ none) · label?(aria-label) · icon? · text? · count?(badge) · html?(raw inner, wins) }
align? "left" left-align the popover (default right) align? "left" left-align the popover (default right)
up? boolean open upward (footer menus) up? boolean open upward (footer menus)
kebab? boolean bare kebab trigger (adds .kebab) kebab? boolean bare kebab trigger (adds .kebab)
@@ -26,7 +26,7 @@
const popCls = "menu-pop" + (locals.align === "left" ? " left" : "") + (locals.up ? " up" : ""); const popCls = "menu-pop" + (locals.align === "left" ? " left" : "") + (locals.up ? " up" : "");
const width = locals.width; const width = locals.width;
-%> -%>
<div class="menu"><button<% if (btnCls) { %> class="<%= btnCls %>"<% } %> type="button" popovertarget="<%= locals.id %>"<% if (trigger.label) { %> aria-label="<%= trigger.label %>"<% } %>><% if (trigger.html != null) { %><%- trigger.html %><% } else { if (trigger.icon) { %><svg class="ico ico-sm"><use href="#<%= trigger.icon %>"/></svg><% } if (trigger.text) { %><%= trigger.text %><% } } %></button><div id="<%= locals.id %>" class="<%= popCls %>" popover<% if (width != null) { %> style="min-width:<%= typeof width === "number" ? width + "px" : width %>"<% } %>> <div class="menu"><button<% if (btnCls) { %> class="<%= btnCls %>"<% } %> type="button" popovertarget="<%= locals.id %>"<% if (trigger.label) { %> aria-label="<%= trigger.label %>"<% } %>><% if (trigger.html != null) { %><%- trigger.html %><% } else { if (trigger.icon) { %><svg class="ico ico-sm"><use href="#<%= trigger.icon %>"/></svg><% } if (trigger.text) { %><%= trigger.text %><% } if (trigger.count != null) { %><span class="badge"><%= trigger.count %></span><% } } %></button><div id="<%= locals.id %>" class="<%= popCls %>" popover<% if (width != null) { %> style="min-width:<%= typeof width === "number" ? width + "px" : width %>"<% } %>>
<% items.forEach((it) => { -%> <% items.forEach((it) => { -%>
<% if (it.head != null) { -%> <% if (it.head != null) { -%>
<div class="menu-head"><%= it.head %></div> <div class="menu-head"><%= it.head %></div>