Compare commits
62 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d0f5a99bf | |||
| 9097552065 | |||
| 076d1f6926 | |||
| 925debbd51 | |||
| 41c568796c | |||
| f7d70cfff8 | |||
| ca3d49ec45 | |||
| acce2d2556 | |||
| 702e42de09 | |||
| 98bdd2c1b5 | |||
| 12913402a6 | |||
| 7dee80a976 | |||
| c0fe8b0a82 | |||
| fb7d20a7db | |||
| 0919adf4ef | |||
| f5f4455b81 | |||
| 3f30f889c3 | |||
| ae1479f55b | |||
| bb6021adf7 | |||
| c4e6212189 | |||
| 6f6aafad39 | |||
| 4aada6eed9 | |||
| a574effb37 | |||
| c259497627 | |||
| 8e74532a77 | |||
| 62f95afe63 | |||
| ffeec70f8f | |||
| 62d4c8b7cd | |||
| 12cc2d54c2 | |||
| bb6adc40af | |||
| c64156a9d5 | |||
| de2ad42f5a | |||
| 9213e5a0de | |||
| 45054db5e6 | |||
| 23bafd247d | |||
| 7c66599f35 | |||
| 6db0f57bf4 | |||
| 175717f04d | |||
| 6c850b8923 | |||
| af4a70d904 | |||
| a0244a32cd | |||
| 6559f40142 | |||
| d3154819f8 | |||
| 1cba6d470c | |||
| 194c090bd1 | |||
| ff5094f7e9 | |||
| 419ee1750b | |||
| cedac950be | |||
| ff455f1ef2 | |||
| 5bd26d773d | |||
| 4f60bad119 | |||
| aea568ea1c | |||
| 145db5b4cd | |||
| 7c39056188 | |||
| 9719586f51 | |||
| 67d8a095a5 | |||
| 476ef6fce2 | |||
| 93fa751d6d | |||
| cc886936ed | |||
| e3e582afef | |||
| 0644ec8f5a | |||
| 058280934b |
@@ -8,6 +8,8 @@ jobs:
|
|||||||
runs-on: docker-host
|
runs-on: docker-host
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4.2.2
|
- 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
|
- run: bash ci.sh
|
||||||
- name: Push app image tagged with the commit hash
|
- name: Push app image tagged with the commit hash
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ name: Mirror
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
tags: ['**']
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
name: Registry cleanup
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '43 3 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prune-stale-images:
|
||||||
|
runs-on: docker-host
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4.2.2
|
||||||
|
- name: Delete hash images that are neither release-tagged nor a branch head
|
||||||
|
env:
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
|
||||||
|
REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }}
|
||||||
|
REPO_TOKEN: ${{ github.token }}
|
||||||
|
REPOSITORY: ${{ github.repository }}
|
||||||
|
SERVER_URL: ${{ github.server_url }}
|
||||||
|
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
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: Release
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v[0-9]+.[0-9]+.[0-9]+']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
retag-image:
|
||||||
|
runs-on: docker-host
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4.2.2
|
||||||
|
- name: Promote the commit-hash image to semver + latest
|
||||||
|
env:
|
||||||
|
GIT_TAG: ${{ github.ref_name }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.DOCKER_REGISTRY_TOKEN }}
|
||||||
|
REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }}
|
||||||
|
REPO: gitea.larvit.se/${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
COMMIT=$(git rev-parse 'HEAD^{commit}')
|
||||||
|
VERSION=${GIT_TAG#v}
|
||||||
|
printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin
|
||||||
|
docker pull "$REPO:$COMMIT" \
|
||||||
|
|| { echo "No image $REPO:$COMMIT - release tags must point at a commit whose branch passed the CI gate"; exit 1; }
|
||||||
|
for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do
|
||||||
|
docker tag "$REPO:$COMMIT" "$REPO:$TAG"
|
||||||
|
docker push "$REPO:$TAG"
|
||||||
|
done
|
||||||
|
- name: Sync the release tags to Docker Hub
|
||||||
|
env:
|
||||||
|
DOCKERHUB_REPO: docker.io/${{ github.repository }}
|
||||||
|
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
|
||||||
|
GIT_TAG: ${{ github.ref_name }}
|
||||||
|
REPO: gitea.larvit.se/${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
COMMIT=$(git rev-parse 'HEAD^{commit}')
|
||||||
|
VERSION=${GIT_TAG#v}
|
||||||
|
[ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \
|
||||||
|
|| { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (README -> CI/CD)"; exit 1; }
|
||||||
|
printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin
|
||||||
|
for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do
|
||||||
|
docker tag "$REPO:$COMMIT" "$DOCKERHUB_REPO:$TAG"
|
||||||
|
docker push "$DOCKERHUB_REPO:$TAG"
|
||||||
|
done
|
||||||
|
- name: Log out of the registries
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
docker logout gitea.larvit.se
|
||||||
|
docker logout docker.io
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
name: Renovate
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '17 4 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
renovate:
|
||||||
|
runs-on: docker-host
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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
|
||||||
|
# trigger-time tip and lags the merges this run made. Skips when main's tip isn't a Renovate commit
|
||||||
|
# (a human owns that release) or nothing new merged. ff-only merges keep the renovate commit's
|
||||||
|
# authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer;
|
||||||
|
# pre-1.0 shifts down (auto-release/next-version.ts). Tag-only — release.yml promotes the
|
||||||
|
# already-built image; pushed with renovate-bot's PAT so release.yml fires (the built-in token won't).
|
||||||
|
auto-release:
|
||||||
|
runs-on: docker-host
|
||||||
|
needs: renovate
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4.2.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Tag a release for what Renovate merged
|
||||||
|
env:
|
||||||
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
|
REPO: ${{ github.repository }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git fetch --force --quiet origin '+refs/heads/main:refs/remotes/origin/main' '+refs/tags/*:refs/tags/*'
|
||||||
|
if [ "$(git log -1 --format='%ae' origin/main)" != "renovate@larvit.se" ]; then
|
||||||
|
echo "main tip not authored by Renovate — a human owns this release; skipping"; exit 0
|
||||||
|
fi
|
||||||
|
LATEST=$(git tag -l 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n1)
|
||||||
|
LATEST=${LATEST:-v0.0.0}
|
||||||
|
if [ -z "$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' --format='%H')" ]; then
|
||||||
|
echo "No untagged renovate commits since ${LATEST} — nothing to release"; exit 0
|
||||||
|
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 \
|
||||||
|
node auto-release/next-version.ts "$LATEST" $BUMPS)
|
||||||
|
echo "Releasing $LATEST -> $NEXT"
|
||||||
|
git tag "$NEXT" origin/main
|
||||||
|
git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT"
|
||||||
@@ -7,7 +7,7 @@ commands and layout.
|
|||||||
|
|
||||||
Use the file `todo.md`.
|
Use the file `todo.md`.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Project priorities (do not erode)
|
## Project priorities (do not erode)
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ them. Revisit only if the stated reason stops holding.
|
|||||||
`server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` stay at the root. Tests
|
`server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` stay at the root. Tests
|
||||||
are co-located (`foo.test.ts` beside `foo.ts`). Add a new module to the folder that owns its
|
are co-located (`foo.test.ts` beside `foo.ts`). Add a new module to the folder that owns its
|
||||||
concern rather than to the root; don't reintroduce a flat tree. The core ships **no domain
|
concern rather than to the root; don't reintroduce a flat tree. The core ships **no domain
|
||||||
screens** — even the admin GUI (users/groups/roles) is a drop-in plugin (`examples/plugins/admin/`),
|
screens** — even the admin GUI (users/groups/permissions) is a drop-in plugin (`examples/plugins/admin/`),
|
||||||
not `src/` code.
|
not `src/` code.
|
||||||
- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the
|
- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the
|
||||||
base request context. It protects the I/O-free hot path on the public, bot-hit landing
|
base request context. It protects the I/O-free hot path on the public, bot-hit landing
|
||||||
@@ -86,6 +86,26 @@ them. Revisit only if the stated reason stops holding.
|
|||||||
`tsconfig.include` and resolve the host surface via `#`-imports, so each example typechecks
|
`tsconfig.include` and resolve the host surface via `#`-imports, so each example typechecks
|
||||||
in place *and* copies across unchanged. Never commit real plugins/config into the root
|
in place *and* copies across unchanged. Never commit real plugins/config into the root
|
||||||
mount dirs (`plugins/`, `config/`) — they ship empty (`.gitkeep`, git-ignored otherwise).
|
mount dirs (`plugins/`, `config/`) — they ship empty (`.gitkeep`, git-ignored otherwise).
|
||||||
|
- **Authorization vocabulary: `User` → `Group` → `Permission`, and there is no `Role`.** Keto ships
|
||||||
|
no namespaces — all four in `ory/keto/namespaces.keto.ts` are ours. `Permission` follows RBAC,
|
||||||
|
where a permission is one operation ("read shifts") and a role is a *bundle* of them; a route
|
||||||
|
gates on one operation, so it gates on a permission, and a bundle is just a group with several
|
||||||
|
grants (groups nest). Ory's own "permission" (the `Resource` `permits`: view/edit/delete) is the
|
||||||
|
separate per-row tier.
|
||||||
|
- **Plainpages says "user" everywhere; Ory's word for it is "identity".** Kratos calls the record
|
||||||
|
an identity, but Ory's own docs state it uses that term *interchangeably* with "users" and
|
||||||
|
"accounts" — so this is house style, not a renamed concept, and "user" is the word readers
|
||||||
|
already know (Nielsen's heuristic #2: match between the system and the real world). One note in
|
||||||
|
README → Auth records the mapping so nobody has to rediscover it. The single exception is the
|
||||||
|
`Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors Kratos' wire shape and keeps Ory's
|
||||||
|
name — don't rename that one.
|
||||||
|
- **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.
|
||||||
|
|
||||||
## Docker only — no host tooling
|
## Docker only — no host tooling
|
||||||
|
|
||||||
@@ -113,15 +133,28 @@ docker compose -f compose.yml up --build -d # production
|
|||||||
running **building plugins** comes first, then **configuring and securing** the system
|
running **building plugins** comes first, then **configuring and securing** the system
|
||||||
(Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are
|
(Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are
|
||||||
deliberately deferred — they're not top of mind when starting out. Concretely: Overview →
|
deliberately deferred — they're not top of mind when starting out. Concretely: Overview →
|
||||||
Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
|
Users, groups & permissions → Building plugins → menu/blocks/interactivity →
|
||||||
Architecture → Testing → Production → Observability → the JWT-rotation runbook → the
|
Configuration → Auth → Email → Architecture → Testing → Production → Observability → the
|
||||||
Project-layout file map → Extending. When adding a section, place it by this value (how
|
JWT-rotation runbook → the Project-layout file map → Extending. When adding a section, place
|
||||||
early an adopter needs it), not by where it sits in the stack.
|
it by this value (how early an adopter needs it), not by where it sits in the stack.
|
||||||
|
|
||||||
|
**Users, groups & permissions precedes Building plugins** because a manifest's
|
||||||
|
`permission:` 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
|
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
|
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).
|
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
|
## Rules
|
||||||
|
|
||||||
- Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable**
|
- Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable**
|
||||||
@@ -137,6 +170,10 @@ one home, linking to it rather than restating (credentials, env vars, rotation s
|
|||||||
- Tests use the built-in `node --test` runner — no test framework dependency.
|
- Tests use the built-in `node --test` runner — no test framework dependency.
|
||||||
- English everywhere. Keep code comments short and information-dense. Self explained code
|
- English everywhere. Keep code comments short and information-dense. Self explained code
|
||||||
without any comment at all is the preferred solution.
|
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
|
- Pin all dependencies and Docker images to exact, human-readable **semantic
|
||||||
versions** — never ranges (`^`, `~`) and never digests/hashes. npm deps are kept
|
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.
|
exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g.
|
||||||
@@ -151,9 +188,8 @@ one home, linking to it rather than restating (credentials, env vars, rotation s
|
|||||||
that re-parses `ctx.url.pathname`: it duplicates the URL shape, ignores the router's params, and
|
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
|
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/`.
|
resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`.
|
||||||
- Run the stability reviewer agent after every implementation of something that can be like
|
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer
|
||||||
a PR. That includes any change pushed directly to main.
|
agents. Decided 2026-08-02, replacing the earlier run-after-every-implementation rule.
|
||||||
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
|
- 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"
|
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.
|
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
|
||||||
FROM node:24.16.0-alpine3.24
|
FROM node:24.18.1-alpine3.24
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
# Plainpages
|
||||||
|
|
||||||
|
A self-hostable foundation for server-rendered web apps — public or gated pages from a
|
||||||
|
zero-JS design system, with a config-driven menu and auth/permissions (Ory) baked in.
|
||||||
|
Every domain feature is a drop-in plugin folder; the app is stateless, no build step.
|
||||||
|
|
||||||
|
**Source, docs & issues: <https://gitea.larvit.se/larvit/plainpages>**
|
||||||
|
([GitHub mirror](https://github.com/larvit/plainpages))
|
||||||
|
|
||||||
|
## Tags
|
||||||
|
|
||||||
|
`X.Y.Z` · `X.Y` · `X` · `latest` — each is a release promoted from a CI-gated build.
|
||||||
|
Pin the exact `X.Y.Z` you deploy.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
This image is the Plainpages web app plus its one-shot bootstrap seeder. It runs
|
||||||
|
alongside its Ory sidecars (Kratos, Keto) and Postgres — and it **ships their config**,
|
||||||
|
so there is nothing to clone. In an empty directory, save this as `compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: larvit/plainpages:0.0.2
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
APP_URL: http://localhost:3000
|
||||||
|
depends_on:
|
||||||
|
bootstrap:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
kratos:
|
||||||
|
condition: service_healthy
|
||||||
|
keto:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro
|
||||||
|
- ./plugins:/app/plugins
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# One-shot, idempotent seed: signing key if absent + the admin@plainpages.local / admin user.
|
||||||
|
bootstrap:
|
||||||
|
image: larvit/plainpages:0.0.2
|
||||||
|
command: node src/auth/bootstrap.ts
|
||||||
|
depends_on:
|
||||||
|
kratos:
|
||||||
|
condition: service_healthy
|
||||||
|
keto:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer
|
||||||
|
- ./plugins:/app/plugins:ro
|
||||||
|
restart: "on-failure:5"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:18.4-alpine3.23
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ory
|
||||||
|
POSTGRES_PASSWORD: ory
|
||||||
|
POSTGRES_USER: ory
|
||||||
|
volumes:
|
||||||
|
- ./ory/postgres/init:/docker-entrypoint-initdb.d:ro
|
||||||
|
- pgdata:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ory -d ory"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
kratos-migrate:
|
||||||
|
image: oryd/kratos:v26.2.0
|
||||||
|
command: -c /etc/config/kratos/kratos.yml migrate sql -e --yes
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DSN: postgres://ory:ory@postgres:5432/kratos?sslmode=disable
|
||||||
|
volumes:
|
||||||
|
- ./ory/kratos:/etc/config/kratos:ro
|
||||||
|
restart: on-failure
|
||||||
|
|
||||||
|
kratos:
|
||||||
|
image: oryd/kratos:v26.2.0
|
||||||
|
command: serve -c /etc/config/kratos/kratos.yml --watch-courier
|
||||||
|
ports:
|
||||||
|
- "4433:4433" # the login form POSTs straight to Kratos from the browser
|
||||||
|
depends_on:
|
||||||
|
kratos-migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
DSN: postgres://ory:ory@postgres:5432/kratos?sslmode=disable
|
||||||
|
volumes:
|
||||||
|
- ./ory/kratos:/etc/config/kratos:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4433/health/ready"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
keto-migrate:
|
||||||
|
image: oryd/keto:v26.2.0
|
||||||
|
command: -c /etc/config/keto/keto.yml migrate up -y
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
DSN: postgres://ory:ory@postgres:5432/keto?sslmode=disable
|
||||||
|
volumes:
|
||||||
|
- ./ory/keto:/etc/config/keto:ro
|
||||||
|
restart: on-failure
|
||||||
|
|
||||||
|
keto:
|
||||||
|
image: oryd/keto:v26.2.0
|
||||||
|
command: serve -c /etc/config/keto/keto.yml
|
||||||
|
depends_on:
|
||||||
|
keto-migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
environment:
|
||||||
|
DSN: postgres://ory:ory@postgres:5432/keto?sslmode=disable
|
||||||
|
volumes:
|
||||||
|
- ./ory/keto:/etc/config/keto:ro
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:4466/health/ready"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit:v1.30.1
|
||||||
|
ports:
|
||||||
|
- "8025:8025"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
```
|
||||||
|
|
||||||
|
Extract the Ory config the image ships, then start:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm larvit/plainpages:0.0.2 tar -cf - ory | tar -xf -
|
||||||
|
mkdir -p plugins
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Open <http://localhost:3000> and sign in as `admin@plainpages.local` / `admin`.
|
||||||
|
|
||||||
|
This quick start runs http-on-localhost with dev-throwaway secrets, and omits Hydra (the
|
||||||
|
OAuth2 provider — only needed when other apps log in *through* Plainpages). For
|
||||||
|
production — https, real secrets (`CSRF_SECRET`, Postgres credentials, a fresh JWT
|
||||||
|
signing key), Hydra — see the repo README → Production & deployment.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Every behaviour is an explicit env toggle read at boot — no `NODE_ENV`. The common ones:
|
||||||
|
|
||||||
|
| Var | Default | What |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | `admin@plainpages.local` / `admin` | the seeded first admin (bootstrap service) |
|
||||||
|
| `APP_URL` | unset | canonical public URL; off-host visitors are redirected to it |
|
||||||
|
| `CACHE_TEMPLATES` | `false` | cache compiled templates (`true` in prod) |
|
||||||
|
| `CSRF_SECRET` | dev throwaway | signs the CSRF token — set a real one in prod |
|
||||||
|
| `KRATOS_*` / `KETO_*` / `HYDRA_*` URLs | compose defaults | the Ory sidecar endpoints |
|
||||||
|
| `LOG_FORMAT` / `LOG_LEVEL` | `text` / `info` | `json` for structured prod logs |
|
||||||
|
| `OTLP_ENDPOINT` | unset | export logs + traces to an OpenTelemetry Collector |
|
||||||
|
| `REQUIRE_SECURE_SECRETS` | `false` | `true` ⇒ refuse to boot on a missing/throwaway `CSRF_SECRET` |
|
||||||
|
| `SECURE_COOKIES` | `false` | mark cookies `Secure` (`true` behind https) |
|
||||||
|
|
||||||
|
Full list (JWT/JWKS, timeouts, instant revoke): repo README → Configuration.
|
||||||
|
|
||||||
|
## Your first plugin
|
||||||
|
|
||||||
|
Everything domain-specific is a plugin folder — the compose above mounts `./plugins`
|
||||||
|
into the app. Create `plugins/hello/plugin.ts`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { definePlugin } from "#plugin-api";
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
apiVersion: "1.0.0",
|
||||||
|
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
||||||
|
routes: [
|
||||||
|
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart (`docker compose restart web`) and visit <http://localhost:3000/hello>. Views,
|
||||||
|
forms, permissions, and the runnable reference plugin: repo README → Building plugins.
|
||||||
@@ -28,14 +28,14 @@ docker compose up -d # http://localhost:3000, live-reloads on source chan
|
|||||||
**`admin@plainpages.local` / `admin`**.
|
**`admin@plainpages.local` / `admin`**.
|
||||||
|
|
||||||
**3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups
|
**3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups
|
||||||
/ Roles / OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/admin/*`:
|
/ Permissions / OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/admin/*`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp -r examples/plugins/admin plugins/admin
|
cp -r examples/plugins/admin plugins/admin
|
||||||
docker compose restart web
|
docker compose restart web
|
||||||
```
|
```
|
||||||
|
|
||||||
The seeded admin already holds the `admin` role, so the **Admin** section now shows in the menu.
|
The seeded admin already holds the `admin` permission, so the **Admin** section now shows in the menu.
|
||||||
See [`examples/plugins/admin/`](examples/plugins/admin/).
|
See [`examples/plugins/admin/`](examples/plugins/admin/).
|
||||||
|
|
||||||
**4. Add your first plugin.** The clone is bind-mounted into the container, so a new
|
**4. Add your first plugin.** The clone is bind-mounted into the container, so a new
|
||||||
@@ -69,6 +69,10 @@ From here, render real pages against the app shell and fetch upstream data — s
|
|||||||
|
|
||||||
- [Overview](#overview)
|
- [Overview](#overview)
|
||||||
- [how it compares](#how-it-compares)
|
- [how it compares](#how-it-compares)
|
||||||
|
- [Users, groups & permissions](#users-groups--permissions)
|
||||||
|
- [a worked example](#a-worked-example)
|
||||||
|
- [granting a permission](#granting-a-permission)
|
||||||
|
- [fine-grained, per-row access](#fine-grained-per-row-access)
|
||||||
- [Building plugins](#building-plugins)
|
- [Building plugins](#building-plugins)
|
||||||
- [anatomy](#anatomy-of-a-plugin)
|
- [anatomy](#anatomy-of-a-plugin)
|
||||||
- [the manifest](#the-manifest)
|
- [the manifest](#the-manifest)
|
||||||
@@ -76,7 +80,7 @@ From here, render real pages against the app shell and fetch upstream data — s
|
|||||||
- [landing pages](#the-landing-pages-home--dashboard)
|
- [landing pages](#the-landing-pages-home--dashboard)
|
||||||
- [RequestContext](#requestcontext)
|
- [RequestContext](#requestcontext)
|
||||||
- [system capabilities (ctx.system)](#system-capabilities-the-ctxsystem-surface)
|
- [system capabilities (ctx.system)](#system-capabilities-the-ctxsystem-surface)
|
||||||
- [nav & permissions](#nav--permissions)
|
- [nav & permission gates](#nav--permission-gates)
|
||||||
- [versioning](#contract-versioning)
|
- [versioning](#contract-versioning)
|
||||||
- [conflict rules](#conflict-rules)
|
- [conflict rules](#conflict-rules)
|
||||||
- [hooks](#hooks)
|
- [hooks](#hooks)
|
||||||
@@ -89,11 +93,12 @@ From here, render real pages against the app shell and fetch upstream data — s
|
|||||||
- [canonical host](#canonical-host-one-public-url)
|
- [canonical host](#canonical-host-one-public-url)
|
||||||
- [what you must supply](#what-you-must-supply-the-only-manual-prep)
|
- [what you must supply](#what-you-must-supply-the-only-manual-prep)
|
||||||
- [SSO](#social-sign-in-sso)
|
- [SSO](#social-sign-in-sso)
|
||||||
- [Auth, sessions & permissions](#auth-sessions--permissions)
|
- [Auth, sessions & access](#auth-sessions--access)
|
||||||
- [login & the session JWT](#login-and-the-session-jwt)
|
- [login & the session JWT](#login-and-the-session-jwt)
|
||||||
- [instant revoke](#instant-revoke-the-optional-denylist)
|
- [instant revoke](#instant-revoke-the-optional-denylist)
|
||||||
- [three tiers](#three-tiers-of-may-i)
|
- [three tiers](#three-tiers-of-may-i)
|
||||||
- [OAuth2 (Hydra)](#oauth2-provider-hydra)
|
- [OAuth2 (Hydra)](#oauth2-provider-hydra)
|
||||||
|
- [security model](#security-model)
|
||||||
- [Email](#email)
|
- [Email](#email)
|
||||||
- [Architecture](#architecture)
|
- [Architecture](#architecture)
|
||||||
- [Stateless](#stateless)
|
- [Stateless](#stateless)
|
||||||
@@ -123,14 +128,14 @@ and operational tools, dashboards, portals, or public sites with a gated area
|
|||||||
use or for a client. You know HTTP, Docker, and identity
|
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
|
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
|
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--permissions)) reads as
|
parts: if "Ory is down ⇒ no logins" (see [Auth](#auth-sessions--access)) reads as
|
||||||
obvious rather than surprising, you're the audience.
|
obvious rather than surprising, you're the audience.
|
||||||
|
|
||||||
**Included vs. what you add.**
|
**Included vs. what you add.**
|
||||||
|
|
||||||
- **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design
|
- **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design
|
||||||
system + app shell, the config-driven menu, sessions, and access control. No domain screens.
|
system + app shell, the config-driven menu, sessions, and access control. No domain screens.
|
||||||
- **Opt-in admin plugin:** the **users, groups, roles, and OAuth2-clients** screens (users via
|
- **Opt-in admin plugin:** the **users, groups, permissions, and OAuth2-clients** screens (users via
|
||||||
Kratos, the relationship graph via Keto, OAuth2 clients via Hydra) ship as
|
Kratos, the relationship graph via Keto, OAuth2 clients via Hydra) ship as
|
||||||
[`examples/plugins/admin/`](examples/plugins/admin/) — copy it into `plugins/` to get a GUI for
|
[`examples/plugins/admin/`](examples/plugins/admin/) — copy it into `plugins/` to get a GUI for
|
||||||
user & group admin. It's an ordinary plugin, using the privileged
|
user & group admin. It's an ordinary plugin, using the privileged
|
||||||
@@ -189,10 +194,142 @@ 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. |
|
| **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
|
No family combines the whole set: **[drop-in plugin folders](#building-plugins)**, a **zero-JS
|
||||||
server-rendered** design system, **[optional auth](#auth-sessions--permissions)** (any page
|
server-rendered** design system, **[optional auth](#auth-sessions--access)** (any page
|
||||||
public or gated), **no app database**, and a **framework-light TypeScript** core with no build
|
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.
|
step. Each neighbour shares one trait and trades away the rest — Plainpages is the intersection.
|
||||||
|
|
||||||
|
## Users, groups & permissions
|
||||||
|
|
||||||
|
Authorization here is two hops: a **user** — directly, or through a **group** — is granted a
|
||||||
|
**permission**, and that permission's *name* is exactly the string a plugin gates on.
|
||||||
|
|
||||||
|
- **Group** answers *who* — a reusable set of people. Optional: a permission can be granted
|
||||||
|
straight to a user.
|
||||||
|
- **Permission** answers *what* — its **name is the string** you write in a manifest's
|
||||||
|
`permission:` gate.
|
||||||
|
- **A relation tuple** is the grant: `Permission:<name>#granted@user:<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 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| **User** | Kratos | who you are | `user:0198f2c1-…` |
|
||||||
|
| **Group** | Keto | who — a reusable set | `Group:support` |
|
||||||
|
| **Permission** | Keto | what you may do | `Permission:scheduling:read` |
|
||||||
|
| **Resource** | Keto | which specific row | `Resource:shift-4471` |
|
||||||
|
|
||||||
|
Users 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).
|
||||||
|
|
||||||
|
> **Ory calls a user an "identity".** Kratos owns that record and names it so: its API is
|
||||||
|
> `/admin/identities`, and a session carries `session.identity`. Plainpages says **user**
|
||||||
|
> everywhere, because that is the word readers already know — and Ory's own documentation states
|
||||||
|
> it uses "identity" interchangeably with "users" and "accounts". You will meet Ory's spelling in
|
||||||
|
> exactly two places: the Kratos API itself, and the `Identity` type in `src/auth/kratos-admin.ts`
|
||||||
|
> that mirrors it.
|
||||||
|
|
||||||
|
> **There is no `Role`.** In RBAC a permission is a single operation ("read shifts") and a role is
|
||||||
|
> a *bundle* of them ("IT Support staff"). A route gates on one operation, so it gates on a
|
||||||
|
> **permission**. When you want the bundle, make a group and grant it several — groups nest, so a
|
||||||
|
> group of groups works too.
|
||||||
|
|
||||||
|
### A worked example
|
||||||
|
|
||||||
|
Alice works support and leads scheduling; Bob works support; Carol administers the system.
|
||||||
|
|
||||||
|
```
|
||||||
|
people groups permissions
|
||||||
|
────── ────── ───────────
|
||||||
|
|
||||||
|
alice ──┬─────────> Group:support ────┐
|
||||||
|
│ ├──> Group:staff ──> Permission:scheduling:read
|
||||||
|
bob ────┘ │
|
||||||
|
│
|
||||||
|
alice ────────────> Group:sched-leads ┴──> Permission:scheduling:write
|
||||||
|
|
||||||
|
carol ───────────────────────────────────────────────> Permission:admin
|
||||||
|
```
|
||||||
|
|
||||||
|
At login the host asks Keto which permissions 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 → permissions: ["scheduling:read", "scheduling:write"]
|
||||||
|
bob → permissions: ["scheduling:read"]
|
||||||
|
carol → permissions: ["admin"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Note what Carol does *not* have. **Permissions do not nest, and there is 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 permission gets the 403 page,
|
||||||
|
because there is nothing to sign in *as* that would help. The menu is filtered by the same
|
||||||
|
permissions, so nobody is shown a door they cannot open.
|
||||||
|
|
||||||
|
### Granting a permission
|
||||||
|
|
||||||
|
Write the tuple. The admin plugin's **Groups** and **Permissions** 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": "Permission", "object": "scheduling:write", "relation": "granted",
|
||||||
|
"subject_set": { "namespace": "Group", "object": "sched-leads", "relation": "members" }
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Permissions are authored **only in Keto** — nothing else writes them. Their 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 coarse permission cannot express: *this* row, shared with
|
||||||
|
*this* person. It is a separate mechanism — a `Resource` carries Keto `permits` (`view`, `edit`,
|
||||||
|
`delete`, which nest as `owner` ⊇ `editor` ⊇ `viewer`) and never appears in the JWT.
|
||||||
|
|
||||||
|
**A per-row grant never widens a coarse gate.** The route's `permission` 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", permission: 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
|
## Building plugins
|
||||||
|
|
||||||
A plugin is a self-contained folder under `plugins/` that the host discovers at boot — no
|
A plugin is a self-contained folder under `plugins/` that the host discovers at boot — no
|
||||||
@@ -205,7 +342,7 @@ the host enforces. A complete, runnable example lives in
|
|||||||
**[`examples/plugins/scheduling/`](examples/plugins/scheduling/)** — a public overview page, a
|
**[`examples/plugins/scheduling/`](examples/plugins/scheduling/)** — a public overview page, a
|
||||||
permission-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
|
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/`
|
writes upstream, and a mix of public + permission-gated nav. It is **not** pre-installed — `plugins/`
|
||||||
ships empty so you mount your own. To run it in dev, copy it in
|
ships empty so you mount your own. To run it in dev, copy it in
|
||||||
(`cp -r examples/plugins/scheduling plugins/scheduling`, then restart) — the dev compose already
|
(`cp -r examples/plugins/scheduling plugins/scheduling`, then restart) — the dev compose already
|
||||||
points `SCHEDULING_UPSTREAM` at its mock backend. Copy it to `plugins/<id>/` and adapt.
|
points `SCHEDULING_UPSTREAM` at its mock backend. Copy it to `plugins/<id>/` and adapt.
|
||||||
@@ -236,7 +373,7 @@ single `plugin.ts`.
|
|||||||
must be **URL/path-safe** (`isValidPluginId`: lowercase `a–z`, digits, and dashes — dashes
|
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
|
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
|
at discovery. The id also namespaces the plugin's `views/`, its `/public/<id>/` assets, and (by
|
||||||
convention) its nav/permission tokens.
|
convention) its nav/permission names.
|
||||||
|
|
||||||
A handful of ids are **reserved** for the host's own first-party mounts — the gated `dashboard`, the
|
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`),
|
Kratos auth flows (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`),
|
||||||
@@ -272,10 +409,10 @@ export default definePlugin({
|
|||||||
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
|
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
|
||||||
nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }],
|
nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }],
|
||||||
|
|
||||||
// Permission tokens this plugin introduces. Optional — see Nav & permissions.
|
// Permissions this plugin gates on. Optional — see Nav & permission gates.
|
||||||
permissions: [
|
permissions: [
|
||||||
{ token: "things:read", description: "View things" },
|
{ description: "View things", name: "things:read" },
|
||||||
{ token: "things:write", description: "Create and edit things" },
|
{ description: "Create and edit things", name: "things:write" },
|
||||||
],
|
],
|
||||||
|
|
||||||
// Route handlers, mounted under the plugin's path (/things). `permission` gates first.
|
// Route handlers, mounted under the plugin's path (/things). `permission` gates first.
|
||||||
@@ -298,7 +435,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). |
|
| `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
||||||
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
||||||
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. |
|
| `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. |
|
||||||
| `permissions` | no | Tokens this plugin introduces. See [Nav & permissions](#nav--permissions). |
|
| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). |
|
||||||
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
||||||
| `hooks` | no | See [Hooks](#hooks). |
|
| `hooks` | no | See [Hooks](#hooks). |
|
||||||
|
|
||||||
@@ -309,11 +446,11 @@ A plugin may be routes-only, nav-only, or hooks-only — every collection field
|
|||||||
A route is `{ method, path, permission?, 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
|
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`,
|
matches `method` + the resolved full path, extracts `:name` segments into `ctx.params.name`,
|
||||||
runs the `permission` gate (a coarse JWT-claim check — see [Nav & permissions](#nav--permissions)),
|
runs the `permission` gate (a coarse JWT-claim check — see [Nav & permission gates](#nav--permission-gates)),
|
||||||
and only then calls the handler with the [request context](#requestcontext). When the gate fails, an
|
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
|
**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
|
requested page is preserved as `return_to`, so after signing in they land **back on the page they
|
||||||
asked for**, not the dashboard. A **signed-in** user who simply lacks the role gets the **403** page.
|
asked for**, not the dashboard. A **signed-in** user who simply lacks the permission gets the **403** page.
|
||||||
A route marked **`public: true`** has no gate at all — anyone reaches it (see [Public pages & menu
|
A route marked **`public: true`** has no gate at all — anyone reaches it (see [Public pages & menu
|
||||||
items](#public-pages--menu-items)).
|
items](#public-pages--menu-items)).
|
||||||
|
|
||||||
@@ -356,7 +493,7 @@ export async function listThings(ctx: RequestContext) {
|
|||||||
extra stylesheet hrefs) — see the reference's `views/shifts.ejs`.
|
extra stylesheet hrefs) — see the reference's `views/shifts.ejs`.
|
||||||
- **Finer authorization than the route `permission`** 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
|
`requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect
|
||||||
to sign in), `can(ctx, role)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx,
|
to sign in), `can(ctx, permission)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx,
|
||||||
{namespace, object, relation})` (a live Keto check for relationship rules — the subject is the
|
{namespace, object, relation})` (a live Keto check for relationship rules — the subject is the
|
||||||
signed-in user, anonymous ⇒ denied). Throw `new GuardError(403, …)` after a failed `can`/`check`
|
signed-in user, anonymous ⇒ denied). Throw `new GuardError(403, …)` after a failed `can`/`check`
|
||||||
to render the 403 page.
|
to render the 403 page.
|
||||||
@@ -412,7 +549,7 @@ a signed-in visitor, or sign-in / register to an anonymous one). After login the
|
|||||||
points there.
|
points there.
|
||||||
|
|
||||||
For the gated `dashboard`, the host enforces the session gate first, so `ctx.user` 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
|
branch on `ctx.permissions` *inside* to tailor the page per permission. Don't gate `dashboard` itself behind a
|
||||||
single permission — 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`.)
|
403. (Both slots answer `GET` and `HEAD`.)
|
||||||
|
|
||||||
@@ -429,15 +566,15 @@ request:
|
|||||||
```ts
|
```ts
|
||||||
interface RequestContext {
|
interface RequestContext {
|
||||||
chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell
|
chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell
|
||||||
|
user: User | null; // { id, email, permissions } from the verified session JWT, or null
|
||||||
log: Log; // request-scoped logger, in this request's trace
|
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 }
|
params: Record<string, string>; // path params from the route match, e.g. /things/:id → { id }
|
||||||
query: URLSearchParams; // alias of url.searchParams
|
query: URLSearchParams; // alias of url.searchParams
|
||||||
req: IncomingMessage;
|
req: IncomingMessage;
|
||||||
res: ServerResponse;
|
res: ServerResponse;
|
||||||
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
|
permissions: string[]; // user?.permissions ?? [] — 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
|
system?: SystemCapabilities; // privileged Ory clients + instant-revoke, for a system plugin (see below); undefined unless the host wired them
|
||||||
url: URL;
|
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
|
verifyCsrf(submitted): boolean; // gate a form POST against the request's signed CSRF cookie
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -446,7 +583,7 @@ interface RequestContext {
|
|||||||
theme, user }`. Hand it to `partials/shell` so a `view` result renders the **native app shell** (the same
|
theme, user }`. Hand it to `partials/shell` so a `view` result renders the **native app shell** (the same
|
||||||
sidebar, branding, theme switch and signed-in profile every page uses); `chrome.nav` is the
|
sidebar, branding, theme switch and signed-in profile every page uses); `chrome.nav` is the
|
||||||
global menu — your plugin's nav fragment plus every other installed plugin's (the admin section among
|
global menu — your plugin's nav fragment plus every other installed plugin's (the admin section among
|
||||||
them, when that plugin is present) — already composed, role-filtered, and current-marked for this
|
them, when that plugin is present) — already composed, permission-filtered, and current-marked for this
|
||||||
request (the gated **Dashboard** link is omitted for an
|
request (the gated **Dashboard** link is omitted for an
|
||||||
anonymous visitor). `chrome.signInHref` is where the shell's anonymous **Sign in** link points — the
|
anonymous visitor). `chrome.signInHref` is where the shell's anonymous **Sign in** link points — the
|
||||||
current page baked in as `return_to`. Map each `chrome.*` to the matching `partials/shell` local —
|
current page baked in as `return_to`. Map each `chrome.*` to the matching `partials/shell` local —
|
||||||
@@ -455,10 +592,11 @@ 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
|
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
|
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
|
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. (See the reference: `examples/plugins/scheduling/`.)
|
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/`.)
|
||||||
|
|
||||||
The same shell renders **every** page (the dashboard, your plugin pages — the admin plugin's included, and the
|
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.
|
login/registration/front pages), so the menu looks identical signed in or out — it just permission-filters.
|
||||||
A page that wants a focused, chrome-free layout passes **`menu: false`** to `partials/shell` (drops the
|
A page that wants a focused, chrome-free layout passes **`menu: false`** to `partials/shell` (drops the
|
||||||
sidebar, single column); everything else still renders.
|
sidebar, single column); everything else still renders.
|
||||||
|
|
||||||
@@ -475,7 +613,7 @@ OpenTelemetry Collector when `OTLP_ENDPOINT` is set).
|
|||||||
**Stability guarantee.** The fields above are the stable contract — present and non-breaking
|
**Stability guarantee.** The fields above are the stable contract — present and non-breaking
|
||||||
across a major `apiVersion`. New fields may be **added** within a major version (additive, never
|
across a major `apiVersion`. New fields may be **added** within a major version (additive, never
|
||||||
breaking). `req`/`res` are the raw Node objects and the full escape hatch; reading them is fine,
|
breaking). `req`/`res` are the raw Node objects and the full escape hatch; reading them is fine,
|
||||||
but prefer the typed fields so a handler keeps working as the host evolves. `user`/`roles` come
|
but prefer the typed fields so a handler keeps working as the host evolves. `user`/`permissions` come
|
||||||
from the JWT middleware and are `null`/`[]` until a session exists.
|
from the JWT middleware and are `null`/`[]` until a session exists.
|
||||||
|
|
||||||
### System capabilities (the `ctx.system` surface)
|
### System capabilities (the `ctx.system` surface)
|
||||||
@@ -489,7 +627,7 @@ needs the host's Ory admin clients and the instant-revoke hook instead. The host
|
|||||||
```ts
|
```ts
|
||||||
interface SystemCapabilities { // every field optional — present only when the host wired it
|
interface SystemCapabilities { // every field optional — present only when the host wired it
|
||||||
hydra?: HydraAdmin; // OAuth2 client admin (register/list/delete Hydra clients)
|
hydra?: HydraAdmin; // OAuth2 client admin (register/list/delete Hydra clients)
|
||||||
keto?: KetoClient; // relationship read/write (groups, roles)
|
keto?: KetoClient; // relationship read/write (groups, permissions)
|
||||||
kratosAdmin?: KratosAdmin; // identity admin (create/edit/deactivate/delete users)
|
kratosAdmin?: KratosAdmin; // identity admin (create/edit/deactivate/delete users)
|
||||||
revoke?: (sub: string) => void; // instant-revoke a subject's live tokens (needs the denylist)
|
revoke?: (sub: string) => void; // instant-revoke a subject's live tokens (needs the denylist)
|
||||||
}
|
}
|
||||||
@@ -499,20 +637,20 @@ interface SystemCapabilities { // every field optional — present only
|
|||||||
Hydra configured, the [revocation denylist](#instant-revoke-the-optional-denylist) enabled). A system
|
Hydra configured, the [revocation denylist](#instant-revoke-the-optional-denylist) enabled). A system
|
||||||
plugin treats every field as optional and **degrades when absent** — the host never fails a request
|
plugin treats every field as optional and **degrades when absent** — the host never fails a request
|
||||||
over it. The built-in **admin plugin** ([`examples/plugins/admin/`](examples/plugins/admin/)) is the
|
over it. The built-in **admin plugin** ([`examples/plugins/admin/`](examples/plugins/admin/)) is the
|
||||||
reference consumer: its Users screen uses `ctx.system.kratosAdmin`, Groups/Roles use `ctx.system.keto`,
|
reference consumer: its Users screen uses `ctx.system.kratosAdmin`, Groups/Permissions use `ctx.system.keto`,
|
||||||
OAuth2 clients use `ctx.system.hydra`, and a deactivate/delete or user role-change calls
|
OAuth2 clients use `ctx.system.hydra`, and a deactivate/delete or user permission-change calls
|
||||||
`ctx.system.revoke` so the change lands now instead of after the JWT TTL; where a capability is missing
|
`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.
|
the screen renders a themed 503.
|
||||||
|
|
||||||
This is a **privileged** surface — it hands a plugin the keys to identity and permissions. It's meant
|
This is a **privileged** surface — it hands a plugin the keys to identity and authorization. It's meant
|
||||||
for first-party system plugins you author or vendor, the same trust level as any plugin (the host
|
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.
|
doesn't sandbox — [crash-isolation is a non-goal](#overview)). An ordinary domain plugin ignores it.
|
||||||
|
|
||||||
### Nav & permissions
|
### Nav & permission gates
|
||||||
|
|
||||||
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
|
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
|
||||||
applies the central override and then **filters per user** by the roles in the session JWT — a
|
applies the central override and then **filters per user** by the permissions in the session JWT — a
|
||||||
node shows iff it is `public`, declares no `permission`, or the user's roles include that token. Use
|
node shows iff it is `public`, declares no `permission`, or the user's permissions include that name. Use
|
||||||
arbitrary depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` is a
|
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
|
**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.
|
are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there.
|
||||||
@@ -520,30 +658,26 @@ are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its luci
|
|||||||
#### Public pages & menu items
|
#### Public pages & menu items
|
||||||
|
|
||||||
A route or nav node may be marked **`public: true`** — reachable by **anyone, signed in or not**,
|
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 `permission` (a no-permission
|
and the menu item shows for everyone. This is the same as omitting `permission` (an ungated
|
||||||
route/node is already open) but stated outright, so "public" is a **deliberate choice, not the
|
route/node is already open) but stated outright, so "public" is a **deliberate choice, not the
|
||||||
accident of a forgotten gate**. `public` and `permission` 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.
|
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
|
A public page still renders in the native shell via `ctx.chrome`; for an anonymous visitor
|
||||||
`ctx.user` 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
|
in place of the profile/sign-out block, the gated **Dashboard** link is hidden, and `ctx.permissions` is
|
||||||
empty (read a role with `can(ctx, …)` to branch). The reference plugin's `/scheduling`
|
empty (read a permission 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,
|
**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`.
|
while the actual shifts list stays behind `scheduling:read`.
|
||||||
|
|
||||||
**A `permission` token is a coarse role.** The route/nav gate passes iff the user's JWT `roles`
|
The gate passes iff the user's JWT `permissions` include that name. How permissions are granted, why their
|
||||||
include the token; those roles come from Keto at login, so an operator grants a token by writing the
|
names are a shared global namespace, and the fine-grained per-row tier are all covered in
|
||||||
Keto tuple `Role:<token>#members@user:<id>` (or to a group) — the admin **Roles** screen does this.
|
[Users, groups & permissions](#users-groups--permissions).
|
||||||
(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.)
|
|
||||||
|
|
||||||
Permission tokens are a **shared global namespace** — that's deliberate, so an operator grants
|
Declaring the ones you gate on in `permissions` is **optional but recommended**: it documents them,
|
||||||
`scheduling:read` once in Keto and every plugin referencing it is gated consistently. Namespace
|
feeds conflict detection, and lets the one-command bootstrap seed them — the demo admin is
|
||||||
your tokens as `<id>:<action>` to avoid accidental clashes. Declaring them in `permissions` is
|
granted every discovered plugin's declared permissions, so a dropped-in plugin works out of the box
|
||||||
optional but recommended: it documents them, feeds conflict detection, and lets the one-command
|
without editing host config.
|
||||||
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
|
### Contract versioning
|
||||||
|
|
||||||
@@ -578,7 +712,7 @@ 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. |
|
| `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. |
|
| `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)). |
|
| `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)). |
|
||||||
| `permission` | warn | A permission token is declared by more than one plugin. Sharing is legitimate (shared role); namespace as `<id>:<action>` if unintended. |
|
| `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; namespace as `<id>:<action>` if unintended. |
|
||||||
|
|
||||||
There is **no separate `basePath` rule**: the mount path is the derived `/<id>`, so its
|
There is **no separate `basePath` rule**: the mount path is the derived `/<id>`, so its
|
||||||
uniqueness follows from the id check. `permission` is the one intentional overlap, so it warns
|
uniqueness follows from the id check. `permission` is the one intentional overlap, so it warns
|
||||||
@@ -713,11 +847,11 @@ The menu is **driven entirely by config** and assembled from two sources:
|
|||||||
```
|
```
|
||||||
|
|
||||||
Every nav item may carry a `permission`; 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
|
reading the permissions in the session JWT (no per-request authz call — see
|
||||||
[Auth, sessions & permissions](#auth-sessions--permissions)), so the menu only ever shows
|
[Auth, sessions & access](#auth-sessions--access)), so the menu only ever shows
|
||||||
what that person can reach. An item (or a whole page) may instead be marked **`public:
|
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
|
true`** to show it to **everyone, signed in or not** — the blessed, explicit way to expose
|
||||||
a public page and its menu entry (a no-permission item is already public; `public` just
|
a public page and its menu entry (an ungated item is already public; `public` just
|
||||||
says so on purpose, and is mutually exclusive with `permission`). The markup is the
|
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,
|
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 —
|
counts, arbitrary depth). Branding (name, logo, default theme) renders in the app shell —
|
||||||
@@ -743,7 +877,7 @@ set of reusable EJS partials + TS helpers, fully styled and zero-JS:
|
|||||||
- **Helpers:** `composeNav` (menu from config), `parseListQuery`
|
- **Helpers:** `composeNav` (menu from config), `parseListQuery`
|
||||||
(`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math), and the
|
(`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math), and the
|
||||||
auth guards a handler calls to authorize (`src/auth/guards.ts`): `requireSession` (assert a
|
auth guards a handler calls to authorize (`src/auth/guards.ts`): `requireSession` (assert a
|
||||||
session — a `GuardError` the host turns into a redirect to sign in), `can(role)` (a coarse
|
session — a `GuardError` the host turns into a redirect to sign in), `can(permission)` (a coarse
|
||||||
JWT-claim check, zero I/O), `check(relation, object)` (the one live Keto call, for
|
JWT-claim check, zero I/O), `check(relation, object)` (the one live Keto call, for
|
||||||
relationship rules).
|
relationship rules).
|
||||||
|
|
||||||
@@ -785,13 +919,13 @@ 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_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` |
|
| `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) |
|
| `KRATOS_PUBLIC_URL` / `KRATOS_ADMIN_URL` | `http://kratos:4433` / `:4434` | identity (self-service / admin) |
|
||||||
| `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | permission check / write |
|
| `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | authorization check / write |
|
||||||
| `HYDRA_ADMIN_URL` | `http://hydra:4445` | OAuth2 provider admin API (login/consent handshake) |
|
| `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 |
|
| `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) |
|
| `JWT_ISSUER` / `JWT_AUDIENCE` | _unset_ | optional: when set, the session JWT's `iss` / `aud` must match (the dev tokenizer sets neither) |
|
||||||
| `JWT_CLOCK_SKEW_SEC` | `60` | exp/nbf leeway (s) for Kratos↔web clock drift (the auth E2E sets `0`) |
|
| `JWT_CLOCK_SKEW_SEC` | `60` | exp/nbf leeway (s) for Kratos↔web clock drift (the auth E2E sets `0`) |
|
||||||
| `ORY_TIMEOUT_SEC` | `5` | per-call timeout for outbound Kratos/Keto/Hydra (and http JWKS) fetches, so a hung Ory can't park a request |
|
| `ORY_TIMEOUT_SEC` | `5` | per-call timeout for outbound Kratos/Keto/Hydra (and http JWKS) fetches, so a hung Ory can't park a request |
|
||||||
| `REVOCATION_DENYLIST` | `false` | when `true`, enable the optional [instant role/session revoke denylist](#instant-revoke-the-optional-denylist) |
|
| `REVOCATION_DENYLIST` | `false` | when `true`, enable the optional [instant permission/session revoke denylist](#instant-revoke-the-optional-denylist) |
|
||||||
| `REVOCATION_TTL_SEC` | `900` | how long a revoke entry lives; keep ≥ tokenizer TTL (10m) + clock skew |
|
| `REVOCATION_TTL_SEC` | `900` | how long a revoke entry lives; keep ≥ tokenizer TTL (10m) + clock skew |
|
||||||
| `CSRF_SECRET` | dev throwaway | signs our double-submit CSRF token; enforced by `REQUIRE_SECURE_SECRETS` |
|
| `CSRF_SECRET` | dev throwaway | signs our double-submit CSRF token; enforced by `REQUIRE_SECURE_SECRETS` |
|
||||||
|
|
||||||
@@ -836,20 +970,35 @@ 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
|
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
|
with dev-throwaway secrets, an auto-generated signing key, and a seeded admin (see
|
||||||
[Quick start](#quick-start)). Exactly **two** things can't be auto-generated, and **both
|
[Quick start](#quick-start)). What can't be auto-generated is **production-only** — none of it
|
||||||
are production-only** — neither blocks a clean clone:
|
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.
|
||||||
|
|
||||||
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.
|
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
|
Supplying a provider's creds via env activates it; no creds ⇒ no SSO button (see
|
||||||
[Social sign-in (SSO)](#social-sign-in-sso)).
|
[Social sign-in (SSO)](#social-sign-in-sso)).
|
||||||
|
|
||||||
Everything else is generated or seeded on first boot — Ory migrations, the dev signing key,
|
Everything else is generated or seeded on first boot — Ory migrations, the dev signing key,
|
||||||
the demo admin identity and its Keto roles, the Keto OPL model — so there is nothing else to
|
the demo admin identity and its Keto permissions, the Keto OPL model — so there is nothing else to
|
||||||
hand-configure.
|
hand-configure.
|
||||||
|
|
||||||
### Social sign-in (SSO)
|
### Social sign-in (SSO)
|
||||||
@@ -864,7 +1013,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
|
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.
|
(Ory Polis) and register that bridge as a generic OIDC provider the same way.
|
||||||
|
|
||||||
## Auth, sessions & permissions
|
## Auth, sessions & access
|
||||||
|
|
||||||
Identity comes from **Kratos**; the hot path stays I/O-free by carrying coarse authorization
|
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,
|
in a **locally-validated JWT**, and **Keto** is reserved for the rare fine-grained,
|
||||||
@@ -883,32 +1032,32 @@ the session for a signed JWT once** via the Kratos **session tokenizer** (`whoam
|
|||||||
```
|
```
|
||||||
── AT LOGIN / REFRESH (the only time Ory is on the path) ──────────
|
── AT LOGIN / REFRESH (the only time Ory is on the path) ──────────
|
||||||
Kratos verifies credentials
|
Kratos verifies credentials
|
||||||
└─► app reads the user's roles from Keto (direct + transitive via groups)
|
└─► app reads the user's permissions from Keto (direct + transitive via groups)
|
||||||
└─► app writes them as a derived projection on the identity (admin API)
|
└─► app writes them as a derived projection on the identity (admin API)
|
||||||
└─► whoami(tokenize_as: "plainpages") ─► signed JWT
|
└─► whoami(tokenize_as: "plainpages") ─► signed JWT
|
||||||
claims: { sub, email, roles:[…from Keto], exp ≈ 10m }
|
claims: { sub, email, permissions:[…from Keto], exp ≈ 10m }
|
||||||
└─► stored as the session cookie
|
└─► stored as the session cookie
|
||||||
|
|
||||||
── EVERY REQUEST (hot path — pure CPU, no I/O) ───────────────────
|
── EVERY REQUEST (hot path — pure CPU, no I/O) ───────────────────
|
||||||
Browser ─cookie(JWT)─► web : verify signature (cached JWKS)
|
Browser ─cookie(JWT)─► web : verify signature (cached JWKS)
|
||||||
read claims.roles
|
read claims.permissions
|
||||||
filter menu · gate routes
|
filter menu · gate routes
|
||||||
```
|
```
|
||||||
|
|
||||||
**Keto is the single source of truth for roles.** Coarse roles are Keto relations (e.g.
|
**Keto is the single source of truth for permissions.** Coarse permissions are Keto relations (e.g.
|
||||||
`role:admin#members@user:alice`); the admin screens write them *only* to Keto. But the
|
`Permission:admin#granted@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
|
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
|
app reads the permissions 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
|
written onto the identity's `metadata_public` for the tokenizer to see, which the template
|
||||||
maps into the JWT `roles` claim. (It must be `metadata_public`, not `metadata_admin`: the
|
maps into the JWT `permissions` claim. (It must be `metadata_public`, not `metadata_admin`: the
|
||||||
session Kratos hands the tokenizer carries only *public* metadata — and the user can already
|
session Kratos hands the tokenizer carries only *public* metadata — and the user can already
|
||||||
read these coarse roles in their own JWT, so nothing is leaked.) That projection is a
|
read these coarse permissions in their own JWT, so nothing is leaked.) That projection is a
|
||||||
per-login cache, authoritative nowhere; nothing edits it by hand, and a stale one self-heals
|
per-login cache, authoritative nowhere; nothing edits it by hand, and a stale one self-heals
|
||||||
on the next login.
|
on the next login.
|
||||||
|
|
||||||
A role can be granted to a user directly or to a **group** the user belongs to; login
|
A permission can be granted to a user directly or to a **group** the user belongs to; login
|
||||||
resolves both (enumerate the defined roles, ask Keto to resolve each membership), so the JWT
|
resolves both (enumerate the defined permissions, ask Keto to resolve each membership), so the JWT
|
||||||
`roles` match what the admin **Effective access** view shows.
|
`permissions` match what the admin **Effective access** view shows.
|
||||||
|
|
||||||
Cost: **a handful of Keto reads + one identity refresh per login** — never per request. JWKS
|
Cost: **a handful of Keto reads + one identity refresh per login** — never per request. JWKS
|
||||||
is cached, so even signature verification hits the network only on key rotation. The app
|
is cached, so even signature verification hits the network only on key rotation. The app
|
||||||
@@ -920,8 +1069,8 @@ recomputed from Keto.
|
|||||||
This design buys an I/O-free hot path that scales to **tens of thousands of concurrent
|
This design buys an I/O-free hot path that scales to **tens of thousands of concurrent
|
||||||
users** on modest hardware. In return:
|
users** on modest hardware. In return:
|
||||||
|
|
||||||
- **Role changes lag by up to one TTL (~10m).** Gating reads the JWT, not Keto, so a granted
|
- **Permission changes lag by up to one TTL (~10m).** Gating reads the JWT, not Keto, so a granted
|
||||||
or revoked role only takes effect when the token is next minted (re-login or TTL refresh).
|
or revoked permission only takes effect when the token is next minted (re-login or TTL refresh).
|
||||||
For an admin tool this is intentional — the alternative is a Keto call per request, which
|
For an admin tool this is intentional — the alternative is a Keto call per request, which
|
||||||
we traded away. For instant revoke, turn on the optional
|
we traded away. For instant revoke, turn on the optional
|
||||||
[revocation denylist](#instant-revoke-the-optional-denylist) — it closes the gap for
|
[revocation denylist](#instant-revoke-the-optional-denylist) — it closes the gap for
|
||||||
@@ -934,12 +1083,12 @@ users** on modest hardware. In return:
|
|||||||
### Instant revoke: the optional denylist
|
### Instant revoke: the optional denylist
|
||||||
|
|
||||||
Off by default; turn it on with `REVOCATION_DENYLIST=true` (`src/auth/denylist.ts`). For
|
Off by default; turn it on with `REVOCATION_DENYLIST=true` (`src/auth/denylist.ts`). For
|
||||||
security-critical revoke (offboarding, a compromised account) the ~10m role/session lag
|
security-critical revoke (offboarding, a compromised account) the ~10m permission/session lag
|
||||||
above is too long. When enabled, an admin **deactivating** or **deleting** a user, or
|
above is too long. When enabled, an admin **deactivating** or **deleting** a user, or
|
||||||
**granting/revoking** a role to a *user*, records that subject as revoked-now; the hot path
|
**granting/revoking** a permission to a *user*, records that subject as revoked-now; the hot path
|
||||||
then rejects every token for it minted **before** the revoke and forces a re-mint — which
|
then rejects every token for it minted **before** the revoke and forces a re-mint — which
|
||||||
re-reads roles from Keto, or clears a now-dead session. A fresh re-login (its JWT issued
|
re-reads permissions from Keto, or clears a now-dead session. A fresh re-login (its JWT issued
|
||||||
*after* the revoke) passes, so a role downgrade lands immediately without locking the
|
*after* the revoke) passes, so a permission downgrade lands immediately without locking the
|
||||||
account.
|
account.
|
||||||
|
|
||||||
It's an in-memory, auto-evicting map — no database, like the JWKS cache, so it stays inside
|
It's an in-memory, auto-evicting map — no database, like the JWKS cache, so it stays inside
|
||||||
@@ -949,10 +1098,13 @@ CPU — **Keto stays off the hot path**. Two deliberate bounds: it's instant on
|
|||||||
instance** that handled the revoke (across replicas/restarts the guarantee falls back to the
|
instance** that handled the revoke (across replicas/restarts the guarantee falls back to the
|
||||||
token TTL — back the denylist with a shared store for hard multi-instance instant-revoke),
|
token TTL — back the denylist with a shared store for hard multi-instance instant-revoke),
|
||||||
and a **group** membership change is transitive across many users, so it's left to lag —
|
and a **group** membership change is transitive across many users, so it's left to lag —
|
||||||
deactivate the user, or use a direct user-role change, for an instant effect.
|
deactivate the user, or use a direct user-permission change, for an instant effect.
|
||||||
|
|
||||||
### Three tiers of "may I?"
|
### Three tiers of "may I?"
|
||||||
|
|
||||||
|
[Users, groups & permissions](#users-groups--permissions) covers *what* the entities are; this is where each
|
||||||
|
**kind** of rule belongs.
|
||||||
|
|
||||||
```
|
```
|
||||||
coarse (menu / route / feature) → JWT claim · in-process, zero I/O
|
coarse (menu / route / feature) → JWT claim · in-process, zero I/O
|
||||||
fine + attribute (owner / tenant / …) → upstream service that owns the row
|
fine + attribute (owner / tenant / …) → upstream service that owns the row
|
||||||
@@ -967,10 +1119,8 @@ 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
|
is for. Reserve it for those; don't pay its tuple-sync cost for rules a service can already
|
||||||
answer from its own data.
|
answer from its own data.
|
||||||
|
|
||||||
The built-in users / groups / permissions screens write authorization **only to Keto** —
|
The admin plugin's users / groups / permissions screens write authorization **only to Keto** — coarse
|
||||||
coarse roles and fine-grained relationships alike. Roles reach the JWT by being read from
|
permissions and fine-grained relationships alike.
|
||||||
Keto at login and projected through the tokenizer (above); nothing authors them anywhere
|
|
||||||
else.
|
|
||||||
|
|
||||||
### OAuth2 provider (Hydra)
|
### OAuth2 provider (Hydra)
|
||||||
|
|
||||||
@@ -997,6 +1147,42 @@ 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;
|
delete. Confidential vs public (PKCE) and the first-party auto-consent flag are set at registration;
|
||||||
writes go only to Hydra.
|
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 `permissions`. `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 permissions from Keto — so a revoked permission, 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: permission 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
|
## Email
|
||||||
|
|
||||||
The only emails are the **recovery** and **verification** codes from Kratos' self-service
|
The only emails are the **recovery** and **verification** codes from Kratos' self-service
|
||||||
@@ -1025,7 +1211,7 @@ docs for the full template-type list and the data each template receives.
|
|||||||
|
|
||||||
Plainpages runs as a small set of containers, orchestrated by Docker Compose:
|
Plainpages runs as a small set of containers, orchestrated by Docker Compose:
|
||||||
|
|
||||||
| Container | Role |
|
| Container | Permission |
|
||||||
| -------------- | ---- |
|
| -------------- | ---- |
|
||||||
| `web` | The Node 24 + TypeScript app: server-rendered EJS, the plugin host, the building-block partials. Stays tiny. |
|
| `web` | The Node 24 + TypeScript app: server-rendered EJS, the plugin host, the building-block partials. Stays tiny. |
|
||||||
| `kratos` | **Ory Kratos** — identity: login, registration, password reset, SSO, sessions. |
|
| `kratos` | **Ory Kratos** — identity: login, registration, password reset, SSO, sessions. |
|
||||||
@@ -1035,12 +1221,12 @@ Plainpages runs as a small set of containers, orchestrated by Docker Compose:
|
|||||||
|
|
||||||
The `web` app is an Ory **relying party**: it never stores passwords. At login it turns
|
The `web` app is an Ory **relying party**: it never stores passwords. At login it turns
|
||||||
the Kratos session into a short-lived, **locally-validated JWT** (the Kratos session
|
the Kratos session into a short-lived, **locally-validated JWT** (the Kratos session
|
||||||
tokenizer) carrying the user's coarse roles — so every later request gates the menu and
|
tokenizer) carrying the user's coarse permissions — so every later request gates the menu and
|
||||||
pages by **verifying the JWT in-process, with no per-request call to Ory**. Keto answers
|
pages by **verifying the JWT in-process, with no per-request call to Ory**. Keto answers
|
||||||
the rarer fine-grained checks; Hydra is used only when the app acts as an OAuth2 **login &
|
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
|
consent provider** for other apps. It reaches the Ory services over their **REST APIs
|
||||||
using Node's built-in `fetch`** — no SDK dependency. See
|
using Node's built-in `fetch`** — no SDK dependency. See
|
||||||
[Auth, sessions & permissions](#auth-sessions--permissions).
|
[Auth, sessions & access](#auth-sessions--access).
|
||||||
|
|
||||||
In **dev** the host-facing Ory ports are published — Kratos public `4433` (where the browser
|
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
|
POSTs self-service flows) and Hydra public `4444`; **prod** (`docker compose -f compose.yml
|
||||||
@@ -1096,7 +1282,7 @@ docker compose -f compose.yml -f e2e-tests/compose.visual.yml down -v
|
|||||||
boots the real Ory stack (Postgres + Kratos + Keto + bootstrap), shortens the session→JWT TTL
|
boots the real Ory stack (Postgres + Kratos + Keto + bootstrap), shortens the session→JWT TTL
|
||||||
to 8s (`ory/kratos/e2e.yml`) and sets `JWT_CLOCK_SKEW_SEC=0`, then logs in the seeded admin
|
to 8s (`ory/kratos/e2e.yml`) and sets `JWT_CLOCK_SKEW_SEC=0`, then logs in the seeded admin
|
||||||
and proves the "stay signed in" hot path: the lapsed JWT is silently **re-minted** from the
|
and proves the "stay signed in" hot path: the lapsed JWT is silently **re-minted** from the
|
||||||
live Kratos session (roles re-read from Keto), and once that session is revoked the stale
|
live Kratos session (permissions re-read from Keto), and once that session is revoked the stale
|
||||||
cookie is **cleared**.
|
cookie is **cleared**.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -1118,8 +1304,9 @@ 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:
|
**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,
|
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, a
|
`e2e-tests/mock-oidc.ts`), **menu filtering by permission**, the **users/groups/permissions** admin CRUD, the
|
||||||
permission-gated **plugin page**, and **logout**. Because the themed form posts straight to
|
**OAuth2-clients** admin screen (register → one-time secret → delete; Hydra is part of this stack
|
||||||
|
for it), 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 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
|
Kratos on one host (`ory/kratos/e2e-proxy.yml` points Kratos at it) — exactly as a production
|
||||||
reverse proxy would.
|
reverse proxy would.
|
||||||
@@ -1163,7 +1350,7 @@ bash ci.sh
|
|||||||
```
|
```
|
||||||
|
|
||||||
Each E2E suite **owns a clean stack** — never point two suites at one backend (auth-refresh
|
Each E2E suite **owns a clean stack** — never point two suites at one backend (auth-refresh
|
||||||
revokes the admin's sessions; full-flow writes users/groups/roles to Keto), which is why the
|
revokes the admin's sessions; full-flow writes users/groups/permissions to Keto), which is why the
|
||||||
gate runs them serially, one stack up/down per suite.
|
gate runs them serially, one stack up/down per suite.
|
||||||
|
|
||||||
## CI/CD
|
## CI/CD
|
||||||
@@ -1173,8 +1360,11 @@ Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs
|
|||||||
|
|
||||||
| Workflow | Trigger | Does |
|
| Workflow | Trigger | Does |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`), then build + push the app image |
|
| `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 |
|
||||||
| `mirror.yml` | push to `main`, or manual | force-push `main` + tags to the [GitHub mirror](https://github.com/larvit/plainpages) |
|
| `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 |
|
||||||
|
| `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut one release tag for the run |
|
||||||
|
|
||||||
`main` is not re-tested on push — its commits are meant to arrive already green from a
|
`main` is not re-tested on push — its commits are meant to arrive already green from a
|
||||||
gated branch, so the status check to gate a merge on is `CI / full-gate (push)`.
|
gated branch, so the status check to gate a merge on is `CI / full-gate (push)`.
|
||||||
@@ -1192,15 +1382,37 @@ and pushed by that exact commit's branch gate; nothing is rebuilt after merge (b
|
|||||||
promote by re-tagging). One-time setup: on an account with package write in the `larvit` org,
|
promote by re-tagging). One-time setup: on an account with package write in the `larvit` org,
|
||||||
create a Gitea access token with `read:package` + `write:package`, and store the account name
|
create a Gitea access token with `read:package` + `write:package`, and store the account name
|
||||||
as the Actions **variable** `DOCKER_REGISTRY_USER` and the token as the Actions **secret**
|
as the Actions **variable** `DOCKER_REGISTRY_USER` and the token as the Actions **secret**
|
||||||
`DOCKER_REGISTRY_TOKEN` (a `GITEA_` prefix is rejected — reserved, like `GITHUB_`). Because
|
`DOCKER_REGISTRY_TOKEN` (a `GITEA_` prefix is rejected — reserved, like `GITHUB_`). The
|
||||||
|
package is **org-owned** (the image path starts with `larvit/`), so it lists under
|
||||||
|
`larvit/-/packages`, not the repo — link it once to the repo's Packages tab:
|
||||||
|
`POST /api/v1/packages/larvit/container/plainpages/-/link/plainpages`. Because
|
||||||
this step runs
|
this step runs
|
||||||
inside the required gate, a missing/expired token (or registry outage) fails every branch's
|
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
|
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
|
non-expiring token or track its expiry. Retention: hash tags accumulate one image per gated
|
||||||
accumulate one image per gated push, so an org-level package **cleanup rule**
|
push, so the nightly `registry-cleanup.yml` prunes them
|
||||||
(org Settings → Packages, applied by Gitea's daily cleanup cron) prunes them — type
|
([`registry-cleanup/cleanup.ts`](registry-cleanup/cleanup.ts) defines what survives).
|
||||||
`container`, remove versions matching `^[0-9a-f]{40}$` older than 30 days, keep the 10 most
|
It reuses `DOCKER_REGISTRY_USER`/`DOCKER_REGISTRY_TOKEN` — no extra setup. Don't
|
||||||
recent and anything matching `^v?\d+\.\d+\.\d+$|^latest$` (semver tags are never pruned).
|
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.
|
||||||
|
|
||||||
|
**Releases** — pushing a semver git tag (`git tag v1.2.3 && git push origin v1.2.3`) runs
|
||||||
|
`release.yml`, which pulls that commit's hash image from the registry and re-tags it as
|
||||||
|
`1.2.3`, `1.2`, `1`, and `latest` — nothing is rebuilt, the released image is byte-identical
|
||||||
|
to the gated one. It fails loud if no hash image exists: release tags must point at a commit
|
||||||
|
that went through the gate (in practice, any `main` commit). The same four tags are then
|
||||||
|
synced to [Docker Hub](https://hub.docker.com/r/larvit/plainpages) (`larvit/plainpages`) —
|
||||||
|
releases only, no hash tags. One-time setup: create the public `larvit/plainpages`
|
||||||
|
repository on Docker Hub, generate a read/write access token **scoped to that repository**
|
||||||
|
(an organization access token, or a token on a dedicated single-purpose account — an
|
||||||
|
account-wide PAT can push to every repo under the account), and store the account name as
|
||||||
|
the Actions **variable** `DOCKERHUB_USER` and the token as the Actions **secret**
|
||||||
|
`DOCKERHUB_TOKEN`. Until they exist, a release run fails at the Docker Hub step — after the
|
||||||
|
Gitea re-tag has succeeded — so set them, then re-run the workflow. The Docker Hub
|
||||||
|
repository **description** is maintained by hand: its source is
|
||||||
|
[`README-dockerhub.md`](README-dockerhub.md) — paste it into the repository overview on
|
||||||
|
Docker Hub when it changes.
|
||||||
|
|
||||||
**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is a
|
**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is a
|
||||||
read-only mirror; after every merge, `mirror.yml` force-pushes `main` and all tags there,
|
read-only mirror; after every merge, `mirror.yml` force-pushes `main` and all tags there,
|
||||||
@@ -1211,13 +1423,47 @@ as the Gitea Actions secret `MIRROR_GITHUB_TOKEN` (repo Settings → Actions →
|
|||||||
rejects secret names starting with `GITHUB_`/`GITEA_`). Trigger the workflow manually for
|
rejects secret names starting with `GITHUB_`/`GITEA_`). Trigger the workflow manually for
|
||||||
the first sync — until the secret exists, the mirror job fails loud on each merge.
|
the first sync — until the secret exists, the mirror job fails loud on each merge.
|
||||||
|
|
||||||
|
**Dependency updates** — `renovate.yml` runs [Renovate](https://docs.renovatebot.com)
|
||||||
|
nightly (self-hosted, this repo only) against [`renovate.json`](renovate.json), opening PRs
|
||||||
|
that bump npm deps (both `package.json`s), Docker base images (both Dockerfiles +
|
||||||
|
`compose*.yml`), Gitea action versions, and the image tags pinned inside workflow `run:`
|
||||||
|
steps (a custom regex manager, so nothing pinned drifts unmanaged). Version-locked sets move
|
||||||
|
together in one PR — the Ory images (kratos/keto/hydra) and the Playwright runner + its
|
||||||
|
browser image — and every bump keeps the existing **exact semver pin** exact, never widening
|
||||||
|
to a range or adding a digest. Each PR
|
||||||
|
runs through the normal gate on its `renovate/*` branch and, with `"automerge": true`,
|
||||||
|
Renovate merges it once `CI / full-gate (push)` is green (rebasing stale branches so the
|
||||||
|
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.
|
||||||
|
|
||||||
|
**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
|
||||||
|
**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
|
||||||
|
(`HOST_API_VERSION`) is deliberately **not** touched here — it moves only when the plugin API
|
||||||
|
itself changes, by hand.
|
||||||
|
|
||||||
**One-time server setup** — register an
|
**One-time server setup** — register an
|
||||||
[act_runner](https://docs.gitea.com/usage/actions/act-runner) in host mode with the label
|
[act_runner](https://docs.gitea.com/usage/actions/act-runner) in host mode with the label
|
||||||
`docker-host` (config: `labels: ["docker-host:host"]`) on a machine with Docker Engine +
|
`docker-host` (config: `labels: ["docker-host:host"]`) on a machine with Docker Engine +
|
||||||
Compose, git, and Node + github.com access (for `actions/checkout`). Runs must **never
|
Compose, git, and Node + github.com access (for `actions/checkout`). Runs must **never
|
||||||
overlap** — the e2e stacks use fixed compose project names and the devstack suite uses host
|
overlap** — the e2e stacks use fixed compose project names and the devstack suite uses host
|
||||||
networking — so register exactly **one** `docker-host` runner, keep its capacity at 1, and
|
networking, and the workflows share the Docker daemon's registry login (`ci.yml` and
|
||||||
keep host ports 3000/4433 free.
|
`release.yml` each log in and log out) — so register exactly **one** `docker-host` runner,
|
||||||
|
keep its capacity at 1, and keep host ports 3000/4433 free.
|
||||||
|
|
||||||
## Production & deployment
|
## Production & deployment
|
||||||
|
|
||||||
@@ -1254,8 +1500,8 @@ The server drains in-flight requests on `SIGTERM`/`SIGINT` rather than cutting t
|
|||||||
mid-response, so container restarts are clean.
|
mid-response, so container restarts are clean.
|
||||||
|
|
||||||
The first-boot **bootstrap** is idempotent and runs on every `up` — it generates the JWT
|
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
|
signing key if absent, creates the demo admin in Kratos, and grants it the `admin` permission plus
|
||||||
every discovered plugin's declared permission tokens in Keto, so permission checks (and any
|
every discovered plugin's declared permission names 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
|
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.**
|
*and* the bootstrap to finish before starting. **Change the demo admin before production.**
|
||||||
|
|
||||||
@@ -1276,7 +1522,7 @@ whole handler (an `AsyncLocalStorage`), so logs and traces correlate. Three expl
|
|||||||
|
|
||||||
Every request emits one access line (`method`, `path` — the query is dropped, it can carry
|
Every request emits one access line (`method`, `path` — the query is dropped, it can carry
|
||||||
tokens — `status`, `ms`, `requestId`); login/logout, admin writes (who-did-what), and
|
tokens — `status`, `ms`, `requestId`); login/logout, admin writes (who-did-what), and
|
||||||
missing-role/CSRF rejections log at `info`/`warn`, and the catch-all 500 + the
|
missing-permission/CSRF rejections log at `info`/`warn`, and the catch-all 500 + the
|
||||||
Ory-unreachable re-mint at `error`/`warn`. An inbound W3C `traceparent` is **adopted**, so a
|
Ory-unreachable re-mint at `error`/`warn`. An inbound W3C `traceparent` is **adopted**, so a
|
||||||
request continues a trace started by an upstream proxy/gateway.
|
request continues a trace started by an upstream proxy/gateway.
|
||||||
|
|
||||||
@@ -1337,7 +1583,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`.
|
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
|
(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.)
|
brief Kratos blip only touches login/re-mint.)
|
||||||
3. **Verify** new logins mint the new `kid` — decode the `plainpages_session` cookie's JWT
|
3. **Verify** new logins mint the new `kid` — decode the `plainpages_jwt` cookie's JWT
|
||||||
header, or watch web's logs for a `jwks reload on kid miss` debug line as old clients
|
header, or watch web's logs for a `jwks reload on kid miss` debug line as old clients
|
||||||
present the new key.
|
present the new key.
|
||||||
4. **Wait ~12 min**, then **prune** the superseded key:
|
4. **Wait ~12 min**, then **prune** the superseded key:
|
||||||
@@ -1388,10 +1634,10 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
|
|||||||
|
|
||||||
auth/ Identity, the session-JWT hot path, guards, and the Ory REST clients
|
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.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.user/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/permissions; 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)
|
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
|
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
|
login.ts completeLogin()/remintSession(): login completion + TTL re-mint — permissions from Keto → metadata_public projection → tokenize → session JWT cookie
|
||||||
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
|
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
|
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)
|
denylist.ts Optional instant-revoke denylist: in-memory, auto-evicting; hot path rejects a revoked subject's pre-revoke tokens (REVOCATION_DENYLIST)
|
||||||
@@ -1399,9 +1645,9 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
|
|||||||
oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login
|
oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login
|
||||||
oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes
|
oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes
|
||||||
routes.ts buildAuthRoutes(): the built-in auth/OAuth2 endpoints as named handlers on the internal route table — themed flow pages, /oauth2/* challenges, /auth/complete, POST /logout, /error; only what the wired clients support is registered
|
routes.ts buildAuthRoutes(): the built-in auth/OAuth2 endpoints as named handlers on the internal route table — themed flow pages, /oauth2/* challenges, /auth/complete, POST /logout, /error; only what the wired clients support is registered
|
||||||
bootstrap.ts One-command bootstrap: idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto
|
bootstrap.ts One-command bootstrap: idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin permission in Keto
|
||||||
kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize
|
kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize
|
||||||
kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection)
|
kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login permission projection)
|
||||||
keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API)
|
keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API)
|
||||||
hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD
|
hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD
|
||||||
fetch-timeout.ts withTimeout(): bound every outbound Ory call — wrap the injected fetch so each request aborts after a deadline unless the caller passed its own signal; server.ts wires it into the Kratos/Keto/Hydra clients
|
fetch-timeout.ts withTimeout(): bound every outbound Ory call — wrap the injected fetch so each request aborts after a deadline unless the caller passed its own signal; server.ts wires it into the Kratos/Keto/Hydra clients
|
||||||
@@ -1419,22 +1665,23 @@ src/ Node 24 + TypeScript app — strict tsc, no build step. *.
|
|||||||
chrome.ts buildPluginChrome(): the one global menu + brand/user/theme/csrf every page renders the shell from (unified across all pages) — exposed on ctx.chrome
|
chrome.ts buildPluginChrome(): the one global menu + brand/user/theme/csrf every page renders the shell from (unified across all pages) — exposed on ctx.chrome
|
||||||
shell-context.ts buildShellContext(): brand/theme/user view-model for the dashboard shell (real signed-in user, no demo profile)
|
shell-context.ts buildShellContext(): brand/theme/user view-model for the dashboard shell (real signed-in user, no demo profile)
|
||||||
dashboard.ts buildDashboardModel(): the gated "/dashboard" app home — a short instructional starter (replace it with a plugin `dashboard` handler); "/" is the public landing (a plugin `home` handler). Both render the one unified menu (ctx.chrome)
|
dashboard.ts buildDashboardModel(): the gated "/dashboard" app home — a short instructional starter (replace it with a plugin `dashboard` handler); "/" is the public landing (a plugin `home` handler). Both render the one unified menu (ctx.chrome)
|
||||||
nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model
|
nav.ts composeNav(): merge plugin nav fragments + central override, permission-filter → nav-tree model
|
||||||
menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding, imported as `#menu-config`), validated at boot
|
menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding, imported as `#menu-config`), validated at boot
|
||||||
icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs)
|
icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs)
|
||||||
list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize }
|
list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize }
|
||||||
paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
|
paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
|
||||||
|
|
||||||
views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent bodies, menu/popover, theme switch, icon sprite). Domain screens live in plugins, not here — the admin plugin ships its own views/ (incl. its Users/Groups/Roles/Clients + confirm bodies)
|
views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent bodies, menu/popover, theme switch, icon sprite). Domain screens live in plugins, not here — the admin plugin ships its own views/ (incl. its Users/Groups/Permissions/Clients + confirm bodies)
|
||||||
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
|
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
|
||||||
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
|
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)
|
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 — permission/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
|
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 + 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)
|
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/Permissions/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
|
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-permission, 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`)
|
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;
|
.gitea/workflows/ Gitea Actions: ci.yml — the full gate (ci.sh) on every branch push except main;
|
||||||
mirror.yml — force-sync main + tags to the GitHub mirror; see CI/CD
|
mirror.yml — force-sync main + tags to the GitHub mirror; see CI/CD
|
||||||
|
README-dockerhub.md The Docker Hub repository description (docker.io/larvit/plainpages) — pasted into the Docker Hub overview by hand when it changes; see CI/CD
|
||||||
```
|
```
|
||||||
|
|
||||||
## Extending the core
|
## Extending the core
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { bumpFromUpdateType, maxLevel, nextVersion } from "./next-version.ts";
|
||||||
|
|
||||||
|
test("bumpFromUpdateType: only major/minor keep their level; everything else is patch", () => {
|
||||||
|
assert.equal(bumpFromUpdateType("major"), "major");
|
||||||
|
assert.equal(bumpFromUpdateType("minor"), "minor");
|
||||||
|
assert.equal(bumpFromUpdateType("patch"), "patch");
|
||||||
|
assert.equal(bumpFromUpdateType("digest"), "patch");
|
||||||
|
assert.equal(bumpFromUpdateType("pin"), "patch");
|
||||||
|
assert.equal(bumpFromUpdateType("lockFileMaintenance"), "patch");
|
||||||
|
assert.equal(bumpFromUpdateType(""), "patch");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("maxLevel: defaults to patch, escalates on the highest level present", () => {
|
||||||
|
assert.equal(maxLevel([]), "patch");
|
||||||
|
assert.equal(maxLevel(["patch"]), "patch");
|
||||||
|
assert.equal(maxLevel(["patch", "minor"]), "minor");
|
||||||
|
assert.equal(maxLevel(["minor", "major", "patch"]), "major");
|
||||||
|
assert.equal(maxLevel(["digest", "pin"]), "patch");
|
||||||
|
assert.equal(maxLevel(["", "bogus"]), "patch"); // unknown → patch, never throws
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nextVersion pre-1.0 (major===0): shift down so we never auto-cross into 1.0.0", () => {
|
||||||
|
// dep major → 0.x minor (the 0.x "breaking" slot); dep minor/patch → 0.x patch
|
||||||
|
assert.equal(nextVersion("v0.0.2", "major"), "v0.1.0");
|
||||||
|
assert.equal(nextVersion("v0.0.2", "minor"), "v0.0.3");
|
||||||
|
assert.equal(nextVersion("v0.0.2", "patch"), "v0.0.3");
|
||||||
|
assert.equal(nextVersion("v0.3.4", "major"), "v0.4.0");
|
||||||
|
assert.equal(nextVersion("v0.3.4", "minor"), "v0.3.5");
|
||||||
|
assert.equal(nextVersion("v0.3.4", "patch"), "v0.3.5");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nextVersion at/after 1.0.0: literal semver", () => {
|
||||||
|
assert.equal(nextVersion("v1.2.3", "major"), "v2.0.0");
|
||||||
|
assert.equal(nextVersion("v1.2.3", "minor"), "v1.3.0");
|
||||||
|
assert.equal(nextVersion("v1.2.3", "patch"), "v1.2.4");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("nextVersion rejects a tag that is not vX.Y.Z", () => {
|
||||||
|
assert.throws(() => nextVersion("1.2.3", "patch"), /vX\.Y\.Z/);
|
||||||
|
assert.throws(() => nextVersion("vx.y.z", "patch"), /vX\.Y\.Z/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Pure release-version math for the Renovate auto-release (see renovate.yml → auto-release job,
|
||||||
|
// README → CI/CD). Renovate stamps each commit with a `Release-Bump: <updateType>` trailer; the
|
||||||
|
// workflow feeds those values here to pick the next `vX.Y.Z` tag. Kept side-effect-free and unit
|
||||||
|
// tested (next-version.test.ts) — the git/tag/push side lives in the workflow shell.
|
||||||
|
|
||||||
|
export type Bump = "major" | "minor" | "patch";
|
||||||
|
|
||||||
|
// A dependency change is always at least a patch; only a real major/minor escalates.
|
||||||
|
export function bumpFromUpdateType(updateType: string): Bump {
|
||||||
|
if (updateType === "major") return "major";
|
||||||
|
if (updateType === "minor") return "minor";
|
||||||
|
return "patch";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maxLevel(updateTypes: string[]): Bump {
|
||||||
|
let level: Bump = "patch";
|
||||||
|
for (const updateType of updateTypes) {
|
||||||
|
const bump = bumpFromUpdateType(updateType);
|
||||||
|
if (bump === "major") return "major";
|
||||||
|
if (bump === "minor") level = "minor";
|
||||||
|
}
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-1.0 (major===0) shifts every level down one notch, so a dependency major only bumps the 0.x
|
||||||
|
// minor and we never auto-cross into 1.0.0 — that stays a deliberate human milestone.
|
||||||
|
export function nextVersion(latestTag: string, level: Bump): string {
|
||||||
|
const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(latestTag);
|
||||||
|
if (!match) throw new Error(`latest tag must be vX.Y.Z, got ${JSON.stringify(latestTag)}`);
|
||||||
|
const major = Number(match[1]);
|
||||||
|
const minor = Number(match[2]);
|
||||||
|
const patch = Number(match[3]);
|
||||||
|
const effective: Bump = major === 0 ? (level === "major" ? "minor" : "patch") : level;
|
||||||
|
if (effective === "major") return `v${major + 1}.0.0`;
|
||||||
|
if (effective === "minor") return `v${major}.${minor + 1}.0`;
|
||||||
|
return `v${major}.${minor}.${patch + 1}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CLI: node auto-release/next-version.ts <latestTag> [updateType...] → prints the next tag.
|
||||||
|
if (process.argv[1]?.endsWith("/next-version.ts")) {
|
||||||
|
const [, , latestTag, ...updateTypes] = process.argv;
|
||||||
|
process.stdout.write(nextVersion(latestTag ?? "", maxLevel(updateTypes)));
|
||||||
|
}
|
||||||
@@ -12,6 +12,26 @@ cd "$(dirname "$0")"
|
|||||||
|
|
||||||
step() { printf '\n\033[1;34m==> %s\033[0m\n' "$1"; }
|
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.
|
# 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)"
|
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.
|
# `|| true` so a no-match doesn't trip `set -e`/`pipefail` before the explicit check below can report.
|
||||||
@@ -20,6 +40,12 @@ 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; }
|
[ -n "$img" ] && [ "$img" = "$pkg" ] || { echo "Playwright pin mismatch/unreadable: image v$img vs @playwright/test $pkg"; exit 1; }
|
||||||
echo "ok ($img)"
|
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"
|
step "Typecheck"
|
||||||
docker compose run --rm --no-deps web npm run 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 —
|
# 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.
|
# stdlib-only, in-memory, no auth. Prod points SCHEDULING_UPSTREAM at the real backend instead.
|
||||||
shifts-upstream:
|
shifts-upstream:
|
||||||
image: node:24.16.0-alpine3.24
|
image: node:24.18.1-alpine3.24
|
||||||
command: node /srv/server.ts
|
command: node /srv/server.ts
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
@@ -36,7 +36,7 @@ services:
|
|||||||
# Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025).
|
# Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025).
|
||||||
# kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
|
# kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
|
||||||
mailpit:
|
mailpit:
|
||||||
image: axllent/mailpit:v1.30.1
|
image: axllent/mailpit:v1.30.6
|
||||||
ports:
|
ports:
|
||||||
- "8025:8025"
|
- "8025:8025"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
+3
-3
@@ -130,9 +130,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
||||||
# Base roles for the demo admin; bootstrap also grants every discovered plugin's declared
|
# Base permissions for the demo admin; bootstrap also grants every discovered plugin's declared
|
||||||
# permission tokens (so the reference plugin — and any drop-in — works out of the box).
|
# permission names (so the reference plugin — and any drop-in — works out of the box).
|
||||||
ADMIN_ROLES: ${ADMIN_ROLES:-admin}
|
ADMIN_PERMISSIONS: ${ADMIN_PERMISSIONS:-admin}
|
||||||
APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner
|
APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner
|
||||||
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
|
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
|
||||||
KETO_WRITE_URL: http://keto:4467
|
KETO_WRITE_URL: http://keto:4467
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/.
|
# Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/.
|
||||||
# Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network.
|
# Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network.
|
||||||
FROM mcr.microsoft.com/playwright:v1.49.1-noble
|
FROM mcr.microsoft.com/playwright:v1.62.1-noble
|
||||||
|
|
||||||
WORKDIR /e2e-tests
|
WORKDIR /e2e-tests
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { expect, test } from "@playwright/test";
|
|||||||
const WEB = process.env.BASE_URL ?? "http://web:3000";
|
const WEB = process.env.BASE_URL ?? "http://web:3000";
|
||||||
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
|
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
|
||||||
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
|
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
|
||||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap; admin role granted in Keto
|
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap; admin permission granted in Keto
|
||||||
const ADMIN_PASSWORD = "admin";
|
const ADMIN_PASSWORD = "admin";
|
||||||
|
|
||||||
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||||
@@ -29,8 +29,8 @@ function relayCookies(res: Response): string {
|
|||||||
.filter((kv) => kv.split("=")[1] !== "")
|
.filter((kv) => kv.split("=")[1] !== "")
|
||||||
.join("; ");
|
.join("; ");
|
||||||
}
|
}
|
||||||
// Read a JWT's claims without verifying (web already verified it; we only inspect exp/roles).
|
// Read a JWT's claims without verifying (web already verified it; we only inspect exp/permissions).
|
||||||
function jwtClaims(jwt: string): { email: string; exp: number; roles: string[]; sub: string } {
|
function jwtClaims(jwt: string): { email: string; exp: number; permissions: string[]; sub: string } {
|
||||||
return JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString());
|
return JSON.parse(Buffer.from(jwt.split(".")[1]!, "base64url").toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,7 +72,7 @@ async function awaitJwtSetCookie(session: string, jwt: string): Promise<string>
|
|||||||
test("an expired session JWT is silently re-minted while Kratos lives, then cleared once it dies", async () => {
|
test("an expired session JWT is silently re-minted while Kratos lives, then cleared once it dies", async () => {
|
||||||
test.setTimeout(90_000); // two short-TTL windows (8s each) + Ory round-trips
|
test.setTimeout(90_000); // two short-TTL windows (8s each) + Ory round-trips
|
||||||
|
|
||||||
// 1. Log in for real, then complete login on web → our session JWT (roles read from Keto).
|
// 1. Log in for real, then complete login on web → our session JWT (permissions read from Keto).
|
||||||
const session = await kratosLogin();
|
const session = await kratosLogin();
|
||||||
const complete = await fetch(`${WEB}/auth/complete`, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" });
|
const complete = await fetch(`${WEB}/auth/complete`, { headers: { cookie: `plainpages_session=${session}` }, redirect: "manual" });
|
||||||
expect(complete.status, "auth/complete redirects home").toBe(303);
|
expect(complete.status, "auth/complete redirects home").toBe(303);
|
||||||
@@ -83,7 +83,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea
|
|||||||
const claims1 = jwtClaims(jwt1);
|
const claims1 = jwtClaims(jwt1);
|
||||||
expect(claims1.email).toBe(ADMIN_EMAIL);
|
expect(claims1.email).toBe(ADMIN_EMAIL);
|
||||||
expect(claims1.sub, "sub is the Kratos identity id").toBeTruthy();
|
expect(claims1.sub, "sub is the Kratos identity id").toBeTruthy();
|
||||||
expect(claims1.roles, "roles are projected from Keto").toContain("admin");
|
expect(claims1.permissions, "permissions are projected from Keto").toContain("admin");
|
||||||
|
|
||||||
// 2. Token timeout → refresh: once the 8s TTL lapses, the next request re-mints a fresh JWT.
|
// 2. Token timeout → refresh: once the 8s TTL lapses, the next request re-mints a fresh JWT.
|
||||||
const jwt2Line = await awaitJwtSetCookie(session, jwt1);
|
const jwt2Line = await awaitJwtSetCookie(session, jwt1);
|
||||||
@@ -91,7 +91,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea
|
|||||||
expect(jwt2, "a different token was minted").not.toBe(jwt1);
|
expect(jwt2, "a different token was minted").not.toBe(jwt1);
|
||||||
const claims2 = jwtClaims(jwt2);
|
const claims2 = jwtClaims(jwt2);
|
||||||
expect(claims2.exp, "the new token expires later").toBeGreaterThan(claims1.exp);
|
expect(claims2.exp, "the new token expires later").toBeGreaterThan(claims1.exp);
|
||||||
expect(claims2.roles, "re-mint re-reads roles from Keto").toContain("admin");
|
expect(claims2.permissions, "re-mint re-reads permissions from Keto").toContain("admin");
|
||||||
|
|
||||||
// 3. Kill the Kratos session: now the lapsed token cannot refresh — the cookie is cleared.
|
// 3. Kill the Kratos session: now the lapsed token cannot refresh — the cookie is cleared.
|
||||||
const revoke = await fetch(`${KRATOS_ADMIN}/admin/identities/${claims1.sub}/sessions`, { method: "DELETE" });
|
const revoke = await fetch(`${KRATOS_ADMIN}/admin/identities/${claims1.sub}/sessions`, { method: "DELETE" });
|
||||||
|
|||||||
+16
-17
@@ -1,21 +1,16 @@
|
|||||||
# Full browser E2E — the real Playwright UI flow against the live stack: password +
|
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
|
||||||
# mocked-SSO login, menu filtering by role, users/groups/roles CRUD, a plugin page, logout. A tiny
|
# login, menu filtering by permission, users/groups/permissions/OAuth2-clients CRUD, a plugin page, logout. A
|
||||||
# same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies
|
# 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.
|
# 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 run --build --rm e2e
|
||||||
# docker compose -f compose.yml -f e2e-tests/compose.full.yml down -v # tear down after
|
# docker compose -f compose.yml -f e2e-tests/compose.full.yml down -v # tear down after
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
# First-party + SSO flows need Kratos + Keto + bootstrap, not Hydra — drop it so the stack is
|
# The base's full depends_on applies (Hydra included — the admin plugin's OAuth2-clients
|
||||||
# leaner. SSO is enabled here only (clean clone stays password-only): the mock provider's whole
|
# screen needs it); only the reference plugin's upstream is added. SSO is enabled here only
|
||||||
# array is the env-settable form Kratos offers, mapped through the committed claims jsonnet.
|
# (clean clone stays password-only): the mock provider's whole array is the env-settable form
|
||||||
depends_on: !override
|
# Kratos offers, mapped through the committed claims jsonnet.
|
||||||
bootstrap:
|
depends_on:
|
||||||
condition: service_completed_successfully
|
|
||||||
kratos:
|
|
||||||
condition: service_healthy
|
|
||||||
keto:
|
|
||||||
condition: service_healthy
|
|
||||||
shifts-upstream:
|
shifts-upstream:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
@@ -35,7 +30,7 @@ services:
|
|||||||
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
|
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
|
||||||
- ./examples/plugins/admin:/app/plugins/admin:ro
|
- ./examples/plugins/admin:/app/plugins/admin:ro
|
||||||
|
|
||||||
# bootstrap grants the demo admin every discovered plugin's permission tokens, so it needs the
|
# bootstrap grants the demo admin every discovered plugin's permission names, so it needs the
|
||||||
# example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403.
|
# example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403.
|
||||||
bootstrap:
|
bootstrap:
|
||||||
volumes:
|
volumes:
|
||||||
@@ -52,9 +47,13 @@ services:
|
|||||||
SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS: >-
|
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"}]
|
[{"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.
|
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
||||||
shifts-upstream:
|
shifts-upstream:
|
||||||
image: node:24.16.0-alpine3.24
|
image: node:24.18.1-alpine3.24
|
||||||
command: ["node", "/server.ts"]
|
command: ["node", "/server.ts"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./examples/shifts-upstream/server.ts:/server.ts:ro
|
- ./examples/shifts-upstream/server.ts:/server.ts:ro
|
||||||
@@ -67,7 +66,7 @@ services:
|
|||||||
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
|
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
|
||||||
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
|
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
|
||||||
mock-oidc:
|
mock-oidc:
|
||||||
image: node:24.16.0-alpine3.24
|
image: node:24.18.1-alpine3.24
|
||||||
command: ["node", "/mock-oidc.ts"]
|
command: ["node", "/mock-oidc.ts"]
|
||||||
environment:
|
environment:
|
||||||
ISSUER: http://mock-oidc:9000
|
ISSUER: http://mock-oidc:9000
|
||||||
@@ -82,7 +81,7 @@ services:
|
|||||||
|
|
||||||
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
|
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
|
||||||
proxy:
|
proxy:
|
||||||
image: node:24.16.0-alpine3.24
|
image: node:24.18.1-alpine3.24
|
||||||
command: ["node", "/proxy.ts"]
|
command: ["node", "/proxy.ts"]
|
||||||
depends_on:
|
depends_on:
|
||||||
web:
|
web:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
// journey and the standalone SSO test run in parallel (fullyParallel) but stay independent: each
|
// journey and the standalone SSO test run in parallel (fullyParallel) but stay independent: each
|
||||||
// uses its own browser context, and only the SSO test writes the mock-OIDC identity — keep it so
|
// uses its own browser context, and only the SSO test writes the mock-OIDC identity — keep it so
|
||||||
// (no cross-group shared backend writes) or serialise the file if that ever changes.
|
// (no cross-group shared backend writes) or serialise the file if that ever changes.
|
||||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the admin role in Keto
|
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the admin permission in Keto
|
||||||
const ADMIN_PASSWORD = "admin";
|
const ADMIN_PASSWORD = "admin";
|
||||||
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login
|
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login
|
||||||
const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names
|
const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names
|
||||||
@@ -36,7 +36,7 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
});
|
});
|
||||||
test.afterAll(async () => { await page.context().close(); });
|
test.afterAll(async () => { await page.context().close(); });
|
||||||
|
|
||||||
test("menu filters by role: an admin sees the gated Admin section + the plugin", async () => {
|
test("menu filters by permission: an admin sees the gated Admin section + the plugin", async () => {
|
||||||
// The signed-in admin holds admin + scheduling:read/write, so both gated sections are present
|
// The signed-in admin holds admin + scheduling:read/write, so both gated sections are present
|
||||||
// in the menu (collapsed by default → assert they're in the DOM, not necessarily visible).
|
// in the menu (collapsed by default → assert they're in the DOM, not necessarily visible).
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
@@ -65,7 +65,7 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
|
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("groups + roles CRUD: create one of each (writes go to Keto) and see them listed", async () => {
|
test("groups + permissions CRUD: create one of each (writes go to Keto) and see them listed", async () => {
|
||||||
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form
|
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form
|
||||||
// enforces it); pick the first option (a user) from the required picker.
|
// enforces it); pick the first option (a user) from the required picker.
|
||||||
const group = `e2e-grp-${suffix}`;
|
const group = `e2e-grp-${suffix}`;
|
||||||
@@ -76,13 +76,42 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
|
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
|
||||||
await expect(page.locator("main")).toContainText(group);
|
await expect(page.locator("main")).toContainText(group);
|
||||||
|
|
||||||
const role = `e2e-role-${suffix}`;
|
const permission = `e2e-permission-${suffix}`;
|
||||||
await page.goto("/admin/roles/new");
|
await page.goto("/admin/permissions/new");
|
||||||
await page.fill('input[name="name"]', role);
|
await page.fill('input[name="name"]', permission);
|
||||||
await page.locator('select[name="member"]').selectOption({ index: 1 });
|
await page.locator('select[name="member"]').selectOption({ index: 1 });
|
||||||
await page.locator('.form-card button[type="submit"]').click();
|
await page.locator('.form-card button[type="submit"]').click();
|
||||||
await expect(page).toHaveURL(/\/admin\/roles(\?|\/|$)/);
|
await expect(page).toHaveURL(/\/admin\/permissions(\?|\/|$)/);
|
||||||
await expect(page.locator("main")).toContainText(role);
|
await expect(page.locator("main")).toContainText(permission);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 () => {
|
test("plugin page: the reference plugin renders its upstream shifts inside the native shell", async () => {
|
||||||
@@ -124,6 +153,6 @@ test("mocked SSO login: the provider button signs a user in via OIDC", async ({
|
|||||||
await page.locator(".sso-btn").click();
|
await page.locator(".sso-btn").click();
|
||||||
// Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in.
|
// Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in.
|
||||||
await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL);
|
await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL);
|
||||||
// A fresh SSO identity holds no roles, so the gated Admin section stays hidden.
|
// A fresh SSO identity holds no permissions, so the gated Admin section stays hidden.
|
||||||
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
|
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+15
-15
@@ -8,23 +8,23 @@
|
|||||||
"name": "plainpages-e2e",
|
"name": "plainpages-e2e",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.49.1"
|
"@playwright/test": "1.62.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.49.1",
|
"version": "1.62.1",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.49.1.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||||
"integrity": "sha512-Ky+BVzPz8pL6PQxHqNRW1k3mIyv933LML7HktS8uik0bUXNCdPhoS/kLihiO1tMf/egaJb4IutXd7UywvXEW+g==",
|
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.49.1"
|
"playwright": "1.62.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
@@ -43,35 +43,35 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.49.1",
|
"version": "1.62.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||||
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
|
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.49.1"
|
"playwright-core": "1.62.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"fsevents": "2.3.2"
|
"fsevents": "2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.49.1",
|
"version": "1.62.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||||
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
|
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright-core": "cli.js"
|
"playwright-core": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,6 @@
|
|||||||
"test": "playwright test"
|
"test": "playwright test"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.49.1"
|
"@playwright/test": "1.62.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,19 +13,19 @@ const shot = (page: Page, name: string): Promise<Buffer> =>
|
|||||||
// Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the
|
// Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the
|
||||||
// gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it
|
// gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it
|
||||||
// with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT.
|
// with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT.
|
||||||
function devSession(roles: string[] = []): string {
|
function devSession(permissions: string[] = []): string {
|
||||||
const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0];
|
const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0];
|
||||||
const key = createPrivateKey({ format: "jwk", key: jwk });
|
const key = createPrivateKey({ format: "jwk", key: jwk });
|
||||||
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
|
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, roles, sub: "visual-demo" })}`;
|
const input = `${b64({ alg: "ES256", kid: jwk.kid, typ: "JWT" })}.${b64({ email: "demo@plainpages.local", exp: now + 3600, iat: now, permissions, sub: "visual-demo" })}`;
|
||||||
return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`;
|
return `${input}.${sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key }).toString("base64url")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); });
|
test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); });
|
||||||
|
|
||||||
// The dashboard is gated: a page navigation needs a session. Plant one per test — a plain
|
// The dashboard is gated: a page navigation needs a session. Plant one per test — a plain
|
||||||
// member (no roles) so the gated scheduling nav stays filtered out.
|
// member (no permissions) so the gated scheduling nav stays filtered out.
|
||||||
test.beforeEach(async ({ context }) => {
|
test.beforeEach(async ({ context }) => {
|
||||||
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
|
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
|
||||||
});
|
});
|
||||||
@@ -99,7 +99,7 @@ test("the public landing at / is ungated and links to sign in + register", async
|
|||||||
await context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session)
|
await context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session)
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await expect(page.locator(".landing")).toBeVisible();
|
await expect(page.locator(".landing")).toBeVisible();
|
||||||
// the same app shell every page renders — the menu shows even signed out (role-filtered).
|
// the same app shell every page renders — the menu shows even signed out (permission-filtered).
|
||||||
await expect(page.locator(".sidebar")).toBeVisible();
|
await expect(page.locator(".sidebar")).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login");
|
await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login");
|
||||||
await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
|
await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
|
||||||
@@ -136,7 +136,7 @@ test("the reference plugin: public Overview is open to all, the gated Shifts red
|
|||||||
expect(res.status()).toBe(303);
|
expect(res.status()).toBe(303);
|
||||||
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
|
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
|
||||||
|
|
||||||
// The signed-in member (no scheduling role) sees the public Scheduling → Overview leaf in the nav,
|
// The signed-in member (no scheduling permission) sees the public Scheduling → Overview leaf in the nav,
|
||||||
// but the gated Shifts leaf is filtered out.
|
// but the gated Shifts leaf is filtered out.
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
|
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
|
||||||
|
|||||||
+1
-1
@@ -6,6 +6,6 @@ across (or bind-mount your own) and restart.
|
|||||||
| Path | Copy into | Example of |
|
| 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 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/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). |
|
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
|
||||||
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
|
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
|
||||||
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. |
|
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. |
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default defineMenu({
|
|||||||
// Operator override (rename → group → order → hide), keyed by node id.
|
// Operator override (rename → group → order → hide), keyed by node id.
|
||||||
override: {
|
override: {
|
||||||
// rename: { people: "Staff" }, // node id → new label
|
// rename: { people: "Staff" }, // node id → new label
|
||||||
// groups: [{ id: "admin", label: "Admin", children: ["users", "roles"] }],
|
// groups: [{ id: "admin", label: "Admin", children: ["users", "permissions"] }],
|
||||||
// order: ["people", "reports"], // top-level order by id
|
// order: ["people", "reports"], // top-level order by id
|
||||||
// hide: ["teams"], // remove nodes (any depth)
|
// hide: ["teams"], // remove nodes (any depth)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Admin — the system-administration plugin
|
# Admin — the system-administration plugin
|
||||||
|
|
||||||
The Users / Groups / Roles / OAuth2-clients screens for running Plainpages itself. These used to be
|
The Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. These used to be
|
||||||
built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI
|
built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI
|
||||||
until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the
|
until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the
|
||||||
screens live at `/admin/*`) and restart:
|
screens live at `/admin/*`) and restart:
|
||||||
@@ -10,7 +10,7 @@ cp -r examples/plugins/admin plugins/admin
|
|||||||
docker compose restart web
|
docker compose restart web
|
||||||
```
|
```
|
||||||
|
|
||||||
The seeded `admin@plainpages.local` already holds the `admin` role, so the section appears in the
|
The seeded `admin@plainpages.local` already holds the `admin` permission, so the section appears in the
|
||||||
menu and the screens work immediately.
|
menu and the screens work immediately.
|
||||||
|
|
||||||
## What it demonstrates — a *system* plugin
|
## What it demonstrates — a *system* plugin
|
||||||
@@ -20,10 +20,10 @@ reference](../scheduling/README.md)). The admin screens instead administer **Pla
|
|||||||
stack**, so they use the privileged **`ctx.system`** surface the host exposes to a system plugin:
|
stack**, so they use the privileged **`ctx.system`** surface the host exposes to a system plugin:
|
||||||
|
|
||||||
- **`ctx.system.kratosAdmin`** — create/edit/deactivate/delete Kratos identities (Users).
|
- **`ctx.system.kratosAdmin`** — create/edit/deactivate/delete Kratos identities (Users).
|
||||||
- **`ctx.system.keto`** — read/write the Keto relationship graph (Groups, Roles).
|
- **`ctx.system.keto`** — read/write the Keto relationship graph (Groups, Permissions).
|
||||||
- **`ctx.system.hydra`** — register/list/delete Ory Hydra OAuth2 clients.
|
- **`ctx.system.hydra`** — register/list/delete Ory Hydra OAuth2 clients.
|
||||||
- **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a
|
- **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a
|
||||||
user's role change kills that subject's live tokens at once instead of waiting out the JWT TTL.
|
user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL.
|
||||||
|
|
||||||
`ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto,
|
`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
|
and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather
|
||||||
@@ -32,9 +32,9 @@ gated per route by `permission: "admin"`, rendering the core building blocks in
|
|||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission token, and the
|
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission, and the
|
||||||
route table — one thin handler per method+path, all gated by `permission: "admin"`.
|
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
|
- `admin-users.ts` · `admin-groups.ts` · `admin-permissions.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
|
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
|
`ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the
|
||||||
admin gate + the needed `ctx.system` clients once.
|
admin gate + the needed `ctx.system` clients once.
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// Built-in Roles admin screen: the pure view-model + Keto builders. A permission is a
|
||||||
|
// Keto subject set (Permission:<name>#members); members are users (subject_id) or groups (subject_set) —
|
||||||
|
// "assign permissions to users/groups". The "effective access" view flattens a Keto `expand` tree into the
|
||||||
|
// distinct set of users who hold the permission directly or transitively via a group. The HTTP
|
||||||
|
// routing/gate/CSRF + live Keto/Kratos calls are exercised over HTTP in app.test.ts.
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { memberView } from "./admin-groups.ts";
|
||||||
|
import {
|
||||||
|
buildPermissionDetailModel,
|
||||||
|
buildPermissionFormModel,
|
||||||
|
buildPermissionsListModel,
|
||||||
|
expandToEffectiveUsers,
|
||||||
|
isValidRoleName,
|
||||||
|
permissionGrantTuple,
|
||||||
|
} from "./admin-permissions.ts";
|
||||||
|
import type { ExpandTree, RelationTuple } from "#plugin-api";
|
||||||
|
|
||||||
|
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
|
||||||
|
const userTuple = (permission: string, n: number): RelationTuple =>
|
||||||
|
({ namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${uid(n)}` });
|
||||||
|
const groupTuple = (permission: string, group: string): RelationTuple =>
|
||||||
|
({ namespace: "Permission", object: permission, relation: "granted", subject_set: { namespace: "Group", object: group, relation: "members" } });
|
||||||
|
|
||||||
|
test("isValidRoleName + permissionGrantTuple map the form value to a Permission tuple over a user/group (else null)", () => {
|
||||||
|
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(permissionGrantTuple("editor", `user:${uid(2)}`), { namespace: "Permission", object: "editor", relation: "granted", subject_id: `user:${uid(2)}` });
|
||||||
|
assert.deepEqual(permissionGrantTuple("editor", "group:eng"), { namespace: "Permission", object: "editor", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
||||||
|
for (const bad of ["", "user:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(permissionGrantTuple("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: `user:${uid(n)}` }, type: "leaf" });
|
||||||
|
const tree: ExpandTree = {
|
||||||
|
children: [
|
||||||
|
leaf(1), // direct
|
||||||
|
{
|
||||||
|
children: [leaf(2), leaf(1)], // via group + dup
|
||||||
|
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Group", object: "eng", relation: "members" } }, // a member group, not a user
|
||||||
|
type: "union",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Permission", object: "admin", relation: "granted" } },
|
||||||
|
type: "union",
|
||||||
|
};
|
||||||
|
assert.deepEqual(expandToEffectiveUsers(tree), [uid(1), uid(2)]);
|
||||||
|
assert.deepEqual(expandToEffectiveUsers(null), []);
|
||||||
|
assert.deepEqual(expandToEffectiveUsers({ type: "leaf" }), []); // an empty permission
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildPermissionsListModel filters by search, sorts, paginates; the name links to the detail page", () => {
|
||||||
|
const permissions = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `permission-${String(i).padStart(2, "0")}` }));
|
||||||
|
|
||||||
|
const all = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions" });
|
||||||
|
assert.equal(all.pagination.summary.total, 30);
|
||||||
|
assert.equal(all.table.rows.length, 25); // default page size
|
||||||
|
assert.equal(all.title, "Permissions");
|
||||||
|
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
||||||
|
assert.equal(first.rowHeader.text, "permission-00");
|
||||||
|
assert.equal(first.rowHeader.href, "/admin/permissions/permission-00");
|
||||||
|
|
||||||
|
const one = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions?q=permission-07" });
|
||||||
|
assert.equal(one.pagination.summary.total, 1);
|
||||||
|
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
||||||
|
|
||||||
|
const desc = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions?sort=-members" });
|
||||||
|
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "permission-29");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildPermissionFormModel: a create form with a required name field + member options (user or group)", () => {
|
||||||
|
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
||||||
|
const m = buildPermissionFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
||||||
|
assert.equal(m.title, "New permission");
|
||||||
|
assert.equal(m.form.action, "/admin/permissions");
|
||||||
|
assert.equal(m.form.submitLabel, "Create permission");
|
||||||
|
assert.equal(m.form.csrfToken, "tok.sig");
|
||||||
|
assert.equal(m.form.nameField.required, true);
|
||||||
|
assert.deepEqual(m.form.memberOptions, options);
|
||||||
|
|
||||||
|
const err = buildPermissionFormModel({ error: "That name is taken.", memberOptions: options, values: { member: "group:eng", name: "Admin" } });
|
||||||
|
assert.equal(err.error, "That name is taken.");
|
||||||
|
assert.equal(err.form.nameField.value, "Admin");
|
||||||
|
assert.equal(err.form.selectedMember, "group:eng");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("buildPermissionDetailModel: 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: `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" },
|
||||||
|
];
|
||||||
|
const effective = [{ label: "ada@example.com" }, { label: "grace@example.com" }]; // ada direct, grace via eng
|
||||||
|
const m = buildPermissionDetailModel({ candidates, effective, members, permission: { name: "admin" } });
|
||||||
|
assert.equal(m.title, "admin");
|
||||||
|
assert.equal(m.members.rows.length, 2);
|
||||||
|
assert.equal(m.members.action, "/admin/permissions/admin/members/delete");
|
||||||
|
assert.equal(m.add.action, "/admin/permissions/admin/members");
|
||||||
|
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/permissions/admin/delete");
|
||||||
|
});
|
||||||
+86
-86
@@ -1,15 +1,15 @@
|
|||||||
// Roles & permissions admin screen: list / create / delete Keto roles and assign
|
// Permissions admin screen: list / create / delete Keto permissions and assign
|
||||||
// them to users and groups. A role is a Keto subject set `Role:<name>#members` (OPL: members are users
|
// them to users and groups. A permission is a Keto subject set `Permission:<name>#members` (OPL: members are users
|
||||||
// or groups, resolved transitively) — the source of truth for the JWT `roles` claim. It shares the
|
// or groups, resolved transitively) — the source of truth for the JWT `permissions` claim. It shares the
|
||||||
// Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging)
|
// Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging)
|
||||||
// are reused from admin-groups. The role-specific piece is the **effective access** view:
|
// are reused from admin-groups. The permission-specific piece is the **effective access** view:
|
||||||
// `keto.expand(Role:<name>#members)` flattened to the distinct users who hold the role directly or via
|
// `keto.expand(Permission:<name>#members)` flattened to the distinct users who hold the permission directly or via
|
||||||
// a group — matching what login projects into the JWT (login.ts readRoles). Writes go only to Keto;
|
// a group — matching what login projects into the JWT (login.ts readPermissions). Writes go only to Keto;
|
||||||
// Kratos is read only to label members. Below the builders are thin per-route handlers (keyed on
|
// 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.
|
// 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 User } from "#plugin-api";
|
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 { ADMIN_PERMISSION, ADMIN_PERMISSIONS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
|
||||||
import {
|
import {
|
||||||
type GroupView,
|
type GroupView,
|
||||||
groupsFromTuples,
|
groupsFromTuples,
|
||||||
@@ -23,29 +23,29 @@ import {
|
|||||||
} from "./admin-groups.ts";
|
} from "./admin-groups.ts";
|
||||||
import type { FieldConfig } from "./admin-users.ts";
|
import type { FieldConfig } from "./admin-users.ts";
|
||||||
|
|
||||||
const ROLE_NS = "Role";
|
const PERMISSION_NS = "Permission";
|
||||||
const MEMBERS = "members";
|
const GRANTED = "granted";
|
||||||
const DEFAULT_PAGE_SIZE = 25;
|
const DEFAULT_PAGE_SIZE = 25;
|
||||||
const PAGE_SIZES = [25, 50, 100];
|
const PAGE_SIZES = [25, 50, 100];
|
||||||
// Expand far past any sane group-nesting depth so the effective-access view never silently
|
// Expand far past any sane group-nesting depth so the effective-access view never silently
|
||||||
// under-reports the deepest members (Keto's own default is shallow).
|
// under-reports the deepest members (Keto's own default is shallow).
|
||||||
const EXPAND_MAX_DEPTH = 50;
|
const EXPAND_MAX_DEPTH = 50;
|
||||||
|
|
||||||
// A role and a group share the URL-safe name rule and the user|group membership model.
|
// A permission and a group share the URL-safe name rule and the user|group membership model.
|
||||||
export type RoleView = GroupView;
|
export type PermissionView = GroupView;
|
||||||
export const isValidRoleName = isValidGroupName;
|
export const isValidRoleName = isValidGroupName;
|
||||||
export const rolesFromTuples = groupsFromTuples;
|
export const permissionsFromTuples = groupsFromTuples;
|
||||||
export interface EffectiveUser {
|
export interface EffectiveUser {
|
||||||
label: string; // email (or the raw id when unresolved)
|
label: string; // email (or the raw id when unresolved)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The full membership tuple for assigning/revoking `value` to/from `role` (null if value is invalid).
|
// The full membership tuple for assigning/revoking `value` to/from `permission` (null if value is invalid).
|
||||||
export function roleMemberTuple(role: string, value: string): RelationTuple | null {
|
export function permissionGrantTuple(permission: string, value: string): RelationTuple | null {
|
||||||
const subject = parseSubject(value);
|
const subject = parseSubject(value);
|
||||||
return subject ? { namespace: ROLE_NS, object: role, relation: MEMBERS, ...subject } : null;
|
return subject ? { namespace: PERMISSION_NS, object: permission, relation: GRANTED, ...subject } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flatten a Keto `expand` tree → the sorted, distinct user ids that effectively hold the role
|
// Flatten a Keto `expand` tree → the sorted, distinct user ids that effectively hold the permission
|
||||||
// (direct leaves + users reached through member groups, any depth). The subject rides on each
|
// (direct leaves + users reached through member groups, any depth). The subject rides on each
|
||||||
// node's `tuple`; subject-set nodes (the groups) contribute nothing directly — their members
|
// node's `tuple`; subject-set nodes (the groups) contribute nothing directly — their members
|
||||||
// surface as leaves under them.
|
// surface as leaves under them.
|
||||||
@@ -70,17 +70,17 @@ interface ListState {
|
|||||||
sort: string | null;
|
sort: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SORT: Record<string, (r: RoleView) => number | string> = {
|
const SORT: Record<string, (r: PermissionView) => number | string> = {
|
||||||
members: (r) => r.memberCount,
|
members: (r) => r.memberCount,
|
||||||
name: (r) => r.name,
|
name: (r) => r.name,
|
||||||
};
|
};
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
{ key: "name", label: "Role" },
|
{ key: "name", label: "Permission" },
|
||||||
{ key: "members", label: "Members" },
|
{ key: "members", label: "Members" },
|
||||||
];
|
];
|
||||||
|
|
||||||
function detailHref(name: string): string {
|
function detailHref(name: string): string {
|
||||||
return `${ADMIN_ROLES_BASE}/${encodeURIComponent(name)}`;
|
return `${ADMIN_PERMISSIONS_BASE}/${encodeURIComponent(name)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function listHref(state: ListState, overrides: Partial<ListState> = {}): string {
|
function listHref(state: ListState, overrides: Partial<ListState> = {}): string {
|
||||||
@@ -91,12 +91,12 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
|
|||||||
if (s.page > 1) p.set("page", String(s.page));
|
if (s.page > 1) p.set("page", String(s.page));
|
||||||
if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize));
|
if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize));
|
||||||
const qs = p.toString();
|
const qs = p.toString();
|
||||||
return qs ? `${ADMIN_ROLES_BASE}?${qs}` : ADMIN_ROLES_BASE;
|
return qs ? `${ADMIN_PERMISSIONS_BASE}?${qs}` : ADMIN_PERMISSIONS_BASE;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildRolesListModel(opts: {
|
export function buildPermissionsListModel(opts: {
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
roles: RoleView[];
|
permissions: PermissionView[];
|
||||||
url: URL | URLSearchParams | string;
|
url: URL | URLSearchParams | string;
|
||||||
}) {
|
}) {
|
||||||
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
|
||||||
@@ -104,7 +104,7 @@ export function buildRolesListModel(opts: {
|
|||||||
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
|
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
|
||||||
const needle = query.q.toLowerCase();
|
const needle = query.q.toLowerCase();
|
||||||
|
|
||||||
let list = opts.roles.filter((r) => !needle || r.name.toLowerCase().includes(needle));
|
let list = opts.permissions.filter((r) => !needle || r.name.toLowerCase().includes(needle));
|
||||||
if (sort) {
|
if (sort) {
|
||||||
const get = SORT[sort.field]!;
|
const get = SORT[sort.field]!;
|
||||||
const dir = sort.dir === "desc" ? -1 : 1;
|
const dir = sort.dir === "desc" ? -1 : 1;
|
||||||
@@ -121,17 +121,17 @@ export function buildRolesListModel(opts: {
|
|||||||
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
|
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Admin" }, { label: "Roles" }],
|
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Admin" }, { label: "Permissions" }],
|
||||||
filterBar: listFilterBar(state),
|
filterBar: listFilterBar(state),
|
||||||
pagination: listPagination(state, page),
|
pagination: listPagination(state, page),
|
||||||
table: listTable(rows, state, sort),
|
table: listTable(rows, state, sort),
|
||||||
title: "Roles",
|
title: "Permissions",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function listTable(rows: RoleView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) {
|
function listTable(rows: PermissionView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null) {
|
||||||
return {
|
return {
|
||||||
caption: "Roles",
|
caption: "Permissions",
|
||||||
columns: COLUMNS.map((c) => {
|
columns: COLUMNS.map((c) => {
|
||||||
const dir = sort && sort.field === c.key ? sort.dir : undefined;
|
const dir = sort && sort.field === c.key ? sort.dir : undefined;
|
||||||
const next = dir === "asc" ? `-${c.key}` : c.key;
|
const next = dir === "asc" ? `-${c.key}` : c.key;
|
||||||
@@ -149,11 +149,11 @@ function listFilterBar(state: ListState) {
|
|||||||
if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
if (state.q) pills.push({ label: "Search", remove: listHref(state, { page: 1, q: "" }), value: state.q });
|
||||||
return {
|
return {
|
||||||
applyLabel: "Apply",
|
applyLabel: "Apply",
|
||||||
clearHref: ADMIN_ROLES_BASE,
|
clearHref: ADMIN_PERMISSIONS_BASE,
|
||||||
label: "Filter roles",
|
label: "Filter permissions",
|
||||||
pills,
|
pills,
|
||||||
rows: [[
|
rows: [[
|
||||||
{ label: "Search roles", name: "q", placeholder: "Search role name…", type: "search", value: state.q },
|
{ label: "Search permissions", name: "q", placeholder: "Search permission name…", type: "search", value: state.q },
|
||||||
{ type: "spacer" },
|
{ type: "spacer" },
|
||||||
]],
|
]],
|
||||||
};
|
};
|
||||||
@@ -178,7 +178,7 @@ function listPagination(state: ListState, page: ReturnType<typeof paginate>) {
|
|||||||
|
|
||||||
// ---- create form + detail view models ----
|
// ---- create form + detail view models ----
|
||||||
|
|
||||||
export function buildRoleFormModel(opts: {
|
export function buildPermissionFormModel(opts: {
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
memberOptions: MemberOption[];
|
memberOptions: MemberOption[];
|
||||||
@@ -186,69 +186,69 @@ export function buildRoleFormModel(opts: {
|
|||||||
}) {
|
}) {
|
||||||
const nameField: FieldConfig = {
|
const nameField: FieldConfig = {
|
||||||
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-shield",
|
autocomplete: "off", hint: "Lowercase letters, digits, dashes and underscores.", icon: "i-shield",
|
||||||
id: "name", label: "Role name", name: "name", required: true, value: opts.values?.name ?? "",
|
id: "name", label: "Permission name", name: "name", required: true, value: opts.values?.name ?? "",
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: "New" }],
|
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { label: "New" }],
|
||||||
error: opts.error,
|
error: opts.error,
|
||||||
form: {
|
form: {
|
||||||
action: ADMIN_ROLES_BASE,
|
action: ADMIN_PERMISSIONS_BASE,
|
||||||
cancelHref: ADMIN_ROLES_BASE,
|
cancelHref: ADMIN_PERMISSIONS_BASE,
|
||||||
csrfToken: opts.csrfToken ?? "",
|
csrfToken: opts.csrfToken ?? "",
|
||||||
memberOptions: opts.memberOptions,
|
memberOptions: opts.memberOptions,
|
||||||
nameField,
|
nameField,
|
||||||
selectedMember: opts.values?.member ?? "",
|
selectedMember: opts.values?.member ?? "",
|
||||||
submitLabel: "Create role",
|
submitLabel: "Create permission",
|
||||||
},
|
},
|
||||||
title: "New role",
|
title: "New permission",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildRoleDetailModel(opts: {
|
export function buildPermissionDetailModel(opts: {
|
||||||
candidates: MemberOption[];
|
candidates: MemberOption[];
|
||||||
csrfToken?: string;
|
csrfToken?: string;
|
||||||
effective: EffectiveUser[];
|
effective: EffectiveUser[];
|
||||||
error?: string;
|
error?: string;
|
||||||
members: MemberView[];
|
members: MemberView[];
|
||||||
role: { name: string };
|
permission: { name: string };
|
||||||
}) {
|
}) {
|
||||||
const name = opts.role.name;
|
const name = opts.permission.name;
|
||||||
const base = detailHref(name);
|
const base = detailHref(name);
|
||||||
const taken = new Set(opts.members.map((m) => m.subject));
|
const taken = new Set(opts.members.map((m) => m.subject));
|
||||||
const options = opts.candidates.filter((c) => !taken.has(c.value)); // members are users/groups, never the role itself
|
const options = opts.candidates.filter((c) => !taken.has(c.value)); // members are users/groups, never the permission itself
|
||||||
return {
|
return {
|
||||||
add: { action: `${base}/members`, options },
|
add: { action: `${base}/members`, options },
|
||||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { label: name }],
|
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { label: name }],
|
||||||
csrfToken: opts.csrfToken ?? "",
|
csrfToken: opts.csrfToken ?? "",
|
||||||
delete: { action: `${base}/delete` },
|
delete: { action: `${base}/delete` },
|
||||||
effective: opts.effective,
|
effective: opts.effective,
|
||||||
error: opts.error,
|
error: opts.error,
|
||||||
members: { action: `${base}/members/delete`, rows: opts.members },
|
members: { action: `${base}/members/delete`, rows: opts.members },
|
||||||
role: { name },
|
permission: { name },
|
||||||
title: name,
|
title: name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- request handler (imperative shell) ----
|
// ---- request handler (imperative shell) ----
|
||||||
|
|
||||||
// instant-revoke: a role change for a `user:<id>` member must take effect now, so revoke that
|
// instant-revoke: a permission 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
|
// user's live tokens (a re-mint then re-reads permissions from Keto). A `group:<name>` change is
|
||||||
// transitive across many users — left to lag (documented), so only direct user members revoke.
|
// transitive across many users — left to lag (documented), so only direct user members revoke.
|
||||||
function revokeUserMember(revoke: ((sub: string) => void) | undefined, member: string): void {
|
function revokeUserMember(revoke: ((sub: string) => void) | undefined, member: string): void {
|
||||||
if (revoke && member.startsWith("user:")) revoke(member.slice("user:".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).
|
// A permission exists exactly while it has ≥1 member (Keto has no create-object).
|
||||||
async function roleExists(keto: KetoClient, name: string): Promise<boolean> {
|
async function roleExists(keto: KetoClient, name: string): Promise<boolean> {
|
||||||
const page = await keto.listRelations({ namespace: ROLE_NS, object: name, relation: MEMBERS, pageSize: 1 });
|
const page = await keto.listRelations({ namespace: PERMISSION_NS, object: name, relation: GRANTED, pageSize: 1 });
|
||||||
return page.tuples.length > 0;
|
return page.tuples.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The distinct users who effectively hold the role (expand → flatten → label by email). Skipped for
|
// The distinct users who effectively hold the permission (expand → flatten → label by email). Skipped for
|
||||||
// an empty role (no member tuples) so we don't expand a non-existent Keto object.
|
// an empty permission (no member tuples) so we don't expand a non-existent Keto object.
|
||||||
async function effectiveUsers(keto: KetoClient, name: string, hasMembers: boolean, emailById: Map<string, string>): Promise<EffectiveUser[]> {
|
async function effectiveUsers(keto: KetoClient, name: string, hasMembers: boolean, emailById: Map<string, string>): Promise<EffectiveUser[]> {
|
||||||
if (!hasMembers) return [];
|
if (!hasMembers) return [];
|
||||||
const tree = await keto.expand({ namespace: ROLE_NS, object: name, relation: MEMBERS }, { maxDepth: EXPAND_MAX_DEPTH });
|
const tree = await keto.expand({ namespace: PERMISSION_NS, object: name, relation: GRANTED }, { maxDepth: EXPAND_MAX_DEPTH });
|
||||||
return expandToEffectiveUsers(tree)
|
return expandToEffectiveUsers(tree)
|
||||||
.map((id) => ({ label: emailById.get(id) ?? `user:${id}` }))
|
.map((id) => ({ label: emailById.get(id) ?? `user:${id}` }))
|
||||||
.sort((a, b) => a.label.localeCompare(b.label));
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
@@ -268,7 +268,7 @@ function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): RouteHandl
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Same, plus the validated :name from ctx.params (an invalid role name → themed 404).
|
// Same, plus the validated :name from ctx.params (an invalid permission name → themed 404).
|
||||||
function withRoleName(inner: (deps: RolesDeps, name: string) => Promise<RouteResult>): RouteHandler {
|
function withRoleName(inner: (deps: RolesDeps, name: string) => Promise<RouteResult>): RouteHandler {
|
||||||
return withRoles((deps) => {
|
return withRoles((deps) => {
|
||||||
const name = deps.ctx.params["name"] ?? "";
|
const name = deps.ctx.params["name"] ?? "";
|
||||||
@@ -279,89 +279,89 @@ function withRoleName(inner: (deps: RolesDeps, name: string) => Promise<RouteRes
|
|||||||
|
|
||||||
const roleFormResult = async (deps: RolesDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
|
const roleFormResult = async (deps: RolesDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
|
||||||
const { options } = await memberCandidates(deps.keto, deps.kratosAdmin);
|
const { options } = await memberCandidates(deps.keto, deps.kratosAdmin);
|
||||||
return { data: { chrome: deps.ctx.chrome, model: buildRoleFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, ...extra }) }, view: "role-form" };
|
return { data: { chrome: deps.ctx.chrome, model: buildPermissionFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, ...extra }) }, view: "permission-form" };
|
||||||
};
|
};
|
||||||
|
|
||||||
// The role detail (members + effective access). With `error` set it's a 400 (a rejected action).
|
// The permission detail (members + effective access). With `error` set it's a 400 (a rejected action).
|
||||||
const roleDetailResult = async (deps: RolesDeps, name: string, error?: string): Promise<RouteResult> => {
|
const permissionDetailResult = async (deps: RolesDeps, name: string, error?: string): Promise<RouteResult> => {
|
||||||
const { emailById, options } = await memberCandidates(deps.keto, deps.kratosAdmin);
|
const { emailById, options } = await memberCandidates(deps.keto, deps.kratosAdmin);
|
||||||
const tuples = await pagedTuples(deps.keto, { namespace: ROLE_NS, object: name, relation: MEMBERS });
|
const tuples = await pagedTuples(deps.keto, { namespace: PERMISSION_NS, object: name, relation: GRANTED });
|
||||||
const members = tuples.map((t) => memberView(t, emailById));
|
const members = tuples.map((t) => memberView(t, emailById));
|
||||||
const effective = await effectiveUsers(deps.keto, name, tuples.length > 0, emailById);
|
const effective = await effectiveUsers(deps.keto, name, tuples.length > 0, emailById);
|
||||||
const result: RouteResult = { data: { chrome: deps.ctx.chrome, model: buildRoleDetailModel({ candidates: options, csrfToken: deps.ctx.chrome.csrfToken, effective, members, role: { name }, ...(error ? { error } : {}) }) }, view: "role-detail" };
|
const result: RouteResult = { data: { chrome: deps.ctx.chrome, model: buildPermissionDetailModel({ candidates: options, csrfToken: deps.ctx.chrome.csrfToken, effective, members, permission: { name }, ...(error ? { error } : {}) }) }, view: "permission-detail" };
|
||||||
return error ? { ...result, status: 400 } : result;
|
return error ? { ...result, status: 400 } : result;
|
||||||
};
|
};
|
||||||
|
|
||||||
// GET /admin/roles — the list.
|
// GET /admin/permissions — the list.
|
||||||
export const rolesList = withRoles(async ({ ctx, keto }) => {
|
export const rolesList = withRoles(async ({ ctx, keto }) => {
|
||||||
const roles = rolesFromTuples(await pagedTuples(keto, { namespace: ROLE_NS, relation: MEMBERS }));
|
const permissions = permissionsFromTuples(await pagedTuples(keto, { namespace: PERMISSION_NS, relation: GRANTED }));
|
||||||
return { data: { chrome: ctx.chrome, model: buildRolesListModel({ csrfToken: ctx.chrome.csrfToken, roles, url: ctx.url }) }, view: "roles" };
|
return { data: { chrome: ctx.chrome, model: buildPermissionsListModel({ csrfToken: ctx.chrome.csrfToken, permissions, url: ctx.url }) }, view: "permissions" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/roles — create + assign the first member (a *user* grant revokes their live tokens).
|
// POST /admin/permissions — create + assign the first member (a *user* grant revokes their live tokens).
|
||||||
export const rolesCreate = withRoles(async (deps) => {
|
export const rolesCreate = withRoles(async (deps) => {
|
||||||
const { ctx, keto, revoke, user } = deps;
|
const { ctx, keto, revoke, user } = deps;
|
||||||
const form = (await guardedForm(ctx))!;
|
const form = (await guardedForm(ctx))!;
|
||||||
const name = (form.get("name") ?? "").trim();
|
const name = (form.get("name") ?? "").trim();
|
||||||
const member = (form.get("member") ?? "").trim();
|
const member = (form.get("member") ?? "").trim();
|
||||||
const tuple = roleMemberTuple(name, member);
|
const tuple = permissionGrantTuple(name, member);
|
||||||
const reject = async (error: string): Promise<RouteResult> => ({ ...(await roleFormResult(deps, { error, values: { member, name } })), status: 400 });
|
const reject = async (error: string): Promise<RouteResult> => ({ ...(await roleFormResult(deps, { error, values: { member, name } })), status: 400 });
|
||||||
if (!isValidRoleName(name)) return reject("Role names use lowercase letters, digits, dashes and underscores.");
|
if (!isValidRoleName(name)) return reject("Permission names use lowercase letters, digits, dashes and underscores.");
|
||||||
if (!tuple) return reject("Pick a user or group to assign the role to.");
|
if (!tuple) return reject("Pick a user or group to assign the permission to.");
|
||||||
if (await roleExists(keto, name)) return reject("A role with that name already exists.");
|
if (await roleExists(keto, name)) return reject("A permission with that name already exists.");
|
||||||
await keto.writeTuple(tuple);
|
await keto.writeTuple(tuple);
|
||||||
revokeUserMember(revoke, member);
|
revokeUserMember(revoke, member);
|
||||||
ctx.log.info("admin: role created + first member assigned", { actor: user.id, member, role: name });
|
ctx.log.info("admin: permission created + first member assigned", { actor: user.id, member, permission: name });
|
||||||
return { redirect: detailHref(name) };
|
return { redirect: detailHref(name) };
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /admin/roles/new — the create form.
|
// GET /admin/permissions/new — the create form.
|
||||||
export const rolesNewForm = withRoles((deps) => roleFormResult(deps, {}));
|
export const rolesNewForm = withRoles((deps) => roleFormResult(deps, {}));
|
||||||
|
|
||||||
// GET /admin/roles/:name — the detail (members + effective access via Keto expand).
|
// GET /admin/permissions/:name — the detail (members + effective access via Keto expand).
|
||||||
export const rolesDetail = withRoleName((deps, name) => roleDetailResult(deps, name));
|
export const rolesDetail = withRoleName((deps, name) => permissionDetailResult(deps, name));
|
||||||
|
|
||||||
// POST /admin/roles/:name/members — assign a user/group; a *user* grant revokes their live tokens.
|
// POST /admin/permissions/:name/members — assign a user/group; a *user* grant revokes their live tokens.
|
||||||
export const rolesAddMember = withRoleName(async (deps, name) => {
|
export const rolesAddMember = withRoleName(async (deps, name) => {
|
||||||
const { ctx, keto, revoke, user } = deps;
|
const { ctx, keto, revoke, user } = deps;
|
||||||
const form = (await guardedForm(ctx))!;
|
const form = (await guardedForm(ctx))!;
|
||||||
const member = (form.get("member") ?? "").trim();
|
const member = (form.get("member") ?? "").trim();
|
||||||
const tuple = roleMemberTuple(name, member); // the picker only offers real users/groups
|
const tuple = permissionGrantTuple(name, member); // the picker only offers real users/groups
|
||||||
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role assigned", { actor: user.id, member, role: name }); }
|
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: permission assigned", { actor: user.id, member, permission: name }); }
|
||||||
return { redirect: detailHref(name) };
|
return { redirect: detailHref(name) };
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /admin/roles/:name/delete — confirm, except the admin role can't be deleted.
|
// GET /admin/permissions/:name/delete — confirm, except the admin permission can't be deleted.
|
||||||
export const rolesDeleteConfirm = withRoleName((deps, name) => {
|
export const rolesDeleteConfirm = withRoleName((deps, name) => {
|
||||||
if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access.");
|
if (name === ADMIN_PERMISSION) return permissionDetailResult(deps, name, "The admin permission can't be deleted — it would remove all admin access.");
|
||||||
const base = detailHref(name);
|
const base = detailHref(name);
|
||||||
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
||||||
breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }],
|
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: "Permissions" }, { href: base, label: name }, { label: "Delete" }],
|
||||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete role",
|
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: "Delete permission",
|
||||||
message: `Delete role ${name}? This revokes it from everyone it's assigned to.`, title: "Delete role",
|
message: `Delete permission ${name}? This revokes it from everyone it's assigned to.`, title: "Delete permission",
|
||||||
}) }, view: "confirm" });
|
}) }, view: "confirm" });
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/roles/:name/delete — remove every member tuple (a whole-role delete lags per the
|
// POST /admin/permissions/:name/delete — remove every member tuple (a whole-permission delete lags per the
|
||||||
// documented instant-revoke tradeoff; the admin role is protected).
|
// documented instant-revoke tradeoff; the admin permission is protected).
|
||||||
export const rolesDelete = withRoleName(async (deps, name) => {
|
export const rolesDelete = withRoleName(async (deps, name) => {
|
||||||
const { ctx, keto, user } = deps;
|
const { ctx, keto, user } = deps;
|
||||||
await guardedForm(ctx); // CSRF-verify the POST
|
await guardedForm(ctx); // CSRF-verify the POST
|
||||||
if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access.");
|
if (name === ADMIN_PERMISSION) return permissionDetailResult(deps, name, "The admin permission can't be deleted — it would remove all admin access.");
|
||||||
await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS });
|
await keto.deleteTuple({ namespace: PERMISSION_NS, object: name, relation: GRANTED });
|
||||||
ctx.log.info("admin: role deleted", { actor: user.id, role: name });
|
ctx.log.info("admin: permission deleted", { actor: user.id, permission: name });
|
||||||
return { redirect: ADMIN_ROLES_BASE };
|
return { redirect: ADMIN_PERMISSIONS_BASE };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/roles/:name/members/delete — unassign; a *user* unassign revokes their live tokens.
|
// POST /admin/permissions/:name/members/delete — unassign; a *user* unassign revokes their live tokens.
|
||||||
// Self-protection: an admin can't revoke their own *direct* admin grant (a group-held admin isn't
|
// Self-protection: an admin can't revoke their own *direct* admin grant (a group-held admin isn't
|
||||||
// covered — the robust "last effective admin" check is deferred).
|
// covered — the robust "last effective admin" check is deferred).
|
||||||
export const rolesRemoveMember = withRoleName(async (deps, name) => {
|
export const rolesRemoveMember = withRoleName(async (deps, name) => {
|
||||||
const { ctx, keto, revoke, user } = deps;
|
const { ctx, keto, revoke, user } = deps;
|
||||||
const form = (await guardedForm(ctx))!;
|
const form = (await guardedForm(ctx))!;
|
||||||
const member = (form.get("member") ?? "").trim();
|
const member = (form.get("member") ?? "").trim();
|
||||||
if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access.");
|
if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return permissionDetailResult(deps, name, "You can't revoke your own admin access.");
|
||||||
const tuple = roleMemberTuple(name, member);
|
const tuple = permissionGrantTuple(name, member);
|
||||||
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); }
|
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: permission unassigned", { actor: user.id, member, permission: name }); }
|
||||||
return { redirect: detailHref(name) };
|
return { redirect: detailHref(name) };
|
||||||
});
|
});
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
// 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
|
|
||||||
// routing/gate/CSRF + live Keto/Kratos calls are exercised over HTTP in app.test.ts.
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import { test } from "node:test";
|
|
||||||
import { memberView } from "./admin-groups.ts";
|
|
||||||
import {
|
|
||||||
buildRoleDetailModel,
|
|
||||||
buildRoleFormModel,
|
|
||||||
buildRolesListModel,
|
|
||||||
expandToEffectiveUsers,
|
|
||||||
isValidRoleName,
|
|
||||||
roleMemberTuple,
|
|
||||||
} from "./admin-roles.ts";
|
|
||||||
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: `user:${uid(n)}` });
|
|
||||||
const groupTuple = (role: string, group: string): RelationTuple =>
|
|
||||||
({ namespace: "Role", object: role, relation: "members", subject_set: { namespace: "Group", object: group, relation: "members" } });
|
|
||||||
|
|
||||||
test("isValidRoleName + roleMemberTuple map the form value to a Role tuple over a user/group (else null)", () => {
|
|
||||||
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", `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 ["", "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: `user:${uid(n)}` }, type: "leaf" });
|
|
||||||
const tree: ExpandTree = {
|
|
||||||
children: [
|
|
||||||
leaf(1), // direct
|
|
||||||
{
|
|
||||||
children: [leaf(2), leaf(1)], // via group + dup
|
|
||||||
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Group", object: "eng", relation: "members" } }, // a member group, not a user
|
|
||||||
type: "union",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Role", object: "admin", relation: "members" } },
|
|
||||||
type: "union",
|
|
||||||
};
|
|
||||||
assert.deepEqual(expandToEffectiveUsers(tree), [uid(1), uid(2)]);
|
|
||||||
assert.deepEqual(expandToEffectiveUsers(null), []);
|
|
||||||
assert.deepEqual(expandToEffectiveUsers({ type: "leaf" }), []); // an empty role
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildRolesListModel filters by search, sorts, paginates; the name links to the detail page", () => {
|
|
||||||
const roles = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `role-${String(i).padStart(2, "0")}` }));
|
|
||||||
|
|
||||||
const all = buildRolesListModel({ roles, url: "http://x/admin/roles" });
|
|
||||||
assert.equal(all.pagination.summary.total, 30);
|
|
||||||
assert.equal(all.table.rows.length, 25); // default page size
|
|
||||||
assert.equal(all.title, "Roles");
|
|
||||||
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
|
||||||
assert.equal(first.rowHeader.text, "role-00");
|
|
||||||
assert.equal(first.rowHeader.href, "/admin/roles/role-00");
|
|
||||||
|
|
||||||
const one = buildRolesListModel({ roles, url: "http://x/admin/roles?q=role-07" });
|
|
||||||
assert.equal(one.pagination.summary.total, 1);
|
|
||||||
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
|
||||||
|
|
||||||
const desc = buildRolesListModel({ roles, url: "http://x/admin/roles?sort=-members" });
|
|
||||||
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "role-29");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("buildRoleFormModel: a create form with a required name field + member options (user or group)", () => {
|
|
||||||
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");
|
|
||||||
assert.equal(m.form.submitLabel, "Create role");
|
|
||||||
assert.equal(m.form.csrfToken, "tok.sig");
|
|
||||||
assert.equal(m.form.nameField.required, true);
|
|
||||||
assert.deepEqual(m.form.memberOptions, options);
|
|
||||||
|
|
||||||
const err = buildRoleFormModel({ error: "That name is taken.", memberOptions: options, values: { member: "group:eng", name: "Admin" } });
|
|
||||||
assert.equal(err.error, "That name is taken.");
|
|
||||||
assert.equal(err.form.nameField.value, "Admin");
|
|
||||||
assert.equal(err.form.selectedMember, "group:eng");
|
|
||||||
});
|
|
||||||
|
|
||||||
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: `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" },
|
|
||||||
];
|
|
||||||
const effective = [{ label: "ada@example.com" }, { label: "grace@example.com" }]; // ada direct, grace via eng
|
|
||||||
const m = buildRoleDetailModel({ candidates, effective, members, role: { name: "admin" } });
|
|
||||||
assert.equal(m.title, "admin");
|
|
||||||
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), [`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");
|
|
||||||
});
|
|
||||||
@@ -9,8 +9,8 @@ import { test } from "node:test";
|
|||||||
import { GuardError, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api";
|
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";
|
import { ADMIN_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts";
|
||||||
|
|
||||||
const admin: User = { email: "ada@x.io", id: "u1", roles: ["admin"] };
|
const admin: User = { email: "ada@x.io", id: "u1", permissions: ["admin"] };
|
||||||
const member: User = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] };
|
const member: User = { email: "bo@x.io", id: "u2", permissions: ["scheduling:read"] };
|
||||||
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
|
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?: User | 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 {
|
||||||
@@ -18,8 +18,8 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
|
|||||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||||
req.method = opts.method ?? "GET";
|
req.method = opts.method ?? "GET";
|
||||||
return {
|
return {
|
||||||
chrome: CHROME, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
chrome: CHROME, user: opts.user ?? null, log: {} as Log, params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
||||||
roles: opts.user?.roles ?? [], url, user: opts.user ?? null, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
permissions: opts.user?.permissions ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,8 +29,8 @@ test("ADMIN_NAV: a gated Admin header over the four screens; no per-request curr
|
|||||||
assert.equal(ADMIN_NAV.id, "admin");
|
assert.equal(ADMIN_NAV.id, "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.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.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.href), ["/admin/users", "/admin/groups", "/admin/permissions", "/admin/clients"]);
|
||||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["Users", "Groups", "Roles", "OAuth2 clients"]);
|
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["Users", "Groups", "Permissions", "OAuth2 clients"]);
|
||||||
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === 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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,13 @@
|
|||||||
|
|
||||||
import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type User } from "#plugin-api";
|
import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type User } from "#plugin-api";
|
||||||
|
|
||||||
export const ADMIN_PERMISSION = "admin"; // role token gating the whole admin section
|
export const ADMIN_PERMISSION = "admin"; // the permission gating the whole admin section
|
||||||
export const ADMIN_USERS_BASE = "/admin/users";
|
export const ADMIN_USERS_BASE = "/admin/users";
|
||||||
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
||||||
export const ADMIN_ROLES_BASE = "/admin/roles";
|
export const ADMIN_PERMISSIONS_BASE = "/admin/permissions";
|
||||||
export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
||||||
|
|
||||||
export type AdminScreen = "clients" | "groups" | "roles" | "users";
|
export type AdminScreen = "clients" | "groups" | "permissions" | "users";
|
||||||
|
|
||||||
// The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into
|
// 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 `permission` drops the whole subtree for a
|
// the one global menu, filters per user (the header's `permission` drops the whole subtree for a
|
||||||
@@ -20,7 +20,7 @@ export const ADMIN_NAV: NavNode = {
|
|||||||
children: [
|
children: [
|
||||||
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" },
|
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" },
|
||||||
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" },
|
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" },
|
||||||
{ href: ADMIN_ROLES_BASE, icon: "i-shield", id: "roles", label: "Roles" },
|
{ href: ADMIN_PERMISSIONS_BASE, icon: "i-shield", id: "permissions", label: "Permissions" },
|
||||||
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "OAuth2 clients" },
|
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "OAuth2 clients" },
|
||||||
],
|
],
|
||||||
icon: "i-shield",
|
icon: "i-shield",
|
||||||
@@ -34,7 +34,7 @@ export const ADMIN_NAV: NavNode = {
|
|||||||
// relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403.
|
// relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403.
|
||||||
export function requireAdmin(ctx: RequestContext): User {
|
export function requireAdmin(ctx: RequestContext): User {
|
||||||
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
|
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
|
||||||
if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin role required");
|
if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin permission required");
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api";
|
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api";
|
||||||
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
|
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
|
||||||
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsRemoveMember } from "./admin-groups.ts";
|
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 { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-permissions.ts";
|
||||||
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
||||||
import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts";
|
import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts";
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ export default definePlugin({
|
|||||||
|
|
||||||
nav: [ADMIN_NAV],
|
nav: [ADMIN_NAV],
|
||||||
|
|
||||||
permissions: [{ description: "Administer users, groups, roles, and OAuth2 clients", token: ADMIN_PERMISSION }],
|
permissions: [{ description: "Administer users, groups, permissions, and OAuth2 clients", name: ADMIN_PERMISSION }],
|
||||||
|
|
||||||
routes: [
|
routes: [
|
||||||
// Users
|
// Users
|
||||||
@@ -46,14 +46,14 @@ export default definePlugin({
|
|||||||
r("POST", "/groups/:name/delete", groupsDelete),
|
r("POST", "/groups/:name/delete", groupsDelete),
|
||||||
r("POST", "/groups/:name/members/delete", groupsRemoveMember),
|
r("POST", "/groups/:name/members/delete", groupsRemoveMember),
|
||||||
// Roles
|
// Roles
|
||||||
r("GET", "/roles", rolesList),
|
r("GET", "/permissions", rolesList),
|
||||||
r("POST", "/roles", rolesCreate),
|
r("POST", "/permissions", rolesCreate),
|
||||||
r("GET", "/roles/new", rolesNewForm),
|
r("GET", "/permissions/new", rolesNewForm),
|
||||||
r("GET", "/roles/:name", rolesDetail),
|
r("GET", "/permissions/:name", rolesDetail),
|
||||||
r("POST", "/roles/:name/members", rolesAddMember),
|
r("POST", "/permissions/:name/members", rolesAddMember),
|
||||||
r("GET", "/roles/:name/delete", rolesDeleteConfirm),
|
r("GET", "/permissions/:name/delete", rolesDeleteConfirm),
|
||||||
r("POST", "/roles/:name/delete", rolesDelete),
|
r("POST", "/permissions/:name/delete", rolesDelete),
|
||||||
r("POST", "/roles/:name/members/delete", rolesRemoveMember),
|
r("POST", "/permissions/:name/members/delete", rolesRemoveMember),
|
||||||
// OAuth2 clients
|
// OAuth2 clients
|
||||||
r("GET", "/clients", clientsList),
|
r("GET", "/clients", clientsList),
|
||||||
r("POST", "/clients", clientsCreate),
|
r("POST", "/clients", clientsCreate),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<%#
|
<%#
|
||||||
OAuth2 clients admin list: apps that log in *through* us (Hydra). Same building blocks as
|
OAuth2 clients admin list: apps that log in *through* us (Hydra). Same building blocks as
|
||||||
the Roles screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts).
|
the Permissions screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts).
|
||||||
%><%
|
%><%
|
||||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
const filters = include("partials/filter-bar", model.filterBar);
|
const filters = include("partials/filter-bar", model.filterBar);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<%#
|
<%#
|
||||||
Admin group membership body, captured into the shell content slot. Config:
|
Admin group membership body, captured into the shell content slot. Config:
|
||||||
group { name }
|
group { name }
|
||||||
members { action, rows: { kind:"group"|"user", label, subject }[] } action = remove-member endpoint
|
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = remove-member endpoint
|
||||||
add { action, options: {label,value}[] } action = add-member endpoint
|
add { action, options: {label,value}[] } action = add-member endpoint
|
||||||
del { action } delete the whole group
|
del { action } delete the whole group
|
||||||
csrfToken, error?
|
csrfToken, error?
|
||||||
|
|||||||
+13
-13
@@ -1,13 +1,13 @@
|
|||||||
<%#
|
<%#
|
||||||
Admin role detail body, captured into the shell content slot. Config:
|
Admin permission detail body, captured into the shell content slot. Config:
|
||||||
role { name }
|
permission { name }
|
||||||
members { action, rows: { kind:"group"|"user", label, subject }[] } action = revoke endpoint
|
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = revoke endpoint
|
||||||
effective { label }[] users who hold the role (expand)
|
effective { label }[] users who hold the permission (expand)
|
||||||
add { action, options: {label,value}[] } action = assign endpoint
|
add { action, options: {label,value}[] } action = assign endpoint
|
||||||
del { action } delete the whole role
|
del { action } delete the whole permission
|
||||||
csrfToken, error?
|
csrfToken, error?
|
||||||
%><%
|
%><%
|
||||||
const role = locals.role;
|
const permission = locals.permission;
|
||||||
const members = locals.members;
|
const members = locals.members;
|
||||||
const effective = locals.effective;
|
const effective = locals.effective;
|
||||||
const add = locals.add;
|
const add = locals.add;
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<section class="form-card" aria-labelledby="members-h">
|
<section class="form-card" aria-labelledby="members-h">
|
||||||
<h2 class="card-title" id="members-h">Assigned to</h2>
|
<h2 class="card-title" id="members-h">Assigned to</h2>
|
||||||
<% if (members.rows.length) { -%>
|
<% if (members.rows.length) { -%>
|
||||||
<div class="table-wrap"><table class="table"><caption class="sr-only">Members of <%= role.name %></caption><thead><tr><th scope="col">Member</th><th scope="col">Type</th><th class="col-actions" scope="col"><span class="sr-only">Actions</span></th></tr></thead><tbody>
|
<div class="table-wrap"><table class="table"><caption class="sr-only">Members of <%= permission.name %></caption><thead><tr><th scope="col">Member</th><th scope="col">Type</th><th class="col-actions" scope="col"><span class="sr-only">Actions</span></th></tr></thead><tbody>
|
||||||
<% members.rows.forEach((m) => { -%>
|
<% members.rows.forEach((m) => { -%>
|
||||||
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? "Group" : "User" %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg>Revoke</button></form></td></tr>
|
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? "Group" : "User" %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg>Revoke</button></form></td></tr>
|
||||||
<% }) -%>
|
<% }) -%>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="form-card" aria-labelledby="effective-h">
|
<section class="form-card" aria-labelledby="effective-h">
|
||||||
<h2 class="card-title" id="effective-h">Effective access</h2>
|
<h2 class="card-title" id="effective-h">Effective access</h2>
|
||||||
<p class="field-hint">Everyone who holds this role — directly or through a group (resolved by Keto).</p>
|
<p class="field-hint">Everyone who holds this permission — directly or through a group (resolved by Keto).</p>
|
||||||
<% if (effective.length) { -%>
|
<% if (effective.length) { -%>
|
||||||
<ul class="plain-list">
|
<ul class="plain-list">
|
||||||
<% effective.forEach((u) => { -%>
|
<% effective.forEach((u) => { -%>
|
||||||
@@ -40,18 +40,18 @@
|
|||||||
<% }) -%>
|
<% }) -%>
|
||||||
</ul>
|
</ul>
|
||||||
<% } else { -%>
|
<% } else { -%>
|
||||||
<p class="cell-muted">No users hold this role yet.</p>
|
<p class="cell-muted">No users hold this permission yet.</p>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
</section>
|
</section>
|
||||||
<section class="form-card" aria-labelledby="add-h">
|
<section class="form-card" aria-labelledby="add-h">
|
||||||
<h2 class="card-title" id="add-h">Assign the role</h2>
|
<h2 class="card-title" id="add-h">Assign the permission</h2>
|
||||||
<% if (add.options.length) { -%>
|
<% if (add.options.length) { -%>
|
||||||
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member">Member</label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected>Choose a user or group…</option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Assign</button></form>
|
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member">Member</label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected>Choose a user or group…</option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Assign</button></form>
|
||||||
<% } else { -%>
|
<% } else { -%>
|
||||||
<p class="cell-muted">All users and groups already have this role.</p>
|
<p class="cell-muted">All users and groups already have this permission.</p>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
</section>
|
</section>
|
||||||
<section class="form-card admin-actions" aria-label="Role actions">
|
<section class="form-card admin-actions" aria-label="Permission actions">
|
||||||
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete role</a>
|
<a class="btn btn-danger" href="<%= del.action %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg>Delete permission</a>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
<%#
|
<%#
|
||||||
Admin role create form body, captured into the shell content slot. Config:
|
Admin permission create form body, captured into the shell content slot. Config:
|
||||||
form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config,
|
form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config,
|
||||||
memberOptions: {label,value}[], selectedMember }
|
memberOptions: {label,value}[], selectedMember }
|
||||||
error? string shown when a write was rejected
|
error? string shown when a write was rejected
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="member">Assign to</label>
|
<label for="member">Assign to</label>
|
||||||
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>>Choose a user or group…</option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
|
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>>Choose a user or group…</option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
|
||||||
<span class="field-hint">A role exists once assigned; add more users or groups after creating it.</span>
|
<span class="field-hint">A permission exists once assigned; add more users or groups after creating it.</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
|
<a class="btn" href="<%= form.cancelHref %>">Cancel</a>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<%#
|
||||||
|
Permission admin detail page: the permission-detail body (members · effective access) in the shell.
|
||||||
|
%><%
|
||||||
|
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
|
const body = include("partials/permission-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, effective: model.effective, error: model.error, members: model.members, permission: model.permission });
|
||||||
|
-%>
|
||||||
|
<%- include("partials/shell", {
|
||||||
|
body,
|
||||||
|
brand: chrome.brand,
|
||||||
|
breadcrumbs: model.breadcrumbs,
|
||||||
|
csrfToken: chrome.csrfToken,
|
||||||
|
nav,
|
||||||
|
theme: chrome.theme,
|
||||||
|
title: model.title,
|
||||||
|
user: chrome.user,
|
||||||
|
}) %>
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
<%#
|
<%#
|
||||||
Role admin create page: the role-form body captured into the app shell.
|
Permission admin create page: the permission-form body captured into the app shell.
|
||||||
%><%
|
%><%
|
||||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
const body = include("partials/role-form-body", { error: model.error, form: model.form });
|
const body = include("partials/permission-form-body", { error: model.error, form: model.form });
|
||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
body,
|
body,
|
||||||
+3
-3
@@ -1,12 +1,12 @@
|
|||||||
<%#
|
<%#
|
||||||
Roles admin list: the same building blocks as the Groups screen, around the shell, backed
|
Permissions admin list: the same building blocks as the Groups screen, around the shell, backed
|
||||||
by live Keto Role subject sets (admin-roles.ts). Filter/sort/page round-trip the URL.
|
by live Keto Permission subject sets (admin-permissions.ts). Filter/sort/page round-trip the URL.
|
||||||
%><%
|
%><%
|
||||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
const filters = include("partials/filter-bar", model.filterBar);
|
const filters = include("partials/filter-bar", model.filterBar);
|
||||||
const table = include("partials/data-table", model.table);
|
const table = include("partials/data-table", model.table);
|
||||||
const pager = include("partials/pagination", model.pagination);
|
const pager = include("partials/pagination", model.pagination);
|
||||||
const actions = '<a class="btn btn-primary" href="/admin/roles/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add role</a>';
|
const actions = '<a class="btn btn-primary" href="/admin/permissions/new"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>Add permission</a>';
|
||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
actions,
|
actions,
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<%#
|
|
||||||
Role admin detail page: the role-detail body (members · effective access) in the shell.
|
|
||||||
%><%
|
|
||||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
|
||||||
const body = include("partials/role-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, effective: model.effective, error: model.error, members: model.members, role: model.role });
|
|
||||||
-%>
|
|
||||||
<%- include("partials/shell", {
|
|
||||||
body,
|
|
||||||
brand: chrome.brand,
|
|
||||||
breadcrumbs: model.breadcrumbs,
|
|
||||||
csrfToken: chrome.csrfToken,
|
|
||||||
nav,
|
|
||||||
theme: chrome.theme,
|
|
||||||
title: model.title,
|
|
||||||
user: chrome.user,
|
|
||||||
}) %>
|
|
||||||
@@ -46,6 +46,6 @@ cosmetically) — normalise to your backend's format there if it matters.
|
|||||||
|
|
||||||
## Granting access
|
## Granting access
|
||||||
|
|
||||||
A user sees Scheduling once they hold the `scheduling:read` role in Keto (and `scheduling:write`
|
A user sees Scheduling once they hold the `scheduling:read` permission in Keto (and `scheduling:write`
|
||||||
to create). The one-command bootstrap grants both to the demo admin, so the seeded
|
to create). The one-command bootstrap grants both to the demo admin, so the seeded
|
||||||
`admin@plainpages.local` can use it immediately.
|
`admin@plainpages.local` can use it immediately.
|
||||||
|
|||||||
@@ -30,14 +30,14 @@ export default definePlugin({
|
|||||||
label: "Scheduling",
|
label: "Scheduling",
|
||||||
}],
|
}],
|
||||||
|
|
||||||
// Tokens this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
|
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
|
||||||
permissions: [
|
permissions: [
|
||||||
{ description: "View shifts", token: READ },
|
{ description: "View shifts", name: READ },
|
||||||
{ description: "Create and edit shifts", token: WRITE },
|
{ description: "Create and edit shifts", name: WRITE },
|
||||||
],
|
],
|
||||||
|
|
||||||
// Mounted under /scheduling; `permission` 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.
|
// (anyone may reach /scheduling, signed in or not); the rest need a permission.
|
||||||
routes: [
|
routes: [
|
||||||
{ handler: overview(), method: "GET", path: "/", public: true },
|
{ handler: overview(), method: "GET", path: "/", public: true },
|
||||||
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
|
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import {
|
|||||||
|
|
||||||
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
|
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
|
||||||
|
|
||||||
function fakeCtx(opts: { body?: string; roles?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||||
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
||||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||||
return {
|
return {
|
||||||
chrome: CHROME, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
chrome: CHROME, user: null, log: new Log("none"), params: {}, query: url.searchParams, req, res: {} as ServerResponse,
|
||||||
roles: opts.roles ?? [], url, user: null, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
permissions: opts.permissions ?? [], url, verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,8 +93,8 @@ test("readInput trims; validate requires title + assignee", () => {
|
|||||||
|
|
||||||
// ---- list handler ----
|
// ---- list handler ----
|
||||||
|
|
||||||
test("listShifts renders the upstream rows; q filters; canWrite reflects the role", async () => {
|
test("listShifts renders the upstream rows; q filters; canWrite reflects the permission", async () => {
|
||||||
const r = asView(await listShifts(fakeUpstream())(fakeCtx({ roles: ["scheduling:write"] })));
|
const r = asView(await listShifts(fakeUpstream())(fakeCtx({ permissions: ["scheduling:write"] })));
|
||||||
assert.equal(r.view, "shifts");
|
assert.equal(r.view, "shifts");
|
||||||
const table = r.data["table"] as { rows: { name: string }[] };
|
const table = r.data["table"] as { rows: { name: string }[] };
|
||||||
assert.deepEqual(table.rows.map((x) => x.name), ["Morning desk", "Afternoon support"]);
|
assert.deepEqual(table.rows.map((x) => x.name), ["Morning desk", "Afternoon support"]);
|
||||||
@@ -112,15 +112,15 @@ test("listShifts degrades to a recoverable error page when the upstream is down
|
|||||||
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []);
|
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- public overview handler (a page anyone can reach, gated data stays behind the role) ----
|
// ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ----
|
||||||
|
|
||||||
test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
|
test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
|
||||||
const anon = asView(await overview()(fakeCtx())); // user null, no roles
|
const anon = asView(await overview()(fakeCtx())); // user null, no permissions
|
||||||
assert.equal(anon.view, "overview");
|
assert.equal(anon.view, "overview");
|
||||||
assert.equal(anon.data["chrome"], CHROME);
|
assert.equal(anon.data["chrome"], CHROME);
|
||||||
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
|
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
|
||||||
|
|
||||||
const reader = asView(await overview()(fakeCtx({ roles: ["scheduling:read"] })));
|
const reader = asView(await overview()(fakeCtx({ permissions: ["scheduling:read"] })));
|
||||||
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
|
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
|
||||||
export const SHIFTS_PATH = "/scheduling/shifts";
|
export const SHIFTS_PATH = "/scheduling/shifts";
|
||||||
export const READ = "scheduling:read"; // permission token gating the list + nav
|
export const READ = "scheduling:read"; // the permission gating the list + nav
|
||||||
export const WRITE = "scheduling:write"; // permission token gating create
|
export const WRITE = "scheduling:write"; // the permission gating create
|
||||||
|
|
||||||
export interface Shift {
|
export interface Shift {
|
||||||
id: string;
|
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
|
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
|
||||||
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data
|
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data
|
||||||
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
|
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
|
||||||
// else a prompt to sign in. ctx.user 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 permission via can() (zero I/O).
|
||||||
export function overview(): RouteHandler {
|
export function overview(): RouteHandler {
|
||||||
return (ctx) => ({
|
return (ctx) => ({
|
||||||
data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" },
|
data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" },
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
actions: "",
|
actions: "",
|
||||||
body: '<div class="scheduling-page"><p>Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> role.</p>' + cta + '</div>',
|
body: '<div class="scheduling-page"><p>Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.</p>' + cta + '</div>',
|
||||||
brand: chrome.brand,
|
brand: chrome.brand,
|
||||||
breadcrumbs,
|
breadcrumbs,
|
||||||
csrfToken: chrome.csrfToken,
|
csrfToken: chrome.csrfToken,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"errors":null,"message":"not found","url":"https://gitea.larvit.se/api/swagger"}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
# Ory Keto — authorization (ReBAC), the source of truth for roles/groups and the rare
|
# Ory Keto — authorization (ReBAC), the source of truth for permissions/groups and the rare
|
||||||
# fine-grained check (README: three tiers of "may I?"). The permission model lives in
|
# fine-grained check (README: three tiers of "may I?"). The permission model lives in
|
||||||
# namespaces.keto.ts (OPL); DSN comes from the env (the per-service keto DB). The web
|
# namespaces.keto.ts (OPL); DSN comes from the env (the per-service keto DB). The web
|
||||||
# app never connects directly — it calls the read (4466) / write (4467) APIs, the ports
|
# app never connects directly — it calls the read (4466) / write (4467) APIs, the ports
|
||||||
|
|||||||
@@ -4,28 +4,30 @@
|
|||||||
// identity ids (== the JWT `sub`).
|
// identity ids (== the JWT `sub`).
|
||||||
import { Context, Namespace, SubjectSet } from "@ory/keto-namespace-types"
|
import { Context, Namespace, SubjectSet } from "@ory/keto-namespace-types"
|
||||||
|
|
||||||
// A human identity. Subjects are written as `user:<kratos-identity-id>`.
|
// A person. Ory calls this an "identity" (Kratos owns the record); Plainpages says "user"
|
||||||
|
// throughout. Subjects are written as `user:<kratos-identity-id>`.
|
||||||
class User implements Namespace {}
|
class User implements Namespace {}
|
||||||
|
|
||||||
// A subject set: a named collection of users (and nested groups), resolved transitively.
|
// A named set of users (and nested groups), resolved transitively. The admin "Groups"
|
||||||
// The admin "Groups" screen manages membership; checks expand it automatically.
|
// screen manages membership; checks expand it automatically.
|
||||||
class Group implements Namespace {
|
class Group implements Namespace {
|
||||||
related: {
|
related: {
|
||||||
members: (User | 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
|
// A coarse permission — an operation a route or menu item gates on, and the source of truth
|
||||||
// `role:<name>#members@user:<id>` from Keto and projects the result into the token
|
// for the JWT `permissions` claim. At login the app reads `Permission:<name>#granted@user:<id>`
|
||||||
// (README: Login → session JWT). A group can hold a role, so members can be users or groups.
|
// from Keto and projects the result into the token (README: Login → session JWT). A group can
|
||||||
class Role implements Namespace {
|
// hold a permission, so grants go to a user or to a whole group.
|
||||||
|
class Permission implements Namespace {
|
||||||
related: {
|
related: {
|
||||||
members: (User | SubjectSet<Group, "members">)[]
|
granted: (User | SubjectSet<Group, "members">)[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A fine-grained, relationship-checked resource — README's third "may I?" tier, the rare
|
// A fine-grained, relationship-checked resource — README's third "may I?" tier, the rare
|
||||||
// live Keto check (e.g. sharing/delegation). Permissions nest: owner ⊇ editor ⊇ viewer.
|
// live Keto check (e.g. sharing/delegation). Permits nest: owner ⊇ editor ⊇ viewer.
|
||||||
// Grants accept a user directly or any member of a group.
|
// Grants accept a user directly or any member of a group.
|
||||||
class Resource implements Namespace {
|
class Resource implements Namespace {
|
||||||
related: {
|
related: {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ selfservice:
|
|||||||
ui_url: http://localhost:3000/login
|
ui_url: http://localhost:3000/login
|
||||||
after:
|
after:
|
||||||
# After authenticating, land on our completion route — it mints the session JWT
|
# After authenticating, land on our completion route — it mints the session JWT
|
||||||
# (roles from Keto → metadata_public projection → tokenize) and sets our cookie.
|
# (permissions from Keto → metadata_public projection → tokenize) and sets our cookie.
|
||||||
default_browser_return_url: http://localhost:3000/auth/complete
|
default_browser_return_url: http://localhost:3000/auth/complete
|
||||||
registration:
|
registration:
|
||||||
ui_url: http://localhost:3000/registration
|
ui_url: http://localhost:3000/registration
|
||||||
@@ -94,7 +94,7 @@ session:
|
|||||||
same_site: Lax
|
same_site: Lax
|
||||||
# Session→JWT tokenizer: whoami(tokenize_as: plainpages) mints a short-lived,
|
# Session→JWT tokenizer: whoami(tokenize_as: plainpages) mints a short-lived,
|
||||||
# locally-verifiable JWT so the hot path never calls Ory. Claims come from the
|
# locally-verifiable JWT so the hot path never calls Ory. Claims come from the
|
||||||
# committed Jsonnet mapper (sub = identity id, email from traits, roles from the
|
# committed Jsonnet mapper (sub = identity id, email from traits, permissions from the
|
||||||
# metadata_public projection); signed with tokenizer/jwks.json.
|
# metadata_public projection); signed with tokenizer/jwks.json.
|
||||||
whoami:
|
whoami:
|
||||||
tokenizer:
|
tokenizer:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Session→JWT claims mapper for the `plainpages` tokenizer. Kratos exposes the
|
// Session→JWT claims mapper for the `plainpages` tokenizer. Kratos exposes the
|
||||||
// session as `session`; `sub` is set from the identity id (subject_source: id) and
|
// session as `session`; `sub` is set from the identity id (subject_source: id) and
|
||||||
// can't be overridden here. roles come from metadata_public — the per-login projection
|
// can't be overridden here. permissions come from metadata_public — the per-login projection
|
||||||
// of Keto roles the app refreshes at login (metadata_admin is NOT carried in the session
|
// of Keto permissions the app refreshes at login (metadata_admin is NOT carried in the session
|
||||||
// the tokenizer sees; metadata_public is). Absent on a fresh identity ⇒ empty list.
|
// the tokenizer sees; metadata_public is). Absent on a fresh identity ⇒ empty list.
|
||||||
local session = std.extVar('session');
|
local session = std.extVar('session');
|
||||||
local meta =
|
local meta =
|
||||||
@@ -12,6 +12,6 @@ local meta =
|
|||||||
{
|
{
|
||||||
claims: {
|
claims: {
|
||||||
email: session.identity.traits.email,
|
email: session.identity.traits.email,
|
||||||
roles: if std.objectHas(meta, 'roles') then meta.roles else [],
|
permissions: if std.objectHas(meta, 'permissions') then meta.permissions else [],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+377
-84
@@ -9,13 +9,13 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@larvit/log": "2.3.0",
|
"@larvit/log": "2.3.0",
|
||||||
"ejs": "3.1.10",
|
"ejs": "6.0.1",
|
||||||
"lucide-static": "1.18.0"
|
"lucide-static": "1.28.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ejs": "3.1.5",
|
"@types/ejs": "3.1.5",
|
||||||
"@types/node": "24.13.2",
|
"@types/node": "24.13.3",
|
||||||
"typescript": "5.9.3"
|
"typescript": "7.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24"
|
"node": ">=24"
|
||||||
@@ -38,113 +38,406 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.13.2",
|
"version": "24.13.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||||
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
|
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": "~7.18.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/async": {
|
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||||
"version": "3.2.6",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||||
"license": "MIT"
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||||
"version": "1.0.2",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||||
"license": "MIT"
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/@typescript/typescript-darwin-x64": {
|
||||||
"version": "2.1.1",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||||
"integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
|
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||||
"license": "MIT",
|
"cpu": [
|
||||||
"dependencies": {
|
"x64"
|
||||||
"balanced-match": "^1.0.0"
|
],
|
||||||
|
"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/ejs": {
|
"node_modules/ejs": {
|
||||||
"version": "3.1.10",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
|
"resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
|
||||||
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
|
"integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
|
||||||
"jake": "^10.8.5"
|
|
||||||
},
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"ejs": "bin/cli.js"
|
"ejs": "bin/cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.12.18"
|
||||||
}
|
|
||||||
},
|
|
||||||
"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": {
|
"node_modules/lucide-static": {
|
||||||
"version": "1.18.0",
|
"version": "1.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.28.0.tgz",
|
||||||
"integrity": "sha512-0WRXLQnjbte5SXuzom6yfeGlVSFsEsC9rzxn66DZN0pXows3+N34CQHy3BHI1qA3uH7u/SUzx8LQhjeAnxd8JQ==",
|
"integrity": "sha512-dC3VJwRFsjEVX7Iaq4rY88pm7Fi2OmOb8P0WRzXsUMgbt7sCmFX8bLhaDBeNW6JdRjuele+jKqqFaam4yr+Ygg==",
|
||||||
"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"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "5.9.3",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc"
|
||||||
"tsserver": "bin/tsserver"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.17"
|
"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_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
|
|||||||
+5
-5
@@ -15,16 +15,16 @@
|
|||||||
"dev": "node --watch src/server.ts",
|
"dev": "node --watch src/server.ts",
|
||||||
"gen-jwks": "node src/auth/gen-jwks.ts",
|
"gen-jwks": "node src/auth/gen-jwks.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\""
|
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"auto-release/**/*.test.ts\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@larvit/log": "2.3.0",
|
"@larvit/log": "2.3.0",
|
||||||
"ejs": "3.1.10",
|
"ejs": "6.0.1",
|
||||||
"lucide-static": "1.18.0"
|
"lucide-static": "1.28.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ejs": "3.1.5",
|
"@types/ejs": "3.1.5",
|
||||||
"@types/node": "24.13.2",
|
"@types/node": "24.13.3",
|
||||||
"typescript": "5.9.3"
|
"typescript": "7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
// Prunes the app's container package in the Gitea registry: a commit-hash tag survives only
|
||||||
|
// while its commit is a branch head or carries a vX.Y.Z release tag. Untagged sha256:* package
|
||||||
|
// versions are CHILD manifests (arch image + provenance) of the tagged OCI indexes — deleting
|
||||||
|
// one that a surviving tag still references breaks that image, so only the ones no kept tag
|
||||||
|
// references are removed. Named tags (1.2.3, latest, …) are never touched.
|
||||||
|
import { planHashTagDeletions, selectOrphanedManifests } from "./select-versions.ts";
|
||||||
|
|
||||||
|
function env(name: string): string {
|
||||||
|
const value = process.env[name];
|
||||||
|
if (value === undefined || value === "") throw new Error(`Missing env var ${name}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const registryToken = env("REGISTRY_TOKEN");
|
||||||
|
const registryUser = env("REGISTRY_USER");
|
||||||
|
const repoToken = env("REPO_TOKEN");
|
||||||
|
const repository = env("REPOSITORY");
|
||||||
|
const serverUrl = env("SERVER_URL").replace(/\/+$/, "");
|
||||||
|
|
||||||
|
const [owner, name] = repository.split("/");
|
||||||
|
if (owner === undefined || name === undefined || owner === "" || name === "") {
|
||||||
|
throw new Error(`REPOSITORY must be owner/name, got "${repository}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchOk(url: string, init: RequestInit): Promise<Response> {
|
||||||
|
const res = await fetch(url, init);
|
||||||
|
if (!res.ok) throw new Error(`${init.method ?? "GET"} ${url} -> ${res.status}: ${await res.text()}`);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiGetAllPages<T>(path: string, token: string): Promise<T[]> {
|
||||||
|
const all: T[] = [];
|
||||||
|
const limit = 50;
|
||||||
|
for (let page = 1; ; page++) {
|
||||||
|
const sep = path.includes("?") ? "&" : "?";
|
||||||
|
const res = await fetchOk(`${serverUrl}/api/v1${path}${sep}limit=${limit}&page=${page}`, {
|
||||||
|
headers: { authorization: `token ${token}` },
|
||||||
|
});
|
||||||
|
const batch = (await res.json()) as T[];
|
||||||
|
// Stop on an empty page, not a short one — a lowered server page-size cap would otherwise
|
||||||
|
// silently truncate the list, and a truncated branch/tag list deletes protected images.
|
||||||
|
if (batch.length === 0) return all;
|
||||||
|
all.push(...batch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function childDigests(tag: string): Promise<string[]> {
|
||||||
|
const res = await fetchOk(`${serverUrl}/v2/${owner}/${name}/manifests/${tag}`, {
|
||||||
|
headers: {
|
||||||
|
accept: [
|
||||||
|
"application/vnd.oci.image.index.v1+json",
|
||||||
|
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||||
|
"application/vnd.oci.image.manifest.v1+json",
|
||||||
|
"application/vnd.docker.distribution.manifest.v2+json",
|
||||||
|
].join(", "),
|
||||||
|
authorization: `Basic ${Buffer.from(`${registryUser}:${registryToken}`).toString("base64")}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const manifest = (await res.json()) as { manifests?: { digest: string }[] };
|
||||||
|
return (manifest.manifests ?? []).map((m) => m.digest);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteVersion(version: string): Promise<void> {
|
||||||
|
const url = `${serverUrl}/api/v1/packages/${owner}/container/${name}/${encodeURIComponent(version)}`;
|
||||||
|
const res = await fetch(url, { headers: { authorization: `token ${registryToken}` }, method: "DELETE" });
|
||||||
|
if (res.status === 404) {
|
||||||
|
console.log(`already gone: ${version}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!res.ok) throw new Error(`DELETE ${url} -> ${res.status}: ${await res.text()}`);
|
||||||
|
console.log(`deleted: ${version}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const branches = await apiGetAllPages<{ commit: { id: string } }>(`/repos/${owner}/${name}/branches`, repoToken);
|
||||||
|
if (branches.length === 0) throw new Error("no branches returned — refusing to prune with an empty keep-set");
|
||||||
|
const tags = await apiGetAllPages<{ commit: { sha: string }; name: string }>(`/repos/${owner}/${name}/tags`, repoToken);
|
||||||
|
const packages = await apiGetAllPages<{ name: string; version: string }>(
|
||||||
|
`/packages/${owner}?type=container&q=${encodeURIComponent(name)}`,
|
||||||
|
registryToken,
|
||||||
|
);
|
||||||
|
|
||||||
|
const versions = packages.filter((p) => p.name === name).map((p) => p.version);
|
||||||
|
const plan = planHashTagDeletions({
|
||||||
|
branchHeadShas: branches.map((b) => b.commit.id),
|
||||||
|
releaseTagShas: tags.filter((t) => /^v\d+\.\d+\.\d+$/.test(t.name)).map((t) => t.commit.sha),
|
||||||
|
versions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const referenced = new Set<string>();
|
||||||
|
for (const tag of plan.keptTags) {
|
||||||
|
for (const digest of await childDigests(tag)) referenced.add(digest);
|
||||||
|
}
|
||||||
|
const orphans = selectOrphanedManifests({ referencedDigests: [...referenced], versions });
|
||||||
|
|
||||||
|
for (const version of [...plan.deletions, ...orphans]) await deleteVersion(version);
|
||||||
|
console.log(
|
||||||
|
`kept ${plan.keptTags.length} tags; deleted ${plan.deletions.length} hash tags and ${orphans.length} orphaned manifests`,
|
||||||
|
);
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { planHashTagDeletions, selectOrphanedManifests } from "./select-versions.ts";
|
||||||
|
|
||||||
|
const headSha = "0582809000000000000000000000000000000001";
|
||||||
|
const releaseSha = "50006dd000000000000000000000000000000002";
|
||||||
|
const staleSha = "c8981c1000000000000000000000000000000003";
|
||||||
|
|
||||||
|
test("planHashTagDeletions deletes hash tags that are neither a branch head nor release-tagged", () => {
|
||||||
|
const plan = planHashTagDeletions({
|
||||||
|
branchHeadShas: [headSha],
|
||||||
|
releaseTagShas: [releaseSha],
|
||||||
|
versions: [headSha, releaseSha, staleSha],
|
||||||
|
});
|
||||||
|
assert.deepEqual(plan.deletions, [staleSha]);
|
||||||
|
assert.deepEqual(plan.keptTags, [headSha, releaseSha]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("planHashTagDeletions keeps named tags and excludes sha256 manifest versions from keptTags", () => {
|
||||||
|
const plan = planHashTagDeletions({
|
||||||
|
branchHeadShas: [],
|
||||||
|
releaseTagShas: [],
|
||||||
|
versions: ["0.0.1", "0", "latest", "some-manual-tag", `sha256:${"a".repeat(64)}`, staleSha],
|
||||||
|
});
|
||||||
|
assert.deepEqual(plan.deletions, [staleSha]);
|
||||||
|
assert.deepEqual(plan.keptTags, ["0.0.1", "0", "latest", "some-manual-tag"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("planHashTagDeletions with no versions plans nothing", () => {
|
||||||
|
const plan = planHashTagDeletions({ branchHeadShas: [headSha], releaseTagShas: [], versions: [] });
|
||||||
|
assert.deepEqual(plan.deletions, []);
|
||||||
|
assert.deepEqual(plan.keptTags, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selectOrphanedManifests picks only sha256 versions unreferenced by kept tags", () => {
|
||||||
|
const referenced = `sha256:${"b".repeat(64)}`;
|
||||||
|
const orphaned = `sha256:${"c".repeat(64)}`;
|
||||||
|
const orphans = selectOrphanedManifests({
|
||||||
|
referencedDigests: [referenced],
|
||||||
|
versions: ["0.0.1", "latest", headSha, referenced, orphaned],
|
||||||
|
});
|
||||||
|
assert.deepEqual(orphans, [orphaned]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("selectOrphanedManifests with nothing referenced orphans every sha256 version", () => {
|
||||||
|
const manifest = `sha256:${"d".repeat(64)}`;
|
||||||
|
assert.deepEqual(selectOrphanedManifests({ referencedDigests: [], versions: [manifest, "latest"] }), [manifest]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
const hashTag = /^[0-9a-f]{40}$/;
|
||||||
|
const manifestVersion = /^sha256:[0-9a-f]{64}$/;
|
||||||
|
|
||||||
|
export type HashTagPlan = {
|
||||||
|
deletions: string[];
|
||||||
|
keptTags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function planHashTagDeletions(input: {
|
||||||
|
branchHeadShas: string[];
|
||||||
|
releaseTagShas: string[];
|
||||||
|
versions: string[];
|
||||||
|
}): HashTagPlan {
|
||||||
|
const keep = new Set([...input.branchHeadShas, ...input.releaseTagShas]);
|
||||||
|
const deletions: string[] = [];
|
||||||
|
const keptTags: string[] = [];
|
||||||
|
for (const version of input.versions) {
|
||||||
|
if (manifestVersion.test(version)) continue;
|
||||||
|
if (hashTag.test(version) && !keep.has(version)) deletions.push(version);
|
||||||
|
else keptTags.push(version);
|
||||||
|
}
|
||||||
|
return { deletions, keptTags };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectOrphanedManifests(input: {
|
||||||
|
referencedDigests: string[];
|
||||||
|
versions: string[];
|
||||||
|
}): string[] {
|
||||||
|
const referenced = new Set(input.referencedDigests);
|
||||||
|
return input.versions.filter((v) => manifestVersion.test(v) && !referenced.has(v));
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": ["config:recommended"],
|
||||||
|
"automerge": true,
|
||||||
|
"commitBody": "Release-Bump: {{{updateType}}}",
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"description": "Ory services share one release train - update kratos, keto and hydra together",
|
||||||
|
"matchDatasources": ["docker"],
|
||||||
|
"matchPackageNames": ["oryd/kratos", "oryd/keto", "oryd/hydra"],
|
||||||
|
"groupName": "Ory stack"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Playwright runner and its browser image are version-locked - bump together",
|
||||||
|
"matchPackageNames": ["@playwright/test", "mcr.microsoft.com/playwright"],
|
||||||
|
"groupName": "Playwright"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"customManagers": [
|
||||||
|
{
|
||||||
|
"customType": "regex",
|
||||||
|
"description": "Pin the Renovate image the Renovate workflow runs",
|
||||||
|
"managerFilePatterns": [".gitea/workflows/renovate.yml"],
|
||||||
|
"matchStrings": ["renovate/renovate:(?<currentValue>[0-9][^\\s\"']*)"],
|
||||||
|
"depNameTemplate": "renovate/renovate",
|
||||||
|
"datasourceTemplate": "docker"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"customType": "regex",
|
||||||
|
"description": "Pin the node image workflow run-steps invoke (registry-cleanup, auto-release)",
|
||||||
|
"managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/renovate.yml"],
|
||||||
|
"matchStrings": ["\\snode:(?<currentValue>[0-9][^\\s\"']*)"],
|
||||||
|
"depNameTemplate": "node",
|
||||||
|
"datasourceTemplate": "docker"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+24
-24
@@ -1,11 +1,11 @@
|
|||||||
// One-command bootstrap: idempotent first-boot seeding. Guards the pure payload
|
// One-command bootstrap: idempotent first-boot seeding. Guards the pure payload
|
||||||
// builders (Kratos create-identity body + Keto role tuple), the idempotent seedAdmin
|
// builders (Kratos create-identity body + Keto permission tuple), the idempotent seedAdmin
|
||||||
// orchestration (fresh 201 vs existing 409 → reuse id), and the JWKS generate-if-absent
|
// orchestration (fresh 201 vs existing 409 → reuse id), and the JWKS generate-if-absent
|
||||||
// safety net. Live boot is verified by running the stack; these catch contract drift.
|
// safety net. Live boot is verified by running the stack; these catch contract drift.
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { ensureJwks, firstRunBanner, identityPayload, roleTuple, seedAdmin, seedRoles } from "./bootstrap.ts";
|
import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, seedAdmin, seedPermissions } from "./bootstrap.ts";
|
||||||
|
|
||||||
const json = (status: number, body?: unknown) =>
|
const json = (status: number, body?: unknown) =>
|
||||||
new Response(body === undefined ? null : JSON.stringify(body), {
|
new Response(body === undefined ? null : JSON.stringify(body), {
|
||||||
@@ -20,27 +20,27 @@ test("identityPayload is a valid Kratos create-identity body with a password cre
|
|||||||
assert.equal(body.credentials.password.config.password, "admin");
|
assert.equal(body.credentials.password.config.password, "admin");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("roleTuple grants a role to user:<id> in the Role namespace", () => {
|
test("permissionTuple grants a permission to user:<id> in the Permission namespace", () => {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
assert.deepEqual(roleTuple(id, "admin"), {
|
assert.deepEqual(permissionTuple(id, "admin"), {
|
||||||
namespace: "Role",
|
namespace: "Permission",
|
||||||
object: "admin",
|
object: "admin",
|
||||||
relation: "members",
|
relation: "granted",
|
||||||
subject_id: `user:${id}`,
|
subject_id: `user:${id}`,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("seedRoles unions ADMIN_ROLES (default 'admin') with the discovered plugins' declared tokens", () => {
|
test("seedPermissions unions ADMIN_PERMISSIONS (default 'admin') with the discovered plugins' declared permissions", () => {
|
||||||
// Clean clone: no ADMIN_ROLES, the scheduling plugin declares its two tokens → the demo admin
|
// Clean clone: no ADMIN_PERMISSIONS, the scheduling plugin declares its two tokens → the demo admin
|
||||||
// gets exactly today's behaviour, but derived from discovery, not hardcoded in the host.
|
// gets exactly today's behaviour, but derived from discovery, not hardcoded in the host.
|
||||||
assert.deepEqual(seedRoles(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]);
|
assert.deepEqual(seedPermissions(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]);
|
||||||
assert.deepEqual(seedRoles(undefined, []), ["admin"]); // no plugins → just the base admin role
|
assert.deepEqual(seedPermissions(undefined, []), ["admin"]); // no plugins → just the base admin permission
|
||||||
assert.deepEqual(seedRoles("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended
|
assert.deepEqual(seedPermissions("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended
|
||||||
assert.deepEqual(seedRoles("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant
|
assert.deepEqual(seedPermissions("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant
|
||||||
assert.deepEqual(seedRoles("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides)
|
assert.deepEqual(seedPermissions("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides)
|
||||||
});
|
});
|
||||||
|
|
||||||
test("seedAdmin on a fresh stack creates the identity and grants every role (one tuple each)", async () => {
|
test("seedAdmin on a fresh stack creates the identity and grants every permission (one tuple each)", async () => {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const calls: { method: string; url: string; body?: unknown }[] = [];
|
const calls: { method: string; url: string; body?: unknown }[] = [];
|
||||||
const fetchImpl = (async (url, init) => {
|
const fetchImpl = (async (url, init) => {
|
||||||
@@ -57,20 +57,20 @@ test("seedAdmin on a fresh stack creates the identity and grants every role (one
|
|||||||
ketoWriteUrl: "http://keto:4467",
|
ketoWriteUrl: "http://keto:4467",
|
||||||
kratosAdminUrl: "http://kratos:4434",
|
kratosAdminUrl: "http://kratos:4434",
|
||||||
password: "admin",
|
password: "admin",
|
||||||
roles: ["admin", "scheduling:read"],
|
permissions: ["admin", "scheduling:read"],
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(result, { created: true, id, roles: ["admin", "scheduling:read"] });
|
assert.deepEqual(result, { created: true, id, permissions: ["admin", "scheduling:read"] });
|
||||||
const puts = calls.filter((c) => c.url.includes("relation-tuples"));
|
const puts = calls.filter((c) => c.url.includes("relation-tuples"));
|
||||||
assert.equal(puts.length, 2); // one grant per role
|
assert.equal(puts.length, 2); // one grant per permission
|
||||||
assert.ok(puts.every((p) => p.method === "PUT"));
|
assert.ok(puts.every((p) => p.method === "PUT"));
|
||||||
assert.deepEqual(puts.map((p) => p.body), [
|
assert.deepEqual(puts.map((p) => p.body), [
|
||||||
{ namespace: "Role", object: "admin", relation: "members", subject_id: `user:${id}` },
|
{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${id}` },
|
||||||
{ namespace: "Role", object: "scheduling:read", relation: "members", subject_id: `user:${id}` },
|
{ namespace: "Permission", object: "scheduling:read", relation: "granted", subject_id: `user:${id}` },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants the role", async () => {
|
test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants the permission", async () => {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
let granted: unknown;
|
let granted: unknown;
|
||||||
const fetchImpl = (async (url, init) => {
|
const fetchImpl = (async (url, init) => {
|
||||||
@@ -90,11 +90,11 @@ test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants
|
|||||||
ketoWriteUrl: "http://keto:4467",
|
ketoWriteUrl: "http://keto:4467",
|
||||||
kratosAdminUrl: "http://kratos:4434",
|
kratosAdminUrl: "http://kratos:4434",
|
||||||
password: "admin",
|
password: "admin",
|
||||||
roles: ["admin"],
|
permissions: ["admin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(result, { created: false, id, roles: ["admin"] });
|
assert.deepEqual(result, { created: false, id, permissions: ["admin"] });
|
||||||
assert.deepEqual(granted, { namespace: "Role", object: "admin", relation: "members", subject_id: `user:${id}` });
|
assert.deepEqual(granted, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${id}` });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("seedAdmin fails loud on an unexpected Kratos error", async () => {
|
test("seedAdmin fails loud on an unexpected Kratos error", async () => {
|
||||||
@@ -106,7 +106,7 @@ test("seedAdmin fails loud on an unexpected Kratos error", async () => {
|
|||||||
ketoWriteUrl: "http://keto:4467",
|
ketoWriteUrl: "http://keto:4467",
|
||||||
kratosAdminUrl: "http://kratos:4434",
|
kratosAdminUrl: "http://kratos:4434",
|
||||||
password: "admin",
|
password: "admin",
|
||||||
roles: ["admin"],
|
permissions: ["admin"],
|
||||||
}),
|
}),
|
||||||
/Kratos/,
|
/Kratos/,
|
||||||
);
|
);
|
||||||
|
|||||||
+23
-23
@@ -2,8 +2,8 @@
|
|||||||
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`:
|
// 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);
|
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
|
||||||
// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
|
// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
|
||||||
// 3. grant it its roles in Keto so menu/permission checks resolve out of the box — `admin` plus
|
// 3. grant it its permissions 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
|
// every discovered plugin's declared permission names, so a dropped-in plugin is usable by
|
||||||
// the demo admin with no host config edit (the host stays plugin-agnostic).
|
// the demo admin with no host config edit (the host stays plugin-agnostic).
|
||||||
// Then prints a first-run banner; fails loud on any unexpected upstream error.
|
// Then prints a first-run banner; fails loud on any unexpected upstream error.
|
||||||
import { existsSync, writeFileSync } from "node:fs";
|
import { existsSync, writeFileSync } from "node:fs";
|
||||||
@@ -22,19 +22,19 @@ export function identityPayload(email: string, password: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coarse-role grant: `Role:<role>#members@user:<id>`. Subject ids are `user:<kratos-id>`
|
// Coarse-permission grant: `Permission:<permission>#members@user:<id>`. Subject ids are `user:<kratos-id>`
|
||||||
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT roles.
|
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT permissions.
|
||||||
export function roleTuple(identityId: string, role: string) {
|
export function permissionTuple(userId: string, permission: string) {
|
||||||
return { namespace: "Role", object: role, relation: "members", subject_id: `user:${identityId}` };
|
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// The roles to grant the demo admin = the configured base (ADMIN_ROLES, default just `admin`)
|
// The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, default just `admin`)
|
||||||
// unioned with every discovered plugin's declared permission tokens (a route/nav `permission` is a
|
// unioned with every discovered plugin's declared permission names (a route/nav `permission` is a
|
||||||
// coarse role — granted as a Keto `Role:<token>#members` tuple). So the host names no plugin, yet a
|
// coarse permission — granted as a Keto `Permission:<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.
|
// dropped-in plugin's tokens are seeded out of the box. Deduped, order-stable, blanks dropped.
|
||||||
export function seedRoles(adminRolesEnv: string | undefined, declaredTokens: string[]): string[] {
|
export function seedPermissions(adminRolesEnv: string | undefined, declaredPermissions: string[]): string[] {
|
||||||
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
|
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
|
||||||
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredTokens)])];
|
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredPermissions)])];
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- JWKS safety net -----------------------------------------------------------------
|
// --- JWKS safety net -----------------------------------------------------------------
|
||||||
@@ -63,13 +63,13 @@ export interface SeedOptions {
|
|||||||
ketoWriteUrl: string;
|
ketoWriteUrl: string;
|
||||||
kratosAdminUrl: string;
|
kratosAdminUrl: string;
|
||||||
password: string;
|
password: string;
|
||||||
roles: string[];
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SeedResult {
|
export interface SeedResult {
|
||||||
created: boolean;
|
created: boolean;
|
||||||
id: string;
|
id: string;
|
||||||
roles: string[];
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
|
export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
|
||||||
@@ -93,17 +93,17 @@ export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
|
|||||||
throw new Error(`bootstrap: Kratos create identity failed (${res.status}): ${await res.text()}`);
|
throw new Error(`bootstrap: Kratos create identity failed (${res.status}): ${await res.text()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Grant each role in Keto. PUT is idempotent — re-running just re-asserts the tuple.
|
// Grant each permission in Keto. PUT is idempotent — re-running just re-asserts the tuple.
|
||||||
for (const role of opts.roles) {
|
for (const permission of opts.permissions) {
|
||||||
const grant = await http(`${opts.ketoWriteUrl}/admin/relation-tuples`, {
|
const grant = await http(`${opts.ketoWriteUrl}/admin/relation-tuples`, {
|
||||||
body: JSON.stringify(roleTuple(id, role)),
|
body: JSON.stringify(permissionTuple(id, permission)),
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
});
|
});
|
||||||
if (!grant.ok) throw new Error(`bootstrap: Keto grant role "${role}" failed (${grant.status}): ${await grant.text()}`);
|
if (!grant.ok) throw new Error(`bootstrap: Keto grant permission "${permission}" failed (${grant.status}): ${await grant.text()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { created, id, roles: opts.roles };
|
return { created, id, permissions: opts.permissions };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findIdentityId(http: typeof fetch, adminUrl: string, email: string): Promise<string> {
|
async function findIdentityId(http: typeof fetch, adminUrl: string, email: string): Promise<string> {
|
||||||
@@ -143,10 +143,10 @@ async function main() {
|
|||||||
await runWithLog(log, async () => {
|
await runWithLog(log, async () => {
|
||||||
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
|
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 permission tokens, so the
|
// Seed `admin` (or ADMIN_PERMISSIONS) + every discovered plugin's declared permission names, so the
|
||||||
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
|
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
|
||||||
const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.token));
|
const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.name));
|
||||||
const roles = seedRoles(env["ADMIN_ROLES"], declared);
|
const permissions = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
|
||||||
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
|
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
|
||||||
const password = env["ADMIN_PASSWORD"] ?? "admin";
|
const password = env["ADMIN_PASSWORD"] ?? "admin";
|
||||||
const result = await seedAdmin({
|
const result = await seedAdmin({
|
||||||
@@ -155,9 +155,9 @@ async function main() {
|
|||||||
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
|
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
|
||||||
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
|
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
|
||||||
password,
|
password,
|
||||||
roles,
|
permissions,
|
||||||
});
|
});
|
||||||
log.info("admin seeded", { created: result.created, id: result.id, roles: result.roles.join(", ") });
|
log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") });
|
||||||
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
|
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
|
||||||
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
|
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
// Optional revocation denylist: instant role/session revoke without putting Keto
|
// Optional revocation denylist: instant permission/session revoke without putting Keto
|
||||||
// back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true.
|
// back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true.
|
||||||
//
|
//
|
||||||
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked role or a
|
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked permission or a
|
||||||
// killed session only takes effect when the token is next minted (re-login / TTL refresh) —
|
// killed session only takes effect when the token is next minted (re-login / TTL refresh) —
|
||||||
// up to one token TTL of lag. For security-critical revoke (offboarding, a compromised
|
// up to one token TTL of lag. For security-critical revoke (offboarding, a compromised
|
||||||
// account) that lag is too long. An admin action records the subject as revoked-now and the
|
// account) that lag is too long. An admin action records the subject as revoked-now and the
|
||||||
// hot path then rejects that subject's pre-revoke tokens at once, forcing a re-mint (which
|
// hot path then rejects that subject's pre-revoke tokens at once, forcing a re-mint (which
|
||||||
// re-reads roles from Keto, or clears a now-dead session).
|
// re-reads permissions from Keto, or clears a now-dead session).
|
||||||
//
|
//
|
||||||
// Cost & scope: an in-memory, auto-evicting Map — no database, like the JWKS cache, so it
|
// Cost & scope: an in-memory, auto-evicting Map — no database, like the JWKS cache, so it
|
||||||
// stays inside the stateless model. A token carries `iat`, so a *fresh* re-login (iat after
|
// stays inside the stateless model. A token carries `iat`, so a *fresh* re-login (iat after
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ test("rotateJwks --prune keeps only the newest (first) key, dropping superseded
|
|||||||
test("a JWS signed with a generated key verifies via our own verifier (reads what Kratos signs)", () => {
|
test("a JWS signed with a generated key verifies via our own verifier (reads what Kratos signs)", () => {
|
||||||
const key = generateJwks().keys[0]!;
|
const key = generateJwks().keys[0]!;
|
||||||
const head = b64url(JSON.stringify({ alg: "ES256", kid: key.kid }));
|
const head = b64url(JSON.stringify({ alg: "ES256", kid: key.kid }));
|
||||||
const body = b64url(JSON.stringify({ email: "a@b.c", roles: [], sub: key.kid }));
|
const body = b64url(JSON.stringify({ email: "a@b.c", permissions: [], sub: key.kid }));
|
||||||
const sig = sign("SHA256", Buffer.from(`${head}.${body}`), { dsaEncoding: "ieee-p1363", key: createPrivateKey({ key: key as unknown as JsonWebKey, format: "jwk" }) });
|
const sig = sign("SHA256", Buffer.from(`${head}.${body}`), { dsaEncoding: "ieee-p1363", key: createPrivateKey({ key: key as unknown as JsonWebKey, format: "jwk" }) });
|
||||||
const token = `${head}.${body}.${sig.toString("base64url")}`;
|
const token = `${head}.${body}.${sig.toString("base64url")}`;
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function ctxFor(user: User | null, url = "/"): RequestContext {
|
|||||||
return buildContext(req, new ServerResponse(req), { user });
|
return buildContext(req, new ServerResponse(req), { user });
|
||||||
}
|
}
|
||||||
|
|
||||||
const alice: User = { email: "a@b.c", id: "u1", roles: ["admin", "scheduling:read"] };
|
const alice: User = { email: "a@b.c", id: "u1", permissions: ["admin", "scheduling:read"] };
|
||||||
|
|
||||||
test("requireSession returns the user, or throws GuardError(401)→/login (preserving return_to) when anonymous", () => {
|
test("requireSession returns the user, or throws GuardError(401)→/login (preserving return_to) when anonymous", () => {
|
||||||
assert.equal(requireSession(ctxFor(alice)), alice);
|
assert.equal(requireSession(ctxFor(alice)), alice);
|
||||||
@@ -30,7 +30,7 @@ test("requireSession returns the user, or throws GuardError(401)→/login (prese
|
|||||||
err instanceof GuardError && err.location === "/login?return_to=%2Fscheduling%2Fshifts%3Fq%3D1");
|
err instanceof GuardError && err.location === "/login?return_to=%2Fscheduling%2Fshifts%3Fq%3D1");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("can reads a coarse role from the JWT claims; anonymous has none", () => {
|
test("can reads a coarse permission from the JWT claims; anonymous has none", () => {
|
||||||
assert.equal(can(ctxFor(alice), "admin"), true);
|
assert.equal(can(ctxFor(alice), "admin"), true);
|
||||||
assert.equal(can(ctxFor(alice), "billing:write"), false);
|
assert.equal(can(ctxFor(alice), "billing:write"), false);
|
||||||
assert.equal(can(ctxFor(null), "admin"), false);
|
assert.equal(can(ctxFor(null), "admin"), false);
|
||||||
|
|||||||
+3
-3
@@ -37,9 +37,9 @@ export function requireSession(ctx: RequestContext): User {
|
|||||||
return ctx.user;
|
return ctx.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coarse role check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false.
|
// Coarse permission check straight from the JWT claims — in-process, zero I/O. Anonymous ⇒ false.
|
||||||
export function can(ctx: RequestContext, role: string): boolean {
|
export function can(ctx: RequestContext, permission: string): boolean {
|
||||||
return ctx.roles.includes(role);
|
return ctx.permissions.includes(permission);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live Keto relationship check at the point of action. The subject is the current user;
|
// Live Keto relationship check at the point of action. The subject is the current user;
|
||||||
|
|||||||
@@ -22,15 +22,17 @@ const jwk2: JsonWebKey = { ...(k2.publicKey.export({ format: "jwk" }) as JsonWeb
|
|||||||
const jwks = staticJwks([jwk1, jwk2]); // rotated set: two live keys
|
const jwks = staticJwks([jwk1, jwk2]); // rotated set: two live keys
|
||||||
|
|
||||||
const NOW = 1_700_000_000; // fixed clock for deterministic exp/nbf checks
|
const NOW = 1_700_000_000; // fixed clock for deterministic exp/nbf checks
|
||||||
const valid = { email: "a@b.c", exp: NOW + 600, roles: ["admin"], sub: "u1" };
|
const valid = { email: "a@b.c", exp: NOW + 600, permissions: ["admin"], sub: "u1" };
|
||||||
|
|
||||||
test("verifyToken: a valid token → User, selecting the verify key by kid across a rotated set", async () => {
|
test("verifyToken: a valid token → User, selecting the verify key by kid across a rotated set", async () => {
|
||||||
const user = await verifyToken(mint(k2.privateKey, "k2", valid), jwks, { now: NOW });
|
const user = await verifyToken(mint(k2.privateKey, "k2", valid), jwks, { now: NOW });
|
||||||
assert.deepEqual(user, { email: "a@b.c", id: "u1", roles: ["admin"] });
|
assert.deepEqual(user, { email: "a@b.c", id: "u1", permissions: ["admin"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("verifyToken rejects expiry and future nbf, with clock-skew leeway", async () => {
|
test("verifyToken requires exp, rejects expiry and future nbf, with clock-skew leeway", async () => {
|
||||||
const opts = { clockSkewSec: 60, now: NOW };
|
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/);
|
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.
|
// exp 30s in the past but inside the 60s skew → still accepted.
|
||||||
await verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 30 }), jwks, opts);
|
await verifyToken(mint(k1.privateKey, "k1", { ...valid, exp: NOW - 30 }), jwks, opts);
|
||||||
@@ -57,18 +59,18 @@ 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/);
|
await assert.rejects(verifyToken(mint(k1.privateKey, "nope", valid), jwks, { now: NOW }), /no JWKS key/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("claimsToUser requires sub + email, defaults roles to [], keeps only string roles", () => {
|
test("claimsToUser requires sub + email, defaults permissions to [], keeps only string permissions", () => {
|
||||||
assert.throws(() => claimsToUser({ email: "a@b.c", exp: NOW }), /sub/);
|
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({ email: "a@b.c", exp: NOW, sub: "" }), /sub/); // empty sub rejected too
|
||||||
assert.throws(() => claimsToUser({ exp: NOW, sub: "u" }), /email/);
|
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.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", sub: "u" }).permissions, []); // permissions absent
|
||||||
assert.deepEqual(claimsToUser({ email: "a@b.c", roles: ["a", 1, "b"], sub: "u" }).roles, ["a", "b"]);
|
assert.deepEqual(claimsToUser({ email: "a@b.c", permissions: ["a", 1, "b"], sub: "u" }).permissions, ["a", "b"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("resolveSession classifies the cookie; authenticate is its fail-closed user 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 cookie = (extra: Record<string, unknown> = {}, kid = "k1") => `${SESSION_COOKIE}=${mint(k1.privateKey, kid, { ...valid, ...extra })}`;
|
||||||
const user = { email: "a@b.c", id: "u1", roles: ["admin"] };
|
const user = { email: "a@b.c", id: "u1", permissions: ["admin"] };
|
||||||
|
|
||||||
// A valid token → the user, not expired.
|
// A valid token → the user, not expired.
|
||||||
assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, user });
|
assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, user });
|
||||||
@@ -94,6 +96,6 @@ test("verifyToken honours an optional denylist: a revoked subject's token reject
|
|||||||
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 }), jwks, { denylist, now: NOW }), /revoked/);
|
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, user: 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.
|
// 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"] });
|
assert.deepEqual(await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW + 5 }), jwks, { denylist, now: NOW }), { email: "a@b.c", id: "u1", permissions: ["admin"] });
|
||||||
await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5, sub: "u2" }), jwks, { denylist, now: NOW });
|
await verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5, sub: "u2" }), jwks, { denylist, now: NOW });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,15 +59,15 @@ export function validateClaims(payload: Record<string, unknown>, options: Verify
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Map verified claims → the request User. sub/email are required and non-empty (the tokenizer
|
// 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
|
// always sets them; an empty email would read as anonymous in the shell); permissions defaults to [] and
|
||||||
// keeps only string entries (defensive).
|
// keeps only string entries (defensive).
|
||||||
export function claimsToUser(payload: Record<string, unknown>): User {
|
export function claimsToUser(payload: Record<string, unknown>): User {
|
||||||
const sub = payload["sub"];
|
const sub = payload["sub"];
|
||||||
if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub");
|
if (typeof sub !== "string" || sub === "") throw new TokenError("token missing sub");
|
||||||
const email = payload["email"];
|
const email = payload["email"];
|
||||||
if (typeof email !== "string" || email === "") throw new TokenError("token missing email");
|
if (typeof email !== "string" || email === "") throw new TokenError("token missing email");
|
||||||
const roles = payload["roles"];
|
const permissions = payload["permissions"];
|
||||||
return { email, id: sub, roles: Array.isArray(roles) ? roles.filter((r): r is string => typeof r === "string") : [] };
|
return { email, id: sub, permissions: Array.isArray(permissions) ? permissions.filter((r): r is string => typeof r === "string") : [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify a session JWT end-to-end: select the key by `kid`, check the signature, validate
|
// Verify a session JWT end-to-end: select the key by `kid`, check the signature, validate
|
||||||
@@ -80,7 +80,7 @@ export async function verifyToken(token: string, jwks: JwksProvider, options: Ve
|
|||||||
validateClaims(verified.payload, options);
|
validateClaims(verified.payload, options);
|
||||||
const user = claimsToUser(verified.payload);
|
const user = claimsToUser(verified.payload);
|
||||||
// Instant revoke: a denylisted subject's pre-revoke token is rejected as *expired* so
|
// 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).
|
// resolveSession routes it through the re-mint (fresh permissions from Keto, or a cleared session).
|
||||||
if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true);
|
if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ const rsaJwk = rsa.publicKey.export({ format: "jwk" }) as JsonWebKey;
|
|||||||
const ecJwk = ec.publicKey.export({ format: "jwk" }) as JsonWebKey;
|
const ecJwk = ec.publicKey.export({ format: "jwk" }) as JsonWebKey;
|
||||||
|
|
||||||
test("verifies an RS256 token, returning the decoded header + payload", () => {
|
test("verifies an RS256 token, returning the decoded header + payload", () => {
|
||||||
const token = makeJws("RS256", rsa.privateKey, { roles: ["admin"], sub: "u" });
|
const token = makeJws("RS256", rsa.privateKey, { permissions: ["admin"], sub: "u" });
|
||||||
const verified = verifyJws(token, rsaJwk);
|
const verified = verifyJws(token, rsaJwk);
|
||||||
assert.equal(verified.header.alg, "RS256");
|
assert.equal(verified.header.alg, "RS256");
|
||||||
assert.deepEqual(verified.payload, { roles: ["admin"], sub: "u" });
|
assert.deepEqual(verified.payload, { permissions: ["admin"], sub: "u" });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("verifies an ES256 token (raw r‖s signature)", () => {
|
test("verifies an ES256 token (raw r‖s signature)", () => {
|
||||||
@@ -35,10 +35,10 @@ test("verifies an ES256 token (raw r‖s signature)", () => {
|
|||||||
|
|
||||||
// All three reach and fail the signature check itself, not an earlier structural guard.
|
// All three reach and fail the signature check itself, not an earlier structural guard.
|
||||||
test("rejects a signature that fails verification (tampered payload, wrong key, empty)", () => {
|
test("rejects a signature that fails verification (tampered payload, wrong key, empty)", () => {
|
||||||
const token = makeJws("RS256", rsa.privateKey, { roles: ["user"], sub: "u" });
|
const token = makeJws("RS256", rsa.privateKey, { permissions: ["user"], sub: "u" });
|
||||||
const [header, payload, signature] = token.split(".");
|
const [header, payload, signature] = token.split(".");
|
||||||
|
|
||||||
const forged = `${header}.${b64url(JSON.stringify({ roles: ["admin"], sub: "u" }))}.${signature}`;
|
const forged = `${header}.${b64url(JSON.stringify({ permissions: ["admin"], sub: "u" }))}.${signature}`;
|
||||||
assert.throws(() => verifyJws(forged, rsaJwk), /invalid signature/);
|
assert.throws(() => verifyJws(forged, rsaJwk), /invalid signature/);
|
||||||
|
|
||||||
const otherJwk = generateKeyPairSync("rsa", { modulusLength: 2048 }).publicKey.export({ format: "jwk" }) as JsonWebKey;
|
const otherJwk = generateKeyPairSync("rsa", { modulusLength: 2048 }).publicKey.export({ format: "jwk" }) as JsonWebKey;
|
||||||
|
|||||||
@@ -29,13 +29,13 @@ const keto = (fetchImpl: typeof fetch) => createKetoClient({ fetchImpl, readUrl:
|
|||||||
|
|
||||||
test("check GETs the read API and returns the allowed boolean (true and false)", async () => {
|
test("check GETs the read API and returns the allowed boolean (true and false)", async () => {
|
||||||
const allow = recorder(() => res(200, { allowed: true }));
|
const allow = recorder(() => res(200, { allowed: true }));
|
||||||
assert.equal(await keto(allow.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: USER }), true);
|
assert.equal(await keto(allow.fetchImpl).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }), true);
|
||||||
assert.match(allow.calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/check\?/);
|
assert.match(allow.calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/check\?/);
|
||||||
assert.match(allow.calls[0]!.url, /namespace=Role&object=admin&relation=members/);
|
assert.match(allow.calls[0]!.url, /namespace=Permission&object=admin&relation=granted/);
|
||||||
assert.match(allow.calls[0]!.url, new RegExp(`subject_id=${encodeURIComponent(USER).replace(/[.]/g, "\\.")}`));
|
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.
|
// A denied check is 403 {allowed:false} (not a 200) — both statuses carry the verdict.
|
||||||
const deny = recorder(() => res(403, { allowed: false }));
|
const deny = recorder(() => res(403, { allowed: false }));
|
||||||
assert.equal(await keto(deny.fetchImpl).check({ namespace: "Role", object: "admin", relation: "members", subject_id: "user:nobody" }), false);
|
assert.equal(await keto(deny.fetchImpl).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "user:nobody" }), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("check on a subject_set builds subject_set.* params and forwards max-depth", async () => {
|
test("check on a subject_set builds subject_set.* params and forwards max-depth", async () => {
|
||||||
@@ -51,20 +51,20 @@ test("check on a subject_set builds subject_set.* params and forwards max-depth"
|
|||||||
|
|
||||||
test("check throws a KetoError carrying the status on an unexpected response", async () => {
|
test("check throws a KetoError carrying the status on an unexpected response", async () => {
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
keto((async () => res(400, { error: "bad" })) as typeof fetch).check({ namespace: "Role", object: "admin", relation: "members", subject_id: USER }),
|
keto((async () => res(400, { error: "bad" })) as typeof fetch).check({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }),
|
||||||
(e: unknown) => e instanceof KetoError && e.status === 400,
|
(e: unknown) => e instanceof KetoError && e.status === 400,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("listRelations builds the filter query + pagination and parses next_page_token", async () => {
|
test("listRelations builds the filter query + pagination and parses next_page_token", async () => {
|
||||||
const tuples = [{ namespace: "Role", object: "admin", relation: "members", subject_id: USER }];
|
const tuples = [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER }];
|
||||||
const { calls, fetchImpl } = recorder(() => res(200, { next_page_token: "NEXT", relation_tuples: tuples }));
|
const { calls, fetchImpl } = recorder(() => res(200, { next_page_token: "NEXT", relation_tuples: tuples }));
|
||||||
const out = await keto(fetchImpl).listRelations({ namespace: "Role", object: "admin", pageSize: 10, pageToken: "CUR", relation: "members" });
|
const out = await keto(fetchImpl).listRelations({ namespace: "Permission", object: "admin", pageSize: 10, pageToken: "CUR", relation: "granted" });
|
||||||
assert.deepEqual(out.tuples, tuples);
|
assert.deepEqual(out.tuples, tuples);
|
||||||
assert.equal(out.nextPageToken, "NEXT");
|
assert.equal(out.nextPageToken, "NEXT");
|
||||||
const url = calls[0]!.url;
|
const url = calls[0]!.url;
|
||||||
assert.match(url, /^http:\/\/keto:4466\/relation-tuples\?/);
|
assert.match(url, /^http:\/\/keto:4466\/relation-tuples\?/);
|
||||||
assert.match(url, /namespace=Role&object=admin&relation=members/);
|
assert.match(url, /namespace=Permission&object=admin&relation=granted/);
|
||||||
assert.match(url, /page_size=10&page_token=CUR/);
|
assert.match(url, /page_size=10&page_token=CUR/);
|
||||||
// No Link header / token in the body ⇒ null, empty list ⇒ [].
|
// No Link header / token in the body ⇒ null, empty list ⇒ [].
|
||||||
const empty = await keto((async () => res(200, {})) as typeof fetch).listRelations();
|
const empty = await keto((async () => res(200, {})) as typeof fetch).listRelations();
|
||||||
@@ -72,16 +72,16 @@ test("listRelations builds the filter query + pagination and parses next_page_to
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("expand GETs the read API for a subject set and returns the tree (with max-depth)", async () => {
|
test("expand GETs the read API for a subject set and returns the tree (with max-depth)", async () => {
|
||||||
const tree = { children: [{ tuple: { namespace: "", object: "", relation: "", subject_id: USER }, type: "leaf" }], tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Role", object: "admin", relation: "members" } }, type: "union" };
|
const tree = { children: [{ tuple: { namespace: "", object: "", relation: "", subject_id: USER }, type: "leaf" }], tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Permission", object: "admin", relation: "granted" } }, type: "union" };
|
||||||
const { calls, fetchImpl } = recorder(() => res(200, tree));
|
const { calls, fetchImpl } = recorder(() => res(200, tree));
|
||||||
const out = await keto(fetchImpl).expand({ namespace: "Role", object: "admin", relation: "members" }, { maxDepth: 3 });
|
const out = await keto(fetchImpl).expand({ namespace: "Permission", object: "admin", relation: "granted" }, { maxDepth: 3 });
|
||||||
assert.deepEqual(out, tree);
|
assert.deepEqual(out, tree);
|
||||||
assert.match(calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/expand\?/);
|
assert.match(calls[0]!.url, /^http:\/\/keto:4466\/relation-tuples\/expand\?/);
|
||||||
assert.match(calls[0]!.url, /namespace=Role&object=admin&relation=members&max-depth=3/);
|
assert.match(calls[0]!.url, /namespace=Permission&object=admin&relation=granted&max-depth=3/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("writeTuple PUTs the tuple as JSON to the write API (idempotent; non-2xx throws)", async () => {
|
test("writeTuple PUTs the tuple as JSON to the write API (idempotent; non-2xx throws)", async () => {
|
||||||
const tuple = { namespace: "Role", object: "admin", relation: "members", subject_id: USER };
|
const tuple = { namespace: "Permission", object: "admin", relation: "granted", subject_id: USER };
|
||||||
const { calls, fetchImpl } = recorder(() => res(201, tuple));
|
const { calls, fetchImpl } = recorder(() => res(201, tuple));
|
||||||
await keto(fetchImpl).writeTuple(tuple);
|
await keto(fetchImpl).writeTuple(tuple);
|
||||||
assert.equal(calls[0]!.method, "PUT");
|
assert.equal(calls[0]!.method, "PUT");
|
||||||
@@ -95,12 +95,12 @@ test("writeTuple PUTs the tuple as JSON to the write API (idempotent; non-2xx th
|
|||||||
|
|
||||||
test("deleteTuple DELETEs the write API by query params (204 resolves; non-204 throws)", async () => {
|
test("deleteTuple DELETEs the write API by query params (204 resolves; non-204 throws)", async () => {
|
||||||
const { calls, fetchImpl } = recorder(() => res(204));
|
const { calls, fetchImpl } = recorder(() => res(204));
|
||||||
await keto(fetchImpl).deleteTuple({ namespace: "Role", object: "admin", relation: "members", subject_id: USER });
|
await keto(fetchImpl).deleteTuple({ namespace: "Permission", object: "admin", relation: "granted", subject_id: USER });
|
||||||
assert.equal(calls[0]!.method, "DELETE");
|
assert.equal(calls[0]!.method, "DELETE");
|
||||||
assert.match(calls[0]!.url, /^http:\/\/keto:4467\/admin\/relation-tuples\?/);
|
assert.match(calls[0]!.url, /^http:\/\/keto:4467\/admin\/relation-tuples\?/);
|
||||||
assert.match(calls[0]!.url, /namespace=Role&object=admin&relation=members/);
|
assert.match(calls[0]!.url, /namespace=Permission&object=admin&relation=granted/);
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
keto((async () => res(404)) as typeof fetch).deleteTuple({ namespace: "Role", object: "x", relation: "members", subject_id: USER }),
|
keto((async () => res(404)) as typeof fetch).deleteTuple({ namespace: "Permission", object: "x", relation: "granted", subject_id: USER }),
|
||||||
(e: unknown) => e instanceof KetoError && e.status === 404,
|
(e: unknown) => e instanceof KetoError && e.status === 404,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export interface SubjectSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A relationship tuple — the wire shape for writes and the filter shape for reads. Subject
|
// A relationship tuple — the wire shape for writes and the filter shape for reads. Subject
|
||||||
// is `subject_id` xor `subject_set` (never both). Mirrors bootstrap.ts's roleTuple.
|
// is `subject_id` xor `subject_set` (never both). Mirrors bootstrap.ts's permissionTuple.
|
||||||
export interface RelationTuple {
|
export interface RelationTuple {
|
||||||
namespace: string;
|
namespace: string;
|
||||||
object: string;
|
object: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Kratos admin-API client: typed fetch wrappers over Ory Kratos' admin endpoints —
|
// Kratos admin-API client: typed fetch wrappers over Ory Kratos' admin endpoints —
|
||||||
// identity CRUD + the surgical metadata_public update the login flow projects roles into.
|
// identity CRUD + the surgical metadata_public update the login flow projects permissions into.
|
||||||
// Guards the request contracts (URLs, method, JSON-Patch body, query/pagination) and the
|
// Guards the request contracts (URLs, method, JSON-Patch body, query/pagination) and the
|
||||||
// result mapping (201/200/404/4xx). Live wiring is verified by login completion.
|
// result mapping (201/200/404/4xx). Live wiring is verified by login completion.
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
@@ -90,13 +90,13 @@ test("updateIdentity PUTs the full body to /admin/identities/<id> and returns th
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("updateMetadataPublic PATCHes a JSON-Patch `add /metadata_public` so it never clobbers traits", async () => {
|
test("updateMetadataPublic PATCHes a JSON-Patch `add /metadata_public` so it never clobbers traits", async () => {
|
||||||
const identity = { id: ID, metadata_public: { roles: ["admin"] } };
|
const identity = { id: ID, metadata_public: { permissions: ["admin"] } };
|
||||||
const { calls, fetchImpl } = recorder(() => res(200, identity));
|
const { calls, fetchImpl } = recorder(() => res(200, identity));
|
||||||
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataPublic(ID, { roles: ["admin"] });
|
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).updateMetadataPublic(ID, { permissions: ["admin"] });
|
||||||
assert.deepEqual(out, identity);
|
assert.deepEqual(out, identity);
|
||||||
assert.equal(calls[0]!.method, "PATCH");
|
assert.equal(calls[0]!.method, "PATCH");
|
||||||
assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`));
|
assert.match(calls[0]!.url, new RegExp(`/admin/identities/${ID}$`));
|
||||||
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_public", value: { roles: ["admin"] } }]);
|
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ op: "add", path: "/metadata_public", value: { permissions: ["admin"] } }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("createRecoveryCode POSTs the identity id to /admin/recovery/code → { code, link }", async () => {
|
test("createRecoveryCode POSTs the identity id to /admin/recovery/code → { code, link }", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Kratos admin-API client: typed `fetch` wrappers over Ory Kratos' admin endpoints
|
// Kratos admin-API client: typed `fetch` wrappers over Ory Kratos' admin endpoints
|
||||||
// (internal-only admin port) — identity CRUD + the surgical `metadata_public` update login
|
// (internal-only admin port) — identity CRUD + the surgical `metadata_public` update login
|
||||||
// completion projects Keto roles into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
|
// completion projects Keto permissions into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
|
||||||
// `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`).
|
// `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`).
|
||||||
import { KratosError } from "./kratos-public.ts";
|
import { KratosError } from "./kratos-public.ts";
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ export interface RecoveryCode {
|
|||||||
|
|
||||||
export interface KratosAdmin {
|
export interface KratosAdmin {
|
||||||
createIdentity(payload: unknown): Promise<Identity>;
|
createIdentity(payload: unknown): Promise<Identity>;
|
||||||
createRecoveryCode(identityId: string, opts?: { expiresIn?: string }): Promise<RecoveryCode>;
|
createRecoveryCode(userId: string, opts?: { expiresIn?: string }): Promise<RecoveryCode>;
|
||||||
deleteIdentity(id: string): Promise<void>;
|
deleteIdentity(id: string): Promise<void>;
|
||||||
getIdentity(id: string): Promise<Identity | null>;
|
getIdentity(id: string): Promise<Identity | null>;
|
||||||
listIdentities(opts?: ListOptions): Promise<IdentityList>;
|
listIdentities(opts?: ListOptions): Promise<IdentityList>;
|
||||||
@@ -67,8 +67,8 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
|
|||||||
|
|
||||||
// Mint a recovery code for an identity (admin "trigger recovery") — the link is mailed to the
|
// Mint a recovery code for an identity (admin "trigger recovery") — the link is mailed to the
|
||||||
// user by Kratos; the code/link are also returned so an operator can hand them over directly.
|
// user by Kratos; the code/link are also returned so an operator can hand them over directly.
|
||||||
async createRecoveryCode(identityId, opts = {}) {
|
async createRecoveryCode(userId, opts = {}) {
|
||||||
const body: Record<string, unknown> = { identity_id: identityId };
|
const body: Record<string, unknown> = { identity_id: userId };
|
||||||
if (opts.expiresIn) body.expires_in = opts.expiresIn;
|
if (opts.expiresIn) body.expires_in = opts.expiresIn;
|
||||||
const res = await http(`${base}/admin/recovery/code`, { body: JSON.stringify(body), headers: json, method: "POST" });
|
const res = await http(`${base}/admin/recovery/code`, { body: JSON.stringify(body), headers: json, method: "POST" });
|
||||||
if (res.status !== 200 && res.status !== 201) return fail("create recovery code", res);
|
if (res.status !== 200 && res.status !== 201) return fail("create recovery code", res);
|
||||||
@@ -106,7 +106,7 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
|
|||||||
},
|
},
|
||||||
|
|
||||||
// JSON Patch `add` sets metadata_public whether it's currently absent, null, or set, and
|
// JSON Patch `add` sets metadata_public whether it's currently absent, null, or set, and
|
||||||
// touches nothing else — so the login role projection never clobbers traits/state.
|
// touches nothing else — so the login permission projection never clobbers traits/state.
|
||||||
// (metadata_public, not _admin: the session the tokenizer sees carries only public metadata.)
|
// (metadata_public, not _admin: the session the tokenizer sees carries only public metadata.)
|
||||||
async updateMetadataPublic(id, metadata) {
|
async updateMetadataPublic(id, metadata) {
|
||||||
const patch = [{ op: "add", path: "/metadata_public", value: metadata }];
|
const patch = [{ op: "add", path: "/metadata_public", value: metadata }];
|
||||||
|
|||||||
+20
-20
@@ -1,4 +1,4 @@
|
|||||||
// Login completion: turn a Kratos session into our session JWT — read roles from Keto,
|
// Login completion: turn a Kratos session into our session JWT — read permissions from Keto,
|
||||||
// project them onto the identity, tokenize, build the cookie. Fakes the three Ory clients;
|
// project them onto the identity, tokenize, build the cookie. Fakes the three Ory clients;
|
||||||
// the live, full-stack login is verified by the Playwright E2E.
|
// the live, full-stack login is verified by the Playwright E2E.
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
@@ -6,10 +6,10 @@ import assert from "node:assert/strict";
|
|||||||
import type { KetoClient, RelationTuple } from "./keto-client.ts";
|
import type { KetoClient, RelationTuple } from "./keto-client.ts";
|
||||||
import type { Identity, KratosAdmin } from "./kratos-admin.ts";
|
import type { Identity, KratosAdmin } from "./kratos-admin.ts";
|
||||||
import type { KratosPublic, Session } from "./kratos-public.ts";
|
import type { KratosPublic, Session } from "./kratos-public.ts";
|
||||||
import { completeLogin, readRoles, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts";
|
import { completeLogin, readPermissions, remintSession, SESSION_COOKIE, sessionCookie } from "./login.ts";
|
||||||
|
|
||||||
const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
|
const ID = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
|
||||||
const roleTuple = (object: string): RelationTuple => ({ namespace: "Role", object, relation: "members", subject_id: `user:${ID}` });
|
const permissionTuple = (object: string): RelationTuple => ({ namespace: "Permission", object, relation: "granted", subject_id: `user:${ID}` });
|
||||||
|
|
||||||
const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
|
const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
|
||||||
check: async () => false,
|
check: async () => false,
|
||||||
@@ -40,32 +40,32 @@ const publicStub = (over: Partial<KratosPublic> = {}): KratosPublic => ({
|
|||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
test("readRoles returns roles held directly OR transitively (enumerate defined roles → Keto-check each)", async () => {
|
test("readPermissions returns permissions held directly OR transitively (enumerate defined permissions → Keto-check each)", async () => {
|
||||||
const listQ: unknown[] = [];
|
const listQ: unknown[] = [];
|
||||||
const checked: string[] = [];
|
const checked: string[] = [];
|
||||||
const role = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Role", object, relation: "members", ...subject });
|
const permission = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Permission", object, relation: "granted", ...subject });
|
||||||
const keto = ketoStub({
|
const keto = ketoStub({
|
||||||
// Enumerate every Role tuple (paged, no subject filter) to find the distinct role names —
|
// Enumerate every Permission tuple (paged, no subject filter) to find the distinct permission names —
|
||||||
// subjects vary (a direct user, a group) and a name repeats across pages → de-duped.
|
// subjects vary (a direct user, a group) and a name repeats across pages → de-duped.
|
||||||
listRelations: async (q) => {
|
listRelations: async (q) => {
|
||||||
listQ.push(q);
|
listQ.push(q);
|
||||||
if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [role("editor", { subject_id: "user:other" })] };
|
if (q?.pageToken === "p2") return { nextPageToken: null, tuples: [permission("editor", { subject_id: "user:other" })] };
|
||||||
return { nextPageToken: "p2", tuples: [
|
return { nextPageToken: "p2", tuples: [
|
||||||
role("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
|
permission("editor", { subject_set: { namespace: "Group", object: "eng", relation: "members" } }),
|
||||||
role("admin", { subject_id: `user:${ID}` }),
|
permission("admin", { subject_id: `user:${ID}` }),
|
||||||
role("viewer", { subject_id: "user:stranger" }),
|
permission("viewer", { subject_id: "user:stranger" }),
|
||||||
] };
|
] };
|
||||||
},
|
},
|
||||||
// Keto resolves transitively: the user holds editor (via a group) + admin (direct), not viewer.
|
// Keto resolves transitively: the user holds editor (via a group) + admin (direct), not viewer.
|
||||||
check: async (t) => { checked.push(t.object); return t.object === "admin" || t.object === "editor"; },
|
check: async (t) => { checked.push(t.object); return t.object === "admin" || t.object === "editor"; },
|
||||||
});
|
});
|
||||||
assert.deepEqual(await readRoles(keto, ID), ["admin", "editor"]);
|
assert.deepEqual(await readPermissions(keto, ID), ["admin", "editor"]);
|
||||||
assert.deepEqual(listQ[0], { namespace: "Role", relation: "members" }); // enumerate, not subject-filtered
|
assert.deepEqual(listQ[0], { namespace: "Permission", relation: "granted" }); // enumerate, not subject-filtered
|
||||||
assert.equal((listQ[1] as { pageToken?: string }).pageToken, "p2"); // second page follows the cursor
|
assert.equal((listQ[1] as { pageToken?: string }).pageToken, "p2"); // second page follows the cursor
|
||||||
assert.deepEqual(checked.sort(), ["admin", "editor", "viewer"]); // every distinct role checked for the user
|
assert.deepEqual(checked.sort(), ["admin", "editor", "viewer"]); // every distinct permission checked for the user
|
||||||
});
|
});
|
||||||
|
|
||||||
test("completeLogin: read roles → project onto metadata_public → tokenize → JWT (in that order)", async () => {
|
test("completeLogin: read permissions → project onto metadata_public → tokenize → JWT (in that order)", async () => {
|
||||||
const events: string[] = [];
|
const events: string[] = [];
|
||||||
let projected: unknown;
|
let projected: unknown;
|
||||||
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
||||||
@@ -76,11 +76,11 @@ test("completeLogin: read roles → project onto metadata_public → tokenize
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const kratosAdmin = adminStub({ updateMetadataPublic: async (_id, meta) => { events.push("project"); projected = meta; return identity; } });
|
const kratosAdmin = adminStub({ updateMetadataPublic: async (_id, meta) => { events.push("project"); projected = meta; return identity; } });
|
||||||
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) });
|
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [permissionTuple("admin")] }) });
|
||||||
|
|
||||||
const out = await completeLogin({ keto, kratosAdmin, kratosPublic }, "plainpages_session=s");
|
const out = await completeLogin({ keto, kratosAdmin, kratosPublic }, "plainpages_session=s");
|
||||||
assert.deepEqual(out, { email: "admin@plainpages.local", identityId: ID, jwt: "h.p.s", roles: ["admin"] });
|
assert.deepEqual(out, { email: "admin@plainpages.local", userId: ID, jwt: "h.p.s", permissions: ["admin"] });
|
||||||
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles, projected for the tokenizer
|
assert.deepEqual(projected, { permissions: ["admin"] }); // Keto permissions, projected for the tokenizer
|
||||||
assert.deepEqual(events, ["whoami", "project", "tokenize"]); // projection MUST precede tokenize
|
assert.deepEqual(events, ["whoami", "project", "tokenize"]); // projection MUST precede tokenize
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -101,11 +101,11 @@ test("completeLogin maps a missing email trait to null and throws if the tokeniz
|
|||||||
test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => {
|
test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => {
|
||||||
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
||||||
const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
|
const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
|
||||||
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [roleTuple("admin")] }) });
|
const keto = ketoStub({ check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [permissionTuple("admin")] }) });
|
||||||
|
|
||||||
// TTL lapsed but the Kratos session lives → re-read roles from Keto, re-tokenize, fresh cookie.
|
// TTL lapsed but the Kratos session lives → re-read permissions from Keto, re-tokenize, fresh cookie.
|
||||||
const live = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic }, "plainpages_session=s");
|
const live = await remintSession({ keto, kratosAdmin: adminStub(), kratosPublic }, "plainpages_session=s");
|
||||||
assert.deepEqual(live.user, { email: "admin@plainpages.local", id: ID, roles: ["admin"] });
|
assert.deepEqual(live.user, { email: "admin@plainpages.local", id: ID, permissions: ["admin"] });
|
||||||
assert.match(live.setCookie, /^plainpages_jwt=h\.p\.s;.*Max-Age=2592000.*HttpOnly/);
|
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.
|
// Kratos session also gone → clear the stale JWT so the next request falls through to anonymous.
|
||||||
|
|||||||
+21
-21
@@ -1,9 +1,9 @@
|
|||||||
// Login completion: turn a fresh Kratos session into our locally-verifiable
|
// Login completion: turn a fresh Kratos session into our locally-verifiable
|
||||||
// session JWT — the one moment Ory is on the path (README: Login → session JWT):
|
// session JWT — the one moment Ory is on the path (README: Login → session JWT):
|
||||||
// 1. whoami(cookie) → the identity (id, email); no active session ⇒ null
|
// 1. whoami(cookie) → the identity (id, email); no active session ⇒ null
|
||||||
// 2. read roles from Keto → the source of truth for the `roles` claim
|
// 2. read permissions from Keto → the source of truth for the `permissions` claim
|
||||||
// 3. project onto metadata_public (admin API) so the tokenizer's mapper can read them
|
// 3. project onto metadata_public (admin API) so the tokenizer's mapper can read them
|
||||||
// 4. whoami(tokenize_as) → the signed JWT { sub, email, roles }, stored as our cookie
|
// 4. whoami(tokenize_as) → the signed JWT { sub, email, permissions }, stored as our cookie
|
||||||
// Order matters: the projection is written before tokenizing, because the claims mapper
|
// Order matters: the projection is written before tokenizing, because the claims mapper
|
||||||
// reads only the identity, never Keto.
|
// reads only the identity, never Keto.
|
||||||
import type { User } from "../http/context.ts";
|
import type { User } from "../http/context.ts";
|
||||||
@@ -32,46 +32,46 @@ export interface LoginDeps {
|
|||||||
|
|
||||||
export interface CompletedLogin {
|
export interface CompletedLogin {
|
||||||
email: string | null;
|
email: string | null;
|
||||||
identityId: string;
|
userId: string;
|
||||||
jwt: string;
|
jwt: string;
|
||||||
roles: string[];
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// The coarse roles a user holds — directly (`Role:<name>#members@user:<id>`) or transitively via a
|
// The coarse permissions a user holds — directly (`Permission:<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
|
// group that is a member of the permission. Enumerates the defined permissions (the distinct objects in the Permission
|
||||||
// namespace) and asks Keto to resolve each membership, so a role granted to a group reaches the JWT —
|
// namespace) and asks Keto to resolve each membership, so a permission granted to a group reaches the JWT —
|
||||||
// matching the OPL model and the admin "Effective access" view. At login/refresh only, never per
|
// 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.
|
// request; permission count is small, so the per-permission checks are cheap and run in parallel.
|
||||||
export async function readRoles(keto: KetoClient, identityId: string): Promise<string[]> {
|
export async function readPermissions(keto: KetoClient, userId: string): Promise<string[]> {
|
||||||
const subject_id = `user:${identityId}`;
|
const subject_id = `user:${userId}`;
|
||||||
const names = new Set<string>();
|
const names = new Set<string>();
|
||||||
let pageToken: string | undefined;
|
let pageToken: string | undefined;
|
||||||
do {
|
do {
|
||||||
const page = await keto.listRelations({ namespace: "Role", relation: "members", ...(pageToken ? { pageToken } : {}) });
|
const page = await keto.listRelations({ namespace: "Permission", relation: "granted", ...(pageToken ? { pageToken } : {}) });
|
||||||
for (const t of page.tuples) names.add(t.object);
|
for (const t of page.tuples) names.add(t.object);
|
||||||
pageToken = page.nextPageToken ?? undefined;
|
pageToken = page.nextPageToken ?? undefined;
|
||||||
} while (pageToken);
|
} while (pageToken);
|
||||||
const roles = [...names];
|
const permissions = [...names];
|
||||||
const held = await Promise.all(roles.map((object) => keto.check({ namespace: "Role", object, relation: "members", subject_id })));
|
const held = await Promise.all(permissions.map((object) => keto.check({ namespace: "Permission", object, relation: "granted", subject_id })));
|
||||||
return roles.filter((_, i) => held[i]).sort();
|
return permissions.filter((_, i) => held[i]).sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function completeLogin(deps: LoginDeps, cookie: string | undefined): Promise<CompletedLogin | null> {
|
export async function completeLogin(deps: LoginDeps, cookie: string | undefined): Promise<CompletedLogin | null> {
|
||||||
const session = await deps.kratosPublic.whoami(cookie ? { cookie } : {});
|
const session = await deps.kratosPublic.whoami(cookie ? { cookie } : {});
|
||||||
if (!session?.identity) return null;
|
if (!session?.identity) return null;
|
||||||
const identityId = session.identity.id;
|
const userId = session.identity.id;
|
||||||
const emailTrait = session.identity.traits?.["email"];
|
const emailTrait = session.identity.traits?.["email"];
|
||||||
const email = typeof emailTrait === "string" ? emailTrait : null;
|
const email = typeof emailTrait === "string" ? emailTrait : null;
|
||||||
|
|
||||||
const roles = await readRoles(deps.keto, identityId);
|
const permissions = await readPermissions(deps.keto, userId);
|
||||||
await deps.kratosAdmin.updateMetadataPublic(identityId, { roles });
|
await deps.kratosAdmin.updateMetadataPublic(userId, { permissions });
|
||||||
|
|
||||||
const tokenized = await deps.kratosPublic.whoami({ ...(cookie ? { cookie } : {}), tokenizeAs: TOKENIZE_AS });
|
const tokenized = await deps.kratosPublic.whoami({ ...(cookie ? { cookie } : {}), tokenizeAs: TOKENIZE_AS });
|
||||||
const jwt = tokenized?.tokenized;
|
const jwt = tokenized?.tokenized;
|
||||||
if (!jwt) throw new Error("login completion: Kratos tokenizer returned no JWT");
|
if (!jwt) throw new Error("login completion: Kratos tokenizer returned no JWT");
|
||||||
|
|
||||||
currentLog()?.info("session minted", { roles: roles.join(","), sub: identityId }); // login or TTL re-mint
|
currentLog()?.info("session minted", { permissions: permissions.join(","), sub: userId }); // login or TTL re-mint
|
||||||
return { email, identityId, jwt, roles };
|
return { email, userId, jwt, permissions };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Reminted {
|
export interface Reminted {
|
||||||
@@ -80,14 +80,14 @@ export interface Reminted {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Re-mint the session JWT on TTL expiry — "stay signed in" (README): the ~10m token lapsed but
|
// Re-mint the session JWT on TTL expiry — "stay signed in" (README): the ~10m token lapsed but
|
||||||
// the long-lived Kratos session may still be live. A live session ⇒ re-read roles from Keto,
|
// the long-lived Kratos session may still be live. A live session ⇒ re-read permissions from Keto,
|
||||||
// re-tokenize, fresh cookie + the refreshed user (the one moment authz recomputes). A dead
|
// re-tokenize, fresh cookie + the refreshed user (the one moment authz recomputes). A dead
|
||||||
// session ⇒ a cookie that *clears* the stale JWT, so later requests fall straight through to
|
// session ⇒ a cookie that *clears* the stale JWT, so later requests fall straight through to
|
||||||
// anonymous instead of re-hitting Ory on every one.
|
// anonymous instead of re-hitting Ory on every one.
|
||||||
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
|
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
|
||||||
const completed = await completeLogin(deps, cookie);
|
const completed = await completeLogin(deps, cookie);
|
||||||
if (!completed) return { setCookie: clearSessionCookie(options), user: null };
|
if (!completed) return { setCookie: clearSessionCookie(options), user: null };
|
||||||
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.identityId, roles: completed.roles } };
|
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.userId, permissions: completed.permissions } };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
|
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// /oauth2/consent?consent_challenge=… (hydra.yml urls.consent). A first-party client (or one
|
// /oauth2/consent?consent_challenge=… (hydra.yml urls.consent). A first-party client (or one
|
||||||
// Hydra already skipped) is auto-granted the requested scopes; a third-party client shows the
|
// Hydra already skipped) is auto-granted the requested scopes; a third-party client shows the
|
||||||
// themed consent screen, then accept (allow) / reject (deny). id_token claims (email/name) come
|
// themed consent screen, then accept (allow) / reject (deny). id_token claims (email/name) come
|
||||||
// from the Kratos identity. OAuth2-provider role only — no first-party page needs this (README).
|
// from the Kratos identity. OAuth2-provider permission only — no first-party page needs this (README).
|
||||||
import type { AcceptConsent, ConsentRequest, HydraAdmin, OAuth2Client } from "./hydra-admin.ts";
|
import type { AcceptConsent, ConsentRequest, HydraAdmin, OAuth2Client } from "./hydra-admin.ts";
|
||||||
import type { KratosPublic } from "./kratos-public.ts";
|
import type { KratosPublic } from "./kratos-public.ts";
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Hydra hands the browser to /oauth2/login?login_challenge=… (hydra.yml urls.login). We
|
// Hydra hands the browser to /oauth2/login?login_challenge=… (hydra.yml urls.login). We
|
||||||
// authenticate the user with their existing Kratos session and accept the request; Hydra then
|
// authenticate the user with their existing Kratos session and accept the request; Hydra then
|
||||||
// proceeds to consent and mints the tokens. No first-party page needs this — it's the OAuth2
|
// proceeds to consent and mints the tokens. No first-party page needs this — it's the OAuth2
|
||||||
// provider role only (README).
|
// provider permission only (README).
|
||||||
import type { HydraAdmin } from "./hydra-admin.ts";
|
import type { HydraAdmin } from "./hydra-admin.ts";
|
||||||
import type { KratosPublic } from "./kratos-public.ts";
|
import type { KratosPublic } from "./kratos-public.ts";
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -192,7 +192,7 @@ function oauthLogout(hydra: HydraAdmin): BuiltinRoute["handler"] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Login completion: where Kratos lands the browser after authenticating (kratos.yml). Mint our
|
// Login completion: where Kratos lands the browser after authenticating (kratos.yml). Mint our
|
||||||
// session JWT — read roles from Keto, project onto the identity, tokenize — and store it as the
|
// session JWT — read permissions from Keto, project onto the identity, tokenize — and store it as the
|
||||||
// cookie; no active session bounces back to sign in.
|
// cookie; no active session bounces back to sign in.
|
||||||
function completeAuth(deps: { keto: KetoClient; kratosAdmin: KratosAdmin; kratosPublic: KratosPublic }, secureCookies: boolean): BuiltinRoute["handler"] {
|
function completeAuth(deps: { keto: KetoClient; kratosAdmin: KratosAdmin; kratosPublic: KratosPublic }, secureCookies: boolean): BuiltinRoute["handler"] {
|
||||||
return async (ctx: RequestContext): Promise<RouteResult> => {
|
return async (ctx: RequestContext): Promise<RouteResult> => {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// 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");
|
||||||
|
});
|
||||||
+2
-2
@@ -31,7 +31,7 @@ export interface Config {
|
|||||||
otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export)
|
otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export)
|
||||||
otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors)
|
otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors)
|
||||||
port: number;
|
port: number;
|
||||||
revocationDenylist: boolean; // enable the optional instant role/session revoke denylist
|
revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist
|
||||||
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
|
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
|
||||||
secureCookies: boolean;
|
secureCookies: boolean;
|
||||||
serviceName: string; // OTLP service.name — an implementer brands their own logs/traces
|
serviceName: string; // OTLP service.name — an implementer brands their own logs/traces
|
||||||
@@ -157,7 +157,7 @@ export function loadConfig(env: Env = process.env): Config {
|
|||||||
otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
|
otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
|
||||||
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
|
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
|
||||||
port: readPort(env),
|
port: readPort(env),
|
||||||
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or role
|
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission
|
||||||
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
|
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
|
||||||
// tokenizer TTL + skew, so it outlasts any pre-revoke token).
|
// tokenizer TTL + skew, so it outlasts any pre-revoke token).
|
||||||
revocationDenylist: readBool(env, "REVOCATION_DENYLIST", false),
|
revocationDenylist: readBool(env, "REVOCATION_DENYLIST", false),
|
||||||
|
|||||||
+65
-65
@@ -40,9 +40,9 @@ function mintJwt(payload: Record<string, unknown>): string {
|
|||||||
const input = `${b64url(JSON.stringify({ alg: "ES256", kid: "test-kid", typ: "JWT" }))}.${b64url(JSON.stringify(payload))}`;
|
const input = `${b64url(JSON.stringify({ alg: "ES256", kid: "test-kid", typ: "JWT" }))}.${b64url(JSON.stringify(payload))}`;
|
||||||
return `${input}.${b64url(sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key: ec.privateKey }))}`;
|
return `${input}.${b64url(sign("SHA256", Buffer.from(input), { dsaEncoding: "ieee-p1363", key: ec.privateKey }))}`;
|
||||||
}
|
}
|
||||||
// A session cookie carrying `roles`, valid for 10 min — the auth most tests need to reach a gated page.
|
// A session cookie carrying `permissions`, valid for 10 min — the auth most tests need to reach a gated page.
|
||||||
const session = (roles: string[] = []): string =>
|
const session = (permissions: string[] = []): string =>
|
||||||
`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, roles, sub: "u1" })}`;
|
`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions, sub: "u1" })}`;
|
||||||
|
|
||||||
const server = createApp({ jwks: staticJwks([ecJwk]) });
|
const server = createApp({ jwks: staticJwks([ecJwk]) });
|
||||||
let base = "";
|
let base = "";
|
||||||
@@ -83,7 +83,7 @@ test("/ is the public landing: anonymous → 200 with intro + sign-in/register l
|
|||||||
const html = await res.text();
|
const html = await res.text();
|
||||||
assert.match(html, /href="\/login"/); // a prominent path to sign in
|
assert.match(html, /href="\/login"/); // a prominent path to sign in
|
||||||
assert.match(html, /href="\/registration"/); // and to register
|
assert.match(html, /href="\/registration"/); // and to register
|
||||||
// the same app shell every page renders — the menu shows even when signed out (role-filtered).
|
// the same app shell every page renders — the menu shows even when signed out (permission-filtered).
|
||||||
assert.match(html, /<aside class="sidebar"/);
|
assert.match(html, /<aside class="sidebar"/);
|
||||||
assert.match(html, /class="landing-title"/); // the landing hero owns the page's single <h1>
|
assert.match(html, /class="landing-title"/); // the landing hero owns the page's single <h1>
|
||||||
});
|
});
|
||||||
@@ -516,9 +516,9 @@ test("a plugin view renders the native chrome; its forms are CSRF-guarded via ct
|
|||||||
assert.equal(ok.status, 303);
|
assert.equal(ok.status, 303);
|
||||||
});
|
});
|
||||||
|
|
||||||
// JWT middleware: a verified session cookie populates ctx.user/roles, which the gate reads.
|
// JWT middleware: a verified session cookie populates ctx.user/permissions, which the gate reads.
|
||||||
// The key + mintJwt + session() helper are hoisted above the shared `server` (top of file).
|
// 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) => {
|
test("a verified session JWT authorizes a permission-gated route; no cookie / expired token → sign in", async (t) => {
|
||||||
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
|
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
|
||||||
await new Promise<void>((r) => app.listen(0, r));
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
t.after(() => app.close());
|
t.after(() => app.close());
|
||||||
@@ -526,8 +526,8 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
|
|||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const secret = (cookie?: string) => fetch(url + "/demo/secret", { redirect: "manual", ...(cookie ? { headers: { cookie } } : {}) });
|
const secret = (cookie?: string) => fetch(url + "/demo/secret", { redirect: "manual", ...(cookie ? { headers: { cookie } } : {}) });
|
||||||
|
|
||||||
// Token carrying the gating role → the handler runs (200).
|
// Token carrying the gating permission → the handler runs (200).
|
||||||
const ok = await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["demo:read"], sub: "u1" })}`);
|
const ok = await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["demo:read"], sub: "u1" })}`);
|
||||||
assert.equal(ok.status, 200);
|
assert.equal(ok.status, 200);
|
||||||
assert.equal(await ok.text(), "secret");
|
assert.equal(await ok.text(), "secret");
|
||||||
|
|
||||||
@@ -536,12 +536,12 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
|
|||||||
const noCookie = await secret();
|
const noCookie = await secret();
|
||||||
assert.equal(noCookie.status, 303);
|
assert.equal(noCookie.status, 303);
|
||||||
assert.equal(noCookie.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
|
assert.equal(noCookie.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
|
||||||
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}`)).status, 303);
|
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["demo:read"], sub: "u1" })}`)).status, 303);
|
||||||
|
|
||||||
// The gated dashboard renders for any signed-in user; anonymous is bounced to sign in before any
|
// The gated dashboard renders for any signed-in user; anonymous is bounced to sign in before any
|
||||||
// page renders (gate on /dashboard). The Admin section links come from the admin plugin — its nav
|
// page renders (gate on /dashboard). The Admin section links come from the admin plugin — its nav
|
||||||
// composition + role-filtering is covered in the admin-screen tests below.
|
// composition + permission-filtering is covered in the admin-screen tests below.
|
||||||
const dash = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["admin"], sub: "u1" })}` } });
|
const dash = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["admin"], sub: "u1" })}` } });
|
||||||
assert.equal(dash.status, 200);
|
assert.equal(dash.status, 200);
|
||||||
const anonDash = await fetch(url + "/dashboard", { redirect: "manual" });
|
const anonDash = await fetch(url + "/dashboard", { redirect: "manual" });
|
||||||
assert.equal(anonDash.status, 303);
|
assert.equal(anonDash.status, 303);
|
||||||
@@ -555,7 +555,7 @@ test("revocation denylist: a revoked subject's token stops authorizing on the ho
|
|||||||
t.after(() => app.close());
|
t.after(() => app.close());
|
||||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const secret = (iat: number) => fetch(url + "/demo/secret", { redirect: "manual", headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, iat, roles: ["demo:read"], sub: "u1" })}` } });
|
const secret = (iat: number) => fetch(url + "/demo/secret", { redirect: "manual", headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, iat, permissions: ["demo:read"], sub: "u1" })}` } });
|
||||||
|
|
||||||
assert.equal((await secret(nowSec)).status, 200); // before any revoke, the token authorizes
|
assert.equal((await secret(nowSec)).status, 200); // before any revoke, the token authorizes
|
||||||
|
|
||||||
@@ -567,10 +567,10 @@ test("revocation denylist: a revoked subject's token stops authorizing on the ho
|
|||||||
test("session re-mint: an expired JWT backed by a live Kratos session is silently re-minted; a dead session clears it", async (t) => {
|
test("session re-mint: an expired JWT backed by a live Kratos session is silently re-minted; a dead session clears it", async (t) => {
|
||||||
const identity: Identity = { id: "u1", traits: { email: "a@b.c" } };
|
const identity: Identity = { id: "u1", traits: { email: "a@b.c" } };
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["demo:read"], sub: "u1" });
|
const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["demo:read"], sub: "u1" });
|
||||||
const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session);
|
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: "user:u1" }] }) });
|
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "demo:read", relation: "granted", subject_id: "user:u1" }] }) });
|
||||||
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}; plainpages_session=s`;
|
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["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.
|
// Live Kratos session: the lapsed token is re-minted — the gated route runs AND a fresh cookie rides the response.
|
||||||
const app = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: live, kratosAdmin: stubAdmin({}), plugins: [demoPlugin] });
|
const app = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: live, kratosAdmin: stubAdmin({}), plugins: [demoPlugin] });
|
||||||
@@ -618,7 +618,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
|
|||||||
t.after(() => app.close());
|
t.after(() => app.close());
|
||||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const auth = (roles: string[]) => ({ headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles, sub: "u1" })}` } });
|
const auth = (permissions: string[]) => ({ headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions, sub: "u1" })}` } });
|
||||||
|
|
||||||
// requireSession: anonymous bounces to /login (remembering the page); a signed-in user reaches the handler.
|
// requireSession: anonymous bounces to /login (remembering the page); a signed-in user reaches the handler.
|
||||||
const anon = await fetch(url + "/guarded/me", { redirect: "manual" });
|
const anon = await fetch(url + "/guarded/me", { redirect: "manual" });
|
||||||
@@ -628,7 +628,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
|
|||||||
assert.equal(me.status, 200);
|
assert.equal(me.status, 200);
|
||||||
assert.match(await me.text(), /hi a@b\.c/);
|
assert.match(await me.text(), /hi a@b\.c/);
|
||||||
|
|
||||||
// can: signed-in but lacking the role → 403 page; carrying it → 200.
|
// can: signed-in but lacking the permission → 403 page; carrying it → 200.
|
||||||
assert.equal((await fetch(url + "/guarded/admin-only", auth([]))).status, 403);
|
assert.equal((await fetch(url + "/guarded/admin-only", auth([]))).status, 403);
|
||||||
assert.equal((await fetch(url + "/guarded/admin-only", auth(["admin"]))).status, 200);
|
assert.equal((await fetch(url + "/guarded/admin-only", auth(["admin"]))).status, 200);
|
||||||
|
|
||||||
@@ -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/open", auth([]))).status, 200);
|
||||||
assert.equal((await fetch(url + "/guarded/doc/shut", auth([]))).status, 403);
|
assert.equal((await fetch(url + "/guarded/doc/shut", auth([]))).status, 403);
|
||||||
|
|
||||||
// declarative route `permission` gate: anonymous → sign in, signed-in-without-role → the 403 page, with → 200.
|
// declarative route `permission` gate: anonymous → sign in, signed-in-without-permission → the 403 page, with → 200.
|
||||||
const gAnon = await fetch(url + "/guarded/gated", { redirect: "manual" });
|
const gAnon = await fetch(url + "/guarded/gated", { redirect: "manual" });
|
||||||
assert.equal(gAnon.status, 303);
|
assert.equal(gAnon.status, 303);
|
||||||
assert.equal(gAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fgated");
|
assert.equal(gAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fgated");
|
||||||
@@ -717,7 +717,7 @@ test("themed auth GET: anonymous inits a flow (CSRF relay, stale→restart); a s
|
|||||||
assert.equal(stale.headers.get("location"), "/login");
|
assert.equal(stale.headers.get("location"), "/login");
|
||||||
|
|
||||||
// Already signed in → /login + /registration short-circuit to the app dashboard; /settings stays reachable.
|
// Already signed in → /login + /registration short-circuit to the app dashboard; /settings stays reachable.
|
||||||
const signedIn = { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, roles: [], sub: "u1" })}` }, redirect: "manual" as const };
|
const signedIn = { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions: [], sub: "u1" })}` }, redirect: "manual" as const };
|
||||||
for (const path of ["/login", "/registration"]) {
|
for (const path of ["/login", "/registration"]) {
|
||||||
const res = await fetch(url + path, signedIn);
|
const res = await fetch(url + path, signedIn);
|
||||||
assert.equal(res.status, 303, `${path} while signed in → 303`);
|
assert.equal(res.status, 303, `${path} while signed in → 303`);
|
||||||
@@ -856,7 +856,7 @@ const fakeKeto = (tuples: RelationTuple[] = [], over: Partial<KetoClient> = {}):
|
|||||||
const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami });
|
const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami });
|
||||||
|
|
||||||
// Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT +
|
// Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT +
|
||||||
// CSRF cookie. get(path, roles)/post(path, body) carry them; `token` is the matching CSRF field.
|
// CSRF cookie. get(path, permissions)/post(path, body) carry them; `token` is the matching CSRF field.
|
||||||
const ADMIN_CSRF = "admin-secret";
|
const ADMIN_CSRF = "admin-secret";
|
||||||
async function adminHarness(t: TestContext, opts: AppOptions = {}) {
|
async function adminHarness(t: TestContext, opts: AppOptions = {}) {
|
||||||
const app = createApp({ csrfSecret: ADMIN_CSRF, jwks: staticJwks([ecJwk]), pluginsDir: examplesPluginsDir, plugins: [adminPlugin], ...opts });
|
const app = createApp({ csrfSecret: ADMIN_CSRF, jwks: staticJwks([ecJwk]), pluginsDir: examplesPluginsDir, plugins: [adminPlugin], ...opts });
|
||||||
@@ -865,14 +865,14 @@ async function adminHarness(t: TestContext, opts: AppOptions = {}) {
|
|||||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
const token = issueCsrfToken(ADMIN_CSRF);
|
const token = issueCsrfToken(ADMIN_CSRF);
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
const cookie = (roles: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, roles, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`;
|
const cookie = (permissions: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, permissions, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`;
|
||||||
const get = (path: string, roles: string[] = ["admin"]) => fetch(url + path, { headers: { cookie: cookie(roles) }, redirect: "manual" });
|
const get = (path: string, permissions: string[] = ["admin"]) => fetch(url + path, { headers: { cookie: cookie(permissions) }, redirect: "manual" });
|
||||||
const post = (path: string, body: string) =>
|
const post = (path: string, body: string) =>
|
||||||
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(["admin"]) }, method: "POST", redirect: "manual" });
|
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(["admin"]) }, method: "POST", redirect: "manual" });
|
||||||
return { get, post, token, url };
|
return { get, post, token, url };
|
||||||
}
|
}
|
||||||
// Every admin route is gated: anonymous → /login, a signed-in non-admin → 403.
|
// Every admin route is gated: anonymous → /login, a signed-in non-admin → 403.
|
||||||
async function assertAdminGate(url: string, get: (path: string, roles?: string[]) => Promise<Response>, path: string) {
|
async function assertAdminGate(url: string, get: (path: string, permissions?: string[]) => Promise<Response>, path: string) {
|
||||||
const anon = await fetch(url + path, { redirect: "manual" });
|
const anon = await fetch(url + path, { redirect: "manual" });
|
||||||
assert.equal(anon.status, 303);
|
assert.equal(anon.status, 303);
|
||||||
assert.equal(anon.headers.get("location"), `/login?return_to=${encodeURIComponent(path)}`); // remembers the page
|
assert.equal(anon.headers.get("location"), `/login?return_to=${encodeURIComponent(path)}`); // remembers the page
|
||||||
@@ -884,7 +884,7 @@ test("login completion (/auth/complete): a live session mints the JWT cookie; no
|
|||||||
let projected: unknown;
|
let projected: unknown;
|
||||||
const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session);
|
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 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: `user:${identity.id}` }] }) });
|
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${identity.id}` }] }) });
|
||||||
const complete = async (app: ReturnType<typeof createApp>, cookie?: string, returnTo?: string) => {
|
const complete = async (app: ReturnType<typeof createApp>, cookie?: string, returnTo?: string) => {
|
||||||
await new Promise<void>((r) => app.listen(0, r));
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
t.after(() => app.close());
|
t.after(() => app.close());
|
||||||
@@ -892,12 +892,12 @@ test("login completion (/auth/complete): a live session mints the JWT cookie; no
|
|||||||
return fetch(`http://localhost:${(app.address() as AddressInfo).port}/auth/complete${q}`, { headers: cookie ? { cookie } : {}, redirect: "manual" });
|
return fetch(`http://localhost:${(app.address() as AddressInfo).port}/auth/complete${q}`, { headers: cookie ? { cookie } : {}, redirect: "manual" });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Live Kratos session: roles from Keto → projection → tokenize → JWT cookie, land on the dashboard.
|
// Live Kratos session: permissions from Keto → projection → tokenize → JWT cookie, land on the dashboard.
|
||||||
const ok = await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s");
|
const ok = await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s");
|
||||||
assert.equal(ok.status, 303);
|
assert.equal(ok.status, 303);
|
||||||
assert.equal(ok.headers.get("location"), "/dashboard");
|
assert.equal(ok.headers.get("location"), "/dashboard");
|
||||||
assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=h\.p\.s;.*HttpOnly/);
|
assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=h\.p\.s;.*HttpOnly/);
|
||||||
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles projected onto the identity for the tokenizer
|
assert.deepEqual(projected, { permissions: ["admin"] }); // Keto permissions projected onto the identity for the tokenizer
|
||||||
|
|
||||||
// return_to: a safe host-relative target lands the user back where they were headed; an
|
// return_to: a safe host-relative target lands the user back where they were headed; an
|
||||||
// off-origin one is ignored (open-redirect guard) and falls back to the dashboard.
|
// off-origin one is ignored (open-redirect guard) and falls back to the dashboard.
|
||||||
@@ -1164,7 +1164,7 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
|
|||||||
assert.equal((await post(`/admin/users/admin1/state`, `_csrf=${token}`)).status, 400);
|
assert.equal((await post(`/admin/users/admin1/state`, `_csrf=${token}`)).status, 400);
|
||||||
assert.equal(store.find((x) => x.id === "admin1")!.state, "active");
|
assert.equal(store.find((x) => x.id === "admin1")!.state, "active");
|
||||||
|
|
||||||
// Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/roles/clients.
|
// Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/permissions/clients.
|
||||||
assert.equal((await get(`/admin/users/${randomUUID()}`)).status, 404);
|
assert.equal((await get(`/admin/users/${randomUUID()}`)).status, 404);
|
||||||
assert.equal((await get("/admin/users/%ZZ")).status, 404);
|
assert.equal((await get("/admin/users/%ZZ")).status, 404);
|
||||||
});
|
});
|
||||||
@@ -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);
|
assert.equal((await get("/admin/groups/%ZZ")).status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Built-in Roles & permissions admin screen: gate + list/create/assign/revoke/delete over HTTP
|
// Built-in Roles 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
|
// 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.
|
// 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) => {
|
test("admin Roles screen: gate, list, create, assign user/group, effective access (expand), revoke, delete", async (t) => {
|
||||||
@@ -1235,10 +1235,10 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
|
|||||||
{ id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } },
|
{ id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } },
|
||||||
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } },
|
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } },
|
||||||
];
|
];
|
||||||
// grace is in the `eng` group; `editor` is an existing role whose only direct member is ada.
|
// grace is in the `eng` group; `editor` is an existing permission whose only direct member is ada.
|
||||||
const tuples: RelationTuple[] = [
|
const tuples: RelationTuple[] = [
|
||||||
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${grace}` },
|
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${grace}` },
|
||||||
{ namespace: "Role", object: "editor", relation: "members", subject_id: `user:${ada}` },
|
{ namespace: "Permission", object: "editor", relation: "granted", subject_id: `user:${ada}` },
|
||||||
];
|
];
|
||||||
// Mirror Keto's expand shape: the subject rides on `tuple`, set nodes carry members as children.
|
// Mirror Keto's expand shape: the subject rides on `tuple`, set nodes carry members as children.
|
||||||
const expandSet = (set: SubjectSet): ExpandTree => ({
|
const expandSet = (set: SubjectSet): ExpandTree => ({
|
||||||
@@ -1250,70 +1250,70 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
|
|||||||
});
|
});
|
||||||
const keto = fakeKeto(tuples, { expand: async (set) => expandSet(set) });
|
const keto = fakeKeto(tuples, { expand: async (set) => expandSet(set) });
|
||||||
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
|
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
const denylist = createDenylist(); // granting/revoking a *user's* role revokes their live tokens (a group change is transitive → left to lag)
|
const denylist = createDenylist(); // granting/revoking a *user's* permission revokes their live tokens (a group change is transitive → left to lag)
|
||||||
const { get, post, token, url } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
const { get, post, token, url } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
||||||
|
|
||||||
await assertAdminGate(url, get, "/admin/roles");
|
await assertAdminGate(url, get, "/admin/permissions");
|
||||||
|
|
||||||
// List: the existing role shows + the "add" link.
|
// List: the existing permission shows + the "add" link.
|
||||||
const listHtml = await (await get("/admin/roles")).text();
|
const listHtml = await (await get("/admin/permissions")).text();
|
||||||
assert.match(listHtml, /href="\/admin\/roles\/editor"/);
|
assert.match(listHtml, /href="\/admin\/permissions\/editor"/);
|
||||||
assert.match(listHtml, /href="\/admin\/roles\/new"/);
|
assert.match(listHtml, /href="\/admin\/permissions\/new"/);
|
||||||
|
|
||||||
// Create: a valid post writes the first-member tuple and redirects to the detail.
|
// 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/);
|
assert.match(await (await get("/admin/permissions/new")).text(), /Create permission/);
|
||||||
const created = await post("/admin/roles", `_csrf=${token}&name=viewer&member=user:${ada}`);
|
const created = await post("/admin/permissions", `_csrf=${token}&name=viewer&member=user:${ada}`);
|
||||||
assert.equal(created.status, 303);
|
assert.equal(created.status, 303);
|
||||||
assert.equal(created.headers.get("location"), "/admin/roles/viewer");
|
assert.equal(created.headers.get("location"), "/admin/permissions/viewer");
|
||||||
assert.ok(tuples.some((tp) => tp.namespace === "Role" && tp.object === "viewer" && tp.subject_id === `user:${ada}`));
|
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && 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
|
assert.equal(denylist.isRevoked(ada, 0), true); // assigning a permission 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.
|
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
|
||||||
const before = tuples.length;
|
const before = tuples.length;
|
||||||
assert.equal((await post("/admin/roles", `_csrf=${token}&name=Bad Name&member=user:${ada}`)).status, 400);
|
assert.equal((await post("/admin/permissions", `_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/permissions", `_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((await post("/admin/permissions", `name=x&member=user:${ada}`)).status, 403);
|
||||||
assert.equal(tuples.length, before);
|
assert.equal(tuples.length, before);
|
||||||
|
|
||||||
// Detail: ada (direct) is in the effective-access list; grace (only reachable via a group) is not
|
// Detail: ada (direct) is in the effective-access list; grace (only reachable via a group) is not
|
||||||
// yet — though grace appears elsewhere as an assignable candidate, so target the effective <li>.
|
// yet — though grace appears elsewhere as an assignable candidate, so target the effective <li>.
|
||||||
const effectiveLi = (email: string) => new RegExp(`<li><span class="cell-strong">${email.replace(".", "\\.")}`);
|
const effectiveLi = (email: string) => new RegExp(`<li><span class="cell-strong">${email.replace(".", "\\.")}`);
|
||||||
const detail = await (await get("/admin/roles/editor")).text();
|
const detail = await (await get("/admin/permissions/editor")).text();
|
||||||
assert.match(detail, effectiveLi("ada@example.com"));
|
assert.match(detail, effectiveLi("ada@example.com"));
|
||||||
assert.doesNotMatch(detail, effectiveLi("grace@example.com"));
|
assert.doesNotMatch(detail, effectiveLi("grace@example.com"));
|
||||||
|
|
||||||
// Assign the `eng` group to the role → grace now holds it transitively (effective access via expand).
|
// Assign the `eng` group to the permission → grace now holds it transitively (effective access via expand).
|
||||||
await post("/admin/roles/editor/members", `_csrf=${token}&member=group:eng`);
|
await post("/admin/permissions/editor/members", `_csrf=${token}&member=group:eng`);
|
||||||
assert.ok(tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
|
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor" && tp.subject_set?.object === "eng"));
|
||||||
const withGroup = await (await get("/admin/roles/editor")).text();
|
const withGroup = await (await get("/admin/permissions/editor")).text();
|
||||||
assert.match(withGroup, effectiveLi("grace@example.com"));
|
assert.match(withGroup, effectiveLi("grace@example.com"));
|
||||||
|
|
||||||
// Revoke the group membership.
|
// Revoke the group membership.
|
||||||
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=group:eng`);
|
await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=group:eng`);
|
||||||
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
|
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && 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.
|
// 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=user:${grace}`);
|
await post("/admin/permissions/editor/members", `_csrf=${token}&member=user:${grace}`);
|
||||||
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=user:${grace}`);
|
await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=user:${grace}`);
|
||||||
assert.equal(denylist.isRevoked(grace, 0), true);
|
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.
|
// Delete the permission: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
||||||
assert.match(await (await get("/admin/roles/editor/delete")).text(), /Cancel/);
|
assert.match(await (await get("/admin/permissions/editor/delete")).text(), /Cancel/);
|
||||||
const del = await post("/admin/roles/editor/delete", `_csrf=${token}`);
|
const del = await post("/admin/permissions/editor/delete", `_csrf=${token}`);
|
||||||
assert.equal(del.status, 303);
|
assert.equal(del.status, 303);
|
||||||
assert.equal(del.headers.get("location"), "/admin/roles");
|
assert.equal(del.headers.get("location"), "/admin/permissions");
|
||||||
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor"));
|
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor"));
|
||||||
|
|
||||||
// Self-protection: the admin role can't be deleted, nor can you revoke your own admin (sub admin1).
|
// Self-protection: the admin permission can't be deleted, nor can you revoke your own admin (sub admin1).
|
||||||
tuples.push({ namespace: "Role", object: "admin", relation: "members", subject_id: "user:admin1" });
|
tuples.push({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "user:admin1" });
|
||||||
assert.equal((await post("/admin/roles/admin/delete", `_csrf=${token}`)).status, 400);
|
assert.equal((await post("/admin/permissions/admin/delete", `_csrf=${token}`)).status, 400);
|
||||||
assert.ok(tuples.some((tp) => tp.object === "admin"));
|
assert.ok(tuples.some((tp) => tp.object === "admin"));
|
||||||
assert.equal((await post("/admin/roles/admin/members/delete", `_csrf=${token}&member=user:admin1`)).status, 400);
|
assert.equal((await post("/admin/permissions/admin/members/delete", `_csrf=${token}&member=user:admin1`)).status, 400);
|
||||||
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "user:admin1"));
|
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.
|
// An invalid permission name in the path → 404; malformed %-encoding doesn't 500.
|
||||||
assert.equal((await get("/admin/roles/Bad%20Name")).status, 404);
|
assert.equal((await get("/admin/permissions/Bad%20Name")).status, 404);
|
||||||
assert.equal((await get("/admin/roles/%ZZ")).status, 404);
|
assert.equal((await get("/admin/permissions/%ZZ")).status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an
|
// Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an
|
||||||
|
|||||||
+10
-10
@@ -2,7 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto";
|
|||||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as ejs from "ejs";
|
import ejs from "ejs";
|
||||||
import { type BuiltinRoute, matchBuiltinRoute, type RequestCsrf } from "./builtin-routes.ts";
|
import { type BuiltinRoute, matchBuiltinRoute, type RequestCsrf } from "./builtin-routes.ts";
|
||||||
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
|
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
|
||||||
import { buildContext, type RequestContext, type User } from "./context.ts";
|
import { buildContext, type RequestContext, type User } from "./context.ts";
|
||||||
@@ -40,7 +40,7 @@ export interface AppOptions {
|
|||||||
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
|
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
|
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
|
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
|
||||||
jwks?: JwksProvider; // verify the session JWT → ctx.user/roles; absent ⇒ always anonymous
|
jwks?: JwksProvider; // verify the session JWT → ctx.user/permissions; absent ⇒ always anonymous
|
||||||
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
|
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
|
||||||
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
|
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
|
||||||
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion
|
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion
|
||||||
@@ -141,7 +141,7 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
await sendResult(ctx.res, result, (view, data) => renderView(dashboardPlugin.id, view, data));
|
await sendResult(ctx.res, result, (view, data) => renderView(dashboardPlugin.id, view, data));
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, nav: ctx.chrome.nav, user: ctx.user }) }, view: "index" };
|
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, user: ctx.user, nav: ctx.chrome.nav }) }, view: "index" };
|
||||||
};
|
};
|
||||||
|
|
||||||
// The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/
|
// The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/
|
||||||
@@ -186,9 +186,9 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the session JWT once (cached JWKS) → ctx.user/roles; none/invalid ⇒ anonymous.
|
// Verify the session JWT once (cached JWKS) → ctx.user/permissions; none/invalid ⇒ anonymous.
|
||||||
// If the token has lapsed but a live Kratos session still backs it (and we have the Ory
|
// 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,
|
// clients), silently re-mint it — "stay signed in": re-read permissions from Keto, re-tokenize,
|
||||||
// and set the fresh cookie via setHeader so it rides whatever response this request produces
|
// 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.
|
// (a dead session clears the stale cookie). This is the only place the hot path touches Ory.
|
||||||
let user: User | null = null;
|
let user: User | null = null;
|
||||||
@@ -226,7 +226,7 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, 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.
|
// base context (no route params yet); reused for onRequest hooks and the landing routes.
|
||||||
const ctx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf, ...(system ? { system } : {}) });
|
const ctx = buildContext(req, res, { chrome, user, log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
||||||
|
|
||||||
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
||||||
if (anyRequestHooks) {
|
if (anyRequestHooks) {
|
||||||
@@ -245,12 +245,12 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
// CSRF cookie is set so those forms have a valid double-submit token.
|
// CSRF cookie is set so those forms have a valid double-submit token.
|
||||||
const match = matchRoute(plugins, method, pathname);
|
const match = matchRoute(plugins, method, pathname);
|
||||||
if (match) {
|
if (match) {
|
||||||
const routeCtx = buildContext(req, res, { chrome, log: reqLog, params: match.params, user, verifyCsrf, ...(system ? { system } : {}) });
|
const routeCtx = buildContext(req, res, { chrome, user, log: reqLog, params: match.params, verifyCsrf, ...(system ? { system } : {}) });
|
||||||
if (!isAuthorized(match.route, routeCtx.roles)) {
|
if (!isAuthorized(match.route, routeCtx.permissions)) {
|
||||||
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
|
// 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.
|
// return_to; a signed-in user who simply lacks the permission gets the 403 page.
|
||||||
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
|
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 });
|
reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
|
||||||
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
|
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ test("buildContext parses the URL, exposes query, and defaults to an anonymous u
|
|||||||
assert.equal(ctx.query.get("q"), "ann");
|
assert.equal(ctx.query.get("q"), "ann");
|
||||||
assert.equal(ctx.query.get("page"), "2");
|
assert.equal(ctx.query.get("page"), "2");
|
||||||
assert.equal(ctx.user, null);
|
assert.equal(ctx.user, null);
|
||||||
assert.deepEqual(ctx.roles, []);
|
assert.deepEqual(ctx.permissions, []);
|
||||||
assert.deepEqual(ctx.params, {});
|
assert.deepEqual(ctx.params, {});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -33,12 +33,12 @@ test("buildContext threads path params supplied by the router", () => {
|
|||||||
assert.equal(ctx.params.id, "42");
|
assert.equal(ctx.params.id, "42");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("buildContext threads the user and derives roles from it", () => {
|
test("buildContext threads the user and derives permissions from it", () => {
|
||||||
const { req, res } = reqRes("/");
|
const { req, res } = reqRes("/");
|
||||||
const user: User = { email: "a@b.c", id: "u1", roles: ["admin", "editor"] };
|
const user: User = { email: "a@b.c", id: "u1", permissions: ["admin", "editor"] };
|
||||||
const ctx = buildContext(req, res, { user });
|
const ctx = buildContext(req, res, { user });
|
||||||
assert.equal(ctx.user, user);
|
assert.equal(ctx.user, user);
|
||||||
assert.equal(ctx.roles, user.roles); // same reference, never a divergent copy — buildContext is the only writer
|
assert.equal(ctx.permissions, user.permissions); // same reference, never a divergent copy — buildContext is the only writer
|
||||||
});
|
});
|
||||||
|
|
||||||
test("buildContext defaults a missing request URL to /", () => {
|
test("buildContext defaults a missing request URL to /", () => {
|
||||||
|
|||||||
+9
-8
@@ -7,12 +7,13 @@ import { createLogger, type Log } from "../logger.ts";
|
|||||||
// per request by `buildContext`: the router supplies matched path `params`, the JWT
|
// per request by `buildContext`: the router supplies matched path `params`, the JWT
|
||||||
// middleware supplies `user` (null until then). The host's single handler argument.
|
// middleware supplies `user` (null until then). The host's single handler argument.
|
||||||
|
|
||||||
// The authenticated user, projected from verified session JWT claims:
|
// The signed-in user, projected from verified session JWT claims. Ory calls this record an
|
||||||
// `id` = `sub`, plus `email` and the coarse `roles` carried in the token.
|
// "identity" (see README); Plainpages says user throughout.
|
||||||
|
// `id` = `sub`, plus `email` and the coarse `permissions` carried in the token.
|
||||||
export interface User {
|
export interface User {
|
||||||
email: string;
|
email: string;
|
||||||
id: string;
|
id: string;
|
||||||
roles: string[];
|
permissions: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RequestContext {
|
export interface RequestContext {
|
||||||
@@ -24,15 +25,15 @@ export interface RequestContext {
|
|||||||
// requestId. Additive, stable per the contract; defaults to a silent logger off the request path.
|
// requestId. Additive, stable per the contract; defaults to a silent logger off the request path.
|
||||||
log: Log;
|
log: Log;
|
||||||
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
|
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
|
||||||
|
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
|
||||||
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
|
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
|
||||||
req: IncomingMessage;
|
req: IncomingMessage;
|
||||||
res: ServerResponse;
|
res: ServerResponse;
|
||||||
roles: string[]; // user?.roles ?? [] — coarse gate without a null-check
|
|
||||||
// Privileged host services (Ory admin clients + instant-revoke) for a system plugin. Undefined
|
// 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.
|
// unless the host wired them; every field optional. Ordinary domain plugins ignore it.
|
||||||
system?: SystemCapabilities;
|
system?: SystemCapabilities;
|
||||||
url: URL;
|
url: URL;
|
||||||
user: User | null;
|
user: User | null; // the signed-in user, or null when anonymous
|
||||||
// Gate a first-party form submission: true iff `submitted` matches this request's signed CSRF
|
// 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.
|
// cookie (double-submit). The host binds the secret; a plugin calls it after reading its body.
|
||||||
verifyCsrf(submitted: string | null | undefined): boolean;
|
verifyCsrf(submitted: string | null | undefined): boolean;
|
||||||
@@ -43,10 +44,10 @@ export interface BuildContextOptions {
|
|||||||
// ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing).
|
// 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.
|
// The host's factory is memoised, so the menu composes at most once per request across contexts.
|
||||||
chrome?: () => PageChrome;
|
chrome?: () => PageChrome;
|
||||||
|
user?: User | null;
|
||||||
log?: Log;
|
log?: Log;
|
||||||
params?: Record<string, string>;
|
params?: Record<string, string>;
|
||||||
system?: SystemCapabilities;
|
system?: SystemCapabilities;
|
||||||
user?: User | null;
|
|
||||||
verifyCsrf?: (submitted: string | null | undefined) => boolean;
|
verifyCsrf?: (submitted: string | null | undefined) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,15 +68,15 @@ export function buildContext(
|
|||||||
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
|
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
|
||||||
return {
|
return {
|
||||||
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
|
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
|
||||||
|
user,
|
||||||
log: options.log ?? SILENT_LOG,
|
log: options.log ?? SILENT_LOG,
|
||||||
params: options.params ?? {},
|
params: options.params ?? {},
|
||||||
query: url.searchParams,
|
query: url.searchParams,
|
||||||
req,
|
req,
|
||||||
res,
|
res,
|
||||||
roles: user?.roles ?? [],
|
permissions: user?.permissions ?? [],
|
||||||
...(options.system ? { system: options.system } : {}),
|
...(options.system ? { system: options.system } : {}),
|
||||||
url,
|
url,
|
||||||
user,
|
|
||||||
verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret
|
verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -1,6 +1,6 @@
|
|||||||
// Guards the Ory Keto config: migrations run before the server (keto-migrate →
|
// 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
|
// keto), the DSN targets the keto database, read/write APIs serve on the ports config.ts
|
||||||
// points at, and the OPL declares the role/group/resource namespaces. Version pinning is
|
// points at, and the OPL declares the user/permission/group/resource namespaces. Version pinning is
|
||||||
// in compose.test.ts. Real boot is verified by running the stack; this catches edits.
|
// in compose.test.ts. Real boot is verified by running the stack; this catches edits.
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
@@ -35,12 +35,12 @@ test("keto loads the OPL namespaces from the mounted file", () => {
|
|||||||
"namespaces come from the committed OPL");
|
"namespaces come from the committed OPL");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the OPL declares role, group and a resource namespace over user subjects", () => {
|
test("the OPL declares permission, group and a resource namespace over user subjects", () => {
|
||||||
for (const ns of ["User", "Group", "Role", "Resource"])
|
for (const ns of ["User", "Group", "Permission", "Resource"])
|
||||||
assert.match(opl, new RegExp(`class ${ns} implements Namespace`), `defines ${ns}`);
|
assert.match(opl, new RegExp(`class ${ns} implements Namespace`), `defines ${ns}`);
|
||||||
// role + group are subject sets read at login → JWT roles claim (README).
|
// permission + group are subject sets read at login → JWT permissions claim (README).
|
||||||
assert.match(opl, /class Role implements Namespace\s*{\s*related:\s*{\s*members:/,
|
assert.match(opl, /class Permission implements Namespace\s*{\s*related:\s*{\s*granted:/,
|
||||||
"Role has a members relation");
|
"Permission has a granted relation");
|
||||||
assert.match(opl, /class Group implements Namespace\s*{\s*related:\s*{\s*members:/,
|
assert.match(opl, /class Group implements Namespace\s*{\s*related:\s*{\s*members:/,
|
||||||
"Group has a members relation");
|
"Group has a members relation");
|
||||||
});
|
});
|
||||||
|
|||||||
+4
-4
@@ -52,7 +52,7 @@ test("self-service flows return to our themed pages (on the localhost dev host)"
|
|||||||
|
|
||||||
test("after a successful login Kratos returns to our /auth/complete route to mint the JWT", () => {
|
test("after a successful login Kratos returns to our /auth/complete route to mint the JWT", () => {
|
||||||
assert.match(kratosYml, /default_browser_return_url:\s*http:\/\/localhost:3000\/auth\/complete/,
|
assert.match(kratosYml, /default_browser_return_url:\s*http:\/\/localhost:3000\/auth\/complete/,
|
||||||
"login completion (read roles → project → tokenize → set cookie) runs at /auth/complete");
|
"login completion (read permissions → project → tokenize → set cookie) runs at /auth/complete");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("recovery + verification run on email code, delivered by a courier", () => {
|
test("recovery + verification run on email code, delivered by a courier", () => {
|
||||||
@@ -79,12 +79,12 @@ test("session tokenizer template 'plainpages' mints a short-lived signed JWT", (
|
|||||||
"claims via the committed mapper");
|
"claims via the committed mapper");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the tokenizer claims mapper emits email + roles from the metadata_public projection", () => {
|
test("the tokenizer claims mapper emits email + permissions from the metadata_public projection", () => {
|
||||||
// metadata_public, not _admin: the session Kratos hands the tokenizer carries only public
|
// metadata_public, not _admin: the session Kratos hands the tokenizer carries only public
|
||||||
// metadata (admin metadata is stripped), so the roles projection must live in metadata_public.
|
// metadata (admin metadata is stripped), so the permissions projection must live in metadata_public.
|
||||||
const mapper = read("ory/kratos/tokenizer/plainpages.jsonnet");
|
const mapper = read("ory/kratos/tokenizer/plainpages.jsonnet");
|
||||||
assert.match(mapper, /email:\s*session\.identity\.traits\.email/, "email ← identity trait");
|
assert.match(mapper, /email:\s*session\.identity\.traits\.email/, "email ← identity trait");
|
||||||
assert.match(mapper, /metadata_public/, "roles ← metadata_public (the per-login Keto projection)");
|
assert.match(mapper, /metadata_public/, "permissions ← metadata_public (the per-login Keto projection)");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("social sign-in is off by default — a clean clone stays password-only", () => {
|
test("social sign-in is off by default — a clean clone stays password-only", () => {
|
||||||
|
|||||||
@@ -85,9 +85,9 @@ test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard)
|
|||||||
assert.equal(typeof plugins[0]?.dashboard, "function");
|
assert.equal(typeof plugins[0]?.dashboard, "function");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a shared permission token only warns — both plugins still load", async (t) => {
|
test("a shared permission name only warns — both plugins still load", async (t) => {
|
||||||
const perm = `export default { apiVersion: "1.0.0", permissions: [{ token: "shared:read" }] };`;
|
const shared = `export default { apiVersion: "1.0.0", permissions: [{ name: "shared:read" }] };`;
|
||||||
const dir = scaffold(t, { "x/plugin.ts": perm, "y/plugin.ts": perm });
|
const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared });
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } });
|
const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } });
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// validate it, assemble the loaded Plugin[]. The imperative shell over plugin.ts's pure rules
|
// 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
|
// (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin problem and
|
||||||
// error-level conflict is collected into one boot-stopping Error; warn-level diagnostics
|
// error-level conflict is collected into one boot-stopping Error; warn-level diagnostics
|
||||||
// (older-minor apiVersion, shared permission token) log and load continues. Folder name = id.
|
// (older-minor apiVersion, shared permission name) log and load continues. Folder name = id.
|
||||||
|
|
||||||
import { existsSync, readdirSync } from "node:fs";
|
import { existsSync, readdirSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
@@ -94,7 +94,7 @@ function shapeError(manifest: PluginManifest): string | null {
|
|||||||
if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`;
|
if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`;
|
||||||
}
|
}
|
||||||
// `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
|
// `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
|
||||||
// "needs this role". Refuse rather than silently pick one, so the author's intent is unambiguous.
|
// "needs this permission". Refuse rather than silently pick one, so the author's intent is unambiguous.
|
||||||
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
||||||
if (route?.public === true && route.permission != null) return `route "${route.method} ${route.path}" sets both public and permission — 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`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const scheduling: PluginManifest = definePlugin({
|
|||||||
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
|
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
|
||||||
icon: "i-cal", id: "scheduling:root", label: "Scheduling",
|
icon: "i-cal", id: "scheduling:root", label: "Scheduling",
|
||||||
}],
|
}],
|
||||||
permissions: [{ description: "View shifts", token: "scheduling:read" }],
|
permissions: [{ description: "View shifts", name: "scheduling:read" }],
|
||||||
routes: [
|
routes: [
|
||||||
{ handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", permission: "scheduling:read" },
|
{ handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", permission: "scheduling:read" },
|
||||||
{ handler: () => ({ redirect: "/scheduling/shifts" }), method: "POST", path: "/shifts", permission: "scheduling:write" },
|
{ handler: () => ({ redirect: "/scheduling/shifts" }), method: "POST", path: "/shifts", permission: "scheduling:write" },
|
||||||
@@ -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")));
|
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 permission token only warns", () => {
|
test("findConflicts: duplicate nav id is an error, a shared permission name only warns", () => {
|
||||||
const navDup = findConflicts([
|
const navDup = findConflicts([
|
||||||
p({ id: "a", nav: [{ id: "dup", label: "A" }] }),
|
p({ id: "a", nav: [{ id: "dup", label: "A" }] }),
|
||||||
p({ id: "b", nav: [{ id: "dup", label: "B" }] }),
|
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")));
|
assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||||
|
|
||||||
// Sharing a permission across plugins is legitimate (shared role) → warn, not error.
|
// Sharing a permission across plugins is legitimate → warn, not error.
|
||||||
const permDup = findConflicts([
|
const permissionDup = findConflicts([
|
||||||
p({ id: "a", permissions: [{ token: "shared:read" }] }),
|
p({ id: "a", permissions: [{ name: "shared:read" }] }),
|
||||||
p({ id: "b", permissions: [{ token: "shared:read" }] }),
|
p({ id: "b", permissions: [{ name: "shared:read" }] }),
|
||||||
]);
|
]);
|
||||||
assert.ok(permDup.some((c) => c.kind === "permission" && c.level === "warn"));
|
assert.ok(permissionDup.some((c) => c.kind === "permission" && c.level === "warn"));
|
||||||
});
|
});
|
||||||
|
|
||||||
test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => {
|
test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => {
|
||||||
|
|||||||
@@ -29,18 +29,18 @@ export interface Route {
|
|||||||
handler: RouteHandler;
|
handler: RouteHandler;
|
||||||
method: HttpMethod;
|
method: HttpMethod;
|
||||||
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
|
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
|
||||||
permission?: string; // coarse gate (a role token); checked before the handler runs
|
permission?: string; // coarse gate — the Keto Permission the caller must hold; checked before the handler runs
|
||||||
// Mark the page reachable by anyone, signed in or not. The same as omitting `permission`
|
// 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
|
// — an ungated route is already open — but stated outright, so "public" is a deliberate
|
||||||
// choice, not an accident. Mutually exclusive with `permission` (discovery refuses both).
|
// choice, not an accident. Mutually exclusive with `permission` (discovery refuses both).
|
||||||
public?: boolean;
|
public?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A permission token this plugin introduces — declared for docs/seeding. Tokens are a shared
|
// A Keto Permission this plugin gates on — declared for docs/seeding. Permission names are a shared
|
||||||
// global namespace (so an operator grants them in Keto); namespace as `<id>:<action>`.
|
// global namespace (so an operator grants them once in Keto); namespace as `<id>:<action>`.
|
||||||
export interface PermissionDecl {
|
export interface PermissionDecl {
|
||||||
description?: string;
|
description?: string;
|
||||||
token: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud.
|
// Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud.
|
||||||
@@ -156,7 +156,7 @@ export interface PluginConflict {
|
|||||||
// The conflict rules: defined, loud resolution — never last-write-wins. Pure over the discovered
|
// 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
|
// 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 permission
|
// 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.
|
// names are the one intentional overlap, so they warn rather than error.
|
||||||
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
||||||
const out: PluginConflict[] = [];
|
const out: PluginConflict[] = [];
|
||||||
|
|
||||||
@@ -184,9 +184,9 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
|||||||
});
|
});
|
||||||
|
|
||||||
collect(plugins, (plugin, push) => {
|
collect(plugins, (plugin, push) => {
|
||||||
for (const decl of plugin.permissions ?? []) push(decl.token);
|
for (const decl of plugin.permissions ?? []) push(decl.name);
|
||||||
}).forEach((owners, token) => {
|
}).forEach((owners, name) => {
|
||||||
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) });
|
if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; namespace as "<id>:<action>" unless shared on purpose`, plugins: uniq(owners) });
|
||||||
});
|
});
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ test("allowedMethods lists methods at a path (GET implies HEAD); empty when the
|
|||||||
assert.deepEqual(allowedMethods(plugins, "/x/missing"), []);
|
assert.deepEqual(allowedMethods(plugins, "/x/missing"), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("isAuthorized: open routes pass; gated routes require the role token; public is explicitly open", () => {
|
test("isAuthorized: open routes pass; gated routes require the permission token; public is explicitly open", () => {
|
||||||
const open: Route = { handler: noop, method: "GET", path: "/" };
|
const open: Route = { handler: noop, method: "GET", path: "/" };
|
||||||
const gated: Route = { handler: noop, method: "GET", path: "/", permission: "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
|
const pub: Route = { handler: noop, method: "GET", path: "/", public: true }; // blessed public alias
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ export function allowedMethods(plugins: Plugin[], pathname: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Coarse permission gate: a route marked `public` (or one with no `permission`) 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
|
// the user's permissions (from the session JWT) must include the token. The same rule composeNav uses
|
||||||
// for the menu. `public` and `permission` are mutually exclusive (discovery refuses both).
|
// for the menu. `public` and `permission` are mutually exclusive (discovery refuses both).
|
||||||
export function isAuthorized(route: Route, roles: string[]): boolean {
|
export function isAuthorized(route: Route, permissions: string[]): boolean {
|
||||||
return route.public === true || route.permission == null || roles.includes(route.permission);
|
return route.public === true || route.permission == null || permissions.includes(route.permission);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
// plugin may deliberately shadow a core partial). The router calls this for a `view` RouteResult.
|
// plugin may deliberately shadow a core partial). The router calls this for a `view` RouteResult.
|
||||||
|
|
||||||
import { isAbsolute, join, relative } from "node:path";
|
import { isAbsolute, join, relative } from "node:path";
|
||||||
import * as ejs from "ejs";
|
import ejs from "ejs";
|
||||||
|
|
||||||
const CONTROL_CHARS = /[\x00-\x1f]/;
|
const CONTROL_CHARS = /[\x00-\x1f]/;
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -31,7 +31,7 @@ const hydra = createHydraAdmin({ baseUrl: config.hydraAdminUrl, fetchImpl: oryFe
|
|||||||
// or fetched http), then served from cache with TTL refresh + rotation-on-miss.
|
// or fetched http), then served from cache with TTL refresh + rotation-on-miss.
|
||||||
const jwks = await createJwksProvider(config.jwksUrl, { fetchImpl: oryFetch }); // bound an http JWKS fetch too
|
const jwks = await createJwksProvider(config.jwksUrl, { fetchImpl: oryFetch }); // bound an http JWKS fetch too
|
||||||
// Optional instant-revoke, off unless REVOCATION_DENYLIST=true: an in-memory denylist the
|
// Optional instant-revoke, off unless REVOCATION_DENYLIST=true: an in-memory denylist the
|
||||||
// hot path consults and the admin screens populate on deactivate/delete/role-change.
|
// hot path consults and the admin screens populate on deactivate/delete/permission-change.
|
||||||
const denylist = config.revocationDenylist ? createDenylist({ ttlSec: config.revocationTtlSec }) : undefined;
|
const denylist = config.revocationDenylist ? createDenylist({ ttlSec: config.revocationTtlSec }) : undefined;
|
||||||
|
|
||||||
const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin
|
const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as ejs from "ejs";
|
import ejs from "ejs";
|
||||||
|
|
||||||
const authCard = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "auth-card.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);
|
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(authCard, data);
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ test("anonymous shell Sign-in link carries the current page as return_to", () =>
|
|||||||
test("a permission 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({
|
const chrome = buildPluginChrome({
|
||||||
currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
|
currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
|
||||||
user: { email: "ada@x.io", id: "u1", roles: ["scheduling:read"] },
|
user: { email: "ada@x.io", id: "u1", permissions: ["scheduling:read"] },
|
||||||
});
|
});
|
||||||
assert.deepEqual(labels(chrome.nav), ["Dashboard", "Scheduling"]); // Dashboard shown to a signed-in user
|
assert.deepEqual(labels(chrome.nav), ["Dashboard", "Scheduling"]); // Dashboard shown to a signed-in user
|
||||||
const section = chrome.nav.find((n) => n.label === "Scheduling")!;
|
const section = chrome.nav.find((n) => n.label === "Scheduling")!;
|
||||||
@@ -58,7 +58,7 @@ test("a permission holder sees the Dashboard link + plugin nav; current path ope
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("a gated section (like the admin plugin) shows to a holder; a sub-path marks its base leaf current", () => {
|
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], user: { 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", permissions: ["admin"] } });
|
||||||
const admin = chrome.nav.find((n) => n.label === "Admin")!;
|
const admin = chrome.nav.find((n) => n.label === "Admin")!;
|
||||||
assert.ok(admin); // gated section visible to an admin
|
assert.ok(admin); // gated section visible to an admin
|
||||||
assert.equal(admin.open, true); // ancestor of the current leaf opened
|
assert.equal(admin.open, true); // ancestor of the current leaf opened
|
||||||
|
|||||||
+3
-3
@@ -18,7 +18,7 @@ const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashbo
|
|||||||
export interface PageChrome {
|
export interface PageChrome {
|
||||||
brand: { logo?: string; name: string; sub?: string };
|
brand: { logo?: string; name: string; sub?: string };
|
||||||
csrfToken: string; // double-submit token for the shell's Sign-out form + a plugin's own forms
|
csrfToken: string; // double-submit token for the shell's Sign-out form + a plugin's own forms
|
||||||
nav: NavNode[]; // global menu, composed + role-filtered + current-marked, ready for nav-tree.ejs
|
nav: NavNode[]; // global menu, composed + permission-filtered + current-marked, ready for nav-tree.ejs
|
||||||
signInHref: string; // where the shell's anonymous "Sign in" link points — carries this page as return_to
|
signInHref: string; // where the shell's anonymous "Sign in" link points — carries this page as return_to
|
||||||
theme?: string;
|
theme?: string;
|
||||||
user: ShellUser;
|
user: ShellUser;
|
||||||
@@ -39,8 +39,8 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
|||||||
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
|
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
|
||||||
for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
|
for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
|
||||||
|
|
||||||
const roles = opts.user?.roles ?? [];
|
const permissions = opts.user?.permissions ?? [];
|
||||||
const nav = composeNav(fragments, opts.menu.override, roles);
|
const nav = composeNav(fragments, opts.menu.override, permissions);
|
||||||
if (opts.currentPath) {
|
if (opts.currentPath) {
|
||||||
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
|
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
|
||||||
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard.
|
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard.
|
||||||
|
|||||||
@@ -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" }];
|
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", () => {
|
test("dashboard model: titled shell, passes the unified nav + csrf + user through", () => {
|
||||||
const m = buildDashboardModel({ csrfToken: "tok.sig", nav: NAV, user: { email: "ada@x.io", id: "u1", roles: ["admin"] } });
|
const m = buildDashboardModel({ csrfToken: "tok.sig", user: { email: "ada@x.io", id: "u1", permissions: ["admin"] }, nav: NAV });
|
||||||
assert.equal(m.shell.title, "Dashboard");
|
assert.equal(m.shell.title, "Dashboard");
|
||||||
assert.equal(m.shell.csrfToken, "tok.sig");
|
assert.equal(m.shell.csrfToken, "tok.sig");
|
||||||
assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile
|
assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as ejs from "ejs";
|
import ejs from "ejs";
|
||||||
|
|
||||||
const dataTable = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "data-table.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);
|
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 { dirname, join } from "node:path";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as ejs from "ejs";
|
import ejs from "ejs";
|
||||||
|
|
||||||
const field = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "field.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);
|
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 { dirname, join } from "node:path";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as ejs from "ejs";
|
import ejs from "ejs";
|
||||||
|
|
||||||
const filterBar = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "filter-bar.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);
|
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 { dirname, join } from "node:path";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as ejs from "ejs";
|
import ejs from "ejs";
|
||||||
import { ICON_NAMES, buildIconSprite } from "./icons.ts";
|
import { ICON_NAMES, buildIconSprite } from "./icons.ts";
|
||||||
|
|
||||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
|
|||||||
+4
-4
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as ejs from "ejs";
|
import ejs from "ejs";
|
||||||
|
|
||||||
const menu = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "menu.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);
|
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(menu, data);
|
||||||
@@ -18,8 +18,8 @@ test("menu renders trigger, positioning, the item matrix and check groups", asyn
|
|||||||
{ label: "Docs", href: "/docs" }, // link
|
{ label: "Docs", href: "/docs" }, // link
|
||||||
{ sep: true },
|
{ sep: true },
|
||||||
{ label: "Sign out", icon: "i-logout", danger: true },
|
{ label: "Sign out", icon: "i-logout", danger: true },
|
||||||
{ group: { legend: "Role", name: "role", control: "radio", options: [
|
{ group: { legend: "Permission", name: "permission", control: "radio", options: [
|
||||||
{ value: "", label: "Any role", checked: true },
|
{ value: "", label: "Any permission", checked: true },
|
||||||
{ value: "admin", label: "Admin" },
|
{ value: "admin", label: "Admin" },
|
||||||
] } },
|
] } },
|
||||||
{ group: { name: "col", options: [{ value: "name", label: "Name", checked: true }] } }, // checkbox default, no legend
|
{ group: { name: "col", options: [{ value: "name", label: "Name", checked: true }] } }, // checkbox default, no legend
|
||||||
@@ -38,7 +38,7 @@ test("menu renders trigger, positioning, the item matrix and check groups", asyn
|
|||||||
assert.match(html, /<button class="menu-item danger" type="button"><svg class="ico"><use href="#i-logout"\s*\/?><\/svg>Sign out<\/button>/);
|
assert.match(html, /<button class="menu-item danger" type="button"><svg class="ico"><use href="#i-logout"\s*\/?><\/svg>Sign out<\/button>/);
|
||||||
|
|
||||||
// Check group: radios reflect `checked`; legend optional; control defaults to checkbox.
|
// Check group: radios reflect `checked`; legend optional; control defaults to checkbox.
|
||||||
assert.match(html, /<fieldset class="menu-field"><legend class="menu-head">Role<\/legend><label class="menu-check"><input type="radio" name="role" value="" checked>Any role<\/label><label class="menu-check"><input type="radio" name="role" value="admin">Admin<\/label><\/fieldset>/);
|
assert.match(html, /<fieldset class="menu-field"><legend class="menu-head">Permission<\/legend><label class="menu-check"><input type="radio" name="permission" value="" checked>Any permission<\/label><label class="menu-check"><input type="radio" name="permission" value="admin">Admin<\/label><\/fieldset>/);
|
||||||
assert.match(html, /<fieldset class="menu-field"><label class="menu-check"><input type="checkbox" name="col" value="name" checked>Name<\/label><\/fieldset>/);
|
assert.match(html, /<fieldset class="menu-field"><label class="menu-check"><input type="checkbox" name="col" value="name" checked>Name<\/label><\/fieldset>/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import * as ejs from "ejs";
|
import ejs from "ejs";
|
||||||
|
|
||||||
const navTree = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "nav-tree.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);
|
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(navTree, data);
|
||||||
@@ -25,7 +25,7 @@ const nodes = [
|
|||||||
{ label: "Webhooks (soon)" }, // leaf · static
|
{ label: "Webhooks (soon)" }, // leaf · static
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ label: "Roles & Access", children: [{ label: "Roles", href: "/roles" }] }, // header · static · closed
|
{ label: "Permissions & Access", children: [{ label: "Permissions", href: "/permissions" }] }, // header · static · closed
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -59,8 +59,8 @@ test("nav-tree renders the header/leaf × clickable/static matrix with counts, i
|
|||||||
assert.match(html, /<span class="nav-self"><span class="nav-label">Webhooks \(soon\)<\/span><\/span>/);
|
assert.match(html, /<span class="nav-self"><span class="nav-label">Webhooks \(soon\)<\/span><\/span>/);
|
||||||
|
|
||||||
// Header · static · closed (no [open]) + label escaping in both label and aria-label.
|
// Header · static · closed (no [open]) + label escaping in both label and aria-label.
|
||||||
assert.match(html, /<details class="nav-disc"><summary class="nav-tog" aria-label="Toggle Roles & Access">/);
|
assert.match(html, /<details class="nav-disc"><summary class="nav-tog" aria-label="Toggle Permissions & Access">/);
|
||||||
assert.match(html, /<span class="nav-label">Roles & Access<\/span>/);
|
assert.match(html, /<span class="nav-label">Permissions & Access<\/span>/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("nav-tree renders an empty root list with no nodes and never throws", async () => {
|
test("nav-tree renders an empty root list with no nodes and never throws", async () => {
|
||||||
|
|||||||
+6
-6
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { composeNav, type NavNode } from "./nav.ts";
|
import { composeNav, type NavNode } from "./nav.ts";
|
||||||
|
|
||||||
// Two plugin fragments; ids let the override target nodes, `permission` gates per role.
|
// Two plugin fragments; ids let the override target nodes, `permission` gates per permission.
|
||||||
const fragments: NavNode[][] = [
|
const fragments: NavNode[][] = [
|
||||||
[{
|
[{
|
||||||
icon: "i-cal", id: "sched", label: "Scheduling",
|
icon: "i-cal", id: "sched", label: "Scheduling",
|
||||||
@@ -14,7 +14,7 @@ const fragments: NavNode[][] = [
|
|||||||
[{ href: "/reports", id: "reports", label: "Reports", permission: "reports:read" }],
|
[{ href: "/reports", id: "reports", label: "Reports", permission: "reports:read" }],
|
||||||
];
|
];
|
||||||
|
|
||||||
test("composeNav merges fragments, filters by role, and emits clean render nodes", () => {
|
test("composeNav merges fragments, filters by permission, and emits clean render nodes", () => {
|
||||||
const tree = composeNav(fragments, {}, ["scheduling:read"]);
|
const tree = composeNav(fragments, {}, ["scheduling:read"]);
|
||||||
|
|
||||||
// Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts.
|
// Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts.
|
||||||
@@ -24,7 +24,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", () => {
|
test("composeNav drops gated subtrees, empty headers, and (with no permissions) all gated nodes", () => {
|
||||||
// A header the user can't reach takes its whole subtree, even visible children.
|
// A header the user can't reach takes its whole subtree, even visible children.
|
||||||
const gatedHeader: NavNode[][] = [[
|
const gatedHeader: NavNode[][] = [[
|
||||||
{ id: "admin", label: "Admin", permission: "admin", children: [{ href: "/u", id: "u", label: "Users" }] },
|
{ id: "admin", label: "Admin", permission: "admin", children: [{ href: "/u", id: "u", label: "Users" }] },
|
||||||
@@ -41,12 +41,12 @@ test("composeNav drops gated subtrees, empty headers, and (with no roles) all ga
|
|||||||
]];
|
]];
|
||||||
assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]);
|
assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]);
|
||||||
|
|
||||||
// No fragments / no roles → empty tree, never throws.
|
// No fragments / no permissions → empty tree, never throws.
|
||||||
assert.deepEqual(composeNav(), []);
|
assert.deepEqual(composeNav(), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("composeNav keeps a node marked public for everyone — the blessed public alias", () => {
|
test("composeNav keeps a node marked public for everyone — the blessed public alias", () => {
|
||||||
// A header with one public child + one gated child: with no roles, the public child keeps the
|
// A header with one public child + one gated child: with no permissions, the public child keeps the
|
||||||
// header alive (the gated child is filtered out) — so a plugin can show a public menu option to all.
|
// header alive (the gated child is filtered out) — so a plugin can show a public menu option to all.
|
||||||
const frag: NavNode[][] = [[{
|
const frag: NavNode[][] = [[{
|
||||||
icon: "i-cal", id: "sched", label: "Scheduling",
|
icon: "i-cal", id: "sched", label: "Scheduling",
|
||||||
@@ -76,7 +76,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
|
|||||||
hide: ["c"], // remove c from inside the group
|
hide: ["c"], // remove c from inside the group
|
||||||
}, ["root"]);
|
}, ["root"]);
|
||||||
|
|
||||||
// grp emitted (b only, c hidden), reordered before a; Secret kept now that role "root" is present.
|
// grp emitted (b only, c hidden), reordered before a; Secret kept now that permission "root" is present.
|
||||||
assert.deepEqual(tree, [
|
assert.deepEqual(tree, [
|
||||||
{ icon: "i-box", label: "Group", open: true, children: [{ href: "/b", label: "Beta" }] },
|
{ icon: "i-box", label: "Group", open: true, children: [{ href: "/b", label: "Beta" }] },
|
||||||
{ href: "/a", label: "First" },
|
{ href: "/a", label: "First" },
|
||||||
|
|||||||
+9
-9
@@ -1,10 +1,10 @@
|
|||||||
// composeNav: merge each plugin's nav fragment into one tree, apply the central
|
// composeNav: merge each plugin's nav fragment into one tree, apply the central
|
||||||
// override, then permission-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
|
// `permissions` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or
|
||||||
// declares no `permission`, or `roles` includes that permission token; a gated header hides its whole
|
// declares no `permission`, or `permissions` includes that permission name; a gated header hides its whole
|
||||||
// subtree, and a pure header left with no children is dropped. The config/menu.ts supplies
|
// 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
|
// 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/permission).
|
// up to the final permission filter and emits clean nodes ready for nav-tree.ejs (no id/permission).
|
||||||
|
|
||||||
export interface NavNode {
|
export interface NavNode {
|
||||||
id?: string; // stable key for override targeting; stripped from the rendered tree
|
id?: string; // stable key for override targeting; stripped from the rendered tree
|
||||||
@@ -15,7 +15,7 @@ export interface NavNode {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
label: string;
|
label: string;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
permission?: string; // required role token; consumed by the filter, never rendered
|
permission?: string; // required permission token; consumed by the filter, never rendered
|
||||||
public?: boolean; // show to everyone, signed in or not — the blessed alias for "no permission", stated outright; consumed by the filter, never rendered. Mutually exclusive with permission (discovery refuses both).
|
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).
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,14 +39,14 @@ export interface NavGroupSpec {
|
|||||||
export function composeNav(
|
export function composeNav(
|
||||||
fragments: NavNode[][] = [],
|
fragments: NavNode[][] = [],
|
||||||
override: NavOverride = {},
|
override: NavOverride = {},
|
||||||
roles: string[] = [],
|
permissions: string[] = [],
|
||||||
): NavNode[] {
|
): NavNode[] {
|
||||||
let nodes: NavNode[] = fragments.flat();
|
let nodes: NavNode[] = fragments.flat();
|
||||||
if (override.rename) nodes = renameTree(nodes, override.rename);
|
if (override.rename) nodes = renameTree(nodes, override.rename);
|
||||||
if (override.groups?.length) nodes = applyGroups(nodes, override.groups);
|
if (override.groups?.length) nodes = applyGroups(nodes, override.groups);
|
||||||
if (override.order?.length) nodes = applyOrder(nodes, override.order);
|
if (override.order?.length) nodes = applyOrder(nodes, override.order);
|
||||||
if (override.hide?.length) nodes = hideTree(nodes, new Set(override.hide));
|
if (override.hide?.length) nodes = hideTree(nodes, new Set(override.hide));
|
||||||
return filterByRoles(nodes, new Set(roles)).map(toRenderNode);
|
return filterByRoles(nodes, new Set(permissions)).map(toRenderNode);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renameTree(nodes: NavNode[], rename: Record<string, string>): NavNode[] {
|
function renameTree(nodes: NavNode[], rename: Record<string, string>): NavNode[] {
|
||||||
@@ -103,12 +103,12 @@ function hideTree(nodes: NavNode[], hide: Set<string>): NavNode[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterByRoles(nodes: NavNode[], roles: Set<string>): NavNode[] {
|
function filterByRoles(nodes: NavNode[], permissions: Set<string>): NavNode[] {
|
||||||
const out: NavNode[] = [];
|
const out: NavNode[] = [];
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
if (n.public !== true && n.permission != null && !roles.has(n.permission)) continue; // gated → drop node + subtree (public always shows)
|
if (n.public !== true && n.permission != null && !permissions.has(n.permission)) continue; // gated → drop node + subtree (public always shows)
|
||||||
if (!n.children) { out.push(n); continue; }
|
if (!n.children) { out.push(n); continue; }
|
||||||
const children = filterByRoles(n.children, roles);
|
const children = filterByRoles(n.children, permissions);
|
||||||
if (children.length === 0 && n.href == null) continue; // empty pure header → drop
|
if (children.length === 0 && n.href == null) continue; // empty pure header → drop
|
||||||
out.push({ ...n, children });
|
out.push({ ...n, children });
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user