Compare commits
2 Commits
v0.4.2
..
e680018c34
| Author | SHA1 | Date | |
|---|---|---|---|
| e680018c34 | |||
| 19a696aebe |
@@ -19,4 +19,4 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker run --rm -v "$PWD:/repo" -w /repo \
|
docker run --rm -v "$PWD:/repo" -w /repo \
|
||||||
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
|
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
|
||||||
node:24.21.0-alpine3.24 node registry-cleanup/cleanup.ts
|
node:24.20.0-alpine3.24 node registry-cleanup/cleanup.ts
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
GIT_TAG: ${{ github.ref_name }}
|
GIT_TAG: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.21.0-alpine3.24 \
|
docker run --rm -v "$PWD:/repo" -w /repo node:24.20.0-alpine3.24 \
|
||||||
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
|
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
|
||||||
- name: Promote the commit-hash image to semver + latest
|
- name: Promote the commit-hash image to semver + latest
|
||||||
env:
|
env:
|
||||||
@@ -98,9 +98,9 @@ jobs:
|
|||||||
VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
|
VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
|
||||||
VERSION=${VERSION#v}
|
VERSION=${VERSION#v}
|
||||||
# An empty dispatch input falls back to the branch name, so gate this like a tag.
|
# An empty dispatch input falls back to the branch name, so gate this like a tag.
|
||||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.21.0-alpine3.24 \
|
docker run --rm -v "$PWD:/repo" -w /repo node:24.20.0-alpine3.24 \
|
||||||
node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts
|
node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts
|
||||||
docker run --rm -v "$PWD:/repo" -w /repo \
|
docker run --rm -v "$PWD:/repo" -w /repo \
|
||||||
-e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \
|
-e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \
|
||||||
node:24.21.0-alpine3.24 \
|
node:24.20.0-alpine3.24 \
|
||||||
node release-tooling/dockerhub-overview.ts "$VERSION"
|
node release-tooling/dockerhub-overview.ts "$VERSION"
|
||||||
|
|||||||
@@ -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.75.1
|
renovate/renovate:44.56.3
|
||||||
|
|
||||||
# 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
|
||||||
@@ -58,11 +58,11 @@ jobs:
|
|||||||
if [ -z "$BUMPS" ]; then
|
if [ -z "$BUMPS" ]; then
|
||||||
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0
|
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0
|
||||||
fi
|
fi
|
||||||
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.21.0-alpine3.24 \
|
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.20.0-alpine3.24 \
|
||||||
node release-tooling/next-version.ts "$LATEST" $BUMPS)
|
node release-tooling/next-version.ts "$LATEST" $BUMPS)
|
||||||
# Read the constant off origin/main, not the checkout, which lags the merges this run made.
|
# Read the constant off origin/main, not the checkout, which lags the merges this run made.
|
||||||
git show origin/main:src/plugin-host/plugin.ts \
|
git show origin/main:src/plugin-host/plugin.ts \
|
||||||
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.21.0-alpine3.24 \
|
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.20.0-alpine3.24 \
|
||||||
node release-tooling/contract-version.ts "$NEXT" -
|
node release-tooling/contract-version.ts "$NEXT" -
|
||||||
echo "Releasing $LATEST -> $NEXT"
|
echo "Releasing $LATEST -> $NEXT"
|
||||||
git tag "$NEXT" origin/main
|
git tag "$NEXT" origin/main
|
||||||
|
|||||||
@@ -36,15 +36,6 @@ branch, create a PR and merge it when the CI/CD turns green.
|
|||||||
## Project priorities (do not erode)
|
## Project priorities (do not erode)
|
||||||
|
|
||||||
1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable.
|
1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable.
|
||||||
**A page is a document**: it scrolls, and the chrome scrolls with it. Nothing may bound the
|
|
||||||
viewport to hold content still — no `height: 100dvh` frame, no `overflow: hidden` on `body`, no
|
|
||||||
`position: sticky` header. Each such box buys an app-like look with CSS the next reader has to
|
|
||||||
reverse-engineer, and is one more thing to undo before the content under it can be reached.
|
|
||||||
Overlays are not this: the skip link, the mobile off-canvas nav and its scrim sit *above* the
|
|
||||||
document rather than holding it still, and have no other spelling — the document keeps scrolling
|
|
||||||
behind the open nav, accepted rather than overlooked. Only the document scroller gets keyboard
|
|
||||||
paging unconditionally and back/forward scroll restoration, and a page a box clips fails silently:
|
|
||||||
nothing in a test or a console says content is unreachable below the fold.
|
|
||||||
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`,
|
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`,
|
||||||
`postgres`). Prefer the Node standard library; justify any new dependency; do not add frameworks.
|
`postgres`). Prefer the Node standard library; justify any new dependency; do not add frameworks.
|
||||||
The **host is stateless — it owns no schema and stores nothing of its own**; a plugin may own a
|
The **host is stateless — it owns no schema and stores nothing of its own**; a plugin may own a
|
||||||
@@ -191,22 +182,6 @@ Revisit only if the stated reason stops holding.
|
|||||||
example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
|
example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
|
||||||
× 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
|
× 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
|
||||||
verb — a route table must answer "what does this need?" on its own.
|
verb — a route table must answer "what does this need?" on its own.
|
||||||
- **A gate is one of three, named exactly once, and `session` is a first-class one.** A route or nav
|
|
||||||
node names exactly one of `public`, `session`, `permission` — discovery refuses none, two, and a
|
|
||||||
flag spelled anything but `true`, so a forgotten gate fails the boot rather than publishing a page.
|
|
||||||
`src/auth/gate.ts` is the one home of the rule the plugin router, the host's own route table and
|
|
||||||
the menu all read. Exactly-one-gate is a discovery-time rule on manifests, not a runtime
|
|
||||||
invariant: `allows({}, user)` stays open **by design**, because the central override's `groups`
|
|
||||||
builds header nodes that carry no gate. Making `allows` fail closed would hide every
|
|
||||||
operator-grouped section. `session` exists because a plugin whose data is
|
|
||||||
the visitor's own — their upstream account, their own tokens — has no distinction a permission could
|
|
||||||
name; the alternative, granting every newly registered user a permission, couples the identity
|
|
||||||
lifecycle to a Keto write that nothing retries when it fails. A page scoped to "mine" joins on
|
|
||||||
`ctx.user.id`, never the email — an address is user-changeable and can be reassigned to someone
|
|
||||||
who would then inherit the previous holder's rows.
|
|
||||||
- **The reference plugin's two shift pages duplicate a view model and markup on purpose.** An example
|
|
||||||
is read far more often than it is changed, and each page reads top to bottom on its own. **Valid
|
|
||||||
while `examples/plugins/scheduling` stays a teaching artifact rather than a maintained product.**
|
|
||||||
- **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry
|
- **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry
|
||||||
`canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders,
|
`canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders,
|
||||||
disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create
|
disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create
|
||||||
@@ -287,10 +262,6 @@ Revisit only if the stated reason stops holding.
|
|||||||
it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
|
it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
|
||||||
profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
|
profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
|
||||||
the two in step.
|
the two in step.
|
||||||
- **One scroller, the document** (priority 1). `.app` is `min-height: 100dvh`. `.nav`'s
|
|
||||||
`overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto` are not leftovers of a bounded frame:
|
|
||||||
they are what makes the off-canvas panel usable with a long tree. `#nav-toggle` is `position: fixed` —
|
|
||||||
a label click focuses it, and a browser scrolls a focused element into view.
|
|
||||||
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it
|
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it
|
||||||
is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference
|
is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference
|
||||||
to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an
|
to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
The release version **is** the plugin contract version (`HOST_API_VERSION`), so a minor is a
|
|
||||||
contract break: a plugin's `apiVersion` must match the host's `major.minor` or discovery refuses it
|
|
||||||
at boot. Entries start at 0.3.0.
|
|
||||||
|
|
||||||
## 0.4.0
|
|
||||||
|
|
||||||
**Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page
|
|
||||||
that relied on filling it scrolls the document instead.
|
|
||||||
|
|
||||||
### The document scrolls, and the chrome scrolls with it
|
|
||||||
|
|
||||||
`.app` was a `100dvh` box with `overflow: hidden`, so a page was only reachable below the fold if its
|
|
||||||
own wrapper was a flex child with `overflow-y: auto`. `.table-wrap` and `.shell-auth` were; nothing
|
|
||||||
else was, and a long page in `.form-page` clipped everything past the window in every engine.
|
|
||||||
|
|
||||||
Now the shell is `min-height: 100dvh` and nothing bounds the viewport. The sidebar and topbar scroll
|
|
||||||
with the page, and keyboard paging, back/forward scroll restoration and find-in-page work without a
|
|
||||||
page doing anything.
|
|
||||||
|
|
||||||
The sticky `thead` on `data-table` goes with it: a header only sticks to a scrollport that moves, and
|
|
||||||
there is no longer one. A plugin that wants a full-height pane owns that in its own stylesheet; the
|
|
||||||
shell offers no opt-out, per the simplicity priority in `AGENTS.md`.
|
|
||||||
|
|
||||||
### Upgrading a plugin
|
|
||||||
|
|
||||||
1. Set `apiVersion: "0.4.0"`.
|
|
||||||
2. A page that scrolled the whole window needs no change — it now scrolls the document.
|
|
||||||
3. A page holding a region that filled the content column (`flex: 1 1 auto; min-height: 0` with its
|
|
||||||
own `overflow`) no longer gets a bounded column to fill, so that region grows and the page scrolls.
|
|
||||||
Either let it, or give the region its own height in the plugin's stylesheet.
|
|
||||||
4. A `data-table` no longer scrolls its rows in a bounded region: the page scrolls, and the header
|
|
||||||
scrolls with it.
|
|
||||||
|
|
||||||
The sidebar stretches the whole document, so on a long page its footer — theme, language, profile and
|
|
||||||
**Sign out** — sits at the end of that page rather than the bottom of the screen.
|
|
||||||
|
|
||||||
## 0.3.0
|
|
||||||
|
|
||||||
**Breaking.** Set `apiVersion: "0.3.0"`, and name a gate on every route and nav node.
|
|
||||||
|
|
||||||
### A session is a gate of its own
|
|
||||||
|
|
||||||
`session: true` takes any signed-in user, with no grant to hold — for a page whose data is the
|
|
||||||
visitor's own (their upstream account, their own tokens), where there is no distinction a permission
|
|
||||||
could name. An anonymous visitor is bounced to `/login` with the page as `return_to`, exactly as a
|
|
||||||
permission gate does.
|
|
||||||
|
|
||||||
Every route and nav node now names **exactly one** of `public: true`, `session: true` or
|
|
||||||
`permission: "<resource>:<action>"`, and a gate is spelled `true`:
|
|
||||||
|
|
||||||
- Naming **none** is refused. It used to mean public, so a forgotten gate published a page; it now
|
|
||||||
fails the boot instead.
|
|
||||||
- Naming **two** is refused, as before.
|
|
||||||
- Spelling one anything but `true` is refused — `public: false` and `session: "yes"` both set no gate
|
|
||||||
while reading as if they set one.
|
|
||||||
|
|
||||||
A section header gates nothing itself, so it takes `public: true` and lets each child decide; the
|
|
||||||
host still drops a header whose children all filtered out.
|
|
||||||
|
|
||||||
`Gate` is exported from `@plainpages/plugin-api`, and `Route` and `NavNode` extend it.
|
|
||||||
|
|
||||||
### Filter bars take a multi-select
|
|
||||||
|
|
||||||
The `filter-bar` partial gains a `multiselect` control — the same checkboxes on the same query
|
|
||||||
parameter as `chips`, but behind a button once the list is too long to lay on the bar. Config is
|
|
||||||
`{ name, legend?, note?, value?, options }`, and the panel says what a capped list left out.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- An identity carrying no email no longer yields a session at all. Login used to mint a JWT for one,
|
|
||||||
which every later request then rejected as anonymous — leaving the browser holding a dead cookie
|
|
||||||
and no way to tell why.
|
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
- Node 24.20.0.
|
|
||||||
|
|
||||||
### Upgrading a plugin
|
|
||||||
|
|
||||||
1. Set `apiVersion: "0.3.0"`.
|
|
||||||
2. Give every route and nav node a gate. Anything that relied on omitting one was public — say
|
|
||||||
`public: true` outright.
|
|
||||||
|
|
||||||
A page that scopes rows to the signed-in visitor should join on `ctx.user.id`. An email address is
|
|
||||||
user-changeable and can be reassigned to someone else, who would then inherit the previous holder's
|
|
||||||
rows. The reference plugin's new `/scheduling/mine` page shows the shape.
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
|
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
|
||||||
FROM node:24.21.0-alpine3.24
|
FROM node:24.20.0-alpine3.24
|
||||||
|
|
||||||
# Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules
|
# Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules
|
||||||
# instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
|
# instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
|
||||||
|
|||||||
@@ -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.4.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>" }) },
|
||||||
@@ -231,7 +231,6 @@ Against the reference plugins' actual routes:
|
|||||||
| Request | Gate | alice | bob | carol | anonymous |
|
| Request | Gate | alice | bob | carol | anonymous |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| `GET /scheduling` | `public: true` | ✅ | ✅ | ✅ | ✅ |
|
| `GET /scheduling` | `public: true` | ✅ | ✅ | ✅ | ✅ |
|
||||||
| `GET /scheduling/mine` | `session: true` | ✅ | ✅ | ✅ | → `/login` |
|
|
||||||
| `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` |
|
| `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` |
|
||||||
| `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
| `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
||||||
| `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
| `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
||||||
@@ -350,9 +349,9 @@ 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.4.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 gate-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).
|
||||||
nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }],
|
nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }],
|
||||||
|
|
||||||
@@ -362,7 +361,7 @@ export default definePlugin({
|
|||||||
{ description: "Create and edit things", name: "things:write" },
|
{ description: "Create and edit things", name: "things:write" },
|
||||||
],
|
],
|
||||||
|
|
||||||
// Route handlers, mounted under the plugin's path (/things). The gate runs first.
|
// Route handlers, mounted under the plugin's path (/things). `permission` gates first.
|
||||||
routes: [
|
routes: [
|
||||||
{ method: "GET", path: "/", permission: "things:read", handler: listThings },
|
{ method: "GET", path: "/", permission: "things:read", handler: listThings },
|
||||||
{ method: "POST", path: "/", permission: "things:write", handler: createThings },
|
{ method: "POST", path: "/", permission: "things:write", handler: createThings },
|
||||||
@@ -379,7 +378,7 @@ folder-derived `id` to produce the loaded `Plugin`.
|
|||||||
| `apiVersion` | yes | Semver string of the host contract the plugin was built against. See [Versioning](#contract-versioning). |
|
| `apiVersion` | yes | Semver string of the host contract the plugin was built against. See [Versioning](#contract-versioning). |
|
||||||
| `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
| `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
||||||
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
||||||
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Every node names [exactly one gate](#public-pages--menu-items). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. |
|
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. |
|
||||||
| `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). |
|
||||||
@@ -390,13 +389,13 @@ A plugin may be routes-only, nav-only, or hooks-only — every collection field
|
|||||||
|
|
||||||
### Routes & handlers
|
### Routes & handlers
|
||||||
|
|
||||||
A route is `{ method, path, handler }` plus [exactly one gate](#public-pages--menu-items) —
|
A route is `{ method, path, permission?, public?, handler }`. `path` is **relative to the plugin's
|
||||||
`permission`, `public: true` or `session: true`. `path` is **relative to the plugin's
|
|
||||||
mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host matches
|
mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host matches
|
||||||
`method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, runs the
|
`method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, runs the
|
||||||
gate ([a coarse JWT-claim check](#nav--permission-gates)), then calls the handler with
|
`permission` gate ([a coarse JWT-claim check](#nav--permission-gates)), then calls the handler with
|
||||||
the [request context](#requestcontext). A failed gate redirects an **anonymous** visitor to `/login`
|
the [request context](#requestcontext). A failed gate redirects an **anonymous** visitor to `/login`
|
||||||
with the page as `return_to`; a **signed-in** user lacking the permission gets the **403** page.
|
with the page as `return_to`; a **signed-in** user lacking the permission gets the **403** page.
|
||||||
|
`public: true` means no gate at all (see [Public pages](#public-pages--menu-items)).
|
||||||
|
|
||||||
`method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`.
|
`method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`.
|
||||||
|
|
||||||
@@ -471,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.4.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
|
||||||
});
|
});
|
||||||
@@ -570,28 +569,20 @@ system plugins you author or vendor. An ordinary domain plugin ignores it.
|
|||||||
|
|
||||||
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
|
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
|
||||||
applies the central override and then **filters per user** by the permissions in the session JWT: a
|
applies the central override and then **filters per user** by the permissions in the session JWT: a
|
||||||
node shows iff it is `public`, is `session` and someone is signed in, or names a `permission` the
|
node shows iff it is `public`, declares no `permission`, or the user holds that name. A node's `icon`
|
||||||
user holds. A node's `icon` is a **Lucide icon** by sprite id (e.g. `i-cal` → lucide `calendar`); the
|
is a **Lucide icon** by sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are
|
||||||
available ids are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name
|
`ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there.
|
||||||
there.
|
|
||||||
|
|
||||||
**Gating a section header.** A `permission` on the header takes the whole subtree with it. When the
|
**Gating a section header.** A `permission` on the header takes the whole subtree with it. When the
|
||||||
children need *different* permissions, mark the header `public: true` — it then gates nothing, each
|
children need *different* permissions, leave the header ungated and gate each child — `composeNav`
|
||||||
child decides, and `composeNav` drops a header whose children all filtered out. That only works while
|
drops a header whose children all filtered out. That only works while the header carries **no
|
||||||
the header carries **no `href`**: give it one and it survives as a leaf, visible to everyone.
|
`href`**: give it one and it survives as an ungated leaf, visible to everyone.
|
||||||
|
|
||||||
#### Public pages & menu items
|
#### Public pages & menu items
|
||||||
|
|
||||||
A route or nav node marked **`public: true`** is reachable by anyone and shows in everyone's menu —
|
A route or nav node marked **`public: true`** is reachable by anyone and shows in everyone's menu.
|
||||||
open stated outright, so it is a deliberate choice rather than a forgotten gate.
|
That is the same as omitting `permission`, but stated outright so public is a deliberate choice
|
||||||
|
rather than a forgotten gate. The two are **mutually exclusive** — declaring both is refused at boot.
|
||||||
**`session: true`** takes any signed-in user, with no grant to hold — for a plugin whose data is the
|
|
||||||
visitor's own. An anonymous visitor is bounced to `/login` with the page as `return_to`, exactly as a
|
|
||||||
permission gate does.
|
|
||||||
|
|
||||||
Every route and nav node names **exactly one** of the three, spelled `true` (or a permission name).
|
|
||||||
Naming none, naming two, or spelling one `false` is refused at boot — so a forgotten gate fails the
|
|
||||||
plugin instead of publishing a page.
|
|
||||||
|
|
||||||
A public page still renders in the native shell; for an anonymous visitor `ctx.user` is `null`, the
|
A public page still renders in the native shell; for an anonymous visitor `ctx.user` is `null`, the
|
||||||
shell shows a **Sign in** link in place of the profile block, the gated **Dashboard** link is hidden,
|
shell shows a **Sign in** link in place of the profile block, the gated **Dashboard** link is hidden,
|
||||||
@@ -626,7 +617,6 @@ provider/consumer semantics in `checkApiVersion`:
|
|||||||
The plugin pins one exact version (no ranges, per the project's pinning rules); the *host* supplies
|
The plugin pins one exact version (no ranges, per the project's pinning rules); the *host* supplies
|
||||||
the compatibility. One digit carries the whole release, so a **minor** means either the plugin
|
the compatibility. One digit carries the whole release, so a **minor** means either the plugin
|
||||||
contract changed or a dependency moved far enough to warrant one.
|
contract changed or a dependency moved far enough to warrant one.
|
||||||
[`CHANGELOG.md`](CHANGELOG.md) is what a minor sends you to: what broke, and what to change.
|
|
||||||
|
|
||||||
|
|
||||||
### Conflict rules
|
### Conflict rules
|
||||||
@@ -645,7 +635,7 @@ The host detects collisions across all discovered plugins with `findConflicts` a
|
|||||||
Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also
|
Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also
|
||||||
rejects **per-manifest shape errors**: a non-array `nav`/`routes`/`permissions`, a non-function
|
rejects **per-manifest shape errors**: a non-array `nav`/`routes`/`permissions`, a non-function
|
||||||
`home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a
|
`home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a
|
||||||
route/nav node that does not name [exactly one gate](#public-pages--menu-items).
|
route/nav node setting both `public` and `permission`.
|
||||||
|
|
||||||
### Hooks
|
### Hooks
|
||||||
|
|
||||||
@@ -757,7 +747,7 @@ camel humps both becoming underscores — so `upstream` on the `scheduling` plug
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0",
|
apiVersion: "0.3.0",
|
||||||
settings: [
|
settings: [
|
||||||
{ key: "upstream", type: "url", required: true, description: "Base URL of the backend" },
|
{ key: "upstream", type: "url", required: true, description: "Base URL of the backend" },
|
||||||
{ key: "pageSize", type: "number", default: 25 },
|
{ key: "pageSize", type: "number", default: 25 },
|
||||||
@@ -811,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.4.0",
|
apiVersion: "0.3.0",
|
||||||
storage: true,
|
storage: true,
|
||||||
hooks: {
|
hooks: {
|
||||||
onBoot: async (boot) => {
|
onBoot: async (boot) => {
|
||||||
@@ -921,10 +911,9 @@ The menu is **driven entirely by config** and assembled from two sources:
|
|||||||
export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } });
|
export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } });
|
||||||
```
|
```
|
||||||
|
|
||||||
Every nav node a **plugin** declares names one gate — a `permission`, **`public: true`** (everyone)
|
Every nav item may carry a `permission`; the rendered tree is **filtered per user** from the session
|
||||||
or **`session: true`** (anyone signed in); a header this override groups takes none, and shows
|
JWT (no per-request authz call), so the menu only shows what that person can reach. An item may
|
||||||
whenever a child does. The rendered tree is **filtered per user** from the session JWT (no
|
instead be **`public: true`** to show it to everyone — mutually exclusive with `permission`.
|
||||||
per-request authz call), so the menu only shows what that person can reach.
|
|
||||||
Branding (name, logo, default theme) renders in the app shell.
|
Branding (name, logo, default theme) renders in the app shell.
|
||||||
|
|
||||||
**One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders
|
**One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders
|
||||||
@@ -933,11 +922,6 @@ recovery / front pages — so it looks identical signed in or out and just shows
|
|||||||
anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page wanting a
|
anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page wanting a
|
||||||
chrome-free layout opts out with the shell's `menu: false`.
|
chrome-free layout opts out with the shell's `menu: false`.
|
||||||
|
|
||||||
**The document scrolls, and the chrome scrolls with it.** Nothing bounds the viewport, so a page is
|
|
||||||
reachable below the fold without adding a scroll region of its own, and browser paging, scroll
|
|
||||||
restoration and find-in-page work without a page doing anything. A plugin that wants a full-height
|
|
||||||
pane owns that in its own stylesheet.
|
|
||||||
|
|
||||||
## Building blocks
|
## Building blocks
|
||||||
|
|
||||||
Plainpages is a **component library, not a page generator** — reusable EJS partials + TS helpers,
|
Plainpages is a **component library, not a page generator** — reusable EJS partials + TS helpers,
|
||||||
@@ -1736,7 +1720,6 @@ e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,aut
|
|||||||
release-tooling/ Everything the release runs: next-version (the bump math), contract-version
|
release-tooling/ Everything the release runs: next-version (the bump math), contract-version
|
||||||
(the HOST_API_VERSION↔tag gate), dockerhub-overview (+ its .md.tmpl)
|
(the HOST_API_VERSION↔tag gate), dockerhub-overview (+ its .md.tmpl)
|
||||||
registry-cleanup/ Nightly image pruning — the Gitea client plus what survives (select-versions.ts)
|
registry-cleanup/ Nightly image pruning — the Gitea client plus what survives (select-versions.ts)
|
||||||
CHANGELOG.md What changed per release, and how to upgrade a plugin across a minor
|
|
||||||
ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack
|
ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack
|
||||||
.gitea/workflows/ Gitea Actions — see CI/CD
|
.gitea/workflows/ Gitea Actions — see CI/CD
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ services:
|
|||||||
# backs it (PLUGIN_SETTING_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 PLUGIN_SETTING_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.21.0-alpine3.24
|
image: node:24.20.0-alpine3.24
|
||||||
command: node /srv/server.ts
|
command: node /srv/server.ts
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
@@ -58,7 +58,7 @@ services:
|
|||||||
# Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025).
|
# Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025).
|
||||||
# kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
|
# kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
|
||||||
mailpit:
|
mailpit:
|
||||||
image: axllent/mailpit:v1.31.1
|
image: axllent/mailpit:v1.31.0
|
||||||
ports:
|
ports:
|
||||||
- "8025:8025"
|
- "8025:8025"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/.
|
# Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/.
|
||||||
# Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network.
|
# Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network.
|
||||||
FROM mcr.microsoft.com/playwright:v1.63.0-noble
|
FROM mcr.microsoft.com/playwright:v1.62.1-noble
|
||||||
|
|
||||||
WORKDIR /e2e-tests
|
WORKDIR /e2e-tests
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ services:
|
|||||||
|
|
||||||
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
||||||
shifts-upstream:
|
shifts-upstream:
|
||||||
image: node:24.21.0-alpine3.24
|
image: node:24.20.0-alpine3.24
|
||||||
command: ["node", "/server.ts"]
|
command: ["node", "/server.ts"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./examples/shifts-upstream/server.ts:/server.ts:ro
|
- ./examples/shifts-upstream/server.ts:/server.ts:ro
|
||||||
@@ -66,7 +66,7 @@ services:
|
|||||||
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
|
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
|
||||||
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
|
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
|
||||||
mock-oidc:
|
mock-oidc:
|
||||||
image: node:24.21.0-alpine3.24
|
image: node:24.20.0-alpine3.24
|
||||||
command: ["node", "/mock-oidc.ts"]
|
command: ["node", "/mock-oidc.ts"]
|
||||||
environment:
|
environment:
|
||||||
ISSUER: http://mock-oidc:9000
|
ISSUER: http://mock-oidc:9000
|
||||||
@@ -81,7 +81,7 @@ services:
|
|||||||
|
|
||||||
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
|
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
|
||||||
proxy:
|
proxy:
|
||||||
image: node:24.21.0-alpine3.24
|
image: node:24.20.0-alpine3.24
|
||||||
command: ["node", "/proxy.ts"]
|
command: ["node", "/proxy.ts"]
|
||||||
depends_on:
|
depends_on:
|
||||||
web:
|
web:
|
||||||
|
|||||||
@@ -193,12 +193,6 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await page.goto("/scheduling/shifts");
|
await page.goto("/scheduling/shifts");
|
||||||
await expect(page.locator("h1")).toHaveText("Shifts");
|
await expect(page.locator("h1")).toHaveText("Shifts");
|
||||||
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
|
||||||
|
|
||||||
// The admin owns none of the demo's rows, so an empty page is the no-leak assertion.
|
|
||||||
await page.goto("/scheduling/mine");
|
|
||||||
await expect(page.locator("h1")).toHaveText("My shifts");
|
|
||||||
await expect(page.getByText("No shifts are assigned to admin@plainpages.local")).toBeVisible();
|
|
||||||
await expect(page.locator("table")).not.toContainText("Morning — Front desk");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("plugin settings: the screen names the variable that sets each declared key", async () => {
|
test("plugin settings: the screen names the variable that sets each declared key", async () => {
|
||||||
|
|||||||
Generated
+30
-12
@@ -6,17 +6,17 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "plainpages-e2e",
|
"name": "plainpages-e2e",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.63.0"
|
"@playwright/test": "1.62.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.63.0",
|
"version": "1.62.1",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||||
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.63.0"
|
"playwright": "1.62.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -25,26 +25,44 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.63.0",
|
"version": "1.62.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.63.0"
|
"playwright-core": "1.62.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.63.0",
|
"version": "1.62.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|||||||
@@ -7,6 +7,6 @@
|
|||||||
"test": "playwright test"
|
"test": "playwright test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.63.0"
|
"@playwright/test": "1.62.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,45 +28,6 @@ test.beforeEach(async ({ context }) => {
|
|||||||
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
|
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// A key press, not scrollIntoView (a script can scroll a box no reader can) and not the wheel
|
|
||||||
// (Firefox's synthetic event never reaches the document).
|
|
||||||
for (const [name, path, tail] of [
|
|
||||||
["the starter dashboard", "/dashboard", ".form-actions .btn"],
|
|
||||||
["the public landing", "/", ".landing-actions .btn"],
|
|
||||||
] as const) {
|
|
||||||
for (const width of [1280, 390]) {
|
|
||||||
test(`${name} scrolls to its end at ${width}px wide`, async ({ page }) => {
|
|
||||||
await page.setViewportSize({ width, height: 200 });
|
|
||||||
await page.goto(path);
|
|
||||||
|
|
||||||
const overflows = await page.evaluate(() => document.documentElement.scrollHeight > window.innerHeight);
|
|
||||||
expect(overflows, "the page must overflow, or it proves nothing").toBe(true);
|
|
||||||
await page.keyboard.press("End");
|
|
||||||
await expect(page.locator(tail).last()).toBeInViewport({ ratio: 1 });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Green only while #nav-toggle is position: fixed — a label tap focuses it, and focus scrolls into view.
|
|
||||||
test("closing the mobile drawer leaves the reader where the scrim found them", async ({ page }) => {
|
|
||||||
await page.setViewportSize({ width: 390, height: 200 });
|
|
||||||
await page.goto("/dashboard");
|
|
||||||
|
|
||||||
await page.locator(".hamburger").click();
|
|
||||||
await expect(page.locator("#nav-toggle")).toBeChecked();
|
|
||||||
// Scripted, because a key press with focus on the toggle does not scroll in every engine — and
|
|
||||||
// what is under test is closing the drawer, not how the reader got down the page.
|
|
||||||
await page.evaluate(() => window.scrollTo(0, 120));
|
|
||||||
const at = await page.evaluate(() => window.scrollY);
|
|
||||||
expect(at, "the page must have somewhere to scroll behind the scrim").toBeGreaterThan(0);
|
|
||||||
|
|
||||||
// The exposed strip beside the 264px panel: the scrim spans the viewport, so its centre is under
|
|
||||||
// the drawer and a centre click lands on the panel instead.
|
|
||||||
await page.locator(".scrim").click({ position: { x: 340, y: 100 } });
|
|
||||||
await expect(page.locator("#nav-toggle")).not.toBeChecked();
|
|
||||||
expect(await page.evaluate(() => window.scrollY), "closing the drawer must not move the page").toBe(at);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("captures the live pages for review", async ({ page }) => {
|
test("captures the live pages for review", async ({ page }) => {
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
await expect(page.locator(".sidebar")).toBeVisible();
|
await expect(page.locator(".sidebar")).toBeVisible();
|
||||||
@@ -182,11 +143,11 @@ test("unknown routes serve the 404 page (a real user-facing flow, covered end-to
|
|||||||
await expect(page.getByRole("link", { name: "Back home" })).toBeVisible();
|
await expect(page.getByRole("link", { name: "Back home" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
// The reference plugin (plugins/scheduling) ships discovered in the image, and shows all three
|
// The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is
|
||||||
// gates: the public Overview is reachable by anyone, My shifts takes any session, and the shifts
|
// reachable by anyone and its menu header shows for everyone; the shifts list stays permission-gated,
|
||||||
// list needs a permission. The authenticated list/form flow is the full E2E (full-flow.spec).
|
// so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the full
|
||||||
// Side-effect-free.
|
// E2E (full-flow.spec). Side-effect-free.
|
||||||
test("the reference plugin: public Overview is open to all, My shifts takes any session, the gated Shifts redirects to /login", async ({ page, request }) => {
|
test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login", async ({ page, request }) => {
|
||||||
// `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so these
|
// `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so these
|
||||||
// probes are genuinely anonymous.
|
// probes are genuinely anonymous.
|
||||||
// The public overview is reachable with no session (200), not bounced to sign in.
|
// The public overview is reachable with no session (200), not bounced to sign in.
|
||||||
@@ -205,23 +166,10 @@ test("the reference plugin: public Overview is open to all, My shifts takes any
|
|||||||
expect(res.status()).toBe(303);
|
expect(res.status()).toBe(303);
|
||||||
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
|
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
|
||||||
|
|
||||||
// A `session: true` route bounces an anonymous visitor the same way — no permission involved.
|
|
||||||
const mine = await request.get("/scheduling/mine", { maxRedirects: 0 });
|
|
||||||
expect(mine.status()).toBe(303);
|
|
||||||
expect(mine.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fmine");
|
|
||||||
|
|
||||||
// The signed-in member (no scheduling permission) sees the public Scheduling → Overview leaf in the nav,
|
// The signed-in member (no scheduling permission) sees the public Scheduling → Overview leaf in the nav,
|
||||||
// but the gated Shifts leaf is filtered out.
|
// but the gated Shifts leaf is filtered out.
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
|
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
|
||||||
await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown
|
await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown
|
||||||
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out
|
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out
|
||||||
await expect(page.locator('.sidebar a[href="/scheduling/mine"]')).toHaveCount(1); // session gate: a session is enough
|
|
||||||
|
|
||||||
// No shifts upstream on this stack, so this also pins the degraded page: the reason, never a 500
|
|
||||||
// and never a claim about what is assigned.
|
|
||||||
await page.goto("/scheduling/mine");
|
|
||||||
await expect(page.getByRole("heading", { name: "My shifts" })).toBeVisible();
|
|
||||||
await expect(page.getByText("Couldn't reach the scheduling service")).toBeVisible();
|
|
||||||
await expect(page.getByText("No shifts are assigned to")).toHaveCount(0);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -35,9 +35,10 @@ export function actionForMethod(method: string): AdminAction {
|
|||||||
return verb === "GET" || verb === "HEAD" ? "read" : "write";
|
return verb === "GET" || verb === "HEAD" ? "read" : "write";
|
||||||
}
|
}
|
||||||
|
|
||||||
// The plugin's nav fragment: the "Admin" header + its four screens, each gated on its own read
|
// The plugin's nav fragment: an ungated "Admin" header + its three screens, each gated on its own
|
||||||
// permission. composeNav drops a header left with no visible children, so a user holding none of
|
// read permission. The header carries no `permission` because a user may hold one screen's and not
|
||||||
// them never sees the section. The host current-marks the active item — no `current`/`open` here.
|
// another's; composeNav drops a header left with no visible children, so a user holding none of the
|
||||||
|
// three never sees the section. The host current-marks the active item — no `current`/`open` here.
|
||||||
export const ADMIN_NAV: NavNode = {
|
export const ADMIN_NAV: NavNode = {
|
||||||
children: [
|
children: [
|
||||||
{ 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") },
|
||||||
@@ -48,7 +49,6 @@ export const ADMIN_NAV: NavNode = {
|
|||||||
icon: "i-shield",
|
icon: "i-shield",
|
||||||
id: "admin",
|
id: "admin",
|
||||||
label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels
|
label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels
|
||||||
public: true, // the header gates nothing; every child needs a permission, and an empty header is dropped
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
|
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const clients = on("oauth2-clients");
|
|||||||
const pluginSettings = on("plugin-settings");
|
const pluginSettings = on("plugin-settings");
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.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],
|
||||||
|
|
||||||
|
|||||||
@@ -15,12 +15,8 @@ What it demonstrates:
|
|||||||
`POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream,
|
`POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream,
|
||||||
then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
|
then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
|
||||||
reusing the core `field` partial.
|
reusing the core `field` partial.
|
||||||
- **All three route gates** — the Overview is `public` (anyone), "My shifts" is `session` (any
|
- **Permission-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` /
|
||||||
signed-in visitor, showing only rows assigned to them), and "Shifts" is gated on `scheduling:read` /
|
`scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant.
|
||||||
`scheduling:write`; a leaf whose gate a visitor fails is invisible in the menu.
|
|
||||||
- **Ownership joined on the identity id** — "My shifts" asks the upstream for `assigneeId=ctx.user.id`,
|
|
||||||
the opaque subject id, and renders the row's separate `assignee` display name. An email address is
|
|
||||||
user-changeable and can be reassigned to someone else, who would then inherit those rows.
|
|
||||||
- **Its own translations** — every string comes from `i18n/en-US.ts` (`sv-SE.ts` beside it), including
|
- **Its own translations** — every string comes from `i18n/en-US.ts` (`sv-SE.ts` beside it), including
|
||||||
the nav labels, which are catalog keys in the manifest. `shifts.count` shows a plural message, and
|
the nav labels, which are catalog keys in the manifest. `shifts.count` shows a plural message, and
|
||||||
the views carry the visitor's language onto their links with `localeHref()`.
|
the views carry the visitor's language onto their links with `localeHref()`.
|
||||||
@@ -42,14 +38,9 @@ Your backend must expose two routes; the plugin treats any non-2xx as a recovera
|
|||||||
|
|
||||||
| Route | Request | Success | Response body |
|
| Route | Request | Success | Response body |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `GET /shifts` | `Accept: application/json`, optional `?assigneeId=<id>` | `200` | JSON array of `{ id, title, assignee, assigneeId, start, end }` (all strings; missing fields coerce to `""`). With `assigneeId`, only that person's rows |
|
| `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) |
|
| `POST /shifts` | JSON body `{ title, assignee, start, end }` | `2xx` | ignored (the plugin POST-redirect-GETs back to the list) |
|
||||||
|
|
||||||
`POST /shifts` carries the assignee as a **display name only**, so a shift created through this
|
|
||||||
plugin's form belongs to nobody and surfaces on no one's "My shifts" — don't go hunting for it
|
|
||||||
there. Resolving a name to an identity id needs a directory this demo has none of; a real backend
|
|
||||||
does that join at create time and stores the `assigneeId` alongside the name.
|
|
||||||
|
|
||||||
Domain rules (overlap, capacity, time ordering) live in your backend — reject with a 4xx and the
|
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.
|
form re-renders. The plugin only validates that `title` and `assignee` are non-empty.
|
||||||
|
|
||||||
@@ -59,8 +50,6 @@ cosmetically) — normalise to your backend's format there if it matters.
|
|||||||
|
|
||||||
## Granting access
|
## Granting access
|
||||||
|
|
||||||
A user sees the shift list once they hold the `scheduling:read` permission in Keto (and
|
A user sees Scheduling once they hold the `scheduling:read` permission in Keto (and `scheduling:write`
|
||||||
`scheduling:write` to create). The one-command bootstrap grants both to the demo admin, so the seeded
|
to create). The one-command bootstrap grants both to the demo admin, so the seeded
|
||||||
`admin@plainpages.local` can use it immediately. "My shifts" needs no grant at all — signing in is
|
`admin@plainpages.local` can use it immediately.
|
||||||
its whole gate; it lists the rows this demo upstream holds against the signed-in visitor's id, and
|
|
||||||
the demo's seeded rows belong to three made-up people, so a freshly seeded admin sees it empty.
|
|
||||||
|
|||||||
@@ -13,16 +13,12 @@ const messages = {
|
|||||||
"scheduling.filter.searchLabel": "Search shifts",
|
"scheduling.filter.searchLabel": "Search shifts",
|
||||||
"scheduling.filter.searchPlaceholder": "Search title or assignee…",
|
"scheduling.filter.searchPlaceholder": "Search title or assignee…",
|
||||||
"scheduling.form.submit": "Create shift",
|
"scheduling.form.submit": "Create shift",
|
||||||
"scheduling.mine.empty": "No shifts are assigned to {{email}}.",
|
|
||||||
"scheduling.mine.title": "My shifts",
|
|
||||||
"scheduling.nav.mine": "My shifts",
|
|
||||||
"scheduling.nav.overview": "Overview",
|
"scheduling.nav.overview": "Overview",
|
||||||
"scheduling.nav.section": "Scheduling",
|
"scheduling.nav.section": "Scheduling",
|
||||||
"scheduling.nav.shifts": "Shifts",
|
"scheduling.nav.shifts": "Shifts",
|
||||||
"scheduling.new.title": "New shift",
|
"scheduling.new.title": "New shift",
|
||||||
"scheduling.overview.lead":
|
"scheduling.overview.lead":
|
||||||
"Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.",
|
"Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.",
|
||||||
"scheduling.overview.mine": "See my shifts",
|
|
||||||
"scheduling.overview.signIn": "Sign in to view shifts",
|
"scheduling.overview.signIn": "Sign in to view shifts",
|
||||||
"scheduling.overview.title": "Scheduling",
|
"scheduling.overview.title": "Scheduling",
|
||||||
"scheduling.overview.view": "View shifts",
|
"scheduling.overview.view": "View shifts",
|
||||||
|
|||||||
@@ -9,16 +9,12 @@ const messages: SchedulingMessages = {
|
|||||||
"scheduling.filter.searchLabel": "Sök pass",
|
"scheduling.filter.searchLabel": "Sök pass",
|
||||||
"scheduling.filter.searchPlaceholder": "Sök på namn eller person…",
|
"scheduling.filter.searchPlaceholder": "Sök på namn eller person…",
|
||||||
"scheduling.form.submit": "Skapa pass",
|
"scheduling.form.submit": "Skapa pass",
|
||||||
"scheduling.mine.empty": "Inga pass är tilldelade {{email}}.",
|
|
||||||
"scheduling.mine.title": "Mina pass",
|
|
||||||
"scheduling.nav.mine": "Mina pass",
|
|
||||||
"scheduling.nav.overview": "Översikt",
|
"scheduling.nav.overview": "Översikt",
|
||||||
"scheduling.nav.section": "Schemaläggning",
|
"scheduling.nav.section": "Schemaläggning",
|
||||||
"scheduling.nav.shifts": "Pass",
|
"scheduling.nav.shifts": "Pass",
|
||||||
"scheduling.new.title": "Nytt pass",
|
"scheduling.new.title": "Nytt pass",
|
||||||
"scheduling.overview.lead":
|
"scheduling.overview.lead":
|
||||||
"Schemaläggningen samordnar teamets pass. Alla kan läsa den här översikten; själva passlistan kräver behörigheten <code>scheduling:read</code>.",
|
"Schemaläggningen samordnar teamets pass. Alla kan läsa den här översikten; själva passlistan kräver behörigheten <code>scheduling:read</code>.",
|
||||||
"scheduling.overview.mine": "Visa mina pass",
|
|
||||||
"scheduling.overview.signIn": "Logga in för att se passen",
|
"scheduling.overview.signIn": "Logga in för att se passen",
|
||||||
"scheduling.overview.title": "Schemaläggning",
|
"scheduling.overview.title": "Schemaläggning",
|
||||||
"scheduling.overview.view": "Visa pass",
|
"scheduling.overview.view": "Visa pass",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// 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 { createShift, createUpstream, listShifts, MINE_PATH, myShifts, 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). Its URL is a declared setting, so it is resolved and validated before onBoot hands it
|
// stateless). Its URL is a declared setting, so it is resolved and validated before onBoot hands it
|
||||||
@@ -12,7 +12,7 @@ let upstreamUrl = "";
|
|||||||
const upstream = createUpstream(() => upstreamUrl);
|
const upstream = createUpstream(() => upstreamUrl);
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.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 — where a plugin receives its resolved
|
// onBoot runs after discovery, before the server listens — where a plugin receives its resolved
|
||||||
// settings. A malformed URL already failed the boot by then; the host validated the declared type.
|
// settings. A malformed URL already failed the boot by then; the host validated the declared type.
|
||||||
@@ -25,13 +25,11 @@ export default definePlugin({
|
|||||||
nav: [{
|
nav: [{
|
||||||
children: [
|
children: [
|
||||||
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "scheduling.nav.overview", public: true },
|
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "scheduling.nav.overview", public: true },
|
||||||
{ href: MINE_PATH, id: "scheduling:mine", label: "scheduling.nav.mine", session: true },
|
|
||||||
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "scheduling.nav.shifts", permission: READ },
|
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "scheduling.nav.shifts", permission: READ },
|
||||||
],
|
],
|
||||||
icon: "i-cal",
|
icon: "i-cal",
|
||||||
id: "scheduling",
|
id: "scheduling",
|
||||||
label: "scheduling.nav.section",
|
label: "scheduling.nav.section",
|
||||||
public: true, // the header gates nothing; each child names its own gate, and an empty header is dropped
|
|
||||||
}],
|
}],
|
||||||
|
|
||||||
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
|
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
|
||||||
@@ -40,9 +38,10 @@ export default definePlugin({
|
|||||||
{ description: "Create and edit shifts", name: WRITE },
|
{ description: "Create and edit shifts", name: WRITE },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Mounted under /scheduling; `permission` gates before the handler runs. The overview is `public`
|
||||||
|
// (anyone may reach /scheduling, signed in or not); the rest need a permission.
|
||||||
routes: [
|
routes: [
|
||||||
{ handler: overview(), method: "GET", path: "/", public: true },
|
{ handler: overview(), method: "GET", path: "/", public: true },
|
||||||
{ handler: myShifts(upstream), method: "GET", path: "/mine", session: true },
|
|
||||||
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
|
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
|
||||||
{ 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 },
|
||||||
|
|||||||
@@ -4,29 +4,29 @@ import { Readable } from "node:stream";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
// Import only from the @plainpages/plugin-api barrel — the same contract boundary shifts.ts uses (the host may
|
// Import only from the @plainpages/plugin-api barrel — the same contract boundary shifts.ts uses (the host may
|
||||||
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
|
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
|
||||||
import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult, type User } 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 {
|
||||||
buildFormModel, createShift, createUpstream, listShifts, myShifts, 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";
|
||||||
|
|
||||||
const t = englishTranslator(enUS); // this plugin's catalog then the host's, as the host would chain them
|
const t = englishTranslator(enUS); // this plugin's catalog then the host's, as the host would chain them
|
||||||
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
|
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
|
||||||
|
|
||||||
function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; user?: User; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||||
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: [], declaredSettings: [], user: opts.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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const SHIFTS: Shift[] = [
|
const SHIFTS: Shift[] = [
|
||||||
{ assignee: "Avery Kline", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", end: "12:00", id: "1", start: "08:00", title: "Morning desk" },
|
{ assignee: "Avery Kline", end: "12:00", id: "1", start: "08:00", title: "Morning desk" },
|
||||||
{ assignee: "Blair Mora", assigneeId: "019bdc1a-4a83-7de2-8f05-6c93a71be4d8", end: "17:00", id: "2", start: "12:00", title: "Afternoon support" },
|
{ assignee: "Blair Mora", end: "17:00", id: "2", start: "12:00", title: "Afternoon support" },
|
||||||
];
|
];
|
||||||
const fakeUpstream = (over: Partial<ShiftsUpstream> = {}): ShiftsUpstream => ({ create: async () => {}, list: async () => SHIFTS, ...over });
|
const fakeUpstream = (over: Partial<ShiftsUpstream> = {}): ShiftsUpstream => ({ create: async () => {}, list: async () => SHIFTS, ...over });
|
||||||
|
|
||||||
@@ -63,11 +63,11 @@ test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", as
|
|||||||
const http = (async (url, init) => {
|
const http = (async (url, init) => {
|
||||||
seen = String(url);
|
seen = String(url);
|
||||||
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", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", 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", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", 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 () => {
|
||||||
@@ -115,21 +115,14 @@ test("listShifts degrades to a recoverable error page when the upstream is down
|
|||||||
|
|
||||||
// ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ----
|
// ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ----
|
||||||
|
|
||||||
test("overview renders a public page for anyone, and its CTA names the best gate the visitor passes", async () => {
|
test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
|
||||||
const anon = asView(await overview()(fakeCtx())); // user null, no permissions
|
const anon = asView(await overview()(fakeCtx())); // user null, no permissions
|
||||||
assert.equal(anon.view, "overview");
|
assert.equal(anon.view, "overview");
|
||||||
assert.equal(anon.data["chrome"], CHROME);
|
assert.equal(anon.data["chrome"], CHROME);
|
||||||
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
|
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
|
||||||
assert.equal(anon.data["signedIn"], false);
|
|
||||||
|
|
||||||
const reader = asView(await overview()(fakeCtx({ permissions: ["scheduling:read"] })));
|
const reader = asView(await overview()(fakeCtx({ permissions: ["scheduling:read"] })));
|
||||||
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
|
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
|
||||||
|
|
||||||
// Signed in but ungranted: the page must not invite them to sign in again.
|
|
||||||
const member = asView(await overview()(fakeCtx({ user: { email: "m@example.test", id: "01a06091-baa3-7a1f-9c62-0e3ab6d2f5c1", permissions: [] } })));
|
|
||||||
assert.equal(member.data["canRead"], false);
|
|
||||||
assert.equal(member.data["signedIn"], true);
|
|
||||||
assert.equal(member.data["mineHref"], "/scheduling/mine");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- create handler ----
|
// ---- create handler ----
|
||||||
@@ -178,38 +171,3 @@ test("buildFormModel marks title/assignee required and attaches field errors", (
|
|||||||
assert.equal(title.error, "needed");
|
assert.equal(title.error, "needed");
|
||||||
assert.equal(fields.find((f) => f.name === "start")!.required, undefined);
|
assert.equal(fields.find((f) => f.name === "start")!.required, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- the session-gated page: the visitor's own rows ----
|
|
||||||
|
|
||||||
test("my shifts scopes the upstream read by the visitor's id, and names them in the empty state", async () => {
|
|
||||||
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
|
|
||||||
const mine: Shift = { assignee: "Blair Mora", assigneeId: user.id, end: "22:00", id: "3", start: "17:00", title: "Evening on-call" };
|
|
||||||
let asked: { assigneeId?: string } | undefined;
|
|
||||||
|
|
||||||
const upstream = fakeUpstream({ list: async (opts) => { asked = opts; return [mine]; } });
|
|
||||||
const r = asView(await myShifts(upstream)(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
|
|
||||||
assert.equal(r.view, "mine");
|
|
||||||
assert.deepEqual(asked, { assigneeId: "01a06091-baa3-71f4-a068-4879972979ff" }); // the id, never the address
|
|
||||||
const table = r.data["table"] as { emptyText: string; rows: { name: string }[] };
|
|
||||||
assert.deepEqual(table.rows.map((row) => row.name), ["Evening on-call"]);
|
|
||||||
assert.match(table.emptyText, /Blair\.Mora@example\.test/); // an empty page still says whose it is
|
|
||||||
|
|
||||||
// `requireSession` narrows `ctx.user` from `User | null` to `User` — the one part of the route's
|
|
||||||
// `session: true` guarantee the contract cannot state in the handler's type.
|
|
||||||
await assert.rejects(async () => { await myShifts(fakeUpstream())(fakeCtx()); }, GuardError);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("my shifts degrades to the reason alone when the upstream is down, claiming nothing about what is assigned", async () => {
|
|
||||||
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
|
|
||||||
const down = fakeUpstream({ list: async () => { throw new UpstreamError("down", 503); } });
|
|
||||||
const r = asView(await myShifts(down)(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
|
|
||||||
assert.match(String(r.data["error"]), /scheduling service/i);
|
|
||||||
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []); // mine.ejs drops the count + table while `error` is set
|
|
||||||
});
|
|
||||||
|
|
||||||
test("my shifts drops a row the upstream returned that is not the visitor's", async () => {
|
|
||||||
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
|
|
||||||
const theirs: Shift = { assignee: "Avery Kline", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", end: "12:00", id: "9", start: "08:00", title: "Not mine" };
|
|
||||||
const r = asView(await myShifts(fakeUpstream({ list: async () => [theirs] }))(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
|
|
||||||
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []); // a backend ignoring the scope must not leak through this page
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// pure functions against a mock upstream with no network (README.md → Local dev & test story).
|
// pure functions against a mock upstream with no network (README.md → Local dev & test story).
|
||||||
|
|
||||||
// One import from the host's @plainpages/plugin-api barrel — the stable author surface (see README.md → Building plugins).
|
// One import from the host's @plainpages/plugin-api barrel — the stable author surface (see README.md → Building plugins).
|
||||||
import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, requireSession, type RouteHandler, type Translate, tracedFetch } from "@plainpages/plugin-api";
|
import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, type Translate, tracedFetch } from "@plainpages/plugin-api";
|
||||||
import enUS from "./i18n/en-US.ts";
|
import enUS from "./i18n/en-US.ts";
|
||||||
|
|
||||||
// The plugin's own English (its catalog, then the host's), for a view model built outside a request:
|
// The plugin's own English (its catalog, then the host's), for a view model built outside a request:
|
||||||
@@ -16,14 +16,12 @@ const EN: Translate = englishTranslator(enUS);
|
|||||||
|
|
||||||
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
|
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
|
||||||
export const SHIFTS_PATH = "/scheduling/shifts";
|
export const SHIFTS_PATH = "/scheduling/shifts";
|
||||||
export const MINE_PATH = "/scheduling/mine"; // the visitor's own shifts — a session is the whole gate
|
|
||||||
export const READ = "scheduling:read"; // the permission gating the list + nav
|
export const READ = "scheduling:read"; // the permission gating the list + nav
|
||||||
export const WRITE = "scheduling:write"; // the permission gating create
|
export const WRITE = "scheduling:write"; // the permission gating create
|
||||||
|
|
||||||
export interface Shift {
|
export interface Shift {
|
||||||
id: string;
|
id: string;
|
||||||
assignee: string; // display name, rendered in the table
|
assignee: string;
|
||||||
assigneeId: string; // who the shift belongs to — an opaque id, the same one `ctx.user.id` carries
|
|
||||||
end: string;
|
end: string;
|
||||||
start: string;
|
start: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -48,9 +46,7 @@ export class UpstreamError extends Error {
|
|||||||
|
|
||||||
export interface ShiftsUpstream {
|
export interface ShiftsUpstream {
|
||||||
create(input: ShiftInput): Promise<void>;
|
create(input: ShiftInput): Promise<void>;
|
||||||
// `assigneeId` scopes the read at the source, which is where an ownership rule belongs (README →
|
list(): Promise<Shift[]>;
|
||||||
// Three tiers of "may I?"); without it the caller would hold everyone's rows to render one page.
|
|
||||||
list(opts?: { assigneeId?: string }): Promise<Shift[]>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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`
|
||||||
@@ -69,9 +65,8 @@ export function createUpstream(baseUrl: () => string, fetchImpl: typeof fetch =
|
|||||||
});
|
});
|
||||||
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(opts = {}) {
|
async list() {
|
||||||
const query = opts.assigneeId == null ? "" : `?${new URLSearchParams({ assigneeId: opts.assigneeId })}`;
|
const res = await fetchImpl(`${base()}/shifts`, { headers: { accept: "application/json" } });
|
||||||
const res = await fetchImpl(`${base()}/shifts${query}`, { 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) : [];
|
||||||
@@ -83,7 +78,7 @@ const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? ""
|
|||||||
|
|
||||||
function toShift(raw: unknown): Shift {
|
function toShift(raw: unknown): Shift {
|
||||||
const r = (raw ?? {}) as Record<string, unknown>;
|
const r = (raw ?? {}) as Record<string, unknown>;
|
||||||
return { assignee: str(r["assignee"]), assigneeId: str(r["assigneeId"]), end: str(r["end"]), id: str(r["id"]), start: str(r["start"]), title: str(r["title"]) };
|
return { assignee: str(r["assignee"]), end: str(r["end"]), id: str(r["id"]), start: str(r["start"]), title: str(r["title"]) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- view models (pure; the EJS views read these) -----------------------------------
|
// ---- view models (pure; the EJS views read these) -----------------------------------
|
||||||
@@ -191,41 +186,6 @@ export function newShiftForm(): RouteHandler {
|
|||||||
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome, t: ctx.t }), view: "shift-new" });
|
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome, t: ctx.t }), view: "shift-new" });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function myShifts(upstream: ShiftsUpstream): RouteHandler {
|
|
||||||
return async (ctx) => {
|
|
||||||
const user = requireSession(ctx);
|
|
||||||
let shifts: Shift[] = [];
|
|
||||||
let error: string | undefined;
|
|
||||||
try {
|
|
||||||
// Join on the id, never the email: an address is user-changeable and can be reassigned to
|
|
||||||
// someone else, which would hand them the previous holder's rows. The re-filter is
|
|
||||||
// defence-in-depth: a backend that ignores an unknown query param would answer with everyone.
|
|
||||||
shifts = (await upstream.list({ assigneeId: user.id })).filter((s) => s.assigneeId === user.id);
|
|
||||||
} catch (err) {
|
|
||||||
ctx.log.warn("scheduling upstream unreachable", { error: String(err) });
|
|
||||||
error = ctx.t("scheduling.upstream.list");
|
|
||||||
}
|
|
||||||
return { data: buildMineModel({ chrome: ctx.chrome, email: user.email, ...(error ? { error } : {}), shifts, t: ctx.t }), view: "mine" };
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildMineModel(opts: { chrome: PageChrome; email: string; error?: string; shifts: Shift[]; t?: Translate }) {
|
|
||||||
const t = opts.t ?? EN;
|
|
||||||
return {
|
|
||||||
breadcrumbs: [{ label: t("scheduling.mine.title") }],
|
|
||||||
chrome: opts.chrome,
|
|
||||||
count: t("scheduling.shifts.count", { count: opts.shifts.length }),
|
|
||||||
...(opts.error ? { error: opts.error } : {}),
|
|
||||||
table: {
|
|
||||||
caption: t("scheduling.mine.title"),
|
|
||||||
columns: [{ label: t("scheduling.table.shift") }, { label: t("scheduling.table.start") }, { label: t("scheduling.table.end") }],
|
|
||||||
emptyText: t("scheduling.mine.empty", { email: opts.email }),
|
|
||||||
rows: opts.shifts.map((s) => ({ cells: [{ rowHeader: { text: s.title } }, s.start, s.end], name: s.title })),
|
|
||||||
},
|
|
||||||
title: t("scheduling.mine.title"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
|
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
|
||||||
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data
|
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data
|
||||||
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
|
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
|
||||||
@@ -236,9 +196,7 @@ export function overview(): RouteHandler {
|
|||||||
breadcrumbs: [{ label: ctx.t("scheduling.nav.overview") }],
|
breadcrumbs: [{ label: ctx.t("scheduling.nav.overview") }],
|
||||||
canRead: can(ctx, READ),
|
canRead: can(ctx, READ),
|
||||||
chrome: ctx.chrome,
|
chrome: ctx.chrome,
|
||||||
mineHref: ctx.localeHref(MINE_PATH),
|
|
||||||
shiftsHref: ctx.localeHref(SHIFTS_PATH), // a plugin carries the visitor's locale onto its own links
|
shiftsHref: ctx.localeHref(SHIFTS_PATH), // a plugin carries the visitor's locale onto its own links
|
||||||
signedIn: ctx.user !== null,
|
|
||||||
signInHref: ctx.localeHref(`/login?return_to=${encodeURIComponent(ctx.localeHref(SHIFTS_PATH))}`),
|
signInHref: ctx.localeHref(`/login?return_to=${encodeURIComponent(ctx.localeHref(SHIFTS_PATH))}`),
|
||||||
title: ctx.t("scheduling.overview.title"),
|
title: ctx.t("scheduling.overview.title"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
<%#
|
|
||||||
Scheduling · the visitor's own shifts (reference plugin).
|
|
||||||
Data: chrome, title, breadcrumbs, count, table, error?
|
|
||||||
%><%
|
|
||||||
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
|
||||||
const tableHtml = include("partials/data-table", table);
|
|
||||||
const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : "";
|
|
||||||
-%>
|
|
||||||
<%- include("partials/shell", {
|
|
||||||
body: '<div class="scheduling-page">' + alertHtml + (locals.error ? '' : '<p class="shift-count">' + count + '</p>' + tableHtml) + '</div>',
|
|
||||||
brand: chrome.brand,
|
|
||||||
breadcrumbs,
|
|
||||||
csrfToken: chrome.csrfToken,
|
|
||||||
nav: navHtml,
|
|
||||||
styles: ["/public/scheduling/scheduling.css"],
|
|
||||||
theme: chrome.theme,
|
|
||||||
title,
|
|
||||||
user: chrome.user,
|
|
||||||
}) %>
|
|
||||||
@@ -3,16 +3,12 @@
|
|||||||
nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for
|
nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for
|
||||||
everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to
|
everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to
|
||||||
it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome.
|
it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome.
|
||||||
Data: chrome, title, breadcrumbs, canRead, mineHref, shiftsHref, signedIn, signInHref
|
Data: chrome, title, breadcrumbs, canRead, shiftsHref, signInHref
|
||||||
%><%
|
%><%
|
||||||
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
// One CTA per gate the visitor passes: the list needs the permission, "My shifts" only a session,
|
|
||||||
// and sign-in is offered to nobody who already has one.
|
|
||||||
const cta = canRead
|
const cta = canRead
|
||||||
? '<a class="btn btn-primary" href="' + shiftsHref + '">' + t("scheduling.overview.view") + '</a>'
|
? '<a class="btn btn-primary" href="' + shiftsHref + '">' + t("scheduling.overview.view") + '</a>'
|
||||||
: signedIn
|
: '<a class="btn btn-primary" href="' + signInHref + '">' + t("scheduling.overview.signIn") + '</a>';
|
||||||
? '<a class="btn btn-primary" href="' + mineHref + '">' + t("scheduling.overview.mine") + '</a>'
|
|
||||||
: '<a class="btn btn-primary" href="' + signInHref + '">' + t("scheduling.overview.signIn") + '</a>';
|
|
||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
actions: "",
|
actions: "",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// of the app: stdlib only, in-memory (state resets on restart), no auth. Point PLUGIN_SETTING_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, assigneeId, assignee, start, end }, … ] (?assigneeId=<id> → only theirs)
|
// GET /shifts → 200 [ { id, title, assignee, start, end }, … ]
|
||||||
// POST /shifts → 201 { id, … } (body: { title, assignee, start, end })
|
// POST /shifts → 201 { id, … } (body: { title, assignee, start, end })
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
@@ -11,12 +11,10 @@ import { createServer } from "node:http";
|
|||||||
|
|
||||||
const PORT = Number(process.env.PORT ?? 4000);
|
const PORT = Number(process.env.PORT ?? 4000);
|
||||||
|
|
||||||
// `assigneeId` is the identity the rows are owned by — an opaque, stable subject id, which is what
|
|
||||||
// `ctx.user.id` carries. These are this demo's own people; a real backend joins on your IdP's ids.
|
|
||||||
const shifts = [
|
const shifts = [
|
||||||
{ id: randomUUID(), title: "Morning — Front desk", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", assignee: "Avery Kline", start: "2026-06-22 08:00", end: "2026-06-22 12:00" },
|
{ id: randomUUID(), title: "Morning — Front desk", assignee: "Avery Kline", start: "2026-06-22 08:00", end: "2026-06-22 12:00" },
|
||||||
{ id: randomUUID(), title: "Afternoon — Support", assigneeId: "019bdc1a-4a83-7de2-8f05-6c93a71be4d8", assignee: "Blair Mora", start: "2026-06-22 12:00", end: "2026-06-22 17:00" },
|
{ id: randomUUID(), title: "Afternoon — Support", assignee: "Blair Mora", start: "2026-06-22 12:00", end: "2026-06-22 17:00" },
|
||||||
{ id: randomUUID(), title: "Evening — On-call", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", assignee: "Casey Nguyen", start: "2026-06-22 17:00", end: "2026-06-22 22:00" },
|
{ id: randomUUID(), title: "Evening — On-call", assignee: "Casey Nguyen", start: "2026-06-22 17:00", end: "2026-06-22 22:00" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const json = (res, status, body) => {
|
const json = (res, status, body) => {
|
||||||
@@ -35,14 +33,10 @@ const readBody = (req) =>
|
|||||||
|
|
||||||
createServer(async (req, res) => {
|
createServer(async (req, res) => {
|
||||||
const url = new URL(req.url ?? "/", "http://localhost");
|
const url = new URL(req.url ?? "/", "http://localhost");
|
||||||
if (url.pathname === "/shifts" && req.method === "GET") {
|
if (url.pathname === "/shifts" && req.method === "GET") return json(res, 200, shifts);
|
||||||
const assigneeId = url.searchParams.get("assigneeId");
|
|
||||||
if (assigneeId === null) return json(res, 200, shifts);
|
|
||||||
return json(res, 200, shifts.filter((s) => s.assigneeId === assigneeId));
|
|
||||||
}
|
|
||||||
if (url.pathname === "/shifts" && req.method === "POST") {
|
if (url.pathname === "/shifts" && req.method === "POST") {
|
||||||
const b = await readBody(req);
|
const b = await readBody(req);
|
||||||
const shift = { id: randomUUID(), assignee: String(b.assignee ?? ""), assigneeId: "", end: String(b.end ?? ""), start: String(b.start ?? ""), title: String(b.title ?? "") };
|
const shift = { id: randomUUID(), assignee: String(b.assignee ?? ""), end: String(b.end ?? ""), start: String(b.start ?? ""), title: String(b.title ?? "") };
|
||||||
shifts.push(shift);
|
shifts.push(shift);
|
||||||
return json(res, 201, shift);
|
return json(res, 201, shift);
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+8
-8
@@ -8,12 +8,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@larvit/log": "2.3.0",
|
"@larvit/log": "2.3.0",
|
||||||
"ejs": "6.0.1",
|
"ejs": "6.0.1",
|
||||||
"lucide-static": "1.45.0",
|
"lucide-static": "1.39.0",
|
||||||
"postgres": "3.4.9"
|
"postgres": "3.4.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ejs": "3.1.5",
|
"@types/ejs": "3.1.5",
|
||||||
"@types/node": "24.13.4",
|
"@types/node": "24.13.3",
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -37,9 +37,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.13.4",
|
"version": "24.13.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||||
"integrity": "sha512-YJ7EqCstVTzIr0fMr7qul/977en+pQHrfmuKIo6Zr9i75Be21dr3MovcfvGtyvi2HAUrRerWps5sMO9I7WaxDw==",
|
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -399,9 +399,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-static": {
|
"node_modules/lucide-static": {
|
||||||
"version": "1.45.0",
|
"version": "1.39.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.45.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.39.0.tgz",
|
||||||
"integrity": "sha512-MaMkcTVobsAlkat8/AsLRgZHeni3/UWHolsAZRazod6/nnBTlhHaS6xlSi+Xh1Y0I1Gf68YHUVXuJe9Up3lZww==",
|
"integrity": "sha512-TaDiwNFZ78c+HrZRNWHoR+LVkIOL8ebYOyNYvytdPkaLWE29G9umks6p1aeCE8kSEIoifnlNLPxMvvE3zqT/YA==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/postgres": {
|
"node_modules/postgres": {
|
||||||
|
|||||||
+2
-2
@@ -18,12 +18,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@larvit/log": "2.3.0",
|
"@larvit/log": "2.3.0",
|
||||||
"ejs": "6.0.1",
|
"ejs": "6.0.1",
|
||||||
"lucide-static": "1.45.0",
|
"lucide-static": "1.39.0",
|
||||||
"postgres": "3.4.9"
|
"postgres": "3.4.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ejs": "3.1.5",
|
"@types/ejs": "3.1.5",
|
||||||
"@types/node": "24.13.4",
|
"@types/node": "24.13.3",
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ html:has(#theme-light:checked) {
|
|||||||
|
|
||||||
/* ---------- 2. RESET ---------------------------------------- */
|
/* ---------- 2. RESET ---------------------------------------- */
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
body { margin: 0; background: var(--bg); color: var(--text);
|
body { margin: 0; background: var(--bg); color: var(--text);
|
||||||
-webkit-font-smoothing: antialiased; }
|
-webkit-font-smoothing: antialiased; }
|
||||||
button { font: inherit; color: inherit; }
|
button { font: inherit; color: inherit; }
|
||||||
@@ -150,7 +151,8 @@ summary { list-style: none; cursor: pointer; }
|
|||||||
.app {
|
.app {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--nav-w) minmax(0, 1fr);
|
grid-template-columns: var(--nav-w) minmax(0, 1fr);
|
||||||
min-height: 100dvh;
|
height: 100dvh;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 4. SIDEBAR -------------------------------------- */
|
/* ---------- 4. SIDEBAR -------------------------------------- */
|
||||||
@@ -158,6 +160,7 @@ summary { list-style: none; cursor: pointer; }
|
|||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
@@ -328,7 +331,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
.content {
|
.content {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0; min-height: 0;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,12 +559,13 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
.pill-clear:hover { text-decoration: underline; }
|
.pill-clear:hover { text-decoration: underline; }
|
||||||
|
|
||||||
/* ---------- 9. TABLE --------------------------------------- */
|
/* ---------- 9. TABLE --------------------------------------- */
|
||||||
.table-wrap { overflow-x: auto; }
|
.table-wrap { flex: 1 1 auto; min-height: 0; overflow: auto; }
|
||||||
table.table {
|
table.table {
|
||||||
width: 100%; border-collapse: separate; border-spacing: 0;
|
width: 100%; border-collapse: separate; border-spacing: 0;
|
||||||
font-size: var(--fz); font-variant-numeric: tabular-nums;
|
font-size: var(--fz); font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
.table thead th {
|
.table thead th {
|
||||||
|
position: sticky; top: 0; z-index: 10;
|
||||||
background: var(--surface-3);
|
background: var(--surface-3);
|
||||||
border-bottom: 1px solid var(--border-2);
|
border-bottom: 1px solid var(--border-2);
|
||||||
color: var(--text-muted); font-weight: 600; font-size: var(--fz-xs);
|
color: var(--text-muted); font-weight: 600; font-size: var(--fz-xs);
|
||||||
@@ -693,7 +697,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* the nav-toggle checkbox itself is visually hidden but focusable */
|
/* the nav-toggle checkbox itself is visually hidden but focusable */
|
||||||
#nav-toggle { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
|
#nav-toggle { position: absolute; opacity: 0; pointer-events: none; }
|
||||||
|
|
||||||
/* admin forms: create/edit user, account actions */
|
/* admin forms: create/edit user, account actions */
|
||||||
.form-page { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
|
.form-page { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
|
||||||
@@ -718,6 +722,5 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
|||||||
/* Chromeless shell: a page may drop the sidebar for a focused single column. */
|
/* Chromeless shell: a page may drop the sidebar for a focused single column. */
|
||||||
.app-bare { grid-template-columns: minmax(0, 1fr); }
|
.app-bare { grid-template-columns: minmax(0, 1fr); }
|
||||||
.app-bare .content { grid-column: 1; }
|
.app-bare .content { grid-column: 1; }
|
||||||
|
|
||||||
/* Auth/landing rendered inside the app shell: a roomy, centered column in the content area. */
|
/* Auth/landing rendered inside the app shell: a roomy, centered column in the content area. */
|
||||||
.shell-auth { flex: 1 1 auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; }
|
.shell-auth { flex: 1 1 auto; overflow-y: auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; }
|
||||||
|
|||||||
@@ -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.4.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", () => {
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ services:
|
|||||||
|
|
||||||
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025
|
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025
|
||||||
mailpit:
|
mailpit:
|
||||||
image: axllent/mailpit:v1.31.1
|
image: axllent/mailpit:v1.31.0
|
||||||
ports:
|
ports:
|
||||||
- "8025:8025"
|
- "8025:8025"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -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.4.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>" }) },
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { test } from "node:test";
|
|
||||||
import type { User } from "../http/context.ts";
|
|
||||||
import { allows, gatesSet } from "./gate.ts";
|
|
||||||
|
|
||||||
const holder: User = { email: "holder@example.test", id: "01a06091-ba9f-765f-abf4-b5144c314bc7", permissions: ["x:read"] };
|
|
||||||
const stranger: User = { email: "stranger@example.test", id: "01a06091-baa3-7b4d-810a-c9ee7e559d98", permissions: [] };
|
|
||||||
|
|
||||||
test("allows: ungated and public are open to anyone; session needs a user; permission needs the token", () => {
|
|
||||||
assert.equal(allows({}, null), true);
|
|
||||||
assert.equal(allows({ public: true }, null), true);
|
|
||||||
|
|
||||||
assert.equal(allows({ session: true }, null), false);
|
|
||||||
assert.equal(allows({ session: true }, stranger), true); // signed in is the whole gate — no grant
|
|
||||||
|
|
||||||
assert.equal(allows({ permission: "x:read" }, null), false);
|
|
||||||
assert.equal(allows({ permission: "x:read" }, stranger), false);
|
|
||||||
assert.equal(allows({ permission: "x:read" }, holder), true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("gatesSet names the gates a declaration sets, so discovery can refuse more than one", () => {
|
|
||||||
assert.deepEqual(gatesSet({}), []);
|
|
||||||
assert.deepEqual(gatesSet({ session: true }), ["session"]);
|
|
||||||
assert.deepEqual(gatesSet({ permission: "x:read", public: true }), ["public", "permission"]);
|
|
||||||
assert.deepEqual(gatesSet({ permission: "x:read", public: true, session: true }), ["public", "session", "permission"]);
|
|
||||||
// Only `true` sets a gate, so a manifest spelling one `false` names none — which discovery refuses.
|
|
||||||
assert.deepEqual(gatesSet({ public: false, session: false }), []);
|
|
||||||
});
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
// One home for the gate rule, so the router and the menu can never disagree about what a visitor
|
|
||||||
// may reach. README → Public pages & menu items.
|
|
||||||
import type { User } from "../http/context.ts";
|
|
||||||
|
|
||||||
const GATES = ["public", "session", "permission"] as const;
|
|
||||||
|
|
||||||
export interface Gate {
|
|
||||||
permission?: string; // the Keto Permission the caller must hold, `<resource>:<action>`
|
|
||||||
public?: boolean; // anyone, signed in or not
|
|
||||||
session?: boolean; // any signed-in user, no grant to hold; anonymous is sent to /login
|
|
||||||
}
|
|
||||||
|
|
||||||
export function allows(gate: Gate, user: User | null): boolean {
|
|
||||||
if (gate.public === true) return true;
|
|
||||||
if (gate.session === true) return user !== null;
|
|
||||||
return gate.permission == null || (user?.permissions.includes(gate.permission) ?? false);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function gatesSet(gate: Gate | null | undefined): string[] {
|
|
||||||
if (gate == null) return [];
|
|
||||||
return GATES.filter((name) => (name === "permission" ? gate.permission != null : gate[name] === true));
|
|
||||||
}
|
|
||||||
+2
-14
@@ -92,24 +92,12 @@ test("completeLogin returns null and touches nothing when there is no active ses
|
|||||||
assert.equal(touched, false);
|
assert.equal(touched, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("completeLogin throws if the tokenizer yields no JWT", async () => {
|
test("completeLogin maps a missing email trait to null and throws if the tokenizer yields no JWT", async () => {
|
||||||
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
const identity: Identity = { id: ID, traits: {} };
|
||||||
const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity }) as Session }); // never returns a tokenized JWT
|
const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity }) as Session }); // never returns a tokenized JWT
|
||||||
await assert.rejects(completeLogin({ keto: ketoStub(), kratosAdmin: adminStub(), kratosPublic }, "c"), /tokenizer returned no JWT/);
|
await assert.rejects(completeLogin({ keto: ketoStub(), kratosAdmin: adminStub(), kratosPublic }, "c"), /tokenizer returned no JWT/);
|
||||||
});
|
});
|
||||||
|
|
||||||
// An identity with no email is no session, decided here so /auth/complete and remintSession cannot
|
|
||||||
// disagree: `claimsToUser` reads a token carrying none as anonymous, so minting one would hand the
|
|
||||||
// browser a cookie every later request refuses.
|
|
||||||
test("completeLogin refuses an identity carrying no email, before it mints anything", async () => {
|
|
||||||
const identity: Identity = { id: ID, traits: {} };
|
|
||||||
let touched = false;
|
|
||||||
const kratosAdmin = adminStub({ updateMetadataPublic: async () => { touched = true; return { id: ID }; } });
|
|
||||||
const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity, tokenized: "h.p.s" }) as Session });
|
|
||||||
assert.equal(await completeLogin({ keto: ketoStub(), kratosAdmin, kratosPublic }, "c"), null);
|
|
||||||
assert.equal(touched, false); // no Keto read, no metadata write, no JWT
|
|
||||||
});
|
|
||||||
|
|
||||||
test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => {
|
test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => {
|
||||||
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
||||||
const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
|
const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
|
||||||
|
|||||||
+3
-9
@@ -31,7 +31,7 @@ export interface LoginDeps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CompletedLogin {
|
export interface CompletedLogin {
|
||||||
email: string;
|
email: string | null;
|
||||||
userId: string;
|
userId: string;
|
||||||
jwt: string;
|
jwt: string;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
@@ -61,13 +61,7 @@ export async function completeLogin(deps: LoginDeps, cookie: string | undefined)
|
|||||||
if (!session?.identity) return null;
|
if (!session?.identity) return null;
|
||||||
const userId = session.identity.id;
|
const userId = session.identity.id;
|
||||||
const emailTrait = session.identity.traits?.["email"];
|
const emailTrait = session.identity.traits?.["email"];
|
||||||
const email = typeof emailTrait === "string" ? emailTrait : "";
|
const email = typeof emailTrait === "string" ? emailTrait : null;
|
||||||
// No email is no session: `claimsToUser` reads a token carrying none as anonymous, so minting one
|
|
||||||
// would hand the browser a cookie every later request refuses.
|
|
||||||
if (!email) {
|
|
||||||
currentLog()?.warn("session dropped: identity has no email", { sub: userId });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissions = await readPermissions(deps.keto, userId);
|
const permissions = await readPermissions(deps.keto, userId);
|
||||||
await deps.kratosAdmin.updateMetadataPublic(userId, { permissions });
|
await deps.kratosAdmin.updateMetadataPublic(userId, { permissions });
|
||||||
@@ -93,7 +87,7 @@ export interface Reminted {
|
|||||||
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
|
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
|
||||||
const completed = await completeLogin(deps, cookie);
|
const completed = await completeLogin(deps, cookie);
|
||||||
if (!completed) return { setCookie: clearSessionCookie(options), user: null };
|
if (!completed) return { setCookie: clearSessionCookie(options), user: null };
|
||||||
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email, id: completed.userId, permissions: completed.permissions } };
|
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.userId, permissions: completed.permissions } };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
|
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { AUTH_FLOWS } from "./flow-view.ts";
|
import { AUTH_FLOWS } from "./flow-view.ts";
|
||||||
import { gatesSet } from "./gate.ts";
|
|
||||||
import type { HydraAdmin } from "./hydra-admin.ts";
|
import type { HydraAdmin } from "./hydra-admin.ts";
|
||||||
import type { KetoClient } from "./keto-client.ts";
|
import type { KetoClient } from "./keto-client.ts";
|
||||||
import type { KratosAdmin } from "./kratos-admin.ts";
|
import type { KratosAdmin } from "./kratos-admin.ts";
|
||||||
@@ -40,12 +39,8 @@ test("hydra alone ⇒ only RP-initiated logout of the OAuth2 group (login/consen
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("everything wired ⇒ the full group: OAuth2 challenges, consent GET+POST, /auth/complete", () => {
|
test("everything wired ⇒ the full group: OAuth2 challenges, consent GET+POST, /auth/complete", () => {
|
||||||
const routes = buildAuthRoutes(deps({ hydra, keto, kratos, kratosAdmin }));
|
const got = keys(buildAuthRoutes(deps({ hydra, keto, kratos, kratosAdmin })));
|
||||||
const got = keys(routes);
|
|
||||||
for (const key of ["GET /auth/complete", "GET /login", "GET /oauth2/consent", "GET /oauth2/login", "GET /oauth2/logout", "POST /logout", "POST /oauth2/consent"]) {
|
for (const key of ["GET /auth/complete", "GET /login", "GET /oauth2/consent", "GET /oauth2/login", "GET /oauth2/logout", "POST /logout", "POST /oauth2/consent"]) {
|
||||||
assert.ok(got.includes(key), key);
|
assert.ok(got.includes(key), key);
|
||||||
}
|
}
|
||||||
// Discovery enforces exactly one gate per plugin declaration; nothing checks the host's own table
|
|
||||||
// at boot, so a route added here without a gate would be silently public.
|
|
||||||
for (const route of routes) assert.deepEqual(gatesSet(route), ["public"], `${route.method} ${route.path}`);
|
|
||||||
});
|
});
|
||||||
|
|||||||
+8
-8
@@ -240,20 +240,20 @@ export function buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secure
|
|||||||
const routes: BuiltinRoute[] = [];
|
const routes: BuiltinRoute[] = [];
|
||||||
if (kratos) {
|
if (kratos) {
|
||||||
for (const [path, flowType] of Object.entries(AUTH_FLOWS)) {
|
for (const [path, flowType] of Object.entries(AUTH_FLOWS)) {
|
||||||
routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path, public: true });
|
routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path });
|
||||||
}
|
}
|
||||||
routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout", public: true });
|
routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout" });
|
||||||
}
|
}
|
||||||
if (hydra && kratos) {
|
if (hydra && kratos) {
|
||||||
const provider = { hydra, kratos };
|
const provider = { hydra, kratos };
|
||||||
routes.push({ handler: oauthLogin(provider, secureCookies), method: "GET", path: "/oauth2/login", public: true });
|
routes.push({ handler: oauthLogin(provider, secureCookies), method: "GET", path: "/oauth2/login" });
|
||||||
routes.push({ handler: consentScreen(provider, menu.branding.name), method: "GET", path: "/oauth2/consent", public: true });
|
routes.push({ handler: consentScreen(provider, menu.branding.name), method: "GET", path: "/oauth2/consent" });
|
||||||
routes.push({ handler: consentDecision(provider), method: "POST", path: "/oauth2/consent", public: true });
|
routes.push({ handler: consentDecision(provider), method: "POST", path: "/oauth2/consent" });
|
||||||
}
|
}
|
||||||
if (hydra) routes.push({ handler: oauthLogout(hydra), method: "GET", path: "/oauth2/logout", public: true });
|
if (hydra) routes.push({ handler: oauthLogout(hydra), method: "GET", path: "/oauth2/logout" });
|
||||||
if (kratos && kratosAdmin && keto) {
|
if (kratos && kratosAdmin && keto) {
|
||||||
routes.push({ handler: completeAuth({ keto, kratosAdmin, kratosPublic: kratos }, secureCookies), method: "GET", path: "/auth/complete", public: true });
|
routes.push({ handler: completeAuth({ keto, kratosAdmin, kratosPublic: kratos }, secureCookies), method: "GET", path: "/auth/complete" });
|
||||||
}
|
}
|
||||||
routes.push({ handler: errorSink, method: "GET", path: "/error", public: true });
|
routes.push({ handler: errorSink, method: "GET", path: "/error" });
|
||||||
return routes;
|
return routes;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -609,7 +609,6 @@ test("guards map to responses: requireSession → /login, a failed can/check →
|
|||||||
{ handler: (ctx) => { if (!can(ctx, "admin")) throw new GuardError(403, "no"); return { html: "ok" }; }, method: "GET", path: "/admin-only" },
|
{ handler: (ctx) => { if (!can(ctx, "admin")) throw new GuardError(403, "no"); return { html: "ok" }; }, method: "GET", path: "/admin-only" },
|
||||||
{ handler: async (ctx) => { if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id ?? "", relation: "view" }))) throw new GuardError(403, "no"); return { html: "seen" }; }, method: "GET", path: "/doc/:id" },
|
{ handler: async (ctx) => { if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id ?? "", relation: "view" }))) throw new GuardError(403, "no"); return { html: "seen" }; }, method: "GET", path: "/doc/:id" },
|
||||||
{ handler: () => ({ html: "gated" }), method: "GET", path: "/gated", permission: "secret:read" }, // declarative route gate
|
{ handler: () => ({ html: "gated" }), method: "GET", path: "/gated", permission: "secret:read" }, // declarative route gate
|
||||||
{ handler: () => ({ html: "mine" }), method: "GET", path: "/mine", session: true }, // declarative session gate
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [guarded] });
|
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [guarded] });
|
||||||
@@ -643,12 +642,6 @@ test("guards map to responses: requireSession → /login, a failed can/check →
|
|||||||
assert.equal(gDenied.status, 403);
|
assert.equal(gDenied.status, 403);
|
||||||
assert.match(await gDenied.text(), /403/); // the rendered 403.ejs over HTTP
|
assert.match(await gDenied.text(), /403/); // the rendered 403.ejs over HTTP
|
||||||
assert.equal((await fetch(url + "/guarded/gated", auth(["secret:read"]))).status, 200);
|
assert.equal((await fetch(url + "/guarded/gated", auth(["secret:read"]))).status, 200);
|
||||||
|
|
||||||
// declarative `session` gate: anonymous → sign in, and any signed-in user through, grant or none.
|
|
||||||
const sAnon = await fetch(url + "/guarded/mine", { redirect: "manual" });
|
|
||||||
assert.equal(sAnon.status, 303);
|
|
||||||
assert.equal(sAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fmine");
|
|
||||||
assert.equal((await fetch(url + "/guarded/mine", auth([]))).status, 200);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("plugin hooks: onRequest can short-circuit a request and onResponse observes the handler result", async (t) => {
|
test("plugin hooks: onRequest can short-circuit a request and onResponse observes the handler result", async (t) => {
|
||||||
|
|||||||
+10
-15
@@ -28,8 +28,7 @@ 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 { PluginSettings } from "../plugin-host/settings.ts";
|
||||||
import type { SystemCapabilities } from "../plugin-host/system.ts";
|
import type { SystemCapabilities } from "../plugin-host/system.ts";
|
||||||
import { allows, type Gate } from "../auth/gate.ts";
|
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
|
||||||
import { allowedMethods, matchRoute } from "../plugin-host/router.ts";
|
|
||||||
import { buildAuthRoutes } from "../auth/routes.ts";
|
import { buildAuthRoutes } from "../auth/routes.ts";
|
||||||
import { securityHeaders } from "./security-headers.ts";
|
import { securityHeaders } from "./security-headers.ts";
|
||||||
import { localPath } from "./safe-url.ts";
|
import { localPath } from "./safe-url.ts";
|
||||||
@@ -157,6 +156,7 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
// "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in
|
// "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in
|
||||||
// starter page.
|
// starter page.
|
||||||
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
|
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
|
||||||
|
if (!ctx.user) return { redirect: loginRedirect(ctx), status: 303 };
|
||||||
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
|
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
|
||||||
csrf.setCookie();
|
csrf.setCookie();
|
||||||
if (dashboardPlugin) {
|
if (dashboardPlugin) {
|
||||||
@@ -173,8 +173,8 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
// routes.ts, capability-gated on the wired clients) plus the two landing slots above.
|
// routes.ts, capability-gated on the wired clients) plus the two landing slots above.
|
||||||
const builtinRoutes: BuiltinRoute[] = [
|
const builtinRoutes: BuiltinRoute[] = [
|
||||||
...buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }),
|
...buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }),
|
||||||
{ handler: serveHome, method: "GET", path: "/", public: true },
|
{ handler: serveHome, method: "GET", path: "/" },
|
||||||
{ handler: serveDashboard, method: "GET", path: "/dashboard", session: true },
|
{ handler: serveDashboard, method: "GET", path: "/dashboard" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// The request handler. Run inside runWithLog (below) so the per-request logger is ambient: every
|
// The request handler. Run inside runWithLog (below) so the per-request logger is ambient: every
|
||||||
@@ -278,19 +278,15 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply lacks
|
|
||||||
// the permission gets the 403 page.
|
|
||||||
const refuse = async (gate: Gate, gateCtx: RequestContext): Promise<void> => {
|
|
||||||
if (!gateCtx.user) { res.writeHead(303, { location: carryLocale(loginRedirect(gateCtx)) }).end(); return; }
|
|
||||||
reqLog.warn("forbidden: missing permission", { path: pathname, required: gate.permission ?? "", sub: gateCtx.user.id });
|
|
||||||
sendHtml(res, 403, await renderPage("403", {}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const match = matchRoute(plugins, method, pathname);
|
const match = matchRoute(plugins, method, pathname);
|
||||||
if (match) {
|
if (match) {
|
||||||
const routeCtx = contextFor(match.plugin.id, match.params);
|
const routeCtx = contextFor(match.plugin.id, match.params);
|
||||||
if (!allows(match.route, routeCtx.user)) {
|
if (!isAuthorized(match.route, routeCtx.permissions)) {
|
||||||
await refuse(match.route, routeCtx);
|
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply
|
||||||
|
// lacks the permission gets the 403 page.
|
||||||
|
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
|
||||||
|
reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
|
||||||
|
sendHtml(res, 403, await renderPage("403", {}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
csrfMint.setCookie();
|
csrfMint.setCookie();
|
||||||
@@ -304,7 +300,6 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
|
|
||||||
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
|
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
|
||||||
if (builtin) {
|
if (builtin) {
|
||||||
if (!allows(builtin, ctx.user)) { await refuse(builtin, ctx); return; }
|
|
||||||
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
|
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
// mint (host-only — a plugin reads the token via ctx.chrome instead). app.ts matches this table
|
// mint (host-only — a plugin reads the token via ctx.chrome instead). app.ts matches this table
|
||||||
// after plugin routes — exact path, a GET route also answering HEAD like the plugin router — and
|
// after plugin routes — exact path, a GET route also answering HEAD like the plugin router — and
|
||||||
// pipes the result through sendResult against the core views.
|
// pipes the result through sendResult against the core views.
|
||||||
import type { Gate } from "../auth/gate.ts";
|
|
||||||
import type { RequestContext } from "./context.ts";
|
import type { RequestContext } from "./context.ts";
|
||||||
import type { RouteResult } from "../plugin-host/plugin.ts";
|
import type { RouteResult } from "../plugin-host/plugin.ts";
|
||||||
|
|
||||||
@@ -20,7 +19,7 @@ export interface RequestCsrf {
|
|||||||
// own context — otherwise the plugin's keys render as bare keys on the pages it owns.
|
// own context — otherwise the plugin's keys render as bare keys on the pages it owns.
|
||||||
export type PluginContextFactory = (pluginId: string) => RequestContext;
|
export type PluginContextFactory = (pluginId: string) => RequestContext;
|
||||||
|
|
||||||
export interface BuiltinRoute extends Gate {
|
export interface BuiltinRoute {
|
||||||
// Returns a RouteResult, or null when the handler wrote to ctx.res itself
|
// Returns a RouteResult, or null when the handler wrote to ctx.res itself
|
||||||
// (the landing slots dispatch a plugin's own result against that plugin's views).
|
// (the landing slots dispatch a plugin's own result against that plugin's views).
|
||||||
handler: (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory) => Promise<RouteResult | null> | RouteResult | null;
|
handler: (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory) => Promise<RouteResult | null> | RouteResult | null;
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ function scaffold(t: TestContext, files: Record<string, string>): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const full = (id: string): string =>
|
const full = (id: string): string =>
|
||||||
`export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "${id}:root", label: "${id}", public: true }], ` +
|
`export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "${id}:root", label: "${id}" }], ` +
|
||||||
`routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "${id}" }) }] };`;
|
`routes: [{ method: "GET", path: "/", handler: () => ({ html: "${id}" }) }] };`;
|
||||||
|
|
||||||
test("a missing plugins/ dir means zero plugins, not an error (clean clone)", async () => {
|
test("a missing plugins/ dir means zero plugins, not an error (clean clone)", async () => {
|
||||||
assert.deepEqual(await discoverPlugins({ dir: join(tmpdir(), "pp-does-not-exist-xyz") }), []);
|
assert.deepEqual(await discoverPlugins({ dir: join(tmpdir(), "pp-does-not-exist-xyz") }), []);
|
||||||
@@ -62,15 +62,6 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
|
|||||||
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
|
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
|
||||||
{ name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s },
|
{ name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s },
|
||||||
{ name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: true, permission: "x:read" }] };` }, match: /contranav.*public.*permission/s },
|
{ name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: true, permission: "x:read" }] };` }, match: /contranav.*public.*permission/s },
|
||||||
{ name: "a route marked session AND permission is contradictory", files: { "contrasess/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", session: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contrasess.*session.*permission/s },
|
|
||||||
{ name: "a route marked public AND session is contradictory", files: { "contrapub/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, session: true, handler: () => ({ html: "x" }) }] };` }, match: /contrapub.*public.*session/s },
|
|
||||||
{ name: "a route whose session flag is a truthy non-boolean is refused, not read as ungated", files: { "truthy/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", session: "yes", handler: () => ({ html: "x" }) }] };` }, match: /truthy.*session.*true/s },
|
|
||||||
{ name: "a nav node whose public flag is a truthy non-boolean is refused too", files: { "truthynav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: 1 }] };` }, match: /truthynav.*public.*true/s },
|
|
||||||
{ name: "a nav node marked session AND permission is contradictory", files: { "contrasessnav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", session: true, permission: "x:read" }] };` }, match: /contrasessnav.*session.*permission/s },
|
|
||||||
// A gate is named, never forgotten: a route or node without one would be an open page nobody chose.
|
|
||||||
{ name: "a route naming no gate at all is refused, not served to everyone", files: { "nogate/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", handler: () => ({ html: "x" }) }] };` }, match: /nogate.*names no gate/s },
|
|
||||||
{ name: "a nav node naming no gate at all is refused too — a section header says `public` outright", files: { "nogatenav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N" }] };` }, match: /nogatenav.*names no gate/s },
|
|
||||||
{ name: "a gate set to false is refused — it reads as a gate but sets none", files: { "falsegate/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: false, handler: () => ({ html: "x" }) }] };` }, match: /falsegate.*public.*true/s },
|
|
||||||
// A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not
|
// A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not
|
||||||
// only in the admin GUI, so it holds for a plugin installed without that GUI.
|
// only in the admin GUI, so it holds for a plugin installed without that GUI.
|
||||||
{ name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s },
|
{ name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s },
|
||||||
@@ -105,19 +96,12 @@ test("a discovery failure tells the operator their plugins/ copy may just be out
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a route + nav node may be marked public, or session, and load fine", async (t) => {
|
test("a route + nav node may be marked public and load fine", async (t) => {
|
||||||
const dir = scaffold(t, {
|
const dir = scaffold(t, { "pub/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };` });
|
||||||
"pub/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };`,
|
|
||||||
"sess/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ href: "/sess", id: "s", label: "S", session: true }], routes: [{ method: "GET", path: "/", session: true, handler: () => ({ html: "x" }) }] };`,
|
|
||||||
});
|
|
||||||
const plugins = await discoverPlugins({ dir });
|
const plugins = await discoverPlugins({ dir });
|
||||||
assert.equal(plugins.length, 2);
|
assert.equal(plugins.length, 1);
|
||||||
const pub = plugins.find((p) => p.id === "pub");
|
assert.equal(plugins[0]?.routes?.[0]?.public, true);
|
||||||
const sess = plugins.find((p) => p.id === "sess");
|
assert.equal(plugins[0]?.nav?.[0]?.public, true);
|
||||||
assert.equal(pub?.routes?.[0]?.public, true);
|
|
||||||
assert.equal(pub?.nav?.[0]?.public, true);
|
|
||||||
assert.equal(sess?.routes?.[0]?.session, true);
|
|
||||||
assert.equal(sess?.nav?.[0]?.session, true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("`admin` is not reserved — the admin screens ship as a drop-in plugin mounted at /admin", async (t) => {
|
test("`admin` is not reserved — the admin screens ship as a drop-in plugin mounted at /admin", async (t) => {
|
||||||
@@ -143,7 +127,7 @@ test("a plugin may carry its own package.json, node_modules and dependencies", a
|
|||||||
"shop/node_modules/price-tag/package.json": `{ "name": "price-tag", "version": "1.0.0", "type": "module", "exports": "./index.js" }`,
|
"shop/node_modules/price-tag/package.json": `{ "name": "price-tag", "version": "1.0.0", "type": "module", "exports": "./index.js" }`,
|
||||||
"shop/node_modules/price-tag/index.js": `export default (n) => \`\${n} kr\`;`,
|
"shop/node_modules/price-tag/index.js": `export default (n) => \`\${n} kr\`;`,
|
||||||
"shop/plugin.ts": `import { definePlugin } from "@plainpages/plugin-api";\nimport price from "price-tag";\n` +
|
"shop/plugin.ts": `import { definePlugin } from "@plainpages/plugin-api";\nimport price from "price-tag";\n` +
|
||||||
`export default definePlugin({ apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: price(20) }) }] });`,
|
`export default definePlugin({ apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", handler: () => ({ html: price(20) }) }] });`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const plugins = await discoverPlugins({ dir });
|
const plugins = await discoverPlugins({ dir });
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
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 { type Gate, gatesSet } from "../auth/gate.ts";
|
|
||||||
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 { settingsDeclError } from "./settings.ts";
|
||||||
import { isValidStoragePluginId, MAX_STORAGE_PLUGIN_ID_LENGTH } from "./storage.ts";
|
import { isValidStoragePluginId, MAX_STORAGE_PLUGIN_ID_LENGTH } from "./storage.ts";
|
||||||
@@ -147,43 +146,46 @@ function shapeError(manifest: PluginManifest): string | null {
|
|||||||
const settings = settingsDeclError(manifest.settings);
|
const settings = settingsDeclError(manifest.settings);
|
||||||
if (settings) return settings;
|
if (settings) return settings;
|
||||||
}
|
}
|
||||||
|
// `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.
|
||||||
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
||||||
const gate = gateError(`route "${route?.method} ${route?.path}"`, route);
|
if (route?.public === true && route.permission != null) return `route "${route.method} ${route.path}" sets both public and permission — they are mutually exclusive`;
|
||||||
if (gate) return gate;
|
}
|
||||||
|
const navContradiction = findPublicNavContradiction(manifest.nav);
|
||||||
|
if (navContradiction) return navContradiction;
|
||||||
|
// Every permission name the manifest mentions — gated on or declared — must be `<resource>:<action>`.
|
||||||
|
// A bare word names a role, and roles are groups here (README → Naming a permission).
|
||||||
|
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
||||||
|
if (route?.permission != null && !isValidPermissionName(route.permission)) {
|
||||||
|
return `route "${route.method} ${route.path}" gates on "${route.permission}"; a permission name is <resource>:<action>, e.g. "things:read"`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const navGate = findNavGateError(manifest.nav);
|
|
||||||
if (navGate) return navGate;
|
|
||||||
for (const decl of Array.isArray(manifest.permissions) ? manifest.permissions : []) {
|
for (const decl of Array.isArray(manifest.permissions) ? manifest.permissions : []) {
|
||||||
if (decl?.name == null || !isValidPermissionName(decl.name)) {
|
if (decl?.name == null || !isValidPermissionName(decl.name)) {
|
||||||
return `declared permission "${decl?.name}" is not <resource>:<action>, e.g. "things:read"`;
|
return `declared permission "${decl?.name}" is not <resource>:<action>, e.g. "things:read"`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const navPermission = findInvalidNavPermission(manifest.nav);
|
||||||
|
if (navPermission) return navPermission;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every rule a declaration's gate must satisfy. Exactly one gate, always: a missing one would be an
|
// Recurse the nav fragment: a node that is both `public` and `permission`-gated is contradictory.
|
||||||
// open page nobody chose, and anything but `true` (a `false`, a `"yes"`) sets no gate while looking
|
function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null {
|
||||||
// like it does. A permission name is `<resource>:<action>` because a bare word names a role, and
|
|
||||||
// roles are groups here (README → Naming a permission).
|
|
||||||
function gateError(what: string, gate: Gate | null | undefined): string | null {
|
|
||||||
for (const flag of ["public", "session"] as const) {
|
|
||||||
const value = gate?.[flag];
|
|
||||||
if (value !== undefined && value !== true) return `${what} sets ${flag} to ${JSON.stringify(value)}; a gate is declared with \`true\``;
|
|
||||||
}
|
|
||||||
const gates = gatesSet(gate);
|
|
||||||
if (gates.length === 0) return `${what} names no gate; name exactly one — public, session or permission`;
|
|
||||||
if (gates.length > 1) return `${what} sets ${gates.join(" and ")}; name exactly one — public, session or permission`;
|
|
||||||
if (gate?.permission != null && !isValidPermissionName(gate.permission)) {
|
|
||||||
return `${what} gates on "${gate.permission}"; a permission name is <resource>:<action>, e.g. "things:read"`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findNavGateError(nodes: PluginManifest["nav"]): string | null {
|
|
||||||
for (const node of Array.isArray(nodes) ? nodes : []) {
|
for (const node of Array.isArray(nodes) ? nodes : []) {
|
||||||
const err = gateError(`nav node "${node?.label ?? node?.id ?? "?"}"`, node);
|
if (node?.public === true && node.permission != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and permission — they are mutually exclusive`;
|
||||||
if (err) return err;
|
const inChild = findPublicNavContradiction(node?.children);
|
||||||
const inChild = findNavGateError(node?.children);
|
if (inChild) return inChild;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findInvalidNavPermission(nodes: PluginManifest["nav"]): string | null {
|
||||||
|
for (const node of Array.isArray(nodes) ? nodes : []) {
|
||||||
|
if (node?.permission != null && !isValidPermissionName(node.permission)) {
|
||||||
|
return `nav node "${node.label ?? node.id ?? "?"}" gates on "${node.permission}"; a permission name is <resource>:<action>, e.g. "things:read"`;
|
||||||
|
}
|
||||||
|
const inChild = findInvalidNavPermission(node?.children);
|
||||||
if (inChild) return inChild;
|
if (inChild) return inChild;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ export type { RequestContext, User } from "../http/context.ts";
|
|||||||
export type { PageChrome } from "../ui/chrome.ts";
|
export type { PageChrome } from "../ui/chrome.ts";
|
||||||
export type { NavNode } from "../ui/nav.ts";
|
export type { NavNode } from "../ui/nav.ts";
|
||||||
export { can, check, GuardError, requireSession } from "../auth/guards.ts";
|
export { can, check, GuardError, requireSession } from "../auth/guards.ts";
|
||||||
// The three coarse gates a route or nav node may declare — `Route` and `NavNode` both extend it.
|
|
||||||
export type { Gate } from "../auth/gate.ts";
|
|
||||||
// Translation: `ctx.t` and the view-level `t(...)` do the work at runtime — these are for
|
// Translation: `ctx.t` and the view-level `t(...)` do the work at runtime — these are for
|
||||||
// authoring a plugin's own catalogs (plugins/<id>/i18n/<locale>.ts) and for building a translator
|
// authoring a plugin's own catalogs (plugins/<id>/i18n/<locale>.ts) and for building a translator
|
||||||
// in a unit test. `PluralMessage` types a message that varies with a count.
|
// in a unit test. `PluralMessage` types a message that varies with a count.
|
||||||
|
|||||||
@@ -4,14 +4,13 @@
|
|||||||
// A plugin's identity is its folder under plugins/: folder name = `id` (isValidPluginId), mount =
|
// A plugin's identity is its folder under plugins/: folder name = `id` (isValidPluginId), mount =
|
||||||
// `/<id>`. Neither is in the manifest — the host derives them, so they can't drift or be claimed twice.
|
// `/<id>`. Neither is in the manifest — the host derives them, so they can't drift or be claimed twice.
|
||||||
|
|
||||||
import type { Gate } from "../auth/gate.ts";
|
|
||||||
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 { 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.4.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";
|
||||||
|
|
||||||
@@ -25,10 +24,14 @@ export type RouteResult =
|
|||||||
|
|
||||||
export type RouteHandler = (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void;
|
export type RouteHandler = (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void;
|
||||||
|
|
||||||
export interface Route extends Gate {
|
export interface Route {
|
||||||
handler: RouteHandler;
|
handler: RouteHandler;
|
||||||
method: HttpMethod;
|
method: HttpMethod;
|
||||||
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
|
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
|
||||||
|
permission?: string; // coarse gate — the Keto Permission the caller must hold; checked before the handler runs
|
||||||
|
// Same as omitting `permission`, but stated outright so public is a deliberate choice rather than
|
||||||
|
// a forgotten gate. Mutually exclusive with `permission` (discovery refuses both).
|
||||||
|
public?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A Keto Permission this plugin gates on — declared for docs/seeding. Names are a shared global
|
// A Keto Permission this plugin gates on — declared for docs/seeding. Names are a shared global
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import type { Plugin, Route } from "./plugin.ts";
|
import type { Plugin, Route } from "./plugin.ts";
|
||||||
import { allowedMethods, matchRoute } from "./router.ts";
|
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
|
||||||
|
|
||||||
const noop: Route["handler"] = () => ({ html: "x" });
|
const noop: Route["handler"] = () => ({ html: "x" });
|
||||||
|
|
||||||
@@ -54,3 +54,14 @@ test("allowedMethods lists methods at a path (GET implies HEAD); empty when the
|
|||||||
assert.deepEqual(allowedMethods(plugins, "/x/a"), ["GET", "HEAD", "POST"]);
|
assert.deepEqual(allowedMethods(plugins, "/x/a"), ["GET", "HEAD", "POST"]);
|
||||||
assert.deepEqual(allowedMethods(plugins, "/x/missing"), []);
|
assert.deepEqual(allowedMethods(plugins, "/x/missing"), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("isAuthorized: open routes pass; gated routes require the permission token; public is explicitly open", () => {
|
||||||
|
const open: Route = { handler: noop, method: "GET", path: "/" };
|
||||||
|
const gated: Route = { handler: noop, method: "GET", path: "/", permission: "x:read" };
|
||||||
|
const pub: Route = { handler: noop, method: "GET", path: "/", public: true }; // blessed public alias
|
||||||
|
assert.equal(isAuthorized(open, []), true);
|
||||||
|
assert.equal(isAuthorized(gated, []), false);
|
||||||
|
assert.equal(isAuthorized(gated, ["x:read"]), true);
|
||||||
|
assert.equal(isAuthorized(gated, ["other"]), false);
|
||||||
|
assert.equal(isAuthorized(pub, []), true); // open to anonymous, like omitting permission — but stated outright
|
||||||
|
});
|
||||||
|
|||||||
@@ -73,3 +73,10 @@ export function allowedMethods(plugins: Plugin[], pathname: string): string[] {
|
|||||||
}
|
}
|
||||||
return [...methods].sort();
|
return [...methods].sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Coarse permission gate: a route marked `public` (or one with no `permission`) is open; otherwise
|
||||||
|
// the user's permissions (from the session JWT) must include the token. The same rule composeNav uses
|
||||||
|
// for the menu. `public` and `permission` are mutually exclusive (discovery refuses both).
|
||||||
|
export function isAuthorized(route: Route, permissions: string[]): boolean {
|
||||||
|
return route.public === true || route.permission == null || permissions.includes(route.permission);
|
||||||
|
}
|
||||||
|
|||||||
+5
-3
@@ -10,7 +10,7 @@ import { composeNav, type NavNode } from "./nav.ts";
|
|||||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||||
import { branding, shellUser, type ShellUser } from "./shell-context.ts";
|
import { branding, shellUser, type ShellUser } from "./shell-context.ts";
|
||||||
|
|
||||||
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard", session: true };
|
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard" };
|
||||||
|
|
||||||
export interface PageChrome {
|
export interface PageChrome {
|
||||||
brand: { logo?: string; name: string; sub?: string };
|
brand: { logo?: string; name: string; sub?: string };
|
||||||
@@ -35,7 +35,8 @@ export interface ChromeOptions {
|
|||||||
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||||
const t = opts.t ?? ENGLISH;
|
const t = opts.t ?? ENGLISH;
|
||||||
const carryLocale = opts.localeHref ?? ((href: string) => href);
|
const carryLocale = opts.localeHref ?? ((href: string) => href);
|
||||||
const fragments: NavNode[][] = [[DASHBOARD_NAV]];
|
// Dashboard is gated, so an anonymous click would only dead-end at /login.
|
||||||
|
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
|
||||||
// A plugin's nav labels are keys in *its* catalog, so translate each fragment with that plugin's
|
// A plugin's nav labels are keys in *its* catalog, so translate each fragment with that plugin's
|
||||||
// translator before merging. composeNav then runs the core one over the result; already-translated
|
// translator before merging. composeNav then runs the core one over the result; already-translated
|
||||||
// text passes through it.
|
// text passes through it.
|
||||||
@@ -43,7 +44,8 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
|||||||
if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t));
|
if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t));
|
||||||
}
|
}
|
||||||
|
|
||||||
const nav = composeNav(fragments, opts.menu.override, opts.user ?? null, t);
|
const permissions = opts.user?.permissions ?? [];
|
||||||
|
const nav = composeNav(fragments, opts.menu.override, permissions, t);
|
||||||
if (opts.currentPath) {
|
if (opts.currentPath) {
|
||||||
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
|
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
|
||||||
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard.
|
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard.
|
||||||
|
|||||||
+9
-19
@@ -1,12 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import type { User } from "../http/context.ts";
|
|
||||||
import { composeNav, type NavNode } from "./nav.ts";
|
import { composeNav, type NavNode } from "./nav.ts";
|
||||||
|
|
||||||
function viewer(...permissions: string[]): User {
|
|
||||||
return { email: "viewer@example.test", id: "01a06091-ba9f-765f-abf4-b5144c314bc7", permissions };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Two plugin fragments; ids let the override target nodes, `permission` gates per permission.
|
// Two plugin fragments; ids let the override target nodes, `permission` gates per permission.
|
||||||
const fragments: NavNode[][] = [
|
const fragments: NavNode[][] = [
|
||||||
[{
|
[{
|
||||||
@@ -20,7 +15,7 @@ const fragments: NavNode[][] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
test("composeNav merges fragments, filters by permission, and emits clean render nodes", () => {
|
test("composeNav merges fragments, filters by permission, and emits clean render nodes", () => {
|
||||||
const tree = composeNav(fragments, {}, viewer("scheduling:read"));
|
const tree = composeNav(fragments, {}, ["scheduling:read"]);
|
||||||
|
|
||||||
// Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts.
|
// Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts.
|
||||||
// Output carries no `id`/`permission` and omits absent fields — ready for nav-tree.ejs.
|
// Output carries no `id`/`permission` and omits absent fields — ready for nav-tree.ejs.
|
||||||
@@ -35,7 +30,7 @@ test("composeNav drops gated subtrees, empty headers, and (with no permissions)
|
|||||||
{ id: "admin", label: "Admin", permission: "users:read", children: [{ href: "/u", id: "u", label: "Users" }] },
|
{ id: "admin", label: "Admin", permission: "users:read", children: [{ href: "/u", id: "u", label: "Users" }] },
|
||||||
{ id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] },
|
{ id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] },
|
||||||
]];
|
]];
|
||||||
assert.deepEqual(composeNav(gatedHeader, {}, viewer()), [
|
assert.deepEqual(composeNav(gatedHeader, {}, []), [
|
||||||
{ label: "Free", children: [{ href: "/d", label: "Docs" }] },
|
{ label: "Free", children: [{ href: "/d", label: "Docs" }] },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -44,31 +39,26 @@ test("composeNav drops gated subtrees, empty headers, and (with no permissions)
|
|||||||
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x:read" }] },
|
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x:read" }] },
|
||||||
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y:read" }] },
|
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y:read" }] },
|
||||||
]];
|
]];
|
||||||
assert.deepEqual(composeNav(emptyHeader, {}, viewer()), [{ href: "/hub", label: "Hub" }]);
|
assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]);
|
||||||
|
|
||||||
// No fragments / no permissions → empty tree, never throws.
|
// No fragments / no permissions → empty tree, never throws.
|
||||||
assert.deepEqual(composeNav(), []);
|
assert.deepEqual(composeNav(), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("composeNav shows a public node to everyone and a session node to any signed-in user", () => {
|
test("composeNav keeps a node marked public for everyone — the blessed public alias", () => {
|
||||||
// A header with a public child, a session child and a gated child: the public child keeps the
|
// A header with one public child + one gated child: with no permissions, the public child keeps the
|
||||||
// header alive for an anonymous visitor — so a plugin can show a menu option to all.
|
// header alive (the gated child is filtered out) — so a plugin can show a public menu option to all.
|
||||||
const frag: NavNode[][] = [[{
|
const frag: NavNode[][] = [[{
|
||||||
icon: "i-cal", id: "sched", label: "Scheduling",
|
icon: "i-cal", id: "sched", label: "Scheduling",
|
||||||
children: [
|
children: [
|
||||||
{ href: "/scheduling", id: "overview", label: "Overview", public: true },
|
{ href: "/scheduling", id: "overview", label: "Overview", public: true },
|
||||||
{ href: "/scheduling/mine", id: "mine", label: "Mine", session: true },
|
|
||||||
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
|
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
|
||||||
],
|
],
|
||||||
}]];
|
}]];
|
||||||
// `public`/`session` are filter-only (like id/permission) — never rendered into the output node.
|
// `public` is filter-only (like id/permission) — never rendered into the output node.
|
||||||
assert.deepEqual(composeNav(frag, {}, null), [
|
assert.deepEqual(composeNav(frag, {}, []), [
|
||||||
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] },
|
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] },
|
||||||
]);
|
]);
|
||||||
// Signed in with no permission at all: the session node appears, the permission-gated one does not.
|
|
||||||
assert.deepEqual(composeNav(frag, {}, viewer()), [
|
|
||||||
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }, { href: "/scheduling/mine", label: "Mine" }] },
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("composeNav applies the override: rename, group, order, hide (then filters)", () => {
|
test("composeNav applies the override: rename, group, order, hide (then filters)", () => {
|
||||||
@@ -84,7 +74,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
|
|||||||
groups: [{ icon: "i-box", id: "grp", label: "Group", open: true, children: ["b", "c"] }], // wrap b+c
|
groups: [{ icon: "i-box", id: "grp", label: "Group", open: true, children: ["b", "c"] }], // wrap b+c
|
||||||
order: ["grp", "a"], // grp before the lone a
|
order: ["grp", "a"], // grp before the lone a
|
||||||
hide: ["c"], // remove c from inside the group
|
hide: ["c"], // remove c from inside the group
|
||||||
}, viewer("secrets:read"));
|
}, ["secrets:read"]);
|
||||||
|
|
||||||
// grp emitted (b only, c hidden), reordered before a; Secret kept now that permission "secrets:read" is present.
|
// grp emitted (b only, c hidden), reordered before a; Secret kept now that permission "secrets:read" is present.
|
||||||
assert.deepEqual(tree, [
|
assert.deepEqual(tree, [
|
||||||
|
|||||||
+13
-13
@@ -1,14 +1,12 @@
|
|||||||
// composeNav: merge each plugin's nav fragment into one tree, apply the central override, then
|
// composeNav: merge each plugin's nav fragment into one tree, apply the central override, then
|
||||||
// filter per user. Pure and I/O-free — menu gating reads the JWT `permissions` claim, never Keto.
|
// permission-filter per user. Pure and I/O-free — menu gating reads the JWT `permissions` claim,
|
||||||
// A node is visible iff `allows` passes its gate; a gated header hides its whole subtree, and a pure
|
// never Keto. A node is visible iff it is `public`, declares no `permission`, or the user holds that
|
||||||
// header left with no children is dropped.
|
// name; a gated header hides its whole subtree, and a pure header left with no children is dropped.
|
||||||
|
|
||||||
import { allows, type Gate } from "../auth/gate.ts";
|
|
||||||
import type { User } from "../http/context.ts";
|
|
||||||
import { ENGLISH } from "../i18n/english.ts";
|
import { ENGLISH } from "../i18n/english.ts";
|
||||||
import type { Translate } from "../i18n/translate.ts";
|
import type { Translate } from "../i18n/translate.ts";
|
||||||
|
|
||||||
export interface NavNode extends Gate {
|
export interface NavNode {
|
||||||
id?: string; // stable key for override targeting; stripped from the rendered tree
|
id?: string; // stable key for override targeting; stripped from the rendered tree
|
||||||
children?: NavNode[];
|
children?: NavNode[];
|
||||||
count?: number;
|
count?: number;
|
||||||
@@ -17,10 +15,12 @@ export interface NavNode extends Gate {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
label: string;
|
label: string;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
|
permission?: string; // required permission token; consumed by the filter, never rendered
|
||||||
|
public?: boolean; // show to everyone, signed in or not — the blessed alias for "no permission", stated outright; consumed by the filter, never rendered. Mutually exclusive with permission (discovery refuses both).
|
||||||
}
|
}
|
||||||
|
|
||||||
// Central override (config/menu.ts). Targets nodes by `id`; applied rename → group →
|
// Central override (config/menu.ts). Targets nodes by `id`; applied rename → group →
|
||||||
// order → hide, then the per-user gate filter runs last.
|
// order → hide, then the per-user permission filter runs last.
|
||||||
export interface NavOverride {
|
export interface NavOverride {
|
||||||
groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header
|
groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header
|
||||||
hide?: string[]; // remove nodes by id, at any depth (incl. a group's id)
|
hide?: string[]; // remove nodes by id, at any depth (incl. a group's id)
|
||||||
@@ -39,7 +39,7 @@ export interface NavGroupSpec {
|
|||||||
export function composeNav(
|
export function composeNav(
|
||||||
fragments: NavNode[][] = [],
|
fragments: NavNode[][] = [],
|
||||||
override: NavOverride = {},
|
override: NavOverride = {},
|
||||||
user: User | null = null,
|
permissions: string[] = [],
|
||||||
t: Translate = ENGLISH,
|
t: Translate = ENGLISH,
|
||||||
): NavNode[] {
|
): NavNode[] {
|
||||||
let nodes: NavNode[] = fragments.flat();
|
let nodes: NavNode[] = fragments.flat();
|
||||||
@@ -47,7 +47,7 @@ export function composeNav(
|
|||||||
if (override.groups?.length) nodes = applyGroups(nodes, override.groups);
|
if (override.groups?.length) nodes = applyGroups(nodes, override.groups);
|
||||||
if (override.order?.length) nodes = applyOrder(nodes, override.order);
|
if (override.order?.length) nodes = applyOrder(nodes, override.order);
|
||||||
if (override.hide?.length) nodes = hideTree(nodes, new Set(override.hide));
|
if (override.hide?.length) nodes = hideTree(nodes, new Set(override.hide));
|
||||||
return filterByGate(nodes, user).map((node) => toRenderNode(node, t));
|
return filterByRoles(nodes, new Set(permissions)).map((node) => toRenderNode(node, t));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renameTree(nodes: NavNode[], rename: Record<string, string>): NavNode[] {
|
function renameTree(nodes: NavNode[], rename: Record<string, string>): NavNode[] {
|
||||||
@@ -104,19 +104,19 @@ function hideTree(nodes: NavNode[], hide: Set<string>): NavNode[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterByGate(nodes: NavNode[], user: User | null): NavNode[] {
|
function filterByRoles(nodes: NavNode[], permissions: Set<string>): NavNode[] {
|
||||||
const out: NavNode[] = [];
|
const out: NavNode[] = [];
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
if (!allows(n, user)) continue; // gated → drop node + subtree
|
if (n.public !== true && n.permission != null && !permissions.has(n.permission)) continue; // gated → drop node + subtree (public always shows)
|
||||||
if (!n.children) { out.push(n); continue; }
|
if (!n.children) { out.push(n); continue; }
|
||||||
const children = filterByGate(n.children, user);
|
const children = filterByRoles(n.children, permissions);
|
||||||
if (children.length === 0 && n.href == null) continue; // empty pure header → drop
|
if (children.length === 0 && n.href == null) continue; // empty pure header → drop
|
||||||
out.push({ ...n, children });
|
out.push({ ...n, children });
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip the helper-only fields (id and the gate) and drop absent ones, so the tree is exactly
|
// Strip the helper-only fields (id/permission) and drop absent ones, so the tree is exactly
|
||||||
// what nav-tree.ejs reads. Labels (a manifest's, or the central override's rename) pass through
|
// what nav-tree.ejs reads. Labels (a manifest's, or the central override's rename) pass through
|
||||||
// `t` on the way out: a label that names a catalog key is translated, any other renders as written.
|
// `t` on the way out: a label that names a catalog key is translated, any other renders as written.
|
||||||
function toRenderNode(n: NavNode, t: Translate): NavNode {
|
function toRenderNode(n: NavNode, t: Translate): NavNode {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
- [ ] Decide what `ICON_NAMES` (`src/ui/icons.ts`) actually is. `i-chart`, `i-copy`, `i-download` and `i-sliders` have no caller anywhere — so either they go, or the comment should say the palette is curated and may carry an id ahead of its first use. Not cosmetic: the sprite is inlined into every page, and the rule decides whether a future removal is routine cleanup or a plugin-facing regression.
|
- [ ] Decide what `ICON_NAMES` (`src/ui/icons.ts`) actually is. `i-chart`, `i-copy`, `i-download` and `i-sliders` have no caller anywhere — so either they go, or the comment should say the palette is curated and may carry an id ahead of its first use. Not cosmetic: the sprite is inlined into every page, and the rule decides whether a future removal is routine cleanup or a plugin-facing regression.
|
||||||
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md and README → Security model; not accepted ⇒ bind the nonce to `sub`.
|
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md and README → Security model; not accepted ⇒ bind the nonce to `sub`.
|
||||||
- [ ] Verify the documented Docker commands on macOS and fix whatever misbehaves — **macOS is a supported dev host**, but nothing here has been run on one. Two suspects, both from the `--user "$(id -u):$(id -g)"` idiom: a macOS `id -g` is `20`, which is `dialout` inside the noble image rather than a user group, and Docker Desktop remaps bind-mount ownership in its own VM layer. The same question covers rootless Docker, where README already says to *drop* the flag.
|
- [ ] Verify the documented Docker commands on macOS and fix whatever misbehaves — **macOS is a supported dev host**, but nothing here has been run on one. Two suspects, both from the `--user "$(id -u):$(id -g)"` idiom: a macOS `id -g` is `20`, which is `dialout` inside the noble image rather than a user group, and Docker Desktop remaps bind-mount ownership in its own VM layer. The same question covers rootless Docker, where README already says to *drop* the flag.
|
||||||
- [ ] Map Kratos' 401 on a self-service flow init, so an anonymous `GET /settings` with no `?flow` renders instead of 500ing. `flowPage` (`src/auth/routes.ts`) maps 403/404/410 → restart the flow, 400 `session_already_available` → `/auth/complete`, and ≥500 → the themed 503, then rethrows everything else — and Kratos answers the settings-flow init with 401 when there is no session. `/settings` is correctly `public` (the recovery flow lands there with a live Kratos session but no app JWT), so the gate is not the fix: a 401 should redirect to `/login` with the page as `return_to`. No E2E covers an anonymous hit on a flow page that needs a session.
|
|
||||||
|
|
||||||
### Architectural review findings (2026-07-02)
|
### Architectural review findings (2026-07-02)
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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.4.0",
|
apiVersion: "0.3.0",
|
||||||
// view names plugins/<id>/views/<view>.ejs, rendered in this same shell
|
// view names plugins/<id>/views/<view>.ejs, rendered in this same shell
|
||||||
dashboard: (ctx) => ({ view: "dashboard", data: { /* … */ } }),
|
dashboard: (ctx) => ({ view: "dashboard", data: { /* … */ } }),
|
||||||
});</code></pre>
|
});</code></pre>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<symbol id="i-box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="M12 22V12" /></symbol>
|
<symbol id="i-box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="M12 22V12" /></symbol>
|
||||||
<symbol id="i-cal" viewBox="0 0 24 24"><path d="M8 2v3" /><path d="M16 2v3" /><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /></symbol>
|
<symbol id="i-cal" viewBox="0 0 24 24"><path d="M8 2v3" /><path d="M16 2v3" /><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /></symbol>
|
||||||
<symbol id="i-chart" viewBox="0 0 24 24"><path d="M5 21v-6" /><path d="M12 21V3" /><path d="M19 21V9" /></symbol>
|
<symbol id="i-chart" viewBox="0 0 24 24"><path d="M5 21v-6" /><path d="M12 21V3" /><path d="M19 21V9" /></symbol>
|
||||||
<symbol id="i-check-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="m16 9-5.5 5.5L8 12" /></symbol>
|
<symbol id="i-check-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="m9 12 2 2 4-4" /></symbol>
|
||||||
<symbol id="i-chev" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol>
|
<symbol id="i-chev" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol>
|
||||||
<symbol id="i-cols" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M9 3v18" /><path d="M15 3v18" /></symbol>
|
<symbol id="i-cols" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M9 3v18" /><path d="M15 3v18" /></symbol>
|
||||||
<symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></symbol>
|
<symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></symbol>
|
||||||
|
|||||||
Reference in New Issue
Block a user