Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab71c6613b |
@@ -8,8 +8,6 @@ jobs:
|
||||
runs-on: docker-host
|
||||
steps:
|
||||
- uses: actions/checkout@v4.2.2
|
||||
with:
|
||||
fetch-depth: 0 # ci.sh's docs-only check needs history; checkout defaults to depth 1
|
||||
- run: bash ci.sh
|
||||
- name: Push app image tagged with the commit hash
|
||||
env:
|
||||
|
||||
@@ -19,4 +19,4 @@ jobs:
|
||||
run: |
|
||||
docker run --rm -v "$PWD:/repo" -w /repo \
|
||||
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
|
||||
node:24.18.1-alpine3.24 node registry-cleanup/cleanup.ts
|
||||
node:24.18.0-alpine3.24 node registry-cleanup/cleanup.ts
|
||||
|
||||
@@ -10,18 +10,16 @@ jobs:
|
||||
steps:
|
||||
- name: Run Renovate against this repo
|
||||
env:
|
||||
GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_TOKEN }}
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
run: |
|
||||
docker run --rm \
|
||||
-e GITHUB_COM_TOKEN \
|
||||
-e LOG_LEVEL=info \
|
||||
-e RENOVATE_ENDPOINT=https://gitea.larvit.se/api/v1 \
|
||||
-e RENOVATE_GIT_AUTHOR="Renovate Bot <renovate@larvit.se>" \
|
||||
-e RENOVATE_PLATFORM=gitea \
|
||||
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
|
||||
-e RENOVATE_TOKEN \
|
||||
renovate/renovate:44.6.0
|
||||
renovate/renovate:43.280.4
|
||||
|
||||
# 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
|
||||
@@ -54,7 +52,7 @@ jobs:
|
||||
fi
|
||||
BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \
|
||||
--format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; })
|
||||
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.18.1-alpine3.24 \
|
||||
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.16.0-alpine3.24 \
|
||||
node auto-release/next-version.ts "$LATEST" $BUMPS)
|
||||
echo "Releasing $LATEST -> $NEXT"
|
||||
git tag "$NEXT" origin/main
|
||||
|
||||
@@ -7,7 +7,7 @@ commands and layout.
|
||||
|
||||
Use the file `todo.md`.
|
||||
|
||||
For each todo item, interview the user extensively to deeply understand the scope and goal of each. When done, check the completed task in `todo.md`. Commit all changes and push to a new branch, create a PR and merge it when the CI/CD turns green.
|
||||
For each todo item, interview the user extensively to deeply understand the scope and goal of each. When done, run the stability reviewer agent in a loop and address all feedback until there is none. If you are not very confident of how to address it, ask the user. Check the completed task in this file. Commit all changes and push to a new branch, create a PR and merge it when the CI/CD turns green.
|
||||
|
||||
## Project priorities (do not erode)
|
||||
|
||||
@@ -89,10 +89,8 @@ them. Revisit only if the stated reason stops holding.
|
||||
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
|
||||
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`:
|
||||
concurrent jobs can race (one job's logout can 401 another's push — recover by re-running),
|
||||
and tokens sit in that file between login and logout. Same class: concurrent runs share the
|
||||
workspace dir, so ci.sh's web-image build races another run's container creation on the
|
||||
`<project>-web` tag. Accepted for a single-maintainer cadence; serialize with a workflow
|
||||
`concurrency` group if it ever bites.
|
||||
and tokens sit in that file between login and logout. Accepted for a single-maintainer
|
||||
cadence; serialize with a workflow `concurrency` group if it ever bites.
|
||||
|
||||
## Docker only — no host tooling
|
||||
|
||||
@@ -120,27 +118,15 @@ docker compose -f compose.yml up --build -d # production
|
||||
running **building plugins** comes first, then **configuring and securing** the system
|
||||
(Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are
|
||||
deliberately deferred — they're not top of mind when starting out. Concretely: Overview →
|
||||
Users, groups & roles → Building plugins → menu/blocks/interactivity → Configuration → Auth →
|
||||
Email → Architecture → Testing → Production → Observability → the JWT-rotation runbook → the
|
||||
Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
|
||||
Architecture → Testing → Production → Observability → the JWT-rotation runbook → the
|
||||
Project-layout file map → Extending. When adding a section, place it by this value (how
|
||||
early an adopter needs it), not by where it sits in the stack.
|
||||
|
||||
**Users, groups & roles precedes Building plugins** because a manifest's `role:` gate is
|
||||
unreadable without the model, and operators need it as much as plugin authors. It is the one
|
||||
home for that model — the plugin and auth sections link to it rather than restating it.
|
||||
|
||||
When editing: put content in the section it belongs to (don't prepend rationale above Quick
|
||||
start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in
|
||||
one home, linking to it rather than restating (credentials, env vars, rotation steps).
|
||||
|
||||
**Don't document internals here.** How a script reaches a decision, why one run behaved
|
||||
differently from another, what a function guards — a developer doesn't need it day to day and
|
||||
can read it off the code or a run's log in seconds. Prose like that only makes the README
|
||||
longer and harder to consume, for humans and machines alike. It belongs in the code it
|
||||
describes, or nowhere. The README earns its length on what you cannot dig out: how to use and
|
||||
operate Plainpages, the external contracts, and one-time setup (secrets, accounts, tokens).
|
||||
Same test before adding a row to a table or the file map — a clause, not a paragraph.
|
||||
|
||||
## Rules
|
||||
|
||||
- Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable**
|
||||
@@ -156,10 +142,6 @@ Same test before adding a row to a table or the file map — a clause, not a par
|
||||
- Tests use the built-in `node --test` runner — no test framework dependency.
|
||||
- English everywhere. Keep code comments short and information-dense. Self explained code
|
||||
without any comment at all is the preferred solution.
|
||||
- Do not comment about history in the code or README. Like "This function included X before,
|
||||
but it moved to Y".
|
||||
- Do not comment about the absence of things, if it is not very unexpected. Banned is things
|
||||
like "This function does not calculate pi, that is done in function Z".
|
||||
- Pin all dependencies and Docker images to exact, human-readable **semantic
|
||||
versions** — never ranges (`^`, `~`) and never digests/hashes. npm deps are kept
|
||||
exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g.
|
||||
@@ -174,8 +156,9 @@ Same test before adding a row to a table or the file map — a clause, not a par
|
||||
that re-parses `ctx.url.pathname`: it duplicates the URL shape, ignores the router's params, and
|
||||
has to re-handle HEAD. Factor shared per-request setup (auth gate, `ctx.system` capability
|
||||
resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`.
|
||||
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer
|
||||
agents. Decided 2026-08-02, replacing the earlier run-after-every-implementation rule.
|
||||
- Run the stability reviewer agent after every implementation of something that can be like
|
||||
a PR. That includes any change pushed directly to main.
|
||||
Skip this if the changes are purely documentation and/or comments.
|
||||
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POST:ing in for
|
||||
for example list pages with filters and pagination. Do: "ids=x&ids=y" and not "ids[]=x&ids[]=y"
|
||||
and not "ids=x,y".
|
||||
and not "ids=x,y".
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
|
||||
FROM node:24.18.1-alpine3.24
|
||||
FROM node:24.18.0-alpine3.24
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
+1
-1
@@ -190,4 +190,4 @@ export default definePlugin({
|
||||
```
|
||||
|
||||
Restart (`docker compose restart web`) and visit <http://localhost:3000/hello>. Views,
|
||||
forms, roles, and the runnable reference plugin: repo README → Building plugins.
|
||||
forms, permissions, and the runnable reference plugin: repo README → Building plugins.
|
||||
|
||||
@@ -69,10 +69,6 @@ From here, render real pages against the app shell and fetch upstream data — s
|
||||
|
||||
- [Overview](#overview)
|
||||
- [how it compares](#how-it-compares)
|
||||
- [Users, groups & roles](#users-groups--roles)
|
||||
- [a worked example](#a-worked-example)
|
||||
- [granting a role](#granting-a-role)
|
||||
- [fine-grained, per-row access](#fine-grained-per-row-access)
|
||||
- [Building plugins](#building-plugins)
|
||||
- [anatomy](#anatomy-of-a-plugin)
|
||||
- [the manifest](#the-manifest)
|
||||
@@ -80,7 +76,7 @@ From here, render real pages against the app shell and fetch upstream data — s
|
||||
- [landing pages](#the-landing-pages-home--dashboard)
|
||||
- [RequestContext](#requestcontext)
|
||||
- [system capabilities (ctx.system)](#system-capabilities-the-ctxsystem-surface)
|
||||
- [nav & role gates](#nav--role-gates)
|
||||
- [nav & permissions](#nav--permissions)
|
||||
- [versioning](#contract-versioning)
|
||||
- [conflict rules](#conflict-rules)
|
||||
- [hooks](#hooks)
|
||||
@@ -93,12 +89,11 @@ From here, render real pages against the app shell and fetch upstream data — s
|
||||
- [canonical host](#canonical-host-one-public-url)
|
||||
- [what you must supply](#what-you-must-supply-the-only-manual-prep)
|
||||
- [SSO](#social-sign-in-sso)
|
||||
- [Auth, sessions & access](#auth-sessions--access)
|
||||
- [Auth, sessions & permissions](#auth-sessions--permissions)
|
||||
- [login & the session JWT](#login-and-the-session-jwt)
|
||||
- [instant revoke](#instant-revoke-the-optional-denylist)
|
||||
- [three tiers](#three-tiers-of-may-i)
|
||||
- [OAuth2 (Hydra)](#oauth2-provider-hydra)
|
||||
- [security model](#security-model)
|
||||
- [Email](#email)
|
||||
- [Architecture](#architecture)
|
||||
- [Stateless](#stateless)
|
||||
@@ -120,7 +115,7 @@ or gated**, so the same foundation serves a purely public site, a fully locked-d
|
||||
tool, or the common middle: a public front with an authenticated area behind it. Its **sweet
|
||||
spot** is the **back-office and operational tooling** you'd otherwise hand-roll for the tenth
|
||||
time, but nothing ties it to internal-only use. The core itself ships **no domain screens at
|
||||
all** — even the screens for running the system (**users, groups, roles**) are a **drop-in
|
||||
all** — even the screens for running the system (**users, groups, permissions**) are a **drop-in
|
||||
plugin** you opt into ([`examples/plugins/admin/`](examples/plugins/admin/)). Everything is a plugin.
|
||||
|
||||
**Who it's for.** Experienced developers building server-rendered web products — back-office
|
||||
@@ -128,7 +123,7 @@ and operational tools, dashboards, portals, or public sites with a gated area
|
||||
use or for a client. You know HTTP, Docker, and identity
|
||||
providers, and you'd rather assemble pages from building blocks than fight a framework or
|
||||
hand-roll auth for the tenth time. It's not a no-code tool and doesn't hide its moving
|
||||
parts: if "Ory is down ⇒ no logins" (see [Auth](#auth-sessions--access)) reads as
|
||||
parts: if "Ory is down ⇒ no logins" (see [Auth](#auth-sessions--permissions)) reads as
|
||||
obvious rather than surprising, you're the audience.
|
||||
|
||||
**Included vs. what you add.**
|
||||
@@ -146,7 +141,7 @@ obvious rather than surprising, you're the audience.
|
||||
**Priorities (unchanged from day one):** **simplicity, few dependencies, strict
|
||||
TypeScript, no build step, Docker-only, environment-agnostic** (no `NODE_ENV` — every
|
||||
behaviour is an explicit config toggle). Heavy lifting that *isn't* simple to do well —
|
||||
identity, sessions, SSO, OAuth2, role checks — is delegated to **Ory** sidecar
|
||||
identity, sessions, SSO, OAuth2, permission checks — is delegated to **Ory** sidecar
|
||||
services rather than reinvented. "Simple" is about the *whole architecture* staying simple
|
||||
— not just at the start, but after you've dropped in 240 plugins and run it hard in
|
||||
production. The shape doesn't change as it grows: every plugin is the same self-contained
|
||||
@@ -194,133 +189,10 @@ Plainpages sits relative to them:
|
||||
| **Themed auth UI on Ory** — Kratos self-service UIs (`ory/kratos-selfservice-ui-node`, `kratos-admin-ui`) | the **login / registration screens** over Ory | The one *slice* with a direct off-the-shelf alternative: Plainpages reimplements it inside its own shell, so you could swap it out to avoid maintaining that part. |
|
||||
|
||||
No family combines the whole set: **[drop-in plugin folders](#building-plugins)**, a **zero-JS
|
||||
server-rendered** design system, **[optional auth](#auth-sessions--access)** (any page
|
||||
server-rendered** design system, **[optional auth](#auth-sessions--permissions)** (any page
|
||||
public or gated), **no app database**, and a **framework-light TypeScript** core with no build
|
||||
step. Each neighbour shares one trait and trades away the rest — Plainpages is the intersection.
|
||||
|
||||
## Users, groups & roles
|
||||
|
||||
Authorization here is **two hops, not three**: a user — directly, or through a group — is a
|
||||
member of a **role**, and that role's *name* is exactly the string a plugin gates on. There is no
|
||||
separate "permission" object to define, register, or wire up.
|
||||
|
||||
- **Group** answers *who* — a reusable set of people. Optional: a role can be granted straight to a user.
|
||||
- **Role** answers *what* — its **name is the string** you write in a manifest's `role:` gate.
|
||||
- **A relation tuple** is the grant: `Role:<name>#members@identity:<id>`, or `@Group:<name>#members`.
|
||||
- **Resource** answers *which row* — a live check, run only where a plugin explicitly asks for it.
|
||||
|
||||
| Entity | Lives in | Answers | Example |
|
||||
| --- | --- | --- | --- |
|
||||
| **Identity** | Kratos | who you are | `identity:0198f2c1-…` |
|
||||
| **Group** | Keto | who — a reusable set | `Group:support` |
|
||||
| **Role** | Keto | what you may do | `Role:scheduling:read` |
|
||||
| **Resource** | Keto | which specific row | `Resource:shift-4471` |
|
||||
|
||||
Identities live in Kratos; every authorization edge is a Keto relation tuple. The app itself
|
||||
stores none of it — it is [stateless](#stateless).
|
||||
|
||||
**Keto ships no entities of its own.** Its entire model is one primitive —
|
||||
`namespace:object#relation@subject` — so the four namespaces above are *ours*, declared in
|
||||
`ory/keto/namespaces.keto.ts`; Keto only supplies the machinery that resolves them (including
|
||||
transitively, through nested groups). `Identity` is named to match Kratos, which owns that
|
||||
record. `Group`, `Role` and `Resource` have no upstream counterpart to match, so they use the
|
||||
ordinary words.
|
||||
|
||||
> **On the word "permission".** Ory uses it for the fine-grained `Resource` tier — the `permits`
|
||||
> block (`view`/`edit`/`delete`). Plainpages therefore never uses it for the coarse tier: what a
|
||||
> route or a menu item gates on is a **role**, always.
|
||||
|
||||
### A worked example
|
||||
|
||||
Alice works support and leads scheduling; Bob works support; Carol administers the system.
|
||||
|
||||
```
|
||||
people groups roles
|
||||
────── ────── ─────
|
||||
|
||||
alice ──┬─────────> Group:support ────┐
|
||||
│ ├──> Group:staff ──> Role:scheduling:read
|
||||
bob ────┘ │
|
||||
│
|
||||
alice ────────────> Group:sched-leads ┴──> Role:scheduling:write
|
||||
|
||||
carol ───────────────────────────────────────────────> Role:admin
|
||||
```
|
||||
|
||||
At login the host asks Keto which roles the user holds, walking those arrows transitively, and
|
||||
bakes the answer into the session JWT (see [Login and the session
|
||||
JWT](#login-and-the-session-jwt)):
|
||||
|
||||
```
|
||||
alice → roles: ["scheduling:read", "scheduling:write"]
|
||||
bob → roles: ["scheduling:read"]
|
||||
carol → roles: ["admin"]
|
||||
```
|
||||
|
||||
Note what Carol does *not* have. **There is no role hierarchy and no superuser** — `admin` is
|
||||
just another name, granting nothing except where a route gates on `admin` itself.
|
||||
|
||||
Against the reference plugins' actual routes:
|
||||
|
||||
| Request | Gate | alice | bob | carol | anonymous |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `GET /scheduling` | `public: true` | ✅ | ✅ | ✅ | ✅ |
|
||||
| `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` |
|
||||
| `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
||||
| `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
||||
| `GET /admin/users` | `admin` | 403 | 403 | ✅ | → `/login` |
|
||||
|
||||
Bob reaches the shifts list with no direct grant: he is in `support`, support's members are
|
||||
`staff`, and staff holds `scheduling:read` — two hops, resolved by Keto at his login. He is
|
||||
refused the new-shift form because `scheduling:write` hangs off `sched-leads`, which he is not in.
|
||||
An anonymous visitor gets a **redirect**, not a 403, carrying `return_to` so signing in lands them
|
||||
on the page they asked for; a signed-in user who merely lacks the role gets the 403 page, because
|
||||
there is nothing to sign in *as* that would help. The menu is filtered by the same roles, so
|
||||
nobody is shown a door they cannot open.
|
||||
|
||||
### Granting a role
|
||||
|
||||
Write the tuple. The admin plugin's **Groups** and **Roles** screens do exactly this, or use
|
||||
Keto's write API directly:
|
||||
|
||||
```bash
|
||||
# everyone in sched-leads may write shifts
|
||||
curl -X PUT http://keto:4467/admin/relation-tuples -H 'content-type: application/json' -d '{
|
||||
"namespace": "Role", "object": "scheduling:write", "relation": "members",
|
||||
"subject_set": { "namespace": "Group", "object": "sched-leads", "relation": "members" }
|
||||
}'
|
||||
```
|
||||
|
||||
Roles are authored **only in Keto** — nothing else writes them. Role names are a shared global
|
||||
namespace on purpose, so an operator grants `scheduling:read` once and every plugin referencing it
|
||||
is gated consistently; namespace yours as `<id>:<action>`.
|
||||
|
||||
A change takes effect on the user's **next login or JWT re-mint** (~10 min) — see [Instant
|
||||
revoke](#instant-revoke-the-optional-denylist) when you need it sooner.
|
||||
|
||||
### Fine-grained, per-row access
|
||||
|
||||
The `Resource` namespace covers what a role cannot express: *this* row, shared with *this* person.
|
||||
Its permissions nest — `owner` ⊇ `editor` ⊇ `viewer`.
|
||||
|
||||
**A per-row grant never widens a coarse gate.** The route's `role` is checked *before* the handler
|
||||
runs, so a user rejected there never reaches the check. Gate the route on something they hold,
|
||||
then narrow inside the handler:
|
||||
|
||||
```ts
|
||||
{ method: "POST", path: "/shifts/:id", role: READ, handler: editShift }
|
||||
|
||||
async function editShift(ctx) {
|
||||
if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id, relation: "editors" })))
|
||||
throw new GuardError(403, "not an editor of this shift");
|
||||
…
|
||||
}
|
||||
```
|
||||
|
||||
Reserve this tier for relationship rules (sharing, delegation, inheritance). Ownership and tenant
|
||||
rules belong in the upstream service that holds the row — see [Three tiers of "may
|
||||
I?"](#three-tiers-of-may-i).
|
||||
|
||||
## Building plugins
|
||||
|
||||
A plugin is a self-contained folder under `plugins/` that the host discovers at boot — no
|
||||
@@ -331,7 +203,7 @@ contract is **TypeScript** (`src/plugin-host/plugin.ts`), so the types there are
|
||||
source of truth; the sections below explain them, the guarantees around them, and the rules
|
||||
the host enforces. A complete, runnable example lives in
|
||||
**[`examples/plugins/scheduling/`](examples/plugins/scheduling/)** — a public overview page, a
|
||||
role-gated list page fetching upstream data (it points `SCHEDULING_UPSTREAM` at its backend;
|
||||
permission-gated list page fetching upstream data (it points `SCHEDULING_UPSTREAM` at its backend;
|
||||
the dev compose ships a tiny mock, `examples/shifts-upstream/`), a CSRF-guarded form forwarding
|
||||
writes upstream, and a mix of public + role-gated nav. It is **not** pre-installed — `plugins/`
|
||||
ships empty so you mount your own. To run it in dev, copy it in
|
||||
@@ -364,7 +236,7 @@ single `plugin.ts`.
|
||||
must be **URL/path-safe** (`isValidPluginId`: lowercase `a–z`, digits, and dashes — dashes
|
||||
anywhere; no uppercase, underscores, dots, or slashes); the host rejects a malformed folder name
|
||||
at discovery. The id also namespaces the plugin's `views/`, its `/public/<id>/` assets, and (by
|
||||
convention) its nav/role names.
|
||||
convention) its nav/permission tokens.
|
||||
|
||||
A handful of ids are **reserved** for the host's own first-party mounts — the gated `dashboard`, the
|
||||
Kratos auth flows (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`),
|
||||
@@ -396,20 +268,20 @@ import { listThings, createThings } from "./handlers.ts";
|
||||
export default definePlugin({
|
||||
apiVersion: "1.0.0", // semver string of the host contract this plugin was built against (see Versioning)
|
||||
|
||||
// Nav fragment, merged into the global menu and role-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).
|
||||
nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", role: "things:read" }],
|
||||
nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }],
|
||||
|
||||
// Roles this plugin gates on. Optional — see Nav & role gates.
|
||||
roles: [
|
||||
{ description: "View things", name: "things:read" },
|
||||
{ description: "Create and edit things", name: "things:write" },
|
||||
// Permission tokens this plugin introduces. Optional — see Nav & permissions.
|
||||
permissions: [
|
||||
{ token: "things:read", description: "View things" },
|
||||
{ token: "things:write", description: "Create and edit things" },
|
||||
],
|
||||
|
||||
// Route handlers, mounted under the plugin's path (/things). `role` gates first.
|
||||
// Route handlers, mounted under the plugin's path (/things). `permission` gates first.
|
||||
routes: [
|
||||
{ method: "GET", path: "/", role: "things:read", handler: listThings },
|
||||
{ method: "POST", path: "/", role: "things:write", handler: createThings },
|
||||
{ method: "GET", path: "/", permission: "things:read", handler: listThings },
|
||||
{ method: "POST", path: "/", permission: "things:write", handler: createThings },
|
||||
],
|
||||
});
|
||||
```
|
||||
@@ -426,7 +298,7 @@ there is **no `id` or `basePath`** in the manifest — both come from the folder
|
||||
| `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. |
|
||||
| `roles` | no | Roles this plugin gates on. See [Nav & role gates](#nav--role-gates). |
|
||||
| `permissions` | no | Tokens this plugin introduces. See [Nav & permissions](#nav--permissions). |
|
||||
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
||||
| `hooks` | no | See [Hooks](#hooks). |
|
||||
|
||||
@@ -434,10 +306,10 @@ A plugin may be routes-only, nav-only, or hooks-only — every collection field
|
||||
|
||||
### Routes & handlers
|
||||
|
||||
A route is `{ method, path, role?, public?, handler }`. `path` is **relative to the plugin's
|
||||
A route is `{ method, path, permission?, public?, handler }`. `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 `role` gate (a coarse JWT-claim check — see [Nav & role gates](#nav--role-gates)),
|
||||
runs the `permission` gate (a coarse JWT-claim check — see [Nav & permissions](#nav--permissions)),
|
||||
and only then calls the handler with the [request context](#requestcontext). When the gate fails, an
|
||||
**anonymous** visitor is redirected to `/login` to sign in; the
|
||||
requested page is preserved as `return_to`, so after signing in they land **back on the page they
|
||||
@@ -482,7 +354,7 @@ export async function listThings(ctx: RequestContext) {
|
||||
partials/subfolders to render a full page — exactly as the admin plugin's screens do. To load the
|
||||
plugin's own CSS, pass its `/public/<id>/x.css` href in the shell's `styles` slot (an array of
|
||||
extra stylesheet hrefs) — see the reference's `views/shifts.ejs`.
|
||||
- **Finer authorization than the route `role`** uses the guards from `#plugin-api`:
|
||||
- **Finer authorization than the route `permission`** uses the guards from `#plugin-api`:
|
||||
`requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect
|
||||
to sign in), `can(ctx, role)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx,
|
||||
{namespace, object, relation})` (a live Keto check for relationship rules — the subject is the
|
||||
@@ -534,14 +406,14 @@ export default definePlugin({
|
||||
Each is a `RouteHandler` like any route's — it receives the [`RequestContext`](#requestcontext) and
|
||||
returns a `RouteResult`, typically a `view` from the plugin's own `views/`. A `dashboard` handler
|
||||
renders against the native app shell via `ctx.chrome` exactly as a route handler does; a `home`
|
||||
handler is a **public** page, so `ctx.identity` may be `null` (use it to show a "go to dashboard" link to
|
||||
handler is a **public** page, so `ctx.user` may be `null` (use it to show a "go to dashboard" link to
|
||||
a signed-in visitor, or sign-in / register to an anonymous one). After login the user lands on
|
||||
`/dashboard` (or the `return_to` they were headed to), and the global menu's **Dashboard** link
|
||||
points there.
|
||||
|
||||
For the gated `dashboard`, the host enforces the session gate first, so `ctx.identity` is non-null;
|
||||
For the gated `dashboard`, the host enforces the session gate first, so `ctx.user` is non-null;
|
||||
branch on `ctx.roles` *inside* to tailor the page per role. Don't gate `dashboard` itself behind a
|
||||
single role — there's no second dashboard to fall back to, so a user lacking it would land on a
|
||||
single permission — there's no second dashboard to fall back to, so a user lacking it would land on a
|
||||
403. (Both slots answer `GET` and `HEAD`.)
|
||||
|
||||
Only **one** plugin may own each slot: two declaring `home` (or two declaring `dashboard`) is a
|
||||
@@ -557,15 +429,15 @@ request:
|
||||
```ts
|
||||
interface RequestContext {
|
||||
chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell
|
||||
identity: SessionIdentity | null; // { id, email, roles } from the verified session JWT, or null
|
||||
log: Log; // request-scoped logger, in this request's trace
|
||||
params: Record<string, string>; // path params from the route match, e.g. /things/:id → { id }
|
||||
query: URLSearchParams; // alias of url.searchParams
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
roles: string[]; // identity?.roles ?? [] — coarse gate without a null-check
|
||||
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
|
||||
system?: SystemCapabilities; // privileged Ory clients + instant-revoke, for a system plugin (see below); undefined unless the host wired them
|
||||
url: URL;
|
||||
user: User | null; // { id, email, roles } from the verified session JWT, or null
|
||||
verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie
|
||||
}
|
||||
```
|
||||
@@ -583,8 +455,7 @@ reference `examples/plugins/scheduling/views/overview.ejs` does; a value you for
|
||||
shell default (e.g. a bare `/login`), it does not error. **`ctx.verifyCsrf(submitted)`** guards a
|
||||
state-changing form: render `chrome.csrfToken` in a hidden `_csrf` field, then on POST read your own
|
||||
body and `if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, …)`. The host owns the
|
||||
secret and sets the cookie; the plugin never touches it. It is **opt-in per handler** — a route
|
||||
that never calls it has no CSRF guard at all. (See the reference: `examples/plugins/scheduling/`.)
|
||||
secret and sets the cookie; the plugin never touches it. (See the reference: `examples/plugins/scheduling/`.)
|
||||
|
||||
The same shell renders **every** page (the dashboard, your plugin pages — the admin plugin's included, and the
|
||||
login/registration/front pages), so the menu looks identical signed in or out — it just role-filters.
|
||||
@@ -633,15 +504,15 @@ OAuth2 clients use `ctx.system.hydra`, and a deactivate/delete or user role-chan
|
||||
`ctx.system.revoke` so the change lands now instead of after the JWT TTL; where a capability is missing
|
||||
the screen renders a themed 503.
|
||||
|
||||
This is a **privileged** surface — it hands a plugin the keys to identity and authorization. It's meant
|
||||
This is a **privileged** surface — it hands a plugin the keys to identity and permissions. It's meant
|
||||
for first-party system plugins you author or vendor, the same trust level as any plugin (the host
|
||||
doesn't sandbox — [crash-isolation is a non-goal](#overview)). An ordinary domain plugin ignores it.
|
||||
|
||||
### Nav & role gates
|
||||
### Nav & permissions
|
||||
|
||||
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 roles in the session JWT — a
|
||||
node shows iff it is `public`, declares no `role`, or the user's roles include that name. Use
|
||||
node shows iff it is `public`, declares no `permission`, or the user's roles include that token. Use
|
||||
arbitrary depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` is a
|
||||
**Lucide icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids
|
||||
are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there.
|
||||
@@ -649,26 +520,30 @@ are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its luci
|
||||
#### Public pages & menu items
|
||||
|
||||
A route or nav node may be marked **`public: true`** — reachable by **anyone, signed in or not**,
|
||||
and the menu item shows for everyone. This is the same as omitting `role` (an ungated
|
||||
and the menu item shows for everyone. This is the same as omitting `permission` (a no-permission
|
||||
route/node is already open) but stated outright, so "public" is a **deliberate choice, not the
|
||||
accident of a forgotten gate**. `public` and `role` are **mutually exclusive** — declaring
|
||||
accident of a forgotten gate**. `public` and `permission` are **mutually exclusive** — declaring
|
||||
both is contradictory and discovery refuses the plugin at boot.
|
||||
|
||||
A public page still renders in the native shell via `ctx.chrome`; for an anonymous visitor
|
||||
`ctx.identity` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page)
|
||||
`ctx.user` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page)
|
||||
in place of the profile/sign-out block, the gated **Dashboard** link is hidden, and `ctx.roles` is
|
||||
empty (read a role with `can(ctx, …)` to branch). The reference plugin's `/scheduling`
|
||||
**Overview** is a worked example: it's `public`, so the "Scheduling" menu header shows for everyone,
|
||||
while the actual shifts list stays behind `scheduling:read`.
|
||||
|
||||
The gate passes iff the user's JWT `roles` include that name. How roles are granted, why their
|
||||
names are a shared global namespace, and the fine-grained per-row tier are all covered in
|
||||
[Users, groups & roles](#users-groups--roles).
|
||||
**A `permission` token is a coarse role.** The route/nav gate passes iff the user's JWT `roles`
|
||||
include the token; those roles come from Keto at login, so an operator grants a token by writing the
|
||||
Keto tuple `Role:<token>#members@user:<id>` (or to a group) — the admin **Roles** screen does this.
|
||||
(The fine-grained, per-row tier is the separate Keto `Resource` namespace — see
|
||||
[Three tiers of "may I?"](#three-tiers-of-may-i); it is not what a route `permission` checks.)
|
||||
|
||||
Declaring the ones you gate on in `roles` is **optional but recommended**: it documents them,
|
||||
feeds conflict detection, and lets the one-command bootstrap seed them — the demo admin is
|
||||
granted every discovered plugin's declared roles, so a dropped-in plugin works out of the box
|
||||
without editing host config.
|
||||
Permission tokens are a **shared global namespace** — that's deliberate, so an operator grants
|
||||
`scheduling:read` once in Keto and every plugin referencing it is gated consistently. Namespace
|
||||
your tokens as `<id>:<action>` to avoid accidental clashes. Declaring them in `permissions` is
|
||||
optional but recommended: it documents them, feeds conflict detection, and lets the one-command
|
||||
bootstrap seed them — the demo admin is granted every discovered plugin's declared tokens, so
|
||||
a dropped-in plugin works out of the box without editing host config.
|
||||
|
||||
### Contract versioning
|
||||
|
||||
@@ -703,15 +578,15 @@ with `findConflicts` and resolves them **loudly — never last-write-wins**. `er
|
||||
| `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. |
|
||||
| `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. |
|
||||
| `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). |
|
||||
| `role` | warn | A role name is declared by more than one plugin. Sharing is legitimate; namespace as `<id>:<action>` if unintended. |
|
||||
| `permission` | warn | A permission token is declared by more than one plugin. Sharing is legitimate (shared role); namespace as `<id>:<action>` if unintended. |
|
||||
|
||||
There is **no separate `basePath` rule**: the mount path is the derived `/<id>`, so its
|
||||
uniqueness follows from the id check. `role` is the one intentional overlap, so it warns
|
||||
uniqueness follows from the id check. `permission` is the one intentional overlap, so it warns
|
||||
rather than aborts; everything else is an error an author fixes before the host will start.
|
||||
|
||||
Beyond cross-plugin conflicts, discovery also rejects **per-manifest shape errors** at boot: a
|
||||
non-array `nav`/`routes`/`roles`, a non-function `home`/`dashboard`, or a route/nav node that
|
||||
sets both `public` and `role` (mutually exclusive — [Public pages](#public-pages--menu-items)).
|
||||
non-array `nav`/`routes`/`permissions`, a non-function `home`/`dashboard`, or a route/nav node that
|
||||
sets both `public` and `permission` (mutually exclusive — [Public pages](#public-pages--menu-items)).
|
||||
|
||||
### Hooks
|
||||
|
||||
@@ -778,7 +653,7 @@ can't escape its own package scope, so it can't point at the host's file directl
|
||||
> Discovery — scanning `plugins/`, importing each `plugin.ts` default export, and
|
||||
> validating it (id, `apiVersion`, conflicts) — runs at boot (`src/plugin-host/discovery.ts`); a bad
|
||||
> plugin stops startup with a precise message. The router (`src/plugin-host/router.ts`) then mounts
|
||||
> each route at `/<id>`, resolves `:name` params, runs the role gate, and turns the
|
||||
> each route at `/<id>`, resolves `:name` params, runs the permission gate, and turns the
|
||||
> handler's `RouteResult` into the response; a `view` result renders
|
||||
> `plugins/<id>/views/<view>.ejs` (`src/plugin-host/view-resolver.ts`), which may `include()` the core
|
||||
> building-block partials. A plugin's `public/` assets are served at `/public/<id>/`
|
||||
@@ -809,7 +684,7 @@ worked example: thin handlers bound to an injectable upstream client, unit-teste
|
||||
|
||||
3. **E2E the user-facing flow.** Per AGENTS.md §6, ship a side-effect-free Playwright test in
|
||||
`e2e-tests/` for each plugin page/form so the suite stays `fullyParallel`, run against the live `web`
|
||||
service with the plugin mounted. The reference's role-gating is covered in `visual.spec.ts`;
|
||||
service with the plugin mounted. The reference's permission-gating is covered in `visual.spec.ts`;
|
||||
its authenticated list/form happy-path is the full-E2E item (needs cross-host login infra).
|
||||
|
||||
The validation an author hits is the same the host runs: bad `apiVersion` or a conflict
|
||||
@@ -837,13 +712,13 @@ 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 `role`; the rendered tree is **filtered per user** by
|
||||
Every nav item may carry a `permission`; the rendered tree is **filtered per user** by
|
||||
reading the roles in the session JWT (no per-request authz call — see
|
||||
[Auth, sessions & access](#auth-sessions--access)), so the menu only ever shows
|
||||
[Auth, sessions & permissions](#auth-sessions--permissions)), so the menu only ever shows
|
||||
what that person can reach. An item (or a whole page) may instead be marked **`public:
|
||||
true`** to show it to **everyone, signed in or not** — the blessed, explicit way to expose
|
||||
a public page and its menu entry (an ungated item is already public; `public` just
|
||||
says so on purpose, and is mutually exclusive with `role`). The markup is the
|
||||
a public page and its menu entry (a no-permission item is already public; `public` just
|
||||
says so on purpose, and is mutually exclusive with `permission`). The markup is the
|
||||
recursive, zero-JS nav tree from the design foundation (header/leaf × clickable/static,
|
||||
counts, arbitrary depth). Branding (name, logo, default theme) renders in the app shell —
|
||||
the sidebar brand shows the configured logo (else a default mark), and the theme sets the
|
||||
@@ -910,7 +785,7 @@ The app is **environment-agnostic**: there is no `NODE_ENV`. Behaviour that used
|
||||
| `OTLP_ENDPOINT` | _unset_ | OpenTelemetry Collector HTTP base URI; set ⇒ export logs + traces (unset ⇒ console only) |
|
||||
| `OTLP_PROTOCOL` | `http/json` | OTLP wire format: `http/json` or `http/protobuf` |
|
||||
| `KRATOS_PUBLIC_URL` / `KRATOS_ADMIN_URL` | `http://kratos:4433` / `:4434` | identity (self-service / admin) |
|
||||
| `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | authorization check / write |
|
||||
| `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | permission check / write |
|
||||
| `HYDRA_ADMIN_URL` | `http://hydra:4445` | OAuth2 provider admin API (login/consent handshake) |
|
||||
| `JWKS_URL` | `file://…/tokenizer/jwks.json` | the Kratos tokenizer signing key; verifies the session JWT |
|
||||
| `JWT_ISSUER` / `JWT_AUDIENCE` | _unset_ | optional: when set, the session JWT's `iss` / `aud` must match (the dev tokenizer sets neither) |
|
||||
@@ -961,29 +836,14 @@ both default to `localhost` (the dev override sets `APP_URL=http://localhost:300
|
||||
|
||||
A clean clone needs **none** of the above — `docker compose up` brings up the whole stack
|
||||
with dev-throwaway secrets, an auto-generated signing key, and a seeded admin (see
|
||||
[Quick start](#quick-start)). What can't be auto-generated is **production-only** — none of it
|
||||
blocks a clean clone:
|
||||
|
||||
1. **Production secrets** — every value below ships as a committed dev throwaway that works
|
||||
out of the box and **must** be replaced before a deploy faces the internet. Only the first
|
||||
is enforced: `REQUIRE_SECURE_SECRETS=true` refuses to boot on a missing or throwaway
|
||||
`CSRF_SECRET` and **nothing else** — the rest fail silently, so treat this as a checklist.
|
||||
|
||||
| Secret | Where | Protects |
|
||||
| --- | --- | --- |
|
||||
| `CSRF_SECRET` | web env | signs our double-submit CSRF token |
|
||||
| JWT signing key | mount a real `jwks.json` or set `…_JWKS_URL` | mints/verifies the session JWT — see [rotation](#jwt-signing-key--rotation) |
|
||||
| `SECRETS_COOKIE` | kratos env | signs Kratos' session + anti-CSRF cookies |
|
||||
| `SECRETS_CIPHER` | kratos env (32 chars) | encrypts credentials at rest |
|
||||
| `SECRETS_SYSTEM` | hydra env | encrypts OAuth2 tokens + consent at rest |
|
||||
| `POSTGRES_USER` / `POSTGRES_PASSWORD` | compose env | the Ory databases (default `ory`/`ory`) |
|
||||
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | bootstrap env | the seeded first admin login (default `admin@plainpages.local` / `admin`) |
|
||||
|
||||
`CSRF_SECRET`, the Postgres pair and the admin pair are interpolated from the host
|
||||
environment. The three Ory secrets are **not**: `compose.yml` passes only `DSN` to
|
||||
`kratos`/`hydra`, so add them to those services' `environment:` (or an `env_file:`) or they
|
||||
silently stay on the throwaways.
|
||||
[Quick start](#quick-start)). Exactly **two** things can't be auto-generated, and **both
|
||||
are production-only** — neither blocks a clean clone:
|
||||
|
||||
1. **Production secrets** — replace the committed dev throwaway `CSRF_SECRET` (env), plus
|
||||
the **JWT signing key** (mount a real `jwks.json` or set `…_JWKS_URL` — see
|
||||
[JWT signing key & rotation](#jwt-signing-key--rotation)). Set
|
||||
`REQUIRE_SECURE_SECRETS=true` and the app refuses to boot until `CSRF_SECRET` is supplied
|
||||
and differs from the throwaway.
|
||||
2. **SSO provider client id/secret** — **optional**; password login works without them.
|
||||
Supplying a provider's creds via env activates it; no creds ⇒ no SSO button (see
|
||||
[Social sign-in (SSO)](#social-sign-in-sso)).
|
||||
@@ -1004,7 +864,7 @@ button, and the whole SSO section disappears when none are configured — no cod
|
||||
add or remove one. Open-source Kratos has **no native SAML** — front it with an OIDC bridge
|
||||
(Ory Polis) and register that bridge as a generic OIDC provider the same way.
|
||||
|
||||
## Auth, sessions & access
|
||||
## Auth, sessions & permissions
|
||||
|
||||
Identity comes from **Kratos**; the hot path stays I/O-free by carrying coarse authorization
|
||||
in a **locally-validated JWT**, and **Keto** is reserved for the rare fine-grained,
|
||||
@@ -1036,7 +896,7 @@ the session for a signed JWT once** via the Kratos **session tokenizer** (`whoam
|
||||
```
|
||||
|
||||
**Keto is the single source of truth for roles.** Coarse roles are Keto relations (e.g.
|
||||
`Role:admin#members@identity:alice`); the admin screens write them *only* to Keto. But the
|
||||
`role:admin#members@user:alice`); the admin screens write them *only* to Keto. But the
|
||||
tokenizer's claims mapper can read only the **identity**, not call Keto — so at login the
|
||||
app reads the roles from Keto and refreshes a **derived projection**: a read-only copy
|
||||
written onto the identity's `metadata_public` for the tokenizer to see, which the template
|
||||
@@ -1093,9 +953,6 @@ deactivate the user, or use a direct user-role change, for an instant effect.
|
||||
|
||||
### Three tiers of "may I?"
|
||||
|
||||
[Users, groups & roles](#users-groups--roles) covers *what* the entities are; this is where each
|
||||
**kind** of rule belongs.
|
||||
|
||||
```
|
||||
coarse (menu / route / feature) → JWT claim · in-process, zero I/O
|
||||
fine + attribute (owner / tenant / …) → upstream service that owns the row
|
||||
@@ -1110,8 +967,10 @@ deactivate the user, or use a direct user-role change, for an instant effect.
|
||||
is for. Reserve it for those; don't pay its tuple-sync cost for rules a service can already
|
||||
answer from its own data.
|
||||
|
||||
The admin plugin's users / groups / roles screens write authorization **only to Keto** — coarse
|
||||
roles and fine-grained relationships alike.
|
||||
The built-in users / groups / permissions screens write authorization **only to Keto** —
|
||||
coarse roles and fine-grained relationships alike. Roles reach the JWT by being read from
|
||||
Keto at login and projected through the tokenizer (above); nothing authors them anywhere
|
||||
else.
|
||||
|
||||
### OAuth2 provider (Hydra)
|
||||
|
||||
@@ -1138,42 +997,6 @@ generated `client_secret` **once**, on the confirmation page — confidential cl
|
||||
delete. Confidential vs public (PKCE) and the first-party auto-consent flag are set at registration;
|
||||
writes go only to Hydra.
|
||||
|
||||
### Security model
|
||||
|
||||
Everything above is *how* auth works. These are the few things the code won't tell you quickly,
|
||||
and that get a deployment wrong if you don't know them.
|
||||
|
||||
**The private container network is the *only* thing guarding the Ory APIs.** Kratos admin
|
||||
(`4434`), Hydra admin (`4445`) and Keto write (`4467`) authenticate no one — reaching them *is*
|
||||
full identity and authorization control. Keto **read** (`4466`) cannot write, but discloses the
|
||||
entire authorization graph, so treat it the same. `compose.yml` publishes none of the six Ory
|
||||
ports (guarded by `src/compose.test.ts`); dev publishes only the two a browser must reach. Never
|
||||
expose one, and never front one with a proxy that lacks its own auth.
|
||||
|
||||
**The JWT is signed, not encrypted.** Claims are base64: a signed-in user can read their own
|
||||
`sub`, `email` and `roles`. `HttpOnly` keeps page JavaScript out of the cookie, not the user.
|
||||
Never put anything in a claim you wouldn't show them.
|
||||
|
||||
**The JWT's ~10m TTL is not the session lifetime.** The browser also holds Kratos'
|
||||
`plainpages_session` cookie (30 days, sliding), and *that* is what silently re-mints a lapsed
|
||||
JWT. So a stolen cookie jar is worth 30 days of re-mintable access, not ten minutes. Only our
|
||||
two cookies obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config.
|
||||
|
||||
**Offboarding is not instant by default.** An expired JWT re-mints off that live Kratos session,
|
||||
re-reading roles from Keto — so a revoked role, or a deactivated identity, lands within one
|
||||
token TTL rather than immediately. With the
|
||||
[denylist](#instant-revoke-the-optional-denylist) on (it is off by default), both take effect at
|
||||
once, on the instance that handled the change.
|
||||
|
||||
**Not guaranteed** — accepted, and stated where each mechanism is: role changes
|
||||
[lag up to one token TTL and sign-in needs Ory up](#two-trade-offs--both-deliberate), and the
|
||||
denylist is [single-instance and skips group changes](#instant-revoke-the-optional-denylist).
|
||||
Hardening a real deploy is `REQUIRE_SECURE_SECRETS=true`, `SECURE_COOKIES=true`, and replacing
|
||||
**every** committed dev secret — see
|
||||
[what you must supply](#what-you-must-supply-the-only-manual-prep). `REQUIRE_SECURE_SECRETS`
|
||||
guards only `CSRF_SECRET`; nothing fails loud if you ship Ory's, Postgres' or the demo admin's
|
||||
throwaways.
|
||||
|
||||
## Email
|
||||
|
||||
The only emails are the **recovery** and **verification** codes from Kratos' self-service
|
||||
@@ -1217,7 +1040,7 @@ pages by **verifying the JWT in-process, with no per-request call to Ory**. Keto
|
||||
the rarer fine-grained checks; Hydra is used only when the app acts as an OAuth2 **login &
|
||||
consent provider** for other apps. It reaches the Ory services over their **REST APIs
|
||||
using Node's built-in `fetch`** — no SDK dependency. See
|
||||
[Auth, sessions & access](#auth-sessions--access).
|
||||
[Auth, sessions & permissions](#auth-sessions--permissions).
|
||||
|
||||
In **dev** the host-facing Ory ports are published — Kratos public `4433` (where the browser
|
||||
POSTs self-service flows) and Hydra public `4444`; **prod** (`docker compose -f compose.yml
|
||||
@@ -1261,7 +1084,7 @@ service — no Node/browsers on the host. There are five suites:
|
||||
**Visual + design system** (`visual.spec.ts`) — Ory-free, so it stays fast. It screenshots
|
||||
the live pages and asserts the rendered design system — the app shell, theme switch, mobile
|
||||
off-canvas layout, icon sprite, CSRF-guarded sign-out, the public landing, the 404 page, and
|
||||
plugin role-gating — the last exercised by bind-mounting the reference example
|
||||
plugin permission-gating — the last exercised by bind-mounting the reference example
|
||||
(`examples/plugins/scheduling/`) onto `/app/plugins/scheduling`.
|
||||
|
||||
```bash
|
||||
@@ -1295,9 +1118,8 @@ docker compose -f compose.yml -f e2e-tests/compose.oauth.yml down -v
|
||||
|
||||
**Full browser flow** (`full-flow.spec.ts`) — the real Playwright UI against the live stack:
|
||||
the themed **password login** and a **mocked-SSO** login (an in-network mock OIDC provider,
|
||||
`e2e-tests/mock-oidc.ts`), **menu filtering by role**, the **users/groups/roles** admin CRUD, the
|
||||
**OAuth2-clients** admin screen (register → one-time secret → delete; Hydra is part of this stack
|
||||
for it), a role-gated **plugin page**, and **logout**. Because the themed form posts straight to
|
||||
`e2e-tests/mock-oidc.ts`), **menu filtering by role**, the **users/groups/roles** admin CRUD, a
|
||||
permission-gated **plugin page**, and **logout**. Because the themed form posts straight to
|
||||
Kratos and cookies are host-scoped, a tiny same-origin gateway (`e2e-tests/proxy.ts`) fronts web +
|
||||
Kratos on one host (`ory/kratos/e2e-proxy.yml` points Kratos at it) — exactly as a production
|
||||
reverse proxy would.
|
||||
@@ -1351,7 +1173,7 @@ Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs
|
||||
|
||||
| Workflow | Trigger | Does |
|
||||
| --- | --- | --- |
|
||||
| `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`, a no-op on a docs-only branch), then build + push the app image |
|
||||
| `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`), then build + push the app image |
|
||||
| `release.yml` | push of a `vX.Y.Z` tag | re-tag that commit's image as `X.Y.Z`, `X.Y`, `X`, `latest`; sync those tags to Docker Hub |
|
||||
| `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags to the [GitHub mirror](https://github.com/larvit/plainpages) |
|
||||
| `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head |
|
||||
@@ -1381,9 +1203,12 @@ this step runs
|
||||
inside the required gate, a missing/expired token (or registry outage) fails every branch's
|
||||
gate and blocks **all** merges until restored — set the secrets before this lands, and use a
|
||||
non-expiring token or track its expiry. Retention: hash tags accumulate one image per gated
|
||||
push, so the nightly `registry-cleanup.yml` prunes them
|
||||
([`registry-cleanup/cleanup.ts`](registry-cleanup/cleanup.ts) defines what survives).
|
||||
It reuses `DOCKER_REGISTRY_USER`/`DOCKER_REGISTRY_TOKEN` — no extra setup. Don't
|
||||
push, so the nightly `registry-cleanup.yml` prunes them precisely
|
||||
([`registry-cleanup/cleanup.ts`](registry-cleanup/cleanup.ts), run in a `node:24` container):
|
||||
a hash tag survives only while its commit is a **branch head** or carries a **`vX.Y.Z`
|
||||
release tag**; deleted alongside are the untagged `sha256:…` child manifests (arch image +
|
||||
provenance) that no surviving tag references. Named tags (`1.2.3`, `latest`, …) are never
|
||||
touched. It reuses `DOCKER_REGISTRY_USER`/`DOCKER_REGISTRY_TOKEN` — no extra setup. Don't
|
||||
add a pattern-based org cleanup rule for this package (and remove it if one exists): its
|
||||
age/count heuristics can't see branch heads or release tags and would delete images the
|
||||
workflow protects.
|
||||
@@ -1427,20 +1252,19 @@ Renovate merges it once `CI / full-gate (push)` is green (rebasing stale branche
|
||||
fast-forward-only merge still holds) — routine bumps land untouched; only a red gate needs a
|
||||
human. One-time setup: reuse the shared `renovate@larvit.se` bot — give it write access to
|
||||
this repo and store its Gitea PAT as the Actions **secret** `RENOVATE_TOKEN`. Until it
|
||||
exists, the nightly job fails loud (and, like the other secrets, a `GITEA_`/`GITHUB_`
|
||||
prefix is rejected). Also store a **scopeless** (read-only) github.com PAT as the secret
|
||||
`RENOVATE_GITHUB_TOKEN` — the workflow hands it to Renovate as `GITHUB_COM_TOKEN`, so
|
||||
lookups of github.com-hosted deps (actions, Playwright, changelogs) run authenticated
|
||||
instead of tripping the anonymous 60-requests/hour limit.
|
||||
exists, the nightly job fails loud (and, like the other secrets, a `GITEA_` prefix is
|
||||
rejected).
|
||||
|
||||
**Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`, `needs:
|
||||
renovate`) cuts **one** `vX.Y.Z` tag per run covering the renovate-bot commits merged to `main`
|
||||
since the last tag (it targets `origin/main`, and **skips** when the tip isn't a Renovate commit —
|
||||
a human owns that release — or when nothing new merged). Renovate stamps every commit with a
|
||||
`Release-Bump: <updateType>` trailer (`commitBody` in `renovate.json`), and
|
||||
[`auto-release/next-version.ts`](auto-release/next-version.ts) (unit-tested) turns the highest
|
||||
trailer on those commits into the next version — pre-1.0 it never auto-crosses into `1.0.0`,
|
||||
which stays a deliberate hand-cut tag. It's
|
||||
`Release-Bump: <updateType>` trailer (`commitBody` in `renovate.json`); the job takes the highest
|
||||
trailer on those commits — any dependency's `major`/`minor`/`patch` maps straight through,
|
||||
defaulting to `patch`.
|
||||
**Pre-1.0 the level shifts down** — a dep major bumps the `0.x` minor, dep minor/patch bump the
|
||||
`0.x` patch (see [`auto-release/next-version.ts`](auto-release/next-version.ts), unit-tested) — so
|
||||
routine bumps never auto-cross into `1.0.0`; `1.0.0` stays a deliberate hand-cut tag. It's
|
||||
**tag-only** (no source commits): the tag hands off to `release.yml`, which promotes the
|
||||
already-built image, and is pushed with renovate-bot's PAT so `release.yml` actually fires (a tag
|
||||
pushed by the built-in Actions token wouldn't trigger it). The plugin-contract version
|
||||
@@ -1492,7 +1316,7 @@ mid-response, so container restarts are clean.
|
||||
|
||||
The first-boot **bootstrap** is idempotent and runs on every `up` — it generates the JWT
|
||||
signing key if absent, creates the demo admin in Kratos, and grants it the `admin` role plus
|
||||
every discovered plugin's declared role names in Keto, so role checks (and any
|
||||
every discovered plugin's declared permission tokens in Keto, so permission checks (and any
|
||||
dropped-in plugin) resolve out of the box. The web app waits for Kratos + Keto to be healthy
|
||||
*and* the bootstrap to finish before starting. **Change the demo admin before production.**
|
||||
|
||||
@@ -1574,7 +1398,7 @@ container-relative; with the dev bind-mount they edit the real file).
|
||||
2. **Restart Kratos** so it signs with the new first key: `docker compose restart kratos`.
|
||||
(web needs no restart — it hot-reloads the file. The hot path verifies JWTs locally, so a
|
||||
brief Kratos blip only touches login/re-mint.)
|
||||
3. **Verify** new logins mint the new `kid` — decode the `plainpages_jwt` cookie's JWT
|
||||
3. **Verify** new logins mint the new `kid` — decode the `plainpages_session` cookie's JWT
|
||||
header, or watch web's logs for a `jwks reload on kid miss` debug line as old clients
|
||||
present the new key.
|
||||
4. **Wait ~12 min**, then **prune** the superseded key:
|
||||
@@ -1625,11 +1449,11 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
|
||||
|
||||
auth/ Identity, the session-JWT hot path, guards, and the Ory REST clients
|
||||
jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK)
|
||||
jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.identity/roles; flags a lapsed token for re-mint
|
||||
jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.user/roles; flags a lapsed token for re-mint
|
||||
jwks.ts JwksProvider — resolve the verify key by kid; createJwksProvider() picks by scheme: staticJwks (base64) or cachingJwks (file/http: TTL cache + rotation-on-miss reload)
|
||||
gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS; see JWT signing key & rotation
|
||||
login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie
|
||||
guards.ts requireSession()/can()/check(): in-handler authorization — the imperative counterpart to the route role gate; GuardError → 303 /login or 403; check() is the one live Keto "may I?" call
|
||||
guards.ts requireSession()/can()/check(): in-handler authorization — the imperative counterpart to the route permission gate; GuardError → 303 /login or 403; check() is the one live Keto "may I?" call
|
||||
csrf.ts CSRF for our own POST forms: signed double-submit token — issue/verify, cookie, request gate
|
||||
denylist.ts Optional instant-revoke denylist: in-memory, auto-evicting; hot path rejects a revoked subject's pre-revoke tokens (REVOCATION_DENYLIST)
|
||||
flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs
|
||||
@@ -1648,7 +1472,7 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
|
||||
plugin-api.ts Stable plugin author barrel — the one module a plugin imports, as `#plugin-api` (definePlugin, ctx/result types, guards, body/CSRF/list-query/paginate helpers, and the ctx.system Ory client types)
|
||||
system.ts SystemCapabilities: the privileged ctx.system surface (Ory admin clients + instant-revoke) a system plugin uses; the host populates it from the wired clients, the admin plugin consumes it
|
||||
discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot
|
||||
router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, role gate
|
||||
router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate
|
||||
hooks.ts runBootHooks()/runRequestHooks()/runResponseHooks(): invoke a plugin's optional lifecycle hooks in discovery order; no sandbox (a throwing hook fails loud), skipped when no plugin declares one
|
||||
view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials
|
||||
|
||||
@@ -1667,7 +1491,7 @@ public/ Static assets under /public/ (css/styles.css + auth.css, fa
|
||||
config/ Drop-in mount point for the central menu override + branding (config/menu.ts). Ships empty (.gitkeep, git-ignored otherwise) — mount your own or copy the template from examples/config/; defaults apply when absent
|
||||
ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service)
|
||||
plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships empty (.gitkeep, git-ignored otherwise) — mount your own; the E2E suites bind-mount the example plugins onto /app/plugins/scheduling and /app/plugins/admin
|
||||
examples/ Copy-in reference material, mirroring the mount dirs: plugins/scheduling/ (the reference plugin — list/form over an upstream + role-gated nav), plugins/admin/ (the system-admin plugin — Users/Groups/Roles/OAuth2-clients over Ory via ctx.system), both copied into plugins/; and config/menu.ts (the menu/branding template copied into config/); shifts-upstream/ is the dev mock backend the scheduling plugin reads/writes (stand-in for your real service)
|
||||
examples/ Copy-in reference material, mirroring the mount dirs: plugins/scheduling/ (the reference plugin — list/form over an upstream + permission-gated nav), plugins/admin/ (the system-admin plugin — Users/Groups/Roles/OAuth2-clients over Ory via ctx.system), both copied into plugins/; and config/menu.ts (the menu/branding template copied into config/); shifts-upstream/ is the dev mock backend the scheduling plugin reads/writes (stand-in for your real service)
|
||||
e2e-tests/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-role, admin CRUD, plugin page, logout) + devstack-login.spec (regression: login works from the banner's localhost URL and 127.0.0.1 is canonicalised, on the plain `docker compose up` topology); proxy.ts (same-origin gateway) + mock-oidc.ts (mock SSO provider) back full-flow. e2e-tests/Dockerfile + e2e-tests/compose.{visual,auth,oauth,full,devstack}.yml run them
|
||||
ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash ci.sh`)
|
||||
.gitea/workflows/ Gitea Actions: ci.yml — the full gate (ci.sh) on every branch push except main;
|
||||
|
||||
@@ -12,26 +12,6 @@ cd "$(dirname "$0")"
|
||||
|
||||
step() { printf '\n\033[1;34m==> %s\033[0m\n' "$1"; }
|
||||
|
||||
# Docs-only fast path: nothing but *.md changed since main, so there is nothing here to break.
|
||||
# The working tree counts too — a dirty tree carrying real code must never skip. Anything
|
||||
# undeterminable (no git, no reachable main, no merge-base) falls through to the gate, never a skip.
|
||||
docs_only() {
|
||||
local base changed
|
||||
git rev-parse --git-dir >/dev/null 2>&1 || return 1
|
||||
git fetch --no-tags --quiet origin +refs/heads/main:refs/remotes/origin/main 2>/dev/null || true
|
||||
base=$(git merge-base refs/remotes/origin/main HEAD 2>/dev/null) || return 1
|
||||
changed=$(
|
||||
{ git diff --name-only "$base" HEAD && git status --porcelain --untracked-files=all | cut -c4-; } 2>/dev/null
|
||||
) || return 1
|
||||
[ -n "$changed" ] || return 1
|
||||
! printf '%s\n' "$changed" | grep -qvE '\.md$'
|
||||
}
|
||||
|
||||
if docs_only; then
|
||||
step "Only *.md changed since main — nothing to test, skipping the gate"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Pins that MUST move in lockstep: a browser/runner mismatch yields confusing E2E failures.
|
||||
step "Playwright pin lockstep (e2e-tests/Dockerfile image == e2e-tests/package.json @playwright/test)"
|
||||
# `|| true` so a no-match doesn't trip `set -e`/`pipefail` before the explicit check below can report.
|
||||
@@ -40,12 +20,6 @@ pkg=$(grep -oE '"@playwright/test": "[0-9.]+"' e2e-tests/package.json | grep -oE
|
||||
[ -n "$img" ] && [ "$img" = "$pkg" ] || { echo "Playwright pin mismatch/unreadable: image v$img vs @playwright/test $pkg"; exit 1; }
|
||||
echo "ok ($img)"
|
||||
|
||||
# Explicit rebuild: without it a stale web image from a previous branch supplies node_modules
|
||||
# (the source is bind-mounted but deps are baked in), so a dep bump gets typechecked/tested
|
||||
# against the OLD packages. Cheap when deps are unchanged (npm ci layer is cache-keyed).
|
||||
step "Build web image"
|
||||
docker compose build web
|
||||
|
||||
step "Typecheck"
|
||||
docker compose run --rm --no-deps web npm run typecheck
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ services:
|
||||
# backs it (SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
|
||||
# stdlib-only, in-memory, no auth. Prod points SCHEDULING_UPSTREAM at the real backend instead.
|
||||
shifts-upstream:
|
||||
image: node:24.18.1-alpine3.24
|
||||
image: node:24.18.0-alpine3.24
|
||||
command: node /srv/server.ts
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
@@ -36,7 +36,7 @@ services:
|
||||
# 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.
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.30.6
|
||||
image: axllent/mailpit:v1.30.5
|
||||
ports:
|
||||
- "8025:8025"
|
||||
restart: unless-stopped
|
||||
|
||||
+1
-1
@@ -131,7 +131,7 @@ services:
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
||||
# Base roles for the demo admin; bootstrap also grants every discovered plugin's declared
|
||||
# role names (so the reference plugin — and any drop-in — works out of the box).
|
||||
# permission tokens (so the reference plugin — and any drop-in — works out of the box).
|
||||
ADMIN_ROLES: ${ADMIN_ROLES:-admin}
|
||||
APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner
|
||||
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
FROM mcr.microsoft.com/playwright:v1.62.1-noble
|
||||
FROM mcr.microsoft.com/playwright:v1.49.1-noble
|
||||
|
||||
WORKDIR /e2e-tests
|
||||
|
||||
|
||||
+17
-16
@@ -1,16 +1,21 @@
|
||||
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
|
||||
# login, menu filtering by role, users/groups/roles/OAuth2-clients CRUD, a plugin page, logout. A
|
||||
# tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies
|
||||
# Full browser E2E — the real Playwright UI flow against the live stack: password +
|
||||
# mocked-SSO login, menu filtering by role, users/groups/roles CRUD, a plugin page, logout. A tiny
|
||||
# same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies
|
||||
# round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test.
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.full.yml run --build --rm e2e
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.full.yml down -v # tear down after
|
||||
services:
|
||||
web:
|
||||
# The base's full depends_on applies (Hydra included — the admin plugin's OAuth2-clients
|
||||
# screen needs it); only the reference plugin's upstream is added. SSO is enabled here only
|
||||
# (clean clone stays password-only): the mock provider's whole array is the env-settable form
|
||||
# Kratos offers, mapped through the committed claims jsonnet.
|
||||
depends_on:
|
||||
# First-party + SSO flows need Kratos + Keto + bootstrap, not Hydra — drop it so the stack is
|
||||
# leaner. SSO is enabled here only (clean clone stays password-only): the mock provider's whole
|
||||
# array is the env-settable form Kratos offers, mapped through the committed claims jsonnet.
|
||||
depends_on: !override
|
||||
bootstrap:
|
||||
condition: service_completed_successfully
|
||||
kratos:
|
||||
condition: service_healthy
|
||||
keto:
|
||||
condition: service_healthy
|
||||
shifts-upstream:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
@@ -30,7 +35,7 @@ services:
|
||||
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
|
||||
- ./examples/plugins/admin:/app/plugins/admin:ro
|
||||
|
||||
# bootstrap grants the demo admin every discovered plugin's role names, so it needs the
|
||||
# bootstrap grants the demo admin every discovered plugin's permission tokens, so it needs the
|
||||
# example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403.
|
||||
bootstrap:
|
||||
volumes:
|
||||
@@ -47,13 +52,9 @@ services:
|
||||
SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS: >-
|
||||
[{"id":"mock","provider":"generic","label":"Mock SSO","client_id":"plainpages-e2e","client_secret":"e2e-secret","issuer_url":"http://mock-oidc:9000","scope":["openid","email"],"mapper_url":"file:///etc/config/kratos/oidc/claims.jsonnet"}]
|
||||
|
||||
# --dev permits the http issuer (the base file drops it for an https prod issuer).
|
||||
hydra:
|
||||
command: serve all --dev -c /etc/config/hydra/hydra.yml
|
||||
|
||||
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
||||
shifts-upstream:
|
||||
image: node:24.18.1-alpine3.24
|
||||
image: node:24.18.0-alpine3.24
|
||||
command: ["node", "/server.ts"]
|
||||
volumes:
|
||||
- ./examples/shifts-upstream/server.ts:/server.ts:ro
|
||||
@@ -66,7 +67,7 @@ services:
|
||||
# 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.
|
||||
mock-oidc:
|
||||
image: node:24.18.1-alpine3.24
|
||||
image: node:24.18.0-alpine3.24
|
||||
command: ["node", "/mock-oidc.ts"]
|
||||
environment:
|
||||
ISSUER: http://mock-oidc:9000
|
||||
@@ -81,7 +82,7 @@ services:
|
||||
|
||||
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
|
||||
proxy:
|
||||
image: node:24.18.1-alpine3.24
|
||||
image: node:24.18.0-alpine3.24
|
||||
command: ["node", "/proxy.ts"]
|
||||
depends_on:
|
||||
web:
|
||||
|
||||
@@ -85,35 +85,6 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
await expect(page.locator("main")).toContainText(role);
|
||||
});
|
||||
|
||||
test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => {
|
||||
const name = `e2e-client-${suffix}`;
|
||||
await page.goto("/admin/clients");
|
||||
await page.getByRole("link", { name: "Register client" }).click();
|
||||
await page.fill('input[name="name"]', name);
|
||||
await page.fill('textarea[name="redirectUris"]', "https://app.example.com/callback");
|
||||
await page.locator('.form-card button[type="submit"]').click();
|
||||
|
||||
// Hydra returns the secret exactly once, so the POST renders the detail directly (no PRG).
|
||||
await expect(page.locator("h1")).toHaveText("Client registered");
|
||||
const clientId = await page.locator("#cid").inputValue();
|
||||
expect(clientId).toBeTruthy();
|
||||
await expect(page.locator("#csecret")).toHaveValue(/.+/);
|
||||
|
||||
// Listed; the row header links to the plain detail, which never shows the secret again.
|
||||
await page.goto("/admin/clients");
|
||||
const row = page.locator("tr", { hasText: name });
|
||||
await expect(row).toBeVisible();
|
||||
await row.getByRole("link", { name }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/admin/clients/${clientId}`));
|
||||
await expect(page.locator("#csecret")).toHaveCount(0);
|
||||
|
||||
// Delete through the confirm interstitial (danger link on the detail → confirm form's button).
|
||||
await page.getByRole("link", { name: "Delete client" }).click();
|
||||
await page.getByRole("button", { name: "Delete client" }).click();
|
||||
await expect(page).toHaveURL(/\/admin\/clients(\?|$)/);
|
||||
await expect(page.locator("tr", { hasText: name })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("plugin page: the reference plugin renders its upstream shifts inside the native shell", async () => {
|
||||
await page.goto("/scheduling/shifts");
|
||||
await expect(page.locator("h1")).toHaveText("Shifts");
|
||||
|
||||
Generated
+15
-15
@@ -8,23 +8,23 @@
|
||||
"name": "plainpages-e2e",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.1"
|
||||
"@playwright/test": "1.49.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||
"version": "1.49.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz",
|
||||
"integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.1"
|
||||
"playwright": "1.49.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
@@ -43,35 +43,35 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"version": "1.49.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
|
||||
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
"playwright-core": "1.49.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"version": "1.49.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
|
||||
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"test": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.1"
|
||||
"@playwright/test": "1.49.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +114,7 @@ test("unknown routes serve the 404 page (a real user-facing flow, covered end-to
|
||||
});
|
||||
|
||||
// The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is
|
||||
// reachable by anyone and its menu header shows for everyone; the shifts list stays role-gated,
|
||||
// reachable by anyone and its menu header shows for everyone; the shifts list stays permission-gated,
|
||||
// so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the full
|
||||
// E2E (full-flow.spec). Side-effect-free.
|
||||
test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login", async ({ page, request }) => {
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ across (or bind-mount your own) and restart.
|
||||
|
||||
| Path | Copy into | Example of |
|
||||
| --- | --- | --- |
|
||||
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and role-gated nav — built from the core building blocks, holding no state. Imports the host surface as `#plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
|
||||
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `#plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
|
||||
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Roles / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
|
||||
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
|
||||
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. |
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// config/ ships empty; mount your own or copy this in. Absent config = built-in defaults.
|
||||
//
|
||||
// Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins —
|
||||
// the override always wins, applied before the per-user role filter. Every field is
|
||||
// the override always wins, applied before the per-user permission filter. Every field is
|
||||
// optional; delete one to fall back to the default.
|
||||
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README.md (The menu system).
|
||||
|
||||
|
||||
@@ -28,12 +28,12 @@ stack**, so they use the privileged **`ctx.system`** surface the host exposes to
|
||||
`ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto,
|
||||
and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather
|
||||
than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered,
|
||||
gated per route by `role: "admin"`, rendering the core building blocks in `views/`.
|
||||
gated per route by `permission: "admin"`, rendering the core building blocks in `views/`.
|
||||
|
||||
## Layout
|
||||
|
||||
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` role, and the
|
||||
route table — one thin handler per method+path, all gated by `role: "admin"`.
|
||||
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission token, and the
|
||||
route table — one thin handler per method+path, all gated by `permission: "admin"`.
|
||||
- `admin-users.ts` · `admin-groups.ts` · `admin-roles.ts` · `admin-clients.ts` — each a set of pure
|
||||
view-model builders (unit-tested in the matching `*.test.ts`) plus thin per-route handlers keyed on
|
||||
`ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin
|
||||
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
|
||||
|
||||
import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type SessionIdentity } from "#plugin-api";
|
||||
import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
|
||||
import { ADMIN_CLIENTS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||
import type { FieldConfig } from "./admin-users.ts";
|
||||
|
||||
@@ -235,7 +235,7 @@ function readClientInput(form: URLSearchParams): ClientInput {
|
||||
|
||||
// Shared per-request deps for the OAuth2-clients screen, resolved by `withClients`: the gate + the
|
||||
// Hydra capability (else a themed 503). Each route below is a thin handler over these.
|
||||
interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: SessionIdentity; }
|
||||
interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: User; }
|
||||
|
||||
function withClients(inner: (deps: ClientsDeps) => Promise<RouteResult>): RouteHandler {
|
||||
return async (ctx) => {
|
||||
|
||||
@@ -18,7 +18,7 @@ import type { RelationTuple } from "#plugin-api";
|
||||
|
||||
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
|
||||
const userTuple = (group: string, n: number): RelationTuple =>
|
||||
({ namespace: "Group", object: group, relation: "members", subject_id: `identity:${uid(n)}` });
|
||||
({ namespace: "Group", object: group, relation: "members", subject_id: `user:${uid(n)}` });
|
||||
const groupTuple = (group: string, child: string): RelationTuple =>
|
||||
({ namespace: "Group", object: group, relation: "members", subject_set: { namespace: "Group", object: child, relation: "members" } });
|
||||
|
||||
@@ -28,12 +28,12 @@ test("isValidGroupName accepts URL-safe names, rejects empties/spaces/uppercase/
|
||||
});
|
||||
|
||||
test("parseSubject + memberTuple map the form value to the user/nested-group subject (else null)", () => {
|
||||
assert.deepEqual(parseSubject(`identity:${uid(1)}`), { subject_id: `identity:${uid(1)}` });
|
||||
assert.deepEqual(parseSubject(`user:${uid(1)}`), { subject_id: `user:${uid(1)}` });
|
||||
assert.deepEqual(parseSubject("group:eng"), { subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
||||
// Both forms are validated: a non-UUID user / invalid group name is rejected, not written blindly.
|
||||
for (const bad of ["", "identity:", "identity:not-a-uuid", "group:", "group:Bad Name", "nope:x", "plain"]) assert.equal(parseSubject(bad), null, bad);
|
||||
for (const bad of ["", "user:", "user:not-a-uuid", "group:", "group:Bad Name", "nope:x", "plain"]) assert.equal(parseSubject(bad), null, bad);
|
||||
|
||||
assert.deepEqual(memberTuple("design", `identity:${uid(2)}`), { namespace: "Group", object: "design", relation: "members", subject_id: `identity:${uid(2)}` });
|
||||
assert.deepEqual(memberTuple("design", `user:${uid(2)}`), { namespace: "Group", object: "design", relation: "members", subject_id: `user:${uid(2)}` });
|
||||
assert.deepEqual(memberTuple("design", "group:eng"), { namespace: "Group", object: "design", relation: "members", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
||||
assert.equal(memberTuple("design", "bad"), null);
|
||||
});
|
||||
@@ -48,8 +48,8 @@ test("groupsFromTuples collapses membership tuples → distinct groups + member
|
||||
|
||||
test("memberView resolves a user subject to its email (else the raw id) and a subject_set to the group", () => {
|
||||
const emails = new Map([[uid(1), "ada@example.com"]]);
|
||||
assert.deepEqual(memberView(userTuple("eng", 1), emails), { kind: "identity", label: "ada@example.com", subject: `identity:${uid(1)}` });
|
||||
assert.deepEqual(memberView(userTuple("eng", 9), emails), { kind: "identity", label: `identity:${uid(9)}`, subject: `identity:${uid(9)}` });
|
||||
assert.deepEqual(memberView(userTuple("eng", 1), emails), { kind: "user", label: "ada@example.com", subject: `user:${uid(1)}` });
|
||||
assert.deepEqual(memberView(userTuple("eng", 9), emails), { kind: "user", label: `user:${uid(9)}`, subject: `user:${uid(9)}` });
|
||||
assert.deepEqual(memberView(groupTuple("eng", "design"), emails), { kind: "group", label: "design", subject: "group:design" });
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ test("buildGroupsListModel filters by search, sorts, paginates; the name links t
|
||||
});
|
||||
|
||||
test("buildGroupFormModel: a create form with a required name field + member options, no group of its own", () => {
|
||||
const options = [{ label: "ada@example.com", value: `identity:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
||||
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
||||
const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
||||
assert.equal(m.title, "New group");
|
||||
assert.equal(m.form.action, "/admin/groups");
|
||||
@@ -96,8 +96,8 @@ test("buildGroupFormModel: a create form with a required name field + member opt
|
||||
test("buildGroupDetailModel: members → rows, add-options exclude current members + the group itself, delete/remove wired", () => {
|
||||
const members = [memberView(userTuple("eng", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("eng", "design"), new Map())];
|
||||
const candidates = [
|
||||
{ label: "ada@example.com", value: `identity:${uid(1)}` }, // already a member → excluded
|
||||
{ label: "grace@example.com", value: `identity:${uid(2)}` },
|
||||
{ label: "ada@example.com", value: `user:${uid(1)}` }, // already a member → excluded
|
||||
{ label: "grace@example.com", value: `user:${uid(2)}` },
|
||||
{ label: "design (group)", value: "group:design" }, // already a member → excluded
|
||||
{ label: "eng (group)", value: "group:eng" }, // the group itself → excluded
|
||||
{ label: "ops (group)", value: "group:ops" },
|
||||
@@ -107,6 +107,6 @@ test("buildGroupDetailModel: members → rows, add-options exclude current membe
|
||||
assert.equal(m.members.rows.length, 2);
|
||||
assert.equal(m.members.action, "/admin/groups/eng/members/delete");
|
||||
assert.equal(m.add.action, "/admin/groups/eng/members");
|
||||
assert.deepEqual(m.add.options.map((o) => o.value), [`identity:${uid(2)}`, "group:ops"]);
|
||||
assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]);
|
||||
assert.equal(m.delete.action, "/admin/groups/eng/delete");
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
|
||||
// each returning a RouteResult.
|
||||
|
||||
import { type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type SessionIdentity } from "#plugin-api";
|
||||
import { type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type User } from "#plugin-api";
|
||||
import { ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||
import type { FieldConfig } from "./admin-users.ts";
|
||||
|
||||
@@ -25,9 +25,9 @@ export interface GroupView {
|
||||
}
|
||||
|
||||
// A member's view model: a user (label = email) or a nested group (label = group name). `subject`
|
||||
// is the form value that round-trips it — `identity:<id>` or `group:<name>` (see parseSubject).
|
||||
// is the form value that round-trips it — `user:<id>` or `group:<name>` (see parseSubject).
|
||||
export interface MemberView {
|
||||
kind: "group" | "identity";
|
||||
kind: "group" | "user";
|
||||
label: string;
|
||||
subject: string;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export interface MemberView {
|
||||
// One option in a member <select>.
|
||||
export interface MemberOption {
|
||||
label: string;
|
||||
value: string; // `identity:<id>` | `group:<name>`
|
||||
value: string; // `user:<id>` | `group:<name>`
|
||||
}
|
||||
|
||||
export function isValidGroupName(name: string): boolean {
|
||||
@@ -51,7 +51,7 @@ export function parseSubject(value: string): { subject_id: string } | { subject_
|
||||
if (!rest) return null;
|
||||
// Validate both subject forms so a crafted POST can't write a dangling tuple (the pickers only
|
||||
// ever offer real users/groups): a user id is a Kratos UUID, a nested group a valid group name.
|
||||
if (value.slice(0, sep) === "identity") return UUID.test(rest) ? { subject_id: `identity:${rest}` } : null;
|
||||
if (value.slice(0, sep) === "user") return UUID.test(rest) ? { subject_id: `user:${rest}` } : null;
|
||||
if (value.slice(0, sep) === "group") return isValidGroupName(rest) ? { subject_set: { namespace: GROUP_NS, object: rest, relation: MEMBERS } } : null;
|
||||
return null;
|
||||
}
|
||||
@@ -72,8 +72,8 @@ export function groupsFromTuples(tuples: RelationTuple[]): GroupView[] {
|
||||
export function memberView(tuple: RelationTuple, emailById: Map<string, string>): MemberView {
|
||||
if (tuple.subject_set) return { kind: "group", label: tuple.subject_set.object, subject: `group:${tuple.subject_set.object}` };
|
||||
const subjectId = tuple.subject_id ?? "";
|
||||
const id = subjectId.startsWith("identity:") ? subjectId.slice("identity:".length) : subjectId;
|
||||
return { kind: "identity", label: emailById.get(id) ?? subjectId, subject: subjectId };
|
||||
const id = subjectId.startsWith("user:") ? subjectId.slice("user:".length) : subjectId;
|
||||
return { kind: "user", label: emailById.get(id) ?? subjectId, subject: subjectId };
|
||||
}
|
||||
|
||||
// ---- list view model ----
|
||||
@@ -267,7 +267,7 @@ export async function memberCandidates(keto: KetoClient, kratosAdmin: KratosAdmi
|
||||
const trait = it.traits?.["email"];
|
||||
const email = typeof trait === "string" ? trait : it.id;
|
||||
emailById.set(it.id, email);
|
||||
userOptions.push({ label: email, value: `identity:${it.id}` });
|
||||
userOptions.push({ label: email, value: `user:${it.id}` });
|
||||
}
|
||||
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
|
||||
return { emailById, options: [...userOptions, ...groups.map((g) => ({ label: `${g.name} (group)`, value: `group:${g.name}` }))] };
|
||||
@@ -281,7 +281,7 @@ async function groupExists(keto: KetoClient, name: string): Promise<boolean> {
|
||||
|
||||
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate + the Keto and
|
||||
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these.
|
||||
interface GroupsDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; user: SessionIdentity; }
|
||||
interface GroupsDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; user: User; }
|
||||
|
||||
function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>): RouteHandler {
|
||||
return async (ctx) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Built-in Roles admin screen: the pure view-model + Keto builders. A role is a
|
||||
// Built-in Roles & permissions admin screen: the pure view-model + Keto builders. A role is a
|
||||
// Keto subject set (Role:<name>#members); members are users (subject_id) or groups (subject_set) —
|
||||
// "assign roles to users/groups". The "effective access" view flattens a Keto `expand` tree into the
|
||||
// distinct set of users who hold the role directly or transitively via a group. The HTTP
|
||||
@@ -18,7 +18,7 @@ import type { ExpandTree, RelationTuple } from "#plugin-api";
|
||||
|
||||
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
|
||||
const userTuple = (role: string, n: number): RelationTuple =>
|
||||
({ namespace: "Role", object: role, relation: "members", subject_id: `identity:${uid(n)}` });
|
||||
({ namespace: "Role", object: role, relation: "members", subject_id: `user:${uid(n)}` });
|
||||
const groupTuple = (role: string, group: string): RelationTuple =>
|
||||
({ namespace: "Role", object: role, relation: "members", subject_set: { namespace: "Group", object: group, relation: "members" } });
|
||||
|
||||
@@ -26,14 +26,14 @@ test("isValidRoleName + roleMemberTuple map the form value to a Role tuple over
|
||||
for (const ok of ["admin", "editor", "team-a", "a1_b9"]) assert.equal(isValidRoleName(ok), true, ok);
|
||||
for (const bad of ["", "Admin", "a b", "-bad", "a".repeat(65)]) assert.equal(isValidRoleName(bad), false, bad);
|
||||
|
||||
assert.deepEqual(roleMemberTuple("editor", `identity:${uid(2)}`), { namespace: "Role", object: "editor", relation: "members", subject_id: `identity:${uid(2)}` });
|
||||
assert.deepEqual(roleMemberTuple("editor", `user:${uid(2)}`), { namespace: "Role", object: "editor", relation: "members", subject_id: `user:${uid(2)}` });
|
||||
assert.deepEqual(roleMemberTuple("editor", "group:eng"), { namespace: "Role", object: "editor", relation: "members", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
||||
for (const bad of ["", "identity:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(roleMemberTuple("editor", bad), null, bad);
|
||||
for (const bad of ["", "user:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(roleMemberTuple("editor", bad), null, bad);
|
||||
});
|
||||
|
||||
test("expandToEffectiveUsers flattens an expand tree → sorted distinct user ids, transitive through groups", () => {
|
||||
// The subject rides on each node's `tuple` (Keto v26.2.0 shape, verified live).
|
||||
const leaf = (n: number): ExpandTree => ({ tuple: { namespace: "", object: "", relation: "", subject_id: `identity:${uid(n)}` }, type: "leaf" });
|
||||
const leaf = (n: number): ExpandTree => ({ tuple: { namespace: "", object: "", relation: "", subject_id: `user:${uid(n)}` }, type: "leaf" });
|
||||
const tree: ExpandTree = {
|
||||
children: [
|
||||
leaf(1), // direct
|
||||
@@ -71,7 +71,7 @@ test("buildRolesListModel filters by search, sorts, paginates; the name links to
|
||||
});
|
||||
|
||||
test("buildRoleFormModel: a create form with a required name field + member options (user or group)", () => {
|
||||
const options = [{ label: "ada@example.com", value: `identity:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
||||
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
||||
const m = buildRoleFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
||||
assert.equal(m.title, "New role");
|
||||
assert.equal(m.form.action, "/admin/roles");
|
||||
@@ -89,8 +89,8 @@ test("buildRoleFormModel: a create form with a required name field + member opti
|
||||
test("buildRoleDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => {
|
||||
const members = [memberView(userTuple("admin", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("admin", "eng"), new Map())];
|
||||
const candidates = [
|
||||
{ label: "ada@example.com", value: `identity:${uid(1)}` }, // already a member → excluded
|
||||
{ label: "grace@example.com", value: `identity:${uid(2)}` },
|
||||
{ label: "ada@example.com", value: `user:${uid(1)}` }, // already a member → excluded
|
||||
{ label: "grace@example.com", value: `user:${uid(2)}` },
|
||||
{ label: "eng (group)", value: "group:eng" }, // already a member → excluded
|
||||
{ label: "ops (group)", value: "group:ops" },
|
||||
];
|
||||
@@ -100,7 +100,7 @@ test("buildRoleDetailModel: members → rows, add-options exclude current member
|
||||
assert.equal(m.members.rows.length, 2);
|
||||
assert.equal(m.members.action, "/admin/roles/admin/members/delete");
|
||||
assert.equal(m.add.action, "/admin/roles/admin/members");
|
||||
assert.deepEqual(m.add.options.map((o) => o.value), [`identity:${uid(2)}`, "group:ops"]);
|
||||
assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]);
|
||||
assert.deepEqual(m.effective.map((e) => e.label), ["ada@example.com", "grace@example.com"]);
|
||||
assert.equal(m.delete.action, "/admin/roles/admin/delete");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Roles admin screen: list / create / delete Keto roles and assign
|
||||
// Roles & permissions admin screen: list / create / delete Keto roles and assign
|
||||
// them to users and groups. A role is a Keto subject set `Role:<name>#members` (OPL: members are users
|
||||
// or groups, resolved transitively) — the source of truth for the JWT `roles` claim. It shares the
|
||||
// Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging)
|
||||
@@ -8,8 +8,8 @@
|
||||
// Kratos is read only to label members. Below the builders are thin per-route handlers (keyed on
|
||||
// ctx.params) over a shared `withRoles` gate — admin-only, CSRF-guarded.
|
||||
|
||||
import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SessionIdentity } from "#plugin-api";
|
||||
import { ADMIN_ROLE, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||
import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
|
||||
import { ADMIN_PERMISSION, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||
import {
|
||||
type GroupView,
|
||||
groupsFromTuples,
|
||||
@@ -54,7 +54,7 @@ export function expandToEffectiveUsers(tree: ExpandTree | null | undefined): str
|
||||
const walk = (node?: ExpandTree | null): void => {
|
||||
if (!node) return;
|
||||
const subjectId = node.tuple?.subject_id;
|
||||
if (subjectId?.startsWith("identity:")) ids.add(subjectId.slice("identity:".length));
|
||||
if (subjectId?.startsWith("user:")) ids.add(subjectId.slice("user:".length));
|
||||
node.children?.forEach(walk);
|
||||
};
|
||||
walk(tree);
|
||||
@@ -231,11 +231,11 @@ export function buildRoleDetailModel(opts: {
|
||||
|
||||
// ---- request handler (imperative shell) ----
|
||||
|
||||
// instant-revoke: a role change for a `identity:<id>` member must take effect now, so revoke that
|
||||
// instant-revoke: a role change for a `user:<id>` member must take effect now, so revoke that
|
||||
// user's live tokens (a re-mint then re-reads roles from Keto). A `group:<name>` change is
|
||||
// transitive across many users — left to lag (documented), so only direct user members revoke.
|
||||
function revokeUserMember(revoke: ((sub: string) => void) | undefined, member: string): void {
|
||||
if (revoke && member.startsWith("identity:")) revoke(member.slice("identity:".length));
|
||||
if (revoke && member.startsWith("user:")) revoke(member.slice("user:".length));
|
||||
}
|
||||
|
||||
// A role exists exactly while it has ≥1 member (Keto has no create-object).
|
||||
@@ -250,13 +250,13 @@ async function effectiveUsers(keto: KetoClient, name: string, hasMembers: boolea
|
||||
if (!hasMembers) return [];
|
||||
const tree = await keto.expand({ namespace: ROLE_NS, object: name, relation: MEMBERS }, { maxDepth: EXPAND_MAX_DEPTH });
|
||||
return expandToEffectiveUsers(tree)
|
||||
.map((id) => ({ label: emailById.get(id) ?? `identity:${id}` }))
|
||||
.map((id) => ({ label: emailById.get(id) ?? `user:${id}` }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
// Shared per-request deps for the Roles screen, resolved by `withRoles`: the gate + the Keto and
|
||||
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these.
|
||||
interface RolesDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: SessionIdentity; }
|
||||
interface RolesDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
|
||||
|
||||
function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): RouteHandler {
|
||||
return async (ctx) => {
|
||||
@@ -333,7 +333,7 @@ export const rolesAddMember = withRoleName(async (deps, name) => {
|
||||
|
||||
// GET /admin/roles/:name/delete — confirm, except the admin role can't be deleted.
|
||||
export const rolesDeleteConfirm = withRoleName((deps, name) => {
|
||||
if (name === ADMIN_ROLE) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access.");
|
||||
if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access.");
|
||||
const base = detailHref(name);
|
||||
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }],
|
||||
@@ -347,7 +347,7 @@ export const rolesDeleteConfirm = withRoleName((deps, name) => {
|
||||
export const rolesDelete = withRoleName(async (deps, name) => {
|
||||
const { ctx, keto, user } = deps;
|
||||
await guardedForm(ctx); // CSRF-verify the POST
|
||||
if (name === ADMIN_ROLE) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access.");
|
||||
if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access.");
|
||||
await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS });
|
||||
ctx.log.info("admin: role deleted", { actor: user.id, role: name });
|
||||
return { redirect: ADMIN_ROLES_BASE };
|
||||
@@ -360,7 +360,7 @@ export const rolesRemoveMember = withRoleName(async (deps, name) => {
|
||||
const { ctx, keto, revoke, user } = deps;
|
||||
const form = (await guardedForm(ctx))!;
|
||||
const member = (form.get("member") ?? "").trim();
|
||||
if (name === ADMIN_ROLE && member === `identity:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access.");
|
||||
if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access.");
|
||||
const tuple = roleMemberTuple(name, member);
|
||||
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); }
|
||||
return { redirect: detailHref(name) };
|
||||
|
||||
@@ -6,20 +6,20 @@ import assert from "node:assert/strict";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Readable } from "node:stream";
|
||||
import { test } from "node:test";
|
||||
import { GuardError, type Log, type PageChrome, type RequestContext, type SessionIdentity } from "#plugin-api";
|
||||
import { ADMIN_NAV, ADMIN_ROLE, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts";
|
||||
import { GuardError, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api";
|
||||
import { ADMIN_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts";
|
||||
|
||||
const admin: SessionIdentity = { email: "ada@x.io", id: "u1", roles: ["admin"] };
|
||||
const member: SessionIdentity = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] };
|
||||
const admin: User = { email: "ada@x.io", id: "u1", roles: ["admin"] };
|
||||
const member: User = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] };
|
||||
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
|
||||
|
||||
function fakeCtx(opts: { body?: string; method?: string; user?: SessionIdentity | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||
function fakeCtx(opts: { body?: string; method?: string; user?: User | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||
const url = new URL("http://localhost/admin/users");
|
||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||
req.method = opts.method ?? "GET";
|
||||
return {
|
||||
chrome: CHROME, identity: opts.user ?? null, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
||||
roles: opts.user?.roles ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||
chrome: CHROME, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
||||
roles: opts.user?.roles ?? [], url, user: opts.user ?? null, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,11 +27,11 @@ function fakeCtx(opts: { body?: string; method?: string; user?: SessionIdentity
|
||||
|
||||
test("ADMIN_NAV: a gated Admin header over the four screens; no per-request current/open state", () => {
|
||||
assert.equal(ADMIN_NAV.id, "admin");
|
||||
assert.equal(ADMIN_NAV.role, ADMIN_ROLE); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin
|
||||
assert.equal(ADMIN_NAV.permission, ADMIN_PERMISSION); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin
|
||||
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
|
||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/roles", "/admin/clients"]);
|
||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["Users", "Groups", "Roles", "OAuth2 clients"]);
|
||||
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.role === undefined)); // the header's gate covers the subtree
|
||||
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === undefined)); // the header's gate covers the subtree
|
||||
});
|
||||
|
||||
// ---- auth gates ----
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
// (themed not-found / capability-unavailable). Ported from the former built-in admin screens;
|
||||
// everything imports the host only through the #plugin-api barrel.
|
||||
|
||||
import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type SessionIdentity } from "#plugin-api";
|
||||
import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type User } from "#plugin-api";
|
||||
|
||||
export const ADMIN_ROLE = "admin"; // the role gating the whole admin section
|
||||
export const ADMIN_PERMISSION = "admin"; // role token gating the whole admin section
|
||||
export const ADMIN_USERS_BASE = "/admin/users";
|
||||
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
||||
export const ADMIN_ROLES_BASE = "/admin/roles";
|
||||
@@ -14,7 +14,7 @@ export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
||||
export type AdminScreen = "clients" | "groups" | "roles" | "users";
|
||||
|
||||
// The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into
|
||||
// the one global menu, filters per user (the header's `role` drops the whole subtree for a
|
||||
// the one global menu, filters per user (the header's `permission` drops the whole subtree for a
|
||||
// non-admin), and current-marks the active item — so there is no `current`/`open` state here.
|
||||
export const ADMIN_NAV: NavNode = {
|
||||
children: [
|
||||
@@ -26,15 +26,15 @@ export const ADMIN_NAV: NavNode = {
|
||||
icon: "i-shield",
|
||||
id: "admin",
|
||||
label: "Admin",
|
||||
role: ADMIN_ROLE,
|
||||
permission: ADMIN_PERMISSION,
|
||||
};
|
||||
|
||||
// The admin gate: a signed-in admin only. Each route already declares `role: "admin"`, so the
|
||||
// The admin gate: a signed-in admin only. Each route already declares `permission: "admin"`, so the
|
||||
// host enforces this before the handler runs; this is defence-in-depth and what a direct unit test
|
||||
// relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403.
|
||||
export function requireAdmin(ctx: RequestContext): SessionIdentity {
|
||||
export function requireAdmin(ctx: RequestContext): User {
|
||||
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
|
||||
if (!can(ctx, ADMIN_ROLE)) throw new GuardError(403, "admin role required");
|
||||
if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin role required");
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// models; below them are thin per-route handlers (keyed on ctx.params) over a shared `withUser` gate
|
||||
// — admin-only, CSRF-guarded, each returning a RouteResult (a view, or a redirect after a write — PRG).
|
||||
|
||||
import { type Identity, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type SessionIdentity } from "#plugin-api";
|
||||
import { type Identity, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api";
|
||||
import { ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||
|
||||
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
|
||||
@@ -266,9 +266,9 @@ function readUserInput(form: URLSearchParams): UserInput {
|
||||
|
||||
// Shared per-request deps for the Users screen, resolved by `withUser`: the gate (admin only) and
|
||||
// the Kratos capability (else a themed 503). Each route below is a thin handler over these.
|
||||
interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: SessionIdentity; }
|
||||
interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
|
||||
|
||||
// Resolve the shared deps, then run `inner`. The route's `role: "admin"` already gated at the
|
||||
// Resolve the shared deps, then run `inner`. The route's `permission: "admin"` already gated at the
|
||||
// host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
|
||||
function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>): RouteHandler {
|
||||
return async (ctx) => {
|
||||
|
||||
@@ -11,19 +11,19 @@ import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clie
|
||||
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsRemoveMember } from "./admin-groups.ts";
|
||||
import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-roles.ts";
|
||||
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
||||
import { ADMIN_NAV, ADMIN_ROLE } from "./admin-shared.ts";
|
||||
import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts";
|
||||
|
||||
// Every admin route is gated by the one `admin` role — the host redirects an anonymous visitor
|
||||
// Every admin route is gated by the one `admin` permission — the host redirects an anonymous visitor
|
||||
// to /login, gives a signed-in non-admin the 403 page, and filters the nav the same way. Handlers are
|
||||
// thin and keyed on ctx.params (the host extracts :id / :name), the idiomatic per-route style.
|
||||
const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, role: ADMIN_ROLE });
|
||||
const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, permission: ADMIN_PERMISSION });
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||
|
||||
nav: [ADMIN_NAV],
|
||||
|
||||
roles: [{ description: "Administer users, groups, roles, and OAuth2 clients", name: ADMIN_ROLE }],
|
||||
permissions: [{ description: "Administer users, groups, roles, and OAuth2 clients", token: ADMIN_PERMISSION }],
|
||||
|
||||
routes: [
|
||||
// Users
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%#
|
||||
Admin group membership body, captured into the shell content slot. Config:
|
||||
group { name }
|
||||
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = remove-member endpoint
|
||||
members { action, rows: { kind:"group"|"user", label, subject }[] } action = remove-member endpoint
|
||||
add { action, options: {label,value}[] } action = add-member endpoint
|
||||
del { action } delete the whole group
|
||||
csrfToken, error?
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<%#
|
||||
Admin role detail body, captured into the shell content slot. Config:
|
||||
role { name }
|
||||
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = revoke endpoint
|
||||
members { action, rows: { kind:"group"|"user", label, subject }[] } action = revoke endpoint
|
||||
effective { label }[] users who hold the role (expand)
|
||||
add { action, options: {label,value}[] } action = assign endpoint
|
||||
del { action } delete the whole role
|
||||
|
||||
@@ -15,7 +15,7 @@ What it demonstrates:
|
||||
`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`,
|
||||
reusing the core `field` partial.
|
||||
- **Role-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` /
|
||||
- **Permission-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` /
|
||||
`scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant.
|
||||
|
||||
The plugin holds **no state** — data lives upstream (README → *Stateless*). Handlers are thin and
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Reference plugin: a worked example of the contract — a list page that fetches upstream
|
||||
// data, a CSRF-guarded form that forwards a write upstream, and role-gated nav. Copy this
|
||||
// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this
|
||||
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
|
||||
|
||||
import { definePlugin } from "#plugin-api";
|
||||
@@ -23,25 +23,25 @@ export default definePlugin({
|
||||
nav: [{
|
||||
children: [
|
||||
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true },
|
||||
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", role: READ },
|
||||
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", permission: READ },
|
||||
],
|
||||
icon: "i-cal",
|
||||
id: "scheduling",
|
||||
label: "Scheduling",
|
||||
}],
|
||||
|
||||
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
|
||||
roles: [
|
||||
{ description: "View shifts", name: READ },
|
||||
{ description: "Create and edit shifts", name: WRITE },
|
||||
// Tokens this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
|
||||
permissions: [
|
||||
{ description: "View shifts", token: READ },
|
||||
{ description: "Create and edit shifts", token: WRITE },
|
||||
],
|
||||
|
||||
// Mounted under /scheduling; `role` gates before the handler runs. The overview is `public`
|
||||
// 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 role.
|
||||
routes: [
|
||||
{ handler: overview(), method: "GET", path: "/", public: true },
|
||||
{ handler: listShifts(upstream), method: "GET", path: "/shifts", role: READ },
|
||||
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", role: WRITE },
|
||||
{ handler: createShift(upstream), method: "POST", path: "/shifts", role: WRITE },
|
||||
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
|
||||
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
|
||||
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -16,8 +16,8 @@ function fakeCtx(opts: { body?: string; roles?: string[]; url?: string; verifyCs
|
||||
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;
|
||||
return {
|
||||
chrome: CHROME, identity: null, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
||||
roles: opts.roles ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||
chrome: CHROME, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
||||
roles: opts.roles ?? [], url, user: null, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormB
|
||||
|
||||
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
|
||||
export const SHIFTS_PATH = "/scheduling/shifts";
|
||||
export const READ = "scheduling:read"; // the role gating the list + nav
|
||||
export const WRITE = "scheduling:write"; // the role gating create
|
||||
export const READ = "scheduling:read"; // permission token gating the list + nav
|
||||
export const WRITE = "scheduling:write"; // permission token gating create
|
||||
|
||||
export interface Shift {
|
||||
id: string;
|
||||
@@ -188,7 +188,7 @@ export function newShiftForm(): RouteHandler {
|
||||
// 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
|
||||
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
|
||||
// else a prompt to sign in. ctx.identity may be null here, so read the role via can() (zero I/O).
|
||||
// else a prompt to sign in. ctx.user may be null here, so read the role via can() (zero I/O).
|
||||
export function overview(): RouteHandler {
|
||||
return (ctx) => ({
|
||||
data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" },
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"errors":null,"message":"not found","url":"https://gitea.larvit.se/api/swagger"}
|
||||
@@ -4,23 +4,23 @@
|
||||
// identity ids (== the JWT `sub`).
|
||||
import { Context, Namespace, SubjectSet } from "@ory/keto-namespace-types"
|
||||
|
||||
// A Kratos identity. Subjects are written as `identity:<kratos-identity-id>`.
|
||||
class Identity implements Namespace {}
|
||||
// A human identity. Subjects are written as `user:<kratos-identity-id>`.
|
||||
class User implements Namespace {}
|
||||
|
||||
// A subject set: a named collection of users (and nested groups), resolved transitively.
|
||||
// The admin "Groups" screen manages membership; checks expand it automatically.
|
||||
class Group implements Namespace {
|
||||
related: {
|
||||
members: (Identity | SubjectSet<Group, "members">)[]
|
||||
members: (User | SubjectSet<Group, "members">)[]
|
||||
}
|
||||
}
|
||||
|
||||
// A coarse role — the source of truth for the JWT `roles` claim. At login the app reads
|
||||
// `Role:<name>#members@identity:<id>` from Keto and projects the result into the token
|
||||
// `role:<name>#members@user:<id>` from Keto and projects the result into the token
|
||||
// (README: Login → session JWT). A group can hold a role, so members can be users or groups.
|
||||
class Role implements Namespace {
|
||||
related: {
|
||||
members: (Identity | SubjectSet<Group, "members">)[]
|
||||
members: (User | SubjectSet<Group, "members">)[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,9 +29,9 @@ class Role implements Namespace {
|
||||
// Grants accept a user directly or any member of a group.
|
||||
class Resource implements Namespace {
|
||||
related: {
|
||||
owners: (Identity | SubjectSet<Group, "members">)[]
|
||||
editors: (Identity | SubjectSet<Group, "members">)[]
|
||||
viewers: (Identity | SubjectSet<Group, "members">)[]
|
||||
owners: (User | SubjectSet<Group, "members">)[]
|
||||
editors: (User | SubjectSet<Group, "members">)[]
|
||||
viewers: (User | SubjectSet<Group, "members">)[]
|
||||
}
|
||||
|
||||
permits = {
|
||||
|
||||
Generated
+84
-377
@@ -9,13 +9,13 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@larvit/log": "2.3.0",
|
||||
"ejs": "6.0.1",
|
||||
"lucide-static": "1.28.0"
|
||||
"ejs": "3.1.10",
|
||||
"lucide-static": "1.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ejs": "3.1.5",
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "7.0.2"
|
||||
"@types/node": "24.13.2",
|
||||
"typescript": "5.9.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
@@ -38,406 +38,113 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||
"version": "24.13.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
"node_modules/async": {
|
||||
"version": "3.2.6",
|
||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-loong64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-s390x": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-sunos-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
||||
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ejs": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
|
||||
"integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
|
||||
"version": "3.1.10",
|
||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
|
||||
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"jake": "^10.8.5"
|
||||
},
|
||||
"bin": {
|
||||
"ejs": "bin/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.12.18"
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/filelist": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
|
||||
"integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"minimatch": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jake": {
|
||||
"version": "10.9.4",
|
||||
"resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
|
||||
"integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"async": "^3.2.6",
|
||||
"filelist": "^1.0.4",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"jake": "bin/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-static": {
|
||||
"version": "1.28.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.28.0.tgz",
|
||||
"integrity": "sha512-dC3VJwRFsjEVX7Iaq4rY88pm7Fi2OmOb8P0WRzXsUMgbt7sCmFX8bLhaDBeNW6JdRjuele+jKqqFaam4yr+Ygg==",
|
||||
"version": "1.18.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.18.0.tgz",
|
||||
"integrity": "sha512-0WRXLQnjbte5SXuzom6yfeGlVSFsEsC9rzxn66DZN0pXows3+N34CQHy3BHI1qA3uH7u/SUzx8LQhjeAnxd8JQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "5.1.9",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
|
||||
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc"
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||
"@typescript/typescript-linux-arm": "7.0.2",
|
||||
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||
"@typescript/typescript-linux-x64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||
"@typescript/typescript-win32-x64": "7.0.2"
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
|
||||
+3
-3
@@ -20,11 +20,11 @@
|
||||
"dependencies": {
|
||||
"@larvit/log": "2.3.0",
|
||||
"ejs": "6.0.1",
|
||||
"lucide-static": "1.28.0"
|
||||
"lucide-static": "1.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ejs": "3.1.5",
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "7.0.2"
|
||||
"@types/node": "24.13.2",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,17 +20,17 @@ test("identityPayload is a valid Kratos create-identity body with a password cre
|
||||
assert.equal(body.credentials.password.config.password, "admin");
|
||||
});
|
||||
|
||||
test("roleTuple grants a role to identity:<id> in the Role namespace", () => {
|
||||
test("roleTuple grants a role to user:<id> in the Role namespace", () => {
|
||||
const id = randomUUID();
|
||||
assert.deepEqual(roleTuple(id, "admin"), {
|
||||
namespace: "Role",
|
||||
object: "admin",
|
||||
relation: "members",
|
||||
subject_id: `identity:${id}`,
|
||||
subject_id: `user:${id}`,
|
||||
});
|
||||
});
|
||||
|
||||
test("seedRoles unions ADMIN_ROLES (default 'admin') with the discovered plugins' declared roles", () => {
|
||||
test("seedRoles unions ADMIN_ROLES (default 'admin') with the discovered plugins' declared tokens", () => {
|
||||
// Clean clone: no ADMIN_ROLES, the scheduling plugin declares its two tokens → the demo admin
|
||||
// gets exactly today's behaviour, but derived from discovery, not hardcoded in the host.
|
||||
assert.deepEqual(seedRoles(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]);
|
||||
@@ -65,8 +65,8 @@ test("seedAdmin on a fresh stack creates the identity and grants every role (one
|
||||
assert.equal(puts.length, 2); // one grant per role
|
||||
assert.ok(puts.every((p) => p.method === "PUT"));
|
||||
assert.deepEqual(puts.map((p) => p.body), [
|
||||
{ namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${id}` },
|
||||
{ namespace: "Role", object: "scheduling:read", relation: "members", subject_id: `identity:${id}` },
|
||||
{ namespace: "Role", object: "admin", relation: "members", subject_id: `user:${id}` },
|
||||
{ namespace: "Role", object: "scheduling:read", relation: "members", subject_id: `user:${id}` },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -94,7 +94,7 @@ test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { created: false, id, roles: ["admin"] });
|
||||
assert.deepEqual(granted, { namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${id}` });
|
||||
assert.deepEqual(granted, { namespace: "Role", object: "admin", relation: "members", subject_id: `user:${id}` });
|
||||
});
|
||||
|
||||
test("seedAdmin fails loud on an unexpected Kratos error", async () => {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`:
|
||||
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
|
||||
// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
|
||||
// 3. grant it its roles in Keto so menu/role checks resolve out of the box — `admin` plus
|
||||
// every discovered plugin's declared role names, so a dropped-in plugin is usable by
|
||||
// 3. grant it its roles in Keto so menu/permission checks resolve out of the box — `admin` plus
|
||||
// every discovered plugin's declared permission tokens, so a dropped-in plugin is usable by
|
||||
// the demo admin with no host config edit (the host stays plugin-agnostic).
|
||||
// Then prints a first-run banner; fails loud on any unexpected upstream error.
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
@@ -22,19 +22,19 @@ export function identityPayload(email: string, password: string) {
|
||||
};
|
||||
}
|
||||
|
||||
// Coarse-role grant: `Role:<role>#members@identity:<id>`. Subject ids are `identity:<kratos-id>`
|
||||
// Coarse-role grant: `Role:<role>#members@user:<id>`. Subject ids are `user:<kratos-id>`
|
||||
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT roles.
|
||||
export function roleTuple(identityId: string, role: string) {
|
||||
return { namespace: "Role", object: role, relation: "members", subject_id: `identity:${identityId}` };
|
||||
return { namespace: "Role", object: role, relation: "members", subject_id: `user:${identityId}` };
|
||||
}
|
||||
|
||||
// The roles to grant the demo admin = the configured base (ADMIN_ROLES, default just `admin`)
|
||||
// unioned with every discovered plugin's declared role names (a route/nav `role` is a
|
||||
// unioned with every discovered plugin's declared permission tokens (a route/nav `permission` is a
|
||||
// coarse role — granted as a Keto `Role:<token>#members` tuple). So the host names no plugin, yet a
|
||||
// dropped-in plugin's tokens are seeded out of the box. Deduped, order-stable, blanks dropped.
|
||||
export function seedRoles(adminRolesEnv: string | undefined, declaredRoles: string[]): string[] {
|
||||
export function seedRoles(adminRolesEnv: string | undefined, declaredTokens: string[]): string[] {
|
||||
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
|
||||
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredRoles)])];
|
||||
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredTokens)])];
|
||||
}
|
||||
|
||||
// --- JWKS safety net -----------------------------------------------------------------
|
||||
@@ -143,9 +143,9 @@ async function main() {
|
||||
await runWithLog(log, async () => {
|
||||
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
|
||||
|
||||
// Seed `admin` (or ADMIN_ROLES) + every discovered plugin's declared role names, so the
|
||||
// Seed `admin` (or ADMIN_ROLES) + every discovered plugin's declared permission tokens, so the
|
||||
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
|
||||
const declared = (await discoverPlugins()).flatMap((p) => (p.roles ?? []).map((d) => d.name));
|
||||
const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.token));
|
||||
const roles = seedRoles(env["ADMIN_ROLES"], declared);
|
||||
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
|
||||
const password = env["ADMIN_PASSWORD"] ?? "admin";
|
||||
|
||||
@@ -2,17 +2,17 @@ import assert from "node:assert/strict";
|
||||
import { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Socket } from "node:net";
|
||||
import { test } from "node:test";
|
||||
import { buildContext, type RequestContext, type SessionIdentity } from "../http/context.ts";
|
||||
import { buildContext, type RequestContext, type User } from "../http/context.ts";
|
||||
import { can, check, GuardError, requireSession } from "./guards.ts";
|
||||
import type { KetoClient, RelationTuple } from "./keto-client.ts";
|
||||
|
||||
function ctxFor(user: SessionIdentity | null, url = "/"): RequestContext {
|
||||
function ctxFor(user: User | null, url = "/"): RequestContext {
|
||||
const req = new IncomingMessage(new Socket());
|
||||
req.url = url;
|
||||
return buildContext(req, new ServerResponse(req), { identity: user });
|
||||
return buildContext(req, new ServerResponse(req), { user });
|
||||
}
|
||||
|
||||
const alice: SessionIdentity = { email: "a@b.c", id: "u1", roles: ["admin", "scheduling:read"] };
|
||||
const alice: User = { email: "a@b.c", id: "u1", roles: ["admin", "scheduling:read"] };
|
||||
|
||||
test("requireSession returns the user, or throws GuardError(401)→/login (preserving return_to) when anonymous", () => {
|
||||
assert.equal(requireSession(ctxFor(alice)), alice);
|
||||
@@ -44,7 +44,7 @@ test("check asks Keto with the current user as subject; anonymous is denied with
|
||||
const tuple = { namespace: "Resource", object: "doc1", relation: "view" };
|
||||
|
||||
assert.equal(await check(keto, ctxFor(alice), tuple), true);
|
||||
assert.deepEqual(asked, { ...tuple, subject_id: "identity:u1" }); // subject is the signed-in user
|
||||
assert.deepEqual(asked, { ...tuple, subject_id: "user:u1" }); // subject is the signed-in user
|
||||
|
||||
asked = undefined;
|
||||
assert.equal(await check(keto, ctxFor(null), tuple), false); // fail-closed, no Keto call
|
||||
|
||||
+7
-7
@@ -1,9 +1,9 @@
|
||||
// Auth guards: in-handler authorization, the imperative counterpart to the
|
||||
// declarative route `role` gate. The middleware already verified the session JWT and put
|
||||
// declarative route `permission` gate. The middleware already verified the session JWT and put
|
||||
// the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps
|
||||
// to a response); `can`/`check` are predicates a handler branches on. `check` is the one live
|
||||
// Keto call — the fine-grained "may I?" tier (README), reserved for relationship rules.
|
||||
import type { RequestContext, SessionIdentity } from "../http/context.ts";
|
||||
import type { RequestContext, User } from "../http/context.ts";
|
||||
import type { KetoClient } from "./keto-client.ts";
|
||||
import { localPath } from "../http/safe-url.ts";
|
||||
|
||||
@@ -32,9 +32,9 @@ export class GuardError extends Error {
|
||||
}
|
||||
|
||||
// Assert a signed-in session and return the user. Anonymous ⇒ GuardError → /login (return_to kept).
|
||||
export function requireSession(ctx: RequestContext): SessionIdentity {
|
||||
if (!ctx.identity) throw new GuardError(401, "authentication required", loginRedirect(ctx));
|
||||
return ctx.identity;
|
||||
export function requireSession(ctx: RequestContext): User {
|
||||
if (!ctx.user) throw new GuardError(401, "authentication required", loginRedirect(ctx));
|
||||
return ctx.user;
|
||||
}
|
||||
|
||||
// Coarse role check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false.
|
||||
@@ -49,6 +49,6 @@ export async function check(
|
||||
ctx: RequestContext,
|
||||
tuple: { namespace: string; object: string; relation: string },
|
||||
): Promise<boolean> {
|
||||
if (!ctx.identity) return false;
|
||||
return keto.check({ ...tuple, subject_id: `identity:${ctx.identity.id}` });
|
||||
if (!ctx.user) return false;
|
||||
return keto.check({ ...tuple, subject_id: `user:${ctx.user.id}` });
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { generateKeyPairSync, sign, type JsonWebKey, type KeyObject } from "node:crypto";
|
||||
import { test } from "node:test";
|
||||
import { staticJwks } from "./jwks.ts";
|
||||
import { authenticate, claimsToIdentity, resolveSession, verifyToken } from "./jwt-middleware.ts";
|
||||
import { authenticate, claimsToUser, resolveSession, verifyToken } from "./jwt-middleware.ts";
|
||||
import { SESSION_COOKIE } from "./login.ts";
|
||||
|
||||
const b64url = (input: Buffer | string): string => Buffer.from(input).toString("base64url");
|
||||
@@ -29,10 +29,8 @@ test("verifyToken: a valid token → User, selecting the verify key by kid acros
|
||||
assert.deepEqual(user, { email: "a@b.c", id: "u1", roles: ["admin"] });
|
||||
});
|
||||
|
||||
test("verifyToken requires exp, rejects expiry and future nbf, with clock-skew leeway", async () => {
|
||||
test("verifyToken rejects expiry and future nbf, with clock-skew leeway", async () => {
|
||||
const opts = { clockSkewSec: 60, now: NOW };
|
||||
// No exp ⇒ rejected outright: an exp-less token must never read as eternal.
|
||||
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: undefined }), jwks, opts), /missing exp/);
|
||||
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 120 }), jwks, opts), /expired/);
|
||||
// exp 30s in the past but inside the 60s skew → still accepted.
|
||||
await verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 30 }), jwks, opts);
|
||||
@@ -59,31 +57,31 @@ test("verifyToken rejects a bad signature and an unknown kid", async () => {
|
||||
await assert.rejects(verifyToken(mint(k1.privateKey, "nope", valid), jwks, { now: NOW }), /no JWKS key/);
|
||||
});
|
||||
|
||||
test("claimsToIdentity requires sub + email, defaults roles to [], keeps only string roles", () => {
|
||||
assert.throws(() => claimsToIdentity({ email: "a@b.c", exp: NOW }), /sub/);
|
||||
assert.throws(() => claimsToIdentity({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too
|
||||
assert.throws(() => claimsToIdentity({ exp: NOW, sub: "u" }), /email/);
|
||||
assert.throws(() => claimsToIdentity({ email: "", exp: NOW, sub: "u" }), /email/); // empty email rejected (the shell keys signed-in vs anonymous off it)
|
||||
assert.deepEqual(claimsToIdentity({ email: "a@b.c", sub: "u" }).roles, []); // roles absent
|
||||
assert.deepEqual(claimsToIdentity({ email: "a@b.c", roles: ["a", 1, "b"], sub: "u" }).roles, ["a", "b"]);
|
||||
test("claimsToUser requires sub + email, defaults roles to [], keeps only string roles", () => {
|
||||
assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW }), /sub/);
|
||||
assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too
|
||||
assert.throws(() => claimsToUser({ exp: NOW, sub: "u" }), /email/);
|
||||
assert.throws(() => claimsToUser({ email: "", exp: NOW, sub: "u" }), /email/); // empty email rejected (the shell keys signed-in vs anonymous off it)
|
||||
assert.deepEqual(claimsToUser({ email: "a@b.c", sub: "u" }).roles, []); // roles absent
|
||||
assert.deepEqual(claimsToUser({ email: "a@b.c", roles: ["a", 1, "b"], sub: "u" }).roles, ["a", "b"]);
|
||||
});
|
||||
|
||||
test("resolveSession classifies the cookie; authenticate is its fail-closed identity projection", async () => {
|
||||
test("resolveSession classifies the cookie; authenticate is its fail-closed user projection", async () => {
|
||||
const cookie = (extra: Record<string, unknown> = {}, kid = "k1") => `${SESSION_COOKIE}=${mint(k1.privateKey, kid, { ...valid, ...extra })}`;
|
||||
const identity = { email: "a@b.c", id: "u1", roles: ["admin"] };
|
||||
const user = { email: "a@b.c", id: "u1", roles: ["admin"] };
|
||||
|
||||
// A valid token → the user, not expired.
|
||||
assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, identity });
|
||||
assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, user });
|
||||
// Present but past exp → the re-mint trigger (expired flagged, no user).
|
||||
assert.deepEqual(await resolveSession(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), { expired: true, identity: null });
|
||||
assert.deepEqual(await resolveSession(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), { expired: true, user: null });
|
||||
// No cookie / non-ours / garbage / bad-signature are NOT re-mint candidates (no Ory round-trip).
|
||||
assert.deepEqual(await resolveSession(undefined, jwks, { now: NOW }), { expired: false, identity: null });
|
||||
assert.deepEqual(await resolveSession("other=1", jwks, { now: NOW }), { expired: false, identity: null });
|
||||
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=not.a.jwt`, jwks, { now: NOW }), { expired: false, identity: null });
|
||||
assert.deepEqual(await resolveSession(cookie({}, "nope"), jwks, { now: NOW }), { expired: false, identity: null });
|
||||
assert.deepEqual(await resolveSession(undefined, jwks, { now: NOW }), { expired: false, user: null });
|
||||
assert.deepEqual(await resolveSession("other=1", jwks, { now: NOW }), { expired: false, user: null });
|
||||
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=not.a.jwt`, jwks, { now: NOW }), { expired: false, user: null });
|
||||
assert.deepEqual(await resolveSession(cookie({}, "nope"), jwks, { now: NOW }), { expired: false, user: null });
|
||||
|
||||
// authenticate() is the convenience wrapper — resolveSession(...).user, dropping the flag.
|
||||
assert.deepEqual(await authenticate(cookie(), jwks, { now: NOW }), identity);
|
||||
assert.deepEqual(await authenticate(cookie(), jwks, { now: NOW }), user);
|
||||
assert.equal(await authenticate(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), null); // expired ⇒ null
|
||||
assert.equal(await authenticate(undefined, jwks, { now: NOW }), null);
|
||||
});
|
||||
@@ -94,7 +92,7 @@ test("verifyToken honours an optional denylist: a revoked subject's token reject
|
||||
|
||||
// Revoked: thrown as *expired* so resolveSession flags it for the re-mint (re-read Keto / clear).
|
||||
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 }), jwks, { denylist, now: NOW }), /revoked/);
|
||||
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 })}`, jwks, { denylist, now: NOW }), { expired: true, identity: null });
|
||||
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 })}`, jwks, { denylist, now: NOW }), { expired: true, user: null });
|
||||
// A token minted after the revoke (fresh login) is accepted; a different subject is untouched.
|
||||
assert.deepEqual(await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW + 5 }), jwks, { denylist, now: NOW }), { email: "a@b.c", id: "u1", roles: ["admin"] });
|
||||
await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5, sub: "u2" }), jwks, { denylist, now: NOW });
|
||||
|
||||
+11
-11
@@ -2,8 +2,8 @@
|
||||
// the hot path that never calls Ory. Select the verify key by `kid` from the cached JWKS,
|
||||
// check the signature (src/auth/jwt.ts), validate the time/issuer/audience claims, project the
|
||||
// User onto the request context. `authenticate` fails closed: any bad/expired token ⇒ null
|
||||
// (anonymous), so the route renders signed-out and the role gate denies.
|
||||
import type { SessionIdentity } from "../http/context.ts";
|
||||
// (anonymous), so the route renders signed-out and the permission gate denies.
|
||||
import type { User } from "../http/context.ts";
|
||||
import { parseCookies } from "../http/cookie.ts";
|
||||
import type { Denylist } from "./denylist.ts";
|
||||
import { decodeJws, verifyJws } from "./jwt.ts";
|
||||
@@ -61,7 +61,7 @@ export function validateClaims(payload: Record<string, unknown>, options: Verify
|
||||
// Map verified claims → the request User. sub/email are required and non-empty (the tokenizer
|
||||
// always sets them; an empty email would read as anonymous in the shell); roles defaults to [] and
|
||||
// keeps only string entries (defensive).
|
||||
export function claimsToIdentity(payload: Record<string, unknown>): SessionIdentity {
|
||||
export function claimsToUser(payload: Record<string, unknown>): User {
|
||||
const sub = payload["sub"];
|
||||
if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub");
|
||||
const email = payload["email"];
|
||||
@@ -72,13 +72,13 @@ export function claimsToIdentity(payload: Record<string, unknown>): SessionIdent
|
||||
|
||||
// Verify a session JWT end-to-end: select the key by `kid`, check the signature, validate
|
||||
// claims, project the User. Throws TokenError / the underlying verify error on any failure.
|
||||
export async function verifyToken(token: string, jwks: JwksProvider, options: VerifyOptions = {}): Promise<SessionIdentity> {
|
||||
export async function verifyToken(token: string, jwks: JwksProvider, options: VerifyOptions = {}): Promise<User> {
|
||||
const { header } = decodeJws(token); // unverified — only to read `kid` for key selection
|
||||
const jwk = await jwks.getKey(header.kid);
|
||||
if (!jwk) throw new TokenError(`no JWKS key for kid ${header.kid ?? "(none)"}`);
|
||||
const verified = verifyJws(token, jwk); // throws on a bad signature / disallowed alg
|
||||
validateClaims(verified.payload, options);
|
||||
const user = claimsToIdentity(verified.payload);
|
||||
const user = claimsToUser(verified.payload);
|
||||
// Instant revoke: a denylisted subject's pre-revoke token is rejected as *expired* so
|
||||
// resolveSession routes it through the re-mint (fresh roles from Keto, or a cleared session).
|
||||
if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true);
|
||||
@@ -87,7 +87,7 @@ export async function verifyToken(token: string, jwks: JwksProvider, options: Ve
|
||||
|
||||
export interface SessionAuth {
|
||||
expired: boolean; // a token was present but rejected as *expired* → a re-mint candidate
|
||||
identity: SessionIdentity | null;
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
// The request middleware: read our session cookie, verify it → the User (fail-closed: any
|
||||
@@ -96,15 +96,15 @@ export interface SessionAuth {
|
||||
// expired session, never for anonymous or garbage requests.
|
||||
export async function resolveSession(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise<SessionAuth> {
|
||||
const token = parseCookies(cookieHeader)[SESSION_COOKIE];
|
||||
if (!token) return { expired: false, identity: null };
|
||||
if (!token) return { expired: false, user: null };
|
||||
try {
|
||||
return { expired: false, identity: await verifyToken(token, jwks, options) };
|
||||
return { expired: false, user: await verifyToken(token, jwks, options) };
|
||||
} catch (err) {
|
||||
return { expired: err instanceof TokenError && err.expired, identity: null };
|
||||
return { expired: err instanceof TokenError && err.expired, user: null };
|
||||
}
|
||||
}
|
||||
|
||||
// Convenience for callers that don't re-mint: just the User, or null.
|
||||
export async function authenticate(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise<SessionIdentity | null> {
|
||||
return (await resolveSession(cookieHeader, jwks, options)).identity;
|
||||
export async function authenticate(cookieHeader: string | undefined, jwks: JwksProvider, options: VerifyOptions = {}): Promise<User | null> {
|
||||
return (await resolveSession(cookieHeader, jwks, options)).user;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { createKetoClient, KetoError } from "./keto-client.ts";
|
||||
|
||||
const READ = "http://keto:4466";
|
||||
const WRITE = "http://keto:4467";
|
||||
const USER = "identity:01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
|
||||
const USER = "user:01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
|
||||
|
||||
function res(status: number, body?: unknown): Response {
|
||||
const h = new Headers();
|
||||
@@ -35,7 +35,7 @@ test("check GETs the read API and returns the allowed boolean (true and false)",
|
||||
assert.match(allow.calls[0]!.url, new RegExp(`subject_id=${encodeURIComponent(USER).replace(/[.]/g, "\\.")}`));
|
||||
// A denied check is 403 {allowed:false} (not a 200) — both statuses carry the verdict.
|
||||
const deny = recorder(() => res(403, { allowed: false }));
|
||||
assert.equal(await keto(deny.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: "identity:nobody" }), false);
|
||||
assert.equal(await keto(deny.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: "user:nobody" }), false);
|
||||
});
|
||||
|
||||
test("check on a subject_set builds subject_set.* params and forwards max-depth", async () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import type { KratosPublic, Session } from "./kratos-public.ts";
|
||||
import { completeLogin, readRoles, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts";
|
||||
|
||||
const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
|
||||
const roleTuple = (object: string): RelationTuple => ({ namespace: "Role", object, relation: "members", subject_id: `identity:${ID}` });
|
||||
const roleTuple = (object: string): RelationTuple => ({ namespace: "Role", object, relation: "members", subject_id: `user:${ID}` });
|
||||
|
||||
const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
|
||||
check: async () => false,
|
||||
@@ -49,11 +49,11 @@ test("readRoles returns roles held directly OR transitively (enumerate defined r
|
||||
// subjects vary (a direct user, a group) and a name repeats across pages → de-duped.
|
||||
listRelations: async (q) => {
|
||||
listQ.push(q);
|
||||
if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [role("editor", { subject_id: "identity:other" })] };
|
||||
if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [role("editor", { subject_id: "user:other" })] };
|
||||
return { nextPageToken: "p2", tuples: [
|
||||
role("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
|
||||
role("admin", { subject_id: `identity:${ID}` }),
|
||||
role("viewer", { subject_id: "identity:stranger" }),
|
||||
role("admin", { subject_id: `user:${ID}` }),
|
||||
role("viewer", { subject_id: "user:stranger" }),
|
||||
] };
|
||||
},
|
||||
// Keto resolves transitively: the user holds editor (via a group) + admin (direct), not viewer.
|
||||
@@ -105,12 +105,12 @@ test("remintSession: a live Kratos session → fresh cookie + refreshed user; a
|
||||
|
||||
// TTL lapsed but the Kratos session lives → re-read roles from Keto, re-tokenize, fresh cookie.
|
||||
const live = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic }, "plainpages_session=s");
|
||||
assert.deepEqual(live.identity, { email: "admin@plainpages.local", id: ID, roles: ["admin"] });
|
||||
assert.deepEqual(live.user, { email: "admin@plainpages.local", id: ID, roles: ["admin"] });
|
||||
assert.match(live.setCookie, /^plainpages_jwt=h\.p\.s;.*Max-Age=2592000.*HttpOnly/);
|
||||
|
||||
// Kratos session also gone → clear the stale JWT so the next request falls through to anonymous.
|
||||
const dead = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic: publicStub() }, undefined);
|
||||
assert.equal(dead.identity, null);
|
||||
assert.equal(dead.user, null);
|
||||
assert.match(dead.setCookie, /^plainpages_jwt=;.*Max-Age=0/);
|
||||
});
|
||||
|
||||
|
||||
+6
-6
@@ -6,7 +6,7 @@
|
||||
// 4. whoami(tokenize_as) → the signed JWT { sub, email, roles }, stored as our cookie
|
||||
// Order matters: the projection is written before tokenizing, because the claims mapper
|
||||
// reads only the identity, never Keto.
|
||||
import type { SessionIdentity } from "../http/context.ts";
|
||||
import type { User } from "../http/context.ts";
|
||||
import { serializeCookie, type CookieOptions } from "../http/cookie.ts";
|
||||
import { currentLog } from "../logger.ts";
|
||||
import type { KetoClient } from "./keto-client.ts";
|
||||
@@ -37,13 +37,13 @@ export interface CompletedLogin {
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
// The coarse roles a user holds — directly (`Role:<name>#members@identity:<id>`) or transitively via a
|
||||
// The coarse roles a user holds — directly (`Role:<name>#members@user:<id>`) or transitively via a
|
||||
// group that is a member of the role. Enumerates the defined roles (the distinct objects in the Role
|
||||
// namespace) and asks Keto to resolve each membership, so a role granted to a group reaches the JWT —
|
||||
// matching the OPL model and the admin "Effective access" view. At login/refresh only, never per
|
||||
// request; role count is small, so the per-role checks are cheap and run in parallel.
|
||||
export async function readRoles(keto: KetoClient, identityId: string): Promise<string[]> {
|
||||
const subject_id = `identity:${identityId}`;
|
||||
const subject_id = `user:${identityId}`;
|
||||
const names = new Set<string>();
|
||||
let pageToken: string | undefined;
|
||||
do {
|
||||
@@ -76,7 +76,7 @@ export async function completeLogin(deps: LoginDeps, cookie: string | undefined)
|
||||
|
||||
export interface Reminted {
|
||||
setCookie: string; // a fresh JWT cookie on success, else a cookie that clears the stale one
|
||||
identity: SessionIdentity | null;
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
// Re-mint the session JWT on TTL expiry — "stay signed in" (README): the ~10m token lapsed but
|
||||
@@ -86,8 +86,8 @@ 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);
|
||||
if (!completed) return { setCookie: clearSessionCookie(options), identity: null };
|
||||
return { setCookie: sessionCookie(completed.jwt, options), identity: { email: completed.email ?? "", id: completed.identityId, roles: completed.roles } };
|
||||
if (!completed) return { setCookie: clearSessionCookie(options), user: null };
|
||||
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.identityId, roles: completed.roles } };
|
||||
}
|
||||
|
||||
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
|
||||
|
||||
+3
-3
@@ -44,7 +44,7 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
|
||||
const pathname = ctx.url.pathname;
|
||||
// Already signed in? Re-authenticating / re-registering is pointless — send them to the app
|
||||
// dashboard. (/settings, /recovery, /verification stay reachable — a signed-in user can use those.)
|
||||
if (ctx.identity && (flowType === "login" || flowType === "registration")) return { redirect: "/dashboard" };
|
||||
if (ctx.user && (flowType === "login" || flowType === "registration")) return { redirect: "/dashboard" };
|
||||
const cookie = ctx.req.headers.cookie;
|
||||
const flowId = ctx.url.searchParams.get("flow");
|
||||
// Only the Kratos calls are in the try, so a render/buildFlowView bug below falls through to
|
||||
@@ -75,7 +75,7 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
|
||||
// Expired/unknown flow → restart by re-initialising (drop the stale ?flow=).
|
||||
if (err instanceof KratosError && [403, 404, 410].includes(err.status)) return { redirect: pathname };
|
||||
// Already authenticated at Kratos but no app JWT yet (e.g. straight after registration, whose
|
||||
// `session` hook signs the user in but routes to verification, not /auth/complete — so ctx.identity
|
||||
// `session` hook signs the user in but routes to verification, not /auth/complete — so ctx.user
|
||||
// is null and the "already signed in" short-circuit above can't fire). Initialising a login/
|
||||
// registration flow then returns Kratos 400 `session_already_available`. Recover by completing
|
||||
// login (mint the JWT from the live session), honouring return_to — never a 500.
|
||||
@@ -218,7 +218,7 @@ function logout(kratos: KratosPublic, secureCookies: boolean): BuiltinRoute["han
|
||||
}
|
||||
const flow = await kratos.createLogoutFlow(ctx.req.headers.cookie ? { cookie: ctx.req.headers.cookie } : {});
|
||||
ctx.res.appendHeader("set-cookie", clearSessionCookie({ secure: secureCookies }));
|
||||
ctx.log.info("logout", { sub: ctx.identity?.id ?? "" });
|
||||
ctx.log.info("logout", { sub: ctx.user?.id ?? "" });
|
||||
return { redirect: flow?.logoutUrl ?? "/login" };
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
// Guards the docs-only fast path: `ci.sh` no-ops when nothing but *.md changed since main. The
|
||||
// decision lives in ci.sh alone so `bash ci.sh` reproduces CI locally, and the workflow must still
|
||||
// push the commit-hash image when it no-ops — release.yml re-tags that exact image, and
|
||||
// fast-forward-only merges make every branch head a main commit.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8");
|
||||
const workflow = read(".gitea/workflows/ci.yml");
|
||||
const gate = read("ci.sh");
|
||||
const step = (needle: string) => {
|
||||
const found = workflow.split("\n - ").slice(1).filter((s) => s.includes(needle));
|
||||
assert.equal(found.length, 1, `exactly one workflow step contains ${needle}`);
|
||||
return found[0]!;
|
||||
};
|
||||
|
||||
test("the skip decision lives in ci.sh, so the workflow only runs it", () => {
|
||||
assert.match(gate, /docs_only\(\)/);
|
||||
assert.doesNotMatch(workflow, /docs_only|merge-base|GITHUB_OUTPUT/);
|
||||
});
|
||||
|
||||
test("checkout is unshallow — the docs-only check needs the branch's history", () => {
|
||||
assert.match(step("actions/checkout"), /fetch-depth: 0/);
|
||||
});
|
||||
|
||||
test("the commit-hash image is pushed even when the gate no-ops", () => {
|
||||
assert.doesNotMatch(step("docker push"), /^\s*if:/m);
|
||||
});
|
||||
|
||||
test("only *.md counts as docs, and a dirty working tree counts as changed", () => {
|
||||
assert.ok(gate.includes("\\.md$"), "the non-docs match is a *.md suffix test");
|
||||
assert.match(gate, /git status --porcelain/, "uncommitted code can never be skipped over");
|
||||
});
|
||||
+34
-34
@@ -105,7 +105,7 @@ test("plugins replace either landing: `home` owns the public /, `dashboard` owns
|
||||
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||
const portal: Plugin = {
|
||||
apiVersion: "1.0.0",
|
||||
dashboard: (ctx) => ({ data: { chrome: ctx.chrome, user: ctx.identity }, view: "board" }),
|
||||
dashboard: (ctx) => ({ data: { chrome: ctx.chrome, user: ctx.user }, view: "board" }),
|
||||
home: () => ({ data: { brand: "Acme" }, view: "welcome" }),
|
||||
id: "portal",
|
||||
};
|
||||
@@ -125,7 +125,7 @@ test("plugins replace either landing: `home` owns the public /, `dashboard` owns
|
||||
assert.equal(board.status, 200);
|
||||
const html = await board.text();
|
||||
assert.match(html, /<h1 class="page-title">My Portal<\/h1>/); // its own title in the native shell
|
||||
assert.match(html, /Hi a@b\.c/); // its handler rendered, with ctx.identity
|
||||
assert.match(html, /Hi a@b\.c/); // its handler rendered, with ctx.user
|
||||
assert.doesNotMatch(html, /Avery Kline/); // the built-in mock People list is gone — fully replaced
|
||||
});
|
||||
|
||||
@@ -385,7 +385,7 @@ test("renders the 500 HTML page when a handler throws", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
// A test plugin exercising each RouteResult shape, a path param, and the role gate.
|
||||
// A test plugin exercising each RouteResult shape, a path param, and the permission gate.
|
||||
const demoPlugin: Plugin = {
|
||||
apiVersion: "1.0.0",
|
||||
id: "demo",
|
||||
@@ -393,7 +393,7 @@ const demoPlugin: Plugin = {
|
||||
{ handler: (ctx) => ({ html: `<p>Hi ${ctx.params.name}</p>` }), method: "GET", path: "/hello/:name" },
|
||||
{ handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" },
|
||||
{ handler: () => ({ redirect: "/demo/hello/world" }), method: "POST", path: "/go" },
|
||||
{ handler: () => ({ html: "secret" }), method: "GET", path: "/secret", role: "demo:read" },
|
||||
{ handler: () => ({ html: "secret" }), method: "GET", path: "/secret", permission: "demo:read" },
|
||||
{ handler: () => ({ html: "open to all" }), method: "GET", path: "/public-page", public: true }, // blessed public
|
||||
{ handler: () => ({ data: { who: "Plainpages" }, view: "page" }), method: "GET", path: "/page" },
|
||||
],
|
||||
@@ -406,7 +406,7 @@ async function startApp(t: TestContext, plugins: Plugin[], pluginsDir?: string):
|
||||
return `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
}
|
||||
|
||||
test("mounts plugin routes: params, html/json/redirect/view results, and the role gate", async (t) => {
|
||||
test("mounts plugin routes: params, html/json/redirect/view results, and the permission gate", async (t) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
|
||||
mkdirSync(join(dir, "demo", "views"), { recursive: true });
|
||||
mkdirSync(join(dir, "demo", "public"), { recursive: true });
|
||||
@@ -516,7 +516,7 @@ test("a plugin view renders the native chrome; its forms are CSRF-guarded via ct
|
||||
assert.equal(ok.status, 303);
|
||||
});
|
||||
|
||||
// JWT middleware: a verified session cookie populates ctx.identity/roles, which the gate reads.
|
||||
// JWT middleware: a verified session cookie populates ctx.user/roles, which the gate reads.
|
||||
// The key + mintJwt + session() helper are hoisted above the shared `server` (top of file).
|
||||
test("a verified session JWT authorizes a role-gated route; no cookie / expired token → sign in", async (t) => {
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
|
||||
@@ -569,7 +569,7 @@ test("session re-mint: an expired JWT backed by a live Kratos session is silentl
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["demo:read"], sub: "u1" });
|
||||
const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session);
|
||||
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "demo:read", relation: "members", subject_id: "identity:u1" }] }) });
|
||||
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "demo:read", relation: "members", subject_id: "user:u1" }] }) });
|
||||
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}; plainpages_session=s`;
|
||||
|
||||
// Live Kratos session: the lapsed token is re-minted — the gated route runs AND a fresh cookie rides the response.
|
||||
@@ -610,7 +610,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
|
||||
{ handler: (ctx) => ({ html: `hi ${requireSession(ctx).email}` }), method: "GET", path: "/me" },
|
||||
{ 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: () => ({ html: "gated" }), method: "GET", path: "/gated", role: "secret:read" }, // declarative route gate
|
||||
{ handler: () => ({ html: "gated" }), method: "GET", path: "/gated", permission: "secret:read" }, // declarative route gate
|
||||
],
|
||||
};
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [guarded] });
|
||||
@@ -636,7 +636,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
|
||||
assert.equal((await fetch(url + "/guarded/doc/open", auth([]))).status, 200);
|
||||
assert.equal((await fetch(url + "/guarded/doc/shut", auth([]))).status, 403);
|
||||
|
||||
// declarative route `role` gate: anonymous → sign in, signed-in-without-role → the 403 page, with → 200.
|
||||
// declarative route `permission` gate: anonymous → sign in, signed-in-without-role → the 403 page, with → 200.
|
||||
const gAnon = await fetch(url + "/guarded/gated", { redirect: "manual" });
|
||||
assert.equal(gAnon.status, 303);
|
||||
assert.equal(gAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fgated");
|
||||
@@ -727,7 +727,7 @@ test("themed auth GET: anonymous inits a flow (CSRF relay, stale→restart); a s
|
||||
});
|
||||
|
||||
test("themed auth GET: an existing Kratos session (no app JWT yet) recovers via /auth/complete, never 500", async (t) => {
|
||||
// After registration's `session` hook the user holds a Kratos session but no app JWT — so ctx.identity
|
||||
// After registration's `session` hook the user holds a Kratos session but no app JWT — so ctx.user
|
||||
// is null and the "already signed in" short-circuit can't fire. Initialising a login/registration
|
||||
// flow then returns Kratos 400 `session_already_available`; recover by completing login (mint the
|
||||
// JWT from the live session), preserving return_to — never fall through to the catch-all 500.
|
||||
@@ -884,7 +884,7 @@ test("login completion (/auth/complete): a live session mints the JWT cookie; no
|
||||
let projected: unknown;
|
||||
const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session);
|
||||
const kratosAdmin = stubAdmin({ updateMetadataPublic: async (_id, meta) => { projected = meta; return identity; } });
|
||||
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "admin", relation: "members", subject_id: `identity:${identity.id}` }] }) });
|
||||
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Role", object: "admin", relation: "members", subject_id: `user:${identity.id}` }] }) });
|
||||
const complete = async (app: ReturnType<typeof createApp>, cookie?: string, returnTo?: string) => {
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
@@ -1178,7 +1178,7 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
|
||||
{ id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } },
|
||||
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } },
|
||||
];
|
||||
const tuples: RelationTuple[] = [{ namespace: "Group", object: "eng", relation: "members", subject_id: `identity:${ada}` }];
|
||||
const tuples: RelationTuple[] = [{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }];
|
||||
const keto = fakeKeto(tuples);
|
||||
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const { get, post, token, url } = await adminHarness(t, { keto, kratosAdmin });
|
||||
@@ -1192,26 +1192,26 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
|
||||
|
||||
// Create: the form renders; a valid post writes the first-member tuple and redirects to the detail.
|
||||
assert.match(await (await get("/admin/groups/new")).text(), /Create group/);
|
||||
const created = await post("/admin/groups", `_csrf=${token}&name=design&member=identity:${grace}`);
|
||||
const created = await post("/admin/groups", `_csrf=${token}&name=design&member=user:${grace}`);
|
||||
assert.equal(created.status, 303);
|
||||
assert.equal(created.headers.get("location"), "/admin/groups/design");
|
||||
assert.ok(tuples.some((tp) => tp.object === "design" && tp.subject_id === `identity:${grace}`));
|
||||
assert.ok(tuples.some((tp) => tp.object === "design" && tp.subject_id === `user:${grace}`));
|
||||
|
||||
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
|
||||
const before = tuples.length;
|
||||
assert.equal((await post("/admin/groups", `_csrf=${token}&name=Bad Name&member=identity:${grace}`)).status, 400);
|
||||
assert.equal((await post("/admin/groups", `_csrf=${token}&name=eng&member=identity:${grace}`)).status, 400); // already exists
|
||||
assert.equal((await post("/admin/groups", `name=x&member=identity:${grace}`)).status, 403);
|
||||
assert.equal((await post("/admin/groups", `_csrf=${token}&name=Bad Name&member=user:${grace}`)).status, 400);
|
||||
assert.equal((await post("/admin/groups", `_csrf=${token}&name=eng&member=user:${grace}`)).status, 400); // already exists
|
||||
assert.equal((await post("/admin/groups", `name=x&member=user:${grace}`)).status, 403);
|
||||
assert.equal(tuples.length, before);
|
||||
|
||||
// Detail: lists the current member by email.
|
||||
assert.match(await (await get("/admin/groups/eng")).text(), /ada@example\.com/);
|
||||
|
||||
// Add a member, then remove it.
|
||||
await post("/admin/groups/eng/members", `_csrf=${token}&member=identity:${grace}`);
|
||||
assert.ok(tuples.some((tp) => tp.object === "eng" && tp.subject_id === `identity:${grace}`));
|
||||
await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=identity:${grace}`);
|
||||
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `identity:${grace}`));
|
||||
await post("/admin/groups/eng/members", `_csrf=${token}&member=user:${grace}`);
|
||||
assert.ok(tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
||||
await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`);
|
||||
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
||||
|
||||
// Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
||||
assert.match(await (await get("/admin/groups/eng/delete")).text(), /Cancel/);
|
||||
@@ -1225,7 +1225,7 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
|
||||
assert.equal((await get("/admin/groups/%ZZ")).status, 404);
|
||||
});
|
||||
|
||||
// Built-in Roles admin screen: gate + list/create/assign/revoke/delete over HTTP
|
||||
// Built-in Roles & permissions admin screen: gate + list/create/assign/revoke/delete over HTTP
|
||||
// against a fake in-memory Keto whose `expand` mirrors Keto's transitive resolution, so the
|
||||
// effective-access view surfaces a user reachable only through a group.
|
||||
test("admin Roles screen: gate, list, create, assign user/group, effective access (expand), revoke, delete", async (t) => {
|
||||
@@ -1237,8 +1237,8 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
|
||||
];
|
||||
// grace is in the `eng` group; `editor` is an existing role whose only direct member is ada.
|
||||
const tuples: RelationTuple[] = [
|
||||
{ namespace: "Group", object: "eng", relation: "members", subject_id: `identity:${grace}` },
|
||||
{ namespace: "Role", object: "editor", relation: "members", subject_id: `identity:${ada}` },
|
||||
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${grace}` },
|
||||
{ namespace: "Role", object: "editor", relation: "members", subject_id: `user:${ada}` },
|
||||
];
|
||||
// Mirror Keto's expand shape: the subject rides on `tuple`, set nodes carry members as children.
|
||||
const expandSet = (set: SubjectSet): ExpandTree => ({
|
||||
@@ -1262,17 +1262,17 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
|
||||
|
||||
// Create: a valid post writes the first-member tuple and redirects to the detail.
|
||||
assert.match(await (await get("/admin/roles/new")).text(), /Create role/);
|
||||
const created = await post("/admin/roles", `_csrf=${token}&name=viewer&member=identity:${ada}`);
|
||||
const created = await post("/admin/roles", `_csrf=${token}&name=viewer&member=user:${ada}`);
|
||||
assert.equal(created.status, 303);
|
||||
assert.equal(created.headers.get("location"), "/admin/roles/viewer");
|
||||
assert.ok(tuples.some((tp) => tp.namespace === "Role" && tp.object === "viewer" && tp.subject_id === `identity:${ada}`));
|
||||
assert.ok(tuples.some((tp) => tp.namespace === "Role" && tp.object === "viewer" && tp.subject_id === `user:${ada}`));
|
||||
assert.equal(denylist.isRevoked(ada, 0), true); // assigning a role to a user revokes their stale token so the grant lands now
|
||||
|
||||
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
|
||||
const before = tuples.length;
|
||||
assert.equal((await post("/admin/roles", `_csrf=${token}&name=Bad Name&member=identity:${ada}`)).status, 400);
|
||||
assert.equal((await post("/admin/roles", `_csrf=${token}&name=editor&member=identity:${ada}`)).status, 400); // already exists
|
||||
assert.equal((await post("/admin/roles", `name=x&member=identity:${ada}`)).status, 403);
|
||||
assert.equal((await post("/admin/roles", `_csrf=${token}&name=Bad Name&member=user:${ada}`)).status, 400);
|
||||
assert.equal((await post("/admin/roles", `_csrf=${token}&name=editor&member=user:${ada}`)).status, 400); // already exists
|
||||
assert.equal((await post("/admin/roles", `name=x&member=user:${ada}`)).status, 403);
|
||||
assert.equal(tuples.length, before);
|
||||
|
||||
// Detail: ada (direct) is in the effective-access list; grace (only reachable via a group) is not
|
||||
@@ -1293,8 +1293,8 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
|
||||
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
|
||||
|
||||
// Unassigning a *user* membership likewise revokes that user's live token, so the loss of access is immediate.
|
||||
await post("/admin/roles/editor/members", `_csrf=${token}&member=identity:${grace}`);
|
||||
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=identity:${grace}`);
|
||||
await post("/admin/roles/editor/members", `_csrf=${token}&member=user:${grace}`);
|
||||
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=user:${grace}`);
|
||||
assert.equal(denylist.isRevoked(grace, 0), true);
|
||||
|
||||
// Delete the role: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
||||
@@ -1305,11 +1305,11 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
|
||||
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor"));
|
||||
|
||||
// Self-protection: the admin role can't be deleted, nor can you revoke your own admin (sub admin1).
|
||||
tuples.push({ namespace: "Role", object: "admin", relation: "members", subject_id: "identity:admin1" });
|
||||
tuples.push({ namespace: "Role", object: "admin", relation: "members", subject_id: "user:admin1" });
|
||||
assert.equal((await post("/admin/roles/admin/delete", `_csrf=${token}`)).status, 400);
|
||||
assert.ok(tuples.some((tp) => tp.object === "admin"));
|
||||
assert.equal((await post("/admin/roles/admin/members/delete", `_csrf=${token}&member=identity:admin1`)).status, 400);
|
||||
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "identity:admin1"));
|
||||
assert.equal((await post("/admin/roles/admin/members/delete", `_csrf=${token}&member=user:admin1`)).status, 400);
|
||||
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "user:admin1"));
|
||||
|
||||
// An invalid role name in the path → 404; malformed %-encoding doesn't 500.
|
||||
assert.equal((await get("/admin/roles/Bad%20Name")).status, 404);
|
||||
|
||||
+16
-16
@@ -2,10 +2,10 @@ import { randomBytes, randomUUID } from "node:crypto";
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import * as ejs from "ejs";
|
||||
import { type BuiltinRoute, matchBuiltinRoute, type RequestCsrf } from "./builtin-routes.ts";
|
||||
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
|
||||
import { buildContext, type RequestContext, type SessionIdentity } from "./context.ts";
|
||||
import { buildContext, type RequestContext, type User } from "./context.ts";
|
||||
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
|
||||
import type { Denylist } from "../auth/denylist.ts";
|
||||
import { buildDashboardModel } from "../ui/dashboard.ts";
|
||||
@@ -40,7 +40,7 @@ export interface AppOptions {
|
||||
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
|
||||
denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes
|
||||
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
|
||||
jwks?: JwksProvider; // verify the session JWT → ctx.identity/roles; absent ⇒ always anonymous
|
||||
jwks?: JwksProvider; // verify the session JWT → ctx.user/roles; absent ⇒ always anonymous
|
||||
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
|
||||
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
|
||||
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion
|
||||
@@ -124,7 +124,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
await sendResult(ctx.res, result, (view, data) => renderView(homePlugin.id, view, data));
|
||||
return null;
|
||||
}
|
||||
return { data: { chrome: ctx.chrome, user: ctx.identity }, view: "home" };
|
||||
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
|
||||
};
|
||||
|
||||
// The post-login app home "/dashboard", gated to a signed-in user: anonymous bounces to sign
|
||||
@@ -132,7 +132,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// handler renders against its own views, same path as a plugin route. Else the built-in
|
||||
// mock-data People list with the one global menu (ctx.chrome.nav) + branding from config/menu.ts.
|
||||
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult | null> => {
|
||||
if (!ctx.identity) return { redirect: loginRedirect(ctx), status: 303 };
|
||||
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) {
|
||||
@@ -141,7 +141,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
await sendResult(ctx.res, result, (view, data) => renderView(dashboardPlugin.id, view, data));
|
||||
return null;
|
||||
}
|
||||
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, identity: ctx.identity, nav: ctx.chrome.nav }) }, view: "index" };
|
||||
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, nav: ctx.chrome.nav, user: ctx.user }) }, view: "index" };
|
||||
};
|
||||
|
||||
// The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/
|
||||
@@ -186,19 +186,19 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the session JWT once (cached JWKS) → ctx.identity/roles; none/invalid ⇒ anonymous.
|
||||
// Verify the session JWT once (cached JWKS) → ctx.user/roles; none/invalid ⇒ anonymous.
|
||||
// If the token has lapsed but a live Kratos session still backs it (and we have the Ory
|
||||
// clients), silently re-mint it — "stay signed in": re-read roles from Keto, re-tokenize,
|
||||
// and set the fresh cookie via setHeader so it rides whatever response this request produces
|
||||
// (a dead session clears the stale cookie). This is the only place the hot path touches Ory.
|
||||
let user: SessionIdentity | null = null;
|
||||
let user: User | null = null;
|
||||
if (jwks) {
|
||||
const auth = await resolveSession(req.headers.cookie, jwks, authOptions);
|
||||
user = auth.identity;
|
||||
user = auth.user;
|
||||
if (!user && auth.expired && keto && kratos && kratosAdmin) {
|
||||
try {
|
||||
const reminted = await remintSession({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie, { secure: secureCookies });
|
||||
user = reminted.identity;
|
||||
user = reminted.user;
|
||||
res.appendHeader("set-cookie", reminted.setCookie);
|
||||
} catch (err) {
|
||||
// Ory unreachable (Kratos/Keto 5xx, refused, timeout) — degrade to anonymous instead of
|
||||
@@ -223,10 +223,10 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// ctx.chrome getter only triggers it when a handler actually reads it (a json/redirect handler,
|
||||
// or the public "/" with a standalone home, never composes the menu).
|
||||
let chromeMemo: PageChrome | undefined;
|
||||
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, identity: user }));
|
||||
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }));
|
||||
|
||||
// base context (no route params yet); reused for onRequest hooks and the landing routes.
|
||||
const ctx = buildContext(req, res, { chrome, identity: user, log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
||||
const ctx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf, ...(system ? { system } : {}) });
|
||||
|
||||
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
||||
if (anyRequestHooks) {
|
||||
@@ -240,17 +240,17 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin routes (any method): gate on the route's role, then run the handler. The
|
||||
// Plugin routes (any method): gate on the route's permission, then run the handler. The
|
||||
// handler gets ctx.chrome (native app shell) + ctx.verifyCsrf (guard its own forms); a fresh
|
||||
// CSRF cookie is set so those forms have a valid double-submit token.
|
||||
const match = matchRoute(plugins, method, pathname);
|
||||
if (match) {
|
||||
const routeCtx = buildContext(req, res, { chrome, identity: user, log: reqLog, params: match.params, verifyCsrf, ...(system ? { system } : {}) });
|
||||
const routeCtx = buildContext(req, res, { chrome, log: reqLog, params: match.params, user, verifyCsrf, ...(system ? { system } : {}) });
|
||||
if (!isAuthorized(match.route, routeCtx.roles)) {
|
||||
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
|
||||
// return_to; a signed-in user who simply lacks the role gets the 403 page.
|
||||
if (!routeCtx.identity) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
|
||||
reqLog.warn("forbidden: missing role", { path: pathname, required: match.route.role ?? "", sub: routeCtx.identity.id });
|
||||
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
|
||||
reqLog.warn("forbidden: missing role", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
|
||||
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Socket } from "node:net";
|
||||
import { test } from "node:test";
|
||||
import { buildContext, type SessionIdentity } from "./context.ts";
|
||||
import { buildContext, type User } from "./context.ts";
|
||||
import { createLogger } from "../logger.ts";
|
||||
|
||||
// A req/res pair without a live server — enough to build and inspect a context.
|
||||
@@ -22,7 +22,7 @@ test("buildContext parses the URL, exposes query, and defaults to an anonymous u
|
||||
assert.equal(ctx.query, ctx.url.searchParams); // same instance, not a copy
|
||||
assert.equal(ctx.query.get("q"), "ann");
|
||||
assert.equal(ctx.query.get("page"), "2");
|
||||
assert.equal(ctx.identity, null);
|
||||
assert.equal(ctx.user, null);
|
||||
assert.deepEqual(ctx.roles, []);
|
||||
assert.deepEqual(ctx.params, {});
|
||||
});
|
||||
@@ -35,9 +35,9 @@ test("buildContext threads path params supplied by the router", () => {
|
||||
|
||||
test("buildContext threads the user and derives roles from it", () => {
|
||||
const { req, res } = reqRes("/");
|
||||
const user: SessionIdentity = { email: "a@b.c", id: "u1", roles: ["admin", "editor"] };
|
||||
const ctx = buildContext(req, res, { identity: user });
|
||||
assert.equal(ctx.identity, user);
|
||||
const user: User = { email: "a@b.c", id: "u1", roles: ["admin", "editor"] };
|
||||
const ctx = buildContext(req, res, { user });
|
||||
assert.equal(ctx.user, user);
|
||||
assert.equal(ctx.roles, user.roles); // same reference, never a divergent copy — buildContext is the only writer
|
||||
});
|
||||
|
||||
|
||||
+9
-10
@@ -5,11 +5,11 @@ import { createLogger, type Log } from "../logger.ts";
|
||||
|
||||
// The request context threaded to every route handler (plugin + built-in), built once
|
||||
// per request by `buildContext`: the router supplies matched path `params`, the JWT
|
||||
// middleware supplies `identity` (null until then). The host's single handler argument.
|
||||
// middleware supplies `user` (null until then). The host's single handler argument.
|
||||
|
||||
// The authenticated Kratos identity, projected from verified session JWT claims:
|
||||
// The authenticated user, projected from verified session JWT claims:
|
||||
// `id` = `sub`, plus `email` and the coarse `roles` carried in the token.
|
||||
export interface SessionIdentity {
|
||||
export interface User {
|
||||
email: string;
|
||||
id: string;
|
||||
roles: string[];
|
||||
@@ -19,8 +19,6 @@ export interface RequestContext {
|
||||
// Page chrome (brand/global-nav/user/theme/csrf) a plugin view hands to partials/shell so its
|
||||
// page renders the native app shell; the host builds it per request (anonymous default otherwise).
|
||||
chrome: PageChrome;
|
||||
// The signed-in Kratos identity, or null when anonymous.
|
||||
identity: SessionIdentity | null;
|
||||
// Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to
|
||||
// log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by
|
||||
// requestId. Additive, stable per the contract; defaults to a silent logger off the request path.
|
||||
@@ -29,11 +27,12 @@ export interface RequestContext {
|
||||
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
roles: string[]; // identity?.roles ?? [] — coarse gate without a null-check
|
||||
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
|
||||
// Privileged host services (Ory admin clients + instant-revoke) for a system plugin. Undefined
|
||||
// unless the host wired them; every field optional. Ordinary domain plugins ignore it.
|
||||
system?: SystemCapabilities;
|
||||
url: URL;
|
||||
user: User | null;
|
||||
// Gate a first-party form submission: true iff `submitted` matches this request's signed CSRF
|
||||
// cookie (double-submit). The host binds the secret; a plugin calls it after reading its body.
|
||||
verifyCsrf(submitted: string | null | undefined): boolean;
|
||||
@@ -44,10 +43,10 @@ export interface BuildContextOptions {
|
||||
// ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing).
|
||||
// The host's factory is memoised, so the menu composes at most once per request across contexts.
|
||||
chrome?: () => PageChrome;
|
||||
identity?: SessionIdentity | null;
|
||||
log?: Log;
|
||||
params?: Record<string, string>;
|
||||
system?: SystemCapabilities;
|
||||
user?: User | null;
|
||||
verifyCsrf?: (submitted: string | null | undefined) => boolean;
|
||||
}
|
||||
|
||||
@@ -63,20 +62,20 @@ export function buildContext(
|
||||
options: BuildContextOptions = {},
|
||||
): RequestContext {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const identity = options.identity ?? null;
|
||||
const user = options.user ?? null;
|
||||
const buildChrome = options.chrome;
|
||||
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
|
||||
return {
|
||||
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
|
||||
identity,
|
||||
log: options.log ?? SILENT_LOG,
|
||||
params: options.params ?? {},
|
||||
query: url.searchParams,
|
||||
req,
|
||||
res,
|
||||
roles: identity?.roles ?? [],
|
||||
roles: user?.roles ?? [],
|
||||
...(options.system ? { system: options.system } : {}),
|
||||
url,
|
||||
user,
|
||||
verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret
|
||||
};
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
// Guards the Ory Keto config: migrations run before the server (keto-migrate →
|
||||
// keto), the DSN targets the keto database, read/write APIs serve on the ports config.ts
|
||||
// points at, and the OPL declares the identity/role/group/resource namespaces. Version pinning is
|
||||
// points at, and the OPL declares the role/group/resource namespaces. Version pinning is
|
||||
// in compose.test.ts. Real boot is verified by running the stack; this catches edits.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
@@ -35,8 +35,8 @@ test("keto loads the OPL namespaces from the mounted file", () => {
|
||||
"namespaces come from the committed OPL");
|
||||
});
|
||||
|
||||
test("the OPL declares role, group and a resource namespace over identity subjects", () => {
|
||||
for (const ns of ["Identity", "Group", "Role", "Resource"])
|
||||
test("the OPL declares role, group and a resource namespace over user subjects", () => {
|
||||
for (const ns of ["User", "Group", "Role", "Resource"])
|
||||
assert.match(opl, new RegExp(`class ${ns} implements Namespace`), `defines ${ns}`);
|
||||
// role + group are subject sets read at login → JWT roles claim (README).
|
||||
assert.match(opl, /class Role implements Namespace\s*{\s*related:\s*{\s*members:/,
|
||||
|
||||
@@ -50,8 +50,8 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
|
||||
{ name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s },
|
||||
{ name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s },
|
||||
{ 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 role is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, role: "x", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*role/s },
|
||||
{ name: "a nav node marked public AND role is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, role: "x" }] };` }, match: /contranav.*public.*role/s },
|
||||
{ name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, permission: "x", 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: "1.0.0", nav: [{ id: "n", label: "N", public: true, permission: "x" }] };` }, match: /contranav.*public.*permission/s },
|
||||
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ },
|
||||
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ },
|
||||
];
|
||||
@@ -85,12 +85,12 @@ test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard)
|
||||
assert.equal(typeof plugins[0]?.dashboard, "function");
|
||||
});
|
||||
|
||||
test("a shared role name only warns — both plugins still load", async (t) => {
|
||||
const shared = `export default { apiVersion: "1.0.0", roles: [{ name: "shared:read" }] };`;
|
||||
const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared });
|
||||
test("a shared permission token only warns — both plugins still load", async (t) => {
|
||||
const perm = `export default { apiVersion: "1.0.0", permissions: [{ token: "shared:read" }] };`;
|
||||
const dir = scaffold(t, { "x/plugin.ts": perm, "y/plugin.ts": perm });
|
||||
const warnings: string[] = [];
|
||||
const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } });
|
||||
|
||||
assert.equal(plugins.length, 2);
|
||||
assert.ok(warnings.some((w) => /shared:read/.test(w)), "expected a role-conflict warning");
|
||||
assert.ok(warnings.some((w) => /shared:read/.test(w)), "expected a permission-conflict warning");
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// validate it, assemble the loaded Plugin[]. The imperative shell over plugin.ts's pure rules
|
||||
// (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin problem and
|
||||
// error-level conflict is collected into one boot-stopping Error; warn-level diagnostics
|
||||
// (older-minor apiVersion, shared role name) log and load continues. Folder name = id.
|
||||
// (older-minor apiVersion, shared permission token) log and load continues. Folder name = id.
|
||||
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
@@ -85,7 +85,7 @@ function asManifest(value: unknown): PluginManifest | null {
|
||||
|
||||
// The collection fields feed findConflicts, which iterates them — a non-array crashes it opaquely.
|
||||
function shapeError(manifest: PluginManifest): string | null {
|
||||
for (const field of ["nav", "roles", "routes"] as const) {
|
||||
for (const field of ["nav", "permissions", "routes"] as const) {
|
||||
if (manifest[field] !== undefined && !Array.isArray(manifest[field])) return `"${field}" must be an array`;
|
||||
}
|
||||
// `home` / `dashboard` (the landing-page overrides) are route handlers; the host calls them, so
|
||||
@@ -93,20 +93,20 @@ function shapeError(manifest: PluginManifest): string | null {
|
||||
for (const slot of ["home", "dashboard"] as const) {
|
||||
if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`;
|
||||
}
|
||||
// `public` and `role` are contradictory on the same route/nav node — "open to all" vs
|
||||
// `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
|
||||
// "needs this role". Refuse rather than silently pick one, so the author's intent is unambiguous.
|
||||
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
||||
if (route?.public === true && route.role != null) return `route "${route.method} ${route.path}" sets both public and role — they are mutually exclusive`;
|
||||
if (route?.public === true && route.permission != null) return `route "${route.method} ${route.path}" sets both public and permission — they are mutually exclusive`;
|
||||
}
|
||||
const navContradiction = findPublicNavContradiction(manifest.nav);
|
||||
if (navContradiction) return navContradiction;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Recurse the nav fragment: a node that is both `public` and `role`-gated is contradictory.
|
||||
// Recurse the nav fragment: a node that is both `public` and `permission`-gated is contradictory.
|
||||
function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null {
|
||||
for (const node of Array.isArray(nodes) ? nodes : []) {
|
||||
if (node?.public === true && node.role != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and role — they are mutually exclusive`;
|
||||
if (node?.public === true && node.permission != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and permission — they are mutually exclusive`;
|
||||
const inChild = findPublicNavContradiction(node?.children);
|
||||
if (inChild) return inChild;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
// a plugin should import from here, never reach into deeper modules. See README.md → Building plugins.
|
||||
|
||||
export { definePlugin } from "./plugin.ts";
|
||||
export type { HttpMethod, Plugin, PluginHooks, PluginManifest, RoleDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
|
||||
export type { RequestContext, SessionIdentity } from "../http/context.ts";
|
||||
export type { HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
|
||||
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";
|
||||
|
||||
@@ -21,13 +21,13 @@ const scheduling: PluginManifest = definePlugin({
|
||||
apiVersion: "1.0.0",
|
||||
hooks: { onBoot: () => {} },
|
||||
nav: [{
|
||||
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", role: "scheduling:read" }],
|
||||
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
|
||||
icon: "i-cal", id: "scheduling:root", label: "Scheduling",
|
||||
}],
|
||||
roles: [{ description: "View shifts", name: "scheduling:read" }],
|
||||
permissions: [{ description: "View shifts", token: "scheduling:read" }],
|
||||
routes: [
|
||||
{ handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", role: "scheduling:read" },
|
||||
{ handler: () => ({ redirect: "/scheduling/shifts" }), method: "POST", path: "/shifts", role: "scheduling:write" },
|
||||
{ handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", permission: "scheduling:read" },
|
||||
{ handler: () => ({ redirect: "/scheduling/shifts" }), method: "POST", path: "/shifts", permission: "scheduling:write" },
|
||||
{ handler: (ctx) => void ctx.res.end("raw"), method: "GET", path: "/raw" }, // void = handler wrote res itself
|
||||
],
|
||||
});
|
||||
@@ -87,19 +87,19 @@ test("findConflicts: a duplicate id and a colliding route are loud errors", () =
|
||||
assert.ok(dupRoute.some((c) => c.kind === "route" && c.level === "error" && c.message.includes("/a/t")));
|
||||
});
|
||||
|
||||
test("findConflicts: duplicate nav id is an error, a shared role name only warns", () => {
|
||||
test("findConflicts: duplicate nav id is an error, a shared permission token only warns", () => {
|
||||
const navDup = findConflicts([
|
||||
p({ id: "a", nav: [{ id: "dup", label: "A" }] }),
|
||||
p({ id: "b", nav: [{ id: "dup", label: "B" }] }),
|
||||
]);
|
||||
assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||
|
||||
// Sharing a role across plugins is legitimate → warn, not error.
|
||||
const roleDup = findConflicts([
|
||||
p({ id: "a", roles: [{ name: "shared:read" }] }),
|
||||
p({ id: "b", roles: [{ name: "shared:read" }] }),
|
||||
// Sharing a permission across plugins is legitimate (shared role) → warn, not error.
|
||||
const permDup = findConflicts([
|
||||
p({ id: "a", permissions: [{ token: "shared:read" }] }),
|
||||
p({ id: "b", permissions: [{ token: "shared:read" }] }),
|
||||
]);
|
||||
assert.ok(roleDup.some((c) => c.kind === "role" && c.level === "warn"));
|
||||
assert.ok(permDup.some((c) => c.kind === "permission" && c.level === "warn"));
|
||||
});
|
||||
|
||||
test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => {
|
||||
|
||||
+15
-15
@@ -29,18 +29,18 @@ export interface Route {
|
||||
handler: RouteHandler;
|
||||
method: HttpMethod;
|
||||
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
|
||||
role?: string; // coarse gate — the Keto Role the caller must hold; checked before the handler runs
|
||||
// Mark the page reachable by anyone, signed in or not. The same as omitting `role`
|
||||
// — an ungated route is already open — but stated outright, so "public" is a deliberate
|
||||
// choice, not an accident. Mutually exclusive with `role` (discovery refuses both).
|
||||
permission?: string; // coarse gate (a role token); checked before the handler runs
|
||||
// Mark the page reachable by anyone, signed in or not. The same as omitting `permission`
|
||||
// — a no-permission route is already open — but stated outright, so "public" is a deliberate
|
||||
// choice, not an accident. Mutually exclusive with `permission` (discovery refuses both).
|
||||
public?: boolean;
|
||||
}
|
||||
|
||||
// A Keto Role this plugin gates on — declared for docs/seeding. Role names are a shared
|
||||
// global namespace (so an operator grants them once in Keto); namespace as `<id>:<action>`.
|
||||
export interface RoleDecl {
|
||||
// A permission token this plugin introduces — declared for docs/seeding. Tokens are a shared
|
||||
// global namespace (so an operator grants them in Keto); namespace as `<id>:<action>`.
|
||||
export interface PermissionDecl {
|
||||
description?: string;
|
||||
name: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
// Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud.
|
||||
@@ -63,7 +63,7 @@ export interface PluginManifest {
|
||||
home?: RouteHandler;
|
||||
hooks?: PluginHooks;
|
||||
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique
|
||||
roles?: RoleDecl[];
|
||||
permissions?: PermissionDecl[];
|
||||
routes?: Route[];
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO
|
||||
}
|
||||
|
||||
export interface PluginConflict {
|
||||
kind: "dashboard" | "home" | "id" | "nav-id" | "role" | "route";
|
||||
kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route";
|
||||
level: "error" | "warn";
|
||||
message: string;
|
||||
plugins: string[]; // unique ids involved
|
||||
@@ -155,8 +155,8 @@ export interface PluginConflict {
|
||||
|
||||
// The conflict rules: defined, loud resolution — never last-write-wins. Pure over the discovered
|
||||
// plugins; discovery throws on any "error" and logs every "warn". Mount-path (`/<id>`) uniqueness
|
||||
// is structural — it follows from the id check, so it needs no rule of its own. Shared role
|
||||
// names are the one intentional overlap, so they warn rather than error.
|
||||
// is structural — it follows from the id check, so it needs no rule of its own. Shared permission
|
||||
// tokens are the one intentional overlap, so they warn rather than error.
|
||||
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
||||
const out: PluginConflict[] = [];
|
||||
|
||||
@@ -184,9 +184,9 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
||||
});
|
||||
|
||||
collect(plugins, (plugin, push) => {
|
||||
for (const decl of plugin.roles ?? []) push(decl.name);
|
||||
}).forEach((owners, name) => {
|
||||
if (owners.length > 1) out.push({ kind: "role", level: "warn", message: `role "${name}" declared by ${uniq(owners).length} plugins; namespace as "<id>:<action>" unless shared on purpose`, plugins: uniq(owners) });
|
||||
for (const decl of plugin.permissions ?? []) push(decl.token);
|
||||
}).forEach((owners, token) => {
|
||||
if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${token}" declared by ${uniq(owners).length} plugins; namespace as "<id>:<action>" unless shared on purpose`, plugins: uniq(owners) });
|
||||
});
|
||||
|
||||
return out;
|
||||
|
||||
@@ -57,11 +57,11 @@ test("allowedMethods lists methods at a path (GET implies HEAD); empty when the
|
||||
|
||||
test("isAuthorized: open routes pass; gated routes require the role token; public is explicitly open", () => {
|
||||
const open: Route = { handler: noop, method: "GET", path: "/" };
|
||||
const gated: Route = { handler: noop, method: "GET", path: "/", role: "x:read" };
|
||||
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 role — but stated outright
|
||||
assert.equal(isAuthorized(pub, []), true); // open to anonymous, like omitting permission — but stated outright
|
||||
});
|
||||
|
||||
@@ -74,9 +74,9 @@ export function allowedMethods(plugins: Plugin[], pathname: string): string[] {
|
||||
return [...methods].sort();
|
||||
}
|
||||
|
||||
// Coarse role gate: a route marked `public` (or one with no `role`) is open; otherwise
|
||||
// Coarse permission gate: a route marked `public` (or one with no `permission`) is open; otherwise
|
||||
// the user's roles (from the session JWT) must include the token. The same rule composeNav uses
|
||||
// for the menu. `public` and `role` are mutually exclusive (discovery refuses both).
|
||||
// for the menu. `public` and `permission` are mutually exclusive (discovery refuses both).
|
||||
export function isAuthorized(route: Route, roles: string[]): boolean {
|
||||
return route.public === true || route.role == null || roles.includes(route.role);
|
||||
return route.public === true || route.permission == null || roles.includes(route.permission);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// plugin may deliberately shadow a core partial). The router calls this for a `view` RouteResult.
|
||||
|
||||
import { isAbsolute, join, relative } from "node:path";
|
||||
import ejs from "ejs";
|
||||
import * as ejs from "ejs";
|
||||
|
||||
const CONTROL_CHARS = /[\x00-\x1f]/;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import * as ejs from "ejs";
|
||||
|
||||
const authCard = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "auth-card.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(authCard, data);
|
||||
|
||||
@@ -9,13 +9,13 @@ const scheduling: Plugin = {
|
||||
apiVersion: "1.0.0",
|
||||
id: "scheduling",
|
||||
nav: [{
|
||||
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", role: "scheduling:read" }],
|
||||
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
|
||||
icon: "i-cal", id: "scheduling", label: "Scheduling",
|
||||
}],
|
||||
};
|
||||
// A plugin with a public nav node (reachable by anyone, signed in or not).
|
||||
const portal: Plugin = { apiVersion: "1.0.0", id: "portal", nav: [{ href: "/portal", id: "portal", label: "Portal", public: true }] };
|
||||
// A gated section fragment like the admin plugin's nav: the header carries the role, so
|
||||
// A gated section fragment like the admin plugin's nav: the header carries the permission, so
|
||||
// composeNav drops the whole subtree for a non-holder (the admin screens ship as a drop-in plugin).
|
||||
const adminLike: Plugin = {
|
||||
apiVersion: "1.0.0", id: "admin",
|
||||
@@ -24,7 +24,7 @@ const adminLike: Plugin = {
|
||||
{ href: "/admin/users", id: "users", label: "Users" },
|
||||
{ href: "/admin/groups", id: "groups", label: "Groups" },
|
||||
],
|
||||
icon: "i-shield", id: "admin", label: "Admin", role: "admin",
|
||||
icon: "i-shield", id: "admin", label: "Admin", permission: "admin",
|
||||
}],
|
||||
};
|
||||
|
||||
@@ -45,10 +45,10 @@ test("anonymous shell Sign-in link carries the current page as return_to", () =>
|
||||
assert.equal(buildPluginChrome({ currentPath: "/portal", menu: DEFAULT_MENU }).signInHref, "/login?return_to=%2Fportal");
|
||||
});
|
||||
|
||||
test("a role holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => {
|
||||
test("a permission holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => {
|
||||
const chrome = buildPluginChrome({
|
||||
currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
|
||||
identity: { email: "ada@x.io", id: "u1", roles: ["scheduling:read"] },
|
||||
user: { email: "ada@x.io", id: "u1", roles: ["scheduling:read"] },
|
||||
});
|
||||
assert.deepEqual(labels(chrome.nav), ["Dashboard", "Scheduling"]); // Dashboard shown to a signed-in user
|
||||
const section = chrome.nav.find((n) => n.label === "Scheduling")!;
|
||||
@@ -58,7 +58,7 @@ test("a role holder sees the Dashboard link + plugin nav; current path opens the
|
||||
});
|
||||
|
||||
test("a gated section (like the admin plugin) shows to a holder; a sub-path marks its base leaf current", () => {
|
||||
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], identity: { email: "a@b.c", id: "u1", roles: ["admin"] } });
|
||||
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], user: { email: "a@b.c", id: "u1", roles: ["admin"] } });
|
||||
const admin = chrome.nav.find((n) => n.label === "Admin")!;
|
||||
assert.ok(admin); // gated section visible to an admin
|
||||
assert.equal(admin.open, true); // ancestor of the current leaf opened
|
||||
|
||||
+5
-5
@@ -5,7 +5,7 @@
|
||||
// admin plugin is installed) — run through composeNav (override + per-user filter) and
|
||||
// current-marked for the request path.
|
||||
|
||||
import type { SessionIdentity } from "../http/context.ts";
|
||||
import type { User } from "../http/context.ts";
|
||||
import { type MenuConfig } from "./menu-config.ts";
|
||||
import { composeNav, type NavNode } from "./nav.ts";
|
||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
@@ -29,17 +29,17 @@ export interface ChromeOptions {
|
||||
currentPath?: string; // request pathname; the matching nav leaf is marked current
|
||||
menu: MenuConfig;
|
||||
plugins?: Plugin[];
|
||||
identity?: SessionIdentity | null;
|
||||
user?: User | null;
|
||||
}
|
||||
|
||||
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||
// The Dashboard link targets the gated /dashboard, so show it only to a signed-in user — to an
|
||||
// anonymous visitor (a public page in the shell) it would only dead-end at /login. The admin
|
||||
// section, when present, is just another plugin's nav fragment (examples/plugins/admin).
|
||||
const fragments: NavNode[][] = opts.identity ? [[DASHBOARD_NAV]] : [];
|
||||
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
|
||||
for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
|
||||
|
||||
const roles = opts.identity?.roles ?? [];
|
||||
const roles = opts.user?.roles ?? [];
|
||||
const nav = composeNav(fragments, opts.menu.override, roles);
|
||||
if (opts.currentPath) {
|
||||
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
|
||||
@@ -56,7 +56,7 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||
// Anonymous "Sign in" returns to the current page (it's host-relative, our own pathname).
|
||||
signInHref: opts.currentPath ? `/login?return_to=${encodeURIComponent(opts.currentPath)}` : "/login",
|
||||
...(b.theme != null ? { theme: b.theme } : {}),
|
||||
user: shellUser(opts.identity),
|
||||
user: shellUser(opts.user),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { NavNode } from "./nav.ts";
|
||||
const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }];
|
||||
|
||||
test("dashboard model: titled shell, passes the unified nav + csrf + user through", () => {
|
||||
const m = buildDashboardModel({ csrfToken: "tok.sig", identity: { email: "ada@x.io", id: "u1", roles: ["admin"] }, nav: NAV });
|
||||
const m = buildDashboardModel({ csrfToken: "tok.sig", nav: NAV, user: { email: "ada@x.io", id: "u1", roles: ["admin"] } });
|
||||
assert.equal(m.shell.title, "Dashboard");
|
||||
assert.equal(m.shell.csrfToken, "tok.sig");
|
||||
assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile
|
||||
|
||||
+3
-3
@@ -4,12 +4,12 @@
|
||||
// this placeholder renders until then. Pure: `nav` is the one global menu (ctx.chrome.nav), built
|
||||
// once per request by the host, so the dashboard shows the exact same menu as every other page.
|
||||
|
||||
import type { SessionIdentity } from "../http/context.ts";
|
||||
import type { User } from "../http/context.ts";
|
||||
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
||||
import type { NavNode } from "./nav.ts";
|
||||
import { buildShellContext } from "./shell-context.ts";
|
||||
|
||||
export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfig; nav?: NavNode[]; identity?: SessionIdentity | null } = {}) {
|
||||
export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfig; nav?: NavNode[]; user?: User | null } = {}) {
|
||||
return {
|
||||
nav: opts.nav ?? [],
|
||||
shell: buildShellContext({
|
||||
@@ -17,7 +17,7 @@ export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfi
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
menu: opts.menu ?? DEFAULT_MENU,
|
||||
title: "Dashboard",
|
||||
identity: opts.identity ?? null,
|
||||
user: opts.user ?? null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import * as ejs from "ejs";
|
||||
|
||||
const dataTable = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "data-table.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(dataTable, data);
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import * as ejs from "ejs";
|
||||
|
||||
const field = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "field.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(field, data);
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import * as ejs from "ejs";
|
||||
|
||||
const filterBar = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "filter-bar.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(filterBar, data);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import * as ejs from "ejs";
|
||||
import { ICON_NAMES, buildIconSprite } from "./icons.ts";
|
||||
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Central menu config: config/menu.ts lets an operator set branding (app name, logo,
|
||||
// default theme) and reorder/rename/group/hide nav nodes across all plugins. The reorder/rename/
|
||||
// group/hide part is the NavOverride composeNav already applies (the override always wins, before
|
||||
// the per-user role filter). Authored as TypeScript (defineMenu types it); loaded once at
|
||||
// the per-user permission filter). Authored as TypeScript (defineMenu types it); loaded once at
|
||||
// boot — fail-loud on a malformed file, defaults when absent (clean clone needs no config).
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import * as ejs from "ejs";
|
||||
|
||||
const menu = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "menu.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(menu, data);
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import * as ejs from "ejs";
|
||||
|
||||
const navTree = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "nav-tree.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(navTree, data);
|
||||
|
||||
+11
-11
@@ -2,23 +2,23 @@ import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { composeNav, type NavNode } from "./nav.ts";
|
||||
|
||||
// Two plugin fragments; ids let the override target nodes, `role` gates per role.
|
||||
// Two plugin fragments; ids let the override target nodes, `permission` gates per role.
|
||||
const fragments: NavNode[][] = [
|
||||
[{
|
||||
icon: "i-cal", id: "sched", label: "Scheduling",
|
||||
children: [
|
||||
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", role: "scheduling:read" },
|
||||
{ href: "/scheduling/manage", id: "manage", label: "Manage", role: "scheduling:admin" },
|
||||
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
|
||||
{ href: "/scheduling/manage", id: "manage", label: "Manage", permission: "scheduling:admin" },
|
||||
],
|
||||
}],
|
||||
[{ href: "/reports", id: "reports", label: "Reports", role: "reports:read" }],
|
||||
[{ href: "/reports", id: "reports", label: "Reports", permission: "reports:read" }],
|
||||
];
|
||||
|
||||
test("composeNav merges fragments, filters by role, and emits clean render nodes", () => {
|
||||
const tree = composeNav(fragments, {}, ["scheduling:read"]);
|
||||
|
||||
// Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts.
|
||||
// Output carries no `id`/`role` and omits absent fields — ready for nav-tree.ejs.
|
||||
// Output carries no `id`/`permission` and omits absent fields — ready for nav-tree.ejs.
|
||||
assert.deepEqual(tree, [
|
||||
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling/shifts", label: "Shifts" }] },
|
||||
]);
|
||||
@@ -27,7 +27,7 @@ test("composeNav merges fragments, filters by role, and emits clean render nodes
|
||||
test("composeNav drops gated subtrees, empty headers, and (with no roles) all gated nodes", () => {
|
||||
// A header the user can't reach takes its whole subtree, even visible children.
|
||||
const gatedHeader: NavNode[][] = [[
|
||||
{ id: "admin", label: "Admin", role: "admin", children: [{ href: "/u", id: "u", label: "Users" }] },
|
||||
{ id: "admin", label: "Admin", permission: "admin", children: [{ href: "/u", id: "u", label: "Users" }] },
|
||||
{ id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] },
|
||||
]];
|
||||
assert.deepEqual(composeNav(gatedHeader, {}, []), [
|
||||
@@ -36,8 +36,8 @@ test("composeNav drops gated subtrees, empty headers, and (with no roles) all ga
|
||||
|
||||
// A pure header whose children are all filtered is dropped; a header with an href survives as a leaf.
|
||||
const emptyHeader: NavNode[][] = [[
|
||||
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", role: "x" }] },
|
||||
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", role: "y" }] },
|
||||
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x" }] },
|
||||
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y" }] },
|
||||
]];
|
||||
assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]);
|
||||
|
||||
@@ -52,10 +52,10 @@ test("composeNav keeps a node marked public for everyone — the blessed public
|
||||
icon: "i-cal", id: "sched", label: "Scheduling",
|
||||
children: [
|
||||
{ href: "/scheduling", id: "overview", label: "Overview", public: true },
|
||||
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", role: "scheduling:read" },
|
||||
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
|
||||
],
|
||||
}]];
|
||||
// `public` is filter-only (like id/role) — never rendered into the output node.
|
||||
// `public` is filter-only (like id/permission) — never rendered into the output node.
|
||||
assert.deepEqual(composeNav(frag, {}, []), [
|
||||
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] },
|
||||
]);
|
||||
@@ -66,7 +66,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
|
||||
{ href: "/a", id: "a", label: "Alpha" },
|
||||
{ href: "/b", id: "b", label: "Beta" },
|
||||
{ href: "/c", id: "c", label: "Gamma" },
|
||||
{ href: "/secret", id: "secret", label: "Secret", role: "root" },
|
||||
{ href: "/secret", id: "secret", label: "Secret", permission: "root" },
|
||||
]];
|
||||
|
||||
const tree = composeNav(base, {
|
||||
|
||||
+8
-8
@@ -1,10 +1,10 @@
|
||||
// composeNav: merge each plugin's nav fragment into one tree, apply the central
|
||||
// override, then role-filter per user. Pure and I/O-free — menu gating reads the JWT
|
||||
// override, then permission-filter per user. Pure and I/O-free — menu gating reads the JWT
|
||||
// `roles` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or
|
||||
// declares no `role`, or `roles` includes that role name; a gated header hides its whole
|
||||
// declares no `permission`, or `roles` includes that permission token; a gated header hides its whole
|
||||
// subtree, and a pure header left with no children is dropped. The config/menu.ts supplies
|
||||
// the override (+ branding); this helper only transforms data, so its result is per-deployment
|
||||
// up to the final role filter and emits clean nodes ready for nav-tree.ejs (no id/role).
|
||||
// up to the final role filter and emits clean nodes ready for nav-tree.ejs (no id/permission).
|
||||
|
||||
export interface NavNode {
|
||||
id?: string; // stable key for override targeting; stripped from the rendered tree
|
||||
@@ -15,12 +15,12 @@ export interface NavNode {
|
||||
icon?: string;
|
||||
label: string;
|
||||
open?: boolean;
|
||||
role?: string; // required role token; consumed by the filter, never rendered
|
||||
public?: boolean; // show to everyone, signed in or not — the blessed alias for "no role", stated outright; consumed by the filter, never rendered. Mutually exclusive with role (discovery refuses both).
|
||||
permission?: string; // required role 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 →
|
||||
// order → hide, then the per-user role filter runs last.
|
||||
// order → hide, then the per-user permission filter runs last.
|
||||
export interface NavOverride {
|
||||
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)
|
||||
@@ -106,7 +106,7 @@ function hideTree(nodes: NavNode[], hide: Set<string>): NavNode[] {
|
||||
function filterByRoles(nodes: NavNode[], roles: Set<string>): NavNode[] {
|
||||
const out: NavNode[] = [];
|
||||
for (const n of nodes) {
|
||||
if (n.public !== true && n.role != null && !roles.has(n.role)) continue; // gated → drop node + subtree (public always shows)
|
||||
if (n.public !== true && n.permission != null && !roles.has(n.permission)) continue; // gated → drop node + subtree (public always shows)
|
||||
if (!n.children) { out.push(n); continue; }
|
||||
const children = filterByRoles(n.children, roles);
|
||||
if (children.length === 0 && n.href == null) continue; // empty pure header → drop
|
||||
@@ -115,7 +115,7 @@ function filterByRoles(nodes: NavNode[], roles: Set<string>): NavNode[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
// Strip the helper-only fields (id/role) 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.
|
||||
function toRenderNode(n: NavNode): NavNode {
|
||||
const out: NavNode = { label: n.label };
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import * as ejs from "ejs";
|
||||
|
||||
const pagination = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "pagination.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(pagination, data);
|
||||
|
||||
@@ -22,7 +22,7 @@ test("buildShellContext maps branding + breadcrumbs, omitting unset optional fie
|
||||
menu: { branding: { logo: "/l.svg", name: "Acme", sub: "Ops", theme: "dark" }, override: {} },
|
||||
signInHref: "/login?return_to=%2Fx",
|
||||
title: "Users",
|
||||
identity: { email: "a@b.c", id: "u1", roles: ["admin"] },
|
||||
user: { email: "a@b.c", id: "u1", roles: ["admin"] },
|
||||
});
|
||||
assert.deepEqual(full.brand, { logo: "/l.svg", name: "Acme", sub: "Ops" });
|
||||
assert.equal(full.theme, "dark");
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// the profile shows the email's local part as the name with the full email beneath, initials from
|
||||
// the local part; anonymous ⇒ "Guest".
|
||||
|
||||
import type { SessionIdentity } from "../http/context.ts";
|
||||
import type { User } from "../http/context.ts";
|
||||
import { type MenuConfig } from "./menu-config.ts";
|
||||
|
||||
export interface ShellUser {
|
||||
@@ -24,10 +24,10 @@ export interface ShellModel {
|
||||
user: ShellUser;
|
||||
}
|
||||
|
||||
export function shellUser(identity: SessionIdentity | null | undefined): ShellUser {
|
||||
if (!identity) return { email: "", initials: "G", name: "Guest" };
|
||||
const local = identity.email.split("@")[0] || identity.email;
|
||||
return { email: identity.email, initials: (local.slice(0, 2) || "U").toUpperCase(), name: local };
|
||||
export function shellUser(user: User | null | undefined): ShellUser {
|
||||
if (!user) return { email: "", initials: "G", name: "Guest" };
|
||||
const local = user.email.split("@")[0] || user.email;
|
||||
return { email: user.email, initials: (local.slice(0, 2) || "U").toUpperCase(), name: local };
|
||||
}
|
||||
|
||||
export function buildShellContext(opts: {
|
||||
@@ -36,7 +36,7 @@ export function buildShellContext(opts: {
|
||||
menu: MenuConfig;
|
||||
signInHref?: string;
|
||||
title: string;
|
||||
identity?: SessionIdentity | null;
|
||||
user?: User | null;
|
||||
}): ShellModel {
|
||||
const b = opts.menu.branding;
|
||||
return {
|
||||
@@ -46,6 +46,6 @@ export function buildShellContext(opts: {
|
||||
...(opts.signInHref != null ? { signInHref: opts.signInHref } : {}),
|
||||
...(b.theme != null ? { theme: b.theme } : {}),
|
||||
title: opts.title,
|
||||
user: shellUser(opts.identity),
|
||||
user: shellUser(opts.user),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import * as ejs from "ejs";
|
||||
|
||||
const shell = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "shell.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(shell, data);
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import * as ejs from "ejs";
|
||||
|
||||
const themeSwitch = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "theme-switch.ejs");
|
||||
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(themeSwitch, data);
|
||||
|
||||
@@ -11,15 +11,11 @@
|
||||
- [x] CI/CD - Sync docker images to docker hub after each re-tag to git tags. (`release.yml` pushes the same `X.Y.Z`/`X.Y`/`X`/`latest` tags to `docker.io/larvit/plainpages` after the Gitea re-tag — releases only, no hash tags; auth via the `DOCKERHUB_USER` variable + `DOCKERHUB_TOKEN` secret; documented in README → CI/CD.)
|
||||
- [x] Write a short text on how to use this docker image to publish on docker hub and save it to README-dockerhub.md (tagline, tags, clone-free quick start — the image ships the Ory config, extracted via `docker run … tar` + a self-contained compose.yml — env table, first plugin; pasted into the Docker Hub overview by hand — noted in README → CI/CD.)
|
||||
- [x] CI/CD - Setup renovate bot. Check how other repos on this Gitea is setup you can get access to, there should be a number of renovate bot activated ones. (`renovate.yml` runs the self-hosted `renovate/renovate` image nightly against `renovate.json` — this repo only, via the shared `renovate@larvit.se` bot + `RENOVATE_TOKEN` secret, mirroring the `pwrpln/core` pattern; standard managers cover npm/Dockerfiles/compose/gitea-action pins, two custom regex managers cover the image tags embedded in workflow `run:` steps, the Ory + Playwright lockstep sets are grouped, every bump stays an exact pin, and each PR automerges once the gate is green; documented in README → CI/CD.)
|
||||
- [x] CI/CD - Renovate: set a read-only `GITHUB_COM_TOKEN` env in `renovate.yml` so Renovate stops hitting github.com rate limits when resolving github-hosted deps (Playwright, lucide, `actions/checkout`) and can fetch changelogs. Non-blocking refinement; needs a read-only GitHub PAT stored as an Actions secret. (The renovate job forwards the `RENOVATE_GITHUB_TOKEN` secret — a scopeless read-only github.com PAT; Gitea rejects `GITHUB_`-prefixed secret names — into the container as `GITHUB_COM_TOKEN`; documented in README → CI/CD.)
|
||||
- [ ] CI/CD - Renovate: set a read-only `GITHUB_COM_TOKEN` env in `renovate.yml` so Renovate stops hitting github.com rate limits when resolving github-hosted deps (Playwright, lucide, `actions/checkout`) and can fetch changelogs. Non-blocking refinement; needs a read-only GitHub PAT stored as an Actions secret.
|
||||
- [x] CI/CD - When renovate updates a dependency - also release a new version of plainpages based on what got updated with Renovate. Major typescript? New apiVersion + new major. A tiny patch to ejs? Only patch release etc. Before implementing, explain in detail how you will solve this. (`renovate.yml` gains an `auto-release` job (`needs: renovate`) that cuts one `vX.Y.Z` tag per run for what Renovate merged; level = highest `Release-Bump:` trailer Renovate stamps via `commitBody`, any dep's major/minor/patch mapped straight through (default patch). Decoupled from `apiVersion` (tag-only, `HOST_API_VERSION` untouched — a "major" is just a bigger image tag, never a plugin break); pre-1.0 shifts down so nothing auto-crosses into 1.0.0. Pure `auto-release/next-version.ts` + unit tests; tag pushed with renovate-bot's PAT so `release.yml` fires; documented in README → CI/CD.)
|
||||
- [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser. (compose.full.yml now includes Hydra (`serve all --dev`) and full-flow.spec.ts drives /admin/clients register → one-time secret → list → detail → delete in the browser; documented in README → Testing.)
|
||||
- [x] Build and publish docker image as CI/CD. (Duplicate of the CI/CD items above: `ci.yml` builds and pushes `gitea.larvit.se/larvit/plainpages:<commit hash>` behind the green gate, `release.yml` re-tags it to semver and syncs those tags to Docker Hub.)
|
||||
- [x] The human developer understands the security model in the auth in this project. (Two README sections. [Users, groups & roles](README.md#users-groups--roles) carries the weight: the entity model, a worked graph, a per-route can/cannot walkthrough, and the trap that a per-row grant never widens a coarse gate — placed before Building plugins because a manifest's `role:` gate is unreadable without it. [Security model](README.md#security-model) is deliberately short, only the facts a deployment gets wrong without them: the private network as the *only* guard on the Ory APIs, signed-not-encrypted claims, the 30-day Kratos session behind the ~10m JWT, and non-instant offboarding. The first attempt answered the *threat* model instead — a 12-row attack/defense table — which was the wrong question and mostly restated code readable at its source; cut. Also corrected the hardening checklist: `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres/demo-admin secrets are now listed in "What you must supply". The mandatory-`exp` guard gained a test in `src/auth/jwt-middleware.test.ts`.)
|
||||
- [ ] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser.
|
||||
- [ ] Build and publish docker image as CI/CD.
|
||||
- [ ] Add i18n support.
|
||||
- [x] Follow Kratos and rename the Keto `User` namespace to `Identity`. (OPL `class Identity`, subjects `identity:<kratos-id>`, and the session type `User` → `SessionIdentity` with `ctx.user` → `ctx.identity`. No migration was needed after all: `keto-migrate` runs Keto's *own* bundled schema migrations and our tuples are runtime data written by `bootstrap.ts` and the admin plugin — with zero installations, `docker compose down -v` is the whole story. The name collided with the existing `Identity` DTO that `#plugin-api` re-exports from `kratos-admin.ts` — that one is the full Kratos record (traits, state, addresses) and kept the plain name; ours is the JWT projection `{ id, email, roles }`, hence `SessionIdentity`. The presentation layer deliberately still says "user" — `ShellUser`, `chrome.user`, the EJS `user` locals — because that is the avatar/profile view-model, not the identity entity.)
|
||||
- [ ] Decide whether the single generic Keto `Resource` namespace should become per-domain namespaces (`Shift`, `Document`, …), as Ory's own examples model it. One global `Resource` bucket is the project's own "no catch-all names" rule (`utils`, `helpers`, `misc`) applied to namespaces. Raised 2026-08-03; a design question, not a naming one.
|
||||
- [ ] 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 (a sibling subdomain, or a plaintext hop with `SECURE_COOKIES=false`) 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 → "Deliberate architectural deviations" and in README → Security model under "Not guaranteed"; not accepted ⇒ bind the nonce to `sub` (small change). Raised by review 2026-08-02; left undecided because it is a maintainer call, and an undocumented exception reads as a bug to the next reviewer.
|
||||
|
||||
## Architectural review findings (2026-07-02)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<symbol id="i-arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7" /><path d="M19 12H5" /></symbol>
|
||||
<symbol id="i-bell" viewBox="0 0 24 24"><path d="M10.268 21a2 2 0 0 0 3.464 0" /><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></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 2v4" /><path d="M16 2v4" /><rect width="18" height="18" x="3" y="4" rx="2" /><path d="M3 10h18" /></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="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>
|
||||
|
||||
Reference in New Issue
Block a user