§7 review checkpoint (todo §7); ran the architecture + product reviewers on the whole project and addressed findings, no Critical from either. Made permissions honest + decoupled the host from the plugin: new pure seedRoles + bootstrap discoverPlugins() seeds the demo admin admin(/ADMIN_ROLES) ∪ every discovered plugin's declared tokens, dropped the hardcoded scheduling:* from compose ADMIN_ROLES (clean-clone unchanged); docs now state a route/nav permission is a coarse role granted as Keto Role:<token>#members. Added src/plugin-api.ts — the stable author barrel the reference plugin now imports from instead of deep src/* (the contract boundary in code). Made per-plugin CSS usable: shell styles slot + plugins/scheduling/public/scheduling.css linked from the views. Reference now demonstrates hooks.onBoot validating SCHEDULING_UPSTREAM fail-loud (assertHttpUrl). Build ctx.chrome at most once per request (memoized). Doc honesty: fixed the false visual.spec coverage comment, softened the "every plugin ships a Playwright test" claim (authed flow = §8), added an Upstream contract block to the plugin README. Added LICENSE (MIT). Stability-reviewer APPROVE, no Critical/High; addressed both Low nits. typecheck + 301 units green. Deferred: internal route-table (M1)→§9, safeUrl()→§9, data-table empty-state + success-flash→§8/polish, apiVersion-literal enforcement (prose), permission→requireRole rename (future minor).
This commit is contained in:
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Larv IT AB
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
17
README.md
17
README.md
@@ -117,8 +117,9 @@ docker compose up # http://localhost:3000, live reload via `node --wa
|
|||||||
merging `compose.override.yml`, which mounts the source and restarts the server on
|
merging `compose.override.yml`, which mounts the source and restarts the server on
|
||||||
change. A one-shot `bootstrap` service then seeds first-boot state with **zero manual
|
change. A one-shot `bootstrap` service then seeds first-boot state with **zero manual
|
||||||
prep** — it generates the JWT signing key if absent, creates a demo admin
|
prep** — it generates the JWT signing key if absent, creates a demo admin
|
||||||
(`admin@plainpages.local` / `admin`) in Kratos, and grants it the `admin` role in Keto
|
(`admin@plainpages.local` / `admin`) in Kratos, and grants it the `admin` role plus every
|
||||||
so permission checks resolve out of the box; it is idempotent, so every `up` re-runs it
|
discovered plugin's declared permission tokens in Keto, so permission checks (and any dropped-in
|
||||||
|
plugin) resolve out of the box; it is idempotent, so every `up` re-runs it
|
||||||
safely. It finishes by printing a banner with the login URL and seeded credentials.
|
safely. It finishes by printing a banner with the login URL and seeded credentials.
|
||||||
**Change the demo admin before production.** The web app waits for Kratos + Keto
|
**Change the demo admin before production.** The web app waits for Kratos + Keto
|
||||||
to be healthy *and* the bootstrap to finish before starting (each Ory service has a
|
to be healthy *and* the bootstrap to finish before starting (each Ory service has a
|
||||||
@@ -282,15 +283,14 @@ The manifest is **TypeScript** — typed, commented, no separate schema to keep
|
|||||||
sync. The `id` and mount path are **derived from the folder name**, not declared:
|
sync. The `id` and mount path are **derived from the folder name**, not declared:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { definePlugin } from "../../src/plugin.ts";
|
import { definePlugin } from "../../src/plugin-api.ts"; // the stable author barrel (see docs)
|
||||||
import { listShifts } from "./shifts.ts";
|
import { listShifts } from "./shifts.ts";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "1.0.0", // semver of the host contract this was built against (a literal — see docs)
|
apiVersion: "1.0.0", // semver of the host contract this was built against (a literal — see docs)
|
||||||
|
|
||||||
// Nav fragment, composed into the global menu. Permission-gated via Keto:
|
// Nav fragment, composed into the global menu. Permission-gated: items the current user can't
|
||||||
// items the current user can't access are hidden. Arbitrary depth.
|
// access are hidden. Arbitrary depth. `icon` is a Lucide icon by its sprite id (src/icons.ts).
|
||||||
// `icon` is a Lucide icon by its sprite id (src/icons.ts).
|
|
||||||
nav: [
|
nav: [
|
||||||
{
|
{
|
||||||
label: "Scheduling", icon: "i-cal",
|
label: "Scheduling", icon: "i-cal",
|
||||||
@@ -300,8 +300,8 @@ export default definePlugin({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
// Route handlers, mounted under the plugin's path (/scheduling). `permission`
|
// Route handlers, mounted under the plugin's path (/scheduling). `permission` is a coarse role
|
||||||
// (a Keto check) is enforced before the handler runs.
|
// (a JWT-claim check) enforced before the handler runs.
|
||||||
routes: [
|
routes: [
|
||||||
{ method: "GET", path: "/shifts", permission: "scheduling:read", handler: listShifts },
|
{ method: "GET", path: "/shifts", permission: "scheduling:read", handler: listShifts },
|
||||||
],
|
],
|
||||||
@@ -585,6 +585,7 @@ src/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, p
|
|||||||
src/nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model
|
src/nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model
|
||||||
src/paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
|
src/paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
|
||||||
src/plugin.ts Plugin contract: manifest types, definePlugin(), version + conflict rules + fullPath()
|
src/plugin.ts Plugin contract: manifest types, definePlugin(), version + conflict rules + fullPath()
|
||||||
|
src/plugin-api.ts Stable plugin author barrel — the one module a plugin imports (definePlugin, ctx/result types, guards, body/CSRF/list-query helpers)
|
||||||
src/discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot (§2)
|
src/discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot (§2)
|
||||||
src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2)
|
src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2)
|
||||||
src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials (§2)
|
src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials (§2)
|
||||||
|
|||||||
@@ -122,8 +122,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
||||||
# Roles granted to the demo admin: `admin` + the reference plugin's tokens (so it works out of the box).
|
# Base roles for the demo admin; bootstrap also grants every discovered plugin's declared
|
||||||
ADMIN_ROLES: ${ADMIN_ROLES:-admin,scheduling:read,scheduling:write}
|
# permission tokens (so the reference plugin — and any drop-in — works out of the box).
|
||||||
|
ADMIN_ROLES: ${ADMIN_ROLES:-admin}
|
||||||
APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner
|
APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner
|
||||||
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
|
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
|
||||||
KETO_WRITE_URL: http://keto:4467
|
KETO_WRITE_URL: http://keto:4467
|
||||||
|
|||||||
@@ -55,8 +55,13 @@ Nothing else references it; the operator stays in control through the central me
|
|||||||
|
|
||||||
## The manifest
|
## The manifest
|
||||||
|
|
||||||
|
A plugin imports its host surface from one module — `src/plugin-api.ts`, the **stable author
|
||||||
|
barrel** (`definePlugin`, the manifest/handler types, `RequestContext`, the guards, and the
|
||||||
|
body/CSRF/list-query helpers). That barrel *is* the contract boundary; don't reach into deeper
|
||||||
|
`src/*` modules — the host may refactor those freely as long as the barrel holds.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { definePlugin } from "../../src/plugin.ts";
|
import { definePlugin } from "../../src/plugin-api.ts";
|
||||||
import { listShifts, createShift } from "./shifts.ts";
|
import { listShifts, createShift } from "./shifts.ts";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
@@ -69,7 +74,8 @@ export default definePlugin({
|
|||||||
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
|
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
|
||||||
}],
|
}],
|
||||||
|
|
||||||
// Permission tokens this plugin introduces (for docs + Keto seeding). Optional.
|
// Permission tokens this plugin introduces. Declared for documentation, conflict detection, and
|
||||||
|
// bootstrap seeding (the demo admin is granted every discovered plugin's tokens). Optional.
|
||||||
permissions: [
|
permissions: [
|
||||||
{ token: "scheduling:read", description: "View shifts" },
|
{ token: "scheduling:read", description: "View shifts" },
|
||||||
{ token: "scheduling:write", description: "Create and edit shifts" },
|
{ token: "scheduling:write", description: "Create and edit shifts" },
|
||||||
@@ -93,7 +99,7 @@ there is **no `id` or `basePath`** in the manifest — both come from the folder
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `apiVersion` | yes | Semver the plugin was built against — a **literal**, not `HOST_API_VERSION`. See [Versioning](#contract-versioning). |
|
| `apiVersion` | yes | Semver the plugin was built against — a **literal**, not `HOST_API_VERSION`. See [Versioning](#contract-versioning). |
|
||||||
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/icons.ts`); node `id`s must be globally unique. |
|
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/icons.ts`); node `id`s must be globally unique. |
|
||||||
| `permissions` | no | Tokens this plugin introduces; declared for documentation and seeding. |
|
| `permissions` | no | Tokens this plugin introduces; declared for docs, conflict detection, and bootstrap seeding (see [Nav & permissions](#nav--permissions)). |
|
||||||
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
||||||
| `hooks` | no | See [Hooks](#hooks). |
|
| `hooks` | no | See [Hooks](#hooks). |
|
||||||
|
|
||||||
@@ -122,8 +128,7 @@ type RouteResult =
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
// shifts.ts
|
// shifts.ts
|
||||||
import type { RequestContext } from "../../src/context.ts";
|
import { parseListQuery, type RequestContext } from "../../src/plugin-api.ts";
|
||||||
import { parseListQuery } from "../../src/list-query.ts";
|
|
||||||
|
|
||||||
export async function listShifts(ctx: RequestContext) {
|
export async function listShifts(ctx: RequestContext) {
|
||||||
const q = parseListQuery(ctx.url);
|
const q = parseListQuery(ctx.url);
|
||||||
@@ -135,8 +140,10 @@ export async function listShifts(ctx: RequestContext) {
|
|||||||
- **`view`** resolves against the plugin's own `views/` (`src/view-resolver.ts`) — nested names
|
- **`view`** resolves against the plugin's own `views/` (`src/view-resolver.ts`) — nested names
|
||||||
like `"shifts/edit"` work, and an out-of-bounds name is refused. The template may `include()`
|
like `"shifts/edit"` work, and an out-of-bounds name is refused. The template may `include()`
|
||||||
the core building-block partials (app shell, nav tree, data table, …) and its own
|
the core building-block partials (app shell, nav tree, data table, …) and its own
|
||||||
partials/subfolders to render a full page — exactly as the built-in screens do.
|
partials/subfolders to render a full page — exactly as the built-in screens do. To load the
|
||||||
- **Finer authorization than the route `permission`** uses the guards in `src/guards.ts`:
|
plugin's own CSS, pass its `/public/<id>/x.css` href in the shell's `styles` slot (an array of
|
||||||
|
extra stylesheet hrefs) — see the reference's `views/shifts.ejs`.
|
||||||
|
- **Finer authorization than the route `permission`** uses the guards from `src/plugin-api.ts`:
|
||||||
`requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect
|
`requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect
|
||||||
to sign in), `can(ctx, role)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx,
|
to sign in), `can(ctx, role)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx,
|
||||||
{namespace, object, relation})` (a live Keto check for relationship rules — the subject is the
|
{namespace, object, relation})` (a live Keto check for relationship rules — the subject is the
|
||||||
@@ -205,10 +212,18 @@ depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` i
|
|||||||
icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are
|
icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are
|
||||||
`ICON_NAMES` in `src/icons.ts`, and adding one means registering its lucide name there.
|
`ICON_NAMES` in `src/icons.ts`, and adding one means registering its lucide name there.
|
||||||
|
|
||||||
|
**A `permission` token is a coarse role.** The route/nav gate passes iff the user's JWT `roles`
|
||||||
|
include the token; those roles come from Keto at login, so an operator grants a token by writing the
|
||||||
|
Keto tuple `Role:<token>#members@user:<id>` (or to a group) — the admin **Roles** screen does this.
|
||||||
|
(The fine-grained, per-row tier is the separate Keto `Resource` namespace — see the README's *Three
|
||||||
|
tiers of "may I?"*; it is not what a route `permission` checks.)
|
||||||
|
|
||||||
Permission tokens are a **shared global namespace** — that's deliberate, so an operator grants
|
Permission tokens are a **shared global namespace** — that's deliberate, so an operator grants
|
||||||
`scheduling:read` once in Keto and every plugin referencing it is gated consistently. Namespace
|
`scheduling:read` once in Keto and every plugin referencing it is gated consistently. Namespace
|
||||||
your tokens as `<id>:<action>` to avoid accidental clashes. Declaring them in `permissions` is
|
your tokens as `<id>:<action>` to avoid accidental clashes. Declaring them in `permissions` is
|
||||||
optional but recommended (it documents them and lets the bootstrap seed Keto, §3).
|
optional but recommended: it documents them, feeds conflict detection, and lets the one-command
|
||||||
|
bootstrap seed them — the demo admin is granted every discovered plugin's declared tokens (§3), so
|
||||||
|
a dropped-in plugin works out of the box without editing host config.
|
||||||
|
|
||||||
## Contract versioning
|
## Contract versioning
|
||||||
|
|
||||||
@@ -293,9 +308,10 @@ worked example: thin handlers bound to an injectable upstream client, unit-teste
|
|||||||
so a test can mount a single manifest and assert its routes, nav, and gating without the rest
|
so a test can mount a single manifest and assert its routes, nav, and gating without the rest
|
||||||
of the stack.
|
of the stack.
|
||||||
|
|
||||||
3. **E2E the user-facing flow.** Per AGENTS.md §6, every plugin page/form ships *with* a
|
3. **E2E the user-facing flow.** Per AGENTS.md §6, ship a side-effect-free Playwright test in
|
||||||
Playwright test in `e2e/`, side-effect-free so the suite stays `fullyParallel`. The test runs
|
`e2e/` for each plugin page/form so the suite stays `fullyParallel`, run against the live `web`
|
||||||
against the live `web` service with the plugin mounted.
|
service with the plugin mounted. The reference's permission-gating is covered in `visual.spec.ts`;
|
||||||
|
its authenticated list/form happy-path is the §8 full-E2E item (needs cross-host login infra).
|
||||||
|
|
||||||
The validation an author hits is the same the host runs: bad `apiVersion` or a conflict
|
The validation an author hits is the same the host runs: bad `apiVersion` or a conflict
|
||||||
([above](#conflict-rules)) stops boot with a precise message naming the plugin(s) involved.
|
([above](#conflict-rules)) stops boot with a precise message naming the plugin(s) involved.
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ test("unknown routes serve the 404 page (a real user-facing flow, covered end-to
|
|||||||
|
|
||||||
// The reference plugin (plugins/scheduling) ships discovered in the image. Its nav + routes are
|
// The reference plugin (plugins/scheduling) ships discovered in the image. Its nav + routes are
|
||||||
// permission-gated, so an anonymous visitor never sees or reaches them (the authenticated list/form
|
// permission-gated, so an anonymous visitor never sees or reaches them (the authenticated list/form
|
||||||
// flow is covered by the full-stack suites). Side-effect-free.
|
// flow needs cross-host login infra — deferred to the §8 full E2E, todo line 121). Side-effect-free.
|
||||||
test("the reference plugin is permission-gated: anonymous → 403, hidden from the dashboard nav", async ({ page }) => {
|
test("the reference plugin is permission-gated: anonymous → 403, hidden from the dashboard nav", async ({ page }) => {
|
||||||
const res = await page.goto("/scheduling/shifts");
|
const res = await page.goto("/scheduling/shifts");
|
||||||
expect(res?.status()).toBe(403);
|
expect(res?.status()).toBe(403);
|
||||||
|
|||||||
@@ -20,9 +20,22 @@ The plugin holds **no state** — data lives upstream (README → *Stateless*).
|
|||||||
|
|
||||||
## Upstream
|
## Upstream
|
||||||
|
|
||||||
Set `SCHEDULING_UPSTREAM` to your backend's base URL (it must expose `GET /shifts` and
|
Set `SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory
|
||||||
`POST /shifts`). The dev compose points it at a tiny in-memory mock (`examples/shifts-upstream/`)
|
mock (`examples/shifts-upstream/`) so `docker compose up` shows the plugin working out of the box.
|
||||||
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).
|
||||||
|
|
||||||
|
### Upstream contract
|
||||||
|
|
||||||
|
Your backend must expose two routes; the plugin treats any non-2xx as a recoverable failure
|
||||||
|
(the list degrades to a "try again" alert, the create re-renders the form keeping the input).
|
||||||
|
|
||||||
|
| Route | Request | Success | Response body |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `GET /shifts` | `Accept: application/json` | `200` | JSON array of `{ id, title, assignee, start, end }` (all strings; missing fields coerce to `""`) |
|
||||||
|
| `POST /shifts` | JSON body `{ title, assignee, start, end }` | `2xx` | ignored (the plugin POST-redirect-GETs back to the list) |
|
||||||
|
|
||||||
|
Domain rules (overlap, capacity, time ordering) live in your backend — reject with a 4xx and the
|
||||||
|
form re-renders. The plugin only validates that `title` and `assignee` are non-empty.
|
||||||
|
|
||||||
## Granting access
|
## Granting access
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,21 @@
|
|||||||
// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this
|
// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this
|
||||||
// folder, rename it, point it at your own backend. Full contract: docs/plugin-contract.md.
|
// folder, rename it, point it at your own backend. Full contract: docs/plugin-contract.md.
|
||||||
|
|
||||||
import { definePlugin } from "../../src/plugin.ts";
|
import { definePlugin } from "../../src/plugin-api.ts";
|
||||||
import { createShift, createUpstream, listShifts, newShiftForm, READ, SHIFTS_PATH, WRITE } from "./shifts.ts";
|
import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, READ, 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). Configure via env; the dev compose points it at a tiny mock (examples/shifts-upstream).
|
||||||
const upstream = createUpstream(process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstream:4000");
|
const upstreamUrl = process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstream:4000";
|
||||||
|
const upstream = createUpstream(upstreamUrl);
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
apiVersion: "1.0.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
|
||||||
|
// typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later.
|
||||||
|
hooks: { onBoot: () => assertHttpUrl(upstreamUrl, "SCHEDULING_UPSTREAM") },
|
||||||
|
|
||||||
// Merged into the global menu + filtered per user: the "Shifts" leaf shows only for a user holding
|
// Merged into the global menu + filtered per user: the "Shifts" leaf shows only for a user holding
|
||||||
// `scheduling:read`, so the whole "Scheduling" header disappears for everyone else.
|
// `scheduling:read`, so the whole "Scheduling" header disappears for everyone else.
|
||||||
nav: [{
|
nav: [{
|
||||||
|
|||||||
6
plugins/scheduling/public/scheduling.css
Normal file
6
plugins/scheduling/public/scheduling.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/* Scheduling plugin styles — served at /public/scheduling/scheduling.css (per-plugin static),
|
||||||
|
linked via the shell `styles` slot. The core design system already styles every building block,
|
||||||
|
so a plugin only adds what's domain-specific, scoped under its own page class (.scheduling-page). */
|
||||||
|
.scheduling-page .table-wrap {
|
||||||
|
margin-top: var(--space-3, 0.75rem);
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import type { RequestContext } from "../../src/context.ts";
|
|||||||
import { GuardError } from "../../src/guards.ts";
|
import { GuardError } from "../../src/guards.ts";
|
||||||
import type { RouteResult } from "../../src/plugin.ts";
|
import type { RouteResult } from "../../src/plugin.ts";
|
||||||
import {
|
import {
|
||||||
buildFormModel, createShift, createUpstream, listShifts, newShiftForm, readInput,
|
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, 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";
|
||||||
|
|
||||||
@@ -33,6 +33,29 @@ 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) ----
|
||||||
|
|
||||||
|
test("assertHttpUrl accepts http(s) and fails loud on a malformed or non-http upstream URL", () => {
|
||||||
|
assert.doesNotThrow(() => assertHttpUrl("http://shifts-upstream:4000", "SCHEDULING_UPSTREAM"));
|
||||||
|
assert.doesNotThrow(() => assertHttpUrl("https://api.example.com/v1", "SCHEDULING_UPSTREAM"));
|
||||||
|
assert.throws(() => assertHttpUrl("not a url", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*valid URL/); // unparseable
|
||||||
|
assert.throws(() => assertHttpUrl("shifts-upstream:4000", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // missing // → parsed as a bogus scheme
|
||||||
|
assert.throws(() => assertHttpUrl("ftp://host/x", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // wrong scheme
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the manifest's onBoot hook validates SCHEDULING_UPSTREAM (the binding, not just the helper)", async () => {
|
||||||
|
const prev = process.env["SCHEDULING_UPSTREAM"];
|
||||||
|
process.env["SCHEDULING_UPSTREAM"] = "nope://bad"; // read at import time below
|
||||||
|
try {
|
||||||
|
const manifest = (await import("./plugin.ts")).default;
|
||||||
|
assert.equal(typeof manifest.hooks?.onBoot, "function");
|
||||||
|
assert.throws(() => manifest.hooks!.onBoot!(), /SCHEDULING_UPSTREAM/); // bad upstream → boot fails loud
|
||||||
|
} finally {
|
||||||
|
if (prev === undefined) delete process.env["SCHEDULING_UPSTREAM"];
|
||||||
|
else process.env["SCHEDULING_UPSTREAM"] = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ---- upstream client (fetch injected) ----
|
// ---- upstream client (fetch injected) ----
|
||||||
|
|
||||||
test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", async () => {
|
test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", async () => {
|
||||||
|
|||||||
@@ -5,12 +5,8 @@
|
|||||||
// Handlers are factories bound to a ShiftsUpstream, and `fetch` is injectable, so they unit-test as
|
// Handlers are factories bound to a ShiftsUpstream, and `fetch` is injectable, so they unit-test as
|
||||||
// pure functions against a mock upstream with no network (docs/plugin-contract.md → dev/test story).
|
// pure functions against a mock upstream with no network (docs/plugin-contract.md → dev/test story).
|
||||||
|
|
||||||
import { readFormBody } from "../../src/body.ts";
|
// One import from the host's plugin-api barrel — the stable author surface (see docs/plugin-contract.md).
|
||||||
import type { PageChrome } from "../../src/chrome.ts";
|
import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler } from "../../src/plugin-api.ts";
|
||||||
import { CSRF_FIELD } from "../../src/csrf.ts";
|
|
||||||
import { can, GuardError } from "../../src/guards.ts";
|
|
||||||
import { parseListQuery } from "../../src/list-query.ts";
|
|
||||||
import type { RouteHandler } from "../../src/plugin.ts";
|
|
||||||
|
|
||||||
export const SHIFTS_PATH = "/scheduling/shifts";
|
export const SHIFTS_PATH = "/scheduling/shifts";
|
||||||
export const READ = "scheduling:read"; // permission token gating the list + nav
|
export const READ = "scheduling:read"; // permission token gating the list + nav
|
||||||
@@ -46,6 +42,18 @@ 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` is
|
// REST client over the upstream service (a stand-in for the customer's real backend). `fetch` is
|
||||||
// injectable so handlers test without a network; the base URL comes from the plugin's own env.
|
// injectable so handlers test without a network; the base URL comes from the plugin's own env.
|
||||||
export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = fetch): ShiftsUpstream {
|
export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = fetch): ShiftsUpstream {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
Data: chrome, title, breadcrumbs, form, formError?
|
Data: chrome, title, breadcrumbs, form, formError?
|
||||||
%><%
|
%><%
|
||||||
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
const body = include("partials/shift-form", { form, formError: locals.formError });
|
const body = '<div class="scheduling-page">' + include("partials/shift-form", { form, formError: locals.formError }) + '</div>';
|
||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
body,
|
body,
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
csrfToken: chrome.csrfToken,
|
csrfToken: chrome.csrfToken,
|
||||||
nav: navHtml,
|
nav: navHtml,
|
||||||
|
styles: ["/public/scheduling/scheduling.css"],
|
||||||
theme: chrome.theme,
|
theme: chrome.theme,
|
||||||
title,
|
title,
|
||||||
user: chrome.user,
|
user: chrome.user,
|
||||||
|
|||||||
@@ -15,11 +15,12 @@
|
|||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
actions,
|
actions,
|
||||||
body: alertHtml + filtersHtml + tableHtml,
|
body: '<div class="scheduling-page">' + alertHtml + filtersHtml + tableHtml + '</div>',
|
||||||
brand: chrome.brand,
|
brand: chrome.brand,
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
csrfToken: chrome.csrfToken,
|
csrfToken: chrome.csrfToken,
|
||||||
nav: navHtml,
|
nav: navHtml,
|
||||||
|
styles: ["/public/scheduling/scheduling.css"],
|
||||||
theme: chrome.theme,
|
theme: chrome.theme,
|
||||||
title,
|
title,
|
||||||
user: chrome.user,
|
user: chrome.user,
|
||||||
|
|||||||
16
src/app.ts
16
src/app.ts
@@ -9,7 +9,7 @@ import { type AdminGroupsDeps, handleAdminGroups } from "./admin-groups.ts";
|
|||||||
import { type AdminRolesDeps, handleAdminRoles } from "./admin-roles.ts";
|
import { type AdminRolesDeps, handleAdminRoles } from "./admin-roles.ts";
|
||||||
import { type AdminUsersDeps, handleAdminUsers } from "./admin-users.ts";
|
import { type AdminUsersDeps, handleAdminUsers } from "./admin-users.ts";
|
||||||
import { readFormBody } from "./body.ts";
|
import { readFormBody } from "./body.ts";
|
||||||
import { buildPluginChrome } from "./chrome.ts";
|
import { buildPluginChrome, type PageChrome } from "./chrome.ts";
|
||||||
import { buildContext, type User } from "./context.ts";
|
import { buildContext, type User } from "./context.ts";
|
||||||
import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "./csrf.ts";
|
import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "./csrf.ts";
|
||||||
import { buildDashboardModel } from "./dashboard.ts";
|
import { buildDashboardModel } from "./dashboard.ts";
|
||||||
@@ -136,11 +136,16 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
// Bound CSRF verifier handed to plugins via ctx.verifyCsrf (the host owns the secret).
|
// Bound CSRF verifier handed to plugins via ctx.verifyCsrf (the host owns the secret).
|
||||||
const verifyCsrf = (submitted: string | null | undefined): boolean =>
|
const verifyCsrf = (submitted: string | null | undefined): boolean =>
|
||||||
verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted });
|
verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted });
|
||||||
// base context (no route params yet); reused for onRequest. Chrome is built lazily — only
|
// Chrome (brand/global-nav/user/theme/csrf) is built lazily and at most once per request —
|
||||||
// plugin routes (and an onRequest short-circuit) read ctx.chrome, so the hot path stays free.
|
// only plugin routes (and an onRequest short-circuit) read it, so the hot path stays free and
|
||||||
|
// a matched plugin request doesn't re-compose the whole menu for the onRequest + route ctx.
|
||||||
|
let chromeMemo: PageChrome | undefined;
|
||||||
|
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }));
|
||||||
|
|
||||||
|
// base context (no route params yet); reused for onRequest.
|
||||||
const ctx = buildContext(req, res, {
|
const ctx = buildContext(req, res, {
|
||||||
user, verifyCsrf,
|
user, verifyCsrf,
|
||||||
...(anyRequestHooks ? { chrome: buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }) } : {}),
|
...(anyRequestHooks ? { chrome: chrome() } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
||||||
@@ -157,8 +162,7 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
// CSRF cookie is set so those forms have a valid double-submit token.
|
// CSRF cookie is set so those forms have a valid double-submit token.
|
||||||
const match = matchRoute(plugins, method, pathname);
|
const match = matchRoute(plugins, method, pathname);
|
||||||
if (match) {
|
if (match) {
|
||||||
const chrome = buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user });
|
const routeCtx = buildContext(req, res, { chrome: chrome(), params: match.params, user, verifyCsrf });
|
||||||
const routeCtx = buildContext(req, res, { chrome, params: match.params, user, verifyCsrf });
|
|
||||||
if (!isAuthorized(match.route, routeCtx.roles)) {
|
if (!isAuthorized(match.route, routeCtx.roles)) {
|
||||||
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
|
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { ensureJwks, firstRunBanner, identityPayload, roleTuple, seedAdmin } from "./bootstrap.ts";
|
import { ensureJwks, firstRunBanner, identityPayload, roleTuple, seedAdmin, seedRoles } from "./bootstrap.ts";
|
||||||
|
|
||||||
const json = (status: number, body?: unknown) =>
|
const json = (status: number, body?: unknown) =>
|
||||||
new Response(body === undefined ? null : JSON.stringify(body), {
|
new Response(body === undefined ? null : JSON.stringify(body), {
|
||||||
@@ -30,6 +30,16 @@ test("roleTuple grants a role to user:<id> in the Role namespace", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("seedRoles unions ADMIN_ROLES (default 'admin') with the discovered plugins' declared tokens", () => {
|
||||||
|
// Clean clone: no ADMIN_ROLES, the scheduling plugin declares its two tokens → the demo admin
|
||||||
|
// gets exactly today's behaviour, but derived from discovery, not hardcoded in the host.
|
||||||
|
assert.deepEqual(seedRoles(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]);
|
||||||
|
assert.deepEqual(seedRoles(undefined, []), ["admin"]); // no plugins → just the base admin role
|
||||||
|
assert.deepEqual(seedRoles("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended
|
||||||
|
assert.deepEqual(seedRoles("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant
|
||||||
|
assert.deepEqual(seedRoles("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides)
|
||||||
|
});
|
||||||
|
|
||||||
test("seedAdmin on a fresh stack creates the identity and grants every role (one tuple each)", async () => {
|
test("seedAdmin on a fresh stack creates the identity and grants every role (one tuple each)", async () => {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const calls: { method: string; url: string; body?: unknown }[] = [];
|
const calls: { method: string; url: string; body?: unknown }[] = [];
|
||||||
|
|||||||
@@ -3,10 +3,12 @@
|
|||||||
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
|
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
|
||||||
// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
|
// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
|
||||||
// 3. grant it its roles in Keto so menu/permission checks resolve out of the box — `admin` plus
|
// 3. grant it its roles in Keto so menu/permission checks resolve out of the box — `admin` plus
|
||||||
// the reference plugin's `scheduling:read`/`scheduling:write`, so the shipped example works.
|
// every discovered plugin's declared permission tokens, so a dropped-in plugin is usable by
|
||||||
|
// the demo admin with no host config edit (the host stays plugin-agnostic).
|
||||||
// Then prints a first-run banner; fails loud on any unexpected upstream error.
|
// Then prints a first-run banner; fails loud on any unexpected upstream error.
|
||||||
import { existsSync, writeFileSync } from "node:fs";
|
import { existsSync, writeFileSync } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { discoverPlugins } from "./discovery.ts";
|
||||||
import { generateJwks, type JwkSet } from "./gen-jwks.ts";
|
import { generateJwks, type JwkSet } from "./gen-jwks.ts";
|
||||||
|
|
||||||
// --- Pure payload builders (the Kratos/Keto request contracts) -----------------------
|
// --- Pure payload builders (the Kratos/Keto request contracts) -----------------------
|
||||||
@@ -25,6 +27,15 @@ export function roleTuple(identityId: string, role: string) {
|
|||||||
return { namespace: "Role", object: role, relation: "members", subject_id: `user:${identityId}` };
|
return { namespace: "Role", object: role, relation: "members", subject_id: `user:${identityId}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The roles to grant the demo admin = the configured base (ADMIN_ROLES, default just `admin`)
|
||||||
|
// unioned with every discovered plugin's declared permission tokens (a route/nav `permission` is a
|
||||||
|
// coarse role — granted as a Keto `Role:<token>#members` tuple). So the host names no plugin, yet a
|
||||||
|
// dropped-in plugin's tokens are seeded out of the box. Deduped, order-stable, blanks dropped.
|
||||||
|
export function seedRoles(adminRolesEnv: string | undefined, declaredTokens: string[]): string[] {
|
||||||
|
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
|
||||||
|
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredTokens)])];
|
||||||
|
}
|
||||||
|
|
||||||
// --- JWKS safety net -----------------------------------------------------------------
|
// --- JWKS safety net -----------------------------------------------------------------
|
||||||
|
|
||||||
export interface JwksFsHooks {
|
export interface JwksFsHooks {
|
||||||
@@ -124,8 +135,10 @@ async function main() {
|
|||||||
const env = process.env;
|
const env = process.env;
|
||||||
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) console.log("bootstrap: generated a JWKS signing key");
|
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) console.log("bootstrap: generated a JWKS signing key");
|
||||||
|
|
||||||
// Default roles include the reference plugin's tokens so the shipped example works out of the box.
|
// Seed `admin` (or ADMIN_ROLES) + every discovered plugin's declared permission tokens, so the
|
||||||
const roles = (env["ADMIN_ROLES"] ?? "admin,scheduling:read,scheduling:write").split(",").map((r) => r.trim()).filter(Boolean);
|
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
|
||||||
|
const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.token));
|
||||||
|
const roles = seedRoles(env["ADMIN_ROLES"], declared);
|
||||||
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
|
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
|
||||||
const password = env["ADMIN_PASSWORD"] ?? "admin";
|
const password = env["ADMIN_PASSWORD"] ?? "admin";
|
||||||
const result = await seedAdmin({
|
const result = await seedAdmin({
|
||||||
|
|||||||
14
src/plugin-api.test.ts
Normal file
14
src/plugin-api.test.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// The plugin author barrel (§7): the stable surface a plugin imports. Guards that the value exports
|
||||||
|
// stay present — removing one is a breaking contract change. The types resolve via typecheck (the
|
||||||
|
// reference plugin imports them from here).
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import * as api from "./plugin-api.ts";
|
||||||
|
|
||||||
|
test("plugin-api re-exports the stable author value surface", () => {
|
||||||
|
for (const name of ["definePlugin", "can", "check", "GuardError", "requireSession", "parseListQuery", "readFormBody", "CSRF_FIELD"]) {
|
||||||
|
assert.ok(name in api && api[name as keyof typeof api] !== undefined, `missing export: ${name}`);
|
||||||
|
}
|
||||||
|
assert.equal(typeof api.definePlugin, "function");
|
||||||
|
assert.equal(api.definePlugin({ apiVersion: "1.0.0" }).apiVersion, "1.0.0"); // identity helper works through the barrel
|
||||||
|
});
|
||||||
15
src/plugin-api.ts
Normal file
15
src/plugin-api.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// The plugin author surface (todo §7) — the ONE module a plugin imports. It re-exports exactly the
|
||||||
|
// stable contract: definePlugin + the manifest/handler types, the RequestContext, the auth guards,
|
||||||
|
// and the request-body/CSRF/list-query helpers the blessed pattern needs. This barrel *is* the
|
||||||
|
// contract boundary in code — the host may refactor any other src/* freely as long as it holds, so
|
||||||
|
// a plugin should import from here, never reach into deeper modules. See docs/plugin-contract.md.
|
||||||
|
|
||||||
|
export { definePlugin } from "./plugin.ts";
|
||||||
|
export type { HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
|
||||||
|
export type { RequestContext, User } from "./context.ts";
|
||||||
|
export type { PageChrome } from "./chrome.ts";
|
||||||
|
export type { NavNode } from "./nav.ts";
|
||||||
|
export { can, check, GuardError, requireSession } from "./guards.ts";
|
||||||
|
export { parseListQuery } from "./list-query.ts";
|
||||||
|
export { readFormBody } from "./body.ts";
|
||||||
|
export { CSRF_FIELD } from "./csrf.ts";
|
||||||
@@ -53,6 +53,15 @@ test("app shell renders a configured logo + default theme, falls back to the bra
|
|||||||
assert.match(plain, /id="theme-auto"\s+checked/); // theme-switch default
|
assert.match(plain, /id="theme-auto"\s+checked/); // theme-switch default
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("app shell links extra per-page stylesheets via the styles slot (e.g. a plugin's own CSS)", async () => {
|
||||||
|
const withCss = await render({ styles: ["/public/scheduling/scheduling.css"] });
|
||||||
|
assert.match(withCss, /<link rel="stylesheet" href="\/public\/css\/styles\.css" \/>/); // core stylesheet always present
|
||||||
|
assert.match(withCss, /<link rel="stylesheet" href="\/public\/scheduling\/scheduling\.css" \/>/); // the extra one
|
||||||
|
|
||||||
|
const none = await render(); // no styles → only the core stylesheet
|
||||||
|
assert.equal((none.match(/rel="stylesheet"/g) ?? []).length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
test("app shell escapes text but passes slot HTML through, and renders with defaults", async () => {
|
test("app shell escapes text but passes slot HTML through, and renders with defaults", async () => {
|
||||||
const escaped = await render({ title: "<x>", body: "<p>raw</p>" });
|
const escaped = await render({ title: "<x>", body: "<p>raw</p>" });
|
||||||
assert.match(escaped, /<title><x><\/title>/); // user text is escaped
|
assert.match(escaped, /<title><x><\/title>/); // user text is escaped
|
||||||
|
|||||||
2
todo.md
2
todo.md
@@ -112,7 +112,7 @@ everything via Docker.
|
|||||||
## 7. Example plugin (reference)
|
## 7. Example plugin (reference)
|
||||||
- [x] Reference plugin (e.g. people directory or scheduling): list page fetching upstream data, a form that forwards writes upstream, permission-gated nav. → `plugins/scheduling/` is the worked example the docs already reference (so contract + reference agree). `shifts.ts` = an injectable-`fetch` upstream REST client (`createUpstream`, stand-in for the customer's backend — the plugin is stateless) + thin handler **factories** bound to it: `listShifts` fetches `/shifts`, filters by `?q`, renders the data-table (upstream down ⇒ a recoverable error page, never a host 500); `newShiftForm` renders the form; `createShift` reads its own body, **CSRF-guards via `ctx.verifyCsrf`** (403 on a bad token), validates, forwards the create upstream, then POST-redirect-GET (a 4xx upstream ⇒ a recoverable 502 form keeping the input). `plugin.ts` = the manifest: `apiVersion` literal, namespaced `scheduling:read`/`scheduling:write` perms, **permission-gated nav** ("Shifts" gated on `read` so the whole "Scheduling" header vanishes for non-holders), routes gated `read`/`write`. Views (`shifts.ejs`, `shift-new.ejs` + the plugin's **own** `partials/shift-form.ejs`) compose the core building blocks (shell/nav-tree/filter-bar/data-table/field/alert via `include()`) around the **native app shell**. **New host capability so a plugin page is native + secure** (`src/chrome.ts` `buildPluginChrome`): `ctx.chrome` = brand/global-nav/user/theme/csrf the view hands to `partials/shell` — the global menu (a Dashboard link + every discovered plugin's nav fragment + the gated admin section), composed + role-filtered + current-marked by request path; `ctx.verifyCsrf(submitted)` = the host's bound double-submit verifier (plugin never sees the secret). Both added to `RequestContext` (defaulted in `buildContext`, anonymous chrome / fail-closed verify), built per plugin route in `app.ts` (CSRF cookie set when fresh so forms carry a token). The dashboard now merges plugin nav fragments too (reachable from `/`; gated ⇒ invisible to anonymous, so the visual E2E is byte-identical). Out of the box: bootstrap now grants the demo admin `scheduling:read`/`scheduling:write` (generalized `seedAdmin` to a roles list, env `ADMIN_ROLES`); the dev compose runs a tiny stdlib mock upstream (`examples/shifts-upstream/`, `SCHEDULING_UPSTREAM`) so `docker compose up` shows it working. Tooling: `plugins/` added to tsconfig + the `npm test` glob (so plugin authors' tests run via `docker compose run web npm test`). Tests-first: `plugins/scheduling/shifts.test.ts` (client w/ mock fetch · validation · list/create handlers incl. CSRF-403, validation-400, PRG, upstream-502 · form model), `src/chrome.test.ts` (brand/nav/role-filter/current/branding), `app.test.ts` (a plugin view renders the chrome + the CSRF round-trip over HTTP), `dashboard.test.ts` (plugin-fragment merge, gated), `bootstrap.test.ts` (multi-role grant). README **Building a plugin** + Layout and `docs/plugin-contract.md` (the `ctx.chrome`/`ctx.verifyCsrf` additions, the upstream pattern, the dev/test pointer) updated. typecheck + **296 units** green; the Ory-free **visual E2E** (real built image) confirms the plugin is discovered at boot, the routes/nav are permission-gated (anonymous → 403, hidden from the dashboard), and the dashboard still renders identically; live full-stack boot-verified — the stack comes up with the plugin + mock upstream, the upstream serves the seeded shifts and is reachable from `web`, and bootstrap grants the admin `admin`/`scheduling:read`/`scheduling:write` in real Keto (all `allowed:true`); torn down. The authenticated browser happy-path (login → rendered list) is deferred to §8's full E2E (line 114 verifies the contract end-to-end) — it needs the cross-host Playwright login infra, not curl. `apiVersion` stays `1.0.0` (the contract is still being assembled in §7, so chrome/verifyCsrf are part of the initial surface — no minor bump, no warn noise).
|
- [x] Reference plugin (e.g. people directory or scheduling): list page fetching upstream data, a form that forwards writes upstream, permission-gated nav. → `plugins/scheduling/` is the worked example the docs already reference (so contract + reference agree). `shifts.ts` = an injectable-`fetch` upstream REST client (`createUpstream`, stand-in for the customer's backend — the plugin is stateless) + thin handler **factories** bound to it: `listShifts` fetches `/shifts`, filters by `?q`, renders the data-table (upstream down ⇒ a recoverable error page, never a host 500); `newShiftForm` renders the form; `createShift` reads its own body, **CSRF-guards via `ctx.verifyCsrf`** (403 on a bad token), validates, forwards the create upstream, then POST-redirect-GET (a 4xx upstream ⇒ a recoverable 502 form keeping the input). `plugin.ts` = the manifest: `apiVersion` literal, namespaced `scheduling:read`/`scheduling:write` perms, **permission-gated nav** ("Shifts" gated on `read` so the whole "Scheduling" header vanishes for non-holders), routes gated `read`/`write`. Views (`shifts.ejs`, `shift-new.ejs` + the plugin's **own** `partials/shift-form.ejs`) compose the core building blocks (shell/nav-tree/filter-bar/data-table/field/alert via `include()`) around the **native app shell**. **New host capability so a plugin page is native + secure** (`src/chrome.ts` `buildPluginChrome`): `ctx.chrome` = brand/global-nav/user/theme/csrf the view hands to `partials/shell` — the global menu (a Dashboard link + every discovered plugin's nav fragment + the gated admin section), composed + role-filtered + current-marked by request path; `ctx.verifyCsrf(submitted)` = the host's bound double-submit verifier (plugin never sees the secret). Both added to `RequestContext` (defaulted in `buildContext`, anonymous chrome / fail-closed verify), built per plugin route in `app.ts` (CSRF cookie set when fresh so forms carry a token). The dashboard now merges plugin nav fragments too (reachable from `/`; gated ⇒ invisible to anonymous, so the visual E2E is byte-identical). Out of the box: bootstrap now grants the demo admin `scheduling:read`/`scheduling:write` (generalized `seedAdmin` to a roles list, env `ADMIN_ROLES`); the dev compose runs a tiny stdlib mock upstream (`examples/shifts-upstream/`, `SCHEDULING_UPSTREAM`) so `docker compose up` shows it working. Tooling: `plugins/` added to tsconfig + the `npm test` glob (so plugin authors' tests run via `docker compose run web npm test`). Tests-first: `plugins/scheduling/shifts.test.ts` (client w/ mock fetch · validation · list/create handlers incl. CSRF-403, validation-400, PRG, upstream-502 · form model), `src/chrome.test.ts` (brand/nav/role-filter/current/branding), `app.test.ts` (a plugin view renders the chrome + the CSRF round-trip over HTTP), `dashboard.test.ts` (plugin-fragment merge, gated), `bootstrap.test.ts` (multi-role grant). README **Building a plugin** + Layout and `docs/plugin-contract.md` (the `ctx.chrome`/`ctx.verifyCsrf` additions, the upstream pattern, the dev/test pointer) updated. typecheck + **296 units** green; the Ory-free **visual E2E** (real built image) confirms the plugin is discovered at boot, the routes/nav are permission-gated (anonymous → 403, hidden from the dashboard), and the dashboard still renders identically; live full-stack boot-verified — the stack comes up with the plugin + mock upstream, the upstream serves the seeded shifts and is reachable from `web`, and bootstrap grants the admin `admin`/`scheduling:read`/`scheduling:write` in real Keto (all `allowed:true`); torn down. The authenticated browser happy-path (login → rendered list) is deferred to §8's full E2E (line 114 verifies the contract end-to-end) — it needs the cross-host Playwright login infra, not curl. `apiVersion` stays `1.0.0` (the contract is still being assembled in §7, so chrome/verifyCsrf are part of the initial surface — no minor bump, no warn noise).
|
||||||
- [x] Verify the full plugin contract end-to-end against the README. → Cross-checked every contract claim in `README.md` (**Building a plugin**, **Where plugins live**, **Layout**) + `docs/plugin-contract.md` + `plugins/scheduling/README.md` against the implementation (`plugin.ts`/`discovery.ts`/`router.ts`/`view-resolver.ts`/`chrome.ts`/`context.ts`/`guards.ts`/`app.ts` `sendResult`) and the shipped reference plugin. **Contract holds end-to-end**: derived id+mount (no `id`/`basePath` in the manifest), `apiVersion` literal + `checkApiVersion` table, permission-gated nav (the "Scheduling" header vanishes without `scheduling:read`) + gated routes, every `RouteResult` shape incl. `view`+`status` and `redirect`-PRG, `ctx.chrome` → native app shell, `ctx.verifyCsrf` double-submit (403 on bad token), `can()`/`GuardError(403)`, the view-resolver rendering `plugins/<id>/views/*` while `include()`-ing **both** core partials (shell/filter-bar/data-table/field/alert/nav-tree — all present in `views/partials/`) and the plugin's own `partials/shift-form`, per-plugin static, upstream-fetch statelessness; all three reference-plugin icons (`i-cal`/`i-plus`/`i-user`) are registered in `src/icons.ts`. **One material doc drift found + fixed:** `docs/plugin-contract.md`'s reserved-id list was stale — it named 8 ids but `RESERVED_PLUGIN_IDS` has 10 (the §5 `admin` + §6 `oauth2` mounts were missing), so an author would wrongly think `plugins/admin/` or `plugins/oauth2/` was allowed when discovery refuses them (test-covered in `discovery.test.ts`). Updated the doc to list all 10 accurately (docs-only, no behaviour change). typecheck + 296 units green.
|
- [x] Verify the full plugin contract end-to-end against the README. → Cross-checked every contract claim in `README.md` (**Building a plugin**, **Where plugins live**, **Layout**) + `docs/plugin-contract.md` + `plugins/scheduling/README.md` against the implementation (`plugin.ts`/`discovery.ts`/`router.ts`/`view-resolver.ts`/`chrome.ts`/`context.ts`/`guards.ts`/`app.ts` `sendResult`) and the shipped reference plugin. **Contract holds end-to-end**: derived id+mount (no `id`/`basePath` in the manifest), `apiVersion` literal + `checkApiVersion` table, permission-gated nav (the "Scheduling" header vanishes without `scheduling:read`) + gated routes, every `RouteResult` shape incl. `view`+`status` and `redirect`-PRG, `ctx.chrome` → native app shell, `ctx.verifyCsrf` double-submit (403 on bad token), `can()`/`GuardError(403)`, the view-resolver rendering `plugins/<id>/views/*` while `include()`-ing **both** core partials (shell/filter-bar/data-table/field/alert/nav-tree — all present in `views/partials/`) and the plugin's own `partials/shift-form`, per-plugin static, upstream-fetch statelessness; all three reference-plugin icons (`i-cal`/`i-plus`/`i-user`) are registered in `src/icons.ts`. **One material doc drift found + fixed:** `docs/plugin-contract.md`'s reserved-id list was stale — it named 8 ids but `RESERVED_PLUGIN_IDS` has 10 (the §5 `admin` + §6 `oauth2` mounts were missing), so an author would wrongly think `plugins/admin/` or `plugins/oauth2/` was allowed when discovery refuses them (test-covered in `discovery.test.ts`). Updated the doc to list all 10 accurately (docs-only, no behaviour change). typecheck + 296 units green.
|
||||||
- [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues.
|
- [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on the whole project (weighted to the §7 reference-plugin surfaces). Architecture: **no Critical**; Product: **no Critical** this checkpoint. **Fixed now (tests-first):** (1) HIGH (arch + product Blocker) — the contract claimed declaring `permissions` "seeds Keto" but nothing wired it, and the host hardcoded the plugin-specific `scheduling:read`/`write` into `ADMIN_ROLES` (`compose.yml` + `bootstrap.ts`) — core knowing about a plugin. Made the claim true *and* removed the coupling: new pure `seedRoles(adminRolesEnv, declaredTokens)` (union+dedup, trims both sides); `bootstrap.main()` now `discoverPlugins()` → seeds the demo admin `admin`(/`ADMIN_ROLES`) ∪ every discovered plugin's declared tokens; `compose.yml` `ADMIN_ROLES` default dropped to just `admin` (host names no plugin). Clean-clone behaviour is unchanged (admin still ends up with the scheduling tokens, now derived via discovery, not hardcoded — `readRoles` enumerates the Role namespace so a granted token reaches the JWT). Docs corrected: a route/nav `permission` **is a coarse role** granted as a Keto `Role:<token>#members` tuple (vs the separate fine-grained `Resource` tier). (2) HIGH (arch) — no delineated plugin SDK: the reference plugin imported six deep `src/*` internals, none of them the documented-stable surface. Added `src/plugin-api.ts` — the one barrel a plugin imports (`definePlugin` + manifest/handler/ctx types + guards + body/CSRF/list-query helpers); repointed `plugins/scheduling/{plugin,shifts}.ts` to it, so the contract boundary now lives in code (the host can refactor internals freely behind it). (3) MEDIUM (arch L1 + product) — per-plugin CSS was documented (`/public/<id>/`) but unusable (the shell `<head>` had no slot) and the reference shipped no `public/`. Added an optional `styles` slot to `shell.ejs` (extra stylesheet hrefs, default `[]`, backward-compatible) + `plugins/scheduling/public/scheduling.css` linked from both reference views (`.scheduling-page` scope) — closes the only unexercised contract feature + the anatomy-diagram drift. (4) MEDIUM (arch M4) — the reference demonstrated no hook; added `hooks.onBoot` validating `SCHEDULING_UPSTREAM` fail-loud at boot (`assertHttpUrl` — unparseable or non-http(s) aborts the boot, a typo no longer degrades every request), closing the hooks-coverage gap. (5) MEDIUM (arch M3) — `ctx.chrome` was built twice on a matched plugin request when any onRequest hook exists; now built at most once per request (memoized lazy `chrome()`), hot path still chrome-free. (6) HIGH/MEDIUM (product) — doc honesty: fixed the false `visual.spec.ts` comment ("covered by the full-stack suites" — it isn't), softened the contract's "every plugin ships *with* a Playwright test" to match reality (the reference's authed list/form happy-path is the §8 item), and added an **Upstream contract** block (routes/JSON shape/status-code expectations) to `plugins/scheduling/README.md`. Stability-reviewer run as a local PR: **APPROVE, no Critical/High**; addressed both Low nits (`seedRoles` now trims/dedups declared tokens too; added an `onBoot`-binding seam test on the real manifest). typecheck + **301 units** green (296 → 301). **Deferred (reviewer-scoped, not the §7 checkpoint):** the host **internal route-table** (fold the admin/oauth if-ladder into one `{method,prefix,handler}` table, derive `RESERVED_PLUGIN_IDS`/`allowedMethods` from it — arch M1) → **§9** (now the top structural item; standalone change, not a review-fix); the `safeUrl()` href helper + a reference row that renders an untrusted URL through it (arch L2 / product 🟢) → **§9** (first untrusted-URL flow / redirect-allowlist work); the data-table **empty state** + **success-flash** after writes (product 🟡, recurring §5/§6 deferral, now visible in the exemplar) → **§8/polish**; compile-time `apiVersion`-literal enforcement (arch M2) → accept prose-only; rename route/nav `permission` → `requireRole` to match the mechanism (arch H1.3) → a future minor `apiVersion` bump.
|
||||||
- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff.
|
- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff.
|
||||||
- [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us.
|
- [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us.
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<%#
|
<%#
|
||||||
App shell: sidebar (brand + nav slot + footer) · topbar · content slot.
|
App shell: sidebar (brand + nav slot + footer) · topbar · content slot.
|
||||||
Slots are pre-rendered HTML locals — `nav` (sidebar tree, see nav-tree partial),
|
Slots are pre-rendered HTML locals — `nav` (sidebar tree, see nav-tree partial),
|
||||||
`actions` (topbar buttons), `body` (page content). Text locals: `title`, `brand`
|
`actions` (topbar buttons), `body` (page content); `styles` is an optional array of
|
||||||
|
extra stylesheet hrefs (e.g. a plugin's own /public/<id>/x.css). Text locals: `title`, `brand`
|
||||||
({ name, logo?, sub? } — logo image else the default mark), `theme` (default for the
|
({ name, logo?, sub? } — logo image else the default mark), `theme` (default for the
|
||||||
theme-switch), `user`, `breadcrumbs`, `csrfToken` (the Sign-out POST form's hidden field).
|
theme-switch), `user`, `breadcrumbs`, `csrfToken` (the Sign-out POST form's hidden field).
|
||||||
Branding comes from config/menu.ts; `user`/`csrfToken` from §4 auth.
|
Branding comes from config/menu.ts; `user`/`csrfToken` from §4 auth.
|
||||||
@@ -13,6 +14,7 @@
|
|||||||
const nav = locals.nav || "";
|
const nav = locals.nav || "";
|
||||||
const actions = locals.actions || "";
|
const actions = locals.actions || "";
|
||||||
const body = locals.body || "";
|
const body = locals.body || "";
|
||||||
|
const styles = locals.styles || []; // extra per-page stylesheet hrefs (e.g. a plugin's own CSS)
|
||||||
%><!doctype html>
|
%><!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -20,7 +22,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title><%= title %></title>
|
<title><%= title %></title>
|
||||||
<link rel="stylesheet" href="/public/css/styles.css" />
|
<link rel="stylesheet" href="/public/css/styles.css" />
|
||||||
<link rel="icon" href="/public/favicon.svg" />
|
<% styles.forEach((href) => { %><link rel="stylesheet" href="<%= href %>" />
|
||||||
|
<% }) %><link rel="icon" href="/public/favicon.svg" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<a class="skip-link" href="#main-content">Skip to content</a>
|
<a class="skip-link" href="#main-content">Skip to content</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user