Compare commits
10 Commits
5d1e8f2309
..
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 54956fe627 | |||
| 9912dd64f1 | |||
| 76fa6a96ea | |||
| e81c281c1a | |||
| 1fb19f6c26 | |||
| 18dc4f3136 | |||
| 390ac5f112 | |||
| 6a0d11d9d3 | |||
| dfb043c3bd | |||
| a113d14e42 |
@@ -21,7 +21,7 @@ jobs:
|
||||
-e RENOVATE_PLATFORM=gitea \
|
||||
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
|
||||
-e RENOVATE_TOKEN \
|
||||
renovate/renovate:44.53.0
|
||||
renovate/renovate:44.59.3
|
||||
|
||||
# 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
|
||||
|
||||
@@ -182,12 +182,19 @@ 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
|
||||
× 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.
|
||||
- **A gate is one of three, and `session` is a first-class one.** A route or nav node names exactly
|
||||
one of `public`, `session`, `permission` (discovery refuses two), and `src/auth/gate.ts` is the one
|
||||
home of the rule the router and the menu both read. `session` exists because a plugin whose data is
|
||||
- **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.
|
||||
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.**
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# 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.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.
|
||||
@@ -47,7 +47,7 @@ folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.
|
||||
import { definePlugin } from "@plainpages/plugin-api";
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "0.4.0",
|
||||
apiVersion: "0.3.0",
|
||||
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
||||
routes: [
|
||||
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
||||
@@ -350,9 +350,9 @@ import { definePlugin } from "@plainpages/plugin-api";
|
||||
import { listThings, createThings } from "./handlers.ts";
|
||||
|
||||
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 permission-filtered per user.
|
||||
// Nav fragment, merged into the global menu and gate-filtered per user.
|
||||
// `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" }],
|
||||
|
||||
@@ -362,7 +362,7 @@ export default definePlugin({
|
||||
{ description: "Create and edit things", name: "things:write" },
|
||||
],
|
||||
|
||||
// Route handlers, mounted under the plugin's path (/things). `permission` gates first.
|
||||
// Route handlers, mounted under the plugin's path (/things). The gate runs first.
|
||||
routes: [
|
||||
{ method: "GET", path: "/", permission: "things:read", handler: listThings },
|
||||
{ method: "POST", path: "/", permission: "things:write", handler: createThings },
|
||||
@@ -379,7 +379,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). |
|
||||
| `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). |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). |
|
||||
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
||||
| `hooks` | no | See [Hooks](#hooks). |
|
||||
@@ -390,14 +390,13 @@ A plugin may be routes-only, nav-only, or hooks-only — every collection field
|
||||
|
||||
### Routes & handlers
|
||||
|
||||
A route is `{ method, path, permission?, public?, session?, handler }`. `path` is **relative to the plugin's
|
||||
A route is `{ method, path, handler }` plus [exactly one gate](#public-pages--menu-items) —
|
||||
`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
|
||||
`method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, runs the
|
||||
`permission` gate ([a coarse JWT-claim check](#nav--permission-gates)), then calls the handler with
|
||||
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`
|
||||
with the page as `return_to`; a **signed-in** user lacking the permission gets the **403** page.
|
||||
`public: true` means no gate at all, `session: true` any signed-in user (see
|
||||
[Public pages](#public-pages--menu-items)).
|
||||
|
||||
`method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`.
|
||||
|
||||
@@ -472,7 +471,7 @@ import { definePlugin } from "@plainpages/plugin-api";
|
||||
import { landing, board } from "./pages.ts";
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "0.4.0",
|
||||
apiVersion: "0.3.0",
|
||||
home: landing, // owns "/" — the public front page
|
||||
dashboard: board, // owns "/dashboard" — the post-login app home
|
||||
});
|
||||
@@ -571,27 +570,28 @@ 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
|
||||
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, declares no `permission`, or
|
||||
the user holds that name. A node's `icon` is a **Lucide icon** by sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are
|
||||
`ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there.
|
||||
node shows iff it is `public`, is `session` and someone is signed in, or names a `permission` the
|
||||
user holds. A node's `icon` is a **Lucide icon** by sprite id (e.g. `i-cal` → lucide `calendar`); the
|
||||
available ids are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name
|
||||
there.
|
||||
|
||||
**Gating a section header.** A `permission` on the header takes the whole subtree with it. When the
|
||||
children need *different* permissions, leave the header ungated and gate each child — `composeNav`
|
||||
drops a header whose children all filtered out. That only works while the header carries **no
|
||||
`href`**: give it one and it survives as an ungated leaf, visible to everyone.
|
||||
children need *different* permissions, mark the header `public: true` — it then gates nothing, each
|
||||
child decides, and `composeNav` drops a header whose children all filtered out. That only works while
|
||||
the header carries **no `href`**: give it one and it survives as a leaf, visible to everyone.
|
||||
|
||||
#### Public pages & menu items
|
||||
|
||||
A route or nav node marked **`public: true`** is reachable by anyone and shows in everyone's menu.
|
||||
That is the same as omitting `permission`, but stated outright so public is a deliberate choice
|
||||
rather than a forgotten gate.
|
||||
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.
|
||||
|
||||
**`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.
|
||||
|
||||
A declaration names **exactly one** of the three; two of them contradict, and discovery refuses the
|
||||
plugin at boot.
|
||||
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
|
||||
shell shows a **Sign in** link in place of the profile block, the gated **Dashboard** link is hidden,
|
||||
@@ -626,6 +626,7 @@ provider/consumer semantics in `checkApiVersion`:
|
||||
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
|
||||
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
|
||||
@@ -644,7 +645,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
|
||||
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
|
||||
route/nav node naming more than one gate.
|
||||
route/nav node that does not name [exactly one gate](#public-pages--menu-items).
|
||||
|
||||
### Hooks
|
||||
|
||||
@@ -756,7 +757,7 @@ camel humps both becoming underscores — so `upstream` on the `scheduling` plug
|
||||
|
||||
```ts
|
||||
export default definePlugin({
|
||||
apiVersion: "0.4.0",
|
||||
apiVersion: "0.3.0",
|
||||
settings: [
|
||||
{ key: "upstream", type: "url", required: true, description: "Base URL of the backend" },
|
||||
{ key: "pageSize", type: "number", default: 25 },
|
||||
@@ -810,7 +811,7 @@ import { definePlugin } from "@plainpages/plugin-api";
|
||||
let sql: ReturnType<typeof postgres>;
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "0.4.0",
|
||||
apiVersion: "0.3.0",
|
||||
storage: true,
|
||||
hooks: {
|
||||
onBoot: async (boot) => {
|
||||
@@ -920,10 +921,10 @@ The menu is **driven entirely by config** and assembled from two sources:
|
||||
export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } });
|
||||
```
|
||||
|
||||
Every nav item may carry a `permission`; the rendered tree is **filtered per user** from the session
|
||||
JWT (no per-request authz call), so the menu only shows what that person can reach. An item may
|
||||
instead be **`public: true`** (everyone) or **`session: true`** (anyone signed in) — one gate per
|
||||
item, never two.
|
||||
Every nav node a **plugin** declares names one gate — a `permission`, **`public: true`** (everyone)
|
||||
or **`session: true`** (anyone signed in); a header this override groups takes none, and shows
|
||||
whenever a child does. The rendered tree is **filtered per user** from the session JWT (no
|
||||
per-request authz call), so the menu only shows what that person can reach.
|
||||
Branding (name, logo, default theme) renders in the app shell.
|
||||
|
||||
**One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders
|
||||
@@ -1730,6 +1731,7 @@ e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,aut
|
||||
release-tooling/ Everything the release runs: next-version (the bump math), contract-version
|
||||
(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)
|
||||
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
|
||||
.gitea/workflows/ Gitea Actions — see CI/CD
|
||||
```
|
||||
|
||||
@@ -194,11 +194,10 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
await expect(page.locator("h1")).toHaveText("Shifts");
|
||||
await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream
|
||||
|
||||
// The session-gated page asks the upstream for this visitor's rows: the one seeded against the
|
||||
// signed-in admin is there, and another person's shift is not.
|
||||
// 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.locator("table")).toContainText("Night — Escalations");
|
||||
await expect(page.getByText("No shifts are assigned to admin@plainpages.local")).toBeVisible();
|
||||
await expect(page.locator("table")).not.toContainText("Morning — Front desk");
|
||||
});
|
||||
|
||||
|
||||
@@ -179,9 +179,8 @@ test("the reference plugin: public Overview is open to all, My shifts takes any
|
||||
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
|
||||
|
||||
// And the page itself renders for that same member, holding no permission at all. This stack runs
|
||||
// no shifts upstream, so it also pins the degraded page: the reason, never a 500 and never a claim
|
||||
// about what is assigned. The working page is asserted against a real upstream in full-flow.spec.
|
||||
// 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();
|
||||
|
||||
@@ -35,10 +35,9 @@ export function actionForMethod(method: string): AdminAction {
|
||||
return verb === "GET" || verb === "HEAD" ? "read" : "write";
|
||||
}
|
||||
|
||||
// The plugin's nav fragment: an ungated "Admin" header + its three screens, each gated on its own
|
||||
// read permission. The header carries no `permission` because a user may hold one screen's and not
|
||||
// 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.
|
||||
// The plugin's nav fragment: the "Admin" header + its four screens, each gated on its own read
|
||||
// permission. composeNav drops a header left with no visible children, so a user holding none of
|
||||
// them never sees the section. The host current-marks the active item — no `current`/`open` here.
|
||||
export const ADMIN_NAV: NavNode = {
|
||||
children: [
|
||||
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") },
|
||||
@@ -49,6 +48,7 @@ export const ADMIN_NAV: NavNode = {
|
||||
icon: "i-shield",
|
||||
id: "admin",
|
||||
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
|
||||
|
||||
@@ -28,7 +28,7 @@ const clients = on("oauth2-clients");
|
||||
const pluginSettings = on("plugin-settings");
|
||||
|
||||
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],
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ What it demonstrates:
|
||||
- **All three route gates** — the Overview is `public` (anyone), "My shifts" is `session` (any
|
||||
signed-in visitor, showing only rows assigned to them), and "Shifts" is gated on `scheduling:read` /
|
||||
`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
|
||||
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()`.
|
||||
@@ -39,9 +42,14 @@ Your backend must expose two routes; the plugin treats any non-2xx as a recovera
|
||||
|
||||
| Route | Request | Success | Response body |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /shifts` | `Accept: application/json`, optional `?assignee=<who>` | `200` | JSON array of `{ id, title, assignee, start, end }` (all strings; missing fields coerce to `""`). With `assignee`, only that person's rows |
|
||||
| `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 |
|
||||
| `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
|
||||
form re-renders. The plugin only validates that `title` and `assignee` are non-empty.
|
||||
|
||||
@@ -54,4 +62,5 @@ cosmetically) — normalise to your backend's format there if it matters.
|
||||
A user sees the shift list once they hold the `scheduling:read` permission in Keto (and
|
||||
`scheduling:write` 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
|
||||
its whole gate.
|
||||
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.
|
||||
|
||||
@@ -22,6 +22,7 @@ const messages = {
|
||||
"scheduling.new.title": "New shift",
|
||||
"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.overview.mine": "See my shifts",
|
||||
"scheduling.overview.signIn": "Sign in to view shifts",
|
||||
"scheduling.overview.title": "Scheduling",
|
||||
"scheduling.overview.view": "View shifts",
|
||||
|
||||
@@ -18,6 +18,7 @@ const messages: SchedulingMessages = {
|
||||
"scheduling.new.title": "Nytt pass",
|
||||
"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>.",
|
||||
"scheduling.overview.mine": "Visa mina pass",
|
||||
"scheduling.overview.signIn": "Logga in för att se passen",
|
||||
"scheduling.overview.title": "Schemaläggning",
|
||||
"scheduling.overview.view": "Visa pass",
|
||||
|
||||
@@ -12,7 +12,7 @@ let upstreamUrl = "";
|
||||
const upstream = createUpstream(() => upstreamUrl);
|
||||
|
||||
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
|
||||
// settings. A malformed URL already failed the boot by then; the host validated the declared type.
|
||||
@@ -31,6 +31,7 @@ export default definePlugin({
|
||||
icon: "i-cal",
|
||||
id: "scheduling",
|
||||
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>`.
|
||||
|
||||
@@ -25,8 +25,8 @@ function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; us
|
||||
}
|
||||
|
||||
const SHIFTS: Shift[] = [
|
||||
{ assignee: "Avery Kline", end: "12:00", id: "1", start: "08:00", title: "Morning desk" },
|
||||
{ assignee: "Blair Mora", end: "17:00", id: "2", start: "12:00", title: "Afternoon support" },
|
||||
{ assignee: "Avery Kline", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", 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" },
|
||||
];
|
||||
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) => {
|
||||
seen = String(url);
|
||||
assert.equal((init?.headers as Record<string, string>).accept, "application/json");
|
||||
return new Response(JSON.stringify([{ assignee: "A", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 });
|
||||
return new Response(JSON.stringify([{ assignee: "A", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
const shifts = await createUpstream(() => "http://up:4000/", http).list(); // trailing slash trimmed
|
||||
assert.equal(seen, "http://up:4000/shifts");
|
||||
assert.deepEqual(shifts, [{ assignee: "A", end: "2", id: "x", start: "1", title: "T" }]);
|
||||
assert.deepEqual(shifts, [{ assignee: "A", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", end: "2", id: "x", start: "1", title: "T" }]);
|
||||
});
|
||||
|
||||
test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => {
|
||||
@@ -115,14 +115,21 @@ 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) ----
|
||||
|
||||
test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
|
||||
test("overview renders a public page for anyone, and its CTA names the best gate the visitor passes", async () => {
|
||||
const anon = asView(await overview()(fakeCtx())); // user null, no permissions
|
||||
assert.equal(anon.view, "overview");
|
||||
assert.equal(anon.data["chrome"], CHROME);
|
||||
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"] })));
|
||||
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 ----
|
||||
@@ -174,19 +181,35 @@ test("buildFormModel marks title/assignee required and attaches field errors", (
|
||||
|
||||
// ---- the session-gated page: the visitor's own rows ----
|
||||
|
||||
test("my shifts asks the upstream for the visitor's own rows, and names them in the empty state", async () => {
|
||||
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@example.test", end: "22:00", id: "3", start: "17:00", title: "Evening on-call" };
|
||||
let asked: { assignee?: string } | undefined;
|
||||
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, { assignee: "Blair.Mora@example.test" });
|
||||
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
|
||||
|
||||
// The route carries `session: true`, but the handler asserts the session itself rather than trusting it.
|
||||
// `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
|
||||
});
|
||||
|
||||
@@ -22,7 +22,8 @@ export const WRITE = "scheduling:write"; // the permission gating create
|
||||
|
||||
export interface Shift {
|
||||
id: string;
|
||||
assignee: string;
|
||||
assignee: string; // display name, rendered in the table
|
||||
assigneeId: string; // who the shift belongs to — an opaque id, the same one `ctx.user.id` carries
|
||||
end: string;
|
||||
start: string;
|
||||
title: string;
|
||||
@@ -47,9 +48,9 @@ export class UpstreamError extends Error {
|
||||
|
||||
export interface ShiftsUpstream {
|
||||
create(input: ShiftInput): Promise<void>;
|
||||
// `assignee` scopes the read at the source, which is where an ownership rule belongs (README →
|
||||
// `assigneeId` scopes the read at the source, which is where an ownership rule belongs (README →
|
||||
// Three tiers of "may I?"); without it the caller would hold everyone's rows to render one page.
|
||||
list(opts?: { assignee?: string }): Promise<Shift[]>;
|
||||
list(opts?: { assigneeId?: string }): Promise<Shift[]>;
|
||||
}
|
||||
|
||||
// REST client over the upstream service (a stand-in for the customer's real backend). `fetch`
|
||||
@@ -69,7 +70,7 @@ export function createUpstream(baseUrl: () => string, fetchImpl: typeof fetch =
|
||||
if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status);
|
||||
},
|
||||
async list(opts = {}) {
|
||||
const query = opts.assignee == null ? "" : `?${new URLSearchParams({ assignee: opts.assignee })}`;
|
||||
const query = opts.assigneeId == null ? "" : `?${new URLSearchParams({ assigneeId: opts.assigneeId })}`;
|
||||
const res = await fetchImpl(`${base()}/shifts${query}`, { headers: { accept: "application/json" } });
|
||||
if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status);
|
||||
const data: unknown = await res.json();
|
||||
@@ -82,7 +83,7 @@ const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? ""
|
||||
|
||||
function toShift(raw: unknown): Shift {
|
||||
const r = (raw ?? {}) as Record<string, unknown>;
|
||||
return { assignee: str(r["assignee"]), end: str(r["end"]), id: str(r["id"]), start: str(r["start"]), title: str(r["title"]) };
|
||||
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"]) };
|
||||
}
|
||||
|
||||
// ---- view models (pure; the EJS views read these) -----------------------------------
|
||||
@@ -196,7 +197,10 @@ export function myShifts(upstream: ShiftsUpstream): RouteHandler {
|
||||
let shifts: Shift[] = [];
|
||||
let error: string | undefined;
|
||||
try {
|
||||
shifts = await upstream.list({ assignee: user.email });
|
||||
// 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");
|
||||
@@ -215,8 +219,7 @@ export function buildMineModel(opts: { chrome: PageChrome; email: string; error?
|
||||
table: {
|
||||
caption: t("scheduling.mine.title"),
|
||||
columns: [{ label: t("scheduling.table.shift") }, { label: t("scheduling.table.start") }, { label: t("scheduling.table.end") }],
|
||||
// Only when the upstream answered: a failed read knows nothing about what is assigned.
|
||||
...(opts.error === undefined ? { emptyText: t("scheduling.mine.empty", { email: opts.email }) } : {}),
|
||||
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"),
|
||||
@@ -233,7 +236,9 @@ export function overview(): RouteHandler {
|
||||
breadcrumbs: [{ label: ctx.t("scheduling.nav.overview") }],
|
||||
canRead: can(ctx, READ),
|
||||
chrome: ctx.chrome,
|
||||
mineHref: ctx.localeHref(MINE_PATH),
|
||||
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))}`),
|
||||
title: ctx.t("scheduling.overview.title"),
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : "";
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
body: '<div class="scheduling-page">' + alertHtml + '<p class="shift-count">' + count + '</p>' + tableHtml + '</div>',
|
||||
body: '<div class="scheduling-page">' + alertHtml + (locals.error ? '' : '<p class="shift-count">' + count + '</p>' + tableHtml) + '</div>',
|
||||
brand: chrome.brand,
|
||||
breadcrumbs,
|
||||
csrfToken: chrome.csrfToken,
|
||||
|
||||
@@ -3,12 +3,16 @@
|
||||
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
|
||||
it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome.
|
||||
Data: chrome, title, breadcrumbs, canRead, shiftsHref, signInHref
|
||||
Data: chrome, title, breadcrumbs, canRead, mineHref, shiftsHref, signedIn, signInHref
|
||||
%><%
|
||||
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
|
||||
? '<a class="btn btn-primary" href="' + shiftsHref + '">' + t("scheduling.overview.view") + '</a>'
|
||||
: '<a class="btn btn-primary" href="' + signInHref + '">' + t("scheduling.overview.signIn") + '</a>';
|
||||
: signedIn
|
||||
? '<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", {
|
||||
actions: "",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// of the app: stdlib only, in-memory (state resets on restart), no auth. Point PLUGIN_SETTING_SCHEDULING_UPSTREAM
|
||||
// at your real service in production.
|
||||
//
|
||||
// GET /shifts → 200 [ { id, title, assignee, start, end }, … ] (?assignee=<who> → only theirs)
|
||||
// GET /shifts → 200 [ { id, title, assigneeId, assignee, start, end }, … ] (?assigneeId=<id> → only theirs)
|
||||
// POST /shifts → 201 { id, … } (body: { title, assignee, start, end })
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -11,11 +11,12 @@ import { createServer } from "node:http";
|
||||
|
||||
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 = [
|
||||
{ id: randomUUID(), title: "Morning — Front desk", assignee: "avery.kline@plainpages.local", start: "2026-06-22 08:00", end: "2026-06-22 12:00" },
|
||||
{ id: randomUUID(), title: "Afternoon — Support", assignee: "blair.mora@plainpages.local", start: "2026-06-22 12:00", end: "2026-06-22 17:00" },
|
||||
{ id: randomUUID(), title: "Evening — On-call", assignee: "casey.nguyen@plainpages.local", start: "2026-06-22 17:00", end: "2026-06-22 22:00" },
|
||||
{ id: randomUUID(), title: "Night — Escalations", assignee: "admin@plainpages.local", start: "2026-06-22 22:00", end: "2026-06-23 06:00" },
|
||||
{ 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: "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: "Evening — On-call", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", assignee: "Casey Nguyen", start: "2026-06-22 17:00", end: "2026-06-22 22:00" },
|
||||
];
|
||||
|
||||
const json = (res, status, body) => {
|
||||
@@ -35,13 +36,13 @@ const readBody = (req) =>
|
||||
createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
if (url.pathname === "/shifts" && req.method === "GET") {
|
||||
const assignee = url.searchParams.get("assignee");
|
||||
if (assignee === null) return json(res, 200, shifts);
|
||||
return json(res, 200, shifts.filter((s) => s.assignee.toLowerCase() === assignee.toLowerCase()));
|
||||
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") {
|
||||
const b = await readBody(req);
|
||||
const shift = { id: randomUUID(), assignee: String(b.assignee ?? ""), end: String(b.end ?? ""), start: String(b.start ?? ""), title: String(b.title ?? "") };
|
||||
const shift = { id: randomUUID(), assignee: String(b.assignee ?? ""), assigneeId: "", end: String(b.end ?? ""), start: String(b.start ?? ""), title: String(b.title ?? "") };
|
||||
shifts.push(shift);
|
||||
return json(res, 201, shift);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
// 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.
|
||||
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", () => {
|
||||
|
||||
@@ -182,7 +182,7 @@ into the app. Create `plugins/hello/plugin.ts`:
|
||||
import { definePlugin } from "@plainpages/plugin-api";
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "0.4.0",
|
||||
apiVersion: "0.3.0",
|
||||
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
||||
routes: [
|
||||
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
||||
|
||||
@@ -23,6 +23,6 @@ test("gatesSet names the gates a declaration sets, so discovery can refuse more
|
||||
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"]);
|
||||
// `false` is not a gate — only a set one counts, so { session: false } is an ungated route.
|
||||
// Only `true` sets a gate, so a manifest spelling one `false` names none — which discovery refuses.
|
||||
assert.deepEqual(gatesSet({ public: false, session: false }), []);
|
||||
});
|
||||
|
||||
+14
-2
@@ -92,12 +92,24 @@ test("completeLogin returns null and touches nothing when there is no active ses
|
||||
assert.equal(touched, false);
|
||||
});
|
||||
|
||||
test("completeLogin maps a missing email trait to null and throws if the tokenizer yields no JWT", async () => {
|
||||
const identity: Identity = { id: ID, traits: {} };
|
||||
test("completeLogin throws if the tokenizer yields no JWT", async () => {
|
||||
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
||||
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/);
|
||||
});
|
||||
|
||||
// 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 () => {
|
||||
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 });
|
||||
|
||||
+9
-5
@@ -31,7 +31,7 @@ export interface LoginDeps {
|
||||
}
|
||||
|
||||
export interface CompletedLogin {
|
||||
email: string | null;
|
||||
email: string;
|
||||
userId: string;
|
||||
jwt: string;
|
||||
permissions: string[];
|
||||
@@ -61,7 +61,13 @@ export async function completeLogin(deps: LoginDeps, cookie: string | undefined)
|
||||
if (!session?.identity) return null;
|
||||
const userId = session.identity.id;
|
||||
const emailTrait = session.identity.traits?.["email"];
|
||||
const email = typeof emailTrait === "string" ? emailTrait : null;
|
||||
const email = typeof emailTrait === "string" ? emailTrait : "";
|
||||
// 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);
|
||||
await deps.kratosAdmin.updateMetadataPublic(userId, { permissions });
|
||||
@@ -86,9 +92,7 @@ export interface Reminted {
|
||||
// anonymous instead of re-hitting Ory on every one.
|
||||
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
|
||||
const completed = await completeLogin(deps, cookie);
|
||||
// No email is no session, exactly as `claimsToUser` reads a token carrying none: a User with an
|
||||
// empty email reads as anonymous in the shell, and is a blank key to whatever scopes on it.
|
||||
if (!completed?.email) 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 } };
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { AUTH_FLOWS } from "./flow-view.ts";
|
||||
import { gatesSet } from "./gate.ts";
|
||||
import type { HydraAdmin } from "./hydra-admin.ts";
|
||||
import type { KetoClient } from "./keto-client.ts";
|
||||
import type { KratosAdmin } from "./kratos-admin.ts";
|
||||
@@ -39,8 +40,12 @@ 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", () => {
|
||||
const got = keys(buildAuthRoutes(deps({ hydra, keto, kratos, kratosAdmin })));
|
||||
const routes = 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"]) {
|
||||
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[] = [];
|
||||
if (kratos) {
|
||||
for (const [path, flowType] of Object.entries(AUTH_FLOWS)) {
|
||||
routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path });
|
||||
routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path, public: true });
|
||||
}
|
||||
routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout" });
|
||||
routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout", public: true });
|
||||
}
|
||||
if (hydra && kratos) {
|
||||
const provider = { hydra, kratos };
|
||||
routes.push({ handler: oauthLogin(provider, secureCookies), method: "GET", path: "/oauth2/login" });
|
||||
routes.push({ handler: consentScreen(provider, menu.branding.name), method: "GET", path: "/oauth2/consent" });
|
||||
routes.push({ handler: consentDecision(provider), method: "POST", path: "/oauth2/consent" });
|
||||
routes.push({ handler: oauthLogin(provider, secureCookies), method: "GET", path: "/oauth2/login", public: true });
|
||||
routes.push({ handler: consentScreen(provider, menu.branding.name), method: "GET", path: "/oauth2/consent", public: true });
|
||||
routes.push({ handler: consentDecision(provider), method: "POST", path: "/oauth2/consent", public: true });
|
||||
}
|
||||
if (hydra) routes.push({ handler: oauthLogout(hydra), method: "GET", path: "/oauth2/logout" });
|
||||
if (hydra) routes.push({ handler: oauthLogout(hydra), method: "GET", path: "/oauth2/logout", public: true });
|
||||
if (kratos && kratosAdmin && keto) {
|
||||
routes.push({ handler: completeAuth({ keto, kratosAdmin, kratosPublic: kratos }, secureCookies), method: "GET", path: "/auth/complete" });
|
||||
routes.push({ handler: completeAuth({ keto, kratosAdmin, kratosPublic: kratos }, secureCookies), method: "GET", path: "/auth/complete", public: true });
|
||||
}
|
||||
routes.push({ handler: errorSink, method: "GET", path: "/error" });
|
||||
routes.push({ handler: errorSink, method: "GET", path: "/error", public: true });
|
||||
return routes;
|
||||
}
|
||||
|
||||
+13
-9
@@ -28,7 +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 type { PluginSettings } from "../plugin-host/settings.ts";
|
||||
import type { SystemCapabilities } from "../plugin-host/system.ts";
|
||||
import { allows } from "../auth/gate.ts";
|
||||
import { allows, type Gate } from "../auth/gate.ts";
|
||||
import { allowedMethods, matchRoute } from "../plugin-host/router.ts";
|
||||
import { buildAuthRoutes } from "../auth/routes.ts";
|
||||
import { securityHeaders } from "./security-headers.ts";
|
||||
@@ -157,7 +157,6 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in
|
||||
// starter page.
|
||||
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.
|
||||
csrf.setCookie();
|
||||
if (dashboardPlugin) {
|
||||
@@ -174,8 +173,8 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// routes.ts, capability-gated on the wired clients) plus the two landing slots above.
|
||||
const builtinRoutes: BuiltinRoute[] = [
|
||||
...buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }),
|
||||
{ handler: serveHome, method: "GET", path: "/" },
|
||||
{ handler: serveDashboard, method: "GET", path: "/dashboard" },
|
||||
{ handler: serveHome, method: "GET", path: "/", public: true },
|
||||
{ handler: serveDashboard, method: "GET", path: "/dashboard", session: true },
|
||||
];
|
||||
|
||||
// The request handler. Run inside runWithLog (below) so the per-request logger is ambient: every
|
||||
@@ -279,15 +278,19 @@ 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);
|
||||
if (match) {
|
||||
const routeCtx = contextFor(match.plugin.id, match.params);
|
||||
if (!allows(match.route, routeCtx.user)) {
|
||||
// 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", {}));
|
||||
await refuse(match.route, routeCtx);
|
||||
return;
|
||||
}
|
||||
csrfMint.setCookie();
|
||||
@@ -301,6 +304,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
|
||||
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
|
||||
if (builtin) {
|
||||
if (!allows(builtin, ctx.user)) { await refuse(builtin, ctx); return; }
|
||||
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// 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
|
||||
// pipes the result through sendResult against the core views.
|
||||
import type { Gate } from "../auth/gate.ts";
|
||||
import type { RequestContext } from "./context.ts";
|
||||
import type { RouteResult } from "../plugin-host/plugin.ts";
|
||||
|
||||
@@ -19,7 +20,7 @@ export interface RequestCsrf {
|
||||
// own context — otherwise the plugin's keys render as bare keys on the pages it owns.
|
||||
export type PluginContextFactory = (pluginId: string) => RequestContext;
|
||||
|
||||
export interface BuiltinRoute {
|
||||
export interface BuiltinRoute extends Gate {
|
||||
// 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).
|
||||
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 =>
|
||||
`export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "${id}:root", label: "${id}" }], ` +
|
||||
`routes: [{ method: "GET", path: "/", handler: () => ({ html: "${id}" }) }] };`;
|
||||
`export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "${id}:root", label: "${id}", public: true }], ` +
|
||||
`routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "${id}" }) }] };`;
|
||||
|
||||
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") }), []);
|
||||
@@ -67,6 +67,10 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
|
||||
{ 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
|
||||
// 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 },
|
||||
@@ -139,7 +143,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/index.js": `export default (n) => \`\${n} kr\`;`,
|
||||
"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: "/", handler: () => ({ html: price(20) }) }] });`,
|
||||
`export default definePlugin({ apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: price(20) }) }] });`,
|
||||
});
|
||||
|
||||
const plugins = await discoverPlugins({ dir });
|
||||
|
||||
@@ -148,57 +148,42 @@ function shapeError(manifest: PluginManifest): string | null {
|
||||
if (settings) return settings;
|
||||
}
|
||||
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
||||
const flag = gateFlagError(`route "${route?.method} ${route?.path}"`, route);
|
||||
if (flag) return flag;
|
||||
const gates = gatesSet(route);
|
||||
if (gates.length > 1) return `route "${route?.method} ${route?.path}" sets ${gates.join(" and ")}; a route names exactly one gate — public, session or permission`;
|
||||
}
|
||||
const navContradiction = findNavGateContradiction(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 gate = gateError(`route "${route?.method} ${route?.path}"`, route);
|
||||
if (gate) return gate;
|
||||
}
|
||||
const navGate = findNavGateError(manifest.nav);
|
||||
if (navGate) return navGate;
|
||||
for (const decl of Array.isArray(manifest.permissions) ? manifest.permissions : []) {
|
||||
if (decl?.name == null || !isValidPermissionName(decl.name)) {
|
||||
return `declared permission "${decl?.name}" is not <resource>:<action>, e.g. "things:read"`;
|
||||
}
|
||||
}
|
||||
const navPermission = findInvalidNavPermission(manifest.nav);
|
||||
if (navPermission) return navPermission;
|
||||
return null;
|
||||
}
|
||||
|
||||
// A truthy non-boolean sets no gate at all, so `session: "yes"` would read as an open page.
|
||||
function gateFlagError(what: string, gate: Gate | null | undefined): string | null {
|
||||
// Every rule a declaration's gate must satisfy. Exactly one gate, always: a missing one would be an
|
||||
// open page nobody chose, and anything but `true` (a `false`, a `"yes"`) sets no gate while looking
|
||||
// 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 && typeof value !== "boolean") return `${what} sets ${flag} to ${JSON.stringify(value)}; a gate is declared with \`true\``;
|
||||
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 findNavGateContradiction(nodes: PluginManifest["nav"]): string | null {
|
||||
function findNavGateError(nodes: PluginManifest["nav"]): string | null {
|
||||
for (const node of Array.isArray(nodes) ? nodes : []) {
|
||||
const flag = gateFlagError(`nav node "${node?.label ?? node?.id ?? "?"}"`, node);
|
||||
if (flag) return flag;
|
||||
const gates = gatesSet(node);
|
||||
if (gates.length > 1) return `nav node "${node?.label ?? node?.id ?? "?"}" sets ${gates.join(" and ")}; a node names exactly one gate — public, session or permission`;
|
||||
const inChild = findNavGateContradiction(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);
|
||||
const err = gateError(`nav node "${node?.label ?? node?.id ?? "?"}"`, node);
|
||||
if (err) return err;
|
||||
const inChild = findNavGateError(node?.children);
|
||||
if (inChild) return inChild;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -14,6 +14,8 @@ export type { RequestContext, User } from "../http/context.ts";
|
||||
export type { PageChrome } from "../ui/chrome.ts";
|
||||
export type { NavNode } from "../ui/nav.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
|
||||
// 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.
|
||||
|
||||
@@ -11,7 +11,7 @@ import { envName, type SettingDecl, type SettingsOf } from "./settings.ts";
|
||||
import type { StorageCredentials } from "./storage.ts";
|
||||
|
||||
// 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";
|
||||
|
||||
@@ -25,7 +25,6 @@ export type RouteResult =
|
||||
|
||||
export type RouteHandler = (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void;
|
||||
|
||||
// `Gate` carries `permission`/`public`/`session`, checked before the handler runs.
|
||||
export interface Route extends Gate {
|
||||
handler: RouteHandler;
|
||||
method: HttpMethod;
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { User } from "../http/context.ts";
|
||||
import { ENGLISH } from "../i18n/english.ts";
|
||||
import type { Translate } from "../i18n/translate.ts";
|
||||
|
||||
// `Gate` carries `permission`/`public`/`session` — consumed by the filter, never rendered.
|
||||
export interface NavNode extends Gate {
|
||||
id?: string; // stable key for override targeting; stripped from the rendered tree
|
||||
children?: NavNode[];
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
- [ ] 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`.
|
||||
- [ ] 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)
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
<p>${t("dashboard.starter.intro")}</p>
|
||||
<p>${t("dashboard.starter.replace")}</p>
|
||||
<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
|
||||
dashboard: (ctx) => ({ view: "dashboard", data: { /* … */ } }),
|
||||
});</code></pre>
|
||||
|
||||
Reference in New Issue
Block a user