Compare commits
240 Commits
fb480973b7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c2389f871d | |||
| 8e73d165da | |||
| 04545274f3 | |||
| 8ffbb0b70d | |||
| b8686377c9 | |||
| 217a36252e | |||
| 03bcf216a7 | |||
| 306f322b62 | |||
| 396ce93a2f | |||
| 5be561c4ba | |||
| 8f7bc59b38 | |||
| 622059101f | |||
| 3592a30f98 | |||
| f340933e39 | |||
| 00fe8b0dd2 | |||
| 767c76b16c | |||
| 4cb21d8909 | |||
| ccb4d32d39 | |||
| 053438f968 | |||
| 29d496a02e | |||
| 4779a9a947 | |||
| 03d7be61b3 | |||
| ead604f49d | |||
| 7cf37acb1b | |||
| c78e5509b2 | |||
| 03544e8d8d | |||
| eda07269d1 | |||
| 2170ca728b | |||
| d0e1de96b8 | |||
| 8a93d59988 | |||
| 7539f8ec0d | |||
| 5a204facdb | |||
| 7f874edb4d | |||
| 7ff8140e07 | |||
| cf8fa437cd | |||
| a7781869fd | |||
| ee4d09df06 | |||
| 282c4b17da | |||
| 1adeb776a2 | |||
| b6afdf1ecc | |||
| b478cc7735 | |||
| 8906340cf8 | |||
| 35f71d435b | |||
| c36f393fff | |||
| ad812e17c2 | |||
| fcc5e04e0d | |||
| 6202b3223b | |||
| c1abc67a92 | |||
| 71b12a6c0a | |||
| 72528f01ef | |||
| 65e2340e49 | |||
| c38ad74e3a | |||
| a7135763bb | |||
| 236f4c4610 | |||
| 6358858103 | |||
| 3d757d4726 | |||
| 910ac18798 | |||
| 48c89baf8e | |||
| b863ea26d9 | |||
| 3c0878ed1a | |||
| 21877aeadd | |||
| 85bfadd040 | |||
| 346d232cf7 | |||
| f4969d5c1f | |||
| 76995008eb | |||
| dfd26ada34 | |||
| 5c84cc77fe | |||
| 943520566e | |||
| 8d1e8be6fc | |||
| dbd8af08ba | |||
| 2a4ed30e93 | |||
| 15c5a09667 | |||
| 73e6d5fb08 | |||
| f527850107 | |||
| 6f689e517b | |||
| ea259f09a8 | |||
| ec35aaaa9e | |||
| 14c09a76c0 | |||
| 2be8ebfa67 | |||
| 5b7ad118ab | |||
| 437a2e8580 | |||
| 57a1a2899f | |||
| 352de29013 | |||
| de4eb7d9ee | |||
| a8a9744744 | |||
| 10152d2c6f | |||
| 4e7d897516 | |||
| cd46c77332 | |||
| 16c9822c29 | |||
| 520d2c928d | |||
| be3553c1e2 | |||
| cef2d4f4f2 | |||
| b336dee6d7 | |||
| af157c95fe | |||
| 3f30cfafe6 | |||
| 25b24a1057 | |||
| 1905d2c310 | |||
| 16da74f03f | |||
| bb8e6bc8e8 | |||
| 0f20b386e2 | |||
| 6815caa47e | |||
| 9e5b60a333 | |||
| 21a00f895a | |||
| 2ab482e774 | |||
| f46e97383e | |||
| ce69269ae4 | |||
| 364b04795c | |||
| 19987ba1b0 | |||
| c12fd2742f | |||
| 48655c0ad2 | |||
| a8d4139dd8 | |||
| a190166421 | |||
| ac52abd330 | |||
| a2edfbee0c | |||
| 635006b5fc | |||
| 3e70768433 | |||
| 56ff987c97 | |||
| d8df129fca | |||
| 270d80c8d0 | |||
| 2a3ace885d | |||
| e9a7c0e726 | |||
| 7e3e8acb28 | |||
| eed1050c08 | |||
| 6f11a9928b | |||
| 070d745a4b | |||
| 9370fb36f2 | |||
| 3c5c0c911c | |||
| 44a777dc3f | |||
| 1dfc6aac3a | |||
| 0c0a3bc905 | |||
| 935674bc49 | |||
| e8dbb2a809 | |||
| 79f26491db | |||
| 77b7e6143f | |||
| 18b6a5a64a | |||
| ad3bb3198e | |||
| 74ebf2e7ec | |||
| 53e996394f | |||
| 3491e660e9 | |||
| 667d589401 | |||
| 5a968fd076 | |||
| b81c136fe5 | |||
| c9e25f2852 | |||
| 603e29e8f1 | |||
| 055bfd6506 | |||
| 80cc692634 | |||
| ebf34deda4 | |||
| 937a0739ca | |||
| 44813be8a7 | |||
| 785230673b | |||
| 80981eb791 | |||
| 0097481b4c | |||
| b09f8b7eb6 | |||
| fb464acf54 | |||
| 8220b34fd0 | |||
| 194557fb49 | |||
| 4a0fc2b493 | |||
| 946c45663f | |||
| 41ab968b20 | |||
| ff548a30d2 | |||
| f80c6c6426 | |||
| 3658797764 | |||
| 23f1158211 | |||
| bf9302ce8e | |||
| 2ed134fc86 | |||
| e07edeb6ae | |||
| e9cd7ba2b3 | |||
| a49bd4f7b0 | |||
| e680552961 | |||
| 12977bc296 | |||
| dac189b124 | |||
| c9e2f1a9e4 | |||
| 7a17ec11de | |||
| ec1579a039 | |||
| 153e77174d | |||
| 6e21eb2d4b | |||
| d8a31def52 | |||
| 5b49bce690 | |||
| 584a383d22 | |||
| 28dbc1a0ff | |||
| 0f0a842f34 | |||
| 88c5357268 | |||
| 3bd9adc73b | |||
| 51c95b32c1 | |||
| 3593b39e43 | |||
| 5f9d74ae4f | |||
| 79cee25b66 | |||
| 2993b462a1 | |||
| 7645f0302a | |||
| e5531a44cd | |||
| 3b1cd891ed | |||
| f06040b511 | |||
| 58767cb53e | |||
| 16873662b9 | |||
| 53ab35bfb8 | |||
| 60aa8a888e | |||
| 2f29853121 | |||
| 41aa59d05d | |||
| cb4f8d6e98 | |||
| 696da397e7 | |||
| 7f2c0cc2c2 | |||
| b9129fba08 | |||
| cb4468ff77 | |||
| 5ffa682fe7 | |||
| fbc1633a1a | |||
| bebefeafde | |||
| 465055e699 | |||
| db56a32e29 | |||
| 51219a3ee5 | |||
| f5c3d93837 | |||
| ea320f6a28 | |||
| d3151f222a | |||
| 54956fe627 | |||
| 9912dd64f1 | |||
| 76fa6a96ea | |||
| e81c281c1a | |||
| 1fb19f6c26 | |||
| 18dc4f3136 | |||
| 390ac5f112 | |||
| 6a0d11d9d3 | |||
| dfb043c3bd | |||
| 5d1e8f2309 | |||
| bf146c07e7 | |||
| a17ed96b54 | |||
| c7e6d66750 | |||
| a113d14e42 | |||
| 8da75b4ca7 | |||
| 4ad8653a06 | |||
| cfcb7a7dc2 | |||
| 814005d267 | |||
| d7bc7fbd36 | |||
| 7bde22713f | |||
| 566c286f87 | |||
| 0ad4c6b09c | |||
| ebc1398906 | |||
| cf48b3014c | |||
| 43bb004ae7 | |||
| f4693af3df | |||
| c702a347dc | |||
| 26ba278eb3 |
@@ -19,4 +19,4 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker run --rm -v "$PWD:/repo" -w /repo \
|
docker run --rm -v "$PWD:/repo" -w /repo \
|
||||||
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
|
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
|
||||||
node:24.19.0-alpine3.24 node registry-cleanup/cleanup.ts
|
node:24.22.2-alpine3.24 node registry-cleanup/cleanup.ts
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
name: Release
|
name: Release
|
||||||
|
# The Hub overview names a released tag, so it is published here only, never on a push to main.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ['v[0-9]+.[0-9]+.[0-9]+']
|
tags: ['v[0-9]+.[0-9]+.[0-9]+']
|
||||||
@@ -21,7 +22,7 @@ jobs:
|
|||||||
GIT_TAG: ${{ github.ref_name }}
|
GIT_TAG: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
|
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
|
||||||
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
|
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
|
||||||
- name: Promote the commit-hash image to semver + latest
|
- name: Promote the commit-hash image to semver + latest
|
||||||
env:
|
env:
|
||||||
@@ -98,9 +99,9 @@ jobs:
|
|||||||
VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
|
VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
|
||||||
VERSION=${VERSION#v}
|
VERSION=${VERSION#v}
|
||||||
# An empty dispatch input falls back to the branch name, so gate this like a tag.
|
# An empty dispatch input falls back to the branch name, so gate this like a tag.
|
||||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
|
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
|
||||||
node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts
|
node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts
|
||||||
docker run --rm -v "$PWD:/repo" -w /repo \
|
docker run --rm -v "$PWD:/repo" -w /repo \
|
||||||
-e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \
|
-e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \
|
||||||
node:24.19.0-alpine3.24 \
|
node:24.22.2-alpine3.24 \
|
||||||
node release-tooling/dockerhub-overview.ts "$VERSION"
|
node release-tooling/dockerhub-overview.ts "$VERSION"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
-e RENOVATE_PLATFORM=gitea \
|
-e RENOVATE_PLATFORM=gitea \
|
||||||
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
|
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
|
||||||
-e RENOVATE_TOKEN \
|
-e RENOVATE_TOKEN \
|
||||||
renovate/renovate:44.41.1
|
renovate/renovate:44.186.6
|
||||||
|
|
||||||
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
|
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
|
||||||
# last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
|
# last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
|
||||||
@@ -38,6 +38,8 @@ jobs:
|
|||||||
- uses: actions/checkout@v7.0.1
|
- uses: actions/checkout@v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
# A persisted token outranks the PAT in the push URL, and Gitea runs nothing for its pushes.
|
||||||
|
persist-credentials: false
|
||||||
- name: Tag a release for what Renovate merged
|
- name: Tag a release for what Renovate merged
|
||||||
env:
|
env:
|
||||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
@@ -58,11 +60,11 @@ jobs:
|
|||||||
if [ -z "$BUMPS" ]; then
|
if [ -z "$BUMPS" ]; then
|
||||||
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0
|
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0
|
||||||
fi
|
fi
|
||||||
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
|
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
|
||||||
node release-tooling/next-version.ts "$LATEST" $BUMPS)
|
node release-tooling/next-version.ts "$LATEST" $BUMPS)
|
||||||
# Read the constant off origin/main, not the checkout, which lags the merges this run made.
|
# Read the constant off origin/main, not the checkout, which lags the merges this run made.
|
||||||
git show origin/main:src/plugin-host/plugin.ts \
|
git show origin/main:src/plugin-host/plugin.ts \
|
||||||
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
|
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
|
||||||
node release-tooling/contract-version.ts "$NEXT" -
|
node release-tooling/contract-version.ts "$NEXT" -
|
||||||
echo "Releasing $LATEST -> $NEXT"
|
echo "Releasing $LATEST -> $NEXT"
|
||||||
git tag "$NEXT" origin/main
|
git tag "$NEXT" origin/main
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ branch, create a PR and merge it when the CI/CD turns green.
|
|||||||
## Project priorities (do not erode)
|
## Project priorities (do not erode)
|
||||||
|
|
||||||
1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable.
|
1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable.
|
||||||
|
**A page is a document**: it scrolls, and the chrome scrolls with it. Nothing may bound the
|
||||||
|
viewport to hold content still — no `height: 100dvh` frame, no `overflow: hidden` on `body`, no
|
||||||
|
`position: sticky` header. Each such box buys an app-like look with CSS the next reader has to
|
||||||
|
reverse-engineer, and is one more thing to undo before the content under it can be reached.
|
||||||
|
Overlays are not this: the skip link, the mobile off-canvas nav and its scrim sit *above* the
|
||||||
|
document rather than holding it still, and have no other spelling — the document keeps scrolling
|
||||||
|
behind the open nav, accepted rather than overlooked. Only the document scroller gets keyboard
|
||||||
|
paging unconditionally and back/forward scroll restoration, and a page a box clips fails silently:
|
||||||
|
nothing in a test or a console says content is unreachable below the fold.
|
||||||
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`,
|
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`,
|
||||||
`postgres`). Prefer the Node standard library; justify any new dependency; do not add frameworks.
|
`postgres`). Prefer the Node standard library; justify any new dependency; do not add frameworks.
|
||||||
The **host is stateless — it owns no schema and stores nothing of its own**; a plugin may own a
|
The **host is stateless — it owns no schema and stores nothing of its own**; a plugin may own a
|
||||||
@@ -182,20 +191,39 @@ Revisit only if the stated reason stops holding.
|
|||||||
example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
|
example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
|
||||||
× 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
|
× 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
|
||||||
verb — a route table must answer "what does this need?" on its own.
|
verb — a route table must answer "what does this need?" on its own.
|
||||||
|
- **A gate is one of three, named exactly once, and `session` is a first-class one.** A route or nav
|
||||||
|
node names exactly one of `public`, `session`, `permission` — discovery refuses none, two, and a
|
||||||
|
flag spelled anything but `true`, so a forgotten gate fails the boot rather than publishing a page.
|
||||||
|
`src/auth/gate.ts` is the one home of the rule the plugin router, the host's own route table and
|
||||||
|
the menu all read. Exactly-one-gate is a discovery-time rule on manifests, not a runtime
|
||||||
|
invariant: `allows({}, user)` stays open **by design**, because the central override's `groups`
|
||||||
|
builds header nodes that carry no gate. Making `allows` fail closed would hide every
|
||||||
|
operator-grouped section. `session` exists because a plugin whose data is
|
||||||
|
the visitor's own — their upstream account, their own tokens — has no distinction a permission could
|
||||||
|
name; the alternative, granting every newly registered user a permission, couples the identity
|
||||||
|
lifecycle to a Keto write that nothing retries when it fails. A page scoped to "mine" joins on
|
||||||
|
`ctx.user.id`, never the email — an address is user-changeable and can be reassigned to someone
|
||||||
|
who would then inherit the previous holder's rows.
|
||||||
|
- **The reference plugin's two shift pages duplicate a view model and markup on purpose.** An example
|
||||||
|
is read far more often than it is changed, and each page reads top to bottom on its own. **Valid
|
||||||
|
while `examples/plugins/scheduling` stays a teaching artifact rather than a maintained product.**
|
||||||
- **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry
|
- **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry
|
||||||
`canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders,
|
`canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders,
|
||||||
disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create
|
disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create
|
||||||
form, a delete-confirm page) is the exception to `actionForMethod` and gates on `:write`. Two
|
form, a delete-confirm page) is the exception to `actionForMethod` and gates on `:write`. Two
|
||||||
grant-specific guards go with it: you cannot revoke your own **direct** grants (self-lockout would
|
grant-specific guards go with it: you cannot revoke your own **direct** grants (self-lockout would
|
||||||
need a `curl` against Keto to undo), and a permission held *through a group* renders
|
need a `curl` against Keto to undo), and a permission held *through a group* renders
|
||||||
ticked-but-disabled, because unticked stated the opposite of the truth. **Known gap:** the group
|
ticked-but-disabled, because unticked stated the opposite of the truth. The group paths are
|
||||||
paths are unguarded — unticking a permission on a group you belong to, leaving it, or deleting it
|
guarded too: unticking a permission on a group you are in, removing yourself (or a nested group you
|
||||||
can still strip your own access. The robust "last effective holder" check needs a reverse Keto
|
are in) from its members, or deleting the group is refused when you would lose a permission you
|
||||||
query and is deferred.
|
don't hold directly.
|
||||||
- **`users:write` and `groups:write` are equivalent to full administrative access**: `groups:write`
|
- **`users:write` and `groups:write` are equivalent to full administrative access**: `groups:write`
|
||||||
adds you to any group, including one holding every permission; `users:write` mints a recovery code
|
adds you to any group, including one holding every permission; `users:write` mints a recovery code
|
||||||
for any account. The containment the split buys is real on the **read** half only (`users:read` is
|
for any account. The containment the split buys is real on the **read** half only (`users:read` is
|
||||||
a safe helpdesk grant). Don't let the per-resource naming imply otherwise in docs.
|
a safe helpdesk grant). Don't let the per-resource naming imply otherwise in docs.
|
||||||
|
- **The CSRF token is deliberately unbound to the session** (`src/auth/csrf.ts`; README → Security
|
||||||
|
model): the usual cost of a signed double-submit token, covered by `SameSite=Lax` and HSTS.
|
||||||
|
**Valid while the session cookie stays `SameSite=Lax` or stricter.**
|
||||||
- **Plainpages says "user" everywhere; Ory's word is "identity".** House style, not a renamed
|
- **Plainpages says "user" everywhere; Ory's word is "identity".** House style, not a renamed
|
||||||
concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors
|
concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors
|
||||||
Kratos' wire shape — don't rename it.
|
Kratos' wire shape — don't rename it.
|
||||||
@@ -218,7 +246,8 @@ Revisit only if the stated reason stops holding.
|
|||||||
- **`locale` is a host-owned query param** — in `parseListQuery`'s reserved set, so a localized list
|
- **`locale` is a host-owned query param** — in `parseListQuery`'s reserved set, so a localized list
|
||||||
page doesn't hand a plugin a phantom `locale` filter. The i18n view locals (`t`, `locale`, `locales`,
|
page doesn't hand a plugin a phantom `locale` filter. The i18n view locals (`t`, `locale`, `locales`,
|
||||||
`localeHref`, `localeParam`, `localeSwitch`, `dir`) are likewise reserved, merged after a handler's
|
`localeHref`, `localeParam`, `localeSwitch`, `dir`) are likewise reserved, merged after a handler's
|
||||||
`data` so a collision loses the key instead of breaking the shell.
|
`data` so a collision loses the key instead of breaking the shell — as is `assetHref`
|
||||||
|
(`src/http/static.ts`), which stays undocumented for plugins because calling it is not yet contract.
|
||||||
- **Catalogs are checked at boot, not at render.** Every locale is compared against its set's `en-US`
|
- **Catalogs are checked at boot, not at render.** Every locale is compared against its set's `en-US`
|
||||||
— keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` requires — and a
|
— keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` requires — and a
|
||||||
mismatch stops startup. A plugin may ship fewer locales than the host (its strings fall back to
|
mismatch stops startup. A plugin may ship fewer locales than the host (its strings fall back to
|
||||||
@@ -254,11 +283,18 @@ Revisit only if the stated reason stops holding.
|
|||||||
open-state style and the old-browser fallback both read; the partial **requires a caller-named
|
open-state style and the old-browser fallback both read; the partial **requires a caller-named
|
||||||
`id`** and fails loud without one, since that is the `popovertarget` idref (never generate one —
|
`id`** and fails loud without one, since that is the `popovertarget` idref (never generate one —
|
||||||
nondeterministic HTML forecloses the caching decision); and **neither `aria-expanded` nor
|
nondeterministic HTML forecloses the caching decision); and **neither `aria-expanded` nor
|
||||||
`aria-haspopup` is written**, because a zero-JS invoker cannot keep the first truthful and the
|
`aria-haspopup` is written**: every engine maintains the first itself on a declarative
|
||||||
second would promise `role="menu"` semantics these panels don't implement. `<details>` stays where
|
`popovertarget` invoker, so a hand-written one replaces a live state with a static lie, and the
|
||||||
|
second would promise `role="menu"` semantics these panels don't implement. **That guarantee is the
|
||||||
|
declarative attribute's alone** — open a panel from script and no engine applies it, so
|
||||||
|
"enhancing" one of these triggers is what would cost it its accessibility. `<details>` stays where
|
||||||
it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
|
it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
|
||||||
profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
|
profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
|
||||||
the two in step.
|
the two in step.
|
||||||
|
- **One scroller, the document** (priority 1). `.app` is `min-height: 100dvh`. `.nav`'s
|
||||||
|
`overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto` are not leftovers of a bounded frame:
|
||||||
|
they are what makes the off-canvas panel usable with a long tree. `#nav-toggle` is `position: fixed` —
|
||||||
|
a label click focuses it, and a browser scrolls a focused element into view.
|
||||||
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it
|
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it
|
||||||
is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference
|
is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference
|
||||||
to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an
|
to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an
|
||||||
@@ -304,10 +340,10 @@ Revisit only if the stated reason stops holding.
|
|||||||
*text* guard — the test image ships neither `git` nor `bash`. This is why the Docker Hub overview is
|
*text* guard — the test image ships neither `git` nor `bash`. This is why the Docker Hub overview is
|
||||||
`release-tooling/dockerhub-overview.md.tmpl` and not a `.md`: a release reads it and a unit test
|
`release-tooling/dockerhub-overview.md.tmpl` and not a `.md`: a release reads it and a unit test
|
||||||
guards it, so giving it a `.md` name would let a broken `{{VERSION}}` merge with its own guard
|
guards it, so giving it a `.md` name would let a broken `{{VERSION}}` merge with its own guard
|
||||||
skipped. `README.md` is the one markdown a test reads — `release-tooling/contract-version.test.ts`
|
skipped. `README.md` is the one markdown tests read, and a README-only change skips them. Accepted:
|
||||||
checks its `apiVersion` samples — and a README-only change skips that check; accepted, because
|
the `apiVersion` samples `release-tooling/contract-version.test.ts` checks are illustrative, and
|
||||||
those samples are illustrative and the copies that matter (`examples/`, `views/`, the template) are
|
the copies that matter (`examples/`, `views/`, the template) are gated. A Hub page command the
|
||||||
gated. **Valid while no markdown file is rendered or executed.**
|
README dropped (`release-tooling/dockerhub-overview.test.ts`) fails the next gated change. **Valid while no markdown file is rendered or executed.**
|
||||||
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
|
- **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
|
`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
|
can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that
|
||||||
@@ -323,6 +359,7 @@ Docker Compose. **Never run `node`, `npm`, or `tsc` on the host.**
|
|||||||
```bash
|
```bash
|
||||||
docker compose up # dev server, live reload
|
docker compose up # dev server, live reload
|
||||||
docker compose run --rm --no-deps web npm run typecheck # strict type check (--no-deps: skip Ory)
|
docker compose run --rm --no-deps web npm run typecheck # strict type check (--no-deps: skip Ory)
|
||||||
|
docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --user "$(id -u):$(id -g)" --rm --no-deps --build e2e npm run typecheck # the E2E specs
|
||||||
docker compose run --rm --no-deps web npm test # tests
|
docker compose run --rm --no-deps web npm test # tests
|
||||||
docker compose -f compose.yml up --build -d # production
|
docker compose -f compose.yml up --build -d # production
|
||||||
```
|
```
|
||||||
@@ -335,7 +372,7 @@ docker compose -f compose.yml up --build -d # production
|
|||||||
stack up and a *minimal* plugin live. Nothing comes before Quick start. Keep its commands
|
stack up and a *minimal* plugin live. Nothing comes before Quick start. Keep its commands
|
||||||
copy-pasteable; deeper detail lives in its own section, linked.
|
copy-pasteable; deeper detail lives in its own section, linked.
|
||||||
2. **Returning developer (rest).** A **Contents** ToC right after Quick start, then sections ordered
|
2. **Returning developer (rest).** A **Contents** ToC right after Quick start, then sections ordered
|
||||||
by **what an adopter reaches for first**, not by architectural layering: Overview → Users, groups
|
by **what an adopter reaches for first**, not by architectural layering: Goals → Audience → Overview → Glossary → Users, groups
|
||||||
& permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
|
& permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
|
||||||
Architecture → Testing → Production → Observability → JWT-rotation runbook → Project-layout file
|
Architecture → Testing → Production → Observability → JWT-rotation runbook → Project-layout file
|
||||||
map → Extending. Place a new section by how early an adopter needs it. **Users, groups &
|
map → Extending. Place a new section by how early an adopter needs it. **Users, groups &
|
||||||
@@ -356,7 +393,7 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
|
|||||||
If a file genuinely must be plain JavaScript, use `.js`; `"type": "module"` is set in both
|
If a file genuinely must be plain JavaScript, use `.js`; `"type": "module"` is set in both
|
||||||
`package.json`s, so `.js` is ESM.
|
`package.json`s, so `.js` is ESM.
|
||||||
- **No build step** and no compiled artifacts — do not add a bundler or `tsc` emit.
|
- **No build step** and no compiled artifacts — do not add a bundler or `tsc` emit.
|
||||||
- Before finishing a change, run the typecheck and tests above; both must pass.
|
- Before finishing a change, run the typechecks and tests above; all must pass.
|
||||||
- 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.
|
- English everywhere.
|
||||||
- Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never
|
- Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never
|
||||||
@@ -398,11 +435,9 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
|
|||||||
Don't funnel many routes into one dispatcher that re-parses `ctx.url.pathname`: it duplicates the
|
Don't funnel many routes into one dispatcher 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
|
URL shape, ignores the router's params, and has to re-handle HEAD. Factor shared per-request setup
|
||||||
into a small `withX` wrapper — see `examples/plugins/admin/`.
|
into a small `withX` wrapper — see `examples/plugins/admin/`.
|
||||||
- **`handleRequest` (`src/http/app.ts`) is a known complexity hotspot** — ~160 lines tracking
|
- **A function stays within 50 lines and complexity 20** (ESLint's defaults, measured by
|
||||||
canonical host, static, locale, session + re-mint, CSRF, chrome, hooks, plugin routing, builtin
|
`src/function-limits.test.ts`). Split one that outgrows them; never add it to the test's
|
||||||
routing, 405/404 and error mapping. The pure parts are already extracted and separately tested;
|
`grandfathered` list or raise a ceiling there.
|
||||||
what remains is orchestration. Planned split along those seams; don't grow it further without
|
|
||||||
taking one out.
|
|
||||||
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer agents.
|
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer agents.
|
||||||
- **A user-visible string belongs in a catalog, not in the code or a view.** Core strings go in
|
- **A user-visible string belongs in a catalog, not in the code or a view.** Core strings go in
|
||||||
`src/i18n/locales/en-US.ts` (then every other locale, or the boot fails); a plugin's go in its own
|
`src/i18n/locales/en-US.ts` (then every other locale, or the boot fails); a plugin's go in its own
|
||||||
@@ -415,9 +450,12 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
|
|||||||
same rule in their own language. An unmapped Kratos id renders Kratos' own wording — map the id when
|
same rule in their own language. An unmapped Kratos id renders Kratos' own wording — map the id when
|
||||||
it matters. **Held by the author, never by a test:** slightly different wording is often the right
|
it matters. **Held by the author, never by a test:** slightly different wording is often the right
|
||||||
call, and a build-failing check takes that judgment away.
|
call, and a build-failing check takes that judgment away.
|
||||||
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POSTing it, for
|
- Use well formed, standard compliant, rich URIs. **State lives in the URL or on the server, never in
|
||||||
example on list pages with filters and pagination. Do `ids=x&ids=y`, not `ids[]=x&ids[]=y` and not
|
a cookie.** Prefer state in the URL over POSTing it, for example on list pages with filters and
|
||||||
`ids=x,y`.
|
pagination. A message for the page a redirect lands on rides its query string — `info-msg`,
|
||||||
|
`warn-msg`, `error-msg` — since a fragment never reaches the server. A cookie carries only what
|
||||||
|
must be bound to the browser: the session (`plainpages_jwt`) and the CSRF token (`plainpages_csrf`).
|
||||||
|
Do `ids=x&ids=y`, not `ids[]=x&ids[]=y` and not `ids=x,y`.
|
||||||
|
|
||||||
## Comments
|
## Comments
|
||||||
|
|
||||||
@@ -425,3 +463,13 @@ Default to **no comment**. Delete one that restates the adjacent code, repeats a
|
|||||||
elsewhere, justifies self-evident code, or records history. Write one only for what a competent
|
elsewhere, justifies self-evident code, or records history. Write one only for what a competent
|
||||||
reader of *this* codebase could not infer: a surprising why, a footgun, an invariant, an external
|
reader of *this* codebase could not infer: a surprising why, a footgun, an invariant, an external
|
||||||
constraint. See [Prose discipline](#prose-discipline).
|
constraint. See [Prose discipline](#prose-discipline).
|
||||||
|
|
||||||
|
## Scoring run
|
||||||
|
|
||||||
|
The comprehension panel's seats fill their prompts from these fields.
|
||||||
|
|
||||||
|
- **Language:** strict TypeScript on Node 24, with EJS views and plain CSS
|
||||||
|
- **Kind:** a server-rendered web GUI host with a plugin system
|
||||||
|
- **Domain:** web authentication, authorization and server-rendered admin GUIs
|
||||||
|
- **Domain docs:** the Ory Kratos, Keto and Hydra documentation
|
||||||
|
- **3am question:** a visitor on an old TV browser reports that the sign-in button is blank. Which file and rule?
|
||||||
|
|||||||
+241
@@ -0,0 +1,241 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
The release version **is** the plugin contract version (`HOST_API_VERSION`), so a minor is a
|
||||||
|
contract break: a plugin's `apiVersion` must match the host's `major.minor` or discovery refuses it
|
||||||
|
at boot. Entries start at 0.3.0.
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### A malformed URL variable's boot error no longer prints the value
|
||||||
|
|
||||||
|
When a URL variable such as `PLUGIN_DB_URL`, `PLUGIN_DB_ADMIN_URL` or `OTLP_ENDPOINT` can't be parsed,
|
||||||
|
the boot error names the variable and not its value, so a DSN whose password breaks parsing doesn't
|
||||||
|
put the password in the log.
|
||||||
|
|
||||||
|
### A secret plugin setting's bad value stays out of the boot error
|
||||||
|
|
||||||
|
When a `secret` setting's value fails validation, the boot error no longer prints it, so a malformed
|
||||||
|
DSN doesn't put its password in the log.
|
||||||
|
|
||||||
|
### The admin's recovery-code notice links to the recovery flow the code belongs to
|
||||||
|
|
||||||
|
After an admin generates a recovery code for a user, the notice asks them to send the user both the
|
||||||
|
code and its link. The link opens the recovery flow the code belongs to, where the user enters it.
|
||||||
|
Before, the notice linked a new recovery flow at its email step, which doesn't accept the code.
|
||||||
|
|
||||||
|
### A verification or recovery code pasted with whitespace around it is accepted
|
||||||
|
|
||||||
|
The code field on the verification and recovery pages takes a code with spaces or tabs around it.
|
||||||
|
Pasting a code from an email often adds them, and before, the browser refused the code. Those two
|
||||||
|
forms now post to Plainpages, which trims the code and passes the form on to Kratos at
|
||||||
|
`KRATOS_PUBLIC_URL`. A visitor who picked a language now keeps it through both flows.
|
||||||
|
|
||||||
|
### A filter shows its selected and focused choice in browsers without `:has()`
|
||||||
|
|
||||||
|
On Chromium below 105, Firefox below 121 and Safari below 15.4, a filter's segments and chips now show
|
||||||
|
their native radio or checkbox, which marks the selected choice and takes the keyboard focus ring.
|
||||||
|
Before, they showed neither. Newer browsers look as before.
|
||||||
|
|
||||||
|
### The tests fail on a stylesheet feature the browser floor lacks
|
||||||
|
|
||||||
|
`npm test` checks every stylesheet in `public/`, `examples/` and `plugins/` against Chromium 87,
|
||||||
|
Safari 15 and Firefox 115: a feature one of them lacks fails it unless an `@supports` around it
|
||||||
|
names that feature. Mount a plugin in `plugins/<id>/` to check its stylesheets. Pages render as
|
||||||
|
before in every browser.
|
||||||
|
|
||||||
|
### Every release reaches Docker Hub, and the Hub page has a short description
|
||||||
|
|
||||||
|
Releases 0.4.1 to 0.4.20 were tagged in git but never published to Docker Hub or as semver tags in
|
||||||
|
the Gitea registry; they are now, and every later release is. The Hub page gains a one-line
|
||||||
|
description.
|
||||||
|
|
||||||
|
### The admin example stops an administrator locking themselves out through a group
|
||||||
|
|
||||||
|
On the Groups screen, unticking a permission on a group you are in, removing yourself (or a nested
|
||||||
|
group you are in) from its members, or deleting the group is refused when it would take away a
|
||||||
|
permission you don't hold directly. The page names the permissions, and another administrator can
|
||||||
|
make the change. The check errs towards refusing: it also refuses when you would keep the permission
|
||||||
|
through another group, or keep your membership another way.
|
||||||
|
|
||||||
|
### The admin example titles a read-only user page "View user"
|
||||||
|
|
||||||
|
A holder of `users:read` without `users:write` sees the user page titled "View user", with a "View"
|
||||||
|
breadcrumb, instead of "Edit user".
|
||||||
|
|
||||||
|
### Browsers cache stylesheets instead of downloading them on every page
|
||||||
|
|
||||||
|
Every `/public/` URL the host renders carries `?v=<hash of the file>` and is cached for a year as
|
||||||
|
immutable: the shell's stylesheets, favicon and logo, the `styles` a plugin passes to the shell, and
|
||||||
|
the stylesheets of the standalone error and consent pages. A changed file gets a new hash, so
|
||||||
|
browsers fetch it at once. A plugin names its stylesheets exactly as before. Any other `/public/` URL, such as a script
|
||||||
|
a plugin's own markup links, is cached for five minutes, then revalidated with `ETag` or
|
||||||
|
`Last-Modified`. A cache or CDN in front of Plainpages may now store `/public/` responses.
|
||||||
|
|
||||||
|
### A language chosen on a sign-in link no longer drops an absolute return target
|
||||||
|
|
||||||
|
`/login?return_to=<absolute URL>&locale=sv-SE` passed the visitor to the dashboard after sign-in and
|
||||||
|
dropped the OAuth2 login challenge in `return_to`. The target now reaches Kratos, and the chosen
|
||||||
|
language is lost across that round-trip instead.
|
||||||
|
|
||||||
|
### The admin example refuses a permission save made from an out-of-date page
|
||||||
|
|
||||||
|
Two administrators editing one user's or group's permissions no longer overwrite each other. The
|
||||||
|
form carries a token for the grants it showed. A save that would change something, made after those
|
||||||
|
grants changed, is refused with "These permissions changed while you had the page open", and the page
|
||||||
|
shows the current grants. A POST with no token is refused the same way, so a script posting to
|
||||||
|
`/admin/users/:id/permissions` or `/admin/groups/:name/permissions` must first read the token from the
|
||||||
|
page's `baseline` field.
|
||||||
|
|
||||||
|
### The admin example confirms a permission save and reports a partial one
|
||||||
|
|
||||||
|
A save now lands on the page with "Permissions saved." If the permission service fails midway, the
|
||||||
|
page says the save stopped part-way instead of showing the generic error page. On a user, the
|
||||||
|
changes that landed still reach the user's live sessions at once when the revocation denylist is on.
|
||||||
|
|
||||||
|
### A read-only holder sees View, not Edit, on the admin example's users list
|
||||||
|
|
||||||
|
A holder of `users:read` without `users:write` gets a "View" row action with an eye icon. The admin
|
||||||
|
example's catalogs gain `admin.grants.partial`, `admin.grants.saved`, `admin.grants.stale` and
|
||||||
|
`admin.users.view`, so a translated catalog mounted under `locales/plugins/admin/` must add them.
|
||||||
|
|
||||||
|
### Every page carries a smaller icon sprite
|
||||||
|
|
||||||
|
`i-chart`, `i-copy` and `i-download` are gone from the inlined icon sprite, so a plugin that
|
||||||
|
referenced one now renders a blank icon. Nothing in the host or the example plugins used them. Pick
|
||||||
|
another id from `views/partials/icons.ejs`, or inline your own SVG.
|
||||||
|
|
||||||
|
### The README defines its terms and states its intended scale
|
||||||
|
|
||||||
|
A new Glossary defines host, plugin, manifest, chrome, nav fragment, permission name, system plugin
|
||||||
|
and denylist. Plugin dependencies also states the scale Plainpages is sized for: a handful of plugins
|
||||||
|
per deployment.
|
||||||
|
|
||||||
|
### The mobile nav stays reachable on engines without `:has()`
|
||||||
|
|
||||||
|
At 860px and narrower, an engine without `:has()` (Chromium below 105, such as webOS TV 22 and 23,
|
||||||
|
and Firefox below 121) hid the sidebar off-canvas with no way to open it, so the pages the nav links
|
||||||
|
to could not be reached. Now the sidebar moves off-canvas, behind the hamburger, only where `:has()`
|
||||||
|
works. Elsewhere it stays in the flow above the content and the hamburger is hidden.
|
||||||
|
|
||||||
|
### The whole nav tree shows on engines without `:has()`
|
||||||
|
|
||||||
|
On an engine without `:has()` (Chromium below 105, such as webOS TV 22 and 23, and Firefox below 121)
|
||||||
|
every nested nav entry was hidden with nothing to open it. Now the tree folds only where `:has()`
|
||||||
|
works and stays fully open elsewhere, with its toggles hidden. The theme switch, which also needs
|
||||||
|
`:has()`, is hidden there.
|
||||||
|
|
||||||
|
### Button and badge text meets 4.5:1 contrast
|
||||||
|
|
||||||
|
The primary button and the current page button take their text colour from the new `--on-accent`
|
||||||
|
token: white in light mode, near-black in dark mode, where white on the accent was 2.7:1. The
|
||||||
|
light-mode success, warning and info badge text is darker. A plugin that overrides `--accent` or
|
||||||
|
`--l-accent` should set `--l-on-accent` and `--d-on-accent` to match.
|
||||||
|
|
||||||
|
### Unused auth stylesheet rules are gone
|
||||||
|
|
||||||
|
`public/css/auth.css` no longer holds the `.auth-view` screen switch, `.tpl-*` preview controls,
|
||||||
|
`.forgot-alert`, `.register-alert`, `.remember` and `.auth-foot`; no core view used them. A plugin
|
||||||
|
view that did must carry its own rules.
|
||||||
|
|
||||||
|
### Colours render on engines without `oklch()`
|
||||||
|
|
||||||
|
The theme palette in `public/css/styles.css` is hex, not `oklch()`. On an engine older than
|
||||||
|
Chromium 111 (Tizen 2024, webOS TV 24) `var(--accent)` was invalid, so the primary button was white
|
||||||
|
text on no background. A plugin that sets a colour custom property, such as `--accent` or `--l-accent`,
|
||||||
|
should use hex, `rgb()` or `hsl()` too.
|
||||||
|
|
||||||
|
### One horizontal inset across the content pane
|
||||||
|
|
||||||
|
The topbar, filter bar, pager, form page and empty-table message inset their content by `--pad-x`
|
||||||
|
(12px), as table cells do, instead of 16px. A plugin that overrides `--pad-x` now moves all of them
|
||||||
|
together.
|
||||||
|
|
||||||
|
### Pages are not kept by a shared cache
|
||||||
|
|
||||||
|
Every page and JSON response now carries `Cache-Control: private, no-store`, so a cache or reverse
|
||||||
|
proxy in front of Plainpages can't store a signed-in page and serve it to the next visitor. A route
|
||||||
|
whose page is the same for everyone can still set its own `cache-control` through
|
||||||
|
`RouteResult.headers`; assets are unchanged.
|
||||||
|
|
||||||
|
## 0.4.0
|
||||||
|
|
||||||
|
**Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page
|
||||||
|
that relied on filling it scrolls the document instead.
|
||||||
|
|
||||||
|
### The document scrolls, and the chrome scrolls with it
|
||||||
|
|
||||||
|
`.app` was a `100dvh` box with `overflow: hidden`, so a page was only reachable below the fold if its
|
||||||
|
own wrapper was a flex child with `overflow-y: auto`. `.table-wrap` and `.shell-auth` were; nothing
|
||||||
|
else was, and a long page in `.form-page` clipped everything past the window in every engine.
|
||||||
|
|
||||||
|
Now the shell is `min-height: 100dvh` and nothing bounds the viewport. The sidebar and topbar scroll
|
||||||
|
with the page, and keyboard paging, back/forward scroll restoration and find-in-page work without a
|
||||||
|
page doing anything.
|
||||||
|
|
||||||
|
The sticky `thead` on `data-table` goes with it: a header only sticks to a scrollport that moves, and
|
||||||
|
there is no longer one. A plugin that wants a full-height pane owns that in its own stylesheet; the
|
||||||
|
shell offers no opt-out, per the simplicity priority in `AGENTS.md`.
|
||||||
|
|
||||||
|
### Upgrading a plugin
|
||||||
|
|
||||||
|
1. Set `apiVersion: "0.4.0"`.
|
||||||
|
2. A page that scrolled the whole window needs no change — it now scrolls the document.
|
||||||
|
3. A page holding a region that filled the content column (`flex: 1 1 auto; min-height: 0` with its
|
||||||
|
own `overflow`) no longer gets a bounded column to fill, so that region grows and the page scrolls.
|
||||||
|
Either let it, or give the region its own height in the plugin's stylesheet.
|
||||||
|
4. A `data-table` no longer scrolls its rows in a bounded region: the page scrolls, and the header
|
||||||
|
scrolls with it.
|
||||||
|
|
||||||
|
The sidebar stretches the whole document, so on a long page its footer — theme, language, profile and
|
||||||
|
**Sign out** — sits at the end of that page rather than the bottom of the screen.
|
||||||
|
|
||||||
|
## 0.3.0
|
||||||
|
|
||||||
|
**Breaking.** Set `apiVersion: "0.3.0"`, and name a gate on every route and nav node.
|
||||||
|
|
||||||
|
### A session is a gate of its own
|
||||||
|
|
||||||
|
`session: true` takes any signed-in user, with no grant to hold — for a page whose data is the
|
||||||
|
visitor's own (their upstream account, their own tokens), where there is no distinction a permission
|
||||||
|
could name. An anonymous visitor is bounced to `/login` with the page as `return_to`, exactly as a
|
||||||
|
permission gate does.
|
||||||
|
|
||||||
|
Every route and nav node now names **exactly one** of `public: true`, `session: true` or
|
||||||
|
`permission: "<resource>:<action>"`, and a gate is spelled `true`:
|
||||||
|
|
||||||
|
- Naming **none** is refused. It used to mean public, so a forgotten gate published a page; it now
|
||||||
|
fails the boot instead.
|
||||||
|
- Naming **two** is refused, as before.
|
||||||
|
- Spelling one anything but `true` is refused — `public: false` and `session: "yes"` both set no gate
|
||||||
|
while reading as if they set one.
|
||||||
|
|
||||||
|
A section header gates nothing itself, so it takes `public: true` and lets each child decide; the
|
||||||
|
host still drops a header whose children all filtered out.
|
||||||
|
|
||||||
|
`Gate` is exported from `@plainpages/plugin-api`, and `Route` and `NavNode` extend it.
|
||||||
|
|
||||||
|
### Filter bars take a multi-select
|
||||||
|
|
||||||
|
The `filter-bar` partial gains a `multiselect` control — the same checkboxes on the same query
|
||||||
|
parameter as `chips`, but behind a button once the list is too long to lay on the bar. Config is
|
||||||
|
`{ name, legend?, note?, value?, options }`, and the panel says what a capped list left out.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- An identity carrying no email no longer yields a session at all. Login used to mint a JWT for one,
|
||||||
|
which every later request then rejected as anonymous — leaving the browser holding a dead cookie
|
||||||
|
and no way to tell why.
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Node 24.20.0.
|
||||||
|
|
||||||
|
### Upgrading a plugin
|
||||||
|
|
||||||
|
1. Set `apiVersion: "0.3.0"`.
|
||||||
|
2. Give every route and nav node a gate. Anything that relied on omitting one was public — say
|
||||||
|
`public: true` outright.
|
||||||
|
|
||||||
|
A page that scopes rows to the signed-in visitor should join on `ctx.user.id`. An email address is
|
||||||
|
user-changeable and can be reassigned to someone else, who would then inherit the previous holder's
|
||||||
|
rows. The reference plugin's new `/scheduling/mine` page shows the shape.
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
|
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
|
||||||
FROM node:24.19.0-alpine3.24
|
FROM node:24.22.2-alpine3.24
|
||||||
|
|
||||||
# Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules
|
# Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules
|
||||||
# instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
|
# instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.
|
|||||||
import { definePlugin } from "@plainpages/plugin-api";
|
import { definePlugin } from "@plainpages/plugin-api";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.2.0",
|
apiVersion: "0.4.0",
|
||||||
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
||||||
routes: [
|
routes: [
|
||||||
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
||||||
@@ -68,9 +68,22 @@ From here, render real pages against the app shell and fetch upstream data — s
|
|||||||
[Building plugins](#building-plugins) and the runnable reference in
|
[Building plugins](#building-plugins) and the runnable reference in
|
||||||
[`examples/plugins/scheduling/`](examples/plugins/scheduling/).
|
[`examples/plugins/scheduling/`](examples/plugins/scheduling/).
|
||||||
|
|
||||||
|
**Without a clone**, the image runs a trial stack on its own. Save the `compose.yml` the
|
||||||
|
[Docker Hub page](https://hub.docker.com/r/larvit/plainpages) shows, then extract the Ory config the
|
||||||
|
image ships and start, with `X.Y.Z` the release that page names. The page goes on to a first plugin:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run --rm larvit/plainpages:X.Y.Z tar -cf - ory | tar -xf -
|
||||||
|
mkdir -p plugins
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
|
- [Goals](#goals)
|
||||||
|
- [Audience](#audience)
|
||||||
- [Overview](#overview)
|
- [Overview](#overview)
|
||||||
|
- [Glossary](#glossary)
|
||||||
- [Users, groups & permissions](#users-groups--permissions)
|
- [Users, groups & permissions](#users-groups--permissions)
|
||||||
- [naming a permission](#naming-a-permission)
|
- [naming a permission](#naming-a-permission)
|
||||||
- [a worked example](#a-worked-example)
|
- [a worked example](#a-worked-example)
|
||||||
@@ -121,6 +134,64 @@ From here, render real pages against the app shell and fetch upstream data — s
|
|||||||
- [Project layout](#project-layout)
|
- [Project layout](#project-layout)
|
||||||
- [Extending the core](#extending-the-core)
|
- [Extending the core](#extending-the-core)
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
1. **Achieve:** Less boilerplate for a server-rendered web GUI: a new project starts at
|
||||||
|
its own domain, not at sign-in, a menu or a table.
|
||||||
|
- Signal: a new plugin reaches a working, gated page with a menu, tables, filters and
|
||||||
|
forms built only from the core building blocks.
|
||||||
|
- Counter-signal: the core grows features only one project needs.
|
||||||
|
- Why: boilerplate removed by absorbing a project's domain only moves it into the core.
|
||||||
|
2. **Achieve:** Every building block works for the person using it: a non-technical user
|
||||||
|
gets what they meant on the first try, and can't hurt themselves by accident.
|
||||||
|
- Signal: bugs users hit in a shipped flow get fixed and stay fixed, such as a pasted
|
||||||
|
code being refused, a double click acting twice, or an admin locking themselves out.
|
||||||
|
- Counter-signal: a fix adds client-side JavaScript, or a setting every plugin has to
|
||||||
|
know about.
|
||||||
|
- Why: a fix that breaks zero-JS or simplicity moves the irritation from users to every
|
||||||
|
plugin author.
|
||||||
|
3. **Maintain:** Plainpages solves only the parts every project shares: auth, the GUI
|
||||||
|
building blocks (menu, tables, filters, forms) and the plugin structure. Everything
|
||||||
|
else is a plugin.
|
||||||
|
4. **Maintain:** Every page is a server-rendered document that works with zero JavaScript,
|
||||||
|
on old and embedded browsers too. A newer browser only makes it nicer.
|
||||||
|
5. **Maintain:** State lives in the URL. Only state the URL can't carry lives in the
|
||||||
|
database, never in local storage or JavaScript. A cookie carries only what must be bound
|
||||||
|
to the browser: the session and its CSRF token.
|
||||||
|
6. **Maintain:** The frontend is semantic: the HTML says what each thing is, and the CSS
|
||||||
|
styles meaning, not looks. That keeps every page accessible.
|
||||||
|
7. **Achieve:** Hackable: a plugin can change anything, and Plainpages stays out of its way.
|
||||||
|
- Signal: a plugin replaces any page, partial, menu entry or string without forking the
|
||||||
|
host, and fewer plugins need a host change to get unblocked.
|
||||||
|
- Counter-signal: plugins break on upgrade with no version signal, or a plugin mistake
|
||||||
|
shows up at runtime instead of at boot.
|
||||||
|
- Why: freedom that breaks silently costs more than the boilerplate it saved.
|
||||||
|
8. **Achieve:** Speed: pages render fast and stay small.
|
||||||
|
- Signal: server time per page and bytes sent per page fall.
|
||||||
|
- Counter-signal: a building block loses a feature or accessibility to save bytes.
|
||||||
|
- Why: a fast page that does less only moves the work back into each plugin.
|
||||||
|
9. **Achieve:** LLM-friendly: an LLM builds a correct plugin from the README and the
|
||||||
|
examples alone.
|
||||||
|
- Signal: an agent given only those writes a plugin that passes discovery and its E2E
|
||||||
|
test on the first try.
|
||||||
|
- Counter-signal: the README grows to restate what the code already shows.
|
||||||
|
- Why: docs that pass the test by growing cost every later reader, human or LLM.
|
||||||
|
10. **Maintain:** Every behaviour is an explicit configuration choice, the same in every
|
||||||
|
environment.
|
||||||
|
|
||||||
|
## Audience
|
||||||
|
|
||||||
|
- **Developers starting a web GUI project, and the LLMs that build plugins with them.**
|
||||||
|
They may rely on `@plainpages/plugin-api`, the core partials and the packaging promises,
|
||||||
|
all versioned by `apiVersion`. A change that breaks a plugin takes a major bump.
|
||||||
|
- **Operators** who self-host Plainpages. They may rely on semver-tagged images, on
|
||||||
|
configuration through environment variables and the drop-in mounts (`plugins/`,
|
||||||
|
`config/`, `locales/`), and on upgrades within a minor version that need no change on
|
||||||
|
their side.
|
||||||
|
- **Visitors** to a site built on Plainpages, on any browser, including the older engines in
|
||||||
|
TVs, kiosks, thin clients and embedded panels. They may rely on pages that load fast and
|
||||||
|
work without JavaScript, with a keyboard and a screen reader.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Plainpages gives you the boring-but-hard parts of a web app — a design system, a menu,
|
Plainpages gives you the boring-but-hard parts of a web app — a design system, a menu,
|
||||||
@@ -136,11 +207,9 @@ public front with an authenticated area behind it.
|
|||||||
- **You add:** everything else domain-specific, as **plugins** — built from the same building
|
- **You add:** everything else domain-specific, as **plugins** — built from the same building
|
||||||
blocks the admin plugin uses.
|
blocks the admin plugin uses.
|
||||||
|
|
||||||
**Priorities:** simplicity, few dependencies, strict TypeScript, no build step, Docker-only,
|
Identity, sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar
|
||||||
environment-agnostic (no `NODE_ENV` — every behaviour is an explicit config toggle). Identity,
|
services. The shape doesn't change as it grows: every plugin is the same self-contained folder,
|
||||||
sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar services. The shape
|
the hot path is the same I/O-free JWT check, and the host stores nothing of its own.
|
||||||
doesn't change as it grows: every plugin is the same self-contained folder, the hot path is the
|
|
||||||
same I/O-free JWT check, and there is no app database.
|
|
||||||
|
|
||||||
**Plugins are the extension model.** The plugin API is the product's main surface: powerful,
|
**Plugins are the extension model.** The plugin API is the product's main surface: powerful,
|
||||||
predictable, and overloadable — a plugin can take over as much of a page as it wants. The host
|
predictable, and overloadable — a plugin can take over as much of a page as it wants. The host
|
||||||
@@ -152,6 +221,24 @@ feature removes the need for JS (theme switching, popovers, disclosure) we use i
|
|||||||
avoid is shipping a client-side runtime, not using the platform. Markup is semantic and accessible
|
avoid is shipping a client-side runtime, not using the platform. Markup is semantic and accessible
|
||||||
(see [AGENTS.md](AGENTS.md)).
|
(see [AGENTS.md](AGENTS.md)).
|
||||||
|
|
||||||
|
## Glossary
|
||||||
|
|
||||||
|
- **Host** — the `web` container: the Node process that discovers plugins, serves every page and
|
||||||
|
owns no state of its own. Everything outside it (a plugin's upstream, Ory) is reached over the network.
|
||||||
|
- **Plugin** — a folder under `plugins/` that adds domain features.
|
||||||
|
- **Manifest** — the object a plugin's `plugin.ts` exports as its default: its routes, nav,
|
||||||
|
permissions and hooks. The host checks it at boot and refuses a bad one ([The manifest](#the-manifest)).
|
||||||
|
- **Chrome** — everything on a page that isn't the page's own content: brand, menu, user, theme and
|
||||||
|
CSRF token. A handler gets it as `ctx.chrome` ([RequestContext](#requestcontext)).
|
||||||
|
- **Nav fragment** — the `nav` entries one plugin contributes. The host merges every plugin's
|
||||||
|
fragment into the one global menu and filters it per user ([The menu system](#the-menu-system)).
|
||||||
|
- **Permission name** — a `<resource>:<action>` string such as `scheduling:write`, granted in Keto and
|
||||||
|
carried in the session JWT's `permissions` claim ([Naming a permission](#naming-a-permission)).
|
||||||
|
- **System plugin** — a plugin that administers Plainpages' own identity stack, through the
|
||||||
|
privileged `ctx.system` ([System capabilities](#system-capabilities-the-ctxsystem-surface)).
|
||||||
|
- **Denylist** — the optional in-memory list, per instance, of subjects revoked just now, so a
|
||||||
|
deactivation or a user's own permission change lands before the JWT expires ([Instant revoke](#instant-revoke-the-optional-denylist)).
|
||||||
|
|
||||||
## Users, groups & permissions
|
## Users, groups & permissions
|
||||||
|
|
||||||
Authorization here is two hops: a **user** — directly, or through a **group** — is granted a
|
Authorization here is two hops: a **user** — directly, or through a **group** — is granted a
|
||||||
@@ -231,6 +318,7 @@ Against the reference plugins' actual routes:
|
|||||||
| Request | Gate | alice | bob | carol | anonymous |
|
| Request | Gate | alice | bob | carol | anonymous |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| `GET /scheduling` | `public: true` | ✅ | ✅ | ✅ | ✅ |
|
| `GET /scheduling` | `public: true` | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| `GET /scheduling/mine` | `session: true` | ✅ | ✅ | ✅ | → `/login` |
|
||||||
| `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` |
|
| `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` |
|
||||||
| `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
| `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
||||||
| `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
| `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
||||||
@@ -349,9 +437,9 @@ import { definePlugin } from "@plainpages/plugin-api";
|
|||||||
import { listThings, createThings } from "./handlers.ts";
|
import { listThings, createThings } from "./handlers.ts";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.2.0", // semver string of the host contract this plugin was built against (see Versioning)
|
apiVersion: "0.4.0", // semver string of the host contract this plugin was built against (see Versioning)
|
||||||
|
|
||||||
// Nav fragment, merged into the global menu and permission-filtered per user.
|
// Nav fragment, merged into the global menu and gate-filtered per user.
|
||||||
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
|
// `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" }],
|
||||||
|
|
||||||
@@ -361,7 +449,7 @@ export default definePlugin({
|
|||||||
{ description: "Create and edit things", name: "things:write" },
|
{ description: "Create and edit things", name: "things:write" },
|
||||||
],
|
],
|
||||||
|
|
||||||
// Route handlers, mounted under the plugin's path (/things). `permission` gates first.
|
// Route handlers, mounted under the plugin's path (/things). The gate runs first.
|
||||||
routes: [
|
routes: [
|
||||||
{ method: "GET", path: "/", permission: "things:read", handler: listThings },
|
{ method: "GET", path: "/", permission: "things:read", handler: listThings },
|
||||||
{ method: "POST", path: "/", permission: "things:write", handler: createThings },
|
{ method: "POST", path: "/", permission: "things:write", handler: createThings },
|
||||||
@@ -378,7 +466,7 @@ folder-derived `id` to produce the loaded `Plugin`.
|
|||||||
| `apiVersion` | yes | Semver string of the host contract the plugin was built against. See [Versioning](#contract-versioning). |
|
| `apiVersion` | yes | Semver string of the host contract the plugin was built against. See [Versioning](#contract-versioning). |
|
||||||
| `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
| `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
||||||
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
||||||
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. |
|
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Every node names [exactly one gate](#public-pages--menu-items). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. |
|
||||||
| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). |
|
| `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). |
|
||||||
@@ -389,13 +477,13 @@ A plugin may be routes-only, nav-only, or hooks-only — every collection field
|
|||||||
|
|
||||||
### Routes & handlers
|
### Routes & handlers
|
||||||
|
|
||||||
A route is `{ method, path, permission?, public?, handler }`. `path` is **relative to the plugin's
|
A route is `{ method, path, handler }` plus [exactly one gate](#public-pages--menu-items) —
|
||||||
|
`permission`, `public: true` or `session: true`. `path` is **relative to the plugin's
|
||||||
mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host matches
|
mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host matches
|
||||||
`method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, runs the
|
`method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, runs the
|
||||||
`permission` gate ([a coarse JWT-claim check](#nav--permission-gates)), then calls the handler with
|
gate ([a coarse JWT-claim check](#nav--permission-gates)), then calls the handler with
|
||||||
the [request context](#requestcontext). A failed gate redirects an **anonymous** visitor to `/login`
|
the [request context](#requestcontext). A failed gate redirects an **anonymous** visitor to `/login`
|
||||||
with the page as `return_to`; a **signed-in** user lacking the permission gets the **403** page.
|
with the page as `return_to`; a **signed-in** user lacking the permission gets the **403** page.
|
||||||
`public: true` means no gate at all (see [Public pages](#public-pages--menu-items)).
|
|
||||||
|
|
||||||
`method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`.
|
`method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`.
|
||||||
|
|
||||||
@@ -432,6 +520,9 @@ export async function listThings(ctx: RequestContext) {
|
|||||||
nested names like `"things/edit"` work, out-of-bounds names are refused. The template may
|
nested names like `"things/edit"` work, out-of-bounds names are refused. The template may
|
||||||
`include()` the core building-block partials and its own. To load the plugin's own CSS, pass its
|
`include()` the core building-block partials and its own. To load the plugin's own CSS, pass its
|
||||||
`/public/<id>/x.css` href in the shell's `styles` slot — see the reference's `views/shifts.ejs`.
|
`/public/<id>/x.css` href in the shell's `styles` slot — see the reference's `views/shifts.ejs`.
|
||||||
|
The host adds the file's content hash to every `/public/` href the shell renders, so browsers and
|
||||||
|
any cache in front of Plainpages keep the file for a year; a `/public/` URL your own markup writes
|
||||||
|
is kept for five minutes, then revalidated.
|
||||||
- **Finer authorization than the route `permission`** uses the guards from `@plainpages/plugin-api`:
|
- **Finer authorization than the route `permission`** uses the guards from `@plainpages/plugin-api`:
|
||||||
`requireSession(ctx)`, `can(ctx, permission)` (coarse JWT-claim check, zero I/O), and
|
`requireSession(ctx)`, `can(ctx, permission)` (coarse JWT-claim check, zero I/O), and
|
||||||
`check(keto, ctx, {namespace, object, relation})` (a live Keto check; anonymous ⇒ denied). Throw
|
`check(keto, ctx, {namespace, object, relation})` (a live Keto check; anonymous ⇒ denied). Throw
|
||||||
@@ -470,7 +561,7 @@ import { definePlugin } from "@plainpages/plugin-api";
|
|||||||
import { landing, board } from "./pages.ts";
|
import { landing, board } from "./pages.ts";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.2.0",
|
apiVersion: "0.4.0",
|
||||||
home: landing, // owns "/" — the public front page
|
home: landing, // owns "/" — the public front page
|
||||||
dashboard: board, // owns "/dashboard" — the post-login app home
|
dashboard: board, // owns "/dashboard" — the post-login app home
|
||||||
});
|
});
|
||||||
@@ -569,20 +660,28 @@ system plugins you author or vendor. An ordinary domain plugin ignores it.
|
|||||||
|
|
||||||
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
|
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
|
||||||
applies the central override and then **filters per user** by the permissions in the session JWT: a
|
applies the central override and then **filters per user** by the permissions in the session JWT: a
|
||||||
node shows iff it is `public`, declares no `permission`, or the user holds that name. A node's `icon`
|
node shows iff it is `public`, is `session` and someone is signed in, or names a `permission` the
|
||||||
is a **Lucide icon** by sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are
|
user holds. A node's `icon` is a **Lucide icon** by sprite id (e.g. `i-cal` → lucide `calendar`); the
|
||||||
`ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there.
|
available ids are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name
|
||||||
|
there.
|
||||||
|
|
||||||
**Gating a section header.** A `permission` on the header takes the whole subtree with it. When the
|
**Gating a section header.** A `permission` on the header takes the whole subtree with it. When the
|
||||||
children need *different* permissions, leave the header ungated and gate each child — `composeNav`
|
children need *different* permissions, mark the header `public: true` — it then gates nothing, each
|
||||||
drops a header whose children all filtered out. That only works while the header carries **no
|
child decides, and `composeNav` drops a header whose children all filtered out. That only works while
|
||||||
`href`**: give it one and it survives as an ungated leaf, visible to everyone.
|
the header carries **no `href`**: give it one and it survives as a leaf, visible to everyone.
|
||||||
|
|
||||||
#### Public pages & menu items
|
#### Public pages & menu items
|
||||||
|
|
||||||
A route or nav node marked **`public: true`** is reachable by anyone and shows in everyone's menu.
|
A route or nav node marked **`public: true`** is reachable by anyone and shows in everyone's menu —
|
||||||
That is the same as omitting `permission`, but stated outright so public is a deliberate choice
|
open stated outright, so it is a deliberate choice rather than a forgotten gate.
|
||||||
rather than a forgotten gate. The two are **mutually exclusive** — declaring both is refused at boot.
|
|
||||||
|
**`session: true`** takes any signed-in user, with no grant to hold — for a plugin whose data is the
|
||||||
|
visitor's own. An anonymous visitor is bounced to `/login` with the page as `return_to`, exactly as a
|
||||||
|
permission gate does.
|
||||||
|
|
||||||
|
Every route and nav node names **exactly one** of the three, spelled `true` (or a permission name).
|
||||||
|
Naming none, naming two, or spelling one `false` is refused at boot — so a forgotten gate fails the
|
||||||
|
plugin instead of publishing a page.
|
||||||
|
|
||||||
A public page still renders in the native shell; for an anonymous visitor `ctx.user` is `null`, the
|
A public page still renders in the native shell; for an anonymous visitor `ctx.user` is `null`, the
|
||||||
shell shows a **Sign in** link in place of the profile block, the gated **Dashboard** link is hidden,
|
shell shows a **Sign in** link in place of the profile block, the gated **Dashboard** link is hidden,
|
||||||
@@ -617,6 +716,7 @@ provider/consumer semantics in `checkApiVersion`:
|
|||||||
The plugin pins one exact version (no ranges, per the project's pinning rules); the *host* supplies
|
The plugin pins one exact version (no ranges, per the project's pinning rules); the *host* supplies
|
||||||
the compatibility. One digit carries the whole release, so a **minor** means either the plugin
|
the compatibility. One digit carries the whole release, so a **minor** means either the plugin
|
||||||
contract changed or a dependency moved far enough to warrant one.
|
contract changed or a dependency moved far enough to warrant one.
|
||||||
|
[`CHANGELOG.md`](CHANGELOG.md) is what a minor sends you to: what broke, and what to change.
|
||||||
|
|
||||||
|
|
||||||
### Conflict rules
|
### Conflict rules
|
||||||
@@ -635,7 +735,7 @@ The host detects collisions across all discovered plugins with `findConflicts` a
|
|||||||
Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also
|
Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also
|
||||||
rejects **per-manifest shape errors**: a non-array `nav`/`routes`/`permissions`, a non-function
|
rejects **per-manifest shape errors**: a non-array `nav`/`routes`/`permissions`, a non-function
|
||||||
`home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a
|
`home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a
|
||||||
route/nav node setting both `public` and `permission`.
|
route/nav node that does not name [exactly one gate](#public-pages--menu-items).
|
||||||
|
|
||||||
### Hooks
|
### Hooks
|
||||||
|
|
||||||
@@ -725,6 +825,9 @@ included, since the plugin folder *is* the repo. A baked image needs no extra st
|
|||||||
above every plugin, and a plugin resolves it from there — nothing to declare, just import it. A
|
above every plugin, and a plugin resolves it from there — nothing to declare, just import it. A
|
||||||
copy inside your plugin's own `node_modules` would shadow it with a *second* instance of the host's
|
copy inside your plugin's own `node_modules` would shadow it with a *second* instance of the host's
|
||||||
contract, turning a sign-in redirect into a 500, so discovery refuses one there at boot.
|
contract, turning a sign-in redirect into a 500, so discovery refuses one there at boot.
|
||||||
|
- **Plainpages is sized for a handful of plugins per deployment, about ten.** Each plugin carries
|
||||||
|
its own dependency tree, so every one adds to disk use and to the memory of the `web` process;
|
||||||
|
hundreds of plugins is not a goal.
|
||||||
- **The host never upgrades or dedupes your dependencies.** Two plugins depending on the same package
|
- **The host never upgrades or dedupes your dependencies.** Two plugins depending on the same package
|
||||||
each get their own copy at their own version, so neither can break the other by upgrading — and
|
each get their own copy at their own version, so neither can break the other by upgrading — and
|
||||||
keeping yours current, and audited, is yours to own. Renovate here watches every manifest in this
|
keeping yours current, and audited, is yours to own. Renovate here watches every manifest in this
|
||||||
@@ -747,7 +850,7 @@ camel humps both becoming underscores — so `upstream` on the `scheduling` plug
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.2.0",
|
apiVersion: "0.4.0",
|
||||||
settings: [
|
settings: [
|
||||||
{ key: "upstream", type: "url", required: true, description: "Base URL of the backend" },
|
{ key: "upstream", type: "url", required: true, description: "Base URL of the backend" },
|
||||||
{ key: "pageSize", type: "number", default: 25 },
|
{ key: "pageSize", type: "number", default: 25 },
|
||||||
@@ -765,7 +868,7 @@ export default definePlugin({
|
|||||||
```
|
```
|
||||||
|
|
||||||
`type` is one of `string`, `number`, `boolean`, `enum` (with `values`) or `url`. A declared type is
|
`type` is one of `string`, `number`, `boolean`, `enum` (with `values`) or `url`. A declared type is
|
||||||
coerced and checked at boot, so a mistyped value names the plugin, the key and the variable instead
|
coerced and checked at boot, so a mistyped value names the plugin and the variable instead
|
||||||
of surfacing later as a broken page.
|
of surfacing later as a broken page.
|
||||||
|
|
||||||
**`required` and `default` are mutually exclusive** — a default means the setting can never fail, so
|
**`required` and `default` are mutually exclusive** — a default means the setting can never fail, so
|
||||||
@@ -801,7 +904,7 @@ import { definePlugin } from "@plainpages/plugin-api";
|
|||||||
let sql: ReturnType<typeof postgres>;
|
let sql: ReturnType<typeof postgres>;
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.2.0",
|
apiVersion: "0.4.0",
|
||||||
storage: true,
|
storage: true,
|
||||||
hooks: {
|
hooks: {
|
||||||
onBoot: async (boot) => {
|
onBoot: async (boot) => {
|
||||||
@@ -911,9 +1014,10 @@ The menu is **driven entirely by config** and assembled from two sources:
|
|||||||
export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } });
|
export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } });
|
||||||
```
|
```
|
||||||
|
|
||||||
Every nav item may carry a `permission`; the rendered tree is **filtered per user** from the session
|
Every nav node a **plugin** declares names one gate — a `permission`, **`public: true`** (everyone)
|
||||||
JWT (no per-request authz call), so the menu only shows what that person can reach. An item may
|
or **`session: true`** (anyone signed in); a header this override groups takes none, and shows
|
||||||
instead be **`public: true`** to show it to everyone — mutually exclusive with `permission`.
|
whenever a child does. The rendered tree is **filtered per user** from the session JWT (no
|
||||||
|
per-request authz call), so the menu only shows what that person can reach.
|
||||||
Branding (name, logo, default theme) renders in the app shell.
|
Branding (name, logo, default theme) renders in the app shell.
|
||||||
|
|
||||||
**One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders
|
**One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders
|
||||||
@@ -922,6 +1026,11 @@ recovery / front pages — so it looks identical signed in or out and just shows
|
|||||||
anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page wanting a
|
anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page wanting a
|
||||||
chrome-free layout opts out with the shell's `menu: false`.
|
chrome-free layout opts out with the shell's `menu: false`.
|
||||||
|
|
||||||
|
**The document scrolls, and the chrome scrolls with it.** Nothing bounds the viewport, so a page is
|
||||||
|
reachable below the fold without adding a scroll region of its own, and browser paging, scroll
|
||||||
|
restoration and find-in-page work without a page doing anything. A plugin that wants a full-height
|
||||||
|
pane owns that in its own stylesheet.
|
||||||
|
|
||||||
## Building blocks
|
## Building blocks
|
||||||
|
|
||||||
Plainpages is a **component library, not a page generator** — reusable EJS partials + TS helpers,
|
Plainpages is a **component library, not a page generator** — reusable EJS partials + TS helpers,
|
||||||
@@ -945,6 +1054,28 @@ underneath it — cramped, but nothing is unreachable. List state
|
|||||||
(`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is bookmarkable and shareable; the
|
(`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is bookmarkable and shareable; the
|
||||||
URL is the only state the UI keeps.
|
URL is the only state the UI keeps.
|
||||||
|
|
||||||
|
**Two browser floors** serve goal 4. Every page's content and actions stay reachable on
|
||||||
|
Chromium 87, Safari 15 and Firefox 115, the engines in years-old TVs, kiosks and Qt panels. Only
|
||||||
|
features [Baseline Widely available](https://web-platform-dx.github.io/baseline/) (in every
|
||||||
|
major engine for 30 months) may shape how a page looks; anything newer, like popovers or anchor
|
||||||
|
positioning, only enhances. A feature the floor engines lack, such as `:has()`, may
|
||||||
|
decorate a page but never decide whether something shows or can be read. `npm test` fails on a
|
||||||
|
stylesheet using a feature Chromium 87, Safari 15 or Firefox 115 lacks, unless an `@supports`
|
||||||
|
around it names that feature. It checks every stylesheet in `public/`, `examples/` and `plugins/`,
|
||||||
|
so mount your plugin in `plugins/<id>/` and run it. A few constructs fail even when guarded, such as
|
||||||
|
`@layer`, nested rules and media range syntax, while a media feature passes unguarded, since a
|
||||||
|
browser without it skips the query; `src/ui/stylesheet-floor.ts` lists the rules.
|
||||||
|
|
||||||
|
**A field whose options depend on another field is asked for in steps, one GET form each**, so every
|
||||||
|
choice is in the URL, and nothing typed is lost to one because nothing is typed until they are made.
|
||||||
|
Once the query names a value, that field renders read-only (`field` with `readonly`, and a `link` back
|
||||||
|
to the URL without it) above the next step's form, which carries the earlier choices as hidden inputs,
|
||||||
|
plus `locale` from `localeParam`. The form that writes comes last and posts to the URL naming every
|
||||||
|
choice, so its handler reads them from `ctx.query`, never from the body; a value the query names that
|
||||||
|
the step does not offer is that step's error, never a silent fallback. Don't redraw a half-filled form
|
||||||
|
through a submit that writes nothing instead: a `required` field blocks it, and the choice never
|
||||||
|
reaches the URL.
|
||||||
|
|
||||||
Plugins that genuinely need it — live dashboards, bulk actions, client-side validation — may **opt
|
Plugins that genuinely need it — live dashboards, bulk actions, client-side validation — may **opt
|
||||||
into progressive enhancement** (htmx, Alpine, vanilla JS) on top of working server-rendered HTML.
|
into progressive enhancement** (htmx, Alpine, vanilla JS) on top of working server-rendered HTML.
|
||||||
The baseline never depends on it.
|
The baseline never depends on it.
|
||||||
@@ -1057,7 +1188,7 @@ Rules worth knowing:
|
|||||||
edit/new/remove/save`, `filter.*`, `pagination.*`, `table.*` — and a plugin's lookup falls through
|
edit/new/remove/save`, `filter.*`, `pagination.*`, `table.*` — and a plugin's lookup falls through
|
||||||
to them. Keep your catalog for your domain words.
|
to them. Keep your catalog for your domain words.
|
||||||
- **Reserved view locals:** `t`, `locale`, `locales`, `localeHref`, `localeParam`, `localeSwitch`,
|
- **Reserved view locals:** `t`, `locale`, `locales`, `localeHref`, `localeParam`, `localeSwitch`,
|
||||||
`dir`. They are merged after your `data`, so a colliding key of yours is ignored rather than
|
`dir`, and the host-internal `assetHref`. They are merged after your `data`, so a colliding key of yours is ignored rather than
|
||||||
breaking the shell. `locale` is likewise reserved in `parseListQuery`, never returned as a filter.
|
breaking the shell. `locale` is likewise reserved in `parseListQuery`, never returned as a filter.
|
||||||
|
|
||||||
**Kratos writes the auth flow's own text** (field labels, validation errors), tagging each string
|
**Kratos writes the auth flow's own text** (field labels, validation errors), tagging each string
|
||||||
@@ -1080,7 +1211,7 @@ The app is **environment-agnostic**: no `NODE_ENV`, every behaviour its own expl
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `APP_URL` | _unset_ (dev: `http://localhost:3000`) | the canonical public URL — the **single source** for the host this deployment lives on; set ⇒ off-host visitors are redirected here, unset ⇒ no redirect (see [Canonical host](#canonical-host-one-public-url)) |
|
| `APP_URL` | _unset_ (dev: `http://localhost:3000`) | the canonical public URL — the **single source** for the host this deployment lives on; set ⇒ off-host visitors are redirected here, unset ⇒ no redirect (see [Canonical host](#canonical-host-one-public-url)) |
|
||||||
| `PORT` | `3000` | web listen port |
|
| `PORT` | `3000` | web listen port |
|
||||||
| `CACHE_TEMPLATES` | `false` | cache compiled EJS templates (`true` in prod) |
|
| `CACHE_TEMPLATES` | `false` | cache compiled EJS templates and `/public/` asset versions (`true` in prod) |
|
||||||
| `SECURE_COOKIES` | `false` | mark our session/CSRF cookies `Secure` (`true` in prod https; off in dev http) |
|
| `SECURE_COOKIES` | `false` | mark our session/CSRF cookies `Secure` (`true` in prod https; off in dev http) |
|
||||||
| `REQUIRE_SECURE_SECRETS` | `false` | when `true`, `CSRF_SECRET` — and `PLUGIN_DB_SECRET` once storage is configured, and every plugin setting declared `secret` — must be supplied and differ from the dev throwaway |
|
| `REQUIRE_SECURE_SECRETS` | `false` | when `true`, `CSRF_SECRET` — and `PLUGIN_DB_SECRET` once storage is configured, and every plugin setting declared `secret` — must be supplied and differ from the dev throwaway |
|
||||||
| `LOG_LEVEL` | `info` | min severity logged: `error`/`warn`/`info`/`verbose`/`debug`/`silly`/`none` |
|
| `LOG_LEVEL` | `info` | min severity logged: `error`/`warn`/`info`/`verbose`/`debug`/`silly`/`none` |
|
||||||
@@ -1293,6 +1424,11 @@ authorization graph, so treat it the same. `compose.yml` publishes none of the s
|
|||||||
so a stolen cookie jar is worth 30 days of re-mintable access, not ten minutes. Only our two cookies
|
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.
|
obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config.
|
||||||
|
|
||||||
|
**The CSRF token isn't bound to the session.** It is a signed double-submit token, so an attacker who
|
||||||
|
can write cookies on your origin can plant one they know. `SameSite=Lax` keeps a cross-site page
|
||||||
|
out, and HSTS (`SECURE_COOKIES=true`) a network attacker. A hostile page on a sibling subdomain can
|
||||||
|
still set cookies, so host nothing untrusted on one.
|
||||||
|
|
||||||
**Offboarding is not instant by default** — a revoked permission or deactivated identity lands
|
**Offboarding is not instant by default** — a revoked permission or deactivated identity lands
|
||||||
within one token TTL, unless the [denylist](#instant-revoke-the-optional-denylist) is on.
|
within one token TTL, unless the [denylist](#instant-revoke-the-optional-denylist) is on.
|
||||||
|
|
||||||
@@ -1367,12 +1503,13 @@ session lives in Kratos and the data lives outside the process.
|
|||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
Type check and unit tests run off the Ory stack — `--no-deps` keeps `web` from dragging up its
|
Type checks and unit tests run off the Ory stack — `--no-deps` keeps each service from dragging up
|
||||||
`depends_on` services:
|
its `depends_on` services:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose run --rm --no-deps web npm run typecheck # strict tsc --noEmit
|
docker compose run --rm --no-deps web npm run typecheck # strict tsc --noEmit
|
||||||
docker compose run --rm --no-deps web npm test # node --test (units)
|
docker compose run --rm --no-deps web npm test # node --test (units)
|
||||||
|
docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --user "$(id -u):$(id -g)" --rm --no-deps --build e2e npm run typecheck # the E2E specs
|
||||||
```
|
```
|
||||||
|
|
||||||
### End-to-end (Playwright)
|
### End-to-end (Playwright)
|
||||||
@@ -1385,7 +1522,7 @@ no Node/browsers on the host. Five suites, each on its own stack:
|
|||||||
| `visual.spec.ts` + `language.spec.ts` | `e2e-tests/compose.visual.yml` | Ory-free: the rendered design system (app shell, theme switch, mobile off-canvas, icon sprite, CSRF-guarded sign-out, landing, 404), plugin permission-gating, and [language switching](#languages-i18n) |
|
| `visual.spec.ts` + `language.spec.ts` | `e2e-tests/compose.visual.yml` | Ory-free: the rendered design system (app shell, theme switch, mobile off-canvas, icon sprite, CSRF-guarded sign-out, landing, 404), plugin permission-gating, and [language switching](#languages-i18n) |
|
||||||
| `auth-refresh.spec.ts` | `e2e-tests/compose.auth.yml` | The real Ory stack with the session→JWT TTL cut to 8s: a lapsed JWT is silently re-minted from the live Kratos session, and once revoked the stale cookie is cleared |
|
| `auth-refresh.spec.ts` | `e2e-tests/compose.auth.yml` | The real Ory stack with the session→JWT TTL cut to 8s: a lapsed JWT is silently re-minted from the live Kratos session, and once revoked the stale cookie is cleared |
|
||||||
| `oauth-login.spec.ts` | `e2e-tests/compose.oauth.yml` | Another app logging in through us — register a client, start an authorization flow, `/oauth2/login` accept, `/oauth2/consent` Allow → authorization code |
|
| `oauth-login.spec.ts` | `e2e-tests/compose.oauth.yml` | Another app logging in through us — register a client, start an authorization flow, `/oauth2/login` accept, `/oauth2/consent` Allow → authorization code |
|
||||||
| `full-flow.spec.ts` | `e2e-tests/compose.full.yml` | The browser UI: password + mocked-SSO login, menu filtering by permission, admin users/groups/permissions CRUD, OAuth2-clients register → one-time secret → delete, a gated plugin page, logout |
|
| `full-flow.spec.ts` | `e2e-tests/compose.full.yml` | The browser UI: password + mocked-SSO login, emailed-code verification + recovery, menu filtering by permission, admin users/groups/permissions CRUD, OAuth2-clients register → one-time secret → delete, a gated plugin page, logout |
|
||||||
| `devstack-login.spec.ts` | `compose.override.yml` + `e2e-tests/compose.devstack.yml` | The plain `docker compose up` topology on the **host network**: sign-in works both from the banner's `localhost:3000` and from `127.0.0.1:3000` via the [canonical-host redirect](#canonical-host-one-public-url) |
|
| `devstack-login.spec.ts` | `compose.override.yml` + `e2e-tests/compose.devstack.yml` | The plain `docker compose up` topology on the **host network**: sign-in works both from the banner's `localhost:3000` and from `127.0.0.1:3000` via the [canonical-host redirect](#canonical-host-one-public-url) |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -1411,7 +1548,7 @@ Ory-backed ones share one backend and stay on Chromium.
|
|||||||
|
|
||||||
### The full gate (one command)
|
### The full gate (one command)
|
||||||
|
|
||||||
`ci.sh` is the whole gate in one reproducible command — typecheck → unit tests →
|
`ci.sh` is the whole gate in one reproducible command — typecheck (the specs in `e2e-tests/` too) → unit tests →
|
||||||
each E2E suite against its own fresh stack, with a guaranteed `down -v` after each (even on
|
each E2E suite against its own fresh stack, with a guaranteed `down -v` after each (even on
|
||||||
failure) and a non-zero exit on the first failure. Run it locally before a release, or wire
|
failure) and a non-zero exit on the first failure. Run it locally before a release, or wire
|
||||||
it into your CI service:
|
it into your CI service:
|
||||||
@@ -1470,7 +1607,7 @@ whose `major.minor` disagrees with `HOST_API_VERSION` and naming the value to se
|
|||||||
**The Docker Hub overview** is published by a separate `publish-overview` job from
|
**The Docker Hub overview** is published by a separate `publish-overview` job from
|
||||||
[`release-tooling/dockerhub-overview.md.tmpl`](release-tooling/dockerhub-overview.md.tmpl), with
|
[`release-tooling/dockerhub-overview.md.tmpl`](release-tooling/dockerhub-overview.md.tmpl), with
|
||||||
`{{VERSION}}` rendered to the release, so the Plainpages tag it tells adopters to pull cannot go
|
`{{VERSION}}` rendered to the release, so the Plainpages tag it tells adopters to pull cannot go
|
||||||
stale. Its sidecar pins are Renovate-managed and gated against this repo's own compose files, so the
|
stale. The same job sets the page's short description. Its sidecar pins are Renovate-managed and gated against this repo's own compose files, so the
|
||||||
quick start stays a topology CI has actually run.
|
quick start stays a topology CI has actually run.
|
||||||
It is its own job for two reasons: the images are already pushed and irreversible by then, so a Hub
|
It is its own job for two reasons: the images are already pushed and irreversible by then, so a Hub
|
||||||
outage leaves the promotion green and the images untouched; and the page has its own door — run the
|
outage leaves the promotion green and the images untouched; and the page has its own door — run the
|
||||||
@@ -1554,6 +1691,11 @@ Every response carries security headers (`src/http/security-headers.ts`): a stri
|
|||||||
so a branding logo must live under `/public/` or be a `data:` URI; a plugin route can override any
|
so a branding logo must live under `/public/` or be a `data:` URI; a plugin route can override any
|
||||||
header per-response via `RouteResult.headers`.
|
header per-response via `RouteResult.headers`.
|
||||||
|
|
||||||
|
Every page and JSON response is `Cache-Control: private, no-store`, so a cache in front of `web`
|
||||||
|
never keeps one user's page for the next. A route may override it only for a page that is the same for
|
||||||
|
every visitor: no user, no CSRF token, no `Set-Cookie`, since the host adds a fresh CSRF cookie to
|
||||||
|
every plugin route.
|
||||||
|
|
||||||
A deep link reached while signed out — or after the session JWT lapses mid-task — bounces to the
|
A deep link reached while signed out — or after the session JWT lapses mid-task — bounces to the
|
||||||
themed sign-in and returns to the **page that was requested** (`return_to`, validated host-relative
|
themed sign-in and returns to the **page that was requested** (`return_to`, validated host-relative
|
||||||
by `localPath` in `src/http/safe-url.ts`, so a crafted value can't turn login completion into an open
|
by `localPath` in `src/http/safe-url.ts`, so a crafted value can't turn login completion into an open
|
||||||
@@ -1687,7 +1829,7 @@ unnecessary here — the signature itself is already invalid.
|
|||||||
```
|
```
|
||||||
src/ The app — strict tsc, no build step. *.test.ts sit beside their module.
|
src/ The app — strict tsc, no build step. *.test.ts sit beside their module.
|
||||||
server.ts Entry point; config.ts env loader; logger.ts structured log + trace span + tracedFetch
|
server.ts Entry point; config.ts env loader; logger.ts structured log + trace span + tracedFetch
|
||||||
*.test.ts Topology guards with no source counterpart (compose/kratos/keto/hydra/postgres/ci-gate)
|
*.test.ts Guards with no source counterpart (compose/kratos/keto/hydra/postgres/ci-gate/function-limits)
|
||||||
http/ Request pipeline: app.ts (createApp), builtin-routes, context, body, cookie,
|
http/ Request pipeline: app.ts (createApp), builtin-routes, context, body, cookie,
|
||||||
static, safe-url (safeUrl + localPath), security-headers
|
static, safe-url (safeUrl + localPath), security-headers
|
||||||
auth/ Identity + the session-JWT hot path: jwt · jwt-middleware · jwks · gen-jwks (CLI) ·
|
auth/ Identity + the session-JWT hot path: jwt · jwt-middleware · jwks · gen-jwks (CLI) ·
|
||||||
@@ -1699,8 +1841,8 @@ src/ The app — strict tsc, no build step. *.test.ts sit beside
|
|||||||
plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `@plainpages/plugin-api` barrel) · system.ts
|
plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `@plainpages/plugin-api` barrel) · system.ts
|
||||||
(ctx.system) · discovery · router · hooks · view-resolver · storage (the rules) ·
|
(ctx.system) · discovery · router · hooks · view-resolver · storage (the rules) ·
|
||||||
storage-provisioning (the DDL; bootstrap-only, holds the driver)
|
storage-provisioning (the DDL; bootstrap-only, holds the driver)
|
||||||
ui/ chrome (the one global menu) · shell-context · dashboard · nav (composeNav) ·
|
ui/ chrome (`ctx.chrome`: menu, brand, user) · nav (composeNav) · menu-config
|
||||||
menu-config (`#menu-config`) · icons (lucide sprite builder) · list-query · paginate
|
(`#menu-config`) · icons (lucide sprite builder) · list-query · paginate
|
||||||
|
|
||||||
plugin-api/ The `@plainpages/plugin-api` package — the author barrel, linked into /node_modules
|
plugin-api/ The `@plainpages/plugin-api` package — the author barrel, linked into /node_modules
|
||||||
views/ Core EJS in the one app shell: home, index, auth, oauth-consent, error, 403/404/500/503,
|
views/ Core EJS in the one app shell: home, index, auth, oauth-consent, error, 403/404/500/503,
|
||||||
@@ -1720,6 +1862,7 @@ e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,aut
|
|||||||
release-tooling/ Everything the release runs: next-version (the bump math), contract-version
|
release-tooling/ Everything the release runs: next-version (the bump math), contract-version
|
||||||
(the HOST_API_VERSION↔tag gate), dockerhub-overview (+ its .md.tmpl)
|
(the HOST_API_VERSION↔tag gate), dockerhub-overview (+ its .md.tmpl)
|
||||||
registry-cleanup/ Nightly image pruning — the Gitea client plus what survives (select-versions.ts)
|
registry-cleanup/ Nightly image pruning — the Gitea client plus what survives (select-versions.ts)
|
||||||
|
CHANGELOG.md What changed per release, and how to upgrade a plugin across a minor
|
||||||
ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack
|
ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack
|
||||||
.gitea/workflows/ Gitea Actions — see CI/CD
|
.gitea/workflows/ Gitea Actions — see CI/CD
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ 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
|
||||||
|
|
||||||
|
# The specs import @playwright/test, which only the runner image installs, so they typecheck there
|
||||||
|
# rather than in the web image. --no-deps skips web; the visual file just names the e2e service.
|
||||||
|
step "Typecheck e2e-tests"
|
||||||
|
docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --user "$(id -u):$(id -g)" --rm --no-deps --build e2e npm run typecheck
|
||||||
|
|
||||||
step "Unit tests"
|
step "Unit tests"
|
||||||
units=$(docker compose run --rm --no-deps web npm test 2>&1) || { echo "$units"; exit 1; }
|
units=$(docker compose run --rm --no-deps web npm test 2>&1) || { echo "$units"; exit 1; }
|
||||||
echo "$units" | grep -E '^. (tests|pass|fail) ' || true
|
echo "$units" | grep -E '^. (tests|pass|fail) ' || true
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ services:
|
|||||||
# backs it (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
|
# backs it (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
|
||||||
# stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead.
|
# stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead.
|
||||||
shifts-upstream:
|
shifts-upstream:
|
||||||
image: node:24.19.0-alpine3.24
|
image: node:24.22.2-alpine3.24
|
||||||
command: node /srv/server.ts
|
command: node /srv/server.ts
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
@@ -58,7 +58,7 @@ services:
|
|||||||
# Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025).
|
# Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025).
|
||||||
# kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
|
# kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
|
||||||
mailpit:
|
mailpit:
|
||||||
image: axllent/mailpit:v1.31.0
|
image: axllent/mailpit:v1.33.0
|
||||||
ports:
|
ports:
|
||||||
- "8025:8025"
|
- "8025:8025"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
# Comprehension history
|
||||||
|
|
||||||
|
## 2026-10-31T00:16:02Z, PR #181 at 56ff987, against eed1050
|
||||||
|
|
||||||
|
Ruling: better
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Mid A | same | better | same | better | better |
|
||||||
|
| Inherited architect | same | better | same | better | better |
|
||||||
|
|
||||||
|
Mid A, decided by:
|
||||||
|
|
||||||
|
- Self-sufficiency, Locality: `public/css/styles.css:24` `:root`: the comment above the hex palette says why a colour must not be oklch(), where a colour is edited
|
||||||
|
- Locality: `src/ui/stylesheet-floor.test.ts:11` `declares no oklch() colour`: a colour edit that breaks the browser floor fails a test instead of an old engine
|
||||||
|
|
||||||
|
Inherited architect, decided by:
|
||||||
|
|
||||||
|
- Self-sufficiency, Locality: `public/css/styles.css:24` `:root`: the comment above the hex palette says why a colour must not be oklch(), where a colour is edited
|
||||||
|
- Locality: `src/ui/stylesheet-floor.test.ts:11` `declares no oklch() colour`: a red gate explains the symptom in the file it lands on
|
||||||
|
- Self-sufficiency: `README.md:1027` "Two browser floors": the colour rule now sits beside the floor rule
|
||||||
|
|
||||||
|
## 2026-10-31T00:43:19Z, PR #182 at 19987ba, against 3e70768
|
||||||
|
|
||||||
|
Ruling: better
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Mid A | better | better | better | better | better |
|
||||||
|
| Inherited architect | better | better | better | better | better |
|
||||||
|
|
||||||
|
Mid A, decided by:
|
||||||
|
|
||||||
|
- Navigation, Shape, Self-sufficiency, Overall: `public/css/auth.css:1` `auth.css`: about 120 lines of rules for markup no view renders are gone, so a cold reader no longer rules them out first
|
||||||
|
- Locality, Self-sufficiency, Overall: `public/css/styles.css:31` `--on-accent`: the text colour on the accent is one token where four hard-coded `#fff` stood
|
||||||
|
- Self-sufficiency, Navigation, Overall: `public/css/styles.css:287` `@supports not selector(:has(a))`: the nav comment, the partial's header and the rule all say the tree folds only where `:has()` works
|
||||||
|
- Locality: `public/css/styles.css:241` `@supports selector(:has(a))`: the nav-fold behaviour now sits in two blocks about 50 lines apart, the second order-dependent; the comment names it
|
||||||
|
|
||||||
|
Inherited architect, decided by:
|
||||||
|
|
||||||
|
- Navigation, Shape, Overall: `public/css/auth.css:1` `auth.css`: the false `:target` banner and the phantom state machine are gone, so every selector left names something a view emits
|
||||||
|
- Self-sufficiency, Overall: `views/partials/nav-tree.ejs:4` `nav-tree.ejs`: the header and `public/css/styles.css:194` now say the branch folds only where `:has()` works; the old text was untrue on old engines
|
||||||
|
- Locality, Overall: `public/css/styles.css:31` `--l-on-accent`: one token feeds `.btn-primary`, `.page-btn[aria-current]` and `.brand-mark`
|
||||||
|
- Locality: `public/css/styles.css:287` `@supports not selector(:has(a))`: its `.nav-disc` rule sits about 85 lines from the nav rules it overrides, and `stylesheet-floor.test.ts` guards the order for `.theme-switch` only
|
||||||
|
- Self-sufficiency: `src/ui/stylesheet-contrast.test.ts:29` `stylesheet-contrast.test.ts`: the darker badge colours now have a stated 4.5:1 reason someone can find
|
||||||
|
|
||||||
|
## 2026-10-31T01:09:38Z, PR #183 at 0f20b38, against 364b047
|
||||||
|
|
||||||
|
Ruling: better
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Mid A | same | same | same | better | better |
|
||||||
|
| Inherited architect | same | better | same | better | better |
|
||||||
|
|
||||||
|
Mid A, decided by:
|
||||||
|
|
||||||
|
- Self-sufficiency, Overall: `public/css/styles.css:1` `App Shell Template header`: the false `light-dark()` bullet is gone, so no reader has to work out which of two comments is lying
|
||||||
|
- Self-sufficiency, Overall: `public/css/styles.css:687` `@supports selector(:has(a))` in `@media (max-width: 860px)`: the guard and its comment say what an engine without `:has()` gets, without opening the CHANGELOG
|
||||||
|
|
||||||
|
Inherited architect, decided by:
|
||||||
|
|
||||||
|
- Self-sufficiency, Overall: `public/css/styles.css:5` `Architecture` header: the false `light-dark()` bullet is gone, so the header no longer contradicts the TOKENS comment
|
||||||
|
- Locality, Self-sufficiency, Overall: `public/css/styles.css:687` `@supports selector(:has(a))` in `@media (max-width: 860px)`: the guard and its comment put the dependency of the open state on `:has()` where an editor would change it
|
||||||
|
|
||||||
|
## 2026-10-31T01:19:49Z, PR #184 at af157c9, against bb8e6bc
|
||||||
|
|
||||||
|
Ruling: better
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Mid A | better | same | same | better | better |
|
||||||
|
| Inherited architect | better | better | same | better | better |
|
||||||
|
|
||||||
|
Mid A, decided by:
|
||||||
|
|
||||||
|
- Self-sufficiency, Navigation, Overall: `public/css/styles.css:12` `TOKENS` header comment: says all three theme rules need `:has()`, names the engines without it and points to the `@supports not selector(:has(a))` block, where the old text called the swaps "rock-solid"
|
||||||
|
- Navigation, Self-sufficiency, Overall: `README.md:214` `## Glossary`: defines host, plugin, manifest, chrome, nav fragment, permission name, system plugin and denylist and links each to its home section
|
||||||
|
- Self-sufficiency: `README.md:815` `Plugin dependencies`: the scale bullet states the plugin count the per-plugin dependency design assumes
|
||||||
|
|
||||||
|
Inherited architect, decided by:
|
||||||
|
|
||||||
|
- Self-sufficiency, Locality, Overall: `public/css/styles.css:12` `1. TOKENS` comment: an editor of the theme mapping learns of the distant `:has()` fallback from the comment, where the old text said the swap could not fail
|
||||||
|
- Navigation, Self-sufficiency, Overall: `README.md:214` `## Glossary`: a cold reader reaches "chrome" or "denylist" and their home sections in one hop
|
||||||
|
- Self-sufficiency: `README.md:815` `### Plugin dependencies`: the scale bullet gives the plugin count the dependency design assumes
|
||||||
|
|
||||||
|
## 2026-10-31T07:26:19Z, PR #187 at 10152d2, against b336dee
|
||||||
|
|
||||||
|
Ruling: better
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Mid A | same | same | better | better | better |
|
||||||
|
| Inherited architect | same | same | better | better | better |
|
||||||
|
|
||||||
|
Mid A, decided by:
|
||||||
|
|
||||||
|
- Self-sufficiency, Overall: `e2e-tests/mock-oidc.ts:18` `idToken`: the helpers' signatures now say what `res`, `nonce` and the `codes` map hold
|
||||||
|
- Self-sufficiency: `e2e-tests/proxy.ts:14` `toKratos`: `(path: string): boolean` shows the routing predicate's contract
|
||||||
|
- Self-sufficiency: `e2e-tests/tsconfig.json:2` `extends`: the specs are held to the host's strict flags, and the Dockerfile comment explains the path link
|
||||||
|
- Shape, Overall: `src/ui/icons.ts:4` `ICON_NAMES`: the comment "the icons the UI actually references" is true again
|
||||||
|
|
||||||
|
Inherited architect, decided by:
|
||||||
|
|
||||||
|
- Self-sufficiency, Overall: `e2e-tests/mock-oidc.ts:18` `b64`: typed helpers replace a trace through the `createServer` callback
|
||||||
|
- Self-sufficiency: `e2e-tests/proxy.ts:14` `toKratos`: the predicate's contract is readable on its own line
|
||||||
|
- Self-sufficiency: `e2e-tests/Dockerfile:11` `COPY tsconfig.json`: the relative `extends` path works in the checkout and in the image, and a comment names the link
|
||||||
|
- Shape, Overall: `src/ui/icons.ts:4` `ICON_NAMES`: the registry matches its description, with no dead ids
|
||||||
|
|
||||||
|
## 2026-10-31T12:28:23Z, PR #200 at 3592a30, against a8a9744
|
||||||
|
|
||||||
|
Ruling: same
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Mid A | better | better | better | same | better |
|
||||||
|
| Inherited architect | worse | worse | better | same | same |
|
||||||
|
| Maintainability senior | same | same | better | same | same |
|
||||||
|
| Junior A | better | better | better | better | better |
|
||||||
|
|
||||||
|
Mid A, decided by:
|
||||||
|
|
||||||
|
- Navigation, Locality, Shape: `src/ui/chrome.ts:74` `shellUser`: the deleted `buildShellContext` and `buildDashboardModel` left two shell models side by side, and now every page reads one `chrome`
|
||||||
|
- Locality, Shape: `src/auth/routes.ts:58` `flowFailure`: a named unit shared by `flowPage` and `codeFlowSubmit`, where the old `flowPage` held the whole error ladder inline
|
||||||
|
- Shape: `src/auth/kratos-public.ts:117` `submitFlow`: takes a flow type and id instead of an action URL, and the Referer quirk is explained where it applies
|
||||||
|
- Locality: `src/http/static.ts:93` `versioned`: reads the module-level `hashes` map, so an asset href depends on what an earlier request hashed
|
||||||
|
- Navigation, Shape: `src/ui/stylesheet-floor.ts:30` `floorViolations`: a 249-line regex walker that only tests use sits in `src/ui/` beside runtime modules, though the README now points straight at it
|
||||||
|
|
||||||
|
Inherited architect, decided by:
|
||||||
|
|
||||||
|
- Navigation: `public/css/styles.css:25` `:root`: the old comment next to the palette answered the 3am question and the new text drops it
|
||||||
|
- Navigation: `src/ui/stylesheet-floor.ts:1` `floorViolations`: its header never says an unsupported value in a custom property blanks every `var()` reading it, so a cold reader walks the regex walker to find the cause
|
||||||
|
- Navigation, Shape: `src/ui/stylesheet-floor.ts:12` `import`: test-only tooling that pulls a devDependency sits beside runtime modules and is missing from the README file map
|
||||||
|
- Locality: `src/http/static.ts:64` `hashes`: a module-level map shared by every `publicAssets()` instance makes a fresh href depend on request history elsewhere
|
||||||
|
- Locality: `src/http/app.ts:188` `renderPage`: `assetHref` is appended last at three render sites, and only a comment keeps a fourth from emitting unversioned hrefs
|
||||||
|
- Shape: `src/ui/chrome.ts:74` `shellUser`: removes the second shell model that duplicated `PageChrome`
|
||||||
|
- Shape: `src/auth/routes.ts:58` `flowFailure`: one named unit shared by `flowPage` and `codeFlowSubmit`, each handler reads in a screen
|
||||||
|
|
||||||
|
Maintainability senior, decided by:
|
||||||
|
|
||||||
|
- Shape, Locality: `src/ui/chrome.ts:74` `shellUser`: the deleted `ShellModel` left two shapes for the same chrome, and now the shell has one input and one builder
|
||||||
|
- Shape, Locality: `src/auth/routes.ts:58` `flowFailure`: the old `flowPage` mixed about 60 lines of error mapping with `return_to` handling, and now `flowReturnTo` and `flowFailure` read on their own
|
||||||
|
- Locality: `src/http/static.ts:64` `hashes`: a hidden module-level cache filled by two readers, and `assetHref` is merged by hand at three render sites
|
||||||
|
- Shape, Navigation: `src/ui/stylesheet-floor.ts:119` `positive`: a 249-line regex walker that only a test uses sits among runtime view modules
|
||||||
|
|
||||||
|
Junior A, decided by:
|
||||||
|
|
||||||
|
- Navigation, Self-sufficiency: `src/ui/stylesheet-floor.ts:1` `floorViolations`: the README's "Two browser floors" points here and the header lists what is checked, where the base had a prose rule and a hand-written regex test
|
||||||
|
- Shape, Locality: `src/ui/chrome.ts:74` `shellUser`: one shell view model replaces `ShellModel` beside `PageChrome`, and its helpers sit beside their only caller
|
||||||
|
- Shape, Locality: `src/auth/routes.ts:58` `flowFailure`: named units replace about 60 inline lines in `flowPage`, and `codeFlowSubmit` reuses them
|
||||||
|
- Self-sufficiency: `src/auth/kratos-admin.ts:68` `createRecoveryCode`: the comment no longer claims Kratos mails the link
|
||||||
|
- Locality: `src/http/static.ts:64` `hashes`: a module-level mutable map backs asset versioning, hidden state the base didn't have
|
||||||
|
|
||||||
|
## 2026-10-31T12:30:19Z, PR #200 at 3592a30
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Junior A | 7 | 6 | 6.5 | 6.5 | 6.5 |
|
||||||
|
| Mid A | 7 | 6 | 6.5 | 6.5 | 6.5 |
|
||||||
|
| Maintainability senior | 7 | 6 | 6.5 | 6.5 | 6.5 |
|
||||||
|
| Inherited architect | 7 | 5.5 | 5.5 | 7.5 | 6.5 |
|
||||||
|
| Mean | 7.00 | 5.88 | 6.25 | 6.75 | 6.50 |
|
||||||
|
|
||||||
|
Junior A, hardest first:
|
||||||
|
|
||||||
|
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a regex-built `@supports` evaluator whose header says what it enforces but not how it decides a guard names a feature
|
||||||
|
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 165 lines in one closure where each stage reads state an earlier stage set
|
||||||
|
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: its 4xx branches can't fire from `codeFlowSubmit` because `submitFlow` throws only on 5xx, and the call site doesn't say so
|
||||||
|
|
||||||
|
Mid A, hardest first:
|
||||||
|
|
||||||
|
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a hand-written CSS walker with three regex passes per declaration, opaque at line level
|
||||||
|
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: a fixed order of about 15 stages that nothing in the code enforces
|
||||||
|
- Self-sufficiency, Shape: `src/auth/login.ts:40` `readPermissions`: the comment names the tuple `#members` where the code uses `granted`
|
||||||
|
- Locality: `src/auth/routes.ts:121` `codeFlowSubmit`: one idea split across `flow-view.ts`, `routes.ts` and `kratos-public.ts`
|
||||||
|
|
||||||
|
Maintainability senior, hardest first:
|
||||||
|
|
||||||
|
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: a 167-line closure of complexity 43 whose order across closure state nothing names as a contract
|
||||||
|
- Self-sufficiency, Locality: `src/auth/routes.ts:58` `flowFailure`: on the submit path every 4xx branch is dead and nothing at the call site says which can fire
|
||||||
|
- Locality: `src/ui/stylesheet-floor.ts:119` `positive`: the one module whose correctness can't be checked by reading
|
||||||
|
- Self-sufficiency: `src/auth/bootstrap.ts:31` `permissionTuple`: the comment says `#members` while the code writes `granted`
|
||||||
|
|
||||||
|
Inherited architect, would restructure first:
|
||||||
|
|
||||||
|
- Shape, Navigation: `src/auth/flow-view.ts:152` `formAction`: the flow-to-route mapping has two homes, here and `routes.ts`, so a code flow's route and its form target are decided in different files
|
||||||
|
- Shape, Navigation: `src/ui/stylesheet-floor.ts:30` `floorViolations`: a CSS linter that never runs at runtime sits among the runtime view helpers
|
||||||
|
- Locality, Shape: `src/http/app.ts:185` `handleRequest`: a level of its own inside `createApp`
|
||||||
|
- Shape: `src/http/builtin-routes.ts:23` `matchBuiltinRoute`: holds no routes, because the table is inline in `app.ts:176` and `buildAuthRoutes`
|
||||||
|
|
||||||
|
## 2026-10-31T12:30:27Z, PR #200 rerun at 3592a30
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Junior A | 7 | 5.5 | 6 | 6.5 | 6 |
|
||||||
|
| Mid A | 7 | 6 | 6.5 | 6.5 | 6.5 |
|
||||||
|
| Maintainability senior | 7 | 5.5 | 6 | 6.5 | 6 |
|
||||||
|
| Inherited architect | 7 | 6 | 6 | 6.5 | 6.5 |
|
||||||
|
| Mean | 7.00 | 5.75 | 6.13 | 6.50 | 6.25 |
|
||||||
|
|
||||||
|
Junior A, hardest first:
|
||||||
|
|
||||||
|
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a regex-built CSS walker that stayed opaque without tracing it by hand
|
||||||
|
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 320 lines of interdependent locals, with a fixed stage order that only a top-to-bottom read shows
|
||||||
|
- Self-sufficiency, Shape: `src/auth/login.ts:40` `completeLogin`: the header says `#members` where the code uses `granted`
|
||||||
|
|
||||||
|
Mid A, hardest first:
|
||||||
|
|
||||||
|
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 165 lines of order-dependent stages, with `renderPage` reassigned partway
|
||||||
|
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: how it decides a guard names a feature stayed opaque
|
||||||
|
- Self-sufficiency, Shape: `src/auth/bootstrap.ts:31` `seedPermissions`: the comment says `#members` where the code uses `granted`
|
||||||
|
|
||||||
|
Maintainability senior, hardest first:
|
||||||
|
|
||||||
|
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 167 lines and complexity 43 in a fixed order nobody states
|
||||||
|
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a hand-rolled regex CSS walker, partly opaque
|
||||||
|
- Locality, Shape: `src/plugin-host/plugin.ts:126` `RESERVED_PLUGIN_IDS`: a hand-kept mirror of the host's path segments that has already drifted, as it omits `error`
|
||||||
|
- Self-sufficiency, Shape: `src/auth/login.ts:40` `readPermissions`: the comment says `#members` where the code uses `granted`
|
||||||
|
|
||||||
|
Inherited architect, would restructure first:
|
||||||
|
|
||||||
|
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: the most concerns and the highest complexity, already grandfathered, and every request-wide concern lands in it
|
||||||
|
- Shape, Navigation: `src/ui/stylesheet-floor.ts:15` `FLOOR`: the 3am rule's home is a test-only module under `ui/`
|
||||||
|
- Shape: `src/auth/routes.ts:275` `buildAuthRoutes`: the 19-module `src/auth/` mixes six concerns, and this file with `flow-view.ts` is where the next flow lands
|
||||||
|
|
||||||
|
## 2026-10-31T13:06:16Z, PR #202 at 8e73d16
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Junior A | 7 | 6 | 6.5 | 6.5 | 6.5 |
|
||||||
|
| Mid A | 7 | 6 | 6 | 6.5 | 6.5 |
|
||||||
|
| Maintainability senior | 7 | 6 | 6 | 6.5 | 6.5 |
|
||||||
|
| Inherited architect | 6.5 | 6 | 6 | 6.5 | 6.5 |
|
||||||
|
| Mean | 6.88 | 6.00 | 6.13 | 6.50 | 6.50 |
|
||||||
|
|
||||||
|
Junior A, hardest first:
|
||||||
|
|
||||||
|
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 165 lines in a fixed order that only a top-to-bottom read shows
|
||||||
|
- Self-sufficiency, Shape: `src/auth/login.ts:40` `readPermissions`: the comment says `#members` where the code uses `granted`
|
||||||
|
- Self-sufficiency: `src/plugin-host/settings.ts:35` `SettingsOf`: nested conditional types that need the charter's rule to decode
|
||||||
|
|
||||||
|
Mid A, hardest first:
|
||||||
|
|
||||||
|
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: complexity 43 inside the 318-line `createApp` closure, and `switchBase` reads `builtinRoutes` declared 60 lines later
|
||||||
|
- Locality, Shape: `src/ui/stylesheet-floor.ts:119` `positive`: a regex CSS walker whose how stayed opaque
|
||||||
|
- Self-sufficiency: `src/auth/bootstrap.ts:31` `permissionTuple`: the comment says `#members` where the code uses `granted`
|
||||||
|
|
||||||
|
Maintainability senior, hardest first:
|
||||||
|
|
||||||
|
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: 167 lines and complexity 43 mixing twelve concerns whose order no type expresses
|
||||||
|
- Locality: `src/ui/stylesheet-floor.ts:119` `positive`: a hand-rolled regex CSS scanner traced by hand
|
||||||
|
- Self-sufficiency: `src/auth/gate.ts:13` `allows`: an empty gate is allowed, and the reason lives only in AGENTS.md
|
||||||
|
|
||||||
|
Inherited architect, would restructure first:
|
||||||
|
|
||||||
|
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: the largest hub, where every request-wide concern lands
|
||||||
|
- Shape, Navigation: `src/auth/routes.ts:275` `buildAuthRoutes`: the 19-module `src/auth/` mixes four concerns with no sub-folder
|
||||||
|
- Shape, Navigation: `src/ui/stylesheet-floor.ts:30` `floorViolations`: test tooling sits among the runtime UI modules
|
||||||
|
|
||||||
|
## 2026-10-31T13:06:21Z, PR #202 rerun at 8e73d16
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Junior A | 7 | 5.5 | 6 | 6 | 6 |
|
||||||
|
| Mid A | 7 | 6 | 6 | 6 | 6.5 |
|
||||||
|
| Maintainability senior | 7 | 6 | 6 | 6 | 6.5 |
|
||||||
|
| Inherited architect | 7 | 5.5 | 6 | 7 | 6.5 |
|
||||||
|
| Mean | 7.00 | 5.75 | 6.00 | 6.25 | 6.38 |
|
||||||
|
|
||||||
|
Junior A, hardest first:
|
||||||
|
|
||||||
|
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: closures over request state in a 318-line `createApp`, and it stayed opaque
|
||||||
|
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: the Kratos status semantics live outside the repo
|
||||||
|
- Self-sufficiency, Shape: `src/auth/login.ts:45` `readPermissions`: the comment says `#members` where the code uses `granted`
|
||||||
|
|
||||||
|
Mid A, hardest first:
|
||||||
|
|
||||||
|
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: one pipeline whose steps mutate what later steps read
|
||||||
|
- Locality: `src/ui/stylesheet-floor.ts:119` `positive`: traced by hand
|
||||||
|
- Self-sufficiency, Shape: `src/auth/login.ts:45` `readPermissions`: the comment says `#members` where the code uses `granted`
|
||||||
|
|
||||||
|
Maintainability senior, hardest first:
|
||||||
|
|
||||||
|
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: an unstated, load-bearing step order in shared closure state
|
||||||
|
- Locality: `src/ui/stylesheet-floor.ts:119` `positive`: a hand-rolled CSS scanner built on lookbehind regexes
|
||||||
|
- Self-sufficiency, Shape: `src/auth/bootstrap.ts:31` `permissionTuple`: the comment is false on the security-relevant path
|
||||||
|
|
||||||
|
Inherited architect, would restructure first:
|
||||||
|
|
||||||
|
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: stage order lives only in line order, in a grandfathered complexity-43 function
|
||||||
|
- Shape, Navigation: `src/auth/routes.ts:275` `buildAuthRoutes`: 19 flat modules in `src/auth/`, and the next flow lands here
|
||||||
|
- Shape, Navigation: `src/ui/stylesheet-floor.ts:15` `FLOOR`: the 3am rule's home is a test-only module under `ui/`
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
# Reading cost
|
||||||
|
|
||||||
|
## Reading cost, chunk 57e864bce02a, finished 2026-10-30T23:59:00Z
|
||||||
|
|
||||||
|
100624 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `src/http/app.test.ts` 9
|
||||||
|
- `README.md` 7
|
||||||
|
- `src/http/app.ts` 4
|
||||||
|
- `todo.md` 3
|
||||||
|
- `e2e-tests/playwright.config.ts` 2
|
||||||
|
- `AGENTS.md` 1
|
||||||
|
- `CHANGELOG.md` 1
|
||||||
|
- `ci.sh` 1
|
||||||
|
- `examples/plugins/scheduling/views/shifts.ejs` 1
|
||||||
|
- `src/auth/csrf.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk df5e30a4db5f, finished 2026-10-31T00:22:00Z
|
||||||
|
|
||||||
|
80251 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `todo.md` 5
|
||||||
|
- `README.md` 3
|
||||||
|
- `CHANGELOG.md` 2
|
||||||
|
- `public/css/styles.css` 2
|
||||||
|
- `AGENTS.md` 1
|
||||||
|
- `src/ui/stylesheet-floor.test.ts` 1
|
||||||
|
- `src/ui/theme-switch.test.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk 3bf0b1a97d77, finished 2026-10-31T00:48:14Z
|
||||||
|
|
||||||
|
167985 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `public/css/styles.css` 17
|
||||||
|
- `todo.md` 7
|
||||||
|
- `README.md` 6
|
||||||
|
- `public/css/auth.css` 5
|
||||||
|
- `AGENTS.md` 1
|
||||||
|
- `CHANGELOG.md` 1
|
||||||
|
- `examples/plugins/scheduling/public/scheduling.css` 1
|
||||||
|
- `examples/plugins/scheduling/views/overview.ejs` 1
|
||||||
|
- `examples/plugins/scheduling/views/shifts.ejs` 1
|
||||||
|
- `src/http/app.test.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk 91d11dbe63f9, finished 2026-10-31T01:13:44Z
|
||||||
|
|
||||||
|
69010 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `todo.md` 7
|
||||||
|
- `README.md` 4
|
||||||
|
- `public/css/styles.css` 4
|
||||||
|
- `CHANGELOG.md` 1
|
||||||
|
- `src/ui/stylesheet-floor.test.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk 70e46c48db80, finished 2026-10-31T07:17:56Z
|
||||||
|
|
||||||
|
70843 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `README.md` 18
|
||||||
|
- `todo.md` 6
|
||||||
|
- `public/css/styles.css` 5
|
||||||
|
- `CHANGELOG.md` 2
|
||||||
|
- `AGENTS.md` 1
|
||||||
|
- `src/auth/jwt-middleware.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk 9e8ffd0aa8dc, finished 2026-10-31T07:36:53Z
|
||||||
|
|
||||||
|
98975 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `README.md` 9
|
||||||
|
- `ci.sh` 3
|
||||||
|
- `e2e-tests/visual.spec.ts` 3
|
||||||
|
- `todo.md` 3
|
||||||
|
- `CHANGELOG.md` 2
|
||||||
|
- `e2e-tests/compose.visual.yml` 2
|
||||||
|
- `e2e-tests/language.spec.ts` 2
|
||||||
|
- `e2e-tests/mock-oidc.ts` 2
|
||||||
|
- `src/ci-gate.test.ts` 2
|
||||||
|
- `.dockerignore` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk 25b7840ff611, finished 2026-10-31T07:46:18Z
|
||||||
|
|
||||||
|
73781 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `todo.md` 5
|
||||||
|
- `README.md` 2
|
||||||
|
- `src/http/app.ts` 2
|
||||||
|
- `AGENTS.md` 1
|
||||||
|
- `src/http/app.test.ts` 1
|
||||||
|
- `src/ui/chrome.test.ts` 1
|
||||||
|
- `src/ui/chrome.ts` 1
|
||||||
|
- `views/index.ejs` 1
|
||||||
|
- `views/partials/shell.ejs` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk e4d3bb0ac092, finished 2026-10-31T08:02:27Z
|
||||||
|
|
||||||
|
154733 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `todo.md` 10
|
||||||
|
- `README.md` 6
|
||||||
|
- `package.json` 3
|
||||||
|
- `.gitignore` 1
|
||||||
|
- `.npmrc` 1
|
||||||
|
- `AGENTS.md` 1
|
||||||
|
- `ci.sh` 1
|
||||||
|
- `public/css/styles.css` 1
|
||||||
|
- `renovate.json` 1
|
||||||
|
- `src/e2e-console-guard.test.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk 2f36d29bd746, finished 2026-10-31T08:19:09Z
|
||||||
|
|
||||||
|
85029 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `e2e-tests/full-flow.spec.ts` 4
|
||||||
|
- `README.md` 3
|
||||||
|
- `src/auth/routes.test.ts` 3
|
||||||
|
- `todo.md` 3
|
||||||
|
- `src/auth/kratos-public.ts` 2
|
||||||
|
- `src/http/app.test.ts` 2
|
||||||
|
- `ci.sh` 1
|
||||||
|
- `compose.yml` 1
|
||||||
|
- `e2e-tests/auth-refresh.spec.ts` 1
|
||||||
|
- `src/auth/flow-view.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk 3373527a4f72, finished 2026-10-31T08:52:59Z
|
||||||
|
|
||||||
|
138664 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `examples/plugins/admin/admin-users.ts` 8
|
||||||
|
- `README.md` 7
|
||||||
|
- `src/http/app.test.ts` 7
|
||||||
|
- `e2e-tests/full-flow.spec.ts` 3
|
||||||
|
- `examples/plugins/admin/admin-groups.ts` 3
|
||||||
|
- `examples/plugins/admin/admin-users.test.ts` 3
|
||||||
|
- `examples/plugins/admin/i18n/en-US.ts` 3
|
||||||
|
- `src/ui/icons.ts` 3
|
||||||
|
- `todo.md` 3
|
||||||
|
- `CHANGELOG.md` 2
|
||||||
|
|
||||||
|
## Reading cost, chunk 0118907cad57, finished 2026-10-31T09:32:49Z
|
||||||
|
|
||||||
|
116614 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `src/http/app.test.ts` 11
|
||||||
|
- `README.md` 7
|
||||||
|
- `src/http/app.ts` 6
|
||||||
|
- `src/ui/shell.test.ts` 5
|
||||||
|
- `e2e-tests/visual.spec.ts` 4
|
||||||
|
- `src/http/static.ts` 4
|
||||||
|
- `todo.md` 3
|
||||||
|
- `src/auth/flow-view.ts` 2
|
||||||
|
- `views/partials/shell.ejs` 2
|
||||||
|
- `AGENTS.md` 1
|
||||||
|
|
||||||
|
## Reading cost reset, 2026-10-31T09:34:55Z
|
||||||
|
|
||||||
|
## Reading cost, chunk 55cc9f01b477, finished 2026-10-31T09:42:24Z
|
||||||
|
|
||||||
|
94244 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `src/http/app.test.ts` 6
|
||||||
|
- `README.md` 4
|
||||||
|
- `src/http/app-plugins.test.ts` 4
|
||||||
|
- `todo.md` 3
|
||||||
|
- `AGENTS.md` 2
|
||||||
|
- `docs/reading-cost.md` 2
|
||||||
|
- `examples/plugins/admin/README.md` 2
|
||||||
|
- `ci.sh` 1
|
||||||
|
- `examples/plugins/admin/admin-clients.test.ts` 1
|
||||||
|
- `examples/plugins/admin/admin-grants.test.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk 4a8c9037acde, finished 2026-10-31T10:07:56Z
|
||||||
|
|
||||||
|
126616 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `src/http/app-admin.test.ts` 7
|
||||||
|
- `examples/plugins/admin/admin-groups.ts` 4
|
||||||
|
- `README.md` 3
|
||||||
|
- `todo.md` 3
|
||||||
|
- `AGENTS.md` 2
|
||||||
|
- `examples/plugins/admin/README.md` 2
|
||||||
|
- `examples/plugins/admin/admin-grants.ts` 2
|
||||||
|
- `examples/plugins/admin/i18n/en-US.ts` 2
|
||||||
|
- `CHANGELOG.md` 1
|
||||||
|
- `examples/plugins/admin/admin-groups.test.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk c90b25a50479, finished 2026-10-31T10:40:23Z
|
||||||
|
|
||||||
|
90935 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `README.md` 9
|
||||||
|
- `todo.md` 5
|
||||||
|
- `AGENTS.md` 3
|
||||||
|
- `CHANGELOG.md` 1
|
||||||
|
- `package.json` 1
|
||||||
|
- `registry-cleanup/cleanup.ts` 1
|
||||||
|
- `release-tooling/dockerhub-overview.md.tmpl` 1
|
||||||
|
- `release-tooling/dockerhub-overview.test.ts` 1
|
||||||
|
- `release-tooling/dockerhub-overview.ts` 1
|
||||||
|
- `src/ci-gate.test.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk 984f4d4c8a76, finished 2026-10-31T11:18:20Z
|
||||||
|
|
||||||
|
209334 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `README.md` 6
|
||||||
|
- `todo.md` 6
|
||||||
|
- `src/ui/stylesheet-floor.test.ts` 4
|
||||||
|
- `src/ui/stylesheet-floor.ts` 4
|
||||||
|
- `CHANGELOG.md` 3
|
||||||
|
- `AGENTS.md` 2
|
||||||
|
- `examples/plugins/scheduling/public/scheduling.css` 2
|
||||||
|
- `package.json` 2
|
||||||
|
- `public/css/styles.css` 2
|
||||||
|
- `ci.sh` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk de26287532f7, finished 2026-10-31T11:29:32Z
|
||||||
|
|
||||||
|
68410 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `todo.md` 5
|
||||||
|
- `public/css/styles.css` 3
|
||||||
|
- `src/ui/stylesheet-floor.test.ts` 3
|
||||||
|
- `CHANGELOG.md` 2
|
||||||
|
- `README.md` 2
|
||||||
|
- `AGENTS.md` 1
|
||||||
|
- `src/ui/theme-switch.test.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk 4a258a237c53, finished 2026-10-31T12:04:07Z
|
||||||
|
|
||||||
|
154777 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `e2e-tests/full-flow.spec.ts` 9
|
||||||
|
- `todo.md` 6
|
||||||
|
- `README.md` 5
|
||||||
|
- `src/auth/flow-view.ts` 5
|
||||||
|
- `src/auth/flow-view.test.ts` 4
|
||||||
|
- `src/auth/routes.ts` 4
|
||||||
|
- `src/auth/kratos-public.test.ts` 3
|
||||||
|
- `src/i18n/locales/en-US.ts` 3
|
||||||
|
- `e2e-tests/compose.full.yml` 2
|
||||||
|
- `src/auth/kratos-admin.ts` 2
|
||||||
|
|
||||||
|
## Reading cost, chunk d8b41620d631, finished 2026-10-31T12:37:38Z
|
||||||
|
|
||||||
|
70583 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `todo.md` 5
|
||||||
|
- `AGENTS.md` 2
|
||||||
|
- `README.md` 2
|
||||||
|
- `examples/plugins/admin/README.md` 2
|
||||||
|
- `src/auth/kratos-admin.ts` 2
|
||||||
|
- `src/http/app-admin.test.ts` 2
|
||||||
|
- `CHANGELOG.md` 1
|
||||||
|
- `e2e-tests/full-flow.spec.ts` 1
|
||||||
|
- `examples/plugins/admin/admin-users.test.ts` 1
|
||||||
|
- `examples/plugins/admin/admin-users.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk f0a49c300aac, finished 2026-10-31T12:47:27Z
|
||||||
|
|
||||||
|
63526 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `README.md` 4
|
||||||
|
- `src/plugin-host/settings.test.ts` 3
|
||||||
|
- `todo.md` 3
|
||||||
|
- `CHANGELOG.md` 2
|
||||||
|
- `src/plugin-host/settings.ts` 2
|
||||||
|
- `AGENTS.md` 1
|
||||||
@@ -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.62.1-noble
|
FROM mcr.microsoft.com/playwright:v1.64.1-noble
|
||||||
|
|
||||||
WORKDIR /e2e-tests
|
WORKDIR /e2e-tests
|
||||||
|
|
||||||
@@ -8,6 +8,8 @@ COPY e2e-tests/package.json e2e-tests/package-lock.json ./
|
|||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
COPY e2e-tests/ ./
|
COPY e2e-tests/ ./
|
||||||
|
# e2e-tests/tsconfig.json extends it, so the specs are held to the host's strictness flags.
|
||||||
|
COPY tsconfig.json /tsconfig.json
|
||||||
|
|
||||||
# Runs as the invoking `--user` so artifacts land owned by them, not root — and an arbitrary uid has
|
# Runs as the invoking `--user` so artifacts land owned by them, not root — and an arbitrary uid has
|
||||||
# no passwd entry here, so its home would be the unwritable `/`. npm's cache follows HOME.
|
# no passwd entry here, so its home would be the unwritable `/`. npm's cache follows HOME.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
|
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
|
||||||
# login, menu filtering by permission, users/groups/OAuth2-clients CRUD + permission granting, a plugin page, logout. A
|
# login, emailed-code verification + recovery, menu filtering by permission, users/groups/OAuth2-clients CRUD + permission granting, a plugin page, logout. A
|
||||||
# tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies
|
# 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 --user "$(id -u):$(id -g)" --build --rm e2e
|
# docker compose -f compose.yml -f e2e-tests/compose.full.yml run --user "$(id -u):$(id -g)" --build --rm e2e
|
||||||
@@ -53,7 +53,7 @@ services:
|
|||||||
|
|
||||||
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
||||||
shifts-upstream:
|
shifts-upstream:
|
||||||
image: node:24.19.0-alpine3.24
|
image: node:24.22.2-alpine3.24
|
||||||
command: ["node", "/server.ts"]
|
command: ["node", "/server.ts"]
|
||||||
volumes:
|
volumes:
|
||||||
- ./examples/shifts-upstream/server.ts:/server.ts:ro
|
- ./examples/shifts-upstream/server.ts:/server.ts:ro
|
||||||
@@ -66,7 +66,7 @@ services:
|
|||||||
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
|
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
|
||||||
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
|
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
|
||||||
mock-oidc:
|
mock-oidc:
|
||||||
image: node:24.19.0-alpine3.24
|
image: node:24.22.2-alpine3.24
|
||||||
command: ["node", "/mock-oidc.ts"]
|
command: ["node", "/mock-oidc.ts"]
|
||||||
environment:
|
environment:
|
||||||
ISSUER: http://mock-oidc:9000
|
ISSUER: http://mock-oidc:9000
|
||||||
@@ -79,9 +79,20 @@ services:
|
|||||||
timeout: 4s
|
timeout: 4s
|
||||||
retries: 15
|
retries: 15
|
||||||
|
|
||||||
|
# Catches the courier's mail (kratos.yml → smtp://mailpit:1025), so a test can read an emailed code.
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit:v1.33.0
|
||||||
|
mem_limit: 128m
|
||||||
|
memswap_limit: 128m
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/mailpit", "readyz"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 4s
|
||||||
|
retries: 15
|
||||||
|
|
||||||
# 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.19.0-alpine3.24
|
image: node:24.22.2-alpine3.24
|
||||||
command: ["node", "/proxy.ts"]
|
command: ["node", "/proxy.ts"]
|
||||||
depends_on:
|
depends_on:
|
||||||
web:
|
web:
|
||||||
@@ -103,6 +114,8 @@ services:
|
|||||||
dockerfile: e2e-tests/Dockerfile
|
dockerfile: e2e-tests/Dockerfile
|
||||||
command: ["npx", "playwright", "test", "full-flow.spec.ts"]
|
command: ["npx", "playwright", "test", "full-flow.spec.ts"]
|
||||||
depends_on:
|
depends_on:
|
||||||
|
mailpit:
|
||||||
|
condition: service_healthy
|
||||||
mock-oidc:
|
mock-oidc:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
proxy:
|
proxy:
|
||||||
@@ -110,5 +123,6 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
BASE_URL: http://proxy
|
BASE_URL: http://proxy
|
||||||
KRATOS_ADMIN_URL: http://kratos:4434
|
KRATOS_ADMIN_URL: http://kratos:4434
|
||||||
|
MAILPIT_URL: http://mailpit:8025
|
||||||
volumes:
|
volumes:
|
||||||
- ./e2e-tests/artifacts:/e2e-tests/artifacts
|
- ./e2e-tests/artifacts:/e2e-tests/artifacts
|
||||||
|
|||||||
+105
-2
@@ -1,5 +1,5 @@
|
|||||||
import type { Browser, Page } from "@playwright/test";
|
import type { Browser, Page } from "@playwright/test";
|
||||||
import { expect, test, watchedPage } from "./console-guard.ts";
|
import { allowConsole, expect, test, watchedPage } from "./console-guard.ts";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
// Full browser E2E: the real Playwright UI against the live stack via the same-origin
|
// Full browser E2E: the real Playwright UI against the live stack via the same-origin
|
||||||
@@ -14,6 +14,8 @@ const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the
|
|||||||
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
|
||||||
|
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
|
||||||
|
const MAILPIT = process.env.MAILPIT_URL ?? "http://mailpit:8025";
|
||||||
|
|
||||||
// Drive the themed password login form → Kratos → /auth/complete → dashboard, signed in.
|
// Drive the themed password login form → Kratos → /auth/complete → dashboard, signed in.
|
||||||
async function loginPassword(page: Page): Promise<void> {
|
async function loginPassword(page: Page): Promise<void> {
|
||||||
@@ -136,7 +138,27 @@ 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 CRUD: create a group (writes go to Keto), see it listed, then grant it a permission", async () => {
|
// Kratos accepts an admin-minted code only in the flow it was minted on, which the notice's link opens.
|
||||||
|
test("recovery code: the notice's link opens the flow that accepts the code, and the code signs the user in", async () => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
const email = `admin-recovery-${suffix}@plainpages.local`;
|
||||||
|
await createIdentity(email);
|
||||||
|
await page.goto("/admin/users");
|
||||||
|
const editHref = await page.locator("tr", { hasText: email }).locator('a[href^="/admin/users/"]').first().getAttribute("href");
|
||||||
|
await page.goto(editHref!);
|
||||||
|
await page.getByRole("button", { name: "Generate recovery code" }).click();
|
||||||
|
const code = await page.locator(".recovery-code code").innerText();
|
||||||
|
const link = await page.locator(".alert-body a").getAttribute("href");
|
||||||
|
|
||||||
|
const user = await watchedPage(await browser.newContext());
|
||||||
|
await user.goto(link!);
|
||||||
|
await user.fill('input[name="code"]', code);
|
||||||
|
await user.getByRole("button", { name: "Continue" }).click();
|
||||||
|
await expect(user).toHaveURL(/\/settings/);
|
||||||
|
await user.context().close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("groups CRUD: create a group (writes go to Keto), see it listed, then grant it a permission", async ({ browser }) => {
|
||||||
// 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}`;
|
||||||
@@ -150,6 +172,10 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
// Permissions are declared in plugin code, so the group's detail page offers them as a fixed
|
// Permissions are declared in plugin code, so the group's detail page offers them as a fixed
|
||||||
// checkbox list rather than a create form — there is no Permissions screen to visit.
|
// checkbox list rather than a create form — there is no Permissions screen to visit.
|
||||||
await page.goto(`/admin/groups/${group}`);
|
await page.goto(`/admin/groups/${group}`);
|
||||||
|
allowConsole(/status of 409 .*\/admin\/groups\/[^/]+\/permissions/); // the stale save below, refused on purpose
|
||||||
|
// A second browser signed in as the same admin, opened before the save below, so its form carries the old grants.
|
||||||
|
const staleTab = await watchedPage(await browser.newContext({ storageState: await page.context().storageState() }));
|
||||||
|
await staleTab.goto(`/admin/groups/${group}`);
|
||||||
const scheduling = page.locator('input[name="permission"][value="scheduling:read"]');
|
const scheduling = page.locator('input[name="permission"][value="scheduling:read"]');
|
||||||
await expect(scheduling).toHaveCount(1); // declared by the reference plugin, so it's on offer
|
await expect(scheduling).toHaveCount(1); // declared by the reference plugin, so it's on offer
|
||||||
await expect(scheduling).not.toBeChecked();
|
await expect(scheduling).not.toBeChecked();
|
||||||
@@ -157,7 +183,16 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await page.locator('form:has(input[name="permission"]) button[type="submit"]').click();
|
await page.locator('form:has(input[name="permission"]) button[type="submit"]').click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/admin/groups/${group}`));
|
await expect(page).toHaveURL(new RegExp(`/admin/groups/${group}`));
|
||||||
|
await expect(page.getByText("Permissions saved.")).toBeVisible();
|
||||||
await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
|
await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
|
||||||
|
|
||||||
|
// The other tab no longer matches Keto, so its save is refused and shows the grant made above.
|
||||||
|
await staleTab.locator('input[name="permission"][value="scheduling:write"]').check();
|
||||||
|
await staleTab.locator('form:has(input[name="permission"]) button[type="submit"]').click();
|
||||||
|
await expect(staleTab.getByText("changed while you had the page open")).toBeVisible();
|
||||||
|
await expect(staleTab.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
|
||||||
|
await expect(staleTab.locator('input[name="permission"][value="scheduling:write"]')).not.toBeChecked();
|
||||||
|
await staleTab.context().close();
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
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 () => {
|
||||||
@@ -193,6 +228,12 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await page.goto("/scheduling/shifts");
|
await page.goto("/scheduling/shifts");
|
||||||
await expect(page.locator("h1")).toHaveText("Shifts");
|
await expect(page.locator("h1")).toHaveText("Shifts");
|
||||||
await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream
|
await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream
|
||||||
|
|
||||||
|
// The admin owns none of the demo's rows, so an empty page is the no-leak assertion.
|
||||||
|
await page.goto("/scheduling/mine");
|
||||||
|
await expect(page.locator("h1")).toHaveText("My shifts");
|
||||||
|
await expect(page.getByText("No shifts are assigned to admin@plainpages.local")).toBeVisible();
|
||||||
|
await expect(page.locator("table")).not.toContainText("Morning — Front desk");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("plugin settings: the screen names the variable that sets each declared key", async () => {
|
test("plugin settings: the screen names the variable that sets each declared key", async () => {
|
||||||
@@ -234,6 +275,17 @@ test("return_to: a deep link while logged out returns to that page after login",
|
|||||||
await expect(page.locator("h1")).toHaveText("Users");
|
await expect(page.locator("h1")).toHaveText("Users");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("settings: an anonymous visit signs in first, then lands on the settings page", async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
await page.goto("/settings");
|
||||||
|
await expect(page).toHaveURL(/\/login(\?|$)/);
|
||||||
|
await page.fill('input[name="identifier"]', ADMIN_EMAIL);
|
||||||
|
await page.fill('input[name="password"]', ADMIN_PASSWORD);
|
||||||
|
await page.locator('.auth-form button[type="submit"]').click();
|
||||||
|
await expect(page).toHaveURL(/\/settings(\?|$)/);
|
||||||
|
await expect(page.locator("h1")).toHaveText("Account settings");
|
||||||
|
});
|
||||||
|
|
||||||
test("mocked SSO login: the provider button signs a user in via OIDC", async ({ page }) => {
|
test("mocked SSO login: the provider button signs a user in via OIDC", async ({ page }) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
await page.goto("/login");
|
await page.goto("/login");
|
||||||
@@ -244,3 +296,54 @@ test("mocked SSO login: the provider button signs a user in via OIDC", async ({
|
|||||||
// A fresh SSO identity holds no permissions, 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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The newest one-time code Kratos' courier mailed to `to`, read from the mail catcher (compose.full.yml).
|
||||||
|
async function mailedCode(to: string): Promise<string> {
|
||||||
|
let code = "";
|
||||||
|
await expect.poll(async () => {
|
||||||
|
const search = (await (await fetch(`${MAILPIT}/api/v1/search?query=${encodeURIComponent(`to:"${to}"`)}`)).json()) as { messages: { ID: string }[] };
|
||||||
|
const id = search.messages[0]?.ID;
|
||||||
|
if (!id) return "";
|
||||||
|
const message = (await (await fetch(`${MAILPIT}/api/v1/message/${id}`)).json()) as { Text: string };
|
||||||
|
code = /\b(\d{6})\b/.exec(message.Text)?.[1] ?? "";
|
||||||
|
return code;
|
||||||
|
}, { timeout: 30_000 }).not.toBe("");
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createIdentity(email: string): Promise<void> {
|
||||||
|
const created = await fetch(`${KRATOS_ADMIN}/admin/identities`, {
|
||||||
|
body: JSON.stringify({ schema_id: "default", traits: { email } }),
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
expect(created.status).toBe(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fills the email step, then pastes the mailed code with whitespace around it, as a paste often brings.
|
||||||
|
async function enterMailedCode(page: Page, email: string): Promise<void> {
|
||||||
|
await page.fill('input[name="email"]', email);
|
||||||
|
await page.getByRole("button", { name: "Continue" }).click();
|
||||||
|
await expect(page.locator('input[name="code"]')).toBeVisible();
|
||||||
|
await page.fill('input[name="code"]', ` ${await mailedCode(email)}\t `);
|
||||||
|
await page.getByRole("button", { name: "Continue" }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("verification: a code pasted with whitespace around it is accepted", async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
const email = `verify-${suffix}@plainpages.local`;
|
||||||
|
await createIdentity(email);
|
||||||
|
await page.goto("/verification");
|
||||||
|
await enterMailedCode(page, email);
|
||||||
|
await expect(page.getByText("You successfully verified your email address.")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("recovery: a pasted code signs in to the settings page, in the chosen language", async ({ page }) => {
|
||||||
|
test.setTimeout(90_000);
|
||||||
|
const email = `recover-${suffix}@plainpages.local`;
|
||||||
|
await createIdentity(email);
|
||||||
|
await page.goto("/recovery?locale=en-US");
|
||||||
|
await enterMailedCode(page, email);
|
||||||
|
await expect(page).toHaveURL(/\/settings\?.*locale=en-US/);
|
||||||
|
await expect(page.locator("h1")).toHaveText("Account settings");
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// never leaves the compose network. Auto-approves /authorize (no provider login UI), then signs an
|
// never leaves the compose network. Auto-approves /authorize (no provider login UI), then signs an
|
||||||
// RS256 id_token Kratos verifies against /jwks. stdlib only, in-memory, NOT app code. The single
|
// RS256 id_token Kratos verifies against /jwks. stdlib only, in-memory, NOT app code. The single
|
||||||
// host (mock-oidc:9000) is reachable by both the browser (/authorize) and Kratos (token/jwks).
|
// host (mock-oidc:9000) is reachable by both the browser (/authorize) and Kratos (token/jwks).
|
||||||
import { createServer } from "node:http";
|
import { createServer, type ServerResponse } from "node:http";
|
||||||
import { createSign, generateKeyPairSync, randomUUID } from "node:crypto";
|
import { createSign, generateKeyPairSync, randomUUID } from "node:crypto";
|
||||||
|
|
||||||
const ISSUER = process.env.ISSUER ?? "http://mock-oidc:9000";
|
const ISSUER = process.env.ISSUER ?? "http://mock-oidc:9000";
|
||||||
@@ -15,8 +15,8 @@ const KID = "mock-1";
|
|||||||
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||||
const jwk = { ...publicKey.export({ format: "jwk" }), alg: "RS256", kid: KID, use: "sig" };
|
const jwk = { ...publicKey.export({ format: "jwk" }), alg: "RS256", kid: KID, use: "sig" };
|
||||||
|
|
||||||
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
|
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
|
||||||
function idToken(nonce) {
|
function idToken(nonce: string): string {
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
const header = b64({ alg: "RS256", kid: KID, typ: "JWT" });
|
const header = b64({ alg: "RS256", kid: KID, typ: "JWT" });
|
||||||
const payload = b64({
|
const payload = b64({
|
||||||
@@ -27,8 +27,8 @@ function idToken(nonce) {
|
|||||||
return `${header}.${payload}.${sig}`;
|
return `${header}.${payload}.${sig}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const codes = new Map(); // single-use auth code → the nonce Kratos sent (echoed into the id_token)
|
const codes = new Map<string, string>(); // single-use auth code → the nonce Kratos sent (echoed into the id_token)
|
||||||
const json = (res, body) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
|
const json = (res: ServerResponse, body: unknown): void => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
|
||||||
|
|
||||||
createServer((req, res) => {
|
createServer((req, res) => {
|
||||||
const url = new URL(req.url ?? "/", ISSUER);
|
const url = new URL(req.url ?? "/", ISSUER);
|
||||||
|
|||||||
Generated
+397
-21
@@ -6,17 +6,19 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "plainpages-e2e",
|
"name": "plainpages-e2e",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.1"
|
"@playwright/test": "1.64.1",
|
||||||
|
"@types/node": "24.19.2",
|
||||||
|
"typescript": "7.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.62.1",
|
"version": "1.64.1",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
|
||||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.62.1"
|
"playwright": "1.64.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -25,44 +27,376 @@
|
|||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/@types/node": {
|
||||||
"version": "2.3.2",
|
"version": "24.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
|
||||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
"integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": ">=7.24.0 <7.24.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
"darwin"
|
"darwin"
|
||||||
],
|
],
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-darwin-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-arm": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-loong64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-s390x": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-linux-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-sunos-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-win32-arm64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@typescript/typescript-win32-x64": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.62.1",
|
"version": "1.64.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.62.1"
|
"playwright-core": "1.64.1"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"fsevents": "2.3.2"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.62.1",
|
"version": "1.64.1",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -71,6 +405,48 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "7.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||||
|
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||||
|
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-arm": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||||
|
"@typescript/typescript-linux-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||||
|
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||||
|
"@typescript/typescript-win32-x64": "7.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.24.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||||
|
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,12 @@
|
|||||||
"description": "Playwright E2E: design-system parity (visual), auth refresh, OAuth2 login/consent, and the full browser flow (login/menu/CRUD/plugin/logout).",
|
"description": "Playwright E2E: design-system parity (visual), auth refresh, OAuth2 login/consent, and the full browser flow (login/menu/CRUD/plugin/logout).",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "playwright test"
|
"test": "playwright test",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.1"
|
"@playwright/test": "1.64.1",
|
||||||
|
"@types/node": "24.19.2",
|
||||||
|
"typescript": "7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export default defineConfig({
|
|||||||
testDir: ".",
|
testDir: ".",
|
||||||
outputDir: "artifacts/test-output",
|
outputDir: "artifacts/test-output",
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
|
workers: 4, // Playwright's default is half the cores, so a run's parallelism would depend on the machine
|
||||||
forbidOnly: true,
|
forbidOnly: true,
|
||||||
reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]],
|
reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]],
|
||||||
use: {
|
use: {
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ const PORT = Number(process.env.PORT ?? 80);
|
|||||||
|
|
||||||
// Kratos public owns these prefixes (self-service flows, sessions, its well-known/schemas); the
|
// Kratos public owns these prefixes (self-service flows, sessions, its well-known/schemas); the
|
||||||
// browser hits them via the flow action + OIDC callbacks. Everything else is the web app.
|
// browser hits them via the flow action + OIDC callbacks. Everything else is the web app.
|
||||||
const toKratos = (path) => ["/self-service", "/sessions", "/.well-known/ory", "/schemas"].some((p) => path === p || path.startsWith(`${p}/`));
|
const toKratos = (path: string): boolean => ["/self-service", "/sessions", "/.well-known/ory", "/schemas"].some((p) => path === p || path.startsWith(`${p}/`));
|
||||||
|
|
||||||
createServer((req, res) => {
|
createServer((req, res) => {
|
||||||
const target = toKratos(req.url ?? "/") ? KRATOS : WEB;
|
const target = toKratos(req.url ?? "/") ? KRATOS : WEB;
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../tsconfig.json",
|
||||||
|
"compilerOptions": { "lib": ["ESNext", "DOM"] },
|
||||||
|
"include": ["*.ts"]
|
||||||
|
}
|
||||||
+157
-10
@@ -10,6 +10,15 @@ const SESSION_COOKIE = "plainpages_jwt"; // src/auth/login.ts — web verifies i
|
|||||||
const shot = (page: Page, name: string): Promise<Buffer> =>
|
const shot = (page: Page, name: string): Promise<Buffer> =>
|
||||||
page.screenshot({ fullPage: true, path: `artifacts/screenshots/${test.info().project.name}/${name}.png` });
|
page.screenshot({ fullPage: true, path: `artifacts/screenshots/${test.info().project.name}/${name}.png` });
|
||||||
|
|
||||||
|
// Either side of the stylesheet's 860px and 560px breakpoints.
|
||||||
|
const VIEWPORTS = [
|
||||||
|
{ name: "desktop-xl", width: 1920, height: 1080 },
|
||||||
|
{ name: "laptop", width: 1280, height: 800 },
|
||||||
|
{ name: "tablet", width: 768, height: 1024 },
|
||||||
|
{ name: "phone", width: 390, height: 844 },
|
||||||
|
{ name: "phone-small", width: 320, height: 568 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -28,6 +37,45 @@ test.beforeEach(async ({ context }) => {
|
|||||||
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
|
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A key press, not scrollIntoView (a script can scroll a box no reader can) and not the wheel
|
||||||
|
// (Firefox's synthetic event never reaches the document).
|
||||||
|
for (const [name, path, tail] of [
|
||||||
|
["the starter dashboard", "/dashboard", ".form-actions .btn"],
|
||||||
|
["the public landing", "/", ".landing-actions .btn"],
|
||||||
|
] as const) {
|
||||||
|
for (const { width } of VIEWPORTS) {
|
||||||
|
test(`${name} scrolls to its end at ${width}px wide`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height: 200 });
|
||||||
|
await page.goto(path);
|
||||||
|
|
||||||
|
const overflows = await page.evaluate(() => document.documentElement.scrollHeight > window.innerHeight);
|
||||||
|
expect(overflows, "the page must overflow, or it proves nothing").toBe(true);
|
||||||
|
await page.keyboard.press("End");
|
||||||
|
await expect(page.locator(tail).last()).toBeInViewport({ ratio: 1 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Green only while #nav-toggle is position: fixed — a label tap focuses it, and focus scrolls into view.
|
||||||
|
test("closing the mobile drawer leaves the reader where the scrim found them", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 200 });
|
||||||
|
await page.goto("/dashboard");
|
||||||
|
|
||||||
|
await page.locator(".hamburger").click();
|
||||||
|
await expect(page.locator("#nav-toggle")).toBeChecked();
|
||||||
|
// Scripted, because a key press with focus on the toggle does not scroll in every engine — and
|
||||||
|
// what is under test is closing the drawer, not how the reader got down the page.
|
||||||
|
await page.evaluate(() => window.scrollTo(0, 120));
|
||||||
|
const at = await page.evaluate(() => window.scrollY);
|
||||||
|
expect(at, "the page must have somewhere to scroll behind the scrim").toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// The exposed strip beside the 264px panel: the scrim spans the viewport, so its centre is under
|
||||||
|
// the drawer and a centre click lands on the panel instead.
|
||||||
|
await page.locator(".scrim").click({ position: { x: 340, y: 100 } });
|
||||||
|
await expect(page.locator("#nav-toggle")).not.toBeChecked();
|
||||||
|
expect(await page.evaluate(() => window.scrollY), "closing the drawer must not move the page").toBe(at);
|
||||||
|
});
|
||||||
|
|
||||||
test("captures the live pages for review", async ({ page }) => {
|
test("captures the live pages for review", async ({ page }) => {
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
await expect(page.locator(".sidebar")).toBeVisible();
|
await expect(page.locator(".sidebar")).toBeVisible();
|
||||||
@@ -38,13 +86,21 @@ test("captures the live pages for review", async ({ page }) => {
|
|||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
await page.locator("#theme-dark").check({ force: true }); // visually-hidden radio
|
await page.locator("#theme-dark").check({ force: true }); // visually-hidden radio
|
||||||
await shot(page, "live-03-dark");
|
await shot(page, "live-03-dark");
|
||||||
|
|
||||||
await page.setViewportSize({ width: 390, height: 844 });
|
|
||||||
await page.goto("/dashboard");
|
|
||||||
await shot(page, "live-04-mobile");
|
|
||||||
await page.setViewportSize({ width: 1280, height: 800 });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The page is a document, so a column wider than the viewport can only show as sideways scroll.
|
||||||
|
for (const { name, width, height } of VIEWPORTS) {
|
||||||
|
for (const [path, label] of [["/dashboard", "dashboard"], ["/", "landing"], ["/scheduling", "plugin"]] as const) {
|
||||||
|
test(`the ${label} page fits ${width}px wide, with no sideways scroll`, async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width, height });
|
||||||
|
await page.goto(path);
|
||||||
|
const [scrollWidth, clientWidth] = await page.evaluate(() => [document.documentElement.scrollWidth, document.documentElement.clientWidth] as const);
|
||||||
|
expect(scrollWidth).toBeLessThanOrEqual(clientWidth);
|
||||||
|
await shot(page, `live-${label}-${name}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
test("every icon <use> resolves to a defined <symbol> (no broken graphics)", async ({ page }) => {
|
test("every icon <use> resolves to a defined <symbol> (no broken graphics)", async ({ page }) => {
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
const missing = await page.evaluate(() => {
|
const missing = await page.evaluate(() => {
|
||||||
@@ -94,6 +150,7 @@ test("a popover menu sits on its trigger and closes on an outside click or Esc
|
|||||||
await expect(panel).toBeVisible();
|
await expect(panel).toBeVisible();
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
await expect(panel).toBeHidden();
|
await expect(panel).toBeHidden();
|
||||||
|
await expect(trigger).toBeFocused(); // the browser returns focus, so no trigger needs a tabindex
|
||||||
});
|
});
|
||||||
|
|
||||||
test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => {
|
test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => {
|
||||||
@@ -108,6 +165,21 @@ test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({
|
|||||||
expect(offCanvas).toBe(true);
|
expect(offCanvas).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the hamburger opens the drawer over the scrim, and a link in it navigates", async ({ page }) => {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.goto("/dashboard");
|
||||||
|
await page.locator(".hamburger").click();
|
||||||
|
|
||||||
|
const sidebar = page.locator(".sidebar");
|
||||||
|
await expect(sidebar).toBeInViewport({ ratio: 1 });
|
||||||
|
// A click is refused while the scrim sits above the drawer, which a lower z-index would cause.
|
||||||
|
await sidebar.locator("summary").first().click(); // the dashboard is the current page, so its links are in a folded branch
|
||||||
|
const link = sidebar.locator(".nav-children a.nav-self").first();
|
||||||
|
const target = await link.getAttribute("href");
|
||||||
|
await link.click();
|
||||||
|
await expect(page).toHaveURL((url) => url.pathname === target?.split("?")[0]);
|
||||||
|
});
|
||||||
|
|
||||||
test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a tokenless POST is refused", async ({ page }) => {
|
test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a tokenless POST is refused", async ({ page }) => {
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
// The page issues a CSRF cookie and embeds the same token in the Sign-out form (double-submit).
|
// The page issues a CSRF cookie and embeds the same token in the Sign-out form (double-submit).
|
||||||
@@ -134,6 +206,68 @@ test("the public landing at / is ungated and links to sign in + register", async
|
|||||||
await shot(page, "live-05-public-landing");
|
await shot(page, "live-05-public-landing");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Measured on the rendered elements, so a theme block that drops --on-accent fails here.
|
||||||
|
const contrastOf = (el: Element): number => {
|
||||||
|
const luminance = (rgb: string): number => {
|
||||||
|
const [r = 0, g = 0, b = 0] = (rgb.match(/[\d.]+/g) ?? []).map((c) => {
|
||||||
|
const v = Number(c) / 255;
|
||||||
|
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
||||||
|
});
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
};
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
const [hi = 0, lo = 0] = [luminance(style.color), luminance(style.backgroundColor)].sort((x, y) => y - x);
|
||||||
|
return (hi + 0.05) / (lo + 0.05);
|
||||||
|
};
|
||||||
|
for (const [name, colorScheme, forceDark] of [["light", "light", false], ["dark", "dark", false], ["forced dark", "light", true]] as const) {
|
||||||
|
test(`text on the accent reaches 4.5:1 in ${name} mode`, async ({ page, context }) => {
|
||||||
|
await context.clearCookies();
|
||||||
|
await page.emulateMedia({ colorScheme });
|
||||||
|
await page.goto("/");
|
||||||
|
if (forceDark) await page.locator("#theme-dark").check({ force: true });
|
||||||
|
// No page of this stack has a second page of results, so add the current-page button.
|
||||||
|
await page.locator("#main-content").evaluate((el) => el.insertAdjacentHTML("beforeend", '<a class="page-btn" aria-current="page" href="#">2</a>'));
|
||||||
|
for (const selector of [".landing .btn-primary", ".brand-mark", ".page-btn"]) {
|
||||||
|
await expect.poll(() => page.locator(selector).first().evaluate(contrastOf), { message: selector }).toBeGreaterThanOrEqual(4.5); // polls past the colour transition
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a nav branch is folded until its toggle opens it — no JavaScript", async ({ page }) => {
|
||||||
|
await page.goto("/dashboard");
|
||||||
|
const child = page.locator('.sidebar a[href="/scheduling"]');
|
||||||
|
await expect(child).toBeHidden();
|
||||||
|
await page.locator('.sidebar summary[aria-label="Toggle Scheduling"]').click();
|
||||||
|
await expect(child).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the reference plugin's lead text is inset by --pad-x, like the bars around it", async ({ page }) => {
|
||||||
|
await page.goto("/scheduling");
|
||||||
|
const [inset, padX] = await page.locator(".scheduling-page > p").evaluate((p) => [
|
||||||
|
p.getBoundingClientRect().left - (p.parentElement as HTMLElement).getBoundingClientRect().left,
|
||||||
|
parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--pad-x")),
|
||||||
|
] as const);
|
||||||
|
expect(inset).toBe(padX);
|
||||||
|
});
|
||||||
|
|
||||||
|
// transferSize is 0 only for a response the browser's own cache served; a 304 still costs a round trip.
|
||||||
|
test("a repeat page view fetches no stylesheet again", async ({ page }) => {
|
||||||
|
const stylesheets = (): Promise<{ name: string; transferSize: number }[]> => page.evaluate(() =>
|
||||||
|
performance.getEntriesByType("resource")
|
||||||
|
.filter((entry): entry is PerformanceResourceTiming => entry instanceof PerformanceResourceTiming && entry.initiatorType === "link" && entry.name.includes(".css"))
|
||||||
|
.map(({ name, transferSize }) => ({ name, transferSize })));
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
const first = await stylesheets();
|
||||||
|
expect(first.length, "the landing links styles.css and auth.css").toBeGreaterThanOrEqual(2);
|
||||||
|
for (const { name, transferSize } of first) expect(transferSize, `${name} on the first view`).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
const repeat = await stylesheets();
|
||||||
|
expect(repeat.map(({ name }) => name)).toEqual(first.map(({ name }) => name));
|
||||||
|
for (const { name, transferSize } of repeat) expect(transferSize, `${name} on the repeat view`).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => {
|
test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => {
|
||||||
allowConsole(/status of 404 .*\/no-such-page$/); // the navigation under test, which Chromium and WebKit log — not a sub-resource of it
|
allowConsole(/status of 404 .*\/no-such-page$/); // the navigation under test, which Chromium and WebKit log — not a sub-resource of it
|
||||||
const res = await page.goto("/no-such-page");
|
const res = await page.goto("/no-such-page");
|
||||||
@@ -142,11 +276,11 @@ test("unknown routes serve the 404 page (a real user-facing flow, covered end-to
|
|||||||
await expect(page.getByRole("link", { name: "Back home" })).toBeVisible();
|
await expect(page.getByRole("link", { name: "Back home" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
// The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is
|
// The reference plugin (plugins/scheduling) ships discovered in the image, and shows all three
|
||||||
// reachable by anyone and its menu header shows for everyone; the shifts list stays permission-gated,
|
// gates: the public Overview is reachable by anyone, My shifts takes any session, and the shifts
|
||||||
// so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the full
|
// list needs a permission. The authenticated list/form flow is the full E2E (full-flow.spec).
|
||||||
// E2E (full-flow.spec). Side-effect-free.
|
// Side-effect-free.
|
||||||
test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login", async ({ page, request }) => {
|
test("the reference plugin: public Overview is open to all, My shifts takes any session, the gated Shifts redirects to /login", async ({ page, request }) => {
|
||||||
// `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so these
|
// `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so these
|
||||||
// probes are genuinely anonymous.
|
// probes are genuinely anonymous.
|
||||||
// The public overview is reachable with no session (200), not bounced to sign in.
|
// The public overview is reachable with no session (200), not bounced to sign in.
|
||||||
@@ -165,10 +299,23 @@ 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");
|
||||||
|
|
||||||
|
// A `session: true` route bounces an anonymous visitor the same way — no permission involved.
|
||||||
|
const mine = await request.get("/scheduling/mine", { maxRedirects: 0 });
|
||||||
|
expect(mine.status()).toBe(303);
|
||||||
|
expect(mine.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fmine");
|
||||||
|
|
||||||
// The signed-in member (no scheduling permission) sees the public Scheduling → Overview leaf in the nav,
|
// The signed-in member (no scheduling permission) sees the public Scheduling → Overview leaf in the nav,
|
||||||
// but the gated Shifts leaf is filtered out.
|
// but the gated Shifts leaf is filtered out.
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
|
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
|
||||||
await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown
|
await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown
|
||||||
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out
|
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out
|
||||||
|
await expect(page.locator('.sidebar a[href="/scheduling/mine"]')).toHaveCount(1); // session gate: a session is enough
|
||||||
|
|
||||||
|
// No shifts upstream on this stack, so this also pins the degraded page: the reason, never a 500
|
||||||
|
// and never a claim about what is assigned.
|
||||||
|
await page.goto("/scheduling/mine");
|
||||||
|
await expect(page.getByRole("heading", { name: "My shifts" })).toBeVisible();
|
||||||
|
await expect(page.getByText("Couldn't reach the scheduling service")).toBeVisible();
|
||||||
|
await expect(page.getByText("No shifts are assigned to")).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -62,4 +62,4 @@ property of a user or a group, edited as a checkbox list on those two screens (`
|
|||||||
|
|
||||||
The three screens hold **no state** — everything lives in Ory. Handlers are thin, so their builders
|
The three screens hold **no state** — everything lives in Ory. Handlers are thin, so their builders
|
||||||
unit-test as pure functions with no host; the HTTP routing/gate/CSRF is covered in
|
unit-test as pure functions with no host; the HTTP routing/gate/CSRF is covered in
|
||||||
`src/http/app.test.ts` (which mounts this plugin) and end-to-end in `e2e-tests/full-flow.spec.ts`.
|
`src/http/app-admin.test.ts` (which mounts this plugin) and end-to-end in `e2e-tests/full-flow.spec.ts`.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Built-in OAuth2 clients admin screen: the pure view-model + Hydra-payload builders. A client
|
// Built-in OAuth2 clients admin screen: the pure view-model + Hydra-payload builders. A client
|
||||||
// is an Ory Hydra OAuth2 client (apps that log in *through* us); writes go only to Hydra. The
|
// is an Ory Hydra OAuth2 client (apps that log in *through* us); writes go only to Hydra. The
|
||||||
// HTTP routing/gate/CSRF + live Hydra calls (incl. the one-time secret) are exercised in app.test.ts.
|
// HTTP routing/gate/CSRF + live Hydra calls (incl. the one-time secret) are exercised in src/http/app-admin.test.ts.
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// The pure half of permission granting: what a submitted checkbox set changes, and the picker the
|
// The pure half of permission granting: what a submitted checkbox set changes, and the picker the
|
||||||
// two screens render from it. The Keto writes and the HTTP round trip are covered in app.test.ts.
|
// two screens render from it. The Keto writes and the HTTP round trip are covered in src/http/app-admin.test.ts.
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import type { PermissionDecl } from "@plainpages/plugin-api";
|
import type { PermissionDecl } from "@plainpages/plugin-api";
|
||||||
import { buildPermissionPicker, grantDiff, grantTuple, groupSubject, userSubject } from "./admin-grants.ts";
|
import type { KetoClient } from "@plainpages/plugin-api";
|
||||||
|
import { applyGrants, baselineToken, buildPermissionPicker, grantDiff, grantTuple, groupSubject, isStale, userSubject } from "./admin-grants.ts";
|
||||||
|
|
||||||
const declared: PermissionDecl[] = [
|
const declared: PermissionDecl[] = [
|
||||||
{ description: "View users", name: "users:read" },
|
{ description: "View users", name: "users:read" },
|
||||||
@@ -81,3 +82,48 @@ test("buildPermissionPicker says so when no plugin declares a permission, rather
|
|||||||
assert.deepEqual(picker.choices, []);
|
assert.deepEqual(picker.choices, []);
|
||||||
assert.ok(picker.empty);
|
assert.ok(picker.empty);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("baselineToken depends on the offered grants held, not on their order or on undeclared ones", () => {
|
||||||
|
const token = baselineToken(declared, ["users:read", "users:write"]);
|
||||||
|
assert.match(token, /^[0-9a-f]+$/);
|
||||||
|
assert.equal(baselineToken(declared, ["users:write", "users:read"]), token);
|
||||||
|
assert.equal(baselineToken(declared, ["users:read", "users:write", "legacy:thing"]), token); // undeclared: never shown, never saved
|
||||||
|
assert.notEqual(baselineToken(declared, ["users:read"]), token);
|
||||||
|
assert.notEqual(baselineToken(declared, []), token);
|
||||||
|
assert.notEqual(baselineToken(declared, []), ""); // an empty set is a state too, not an absent token
|
||||||
|
assert.equal(buildPermissionPicker({ action: "/x", declared, direct: ["users:read", "users:write"] }).baseline, token);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isStale refuses a missing or old token, but only for a save that would change something", () => {
|
||||||
|
const held = ["users:read"];
|
||||||
|
const change = grantDiff(declared, held, ["users:read", "users:write"]);
|
||||||
|
const nothing = grantDiff(declared, held, ["users:read"]);
|
||||||
|
const current = new URLSearchParams({ baseline: baselineToken(declared, held) });
|
||||||
|
const old = new URLSearchParams({ baseline: baselineToken(declared, []) });
|
||||||
|
assert.equal(isStale(current, declared, held, change), false);
|
||||||
|
assert.equal(isStale(old, declared, held, change), true);
|
||||||
|
assert.equal(isStale(new URLSearchParams(), declared, held, change), true); // no token at all
|
||||||
|
assert.equal(isStale(old, declared, held, nothing), false); // a repeated save has nothing to refuse
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applyGrants applies writes then deletes, and on a Keto failure stops and reports what was applied", async () => {
|
||||||
|
const log: string[] = [];
|
||||||
|
const keto = (failOn?: string): KetoClient => ({
|
||||||
|
check: async () => false,
|
||||||
|
deleteTuple: async (tp) => { if (tp.object === failOn) throw new Error("boom"); log.push(`delete ${tp.object}`); },
|
||||||
|
expand: async () => ({ type: "leaf" }),
|
||||||
|
listRelations: async () => ({ nextPageToken: null, tuples: [] }),
|
||||||
|
writeTuple: async (tp) => { if (tp.object === failOn) throw new Error("boom"); log.push(`write ${tp.object}`); },
|
||||||
|
});
|
||||||
|
const diff = { grant: ["a:read", "b:read"], revoke: ["c:read"] };
|
||||||
|
|
||||||
|
const ok = await applyGrants(keto(), userSubject("u1"), diff);
|
||||||
|
assert.deepEqual(ok, { applied: diff });
|
||||||
|
assert.deepEqual(log, ["write a:read", "write b:read", "delete c:read"]);
|
||||||
|
|
||||||
|
log.length = 0;
|
||||||
|
const failed = await applyGrants(keto("b:read"), userSubject("u1"), diff);
|
||||||
|
assert.deepEqual(failed.applied, { grant: ["a:read"], revoke: [] });
|
||||||
|
assert.ok(failed.error instanceof Error);
|
||||||
|
assert.deepEqual(log, ["write a:read"]); // nothing after the failure was attempted
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,11 +6,17 @@
|
|||||||
// what the installed plugins declare in code. Nothing here invents a name, which is why the old
|
// what the installed plugins declare in code. Nothing here invents a name, which is why the old
|
||||||
// Permissions screen is gone: a grant is a property of a user or a group, edited where they are.
|
// Permissions screen is gone: a grant is a property of a user or a group, edited where they are.
|
||||||
|
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api";
|
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api";
|
||||||
|
|
||||||
const PERMISSION_NS = "Permission";
|
const PERMISSION_NS = "Permission";
|
||||||
const GRANTED = "granted";
|
const GRANTED = "granted";
|
||||||
export const PERMISSIONS_FIELD = "permission"; // the checkbox name the two forms post
|
export const PERMISSIONS_FIELD = "permission"; // the checkbox name the two forms post
|
||||||
|
export const BASELINE_FIELD = "baseline"; // the hidden field carrying baselineToken
|
||||||
|
export const SAVED_NOTICE = "permissions-saved"; // the `info-msg` a successful save redirects with
|
||||||
|
|
||||||
|
// Only that exact value counts, so a crafted link can't put words on the page.
|
||||||
|
export const isSavedNotice = (url: URL): boolean => url.searchParams.get("info-msg") === SAVED_NOTICE;
|
||||||
|
|
||||||
export type GrantSubject = { subject_id: string } | { subject_set: SubjectSet };
|
export type GrantSubject = { subject_id: string } | { subject_set: SubjectSet };
|
||||||
|
|
||||||
@@ -53,6 +59,7 @@ export interface PermissionChoice {
|
|||||||
|
|
||||||
export interface PermissionPicker {
|
export interface PermissionPicker {
|
||||||
action: string;
|
action: string;
|
||||||
|
baseline: string; // baselineToken of the direct grants this form was rendered from
|
||||||
choices: PermissionChoice[];
|
choices: PermissionChoice[];
|
||||||
empty: string | undefined; // set when no plugin declares a permission — the picker has nothing to offer
|
empty: string | undefined; // set when no plugin declares a permission — the picker has nothing to offer
|
||||||
error?: string; // a rejected save (e.g. the self-revoke guard), rendered above the list
|
error?: string; // a rejected save (e.g. the self-revoke guard), rendered above the list
|
||||||
@@ -60,6 +67,7 @@ export interface PermissionPicker {
|
|||||||
hint: string;
|
hint: string;
|
||||||
inheritedNote: string | undefined; // set when at least one choice is group-held, to explain the disabled row
|
inheritedNote: string | undefined; // set when at least one choice is group-held, to explain the disabled row
|
||||||
legend: string;
|
legend: string;
|
||||||
|
notice?: string; // a confirmation, rendered above the list
|
||||||
// Set for a group: its members hold these transitively, so a change reaches them at their next
|
// Set for a group: its members hold these transitively, so a change reaches them at their next
|
||||||
// re-mint rather than at once. The user picker revokes live tokens, so it says nothing.
|
// re-mint rather than at once. The user picker revokes live tokens, so it says nothing.
|
||||||
pending: string | undefined;
|
pending: string | undefined;
|
||||||
@@ -90,6 +98,7 @@ export function buildPermissionPicker(opts: {
|
|||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
action: opts.action,
|
action: opts.action,
|
||||||
|
baseline: baselineToken(opts.declared, opts.direct),
|
||||||
choices,
|
choices,
|
||||||
empty: opts.declared.length === 0 ? t("admin.grants.none") : undefined,
|
empty: opts.declared.length === 0 ? t("admin.grants.none") : undefined,
|
||||||
field: PERMISSIONS_FIELD,
|
field: PERMISSIONS_FIELD,
|
||||||
@@ -107,7 +116,7 @@ export function buildPermissionPicker(opts: {
|
|||||||
// What a submitted set changes. Pure so the diff is testable without Keto: only declared names are
|
// What a submitted set changes. Pure so the diff is testable without Keto: only declared names are
|
||||||
// considered, so a crafted POST cannot grant something no plugin gates on, and a held-but-undeclared
|
// considered, so a crafted POST cannot grant something no plugin gates on, and a held-but-undeclared
|
||||||
// name (left over from an uninstalled plugin) is never silently revoked by an unrelated save.
|
// name (left over from an uninstalled plugin) is never silently revoked by an unrelated save.
|
||||||
export function grantDiff(declared: readonly PermissionDecl[], held: string[], wanted: string[]): { grant: string[]; revoke: string[] } {
|
export function grantDiff(declared: readonly PermissionDecl[], held: string[], wanted: string[]): GrantChange {
|
||||||
const offered = new Set(declared.map((d) => d.name));
|
const offered = new Set(declared.map((d) => d.name));
|
||||||
const heldSet = new Set(held);
|
const heldSet = new Set(held);
|
||||||
const wantedSet = new Set(wanted.filter((name) => offered.has(name)));
|
const wantedSet = new Set(wanted.filter((name) => offered.has(name)));
|
||||||
@@ -117,7 +126,32 @@ export function grantDiff(declared: readonly PermissionDecl[], held: string[], w
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function applyGrants(keto: KetoClient, subject: GrantSubject, diff: { grant: string[]; revoke: string[] }): Promise<void> {
|
// A token for the direct grants a form was rendered from. Like grantDiff it covers only the declared
|
||||||
for (const name of diff.grant) await keto.writeTuple(grantTuple(name, subject));
|
// names the form offered. It is a digest so an empty set is still a token; it is a guard against a
|
||||||
for (const name of diff.revoke) await keto.deleteTuple(grantTuple(name, subject));
|
// stale page, not a secret, since anyone who can read the page can compute it.
|
||||||
|
export function baselineToken(declared: readonly PermissionDecl[], held: string[]): string {
|
||||||
|
const offered = new Set(declared.map((d) => d.name));
|
||||||
|
return createHash("sha256").update(held.filter((name) => offered.has(name)).sort().join("\n")).digest("hex").slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// True when the form's token is missing or no longer matches Keto *and* the save would change
|
||||||
|
// something. A save that changes nothing, such as a double-clicked Save whose first POST landed, is
|
||||||
|
// harmless, so it is not refused.
|
||||||
|
export const isStale = (form: { get(name: string): string | null }, declared: readonly PermissionDecl[], held: string[], diff: GrantChange): boolean =>
|
||||||
|
(diff.grant.length > 0 || diff.revoke.length > 0) && form.get(BASELINE_FIELD) !== baselineToken(declared, held);
|
||||||
|
|
||||||
|
export interface GrantChange { grant: string[]; revoke: string[] }
|
||||||
|
|
||||||
|
// KetoClient writes one tuple per call, so the writes and deletes run one by one and stop at the first
|
||||||
|
// failure. What was applied by then is returned beside the error, so the caller can still revoke live
|
||||||
|
// tokens and tell the admin the save is only partly done.
|
||||||
|
export async function applyGrants(keto: KetoClient, subject: GrantSubject, diff: GrantChange): Promise<{ applied: GrantChange; error?: unknown }> {
|
||||||
|
const applied: GrantChange = { grant: [], revoke: [] };
|
||||||
|
try {
|
||||||
|
for (const name of diff.grant) { await keto.writeTuple(grantTuple(name, subject)); applied.grant.push(name); }
|
||||||
|
for (const name of diff.revoke) { await keto.deleteTuple(grantTuple(name, subject)); applied.revoke.push(name); }
|
||||||
|
} catch (error) {
|
||||||
|
return { applied, error };
|
||||||
|
}
|
||||||
|
return { applied };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Built-in Groups admin screen: the pure view-model + Keto-tuple builders. A group is a
|
// Built-in Groups admin screen: the pure view-model + Keto-tuple builders. A group is a
|
||||||
// Keto subject set (Group:<name>#members); membership tuples carry users (subject_id) or nested
|
// Keto subject set (Group:<name>#members); membership tuples carry users (subject_id) or nested
|
||||||
// groups (subject_set). The HTTP routing/gate/CSRF + live Keto/Kratos calls are exercised over
|
// groups (subject_set). The HTTP routing/gate/CSRF + live Keto/Kratos calls are exercised over
|
||||||
// HTTP in app.test.ts.
|
// HTTP in src/http/app-admin.test.ts.
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
// each returning a RouteResult.
|
// each returning a RouteResult.
|
||||||
|
|
||||||
import { can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api";
|
import { can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api";
|
||||||
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD } from "./admin-grants.ts";
|
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
|
||||||
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||||
import type { FieldConfig } from "./admin-users.ts";
|
import type { FieldConfig } from "./admin-users.ts";
|
||||||
|
|
||||||
const GROUP_NS = "Group";
|
const GROUP_NS = "Group";
|
||||||
@@ -293,6 +293,32 @@ async function groupExists(keto: KetoClient, name: string): Promise<boolean> {
|
|||||||
return page.tuples.length > 0;
|
return page.tuples.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMember = (keto: KetoClient, actor: string, group: string): Promise<boolean> =>
|
||||||
|
keto.check({ namespace: GROUP_NS, object: group, relation: MEMBERS, subject_id: `user:${actor}` });
|
||||||
|
|
||||||
|
// The self-lockout guard, the group-side twin of the Users screen's self-revoke one: of the permissions
|
||||||
|
// a change takes from a group the actor is in, those they don't hold directly. One they also hold
|
||||||
|
// through another group is included, since telling those apart needs a reverse Keto query.
|
||||||
|
async function lostToSelf(keto: KetoClient, actor: string, taken: readonly string[]): Promise<string[]> {
|
||||||
|
if (taken.length === 0) return [];
|
||||||
|
const direct = new Set(await heldPermissions(keto, userSubject(actor)));
|
||||||
|
return taken.filter((name) => !direct.has(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leaving a group takes everything it confers, including through its own parents.
|
||||||
|
async function lostOnLeaving({ ctx, keto, user }: GroupsDeps, group: string): Promise<string[]> {
|
||||||
|
return lostToSelf(keto, user.id, await effectivePermissions(keto, groupSubject(group), ctx.declaredPermissions));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lostOnDelete(deps: GroupsDeps, group: string): Promise<string[]> {
|
||||||
|
return (await isMember(deps.keto, deps.user.id, group)) ? lostOnLeaving(deps, group) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refuseSelfLockout(deps: GroupsDeps, name: string, action: string, lost: string[]): Promise<RouteResult> {
|
||||||
|
deps.ctx.log.warn("admin: refused a group change that would lock the actor out", { action, actor: deps.user.id, group: name, lost: lost.join(",") });
|
||||||
|
return { ...(await groupDetailPage(deps, name, { pageError: deps.ctx.t("admin.groups.error.selfLockout", { permissions: lost.join(", ") }) })), status: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate (`groups:read` on
|
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate (`groups:read` on
|
||||||
// a GET, `groups:write` on a POST) + the Keto and Kratos capabilities (else a themed 503). Each route
|
// a GET, `groups:write` on a POST) + the Keto and Kratos capabilities (else a themed 503). Each route
|
||||||
// below is a thin handler over these.
|
// below is a thin handler over these.
|
||||||
@@ -347,13 +373,15 @@ export const groupsCreate = withGroups(async (deps) => {
|
|||||||
// GET /admin/groups/new — the create form.
|
// GET /admin/groups/new — the create form.
|
||||||
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write");
|
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write");
|
||||||
|
|
||||||
// GET /admin/groups/:name — the detail + membership page.
|
// The detail + membership page. `notice` and `error` ride the permission picker; `pageError` tops the
|
||||||
export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, name) => {
|
// page, for any change refused because it would lock the actor out.
|
||||||
|
async function groupDetailPage({ ctx, keto, kratosAdmin }: GroupsDeps, name: string, say: { error?: string; notice?: string; pageError?: string } = {}): Promise<RouteResult> {
|
||||||
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
|
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
|
||||||
const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById));
|
const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById));
|
||||||
const subject = groupSubject(name);
|
const subject = groupSubject(name);
|
||||||
const [direct, effective] = await Promise.all([heldPermissions(keto, subject), effectivePermissions(keto, subject, ctx.declaredPermissions)]);
|
const [direct, effective] = await Promise.all([heldPermissions(keto, subject), effectivePermissions(keto, subject, ctx.declaredPermissions)]);
|
||||||
const permissions = buildPermissionPicker({
|
const permissions: PermissionPicker = {
|
||||||
|
...buildPermissionPicker({
|
||||||
action: `${detailHref(name)}/permissions`,
|
action: `${detailHref(name)}/permissions`,
|
||||||
declared: ctx.declaredPermissions,
|
declared: ctx.declaredPermissions,
|
||||||
direct,
|
direct,
|
||||||
@@ -361,22 +389,38 @@ export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, nam
|
|||||||
readOnly: !can(ctx, permissionName("groups", "write")),
|
readOnly: !can(ctx, permissionName("groups", "write")),
|
||||||
t: ctx.t,
|
t: ctx.t,
|
||||||
transitive: true, // members inherit, so a change here lands at their next re-mint, not at once
|
transitive: true, // members inherit, so a change here lands at their next re-mint, not at once
|
||||||
});
|
}),
|
||||||
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ canWrite: !permissions.readOnly, candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, permissions, t: ctx.t }) }, view: "group-detail" };
|
...(say.error ? { error: say.error } : {}),
|
||||||
});
|
...(say.notice ? { notice: say.notice } : {}),
|
||||||
|
};
|
||||||
|
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ canWrite: !permissions.readOnly, candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, permissions, t: ctx.t, ...(say.pageError ? { error: say.pageError } : {}) }) }, view: "group-detail" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/groups/:name — the detail + membership page.
|
||||||
|
export const groupsDetail = withGroupName((deps, name) =>
|
||||||
|
groupDetailPage(deps, name, isSavedNotice(deps.ctx.url) ? { notice: deps.ctx.t("admin.grants.saved") } : {}));
|
||||||
|
|
||||||
// POST /admin/groups/:name/permissions — the submitted checkboxes are the desired set. Members hold
|
// POST /admin/groups/:name/permissions — the submitted checkboxes are the desired set. Members hold
|
||||||
// a group's permissions transitively, so the change reaches them at their next login or re-mint —
|
// a group's permissions transitively, so the change reaches them at their next login or re-mint —
|
||||||
// the documented instant-revoke tradeoff for anything held through a group.
|
// the documented instant-revoke tradeoff for anything held through a group.
|
||||||
export const groupsPermissions = withGroupName(async ({ ctx, keto, user }, name) => {
|
export const groupsPermissions = withGroupName(async (deps, name) => {
|
||||||
|
const { ctx, keto, user } = deps;
|
||||||
const form = (await guardedForm(ctx))!;
|
const form = (await guardedForm(ctx))!;
|
||||||
const subject = groupSubject(name);
|
const subject = groupSubject(name);
|
||||||
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
|
const held = await heldPermissions(keto, subject);
|
||||||
await applyGrants(keto, subject, diff);
|
const diff = grantDiff(ctx.declaredPermissions, held, form.getAll(PERMISSIONS_FIELD));
|
||||||
if (diff.grant.length > 0 || diff.revoke.length > 0) {
|
if (isStale(form, ctx.declaredPermissions, held, diff)) return { ...(await groupDetailPage(deps, name, { error: ctx.t("admin.grants.stale") })), status: 409 };
|
||||||
ctx.log.info("admin: group permissions changed", { actor: user.id, granted: diff.grant.join(","), group: name, revoked: diff.revoke.join(",") });
|
const lost = diff.revoke.length > 0 && await isMember(keto, user.id, name) ? await lostToSelf(keto, user.id, diff.revoke) : [];
|
||||||
|
if (lost.length > 0) return refuseSelfLockout(deps, name, "save", lost);
|
||||||
|
const { applied, error } = await applyGrants(keto, subject, diff);
|
||||||
|
if (applied.grant.length > 0 || applied.revoke.length > 0) {
|
||||||
|
ctx.log.info("admin: group permissions changed", { actor: user.id, granted: applied.grant.join(","), group: name, revoked: applied.revoke.join(",") });
|
||||||
}
|
}
|
||||||
return { redirect: detailHref(name) };
|
if (error) {
|
||||||
|
ctx.log.error("admin: group permissions only partly saved", { actor: user.id, error: String(error), group: name });
|
||||||
|
return partialSave(ctx, async () => ({ ...(await groupDetailPage(deps, name, { error: ctx.t("admin.grants.partial") })), status: 502 }));
|
||||||
|
}
|
||||||
|
return { redirect: `${detailHref(name)}?info-msg=${SAVED_NOTICE}` };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
|
// POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
|
||||||
@@ -388,19 +432,24 @@ export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /admin/groups/:name/delete — the deliberate confirm step.
|
// GET /admin/groups/:name/delete — the deliberate confirm step.
|
||||||
export const groupsDeleteConfirm = withGroupName((deps, name) => {
|
export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
|
||||||
|
const lost = await lostOnDelete(deps, name);
|
||||||
|
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
|
||||||
const base = detailHref(name);
|
const base = detailHref(name);
|
||||||
const tt = deps.ctx.t;
|
const tt = deps.ctx.t;
|
||||||
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
return { data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
||||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: tt("admin.groups.title") }, { href: base, label: name }, { label: tt("common.delete") }],
|
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: tt("admin.groups.title") }, { href: base, label: name }, { label: tt("common.delete") }],
|
||||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.groups.delete"),
|
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.groups.delete"),
|
||||||
message: tt("admin.groups.deleteMessage", { name }), title: tt("admin.groups.delete"),
|
message: tt("admin.groups.deleteMessage", { name }), title: tt("admin.groups.delete"),
|
||||||
}) }, view: "confirm" });
|
}) }, view: "confirm" };
|
||||||
}, "write");
|
}, "write");
|
||||||
|
|
||||||
// POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
|
// POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
|
||||||
export const groupsDelete = withGroupName(async ({ ctx, keto, user }, name) => {
|
export const groupsDelete = withGroupName(async (deps, name) => {
|
||||||
|
const { ctx, keto, user } = deps;
|
||||||
await guardedForm(ctx); // CSRF-verify the POST
|
await guardedForm(ctx); // CSRF-verify the POST
|
||||||
|
const lost = await lostOnDelete(deps, name);
|
||||||
|
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
|
||||||
// Drop what the group *holds* before what it *contains*: a Keto set exists only through its
|
// Drop what the group *holds* before what it *contains*: a Keto set exists only through its
|
||||||
// tuples, so leaving the grants behind would resurrect every permission the moment someone
|
// tuples, so leaving the grants behind would resurrect every permission the moment someone
|
||||||
// re-created a group with the same name.
|
// re-created a group with the same name.
|
||||||
@@ -413,9 +462,15 @@ export const groupsDelete = withGroupName(async ({ ctx, keto, user }, name) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/groups/:name/members/delete — remove one member.
|
// POST /admin/groups/:name/members/delete — remove one member.
|
||||||
export const groupsRemoveMember = withGroupName(async ({ ctx, keto }, name) => {
|
export const groupsRemoveMember = withGroupName(async (deps, name) => {
|
||||||
|
const { ctx, keto, user } = deps;
|
||||||
const form = (await guardedForm(ctx))!;
|
const form = (await guardedForm(ctx))!;
|
||||||
const tuple = memberTuple(name, (form.get("member") ?? "").trim());
|
const tuple = memberTuple(name, (form.get("member") ?? "").trim());
|
||||||
if (tuple) await keto.deleteTuple(tuple);
|
if (!tuple) return { redirect: detailHref(name) };
|
||||||
|
// Removing the actor, or a nested group they are in, can take them out of this group.
|
||||||
|
const removesSelf = tuple.subject_set ? await isMember(keto, user.id, tuple.subject_set.object) : tuple.subject_id === `user:${user.id}`;
|
||||||
|
const lost = removesSelf ? await lostOnLeaving(deps, name) : [];
|
||||||
|
if (lost.length > 0) return refuseSelfLockout(deps, name, "remove", lost);
|
||||||
|
await keto.deleteTuple(tuple);
|
||||||
return { redirect: detailHref(name) };
|
return { redirect: detailHref(name) };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical
|
// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical
|
||||||
// (requirePermission/guardedForm gate every admin write) and reused across all three screens, so pin the
|
// (requirePermission/guardedForm gate every admin write) and reused across all three screens, so pin the
|
||||||
// contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts.
|
// contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app-admin.test.ts.
|
||||||
// Import only from the @plainpages/plugin-api barrel — the same contract boundary the plugin code uses.
|
// Import only from the @plainpages/plugin-api barrel — the same contract boundary the plugin code uses.
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
|||||||
@@ -35,10 +35,9 @@ export function actionForMethod(method: string): AdminAction {
|
|||||||
return verb === "GET" || verb === "HEAD" ? "read" : "write";
|
return verb === "GET" || verb === "HEAD" ? "read" : "write";
|
||||||
}
|
}
|
||||||
|
|
||||||
// The plugin's nav fragment: an ungated "Admin" header + its three screens, each gated on its own
|
// The plugin's nav fragment: the "Admin" header + its four screens, each gated on its own read
|
||||||
// read permission. The header carries no `permission` because a user may hold one screen's and not
|
// permission. composeNav drops a header left with no visible children, so a user holding none of
|
||||||
// another's; composeNav drops a header left with no visible children, so a user holding none of the
|
// them never sees the section. The host current-marks the active item — no `current`/`open` here.
|
||||||
// three never sees the section. The host current-marks the active item — no `current`/`open` here.
|
|
||||||
export const ADMIN_NAV: NavNode = {
|
export const ADMIN_NAV: NavNode = {
|
||||||
children: [
|
children: [
|
||||||
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") },
|
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") },
|
||||||
@@ -49,6 +48,7 @@ export const ADMIN_NAV: NavNode = {
|
|||||||
icon: "i-shield",
|
icon: "i-shield",
|
||||||
id: "admin",
|
id: "admin",
|
||||||
label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels
|
label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels
|
||||||
|
public: true, // the header gates nothing; every child needs a permission, and an empty header is dropped
|
||||||
};
|
};
|
||||||
|
|
||||||
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
|
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
|
||||||
@@ -83,6 +83,16 @@ export function unavailable(ctx: RequestContext, what: string): RouteResult {
|
|||||||
return { data: { chrome: ctx.chrome, message: ctx.t("admin.unavailable.message", { what }), title: ctx.t("admin.unavailable.title") }, status: 503, view: "notice" };
|
return { data: { chrome: ctx.chrome, message: ctx.t("admin.unavailable.message", { what }), title: ctx.t("admin.unavailable.title") }, status: 503, view: "notice" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The page a partial permission save reports on reads Keto again. If that read fails too, the report
|
||||||
|
// stands alone on the notice page, so the admin still hears that part of the save landed.
|
||||||
|
export async function partialSave(ctx: RequestContext, render: () => Promise<RouteResult>): Promise<RouteResult> {
|
||||||
|
try {
|
||||||
|
return await render();
|
||||||
|
} catch {
|
||||||
|
return { data: { chrome: ctx.chrome, message: ctx.t("admin.grants.partial"), title: ctx.t("admin.unavailable.title") }, status: 502, view: "notice" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Model for the shared destructive-confirm page (views/confirm.ejs). The view reads the shell fields
|
// Model for the shared destructive-confirm page (views/confirm.ejs). The view reads the shell fields
|
||||||
// (brand/csrf/theme/user/nav) from ctx.chrome; this carries only the page body + title/breadcrumbs.
|
// (brand/csrf/theme/user/nav) from ctx.chrome; this carries only the page body + title/breadcrumbs.
|
||||||
export function buildConfirmModel(opts: {
|
export function buildConfirmModel(opts: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Users admin screen (example plugin): the pure view-model + Kratos-payload builders. The HTTP
|
// Users admin screen (example plugin): the pure view-model + Kratos-payload builders. The HTTP
|
||||||
// routing/gate/CSRF + live Kratos calls are exercised over HTTP in src/http/app.test.ts.
|
// routing/gate/CSRF + live Kratos calls are exercised over HTTP in src/http/app-admin.test.ts.
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import type { Identity } from "@plainpages/plugin-api";
|
import type { Identity } from "@plainpages/plugin-api";
|
||||||
@@ -60,6 +60,14 @@ test("buildUsersListModel filters by search + status, sorts, and paginates", ()
|
|||||||
|
|
||||||
// Edit link points at the per-identity route.
|
// Edit link points at the per-identity route.
|
||||||
assert.match(all.table.rows[0]!.actions![0]!.href!, new RegExp(`/admin/users/${people[0]!.id}$`));
|
assert.match(all.table.rows[0]!.actions![0]!.href!, new RegExp(`/admin/users/${people[0]!.id}$`));
|
||||||
|
assert.deepEqual([all.table.rows[0]!.actions![0]!.icon, all.table.rows[0]!.actions![0]!.label], ["i-edit", "Edit"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The detail page of a `users:read` holder is read-only, so the row action must not promise an edit.
|
||||||
|
test("buildUsersListModel: a read-only holder's row action says View, with the same target", () => {
|
||||||
|
const people: Identity[] = [{ id: "u1", traits: { email: "a@x.io" } }];
|
||||||
|
const action = buildUsersListModel({ canWrite: false, identities: people, url: "http://x/admin/users" }).table.rows[0]!.actions![0]!;
|
||||||
|
assert.deepEqual([action.icon, action.label, action.href], ["i-eye", "View", "/admin/users/u1"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("buildUserFormModel: create mode has an editable email + password, no edit actions", () => {
|
test("buildUserFormModel: create mode has an editable email + password, no edit actions", () => {
|
||||||
@@ -92,6 +100,19 @@ test("buildUserFormModel: edit mode prefills, locks email, and exposes state/del
|
|||||||
assert.equal(buildUserFormModel({ identity: identity(4, { state: "inactive" }) }).edit!.nextLabel, "Reactivate");
|
assert.equal(buildUserFormModel({ identity: identity(4, { state: "inactive" }) }).edit!.nextLabel, "Reactivate");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("buildUserFormModel: the title and breadcrumb say New, Edit, or View for a users:read holder", () => {
|
||||||
|
const cases: [Parameters<typeof buildUserFormModel>[0], string, string][] = [
|
||||||
|
[{}, "New user", "New"],
|
||||||
|
[{ identity: identity(3) }, "Edit user", "Edit"],
|
||||||
|
[{ canWrite: false, identity: identity(3) }, "View user", "View"],
|
||||||
|
];
|
||||||
|
for (const [opts, title, crumb] of cases) {
|
||||||
|
const m = buildUserFormModel(opts);
|
||||||
|
assert.equal(m.title, title);
|
||||||
|
assert.deepEqual(m.breadcrumbs, [{ href: "/admin/users", label: "Users" }, { label: crumb }]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("createIdentityPayload: schema/state/traits, name only when given, password only when set", () => {
|
test("createIdentityPayload: schema/state/traits, name only when given, password only when set", () => {
|
||||||
assert.deepEqual(createIdentityPayload({ email: "a@b.c", first: "Ada", last: "Lovelace", password: "" }), {
|
assert.deepEqual(createIdentityPayload({ email: "a@b.c", first: "Ada", last: "Lovelace", password: "" }), {
|
||||||
schema_id: "default",
|
schema_id: "default",
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
// a shared `withUser` gate.
|
// a shared `withUser` gate.
|
||||||
|
|
||||||
import { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
|
import { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
|
||||||
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD, userSubject } from "./admin-grants.ts";
|
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
|
||||||
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||||
|
|
||||||
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
|
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
|
||||||
const DEFAULT_PAGE_SIZE = 25;
|
const DEFAULT_PAGE_SIZE = 25;
|
||||||
@@ -138,12 +138,12 @@ export function buildUsersListModel(opts: {
|
|||||||
canWrite: opts.canWrite !== false,
|
canWrite: opts.canWrite !== false,
|
||||||
filterBar: listFilterBar(state, all.length, t),
|
filterBar: listFilterBar(state, all.length, t),
|
||||||
pagination: listPagination(state, page, t),
|
pagination: listPagination(state, page, t),
|
||||||
table: listTable(rows, state, sort, t),
|
table: listTable(rows, state, sort, t, opts.canWrite !== false),
|
||||||
title: t("admin.users.title"),
|
title: t("admin.users.title"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate) {
|
function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate, canWrite: boolean) {
|
||||||
return {
|
return {
|
||||||
actions: true,
|
actions: true,
|
||||||
caption: t("admin.users.title"),
|
caption: t("admin.users.title"),
|
||||||
@@ -153,7 +153,7 @@ function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "des
|
|||||||
return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true };
|
return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true };
|
||||||
}),
|
}),
|
||||||
rows: rows.map((u) => ({
|
rows: rows.map((u) => ({
|
||||||
actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: "i-edit", label: t("common.edit") }],
|
actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: canWrite ? "i-edit" : "i-eye", label: t(canWrite ? "common.edit" : "admin.users.view") }],
|
||||||
cells: [
|
cells: [
|
||||||
{ user: { initials: u.initials, name: u.name } },
|
{ user: { initials: u.initials, name: u.name } },
|
||||||
u.email,
|
u.email,
|
||||||
@@ -243,8 +243,9 @@ export function buildUserFormModel(opts: {
|
|||||||
if (!editing) fields.push({ autocomplete: "new-password", hint: t("admin.users.field.passwordHint"), icon: "i-lock", id: "password", label: t("admin.users.field.password"), name: "password", optional: true, type: "password" });
|
if (!editing) fields.push({ autocomplete: "new-password", hint: t("admin.users.field.passwordHint"), icon: "i-lock", id: "password", label: t("admin.users.field.password"), name: "password", optional: true, type: "password" });
|
||||||
|
|
||||||
const canWrite = opts.canWrite !== false;
|
const canWrite = opts.canWrite !== false;
|
||||||
|
const mode = !editing ? "new" : canWrite ? "edit" : "view";
|
||||||
return {
|
return {
|
||||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.users.title") }, { label: editing ? t("common.edit") : t("common.new") }],
|
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.users.title") }, { label: t({ edit: "common.edit", new: "common.new", view: "admin.users.view" }[mode]) }],
|
||||||
canWrite, // the view drops every write affordance when false; the host already 403s the POSTs
|
canWrite, // the view drops every write affordance when false; the host already 403s the POSTs
|
||||||
edit: editing ? {
|
edit: editing ? {
|
||||||
deleteAction: `${idPath}/delete`,
|
deleteAction: `${idPath}/delete`,
|
||||||
@@ -258,7 +259,7 @@ export function buildUserFormModel(opts: {
|
|||||||
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? t("admin.users.save") : t("admin.users.create") },
|
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? t("admin.users.save") : t("admin.users.create") },
|
||||||
permissions: editing ? opts.permissions : undefined,
|
permissions: editing ? opts.permissions : undefined,
|
||||||
recovery: opts.recovery,
|
recovery: opts.recovery,
|
||||||
title: editing ? t("admin.users.edit") : t("admin.users.new"),
|
title: t({ edit: "admin.users.edit", new: "admin.users.new", view: "admin.users.viewTitle" }[mode]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,7 +330,7 @@ export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx
|
|||||||
|
|
||||||
// GET /admin/users/:id — the edit form, prefilled.
|
// GET /admin/users/:id — the edit form, prefilled.
|
||||||
export const usersEditForm = withTarget(async (deps, identity, id) => {
|
export const usersEditForm = withTarget(async (deps, identity, id) => {
|
||||||
const permissions = await userPermissionPicker(deps, id);
|
const permissions = await userPermissionPicker(deps, id, undefined, isSavedNotice(deps.ctx.url) ? deps.ctx.t("admin.grants.saved") : undefined);
|
||||||
return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) });
|
return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -338,7 +339,7 @@ const canWriteUsers = (ctx: RequestContext): boolean => can(ctx, permissionName(
|
|||||||
// The checkbox list of declared permissions: ticked where this user holds one, and disabled where
|
// The checkbox list of declared permissions: ticked where this user holds one, and disabled where
|
||||||
// the grant comes from a group (real, but removed on that group). Undefined when Keto isn't wired —
|
// the grant comes from a group (real, but removed on that group). Undefined when Keto isn't wired —
|
||||||
// the rest of the edit page still works.
|
// the rest of the edit page still works.
|
||||||
async function userPermissionPicker(deps: UsersDeps, id: string, error?: string): Promise<PermissionPicker | undefined> {
|
async function userPermissionPicker(deps: UsersDeps, id: string, error?: string, notice?: string): Promise<PermissionPicker | undefined> {
|
||||||
if (!deps.keto) return undefined;
|
if (!deps.keto) return undefined;
|
||||||
const subject = userSubject(id);
|
const subject = userSubject(id);
|
||||||
const [direct, effective] = await Promise.all([
|
const [direct, effective] = await Promise.all([
|
||||||
@@ -355,6 +356,7 @@ async function userPermissionPicker(deps: UsersDeps, id: string, error?: string)
|
|||||||
t: deps.ctx.t,
|
t: deps.ctx.t,
|
||||||
}),
|
}),
|
||||||
...(error ? { error } : {}),
|
...(error ? { error } : {}),
|
||||||
|
...(notice ? { notice } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,21 +368,30 @@ export const usersPermissions = withTarget(async (deps, identity, id) => {
|
|||||||
const form = (await guardedForm(ctx))!;
|
const form = (await guardedForm(ctx))!;
|
||||||
if (!keto) return unavailable(ctx, ctx.t("admin.capability.keto"));
|
if (!keto) return unavailable(ctx, ctx.t("admin.capability.keto"));
|
||||||
const subject = userSubject(id);
|
const subject = userSubject(id);
|
||||||
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
|
const held = await heldPermissions(keto, subject);
|
||||||
|
const refuse = async (error: string, status: number): Promise<RouteResult> => {
|
||||||
|
const permissions = await userPermissionPicker(deps, id, error);
|
||||||
|
return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), identity, ...(permissions ? { permissions } : {}) }), status };
|
||||||
|
};
|
||||||
|
const diff = grantDiff(ctx.declaredPermissions, held, form.getAll(PERMISSIONS_FIELD));
|
||||||
|
if (isStale(form, ctx.declaredPermissions, held, diff)) return refuse(ctx.t("admin.grants.stale"), 409);
|
||||||
// Self-lockout guard, matching the self-deactivate/self-delete ones: revoking your own grants can
|
// Self-lockout guard, matching the self-deactivate/self-delete ones: revoking your own grants can
|
||||||
// remove the last `users:write` on the deployment, and the instant-revoke hook lands it on the very
|
// remove the last `users:write` on the deployment, and the instant-revoke hook lands it on the very
|
||||||
// next request — leaving a `curl` against Keto as the only way back in.
|
// next request — leaving a `curl` against Keto as the only way back in.
|
||||||
if (id === user.id && diff.revoke.length > 0) {
|
if (id === user.id && diff.revoke.length > 0) {
|
||||||
ctx.log.warn("admin: refused a self-revoke of permissions", { actor: user.id, refused: diff.revoke.join(",") });
|
ctx.log.warn("admin: refused a self-revoke of permissions", { actor: user.id, refused: diff.revoke.join(",") });
|
||||||
const permissions = await userPermissionPicker(deps, id, ctx.t("admin.grants.selfRevoke"));
|
return refuse(ctx.t("admin.grants.selfRevoke"), 400);
|
||||||
return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), identity, ...(permissions ? { permissions } : {}) }), status: 400 };
|
|
||||||
}
|
}
|
||||||
await applyGrants(keto, subject, diff);
|
const { applied, error } = await applyGrants(keto, subject, diff);
|
||||||
if (diff.grant.length > 0 || diff.revoke.length > 0) {
|
if (applied.grant.length > 0 || applied.revoke.length > 0) {
|
||||||
revoke?.(id);
|
revoke?.(id);
|
||||||
ctx.log.info("admin: user permissions changed", { actor: user.id, granted: diff.grant.join(","), revoked: diff.revoke.join(","), target: id });
|
ctx.log.info("admin: user permissions changed", { actor: user.id, granted: applied.grant.join(","), revoked: applied.revoke.join(","), target: id });
|
||||||
}
|
}
|
||||||
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
if (error) {
|
||||||
|
ctx.log.error("admin: user permissions only partly saved", { actor: user.id, error: String(error), target: id });
|
||||||
|
return partialSave(ctx, () => refuse(ctx.t("admin.grants.partial"), 502));
|
||||||
|
}
|
||||||
|
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}?info-msg=${SAVED_NOTICE}` };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
|
// POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
|
||||||
|
|||||||
@@ -53,9 +53,12 @@ const messages = {
|
|||||||
"admin.grants.inherited": "Greyed-out permissions come from a group. Change them on that group.",
|
"admin.grants.inherited": "Greyed-out permissions come from a group. Change them on that group.",
|
||||||
"admin.grants.legend": "Permissions",
|
"admin.grants.legend": "Permissions",
|
||||||
"admin.grants.none": "No installed plugin declares a permission, so there is nothing to grant.",
|
"admin.grants.none": "No installed plugin declares a permission, so there is nothing to grant.",
|
||||||
|
"admin.grants.partial": "Saving stopped part-way because the permission service failed, so only some changes were applied. Check the permissions and make any remaining changes again.",
|
||||||
"admin.grants.pending": "Members get this at their next sign-in (up to 10 minutes).",
|
"admin.grants.pending": "Members get this at their next sign-in (up to 10 minutes).",
|
||||||
"admin.grants.save": "Save permissions",
|
"admin.grants.save": "Save permissions",
|
||||||
|
"admin.grants.saved": "Permissions saved.",
|
||||||
"admin.grants.selfRevoke": "You can't revoke your own permissions — ask another administrator, so you can't lock yourself out.",
|
"admin.grants.selfRevoke": "You can't revoke your own permissions — ask another administrator, so you can't lock yourself out.",
|
||||||
|
"admin.grants.stale": "These permissions changed while you had the page open, so nothing was saved. The list now shows the current state. Review it and save again.",
|
||||||
|
|
||||||
"admin.groups.actions": "Group actions",
|
"admin.groups.actions": "Group actions",
|
||||||
"admin.groups.addMember": "Add a member",
|
"admin.groups.addMember": "Add a member",
|
||||||
@@ -65,6 +68,7 @@ const messages = {
|
|||||||
"admin.groups.create": "Create group",
|
"admin.groups.create": "Create group",
|
||||||
"admin.groups.delete": "Delete group",
|
"admin.groups.delete": "Delete group",
|
||||||
"admin.groups.deleteMessage": "Delete group {{name}}? This can't be undone.",
|
"admin.groups.deleteMessage": "Delete group {{name}}? This can't be undone.",
|
||||||
|
"admin.groups.error.selfLockout": "This change would lock you out of {{permissions}}, which you hold through this group. Ask another administrator to make it.",
|
||||||
"admin.groups.field.name": "Group name",
|
"admin.groups.field.name": "Group name",
|
||||||
"admin.groups.field.nameHint": "Lowercase letters, digits, dashes and underscores.",
|
"admin.groups.field.nameHint": "Lowercase letters, digits, dashes and underscores.",
|
||||||
"admin.groups.filter": "Filter groups",
|
"admin.groups.filter": "Filter groups",
|
||||||
@@ -137,8 +141,8 @@ const messages = {
|
|||||||
"admin.users.new": "New user",
|
"admin.users.new": "New user",
|
||||||
"admin.users.pagination": "Users pagination",
|
"admin.users.pagination": "Users pagination",
|
||||||
"admin.users.reactivate": "Reactivate",
|
"admin.users.reactivate": "Reactivate",
|
||||||
"admin.users.recovery.body": "Give it to the user — they enter it to set a new password (generate a fresh one if it has expired):",
|
"admin.users.recovery.body": "Send the user this link and code. They open the link and enter the code to set a new password (generate a fresh code if it has expired):",
|
||||||
"admin.users.recovery.link": "the password-reset screen",
|
"admin.users.recovery.link": "password-reset link",
|
||||||
"admin.users.recovery.generate": "Generate recovery code",
|
"admin.users.recovery.generate": "Generate recovery code",
|
||||||
"admin.users.recovery.title": "Recovery code generated",
|
"admin.users.recovery.title": "Recovery code generated",
|
||||||
"admin.users.save": "Save changes",
|
"admin.users.save": "Save changes",
|
||||||
@@ -149,6 +153,8 @@ const messages = {
|
|||||||
"admin.users.status.inactive": "Inactive",
|
"admin.users.status.inactive": "Inactive",
|
||||||
"admin.users.status.label": "Status",
|
"admin.users.status.label": "Status",
|
||||||
"admin.users.title": "Users",
|
"admin.users.title": "Users",
|
||||||
|
"admin.users.view": "View",
|
||||||
|
"admin.users.viewTitle": "View user",
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminMessages = typeof messages;
|
export type AdminMessages = typeof messages;
|
||||||
|
|||||||
@@ -53,9 +53,12 @@ const messages: AdminMessages = {
|
|||||||
"admin.grants.inherited": "Gråmarkerade behörigheter kommer från en grupp. Ändra dem på gruppen.",
|
"admin.grants.inherited": "Gråmarkerade behörigheter kommer från en grupp. Ändra dem på gruppen.",
|
||||||
"admin.grants.legend": "Behörigheter",
|
"admin.grants.legend": "Behörigheter",
|
||||||
"admin.grants.none": "Ingen installerad plugin deklarerar någon behörighet, så det finns inget att tilldela.",
|
"admin.grants.none": "Ingen installerad plugin deklarerar någon behörighet, så det finns inget att tilldela.",
|
||||||
|
"admin.grants.partial": "Sparandet avbröts halvvägs eftersom behörighetstjänsten gav fel, så bara en del av ändringarna genomfördes. Kontrollera behörigheterna och gör de återstående ändringarna igen.",
|
||||||
"admin.grants.pending": "Medlemmar får detta vid nästa inloggning (upp till 10 minuter).",
|
"admin.grants.pending": "Medlemmar får detta vid nästa inloggning (upp till 10 minuter).",
|
||||||
"admin.grants.save": "Spara behörigheter",
|
"admin.grants.save": "Spara behörigheter",
|
||||||
|
"admin.grants.saved": "Behörigheterna är sparade.",
|
||||||
"admin.grants.selfRevoke": "Du kan inte återkalla dina egna behörigheter — be en annan administratör, så att du inte låser ute dig själv.",
|
"admin.grants.selfRevoke": "Du kan inte återkalla dina egna behörigheter — be en annan administratör, så att du inte låser ute dig själv.",
|
||||||
|
"admin.grants.stale": "Behörigheterna ändrades medan du hade sidan öppen, så ingenting sparades. Listan visar nu aktuellt läge. Granska den och spara igen.",
|
||||||
|
|
||||||
"admin.groups.actions": "Gruppåtgärder",
|
"admin.groups.actions": "Gruppåtgärder",
|
||||||
"admin.groups.addMember": "Lägg till en medlem",
|
"admin.groups.addMember": "Lägg till en medlem",
|
||||||
@@ -65,6 +68,7 @@ const messages: AdminMessages = {
|
|||||||
"admin.groups.create": "Skapa grupp",
|
"admin.groups.create": "Skapa grupp",
|
||||||
"admin.groups.delete": "Radera grupp",
|
"admin.groups.delete": "Radera grupp",
|
||||||
"admin.groups.deleteMessage": "Ta bort gruppen {{name}}? Det går inte att ångra.",
|
"admin.groups.deleteMessage": "Ta bort gruppen {{name}}? Det går inte att ångra.",
|
||||||
|
"admin.groups.error.selfLockout": "Ändringen skulle låsa ute dig från {{permissions}}, som du har genom den här gruppen. Be en annan administratör att göra den.",
|
||||||
"admin.groups.field.name": "Gruppnamn",
|
"admin.groups.field.name": "Gruppnamn",
|
||||||
"admin.groups.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.",
|
"admin.groups.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.",
|
||||||
"admin.groups.filter": "Filtrera grupper",
|
"admin.groups.filter": "Filtrera grupper",
|
||||||
@@ -137,8 +141,8 @@ const messages: AdminMessages = {
|
|||||||
"admin.users.new": "Ny användare",
|
"admin.users.new": "Ny användare",
|
||||||
"admin.users.pagination": "Sidnavigering för användare",
|
"admin.users.pagination": "Sidnavigering för användare",
|
||||||
"admin.users.reactivate": "Aktivera igen",
|
"admin.users.reactivate": "Aktivera igen",
|
||||||
"admin.users.recovery.body": "Ge den till användaren — koden anges för att sätta ett nytt lösenord (skapa en ny om den hunnit gå ut):",
|
"admin.users.recovery.body": "Skicka länken och koden till användaren. Användaren öppnar länken och anger koden för att sätta ett nytt lösenord (skapa en ny kod om den hunnit gå ut):",
|
||||||
"admin.users.recovery.link": "sidan för lösenordsåterställning",
|
"admin.users.recovery.link": "länk för lösenordsåterställning",
|
||||||
"admin.users.recovery.generate": "Skapa återställningskod",
|
"admin.users.recovery.generate": "Skapa återställningskod",
|
||||||
"admin.users.recovery.title": "Återställningskod skapad",
|
"admin.users.recovery.title": "Återställningskod skapad",
|
||||||
"admin.users.save": "Spara ändringar",
|
"admin.users.save": "Spara ändringar",
|
||||||
@@ -149,6 +153,8 @@ const messages: AdminMessages = {
|
|||||||
"admin.users.status.inactive": "Inaktiv",
|
"admin.users.status.inactive": "Inaktiv",
|
||||||
"admin.users.status.label": "Status",
|
"admin.users.status.label": "Status",
|
||||||
"admin.users.title": "Användare",
|
"admin.users.title": "Användare",
|
||||||
|
"admin.users.view": "Visa",
|
||||||
|
"admin.users.viewTitle": "Visa användare",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default messages;
|
export default messages;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const clients = on("oauth2-clients");
|
|||||||
const pluginSettings = on("plugin-settings");
|
const pluginSettings = on("plugin-settings");
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.2.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||||
|
|
||||||
nav: [ADMIN_NAV],
|
nav: [ADMIN_NAV],
|
||||||
|
|
||||||
|
|||||||
@@ -7,13 +7,16 @@
|
|||||||
there) and every row when `readOnly` (the viewer holds :read but not :write). Neither can be diffed
|
there) and every row when `readOnly` (the viewer holds :read but not :write). Neither can be diffed
|
||||||
into an accidental revoke, because grantDiff compares against the direct grants only.
|
into an accidental revoke, because grantDiff compares against the direct grants only.
|
||||||
|
|
||||||
Locals: csrfToken, permissions ({ action, choices, empty, error, field, hint, inheritedNote, legend, pending, readOnly, submit }).
|
Locals: csrfToken, permissions ({ action, baseline, choices, empty, error, field, hint, inheritedNote, legend, notice, pending, readOnly, submit }).
|
||||||
%>
|
%>
|
||||||
<section class="form-card" aria-labelledby="permissions-h">
|
<section class="form-card" aria-labelledby="permissions-h">
|
||||||
<h2 class="card-title" id="permissions-h"><%= permissions.legend %></h2>
|
<h2 class="card-title" id="permissions-h"><%= permissions.legend %></h2>
|
||||||
<% if (permissions.error) { -%>
|
<% if (permissions.error) { -%>
|
||||||
<%- include("partials/alert", { text: permissions.error, tone: "neg" }) %>
|
<%- include("partials/alert", { text: permissions.error, tone: "neg" }) %>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
|
<% if (permissions.notice) { -%>
|
||||||
|
<%- include("partials/alert", { text: permissions.notice, tone: "pos" }) %>
|
||||||
|
<% } -%>
|
||||||
<% if (permissions.empty) { -%>
|
<% if (permissions.empty) { -%>
|
||||||
<p class="cell-muted"><%= permissions.empty %></p>
|
<p class="cell-muted"><%= permissions.empty %></p>
|
||||||
<% } else { -%>
|
<% } else { -%>
|
||||||
@@ -26,8 +29,10 @@
|
|||||||
<% }) -%>
|
<% }) -%>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
<% } else { -%>
|
<% } else { -%>
|
||||||
<form method="post" action="<%= localeHref(permissions.action) %>">
|
<%# The fragment brings the card, and the notice in it, into view on a long page. %>
|
||||||
|
<form method="post" action="<%= localeHref(permissions.action + '#permissions-h') %>">
|
||||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<input type="hidden" name="baseline" value="<%= permissions.baseline %>">
|
||||||
<fieldset class="check-group">
|
<fieldset class="check-group">
|
||||||
<legend class="sr-only"><%= permissions.legend %></legend>
|
<legend class="sr-only"><%= permissions.legend %></legend>
|
||||||
<% permissions.choices.forEach((c) => { -%>
|
<% permissions.choices.forEach((c) => { -%>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Admin user create/edit form body, captured into the shell content slot. Config:
|
Admin user create/edit form body, captured into the shell content slot. Config:
|
||||||
form { action, csrfToken, submitLabel, cancelHref, fields: field.ejs config[] }
|
form { action, csrfToken, submitLabel, cancelHref, fields: field.ejs config[] }
|
||||||
edit? { nextLabel, stateAction, recoveryAction, deleteAction } (edit mode only)
|
edit? { nextLabel, stateAction, recoveryAction, deleteAction } (edit mode only)
|
||||||
recovery? { code? } shown after a recovery code is generated (recovery is code-based)
|
recovery? { code, link } shown after a recovery code is generated; link is the URL of the flow the code was minted on, the only flow that accepts the code
|
||||||
error? string shown when a write was rejected
|
error? string shown when a write was rejected
|
||||||
%><%
|
%><%
|
||||||
const form = locals.form;
|
const form = locals.form;
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
<% if (recovery) { -%>
|
<% if (recovery) { -%>
|
||||||
<div class="alert alert-pos" role="status"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><div class="alert-body"><strong><%= t("admin.users.recovery.title") %></strong><span><%= t("admin.users.recovery.body") %> <a href="<%= localeHref("/recovery") %>"><%= t("admin.users.recovery.link") %></a></span><% if (recovery.code) { %><span class="recovery-code"><code><%= recovery.code %></code></span><% } %></div></div>
|
<div class="alert alert-pos" role="status"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><div class="alert-body"><strong><%= t("admin.users.recovery.title") %></strong><span><%= t("admin.users.recovery.body") %> <a href="<%= recovery.link %>"><%= t("admin.users.recovery.link") %></a></span><span class="recovery-code"><code><%= recovery.code %></code></span></div></div>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
|
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
|
||||||
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||||
|
|||||||
@@ -15,8 +15,12 @@ What it demonstrates:
|
|||||||
`POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream,
|
`POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream,
|
||||||
then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
|
then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
|
||||||
reusing the core `field` partial.
|
reusing the core `field` partial.
|
||||||
- **Permission-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` /
|
- **All three route gates** — the Overview is `public` (anyone), "My shifts" is `session` (any
|
||||||
`scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant.
|
signed-in visitor, showing only rows assigned to them), and "Shifts" is gated on `scheduling:read` /
|
||||||
|
`scheduling:write`; a leaf whose gate a visitor fails is invisible in the menu.
|
||||||
|
- **Ownership joined on the identity id** — "My shifts" asks the upstream for `assigneeId=ctx.user.id`,
|
||||||
|
the opaque subject id, and renders the row's separate `assignee` display name. An email address is
|
||||||
|
user-changeable and can be reassigned to someone else, who would then inherit those rows.
|
||||||
- **Its own translations** — every string comes from `i18n/en-US.ts` (`sv-SE.ts` beside it), including
|
- **Its own translations** — every string comes from `i18n/en-US.ts` (`sv-SE.ts` beside it), including
|
||||||
the nav labels, which are catalog keys in the manifest. `shifts.count` shows a plural message, and
|
the nav labels, which are catalog keys in the manifest. `shifts.count` shows a plural message, and
|
||||||
the views carry the visitor's language onto their links with `localeHref()`.
|
the views carry the visitor's language onto their links with `localeHref()`.
|
||||||
@@ -38,9 +42,14 @@ Your backend must expose two routes; the plugin treats any non-2xx as a recovera
|
|||||||
|
|
||||||
| Route | Request | Success | Response body |
|
| Route | Request | Success | Response body |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `GET /shifts` | `Accept: application/json` | `200` | JSON array of `{ id, title, assignee, start, end }` (all strings; missing fields coerce to `""`) |
|
| `GET /shifts` | `Accept: application/json`, optional `?assigneeId=<id>` | `200` | JSON array of `{ id, title, assignee, assigneeId, start, end }` (all strings; missing fields coerce to `""`). With `assigneeId`, only that person's rows |
|
||||||
| `POST /shifts` | JSON body `{ title, assignee, start, end }` | `2xx` | ignored (the plugin POST-redirect-GETs back to the list) |
|
| `POST /shifts` | JSON body `{ title, assignee, start, end }` | `2xx` | ignored (the plugin POST-redirect-GETs back to the list) |
|
||||||
|
|
||||||
|
`POST /shifts` carries the assignee as a **display name only**, so a shift created through this
|
||||||
|
plugin's form belongs to nobody and surfaces on no one's "My shifts" — don't go hunting for it
|
||||||
|
there. Resolving a name to an identity id needs a directory this demo has none of; a real backend
|
||||||
|
does that join at create time and stores the `assigneeId` alongside the name.
|
||||||
|
|
||||||
Domain rules (overlap, capacity, time ordering) live in your backend — reject with a 4xx and the
|
Domain rules (overlap, capacity, time ordering) live in your backend — reject with a 4xx and the
|
||||||
form re-renders. The plugin only validates that `title` and `assignee` are non-empty.
|
form re-renders. The plugin only validates that `title` and `assignee` are non-empty.
|
||||||
|
|
||||||
@@ -50,6 +59,8 @@ 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` permission in Keto (and `scheduling:write`
|
A user sees the shift list once they hold the `scheduling:read` permission in Keto (and
|
||||||
to create). The one-command bootstrap grants both to the demo admin, so the seeded
|
`scheduling:write` 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. "My shifts" needs no grant at all — signing in is
|
||||||
|
its whole gate; it lists the rows this demo upstream holds against the signed-in visitor's id, and
|
||||||
|
the demo's seeded rows belong to three made-up people, so a freshly seeded admin sees it empty.
|
||||||
|
|||||||
@@ -13,12 +13,16 @@ const messages = {
|
|||||||
"scheduling.filter.searchLabel": "Search shifts",
|
"scheduling.filter.searchLabel": "Search shifts",
|
||||||
"scheduling.filter.searchPlaceholder": "Search title or assignee…",
|
"scheduling.filter.searchPlaceholder": "Search title or assignee…",
|
||||||
"scheduling.form.submit": "Create shift",
|
"scheduling.form.submit": "Create shift",
|
||||||
|
"scheduling.mine.empty": "No shifts are assigned to {{email}}.",
|
||||||
|
"scheduling.mine.title": "My shifts",
|
||||||
|
"scheduling.nav.mine": "My shifts",
|
||||||
"scheduling.nav.overview": "Overview",
|
"scheduling.nav.overview": "Overview",
|
||||||
"scheduling.nav.section": "Scheduling",
|
"scheduling.nav.section": "Scheduling",
|
||||||
"scheduling.nav.shifts": "Shifts",
|
"scheduling.nav.shifts": "Shifts",
|
||||||
"scheduling.new.title": "New shift",
|
"scheduling.new.title": "New shift",
|
||||||
"scheduling.overview.lead":
|
"scheduling.overview.lead":
|
||||||
"Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.",
|
"Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.",
|
||||||
|
"scheduling.overview.mine": "See my shifts",
|
||||||
"scheduling.overview.signIn": "Sign in to view shifts",
|
"scheduling.overview.signIn": "Sign in to view shifts",
|
||||||
"scheduling.overview.title": "Scheduling",
|
"scheduling.overview.title": "Scheduling",
|
||||||
"scheduling.overview.view": "View shifts",
|
"scheduling.overview.view": "View shifts",
|
||||||
|
|||||||
@@ -9,12 +9,16 @@ const messages: SchedulingMessages = {
|
|||||||
"scheduling.filter.searchLabel": "Sök pass",
|
"scheduling.filter.searchLabel": "Sök pass",
|
||||||
"scheduling.filter.searchPlaceholder": "Sök på namn eller person…",
|
"scheduling.filter.searchPlaceholder": "Sök på namn eller person…",
|
||||||
"scheduling.form.submit": "Skapa pass",
|
"scheduling.form.submit": "Skapa pass",
|
||||||
|
"scheduling.mine.empty": "Inga pass är tilldelade {{email}}.",
|
||||||
|
"scheduling.mine.title": "Mina pass",
|
||||||
|
"scheduling.nav.mine": "Mina pass",
|
||||||
"scheduling.nav.overview": "Översikt",
|
"scheduling.nav.overview": "Översikt",
|
||||||
"scheduling.nav.section": "Schemaläggning",
|
"scheduling.nav.section": "Schemaläggning",
|
||||||
"scheduling.nav.shifts": "Pass",
|
"scheduling.nav.shifts": "Pass",
|
||||||
"scheduling.new.title": "Nytt pass",
|
"scheduling.new.title": "Nytt pass",
|
||||||
"scheduling.overview.lead":
|
"scheduling.overview.lead":
|
||||||
"Schemaläggningen samordnar teamets pass. Alla kan läsa den här översikten; själva passlistan kräver behörigheten <code>scheduling:read</code>.",
|
"Schemaläggningen samordnar teamets pass. Alla kan läsa den här översikten; själva passlistan kräver behörigheten <code>scheduling:read</code>.",
|
||||||
|
"scheduling.overview.mine": "Visa mina pass",
|
||||||
"scheduling.overview.signIn": "Logga in för att se passen",
|
"scheduling.overview.signIn": "Logga in för att se passen",
|
||||||
"scheduling.overview.title": "Schemaläggning",
|
"scheduling.overview.title": "Schemaläggning",
|
||||||
"scheduling.overview.view": "Visa pass",
|
"scheduling.overview.view": "Visa pass",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
|
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
|
||||||
|
|
||||||
import { definePlugin } from "@plainpages/plugin-api";
|
import { definePlugin } from "@plainpages/plugin-api";
|
||||||
import { createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
|
import { createShift, createUpstream, listShifts, MINE_PATH, myShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
|
||||||
|
|
||||||
// The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is
|
// The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is
|
||||||
// stateless). Its URL is a declared setting, so it is resolved and validated before onBoot hands it
|
// stateless). Its URL is a declared setting, so it is resolved and validated before onBoot hands it
|
||||||
@@ -12,7 +12,7 @@ let upstreamUrl = "";
|
|||||||
const upstream = createUpstream(() => upstreamUrl);
|
const upstream = createUpstream(() => upstreamUrl);
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.2.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||||
|
|
||||||
// onBoot runs after discovery, before the server listens — where a plugin receives its resolved
|
// onBoot runs after discovery, before the server listens — where a plugin receives its resolved
|
||||||
// settings. A malformed URL already failed the boot by then; the host validated the declared type.
|
// settings. A malformed URL already failed the boot by then; the host validated the declared type.
|
||||||
@@ -25,11 +25,13 @@ export default definePlugin({
|
|||||||
nav: [{
|
nav: [{
|
||||||
children: [
|
children: [
|
||||||
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "scheduling.nav.overview", public: true },
|
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "scheduling.nav.overview", public: true },
|
||||||
|
{ href: MINE_PATH, id: "scheduling:mine", label: "scheduling.nav.mine", session: true },
|
||||||
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "scheduling.nav.shifts", permission: READ },
|
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "scheduling.nav.shifts", permission: READ },
|
||||||
],
|
],
|
||||||
icon: "i-cal",
|
icon: "i-cal",
|
||||||
id: "scheduling",
|
id: "scheduling",
|
||||||
label: "scheduling.nav.section",
|
label: "scheduling.nav.section",
|
||||||
|
public: true, // the header gates nothing; each child names its own gate, and an empty header is dropped
|
||||||
}],
|
}],
|
||||||
|
|
||||||
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
|
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
|
||||||
@@ -38,10 +40,9 @@ export default definePlugin({
|
|||||||
{ description: "Create and edit shifts", name: WRITE },
|
{ description: "Create and edit shifts", name: WRITE },
|
||||||
],
|
],
|
||||||
|
|
||||||
// Mounted under /scheduling; `permission` gates before the handler runs. The overview is `public`
|
|
||||||
// (anyone may reach /scheduling, signed in or not); the rest need a permission.
|
|
||||||
routes: [
|
routes: [
|
||||||
{ handler: overview(), method: "GET", path: "/", public: true },
|
{ handler: overview(), method: "GET", path: "/", public: true },
|
||||||
|
{ handler: myShifts(upstream), method: "GET", path: "/mine", session: true },
|
||||||
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
|
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
|
||||||
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
|
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
|
||||||
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },
|
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },
|
||||||
|
|||||||
@@ -4,3 +4,9 @@
|
|||||||
.scheduling-page .table-wrap {
|
.scheduling-page .table-wrap {
|
||||||
margin-top: var(--space-3, 0.75rem);
|
margin-top: var(--space-3, 0.75rem);
|
||||||
}
|
}
|
||||||
|
/* Inset what sits directly in the pane by --pad-x, as the filter bar, table and pager already are. */
|
||||||
|
.scheduling-page > p,
|
||||||
|
.scheduling-page > .alert,
|
||||||
|
.scheduling-page > .btn {
|
||||||
|
margin-inline: var(--pad-x);
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,29 +4,29 @@ import { Readable } from "node:stream";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
// Import only from the @plainpages/plugin-api barrel — the same contract boundary shifts.ts uses (the host may
|
// Import only from the @plainpages/plugin-api barrel — the same contract boundary shifts.ts uses (the host may
|
||||||
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
|
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
|
||||||
import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "@plainpages/plugin-api";
|
import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult, type User } from "@plainpages/plugin-api";
|
||||||
import enUS from "./i18n/en-US.ts";
|
import enUS from "./i18n/en-US.ts";
|
||||||
import {
|
import {
|
||||||
buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
|
buildFormModel, createShift, createUpstream, listShifts, myShifts, newShiftForm, overview, readInput,
|
||||||
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
|
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
|
||||||
} from "./shifts.ts";
|
} from "./shifts.ts";
|
||||||
|
|
||||||
const t = englishTranslator(enUS); // this plugin's catalog then the host's, as the host would chain them
|
const t = englishTranslator(enUS); // this plugin's catalog then the host's, as the host would chain them
|
||||||
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
|
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
|
||||||
|
|
||||||
function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; user?: User; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||||
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
||||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||||
return {
|
return {
|
||||||
chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
|
chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
|
||||||
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
|
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
|
||||||
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const SHIFTS: Shift[] = [
|
const SHIFTS: Shift[] = [
|
||||||
{ assignee: "Avery Kline", end: "12:00", id: "1", start: "08:00", title: "Morning desk" },
|
{ assignee: "Avery Kline", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", end: "12:00", id: "1", start: "08:00", title: "Morning desk" },
|
||||||
{ assignee: "Blair Mora", end: "17:00", id: "2", start: "12:00", title: "Afternoon support" },
|
{ assignee: "Blair Mora", assigneeId: "019bdc1a-4a83-7de2-8f05-6c93a71be4d8", end: "17:00", id: "2", start: "12:00", title: "Afternoon support" },
|
||||||
];
|
];
|
||||||
const fakeUpstream = (over: Partial<ShiftsUpstream> = {}): ShiftsUpstream => ({ create: async () => {}, list: async () => SHIFTS, ...over });
|
const fakeUpstream = (over: Partial<ShiftsUpstream> = {}): ShiftsUpstream => ({ create: async () => {}, list: async () => SHIFTS, ...over });
|
||||||
|
|
||||||
@@ -63,11 +63,11 @@ test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", as
|
|||||||
const http = (async (url, init) => {
|
const http = (async (url, init) => {
|
||||||
seen = String(url);
|
seen = String(url);
|
||||||
assert.equal((init?.headers as Record<string, string>).accept, "application/json");
|
assert.equal((init?.headers as Record<string, string>).accept, "application/json");
|
||||||
return new Response(JSON.stringify([{ assignee: "A", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 });
|
return new Response(JSON.stringify([{ assignee: "A", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 });
|
||||||
}) as typeof fetch;
|
}) as typeof fetch;
|
||||||
const shifts = await createUpstream(() => "http://up:4000/", http).list(); // trailing slash trimmed
|
const shifts = await createUpstream(() => "http://up:4000/", http).list(); // trailing slash trimmed
|
||||||
assert.equal(seen, "http://up:4000/shifts");
|
assert.equal(seen, "http://up:4000/shifts");
|
||||||
assert.deepEqual(shifts, [{ assignee: "A", end: "2", id: "x", start: "1", title: "T" }]);
|
assert.deepEqual(shifts, [{ assignee: "A", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", end: "2", id: "x", start: "1", title: "T" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => {
|
test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => {
|
||||||
@@ -115,14 +115,21 @@ test("listShifts degrades to a recoverable error page when the upstream is down
|
|||||||
|
|
||||||
// ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ----
|
// ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ----
|
||||||
|
|
||||||
test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
|
test("overview renders a public page for anyone, and its CTA names the best gate the visitor passes", async () => {
|
||||||
const anon = asView(await overview()(fakeCtx())); // user null, no permissions
|
const anon = asView(await overview()(fakeCtx())); // user null, no permissions
|
||||||
assert.equal(anon.view, "overview");
|
assert.equal(anon.view, "overview");
|
||||||
assert.equal(anon.data["chrome"], CHROME);
|
assert.equal(anon.data["chrome"], CHROME);
|
||||||
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
|
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
|
||||||
|
assert.equal(anon.data["signedIn"], false);
|
||||||
|
|
||||||
const reader = asView(await overview()(fakeCtx({ permissions: ["scheduling:read"] })));
|
const reader = asView(await overview()(fakeCtx({ permissions: ["scheduling:read"] })));
|
||||||
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
|
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
|
||||||
|
|
||||||
|
// Signed in but ungranted: the page must not invite them to sign in again.
|
||||||
|
const member = asView(await overview()(fakeCtx({ user: { email: "m@example.test", id: "01a06091-baa3-7a1f-9c62-0e3ab6d2f5c1", permissions: [] } })));
|
||||||
|
assert.equal(member.data["canRead"], false);
|
||||||
|
assert.equal(member.data["signedIn"], true);
|
||||||
|
assert.equal(member.data["mineHref"], "/scheduling/mine");
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- create handler ----
|
// ---- create handler ----
|
||||||
@@ -171,3 +178,38 @@ test("buildFormModel marks title/assignee required and attaches field errors", (
|
|||||||
assert.equal(title.error, "needed");
|
assert.equal(title.error, "needed");
|
||||||
assert.equal(fields.find((f) => f.name === "start")!.required, undefined);
|
assert.equal(fields.find((f) => f.name === "start")!.required, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- the session-gated page: the visitor's own rows ----
|
||||||
|
|
||||||
|
test("my shifts scopes the upstream read by the visitor's id, and names them in the empty state", async () => {
|
||||||
|
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
|
||||||
|
const mine: Shift = { assignee: "Blair Mora", assigneeId: user.id, end: "22:00", id: "3", start: "17:00", title: "Evening on-call" };
|
||||||
|
let asked: { assigneeId?: string } | undefined;
|
||||||
|
|
||||||
|
const upstream = fakeUpstream({ list: async (opts) => { asked = opts; return [mine]; } });
|
||||||
|
const r = asView(await myShifts(upstream)(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
|
||||||
|
assert.equal(r.view, "mine");
|
||||||
|
assert.deepEqual(asked, { assigneeId: "01a06091-baa3-71f4-a068-4879972979ff" }); // the id, never the address
|
||||||
|
const table = r.data["table"] as { emptyText: string; rows: { name: string }[] };
|
||||||
|
assert.deepEqual(table.rows.map((row) => row.name), ["Evening on-call"]);
|
||||||
|
assert.match(table.emptyText, /Blair\.Mora@example\.test/); // an empty page still says whose it is
|
||||||
|
|
||||||
|
// `requireSession` narrows `ctx.user` from `User | null` to `User` — the one part of the route's
|
||||||
|
// `session: true` guarantee the contract cannot state in the handler's type.
|
||||||
|
await assert.rejects(async () => { await myShifts(fakeUpstream())(fakeCtx()); }, GuardError);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("my shifts degrades to the reason alone when the upstream is down, claiming nothing about what is assigned", async () => {
|
||||||
|
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
|
||||||
|
const down = fakeUpstream({ list: async () => { throw new UpstreamError("down", 503); } });
|
||||||
|
const r = asView(await myShifts(down)(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
|
||||||
|
assert.match(String(r.data["error"]), /scheduling service/i);
|
||||||
|
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []); // mine.ejs drops the count + table while `error` is set
|
||||||
|
});
|
||||||
|
|
||||||
|
test("my shifts drops a row the upstream returned that is not the visitor's", async () => {
|
||||||
|
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
|
||||||
|
const theirs: Shift = { assignee: "Avery Kline", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", end: "12:00", id: "9", start: "08:00", title: "Not mine" };
|
||||||
|
const r = asView(await myShifts(fakeUpstream({ list: async () => [theirs] }))(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
|
||||||
|
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []); // a backend ignoring the scope must not leak through this page
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// pure functions against a mock upstream with no network (README.md → Local dev & test story).
|
// pure functions against a mock upstream with no network (README.md → Local dev & test story).
|
||||||
|
|
||||||
// One import from the host's @plainpages/plugin-api barrel — the stable author surface (see README.md → Building plugins).
|
// One import from the host's @plainpages/plugin-api barrel — the stable author surface (see README.md → Building plugins).
|
||||||
import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, type Translate, tracedFetch } from "@plainpages/plugin-api";
|
import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, requireSession, type RouteHandler, type Translate, tracedFetch } from "@plainpages/plugin-api";
|
||||||
import enUS from "./i18n/en-US.ts";
|
import enUS from "./i18n/en-US.ts";
|
||||||
|
|
||||||
// The plugin's own English (its catalog, then the host's), for a view model built outside a request:
|
// The plugin's own English (its catalog, then the host's), for a view model built outside a request:
|
||||||
@@ -16,12 +16,14 @@ const EN: Translate = englishTranslator(enUS);
|
|||||||
|
|
||||||
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
|
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
|
||||||
export const SHIFTS_PATH = "/scheduling/shifts";
|
export const SHIFTS_PATH = "/scheduling/shifts";
|
||||||
|
export const MINE_PATH = "/scheduling/mine"; // the visitor's own shifts — a session is the whole gate
|
||||||
export const READ = "scheduling:read"; // the permission gating the list + nav
|
export const READ = "scheduling:read"; // the permission gating the list + nav
|
||||||
export const WRITE = "scheduling:write"; // the permission gating create
|
export const WRITE = "scheduling:write"; // the permission gating create
|
||||||
|
|
||||||
export interface Shift {
|
export interface Shift {
|
||||||
id: string;
|
id: string;
|
||||||
assignee: string;
|
assignee: string; // display name, rendered in the table
|
||||||
|
assigneeId: string; // who the shift belongs to — an opaque id, the same one `ctx.user.id` carries
|
||||||
end: string;
|
end: string;
|
||||||
start: string;
|
start: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -46,7 +48,9 @@ export class UpstreamError extends Error {
|
|||||||
|
|
||||||
export interface ShiftsUpstream {
|
export interface ShiftsUpstream {
|
||||||
create(input: ShiftInput): Promise<void>;
|
create(input: ShiftInput): Promise<void>;
|
||||||
list(): Promise<Shift[]>;
|
// `assigneeId` scopes the read at the source, which is where an ownership rule belongs (README →
|
||||||
|
// Three tiers of "may I?"); without it the caller would hold everyone's rows to render one page.
|
||||||
|
list(opts?: { assigneeId?: string }): Promise<Shift[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// REST client over the upstream service (a stand-in for the customer's real backend). `fetch`
|
// REST client over the upstream service (a stand-in for the customer's real backend). `fetch`
|
||||||
@@ -65,8 +69,9 @@ export function createUpstream(baseUrl: () => string, fetchImpl: typeof fetch =
|
|||||||
});
|
});
|
||||||
if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status);
|
if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status);
|
||||||
},
|
},
|
||||||
async list() {
|
async list(opts = {}) {
|
||||||
const res = await fetchImpl(`${base()}/shifts`, { headers: { accept: "application/json" } });
|
const query = opts.assigneeId == null ? "" : `?${new URLSearchParams({ assigneeId: opts.assigneeId })}`;
|
||||||
|
const res = await fetchImpl(`${base()}/shifts${query}`, { headers: { accept: "application/json" } });
|
||||||
if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status);
|
if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status);
|
||||||
const data: unknown = await res.json();
|
const data: unknown = await res.json();
|
||||||
return Array.isArray(data) ? data.map(toShift) : [];
|
return Array.isArray(data) ? data.map(toShift) : [];
|
||||||
@@ -78,7 +83,7 @@ const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? ""
|
|||||||
|
|
||||||
function toShift(raw: unknown): Shift {
|
function toShift(raw: unknown): Shift {
|
||||||
const r = (raw ?? {}) as Record<string, unknown>;
|
const r = (raw ?? {}) as Record<string, unknown>;
|
||||||
return { assignee: str(r["assignee"]), end: str(r["end"]), id: str(r["id"]), start: str(r["start"]), title: str(r["title"]) };
|
return { assignee: str(r["assignee"]), assigneeId: str(r["assigneeId"]), end: str(r["end"]), id: str(r["id"]), start: str(r["start"]), title: str(r["title"]) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- view models (pure; the EJS views read these) -----------------------------------
|
// ---- view models (pure; the EJS views read these) -----------------------------------
|
||||||
@@ -186,6 +191,41 @@ export function newShiftForm(): RouteHandler {
|
|||||||
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome, t: ctx.t }), view: "shift-new" });
|
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome, t: ctx.t }), view: "shift-new" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function myShifts(upstream: ShiftsUpstream): RouteHandler {
|
||||||
|
return async (ctx) => {
|
||||||
|
const user = requireSession(ctx);
|
||||||
|
let shifts: Shift[] = [];
|
||||||
|
let error: string | undefined;
|
||||||
|
try {
|
||||||
|
// Join on the id, never the email: an address is user-changeable and can be reassigned to
|
||||||
|
// someone else, which would hand them the previous holder's rows. The re-filter is
|
||||||
|
// defence-in-depth: a backend that ignores an unknown query param would answer with everyone.
|
||||||
|
shifts = (await upstream.list({ assigneeId: user.id })).filter((s) => s.assigneeId === user.id);
|
||||||
|
} catch (err) {
|
||||||
|
ctx.log.warn("scheduling upstream unreachable", { error: String(err) });
|
||||||
|
error = ctx.t("scheduling.upstream.list");
|
||||||
|
}
|
||||||
|
return { data: buildMineModel({ chrome: ctx.chrome, email: user.email, ...(error ? { error } : {}), shifts, t: ctx.t }), view: "mine" };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMineModel(opts: { chrome: PageChrome; email: string; error?: string; shifts: Shift[]; t?: Translate }) {
|
||||||
|
const t = opts.t ?? EN;
|
||||||
|
return {
|
||||||
|
breadcrumbs: [{ label: t("scheduling.mine.title") }],
|
||||||
|
chrome: opts.chrome,
|
||||||
|
count: t("scheduling.shifts.count", { count: opts.shifts.length }),
|
||||||
|
...(opts.error ? { error: opts.error } : {}),
|
||||||
|
table: {
|
||||||
|
caption: t("scheduling.mine.title"),
|
||||||
|
columns: [{ label: t("scheduling.table.shift") }, { label: t("scheduling.table.start") }, { label: t("scheduling.table.end") }],
|
||||||
|
emptyText: t("scheduling.mine.empty", { email: opts.email }),
|
||||||
|
rows: opts.shifts.map((s) => ({ cells: [{ rowHeader: { text: s.title } }, s.start, s.end], name: s.title })),
|
||||||
|
},
|
||||||
|
title: t("scheduling.mine.title"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
|
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
|
||||||
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data
|
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data
|
||||||
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
|
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
|
||||||
@@ -196,7 +236,9 @@ export function overview(): RouteHandler {
|
|||||||
breadcrumbs: [{ label: ctx.t("scheduling.nav.overview") }],
|
breadcrumbs: [{ label: ctx.t("scheduling.nav.overview") }],
|
||||||
canRead: can(ctx, READ),
|
canRead: can(ctx, READ),
|
||||||
chrome: ctx.chrome,
|
chrome: ctx.chrome,
|
||||||
|
mineHref: ctx.localeHref(MINE_PATH),
|
||||||
shiftsHref: ctx.localeHref(SHIFTS_PATH), // a plugin carries the visitor's locale onto its own links
|
shiftsHref: ctx.localeHref(SHIFTS_PATH), // a plugin carries the visitor's locale onto its own links
|
||||||
|
signedIn: ctx.user !== null,
|
||||||
signInHref: ctx.localeHref(`/login?return_to=${encodeURIComponent(ctx.localeHref(SHIFTS_PATH))}`),
|
signInHref: ctx.localeHref(`/login?return_to=${encodeURIComponent(ctx.localeHref(SHIFTS_PATH))}`),
|
||||||
title: ctx.t("scheduling.overview.title"),
|
title: ctx.t("scheduling.overview.title"),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<%#
|
||||||
|
Scheduling · the visitor's own shifts (reference plugin).
|
||||||
|
Data: chrome, title, breadcrumbs, count, table, error?
|
||||||
|
%><%
|
||||||
|
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
|
const tableHtml = include("partials/data-table", table);
|
||||||
|
const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : "";
|
||||||
|
-%>
|
||||||
|
<%- include("partials/shell", {
|
||||||
|
body: '<div class="scheduling-page">' + alertHtml + (locals.error ? '' : '<p class="shift-count">' + count + '</p>' + tableHtml) + '</div>',
|
||||||
|
brand: chrome.brand,
|
||||||
|
breadcrumbs,
|
||||||
|
csrfToken: chrome.csrfToken,
|
||||||
|
nav: navHtml,
|
||||||
|
styles: ["/public/scheduling/scheduling.css"],
|
||||||
|
theme: chrome.theme,
|
||||||
|
title,
|
||||||
|
user: chrome.user,
|
||||||
|
}) %>
|
||||||
@@ -3,11 +3,15 @@
|
|||||||
nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for
|
nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for
|
||||||
everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to
|
everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to
|
||||||
it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome.
|
it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome.
|
||||||
Data: chrome, title, breadcrumbs, canRead, shiftsHref, signInHref
|
Data: chrome, title, breadcrumbs, canRead, mineHref, shiftsHref, signedIn, signInHref
|
||||||
%><%
|
%><%
|
||||||
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
|
// One CTA per gate the visitor passes: the list needs the permission, "My shifts" only a session,
|
||||||
|
// and sign-in is offered to nobody who already has one.
|
||||||
const cta = canRead
|
const cta = canRead
|
||||||
? '<a class="btn btn-primary" href="' + shiftsHref + '">' + t("scheduling.overview.view") + '</a>'
|
? '<a class="btn btn-primary" href="' + shiftsHref + '">' + t("scheduling.overview.view") + '</a>'
|
||||||
|
: signedIn
|
||||||
|
? '<a class="btn btn-primary" href="' + mineHref + '">' + t("scheduling.overview.mine") + '</a>'
|
||||||
: '<a class="btn btn-primary" href="' + signInHref + '">' + t("scheduling.overview.signIn") + '</a>';
|
: '<a class="btn btn-primary" href="' + signInHref + '">' + t("scheduling.overview.signIn") + '</a>';
|
||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Scheduling · Shifts list (reference plugin). The handler fetched the rows from the upstream
|
Scheduling · Shifts list (reference plugin). The handler fetched the rows from the upstream
|
||||||
service; this view renders them with the core building blocks inside the native app shell
|
service; this view renders them with the core building blocks inside the native app shell
|
||||||
(ctx.chrome). `include()` reaches the core partials (shell, nav-tree, filter-bar, data-table,
|
(ctx.chrome). `include()` reaches the core partials (shell, nav-tree, filter-bar, data-table,
|
||||||
alert) — see docs/plugin-contract.md. Zero-JS: search round-trips the URL.
|
alert) — see the README. Zero-JS: search round-trips the URL.
|
||||||
Data: chrome, title, breadcrumbs, count, filterBar, table, canWrite, newHref, error?
|
Data: chrome, title, breadcrumbs, count, filterBar, table, canWrite, newHref, error?
|
||||||
%><%
|
%><%
|
||||||
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// of the app: stdlib only, in-memory (state resets on restart), no auth. Point PLUGIN_SETTING_SCHEDULING_UPSTREAM
|
// of the app: stdlib only, in-memory (state resets on restart), no auth. Point PLUGIN_SETTING_SCHEDULING_UPSTREAM
|
||||||
// at your real service in production.
|
// at your real service in production.
|
||||||
//
|
//
|
||||||
// GET /shifts → 200 [ { id, title, assignee, start, end }, … ]
|
// GET /shifts → 200 [ { id, title, assigneeId, assignee, start, end }, … ] (?assigneeId=<id> → only theirs)
|
||||||
// POST /shifts → 201 { id, … } (body: { title, assignee, start, end })
|
// POST /shifts → 201 { id, … } (body: { title, assignee, start, end })
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
@@ -11,10 +11,12 @@ import { createServer } from "node:http";
|
|||||||
|
|
||||||
const PORT = Number(process.env.PORT ?? 4000);
|
const PORT = Number(process.env.PORT ?? 4000);
|
||||||
|
|
||||||
|
// `assigneeId` is the identity the rows are owned by — an opaque, stable subject id, which is what
|
||||||
|
// `ctx.user.id` carries. These are this demo's own people; a real backend joins on your IdP's ids.
|
||||||
const shifts = [
|
const shifts = [
|
||||||
{ id: randomUUID(), title: "Morning — Front desk", assignee: "Avery Kline", start: "2026-06-22 08:00", end: "2026-06-22 12:00" },
|
{ id: randomUUID(), title: "Morning — Front desk", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", assignee: "Avery Kline", start: "2026-06-22 08:00", end: "2026-06-22 12:00" },
|
||||||
{ id: randomUUID(), title: "Afternoon — Support", assignee: "Blair Mora", start: "2026-06-22 12:00", end: "2026-06-22 17:00" },
|
{ id: randomUUID(), title: "Afternoon — Support", assigneeId: "019bdc1a-4a83-7de2-8f05-6c93a71be4d8", assignee: "Blair Mora", start: "2026-06-22 12:00", end: "2026-06-22 17:00" },
|
||||||
{ id: randomUUID(), title: "Evening — On-call", assignee: "Casey Nguyen", start: "2026-06-22 17:00", end: "2026-06-22 22:00" },
|
{ id: randomUUID(), title: "Evening — On-call", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", assignee: "Casey Nguyen", start: "2026-06-22 17:00", end: "2026-06-22 22:00" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const json = (res, status, body) => {
|
const json = (res, status, body) => {
|
||||||
@@ -33,10 +35,14 @@ const readBody = (req) =>
|
|||||||
|
|
||||||
createServer(async (req, res) => {
|
createServer(async (req, res) => {
|
||||||
const url = new URL(req.url ?? "/", "http://localhost");
|
const url = new URL(req.url ?? "/", "http://localhost");
|
||||||
if (url.pathname === "/shifts" && req.method === "GET") return json(res, 200, shifts);
|
if (url.pathname === "/shifts" && req.method === "GET") {
|
||||||
|
const assigneeId = url.searchParams.get("assigneeId");
|
||||||
|
if (assigneeId === null) return json(res, 200, shifts);
|
||||||
|
return json(res, 200, shifts.filter((s) => s.assigneeId === assigneeId));
|
||||||
|
}
|
||||||
if (url.pathname === "/shifts" && req.method === "POST") {
|
if (url.pathname === "/shifts" && req.method === "POST") {
|
||||||
const b = await readBody(req);
|
const b = await readBody(req);
|
||||||
const shift = { id: randomUUID(), assignee: String(b.assignee ?? ""), end: String(b.end ?? ""), start: String(b.start ?? ""), title: String(b.title ?? "") };
|
const shift = { id: randomUUID(), assignee: String(b.assignee ?? ""), assigneeId: "", end: String(b.end ?? ""), start: String(b.start ?? ""), title: String(b.title ?? "") };
|
||||||
shifts.push(shift);
|
shifts.push(shift);
|
||||||
return json(res, 201, shift);
|
return json(res, 201, shift);
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+34
-12
@@ -8,12 +8,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@larvit/log": "2.3.0",
|
"@larvit/log": "2.3.0",
|
||||||
"ejs": "6.0.1",
|
"ejs": "6.0.1",
|
||||||
"lucide-static": "1.33.0",
|
"lucide-static": "1.64.0",
|
||||||
"postgres": "3.4.9"
|
"postgres": "3.4.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@mdn/browser-compat-data": "8.1.7",
|
||||||
"@types/ejs": "3.1.5",
|
"@types/ejs": "3.1.5",
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.19.2",
|
||||||
|
"acorn": "8.19.0",
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -29,6 +31,13 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@mdn/browser-compat-data": {
|
||||||
|
"version": "8.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-8.1.7.tgz",
|
||||||
|
"integrity": "sha512-TJ+rX530bFoE8678PuH2gjCSzwpBcaLlcElRhnnxFdkLHHT1clUAxtjImPFZDAlWQ005px9xaJ4Nd+aQp/rylQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "CC0-1.0"
|
||||||
|
},
|
||||||
"node_modules/@types/ejs": {
|
"node_modules/@types/ejs": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz",
|
||||||
@@ -37,13 +46,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.13.3",
|
"version": "24.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
|
||||||
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
"integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.18.0"
|
"undici-types": ">=7.24.0 <7.24.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||||
@@ -386,6 +395,19 @@
|
|||||||
"node": ">=16.20.0"
|
"node": ">=16.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/acorn": {
|
||||||
|
"version": "8.19.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
|
||||||
|
"integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"acorn": "bin/acorn"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ejs": {
|
"node_modules/ejs": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
|
||||||
@@ -399,9 +421,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-static": {
|
"node_modules/lucide-static": {
|
||||||
"version": "1.33.0",
|
"version": "1.64.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.33.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.64.0.tgz",
|
||||||
"integrity": "sha512-jNGgvTNcLUfVRX4N9PH9pVVTJzoph/BmYmgU838bYBQodkUJL4nAThkuymFz1x3OUYMhJxPndC7rdg1sxOPYKg==",
|
"integrity": "sha512-UxWmMteOXbKHI5E4f2qVtxCAAsI9gsInYK1lNw0nrOA0w4FkJeOVZ+UOV6aGA2377FZe3qyiFYq3lqjBKZ6OuA==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/postgres": {
|
"node_modules/postgres": {
|
||||||
@@ -453,9 +475,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.18.2",
|
"version": "7.24.6",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-2
@@ -18,12 +18,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@larvit/log": "2.3.0",
|
"@larvit/log": "2.3.0",
|
||||||
"ejs": "6.0.1",
|
"ejs": "6.0.1",
|
||||||
"lucide-static": "1.33.0",
|
"lucide-static": "1.64.0",
|
||||||
"postgres": "3.4.9"
|
"postgres": "3.4.9"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@mdn/browser-compat-data": "8.1.7",
|
||||||
"@types/ejs": "3.1.5",
|
"@types/ejs": "3.1.5",
|
||||||
"@types/node": "24.13.3",
|
"@types/node": "24.19.2",
|
||||||
|
"acorn": "8.19.0",
|
||||||
"typescript": "7.0.2"
|
"typescript": "7.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-121
@@ -1,17 +1,11 @@
|
|||||||
/* =============================================================
|
|
||||||
Auth flow — login / register / forgot password
|
|
||||||
Built on the shared tokens in styles.css. Pure HTML/CSS:
|
|
||||||
- screens switch via :target (no JS)
|
|
||||||
- SSO is a config section toggled by a class/checkbox; providers
|
|
||||||
are a <ul> you extend by adding one <li class="sso-item">.
|
|
||||||
============================================================= */
|
|
||||||
|
|
||||||
.auth-stage {
|
.auth-stage {
|
||||||
min-height: 100dvh;
|
|
||||||
display: grid; place-items: center;
|
display: grid; place-items: center;
|
||||||
padding: 40px 20px 96px;
|
padding: 40px 20px 96px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
@supports (min-height: 100dvh) {
|
||||||
|
.auth-stage { min-height: 100dvh; }
|
||||||
|
}
|
||||||
.auth {
|
.auth {
|
||||||
width: 100%; max-width: 408px;
|
width: 100%; max-width: 408px;
|
||||||
display: flex; flex-direction: column; gap: 22px;
|
display: flex; flex-direction: column; gap: 22px;
|
||||||
@@ -34,13 +28,6 @@
|
|||||||
.landing-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 4px; }
|
.landing-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 4px; }
|
||||||
.landing-actions .btn { height: 40px; padding: 0 20px; font-size: var(--fz); }
|
.landing-actions .btn { height: 40px; padding: 0 20px; font-size: var(--fz); }
|
||||||
|
|
||||||
/* ---- screen switching via :target (login is the default) ---- */
|
|
||||||
.auth-view { display: none; }
|
|
||||||
#login { display: block; }
|
|
||||||
#register:target, #forgot:target { display: block; }
|
|
||||||
body:has(#register:target) #login,
|
|
||||||
body:has(#forgot:target) #login { display: none; }
|
|
||||||
|
|
||||||
/* card */
|
/* card */
|
||||||
.auth-card {
|
.auth-card {
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
@@ -118,13 +105,13 @@ body:has(#forgot:target) #login { display: none; }
|
|||||||
.input:focus { outline: none; border-color: var(--accent);
|
.input:focus { outline: none; border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 2px var(--accent-bg); }
|
box-shadow: 0 0 0 2px var(--accent-bg); }
|
||||||
/* gentle validation feedback only after the user has interacted */
|
/* gentle validation feedback only after the user has interacted */
|
||||||
.input:user-invalid { border-color: var(--neg); }
|
@supports selector(:user-invalid) {
|
||||||
.input:user-invalid:focus { box-shadow: 0 0 0 2px var(--neg-bg); }
|
.input:user-invalid { border-color: var(--neg); }
|
||||||
|
.input:user-invalid:focus { box-shadow: 0 0 0 2px var(--neg-bg); }
|
||||||
|
}
|
||||||
|
|
||||||
.field-hint { font-size: var(--fz-xs); color: var(--text-faint); }
|
.field-hint { font-size: var(--fz-xs); color: var(--text-faint); }
|
||||||
|
|
||||||
.remember { margin-top: 2px; }
|
|
||||||
|
|
||||||
/* full-width primary button (extends .btn / .btn-primary from styles.css) */
|
/* full-width primary button (extends .btn / .btn-primary from styles.css) */
|
||||||
.btn-block { width: 100%; justify-content: center; height: 40px; font-size: var(--fz); }
|
.btn-block { width: 100%; justify-content: center; height: 40px; font-size: var(--fz); }
|
||||||
|
|
||||||
@@ -138,74 +125,6 @@ body:has(#forgot:target) #login { display: none; }
|
|||||||
.auth-aside a { color: var(--accent); }
|
.auth-aside a { color: var(--accent); }
|
||||||
.auth-aside a:hover { text-decoration: underline; }
|
.auth-aside a:hover { text-decoration: underline; }
|
||||||
|
|
||||||
/* legal footnote */
|
|
||||||
.auth-foot { margin: 0; text-align: center; font-size: var(--fz-xs); color: var(--text-faint); }
|
|
||||||
.auth-foot a { color: var(--text-muted); text-decoration: underline; }
|
|
||||||
|
|
||||||
/* =============================================================
|
|
||||||
Template preview controls (delete this block + its markup for
|
|
||||||
production). Drives theme + SSO on/off with zero JS.
|
|
||||||
============================================================= */
|
|
||||||
.tpl-controls {
|
|
||||||
position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%);
|
|
||||||
z-index: 50;
|
|
||||||
display: flex; align-items: center; gap: 12px;
|
|
||||||
padding: 6px 8px 6px 14px;
|
|
||||||
background: var(--surface); border: 1px solid var(--border);
|
|
||||||
border-radius: 99px; box-shadow: 0 6px 22px rgba(0,0,0,.12);
|
|
||||||
}
|
|
||||||
.tpl-label { font-size: var(--fz-xs); color: var(--text-faint);
|
|
||||||
text-transform: uppercase; letter-spacing: .05em; font-weight: 600; }
|
|
||||||
.tpl-sep { width: 1px; height: 20px; background: var(--border); }
|
|
||||||
.tpl-controls .theme-switch { width: 156px; }
|
|
||||||
|
|
||||||
/* on/off switch */
|
|
||||||
.tpl-toggle { display: inline-flex; align-items: center; gap: 8px;
|
|
||||||
font-size: var(--fz-sm); color: var(--text-muted); cursor: pointer; }
|
|
||||||
.tpl-toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
|
|
||||||
.tpl-track { width: 32px; height: 18px; border-radius: 99px;
|
|
||||||
background: var(--border-2); position: relative; transition: background .15s ease; }
|
|
||||||
.tpl-track::after { content: ""; position: absolute; top: 2px; left: 2px;
|
|
||||||
width: 14px; height: 14px; border-radius: 50%; background: #fff;
|
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,.3); transition: transform .15s ease; }
|
|
||||||
.tpl-toggle:has(input:checked) .tpl-track { background: var(--accent); }
|
|
||||||
.tpl-toggle:has(input:checked) .tpl-track::after { transform: translateX(14px); }
|
|
||||||
.tpl-toggle:has(input:focus-visible) .tpl-track {
|
|
||||||
outline: 2px solid var(--focus); outline-offset: 2px; }
|
|
||||||
|
|
||||||
@media (max-width: 520px) {
|
|
||||||
.tpl-label { display: none; }
|
|
||||||
.tpl-controls { gap: 10px; padding: 6px 10px; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* =============================================================
|
|
||||||
Forgot-password feedback (Post/Redirect/Get, no JS).
|
|
||||||
The form POSTs to its own URL; on the redirect-GET the server
|
|
||||||
re-renders #forgot with a state class so the matching alert shows:
|
|
||||||
<section id="forgot" class="auth-view state-sent"> … </section>
|
|
||||||
(state-sent | state-error). No extra view is added.
|
|
||||||
The preview radios below demonstrate the same states live.
|
|
||||||
============================================================= */
|
|
||||||
.forgot-alert { display: none; }
|
|
||||||
#forgot.state-sent .forgot-alert.is-sent,
|
|
||||||
body:has(#fstate-sent:checked) #forgot .forgot-alert.is-sent { display: flex; }
|
|
||||||
#forgot.state-error .forgot-alert.is-error,
|
|
||||||
body:has(#fstate-error:checked) #forgot .forgot-alert.is-error { display: flex; }
|
|
||||||
|
|
||||||
/* the Forgot-state control only appears while the forgot view is open */
|
|
||||||
.tpl-forgot { display: none; }
|
|
||||||
body:has(#forgot:target) .tpl-forgot { display: flex; }
|
|
||||||
.tpl-controls .segmented { padding: 1px; }
|
|
||||||
.tpl-controls .segmented label { padding: 3px 8px; }
|
|
||||||
|
|
||||||
/* =============================================================
|
|
||||||
Field-level errors (e.g. "email already used"), server-driven.
|
|
||||||
The server re-renders the field's form with a state class and
|
|
||||||
adds aria-invalid="true" to the input:
|
|
||||||
<section id="register" class="auth-view state-email-taken"> … </section>
|
|
||||||
Reusable: drop a <p class="field-error"> under any field and gate
|
|
||||||
it on whatever state your server sets.
|
|
||||||
============================================================= */
|
|
||||||
.field-error {
|
.field-error {
|
||||||
display: none; align-items: center; gap: 6px; margin: 0;
|
display: none; align-items: center; gap: 6px; margin: 0;
|
||||||
font-size: var(--fz-xs); color: var(--neg);
|
font-size: var(--fz-xs); color: var(--neg);
|
||||||
@@ -213,36 +132,3 @@ body:has(#forgot:target) .tpl-forgot { display: flex; }
|
|||||||
.field.has-error .field-error { display: flex; } /* server-rendered field shows its own error */
|
.field.has-error .field-error { display: flex; } /* server-rendered field shows its own error */
|
||||||
.field-error > .ico { flex: 0 0 auto; color: var(--neg); }
|
.field-error > .ico { flex: 0 0 auto; color: var(--neg); }
|
||||||
.field-error a { color: var(--neg); font-weight: 600; text-decoration: underline; }
|
.field-error a { color: var(--neg); font-weight: 600; text-decoration: underline; }
|
||||||
|
|
||||||
/* register form-level error banner (pairs with field-level errors) */
|
|
||||||
.register-alert { display: none; }
|
|
||||||
|
|
||||||
/* --- state A: email already taken — field-level error only --- */
|
|
||||||
#register.state-email-taken .err-email,
|
|
||||||
body:has(#rstate-taken:checked) #register .err-email { display: flex; }
|
|
||||||
#register.state-email-taken #reg-email,
|
|
||||||
body:has(#rstate-taken:checked) #register #reg-email { border-color: var(--neg); }
|
|
||||||
|
|
||||||
/* --- state B: multiple errors — banner + several field errors together --- */
|
|
||||||
#register.state-invalid .register-alert,
|
|
||||||
body:has(#rstate-combined:checked) #register .register-alert { display: flex; }
|
|
||||||
#register.state-invalid .err-email,
|
|
||||||
#register.state-invalid .err-password,
|
|
||||||
body:has(#rstate-combined:checked) #register .err-email,
|
|
||||||
body:has(#rstate-combined:checked) #register .err-password { display: flex; }
|
|
||||||
#register.state-invalid #reg-email,
|
|
||||||
#register.state-invalid #reg-password,
|
|
||||||
body:has(#rstate-combined:checked) #register #reg-email,
|
|
||||||
body:has(#rstate-combined:checked) #register #reg-password { border-color: var(--neg); }
|
|
||||||
|
|
||||||
/* red focus ring on any invalid register input */
|
|
||||||
#register.state-email-taken #reg-email:focus,
|
|
||||||
#register.state-invalid #reg-email:focus,
|
|
||||||
#register.state-invalid #reg-password:focus,
|
|
||||||
body:has(#rstate-taken:checked) #register #reg-email:focus,
|
|
||||||
body:has(#rstate-combined:checked) #register #reg-email:focus,
|
|
||||||
body:has(#rstate-combined:checked) #register #reg-password:focus { box-shadow: 0 0 0 2px var(--neg-bg); }
|
|
||||||
|
|
||||||
/* the Register-state control only appears while the register view is open */
|
|
||||||
.tpl-register { display: none; }
|
|
||||||
body:has(#register:target) .tpl-register { display: flex; }
|
|
||||||
|
|||||||
+147
-84
@@ -2,8 +2,6 @@
|
|||||||
App Shell Template — pure HTML/CSS, no JavaScript
|
App Shell Template — pure HTML/CSS, no JavaScript
|
||||||
-------------------------------------------------------------
|
-------------------------------------------------------------
|
||||||
Architecture
|
Architecture
|
||||||
- Design tokens via light-dark() so theme follows system by
|
|
||||||
default and can be forced Light / Dark via radio + :has().
|
|
||||||
- Everything is a reusable "block": .nav-item, .filter,
|
- Everything is a reusable "block": .nav-item, .filter,
|
||||||
.chip, .badge, .btn, .menu, .table — combine freely.
|
.chip, .badge, .btn, .menu, .table — combine freely.
|
||||||
============================================================= */
|
============================================================= */
|
||||||
@@ -11,43 +9,46 @@
|
|||||||
/* ---------- 1. TOKENS ----------------------------------------
|
/* ---------- 1. TOKENS ----------------------------------------
|
||||||
Two raw palettes (--l-* light, --d-* dark) are defined once.
|
Two raw palettes (--l-* light, --d-* dark) are defined once.
|
||||||
A small mapping (--bg: var(--l-bg) ...) picks the active set.
|
A small mapping (--bg: var(--l-bg) ...) picks the active set.
|
||||||
Theme resolution (no JS):
|
Theme resolution (no JS); all three rules below need :has():
|
||||||
• default + @media(dark) → follows the OS (Auto)
|
• default + @media(dark) → follows the OS (Auto)
|
||||||
• html:has(#theme-light) → force light
|
• html:has(#theme-light) → force light
|
||||||
• html:has(#theme-dark) → force dark
|
• html:has(#theme-dark) → force dark
|
||||||
|
Engines without :has() (Chromium below 105, Firefox below 121,
|
||||||
|
Safari below 15.4) ignore all three and stay light; see the @supports not
|
||||||
|
selector(:has(a)) block further down.
|
||||||
We avoid light-dark()/color-scheme for app colors because their
|
We avoid light-dark()/color-scheme for app colors because their
|
||||||
resolution is unreliable across engines; :has() variable swaps
|
resolution is unreliable across engines. color-scheme is still
|
||||||
are rock-solid. color-scheme is still set as a hint for native
|
set as a hint for native widgets (date picker, scrollbars,
|
||||||
widgets (date picker, scrollbars, selects).
|
selects).
|
||||||
------------------------------------------------------------- */
|
------------------------------------------------------------- */
|
||||||
:root {
|
:root {
|
||||||
/* ---- raw LIGHT palette ---- */
|
/* ---- raw LIGHT palette ---- */
|
||||||
--l-bg:#f3f3f4; --l-surface:#ffffff; --l-surface-2:#f6f6f7; --l-surface-3:#fbfbfc;
|
--l-bg:#f3f3f4; --l-surface:#ffffff; --l-surface-2:#f6f6f7; --l-surface-3:#fbfbfc;
|
||||||
--l-border:#e5e5e8; --l-border-2:#d6d6da;
|
--l-border:#e5e5e8; --l-border-2:#d6d6da;
|
||||||
--l-text:#18181b; --l-text-muted:#6b6b73; --l-text-faint:#9a9aa2;
|
--l-text:#18181b; --l-text-muted:#6b6b73; --l-text-faint:#9a9aa2;
|
||||||
--l-accent:oklch(0.52 0.12 256); --l-accent-bg:oklch(0.96 0.03 256);
|
--l-accent:#3669ac; --l-accent-bg:#e5f3ff;
|
||||||
--l-accent-bd:oklch(0.82 0.07 256); --l-focus:oklch(0.52 0.14 256);
|
--l-accent-bd:#a7c7f2; --l-focus:#2a68b7; --l-on-accent:#ffffff;
|
||||||
--l-pos:oklch(0.54 0.13 150); --l-pos-bg:oklch(0.95 0.03 150); --l-pos-bd:oklch(0.83 0.07 150);
|
--l-pos:#1f7a3d; --l-pos-bg:#e1f5e4; --l-pos-bd:#a8d5b0;
|
||||||
--l-neg:oklch(0.55 0.18 25); --l-neg-bg:oklch(0.96 0.03 25); --l-neg-bd:oklch(0.85 0.08 25);
|
--l-neg:#c53637; --l-neg-bg:#ffebe8; --l-neg-bd:#febab4;
|
||||||
--l-warn:oklch(0.55 0.12 75); --l-warn-bg:oklch(0.96 0.04 85); --l-warn-bd:oklch(0.84 0.08 85);
|
--l-warn:#8f5d00; --l-warn-bg:#fef0d4; --l-warn-bd:#e3c78e;
|
||||||
--l-info:oklch(0.54 0.12 245);--l-info-bg:oklch(0.96 0.03 245);--l-info-bd:oklch(0.83 0.07 245);
|
--l-info:#1d6aa5;--l-info-bg:#e2f5ff;--l-info-bd:#a2cdf2;
|
||||||
|
|
||||||
/* ---- raw DARK palette ---- */
|
/* ---- raw DARK palette ---- */
|
||||||
--d-bg:#0d0d0f; --d-surface:#161619; --d-surface-2:#1d1d22; --d-surface-3:#1a1a1e;
|
--d-bg:#0d0d0f; --d-surface:#161619; --d-surface-2:#1d1d22; --d-surface-3:#1a1a1e;
|
||||||
--d-border:#2a2a31; --d-border-2:#3a3a43;
|
--d-border:#2a2a31; --d-border-2:#3a3a43;
|
||||||
--d-text:#ededf0; --d-text-muted:#9b9ba6; --d-text-faint:#6a6a74;
|
--d-text:#ededf0; --d-text-muted:#9b9ba6; --d-text-faint:#6a6a74;
|
||||||
--d-accent:oklch(0.70 0.12 256); --d-accent-bg:oklch(0.32 0.06 256);
|
--d-accent:#6ba0e8; --d-accent-bg:#1d3351;
|
||||||
--d-accent-bd:oklch(0.48 0.08 256); --d-focus:oklch(0.74 0.13 256);
|
--d-accent-bd:#3f5f8a; --d-focus:#72adfb; --d-on-accent:#0d0d0f;
|
||||||
--d-pos:oklch(0.74 0.14 150); --d-pos-bg:oklch(0.30 0.05 150); --d-pos-bd:oklch(0.45 0.07 150);
|
--d-pos:#62c37a; --d-pos-bg:#1a3520; --d-pos-bd:#376040;
|
||||||
--d-neg:oklch(0.74 0.16 25); --d-neg-bg:oklch(0.30 0.07 25); --d-neg-bd:oklch(0.47 0.10 25);
|
--d-neg:#ff7e76; --d-neg-bg:#4b1d1b; --d-neg-bd:#8a423d;
|
||||||
--d-warn:oklch(0.80 0.13 85); --d-warn-bg:oklch(0.31 0.05 80); --d-warn-bd:oklch(0.48 0.07 80);
|
--d-warn:#e4b750; --d-warn-bg:#3e2d10; --d-warn-bd:#72592c;
|
||||||
--d-info:oklch(0.74 0.12 245);--d-info-bg:oklch(0.31 0.06 245);--d-info-bd:oklch(0.47 0.08 245);
|
--d-info:#65b2f1;--d-info-bg:#12334c;--d-info-bd:#305f85;
|
||||||
|
|
||||||
/* ---- active mapping: default = LIGHT ---- */
|
/* ---- active mapping: default = LIGHT ---- */
|
||||||
--bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3);
|
--bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3);
|
||||||
--border:var(--l-border); --border-2:var(--l-border-2);
|
--border:var(--l-border); --border-2:var(--l-border-2);
|
||||||
--text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint);
|
--text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint);
|
||||||
--accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus);
|
--accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus); --on-accent:var(--l-on-accent);
|
||||||
--pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd);
|
--pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd);
|
||||||
--neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd);
|
--neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd);
|
||||||
--warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd);
|
--warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd);
|
||||||
@@ -58,7 +59,7 @@
|
|||||||
--radius: 5px;
|
--radius: 5px;
|
||||||
--nav-w: 264px;
|
--nav-w: 264px;
|
||||||
--row-h: 34px;
|
--row-h: 34px;
|
||||||
--pad-x: 12px;
|
--pad-x: 12px; /* side padding of the content pane's bars and form page, and of every table cell, so their text lines up */
|
||||||
--fz: 13px;
|
--fz: 13px;
|
||||||
--fz-sm: 12px;
|
--fz-sm: 12px;
|
||||||
--fz-xs: 11px;
|
--fz-xs: 11px;
|
||||||
@@ -68,50 +69,51 @@
|
|||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* shared DARK mapping, applied to Auto-via-OS and forced-dark */
|
@supports selector(:has(a)) {
|
||||||
@media (prefers-color-scheme: dark) {
|
/* shared DARK mapping, applied to Auto-via-OS and forced-dark */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
:root:not(:has(#theme-light:checked)):not(:has(#theme-dark:checked)) {
|
:root:not(:has(#theme-light:checked)):not(:has(#theme-dark:checked)) {
|
||||||
--bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3);
|
--bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3);
|
||||||
--border:var(--d-border); --border-2:var(--d-border-2);
|
--border:var(--d-border); --border-2:var(--d-border-2);
|
||||||
--text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint);
|
--text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint);
|
||||||
--accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus);
|
--accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus); --on-accent:var(--d-on-accent);
|
||||||
--pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd);
|
--pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd);
|
||||||
--neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd);
|
--neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd);
|
||||||
--warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd);
|
--warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd);
|
||||||
--info:var(--d-info); --info-bg:var(--d-info-bg); --info-bd:var(--d-info-bd);
|
--info:var(--d-info); --info-bg:var(--d-info-bg); --info-bd:var(--d-info-bd);
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* forced DARK (overrides everything) */
|
/* forced DARK (overrides everything) */
|
||||||
html:has(#theme-dark:checked) {
|
html:has(#theme-dark:checked) {
|
||||||
--bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3);
|
--bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3);
|
||||||
--border:var(--d-border); --border-2:var(--d-border-2);
|
--border:var(--d-border); --border-2:var(--d-border-2);
|
||||||
--text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint);
|
--text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint);
|
||||||
--accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus);
|
--accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus); --on-accent:var(--d-on-accent);
|
||||||
--pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd);
|
--pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd);
|
||||||
--neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd);
|
--neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd);
|
||||||
--warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd);
|
--warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd);
|
||||||
--info:var(--d-info); --info-bg:var(--d-info-bg); --info-bd:var(--d-info-bd);
|
--info:var(--d-info); --info-bg:var(--d-info-bg); --info-bd:var(--d-info-bd);
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* forced LIGHT (overrides the OS-dark media query) */
|
/* forced LIGHT (overrides the OS-dark media query) */
|
||||||
html:has(#theme-light:checked) {
|
html:has(#theme-light:checked) {
|
||||||
--bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3);
|
--bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3);
|
||||||
--border:var(--l-border); --border-2:var(--l-border-2);
|
--border:var(--l-border); --border-2:var(--l-border-2);
|
||||||
--text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint);
|
--text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint);
|
||||||
--accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus);
|
--accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus); --on-accent:var(--l-on-accent);
|
||||||
--pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd);
|
--pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd);
|
||||||
--neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd);
|
--neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd);
|
||||||
--warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd);
|
--warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd);
|
||||||
--info:var(--l-info); --info-bg:var(--l-info-bg); --info-bd:var(--l-info-bd);
|
--info:var(--l-info); --info-bg:var(--l-info-bg); --info-bd:var(--l-info-bd);
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 2. RESET ---------------------------------------- */
|
/* ---------- 2. RESET ---------------------------------------- */
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
html, body { height: 100%; }
|
|
||||||
body { margin: 0; background: var(--bg); color: var(--text);
|
body { margin: 0; background: var(--bg); color: var(--text);
|
||||||
-webkit-font-smoothing: antialiased; }
|
-webkit-font-smoothing: antialiased; }
|
||||||
button { font: inherit; color: inherit; }
|
button { font: inherit; color: inherit; }
|
||||||
@@ -120,10 +122,12 @@ a { color: inherit; text-decoration: none; }
|
|||||||
summary::-webkit-details-marker { display: none; }
|
summary::-webkit-details-marker { display: none; }
|
||||||
summary { list-style: none; cursor: pointer; }
|
summary { list-style: none; cursor: pointer; }
|
||||||
|
|
||||||
:focus-visible {
|
@supports selector(:focus-visible) {
|
||||||
|
:focus-visible {
|
||||||
outline: 2px solid var(--focus);
|
outline: 2px solid var(--focus);
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
.sidebar, .scrim, summary, .nav-item, .btn, .chip, .profile, .kebab { transition: .15s ease; }
|
.sidebar, .scrim, summary, .nav-item, .btn, .chip, .profile, .kebab { transition: .15s ease; }
|
||||||
@@ -151,8 +155,9 @@ summary { list-style: none; cursor: pointer; }
|
|||||||
.app {
|
.app {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--nav-w) minmax(0, 1fr);
|
grid-template-columns: var(--nav-w) minmax(0, 1fr);
|
||||||
height: 100dvh;
|
}
|
||||||
overflow: hidden;
|
@supports (min-height: 100dvh) {
|
||||||
|
.app { min-height: 100dvh; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- 4. SIDEBAR -------------------------------------- */
|
/* ---------- 4. SIDEBAR -------------------------------------- */
|
||||||
@@ -160,7 +165,6 @@ summary { list-style: none; cursor: pointer; }
|
|||||||
grid-column: 1;
|
grid-column: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 0;
|
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
@@ -172,9 +176,9 @@ summary { list-style: none; cursor: pointer; }
|
|||||||
.brand-mark {
|
.brand-mark {
|
||||||
width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto;
|
width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
display: grid; place-items: center; color: #fff;
|
display: grid; place-items: center; color: var(--on-accent);
|
||||||
}
|
}
|
||||||
.brand-mark .ico { stroke: #fff; }
|
.brand-mark .ico { stroke: var(--on-accent); }
|
||||||
.brand-logo { width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto; object-fit: contain; }
|
.brand-logo { width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto; object-fit: contain; }
|
||||||
.brand-name { font-weight: 650; letter-spacing: -.01em; }
|
.brand-name { font-weight: 650; letter-spacing: -.01em; }
|
||||||
.brand-sub { color: var(--text-faint); font-size: var(--fz-xs); margin-left: auto; }
|
.brand-sub { color: var(--text-faint); font-size: var(--fz-xs); margin-left: auto; }
|
||||||
@@ -192,9 +196,10 @@ summary { list-style: none; cursor: pointer; }
|
|||||||
• CLICKABLE = label is <a href> → navigates on click.
|
• CLICKABLE = label is <a href> → navigates on click.
|
||||||
STATIC = label is <span> → not a navigation target.
|
STATIC = label is <span> → not a navigation target.
|
||||||
So "header" and "clickable" are independent: any combination works.
|
So "header" and "clickable" are independent: any combination works.
|
||||||
Children sit in a sibling <ul class="nav-children">, revealed by
|
Children sit in a sibling <ul class="nav-children">. Where :has() works, a
|
||||||
:has() on THIS row's own <details open> (scoped with > so a node
|
branch is folded unless THIS row's own <details open> (scoped with > so a node
|
||||||
only ever controls its own subtree — infinite depth, no JS).
|
only ever controls its own subtree — infinite depth, no JS). Without :has()
|
||||||
|
every branch stays open, since nothing could reopen one.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
.nav-row { display: flex; align-items: center; gap: 2px; }
|
.nav-row { display: flex; align-items: center; gap: 2px; }
|
||||||
|
|
||||||
@@ -218,7 +223,10 @@ summary { list-style: none; cursor: pointer; }
|
|||||||
min-height: 30px; padding: 4px 8px;
|
min-height: 30px; padding: 4px 8px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
color: var(--text-muted); font-size: var(--fz);
|
color: var(--text-muted); font-size: var(--fz);
|
||||||
position: relative; user-select: none;
|
position: relative;
|
||||||
|
}
|
||||||
|
@supports (user-select: none) {
|
||||||
|
.nav-self { user-select: none; }
|
||||||
}
|
}
|
||||||
a.nav-self:hover { background: var(--surface-2); color: var(--text); }
|
a.nav-self:hover { background: var(--surface-2); color: var(--text); }
|
||||||
.nav-self .ico { color: var(--text-faint); }
|
.nav-self .ico { color: var(--text-faint); }
|
||||||
@@ -238,8 +246,10 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
.nav-self[aria-current="page"] .ico { color: var(--accent); }
|
.nav-self[aria-current="page"] .ico { color: var(--accent); }
|
||||||
|
|
||||||
/* children + infinite indentation with a guide line per level */
|
/* children + infinite indentation with a guide line per level */
|
||||||
.nav-node > .nav-children { display: none; }
|
@supports selector(:has(a)) {
|
||||||
.nav-node:has(> .nav-row > .nav-disc[open]) > .nav-children { display: block; }
|
.nav-node > .nav-children { display: none; }
|
||||||
|
.nav-node:has(> .nav-row > .nav-disc[open]) > .nav-children { display: block; }
|
||||||
|
}
|
||||||
.nav-children { padding-left: 22px; position: relative; }
|
.nav-children { padding-left: 22px; position: relative; }
|
||||||
.nav-children::before {
|
.nav-children::before {
|
||||||
content: ""; position: absolute; left: 10px; top: 2px; bottom: 2px;
|
content: ""; position: absolute; left: 10px; top: 2px; bottom: 2px;
|
||||||
@@ -274,12 +284,14 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
}
|
}
|
||||||
.theme-switch label:hover { color: var(--text); }
|
.theme-switch label:hover { color: var(--text); }
|
||||||
.theme-switch input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
|
.theme-switch input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
|
||||||
.theme-switch label:has(input:checked) {
|
@supports selector(:has(:focus-visible)) {
|
||||||
|
.theme-switch label:has(input:checked) {
|
||||||
background: var(--surface); color: var(--text);
|
background: var(--surface); color: var(--text);
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,.06); font-weight: 550;
|
box-shadow: 0 1px 2px rgba(0,0,0,.06); font-weight: 550;
|
||||||
}
|
}
|
||||||
.theme-switch label:has(input:focus-visible) {
|
.theme-switch label:has(input:focus-visible) {
|
||||||
outline: 2px solid var(--focus); outline-offset: 1px;
|
outline: 2px solid var(--focus); outline-offset: 1px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-actions { display: flex; align-items: center; gap: 4px; }
|
.footer-actions { display: flex; align-items: center; gap: 4px; }
|
||||||
@@ -312,10 +324,15 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
}
|
}
|
||||||
.btn:hover { background: var(--surface-2); }
|
.btn:hover { background: var(--surface-2); }
|
||||||
.btn-primary { background: var(--accent); border-color: var(--accent);
|
.btn-primary { background: var(--accent); border-color: var(--accent);
|
||||||
color: #fff; font-weight: 550; }
|
color: var(--on-accent); font-weight: 550; }
|
||||||
.btn-primary:hover { filter: brightness(1.06); }
|
.btn-primary:hover { filter: brightness(1.06); }
|
||||||
.btn-ghost { background: transparent; border-color: transparent; }
|
.btn-ghost { background: transparent; border-color: transparent; }
|
||||||
.btn-ghost:hover { background: var(--surface-2); }
|
.btn-ghost:hover { background: var(--surface-2); }
|
||||||
|
.btn-menu::after {
|
||||||
|
content: ""; width: 7px; height: 7px; margin: -2px 1px 0 1px;
|
||||||
|
border-right: 1.5px solid var(--text-faint); border-bottom: 1.5px solid var(--text-faint);
|
||||||
|
transform: rotate(45deg);
|
||||||
|
}
|
||||||
.icon-btn {
|
.icon-btn {
|
||||||
width: 30px; height: 30px; padding: 0; justify-content: center;
|
width: 30px; height: 30px; padding: 0; justify-content: center;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -326,7 +343,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
.content {
|
.content {
|
||||||
grid-column: 2;
|
grid-column: 2;
|
||||||
display: flex; flex-direction: column;
|
display: flex; flex-direction: column;
|
||||||
min-width: 0; min-height: 0;
|
min-width: 0;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,7 +351,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
.topbar {
|
.topbar {
|
||||||
flex: 0 0 auto; height: 48px;
|
flex: 0 0 auto; height: 48px;
|
||||||
display: flex; align-items: center; gap: 12px;
|
display: flex; align-items: center; gap: 12px;
|
||||||
padding: 0 16px; border-bottom: 1px solid var(--border);
|
padding: 0 var(--pad-x); border-bottom: 1px solid var(--border);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
.hamburger { display: none; } /* shown only on narrow */
|
.hamburger { display: none; } /* shown only on narrow */
|
||||||
@@ -350,7 +367,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
.filters {
|
.filters {
|
||||||
flex: 0 0 auto; background: var(--surface);
|
flex: 0 0 auto; background: var(--surface);
|
||||||
border-bottom: 1px solid var(--border);
|
border-bottom: 1px solid var(--border);
|
||||||
padding: 10px 16px;
|
padding: 10px var(--pad-x);
|
||||||
display: flex; flex-direction: column; gap: 9px;
|
display: flex; flex-direction: column; gap: 9px;
|
||||||
}
|
}
|
||||||
.filter-row {
|
.filter-row {
|
||||||
@@ -399,7 +416,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
position: relative; display: inline-flex; align-items: center;
|
position: relative; display: inline-flex; align-items: center;
|
||||||
}
|
}
|
||||||
.select select {
|
.select select {
|
||||||
appearance: none; height: 30px; padding: 0 26px 0 9px;
|
height: 30px; padding: 0 26px 0 9px;
|
||||||
background: var(--surface); color: var(--text);
|
background: var(--surface); color: var(--text);
|
||||||
border: 1px solid var(--border-2); border-radius: var(--radius);
|
border: 1px solid var(--border-2); border-radius: var(--radius);
|
||||||
font: inherit; font-size: var(--fz-sm); cursor: pointer;
|
font: inherit; font-size: var(--fz-sm); cursor: pointer;
|
||||||
@@ -409,8 +426,13 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
width: 7px; height: 7px; border-right: 1.5px solid var(--text-faint);
|
width: 7px; height: 7px; border-right: 1.5px solid var(--text-faint);
|
||||||
border-bottom: 1.5px solid var(--text-faint); transform: translateY(-2px) rotate(45deg);
|
border-bottom: 1.5px solid var(--text-faint); transform: translateY(-2px) rotate(45deg);
|
||||||
}
|
}
|
||||||
.select select:focus-visible { border-color: var(--accent); outline: none;
|
@supports (appearance: none) {
|
||||||
|
.select select { appearance: none; }
|
||||||
|
}
|
||||||
|
@supports selector(:focus-visible) {
|
||||||
|
.select select:focus-visible { border-color: var(--accent); outline: none;
|
||||||
box-shadow: 0 0 0 2px var(--accent-bg); }
|
box-shadow: 0 0 0 2px var(--accent-bg); }
|
||||||
|
}
|
||||||
|
|
||||||
/* segmented toggle (radios) */
|
/* segmented toggle (radios) */
|
||||||
.segmented {
|
.segmented {
|
||||||
@@ -425,11 +447,13 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
}
|
}
|
||||||
.segmented label:hover { color: var(--text); }
|
.segmented label:hover { color: var(--text); }
|
||||||
.segmented input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
|
.segmented input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
|
||||||
.segmented label:has(input:checked) {
|
@supports selector(:has(:focus-visible)) {
|
||||||
|
.segmented label:has(input:checked) {
|
||||||
background: var(--surface); color: var(--text); font-weight: 550;
|
background: var(--surface); color: var(--text); font-weight: 550;
|
||||||
box-shadow: 0 1px 2px rgba(0,0,0,.06);
|
box-shadow: 0 1px 2px rgba(0,0,0,.06);
|
||||||
|
}
|
||||||
|
.segmented label:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||||
}
|
}
|
||||||
.segmented label:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px; }
|
|
||||||
.seg-count { font-size: var(--fz-xs); color: var(--text-faint); }
|
.seg-count { font-size: var(--fz-xs); color: var(--text-faint); }
|
||||||
|
|
||||||
/* chips (multi-select checkboxes) */
|
/* chips (multi-select checkboxes) */
|
||||||
@@ -445,12 +469,25 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
.chip input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
|
.chip input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
|
||||||
.chip .chip-dot { width: 7px; height: 7px; border-radius: 50%;
|
.chip .chip-dot { width: 7px; height: 7px; border-radius: 50%;
|
||||||
background: var(--border-2); }
|
background: var(--border-2); }
|
||||||
.chip:has(input:checked) {
|
@supports selector(:has(:focus-visible)) {
|
||||||
|
.chip:has(input:checked) {
|
||||||
background: var(--accent-bg); border-color: var(--accent-bd);
|
background: var(--accent-bg); border-color: var(--accent-bd);
|
||||||
color: var(--text); font-weight: 550;
|
color: var(--text); font-weight: 550;
|
||||||
|
}
|
||||||
|
.chip:has(input:checked) .chip-dot { background: var(--accent); }
|
||||||
|
.chip:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Without :has() every nav branch stays open and no theme rule applies, so the disclosure toggles and
|
||||||
|
the theme switch are hidden. The filter inputs are shown instead: the native control is then the
|
||||||
|
only sign of a choice or of focus, and the dot goes, since it would stay grey beside a ticked box.
|
||||||
|
This block must come after the base rules, since @supports adds no specificity. */
|
||||||
|
@supports not selector(:has(a)) {
|
||||||
|
.nav-disc { visibility: hidden; } /* keeps its width, so the labels stay aligned */
|
||||||
|
.theme-switch { display: none; }
|
||||||
|
.segmented input, .chip input { position: static; opacity: 1; margin: 0; }
|
||||||
|
.chip .chip-dot { display: none; }
|
||||||
}
|
}
|
||||||
.chip:has(input:checked) .chip-dot { background: var(--accent); }
|
|
||||||
.chip:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px; }
|
|
||||||
|
|
||||||
/* date range */
|
/* date range */
|
||||||
.daterange {
|
.daterange {
|
||||||
@@ -471,8 +508,10 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
display: inline-flex; align-items: center; gap: 7px;
|
display: inline-flex; align-items: center; gap: 7px;
|
||||||
font-size: var(--fz-sm); color: var(--text-muted); cursor: pointer;
|
font-size: var(--fz-sm); color: var(--text-muted); cursor: pointer;
|
||||||
}
|
}
|
||||||
.check input, .radio input { width: 15px; height: 15px; accent-color: var(--accent);
|
.check input, .radio input { width: 15px; height: 15px; margin: 0; cursor: pointer; }
|
||||||
margin: 0; cursor: pointer; }
|
@supports (accent-color: auto) {
|
||||||
|
.check input, .radio input { accent-color: var(--accent); }
|
||||||
|
}
|
||||||
.check:hover, .radio:hover { color: var(--text); }
|
.check:hover, .radio:hover { color: var(--text); }
|
||||||
/* A stacked group of .check rows in a <fieldset> — the right element for related checkboxes, but the
|
/* A stacked group of .check rows in a <fieldset> — the right element for related checkboxes, but the
|
||||||
UA gives it a groove border, so reset it like .filter-field and .menu-field do. A disabled row is
|
UA gives it a groove border, so reset it like .filter-field and .menu-field do. A disabled row is
|
||||||
@@ -480,7 +519,9 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
.check-group { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
.check-group { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||||
.check-group .check { align-items: baseline; }
|
.check-group .check { align-items: baseline; }
|
||||||
.check-group .check input:disabled { cursor: default; }
|
.check-group .check input:disabled { cursor: default; }
|
||||||
.check-group .check:has(input:disabled) { opacity: .7; cursor: default; }
|
@supports selector(:has(a)) {
|
||||||
|
.check-group .check:has(input:disabled) { opacity: .7; cursor: default; }
|
||||||
|
}
|
||||||
.check-group .check .cell-muted { margin-left: auto; padding-left: 12px; font-size: var(--fz-xs); }
|
.check-group .check .cell-muted { margin-left: auto; padding-left: 12px; font-size: var(--fz-xs); }
|
||||||
|
|
||||||
/* popover menu (language picker, profile, row kebab) — a <button popovertarget> and its [popover]
|
/* popover menu (language picker, profile, row kebab) — a <button popovertarget> and its [popover]
|
||||||
@@ -491,16 +532,24 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
.menu { display: inline-flex; }
|
.menu { display: inline-flex; }
|
||||||
.menu-pop {
|
.menu-pop {
|
||||||
position: absolute; inset: auto; margin: 6px 0 0;
|
position: absolute; inset: auto; margin: 6px 0 0;
|
||||||
position-anchor: auto;
|
min-width: 210px; max-width: min(320px, 92vw); padding: 6px;
|
||||||
position-try-fallbacks: flip-block, flip-inline;
|
max-height: 60vh; overflow-y: auto;
|
||||||
top: anchor(bottom); right: anchor(right);
|
|
||||||
min-width: 210px; padding: 6px;
|
|
||||||
background: var(--surface); color: var(--text);
|
background: var(--surface); color: var(--text);
|
||||||
border: 1px solid var(--border-2); border-radius: var(--radius);
|
border: 1px solid var(--border-2); border-radius: var(--radius);
|
||||||
box-shadow: 0 8px 28px rgba(0,0,0,.16);
|
box-shadow: 0 8px 28px rgba(0,0,0,.16);
|
||||||
}
|
}
|
||||||
.menu-pop.left { right: auto; left: anchor(left); }
|
.menu-pop.up { margin: 0 0 6px; }
|
||||||
.menu-pop.up { top: auto; bottom: anchor(top); margin: 0 0 6px; }
|
@supports (overflow-wrap: anywhere) {
|
||||||
|
.menu-pop { overflow-wrap: anywhere; }
|
||||||
|
}
|
||||||
|
@supports (position-anchor: auto) and (top: anchor(bottom)) {
|
||||||
|
.menu-pop { position-anchor: auto; top: anchor(bottom); right: anchor(right); }
|
||||||
|
.menu-pop.left { right: auto; left: anchor(left); }
|
||||||
|
.menu-pop.up { top: auto; bottom: anchor(top); }
|
||||||
|
}
|
||||||
|
@supports (position-try-fallbacks: flip-block, flip-inline) {
|
||||||
|
.menu-pop { position-try-fallbacks: flip-block, flip-inline; }
|
||||||
|
}
|
||||||
/* A browser too old for the Popover API leaves the trigger inert, so let each panel flow under its
|
/* A browser too old for the Popover API leaves the trigger inert, so let each panel flow under its
|
||||||
trigger rather than stranding Sign out and the language picker behind a button that does nothing.
|
trigger rather than stranding Sign out and the language picker behind a button that does nothing.
|
||||||
The wrapper turns block so the pair stacks instead of sitting side by side, and min-width goes —
|
The wrapper turns block so the pair stacks instead of sitting side by side, and min-width goes —
|
||||||
@@ -531,7 +580,10 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
.menu-check { display: flex; align-items: center; gap: 9px; width: 100%;
|
.menu-check { display: flex; align-items: center; gap: 9px; width: 100%;
|
||||||
padding: 6px 8px; border-radius: 4px; font-size: var(--fz-sm); cursor: pointer; }
|
padding: 6px 8px; border-radius: 4px; font-size: var(--fz-sm); cursor: pointer; }
|
||||||
.menu-check:hover { background: var(--surface-2); }
|
.menu-check:hover { background: var(--surface-2); }
|
||||||
.menu-check input { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; }
|
.menu-check input { width: 15px; height: 15px; margin: 0; }
|
||||||
|
@supports (accent-color: auto) {
|
||||||
|
.menu-check input { accent-color: var(--accent); }
|
||||||
|
}
|
||||||
|
|
||||||
/* active filter pills */
|
/* active filter pills */
|
||||||
.active-pills { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
|
.active-pills { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
|
||||||
@@ -553,13 +605,12 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
|||||||
.pill-clear:hover { text-decoration: underline; }
|
.pill-clear:hover { text-decoration: underline; }
|
||||||
|
|
||||||
/* ---------- 9. TABLE --------------------------------------- */
|
/* ---------- 9. TABLE --------------------------------------- */
|
||||||
.table-wrap { flex: 1 1 auto; min-height: 0; overflow: auto; }
|
.table-wrap { overflow-x: auto; }
|
||||||
table.table {
|
table.table {
|
||||||
width: 100%; border-collapse: separate; border-spacing: 0;
|
width: 100%; border-collapse: separate; border-spacing: 0;
|
||||||
font-size: var(--fz); font-variant-numeric: tabular-nums;
|
font-size: var(--fz); font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
.table thead th {
|
.table thead th {
|
||||||
position: sticky; top: 0; z-index: 10;
|
|
||||||
background: var(--surface-3);
|
background: var(--surface-3);
|
||||||
border-bottom: 1px solid var(--border-2);
|
border-bottom: 1px solid var(--border-2);
|
||||||
color: var(--text-muted); font-weight: 600; font-size: var(--fz-xs);
|
color: var(--text-muted); font-weight: 600; font-size: var(--fz-xs);
|
||||||
@@ -574,15 +625,19 @@ table.table {
|
|||||||
}
|
}
|
||||||
.table tbody th { font-weight: inherit; text-align: left; } /* row-header cell, not bold/centered */
|
.table tbody th { font-weight: inherit; text-align: left; } /* row-header cell, not bold/centered */
|
||||||
.table tbody tr:hover td, .table tbody tr:hover th { background: var(--surface-2); }
|
.table tbody tr:hover td, .table tbody tr:hover th { background: var(--surface-2); }
|
||||||
.table tbody tr:has(.row-select:checked) td,
|
@supports selector(:has(a)) {
|
||||||
.table tbody tr:has(.row-select:checked) th { background: var(--accent-bg); }
|
.table tbody tr:has(.row-select:checked) td,
|
||||||
|
.table tbody tr:has(.row-select:checked) th { background: var(--accent-bg); }
|
||||||
|
}
|
||||||
.col-check { width: 36px; text-align: center; padding: 0; }
|
.col-check { width: 36px; text-align: center; padding: 0; }
|
||||||
.col-check input { accent-color: var(--accent); width: 15px; height: 15px;
|
.col-check input { width: 15px; height: 15px; margin: 0; vertical-align: middle; }
|
||||||
margin: 0; vertical-align: middle; }
|
@supports (accent-color: auto) {
|
||||||
|
.col-check input { accent-color: var(--accent); }
|
||||||
|
}
|
||||||
.col-num { text-align: right; }
|
.col-num { text-align: right; }
|
||||||
.cell-strong { font-weight: 550; }
|
.cell-strong { font-weight: 550; }
|
||||||
.cell-muted { color: var(--text-muted); }
|
.cell-muted { color: var(--text-muted); }
|
||||||
.table-empty { text-align: center; color: var(--text-muted); padding: 28px 12px; }
|
.table-empty { text-align: center; color: var(--text-muted); padding: 28px var(--pad-x); }
|
||||||
.cell-mono { font-variant-numeric: tabular-nums;
|
.cell-mono { font-variant-numeric: tabular-nums;
|
||||||
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: var(--fz-sm); }
|
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: var(--fz-sm); }
|
||||||
|
|
||||||
@@ -640,13 +695,15 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
|||||||
.kebab:hover { background: var(--surface-2); color: var(--text); }
|
.kebab:hover { background: var(--surface-2); color: var(--text); }
|
||||||
/* Held open — a trigger's panel is its next sibling. Its own rule: :has() is non-forgiving, so an
|
/* Held open — a trigger's panel is its next sibling. Its own rule: :has() is non-forgiving, so an
|
||||||
engine that doesn't know :popover-open would drop the hover style along with it. */
|
engine that doesn't know :popover-open would drop the hover style along with it. */
|
||||||
.kebab:has(+ .menu-pop:popover-open) { background: var(--surface-2); color: var(--text); }
|
@supports selector(.kebab:has(+ .menu-pop:popover-open)) {
|
||||||
|
.kebab:has(+ .menu-pop:popover-open) { background: var(--surface-2); color: var(--text); }
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- 10. PAGINATION --------------------------------- */
|
/* ---------- 10. PAGINATION --------------------------------- */
|
||||||
.pager {
|
.pager {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
display: flex; align-items: center; gap: 12px;
|
display: flex; align-items: center; gap: 12px;
|
||||||
padding: 8px 16px; border-top: 1px solid var(--border);
|
padding: 8px var(--pad-x); border-top: 1px solid var(--border);
|
||||||
background: var(--surface); font-size: var(--fz-sm); color: var(--text-muted);
|
background: var(--surface); font-size: var(--fz-sm); color: var(--text-muted);
|
||||||
}
|
}
|
||||||
.pager .spacer { flex: 1 1 auto; }
|
.pager .spacer { flex: 1 1 auto; }
|
||||||
@@ -661,20 +718,25 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
|||||||
}
|
}
|
||||||
.page-btn:hover { background: var(--surface-2); color: var(--text); }
|
.page-btn:hover { background: var(--surface-2); color: var(--text); }
|
||||||
.page-btn[aria-current="page"] { background: var(--accent); border-color: var(--accent);
|
.page-btn[aria-current="page"] { background: var(--accent); border-color: var(--accent);
|
||||||
color: #fff; font-weight: 600; }
|
color: var(--on-accent); font-weight: 600; }
|
||||||
.page-btn:disabled { opacity: .4; cursor: not-allowed; }
|
.page-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||||
|
|
||||||
/* ---------- 11. RESPONSIVE / HAMBURGER --------------------- */
|
/* ---------- 11. RESPONSIVE / HAMBURGER --------------------- */
|
||||||
.scrim { display: none; }
|
.scrim { display: none; }
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
.app { grid-template-columns: minmax(0, 1fr); }
|
.app { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto 1fr; }
|
||||||
|
.content { grid-column: 1; grid-row: 2; }
|
||||||
|
.search { min-width: 150px; flex: 1 1 auto; }
|
||||||
|
|
||||||
|
/* Off-canvas only where :has() works: without it nothing can read #nav-toggle, so the sidebar
|
||||||
|
stays in the flow above the content and the hamburger stays hidden. */
|
||||||
|
@supports selector(:has(a)) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: fixed; inset: 0 auto 0 0; width: min(86vw, var(--nav-w));
|
position: fixed; inset: 0 auto 0 0; width: min(86vw, var(--nav-w));
|
||||||
z-index: 60; transform: translateX(-100%);
|
z-index: 60; transform: translateX(-100%);
|
||||||
box-shadow: 2px 0 24px rgba(0,0,0,.18);
|
box-shadow: 2px 0 24px rgba(0,0,0,.18);
|
||||||
}
|
}
|
||||||
.content { grid-column: 1; }
|
|
||||||
.hamburger { display: inline-flex; }
|
.hamburger { display: inline-flex; }
|
||||||
|
|
||||||
/* checkbox-driven open state */
|
/* checkbox-driven open state */
|
||||||
@@ -683,7 +745,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
|||||||
display: block; position: fixed; inset: 0; z-index: 50;
|
display: block; position: fixed; inset: 0; z-index: 50;
|
||||||
background: rgba(0,0,0,.42);
|
background: rgba(0,0,0,.42);
|
||||||
}
|
}
|
||||||
.search { min-width: 150px; flex: 1 1 auto; }
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.crumbs, .page-sub { display: none; }
|
.crumbs, .page-sub { display: none; }
|
||||||
@@ -691,10 +753,10 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* the nav-toggle checkbox itself is visually hidden but focusable */
|
/* the nav-toggle checkbox itself is visually hidden but focusable */
|
||||||
#nav-toggle { position: absolute; opacity: 0; pointer-events: none; }
|
#nav-toggle { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
|
||||||
|
|
||||||
/* admin forms: create/edit user, account actions */
|
/* admin forms: create/edit user, account actions */
|
||||||
.form-page { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
|
.form-page { padding: 16px var(--pad-x); display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
|
||||||
.form-card {
|
.form-card {
|
||||||
display: flex; flex-direction: column; gap: 14px;
|
display: flex; flex-direction: column; gap: 14px;
|
||||||
padding: 18px; background: var(--surface);
|
padding: 18px; background: var(--surface);
|
||||||
@@ -716,5 +778,6 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
|||||||
/* Chromeless shell: a page may drop the sidebar for a focused single column. */
|
/* Chromeless shell: a page may drop the sidebar for a focused single column. */
|
||||||
.app-bare { grid-template-columns: minmax(0, 1fr); }
|
.app-bare { grid-template-columns: minmax(0, 1fr); }
|
||||||
.app-bare .content { grid-column: 1; }
|
.app-bare .content { grid-column: 1; }
|
||||||
|
|
||||||
/* Auth/landing rendered inside the app shell: a roomy, centered column in the content area. */
|
/* Auth/landing rendered inside the app shell: a roomy, centered column in the content area. */
|
||||||
.shell-auth { flex: 1 1 auto; overflow-y: auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; }
|
.shell-auth { flex: 1 1 auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; }
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { test } from "node:test";
|
||||||
|
|
||||||
|
test("auto-release's checkout doesn't persist credentials, so the tag push authenticates as renovate-bot", () => {
|
||||||
|
const workflow = readFileSync(".gitea/workflows/renovate.yml", "utf8");
|
||||||
|
const job = workflow.slice(workflow.indexOf("\n auto-release:"));
|
||||||
|
const checkout = job.slice(job.indexOf("actions/checkout@"), job.indexOf("\n - name:"));
|
||||||
|
assert.match(checkout, /^\s+persist-credentials: false$/m);
|
||||||
|
});
|
||||||
@@ -12,7 +12,7 @@ test("readHostApiVersion pulls the constant out of the real source, and returns
|
|||||||
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
|
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
|
||||||
// Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
|
// Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
|
||||||
// makes an accidental edit fail here rather than at release time.
|
// makes an accidental edit fail here rather than at release time.
|
||||||
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.2.0");
|
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.4.0");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("every author-facing apiVersion sample matches the shipped contract", () => {
|
test("every author-facing apiVersion sample matches the shipped contract", () => {
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ services:
|
|||||||
|
|
||||||
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025
|
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025
|
||||||
mailpit:
|
mailpit:
|
||||||
image: axllent/mailpit:v1.31.0
|
image: axllent/mailpit:v1.33.0
|
||||||
ports:
|
ports:
|
||||||
- "8025:8025"
|
- "8025:8025"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -163,7 +163,7 @@ Every behaviour is an explicit env toggle read at boot — no `NODE_ENV`. The co
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | `admin@plainpages.local` / `admin` | the seeded first admin (bootstrap service) |
|
| `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 |
|
| `APP_URL` | unset | canonical public URL; off-host visitors are redirected to it |
|
||||||
| `CACHE_TEMPLATES` | `false` | cache compiled templates (`true` in prod) |
|
| `CACHE_TEMPLATES` | `false` | cache compiled templates and `/public/` asset versions (`true` in prod) |
|
||||||
| `CSRF_SECRET` | dev throwaway | signs the CSRF token — set a real one 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 |
|
| `KRATOS_*` / `KETO_*` / `HYDRA_*` URLs | compose defaults | the Ory sidecar endpoints |
|
||||||
| `LOG_FORMAT` / `LOG_LEVEL` | `text` / `info` | `json` for structured prod logs |
|
| `LOG_FORMAT` / `LOG_LEVEL` | `text` / `info` | `json` for structured prod logs |
|
||||||
@@ -182,7 +182,7 @@ into the app. Create `plugins/hello/plugin.ts`:
|
|||||||
import { definePlugin } from "@plainpages/plugin-api";
|
import { definePlugin } from "@plainpages/plugin-api";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.2.0",
|
apiVersion: "0.4.0",
|
||||||
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
||||||
routes: [
|
routes: [
|
||||||
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { jwtFrom, leftoverPlaceholders, renderOverview } from "./dockerhub-overview.ts";
|
import { DESCRIPTION, hubPatch, jwtFrom, leftoverPlaceholders, renderOverview } from "./dockerhub-overview.ts";
|
||||||
|
|
||||||
const TEMPLATE = "release-tooling/dockerhub-overview.md.tmpl";
|
const TEMPLATE = "release-tooling/dockerhub-overview.md.tmpl";
|
||||||
const template = () => readFileSync(TEMPLATE, "utf8");
|
const template = () => readFileSync(TEMPLATE, "utf8");
|
||||||
@@ -46,3 +46,28 @@ test("jwtFrom accepts only a non-empty string token, never throwing on a hostile
|
|||||||
assert.equal(jwtFrom({ token: "" }), null);
|
assert.equal(jwtFrom({ token: "" }), null);
|
||||||
assert.equal(jwtFrom({ token: 42 }), null);
|
assert.equal(jwtFrom({ token: 42 }), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the short description opens a README sentence and fits Docker Hub's 100 characters", () => {
|
||||||
|
const readme = readFileSync("README.md", "utf8").replaceAll("**", "");
|
||||||
|
assert.ok(DESCRIPTION.trim() !== "" && DESCRIPTION.length <= 100, `${DESCRIPTION.length} characters`);
|
||||||
|
const escaped = DESCRIPTION.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
assert.match(readme, new RegExp(`(?:\\n\\n|\\.\\s)${escaped}(?=[\\s.:,])`));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every command the overview shows, the README shows too", () => {
|
||||||
|
const commands = (source: string) =>
|
||||||
|
[...source.matchAll(/```bash\n(.*?)```/gs)].flatMap((block) =>
|
||||||
|
(block[1] ?? "")
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => (line.split(" #")[0] ?? "").trim().replace(/larvit\/plainpages:\S+/g, "larvit/plainpages:X.Y.Z"))
|
||||||
|
.filter((line) => line !== ""),
|
||||||
|
);
|
||||||
|
const shown = new Set(commands(readFileSync("README.md", "utf8")));
|
||||||
|
const published = commands(template());
|
||||||
|
assert.ok(published.length > 0, `${TEMPLATE} should show commands`);
|
||||||
|
for (const command of published) assert.ok(shown.has(command), `${TEMPLATE} shows ${command}, which README.md doesn't`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the PATCH carries the short description beside the overview", () => {
|
||||||
|
assert.deepEqual(hubPatch("# page"), { description: DESCRIPTION, full_description: "# page" });
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
// Publishes the Docker Hub repository overview from dockerhub-overview.md.tmpl, rendering
|
|
||||||
// `{{VERSION}}` to the release being published.
|
|
||||||
|
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
@@ -8,6 +5,12 @@ const HUB = "https://hub.docker.com/v2";
|
|||||||
const TIMEOUT_MS = 30_000;
|
const TIMEOUT_MS = 30_000;
|
||||||
const VERSION = /^\d+\.\d+\.\d+$/;
|
const VERSION = /^\d+\.\d+\.\d+$/;
|
||||||
|
|
||||||
|
export const DESCRIPTION = "A self-hostable foundation for server-rendered web applications";
|
||||||
|
|
||||||
|
export function hubPatch(overview: string): { description: string; full_description: string } {
|
||||||
|
return { description: DESCRIPTION, full_description: overview };
|
||||||
|
}
|
||||||
|
|
||||||
export function renderOverview(source: string, version: string): string {
|
export function renderOverview(source: string, version: string): string {
|
||||||
return source.replaceAll("{{VERSION}}", version);
|
return source.replaceAll("{{VERSION}}", version);
|
||||||
}
|
}
|
||||||
@@ -81,21 +84,21 @@ async function main(): Promise<number> {
|
|||||||
if (!jwt) return fail("Docker Hub login returned no token");
|
if (!jwt) return fail("Docker Hub login returned no token");
|
||||||
|
|
||||||
const res = await post(`${HUB}/repositories/${repo}/`, {
|
const res = await post(`${HUB}/repositories/${repo}/`, {
|
||||||
body: JSON.stringify({ full_description: body }),
|
body: JSON.stringify(hubPatch(body)),
|
||||||
headers: { authorization: `Bearer ${jwt}`, "content-type": "application/json" },
|
headers: { authorization: `Bearer ${jwt}`, "content-type": "application/json" },
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
});
|
});
|
||||||
if ("error" in res) return fail(`Docker Hub unreachable: ${res.error}`);
|
if ("error" in res) return fail(`Docker Hub unreachable: ${res.error}`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return fail(
|
return fail(
|
||||||
`Docker Hub overview PATCH failed: ${res.status} ${res.text}` +
|
`Docker Hub description and overview PATCH failed: ${res.status} ${res.text}` +
|
||||||
(res.status === 403
|
(res.status === 403
|
||||||
? "\n403 means DOCKERHUB_TOKEN lacks the delete scope — editing the overview needs " +
|
? "\n403 means DOCKERHUB_TOKEN lacks the delete scope — editing the overview needs " +
|
||||||
"read/write/delete, which pushing images alone does not (README -> CI/CD)."
|
"read/write/delete, which pushing images alone does not (README -> CI/CD)."
|
||||||
: ""),
|
: ""),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
process.stdout.write(`Docker Hub overview updated for ${repo} at ${version}\n`);
|
process.stdout.write(`Docker Hub description and overview updated for ${repo} at ${version}\n`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,9 +100,7 @@ test("collects oidc nodes as SSO providers (text logo = initial), keeping csrf a
|
|||||||
assert.deepEqual(view.buttons, [{ label: "Sign in", name: "method", value: "password" }]);
|
assert.deepEqual(view.buttons, [{ label: "Sign in", name: "method", value: "password" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the code field guards a pasted space: one-time-code autofill + numeric inputmode + digits-only pattern", () => {
|
test("the code field takes a pasted code: one-time-code autofill + numeric inputmode + digits, whitespace around them allowed", () => {
|
||||||
// Verification/recovery enter a numeric OTP. Kratos doesn't trim, so a stray pasted space makes it
|
|
||||||
// reject the code as "invalid"; a digits-only pattern blocks that in the browser before submit.
|
|
||||||
const view = buildFlowView(
|
const view = buildFlowView(
|
||||||
flow([
|
flow([
|
||||||
node({ name: "csrf_token", type: "hidden", value: "tok" }),
|
node({ name: "csrf_token", type: "hidden", value: "tok" }),
|
||||||
@@ -113,18 +111,26 @@ test("the code field guards a pasted space: one-time-code autofill + numeric inp
|
|||||||
);
|
);
|
||||||
assert.deepEqual(view.fields.find((f) => f.name === "code"), {
|
assert.deepEqual(view.fields.find((f) => f.name === "code"), {
|
||||||
autocomplete: "one-time-code", // Kratos sends none for the OTP node — enable OS/email autofill
|
autocomplete: "one-time-code", // Kratos sends none for the OTP node — enable OS/email autofill
|
||||||
hint: "Digits only — no spaces.", // the pattern refusal alone reads as a bare "match the requested format"
|
hint: "Digits only.", // the pattern refusal alone reads as a bare "match the requested format"
|
||||||
icon: "i-shield",
|
icon: "i-shield",
|
||||||
id: "field-code",
|
id: "field-code",
|
||||||
inputmode: "numeric",
|
inputmode: "numeric",
|
||||||
label: "Verification code",
|
label: "Verification code",
|
||||||
name: "code",
|
name: "code",
|
||||||
pattern: "[0-9]*",
|
pattern: "\\s*[0-9]*\\s*",
|
||||||
required: true,
|
required: true,
|
||||||
type: "text",
|
type: "text",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the code flows post to the host, which trims the code; every other flow posts to Kratos", () => {
|
||||||
|
for (const type of ["recovery", "verification"] as const) assert.equal(buildFlowView(flow([]), type).action, `/${type}?flow=f1`);
|
||||||
|
for (const type of ["login", "registration", "settings"] as const) {
|
||||||
|
assert.equal(buildFlowView(flow([]), type).action, "http://127.0.0.1:4433/self-service/login?flow=f1");
|
||||||
|
}
|
||||||
|
assert.equal(buildFlowView({ ...flow([]), id: "a&b" }, "recovery").action, "/recovery?flow=a%26b");
|
||||||
|
});
|
||||||
|
|
||||||
test("chrome varies per flow type: registration alt, recovery back link", () => {
|
test("chrome varies per flow type: registration alt, recovery back link", () => {
|
||||||
const reg = buildFlowView(flow([]), "registration");
|
const reg = buildFlowView(flow([]), "registration");
|
||||||
assert.equal(reg.title, "Create account");
|
assert.equal(reg.title, "Create account");
|
||||||
|
|||||||
+15
-8
@@ -1,8 +1,8 @@
|
|||||||
// Kratos flow → themed view model. Pure: turns a fetched self-service Flow
|
// Kratos flow → themed view model. Pure: turns a fetched self-service Flow
|
||||||
// (src/auth/kratos-public.ts) into the data views/auth.ejs renders — hidden inputs (incl. the
|
// (src/auth/kratos-public.ts) into the data views/auth.ejs renders — hidden inputs (incl. the
|
||||||
// CSRF token), themed fields, submit buttons, tone-mapped messages, and one SSO button per
|
// CSRF token), themed fields, submit buttons, tone-mapped messages, and one SSO button per
|
||||||
// configured `oidc` provider. The form posts straight back to `flow.ui.action`, so Kratos
|
// configured `oidc` provider. The form posts to `flow.ui.action`, except a CODE_FLOWS form, which
|
||||||
// owns its CSRF; we only render and map errors. No providers configured ⇒ no SSO buttons.
|
// posts to the host first; Kratos owns the CSRF of both. No providers configured ⇒ no SSO buttons.
|
||||||
|
|
||||||
import { ENGLISH } from "../i18n/english.ts";
|
import { ENGLISH } from "../i18n/english.ts";
|
||||||
import type { Translate } from "../i18n/translate.ts";
|
import type { Translate } from "../i18n/translate.ts";
|
||||||
@@ -17,7 +17,7 @@ export interface FlowField {
|
|||||||
inputmode?: string; // virtual-keyboard hint (e.g. "numeric" for the OTP code)
|
inputmode?: string; // virtual-keyboard hint (e.g. "numeric" for the OTP code)
|
||||||
label: string;
|
label: string;
|
||||||
name: string;
|
name: string;
|
||||||
pattern?: string; // client-side validity regex; blocks a pasted space before it reaches Kratos
|
pattern?: string; // client-side validity regex
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
type: string;
|
type: string;
|
||||||
value?: string;
|
value?: string;
|
||||||
@@ -69,6 +69,9 @@ export const AUTH_FLOWS: Record<string, FlowType> = {
|
|||||||
"/verification": "verification",
|
"/verification": "verification",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The flows that take an emailed one-time code. Their form posts to the host, which trims the code.
|
||||||
|
export const CODE_FLOWS: ReadonlySet<FlowType> = new Set(["recovery", "verification"]);
|
||||||
|
|
||||||
// Where each flow's card links; its words come from the catalog under `auth.<flow>.*`.
|
// Where each flow's card links; its words come from the catalog under `auth.<flow>.*`.
|
||||||
const LINKS: Record<FlowType, { alt?: string; back?: boolean }> = {
|
const LINKS: Record<FlowType, { alt?: string; back?: boolean }> = {
|
||||||
login: { alt: "/registration" },
|
login: { alt: "/registration" },
|
||||||
@@ -123,9 +126,9 @@ const ssoLogo = (value: string): string => (value.charAt(0) || "?").toUpperCase(
|
|||||||
|
|
||||||
function toField(node: UiNode, name: string, type: string, t: Translate): FlowField {
|
function toField(node: UiNode, name: string, type: string, t: Translate): FlowField {
|
||||||
const value = str(node.attributes["value"]);
|
const value = str(node.attributes["value"]);
|
||||||
// The recovery/verification one-time code: numeric, and Kratos doesn't trim it, so a stray pasted
|
// The recovery/verification one-time code is numeric: a numeric keypad and a digits pattern. The
|
||||||
// space makes it reject the code as "invalid". A digits-only pattern + numeric keypad block that in
|
// pattern allows whitespace around the digits, as a paste brings; routes.ts → codeFlowSubmit trims it.
|
||||||
// the browser; one-time-code enables OS/email autofill (Kratos sends no autocomplete for the node).
|
// one-time-code enables OS/email autofill (Kratos sends no autocomplete for the node).
|
||||||
const isCode = name === "code";
|
const isCode = name === "code";
|
||||||
const autocomplete = str(node.attributes["autocomplete"]) ?? (isCode ? "one-time-code" : undefined);
|
const autocomplete = str(node.attributes["autocomplete"]) ?? (isCode ? "one-time-code" : undefined);
|
||||||
const icon = iconFor(name, type);
|
const icon = iconFor(name, type);
|
||||||
@@ -140,12 +143,16 @@ function toField(node: UiNode, name: string, type: string, t: Translate): FlowFi
|
|||||||
...(autocomplete ? { autocomplete } : {}),
|
...(autocomplete ? { autocomplete } : {}),
|
||||||
...(errorMsg ? { error: { text: kratosText(t, errorMsg.text, idKey(errorMsg.id)) } } : {}),
|
...(errorMsg ? { error: { text: kratosText(t, errorMsg.text, idKey(errorMsg.id)) } } : {}),
|
||||||
...(icon ? { icon } : {}),
|
...(icon ? { icon } : {}),
|
||||||
...(isCode ? { hint: t("auth.field.code.hint"), inputmode: "numeric", pattern: "[0-9]*" } : {}),
|
...(isCode ? { hint: t("auth.field.code.hint"), inputmode: "numeric", pattern: "\\s*[0-9]*\\s*" } : {}),
|
||||||
...(node.attributes["required"] === true ? { required: true } : {}),
|
...(node.attributes["required"] === true ? { required: true } : {}),
|
||||||
...(value ? { value } : {}),
|
...(value ? { value } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formAction(flow: Flow, type: FlowType): string {
|
||||||
|
return CODE_FLOWS.has(type) ? `/${type}?flow=${encodeURIComponent(flow.id)}` : flow.ui.action;
|
||||||
|
}
|
||||||
|
|
||||||
export function buildFlowView(flow: Flow, type: FlowType, t: Translate = ENGLISH): FlowView {
|
export function buildFlowView(flow: Flow, type: FlowType, t: Translate = ENGLISH): FlowView {
|
||||||
const hidden: { name: string; value: string }[] = [];
|
const hidden: { name: string; value: string }[] = [];
|
||||||
const fields: FlowField[] = [];
|
const fields: FlowField[] = [];
|
||||||
@@ -174,7 +181,7 @@ export function buildFlowView(flow: Flow, type: FlowType, t: Translate = ENGLISH
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
action: flow.ui.action,
|
action: formAction(flow, type),
|
||||||
buttons,
|
buttons,
|
||||||
fields,
|
fields,
|
||||||
hidden,
|
hidden,
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import type { User } from "../http/context.ts";
|
||||||
|
import { allows, gatesSet } from "./gate.ts";
|
||||||
|
|
||||||
|
const holder: User = { email: "holder@example.test", id: "01a06091-ba9f-765f-abf4-b5144c314bc7", permissions: ["x:read"] };
|
||||||
|
const stranger: User = { email: "stranger@example.test", id: "01a06091-baa3-7b4d-810a-c9ee7e559d98", permissions: [] };
|
||||||
|
|
||||||
|
test("allows: ungated and public are open to anyone; session needs a user; permission needs the token", () => {
|
||||||
|
assert.equal(allows({}, null), true);
|
||||||
|
assert.equal(allows({ public: true }, null), true);
|
||||||
|
|
||||||
|
assert.equal(allows({ session: true }, null), false);
|
||||||
|
assert.equal(allows({ session: true }, stranger), true); // signed in is the whole gate — no grant
|
||||||
|
|
||||||
|
assert.equal(allows({ permission: "x:read" }, null), false);
|
||||||
|
assert.equal(allows({ permission: "x:read" }, stranger), false);
|
||||||
|
assert.equal(allows({ permission: "x:read" }, holder), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gatesSet names the gates a declaration sets, so discovery can refuse more than one", () => {
|
||||||
|
assert.deepEqual(gatesSet({}), []);
|
||||||
|
assert.deepEqual(gatesSet({ session: true }), ["session"]);
|
||||||
|
assert.deepEqual(gatesSet({ permission: "x:read", public: true }), ["public", "permission"]);
|
||||||
|
assert.deepEqual(gatesSet({ permission: "x:read", public: true, session: true }), ["public", "session", "permission"]);
|
||||||
|
// Only `true` sets a gate, so a manifest spelling one `false` names none — which discovery refuses.
|
||||||
|
assert.deepEqual(gatesSet({ public: false, session: false }), []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// One home for the gate rule, so the router and the menu can never disagree about what a visitor
|
||||||
|
// may reach. README → Public pages & menu items.
|
||||||
|
import type { User } from "../http/context.ts";
|
||||||
|
|
||||||
|
const GATES = ["public", "session", "permission"] as const;
|
||||||
|
|
||||||
|
export interface Gate {
|
||||||
|
permission?: string; // the Keto Permission the caller must hold, `<resource>:<action>`
|
||||||
|
public?: boolean; // anyone, signed in or not
|
||||||
|
session?: boolean; // any signed-in user, no grant to hold; anonymous is sent to /login
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allows(gate: Gate, user: User | null): boolean {
|
||||||
|
if (gate.public === true) return true;
|
||||||
|
if (gate.session === true) return user !== null;
|
||||||
|
return gate.permission == null || (user?.permissions.includes(gate.permission) ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gatesSet(gate: Gate | null | undefined): string[] {
|
||||||
|
if (gate == null) return [];
|
||||||
|
return GATES.filter((name) => (name === "permission" ? gate.permission != null : gate[name] === true));
|
||||||
|
}
|
||||||
@@ -100,9 +100,9 @@ test("updateMetadataPublic PATCHes a JSON-Patch `add /metadata_public` so it nev
|
|||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
const { calls, fetchImpl } = recorder(() => res(200, { recovery_code: "123456", recovery_link: "http://kratos/self-service/recovery?flow=f&code=123456" }));
|
const { calls, fetchImpl } = recorder(() => res(200, { recovery_code: "123456", recovery_link: "http://kratos/self-service/recovery?flow=f" }));
|
||||||
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).createRecoveryCode(ID);
|
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).createRecoveryCode(ID);
|
||||||
assert.deepEqual(out, { code: "123456", link: "http://kratos/self-service/recovery?flow=f&code=123456" });
|
assert.deepEqual(out, { code: "123456", link: "http://kratos/self-service/recovery?flow=f" });
|
||||||
assert.equal(calls[0]!.method, "POST");
|
assert.equal(calls[0]!.method, "POST");
|
||||||
assert.match(calls[0]!.url, /\/admin\/recovery\/code$/);
|
assert.match(calls[0]!.url, /\/admin\/recovery\/code$/);
|
||||||
assert.deepEqual(JSON.parse(calls[0]!.body!), { identity_id: ID });
|
assert.deepEqual(JSON.parse(calls[0]!.body!), { identity_id: ID });
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export interface ListOptions {
|
|||||||
pageToken?: string;
|
pageToken?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A one-time recovery code + the self-service link wrapping it (admin "trigger recovery").
|
// A one-time recovery code + the URL of the recovery flow it was minted on, the only flow that accepts it.
|
||||||
export interface RecoveryCode {
|
export interface RecoveryCode {
|
||||||
code: string;
|
code: string;
|
||||||
link: string;
|
link: string;
|
||||||
@@ -65,8 +65,8 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
|
|||||||
return (await res.json()) as Identity;
|
return (await res.json()) as Identity;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 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"). Kratos mails nothing, so the
|
||||||
// user by Kratos; the code/link are also returned so an operator can hand them over directly.
|
// operator hands the code and link to the user.
|
||||||
async createRecoveryCode(userId, opts = {}) {
|
async createRecoveryCode(userId, opts = {}) {
|
||||||
const body: Record<string, unknown> = { identity_id: userId };
|
const body: Record<string, unknown> = { identity_id: userId };
|
||||||
if (opts.expiresIn) body.expires_in = opts.expiresIn;
|
if (opts.expiresIn) body.expires_in = opts.expiresIn;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Kratos public-API client: typed fetch wrappers over Ory Kratos' public endpoints.
|
// Kratos public-API client: typed fetch wrappers over Ory Kratos' public endpoints.
|
||||||
// Guards the request contracts (URLs, JSON-accept, cookie relay) and the result mapping
|
// Guards the request contracts (URLs, JSON-accept, cookie relay) and the result mapping
|
||||||
// (200/401/4xx, validation-flow vs success, tokenized JWT). Live wiring is verified by the
|
// (200/401/4xx, a submit's redirect, tokenized JWT). Live wiring is verified by the
|
||||||
// flow pages; these catch contract drift with a mock fetch.
|
// flow pages; these catch contract drift with a mock fetch.
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
@@ -17,12 +17,13 @@ function res(status: number, body?: unknown, setCookie: string[] = []): Response
|
|||||||
|
|
||||||
// Records each call so a test can assert URL/method/headers/body.
|
// Records each call so a test can assert URL/method/headers/body.
|
||||||
function recorder(handler: (url: string, init: RequestInit | undefined) => Response) {
|
function recorder(handler: (url: string, init: RequestInit | undefined) => Response) {
|
||||||
const calls: { body: string | undefined; headers: Headers; method: string; url: string }[] = [];
|
const calls: { body: string | undefined; headers: Headers; method: string; redirect: string | undefined; url: string }[] = [];
|
||||||
const fetchImpl = (async (input: unknown, init?: RequestInit) => {
|
const fetchImpl = (async (input: unknown, init?: RequestInit) => {
|
||||||
calls.push({
|
calls.push({
|
||||||
body: init?.body as string | undefined,
|
body: init?.body as string | undefined,
|
||||||
headers: new Headers(init?.headers),
|
headers: new Headers(init?.headers),
|
||||||
method: init?.method ?? "GET",
|
method: init?.method ?? "GET",
|
||||||
|
redirect: init?.redirect,
|
||||||
url: String(input),
|
url: String(input),
|
||||||
});
|
});
|
||||||
return handler(String(input), init);
|
return handler(String(input), init);
|
||||||
@@ -57,35 +58,28 @@ test("getFlow throws a KratosError carrying the status when the flow is gone (41
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("submitFlow POSTs urlencoded to the action and reports success + relays Set-Cookie", async () => {
|
test("submitFlow POSTs the form to the flow on Kratos' own base URL, as a browser would, and relays the redirect + Set-Cookie", async () => {
|
||||||
const { calls, fetchImpl } = recorder(() => res(200, { session: { active: true } }, ["plainpages_session=s; Path=/"]));
|
const { calls, fetchImpl } = recorder(() => new Response(null, {
|
||||||
|
headers: new Headers([["location", "http://app/settings?flow=s1"], ["set-cookie", "plainpages_session=s; Path=/"]]),
|
||||||
|
status: 303,
|
||||||
|
}));
|
||||||
const out = await createKratosPublic({ baseUrl: BASE, fetchImpl })
|
const out = await createKratosPublic({ baseUrl: BASE, fetchImpl })
|
||||||
.submitFlow(`${BASE}/self-service/login?flow=f`, { body: "identifier=a&password=b", cookie: "csrf_token=abc" });
|
.submitFlow("recovery", "f 1", { body: "code=123456&method=code", cookie: "csrf_token=abc" });
|
||||||
assert.equal(out.ok, true);
|
assert.deepEqual(out, { location: "http://app/settings?flow=s1", setCookie: ["plainpages_session=s; Path=/"], status: 303 });
|
||||||
assert.equal(out.status, 200);
|
assert.equal(calls[0]!.url, `${BASE}/self-service/recovery?flow=f+1`);
|
||||||
assert.deepEqual(out.setCookie, ["plainpages_session=s; Path=/"]);
|
|
||||||
assert.equal(calls[0]!.method, "POST");
|
assert.equal(calls[0]!.method, "POST");
|
||||||
|
assert.equal(calls[0]!.redirect, "manual");
|
||||||
|
assert.equal(calls[0]!.headers.get("accept"), "text/html");
|
||||||
|
assert.equal(calls[0]!.headers.get("referer"), `${BASE}/`);
|
||||||
assert.equal(calls[0]!.headers.get("content-type"), "application/x-www-form-urlencoded");
|
assert.equal(calls[0]!.headers.get("content-type"), "application/x-www-form-urlencoded");
|
||||||
assert.equal(calls[0]!.body, "identifier=a&password=b");
|
assert.equal(calls[0]!.headers.get("cookie"), "csrf_token=abc");
|
||||||
|
assert.equal(calls[0]!.body, "code=123456&method=code");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("submitFlow returns the re-rendered flow (no throw) on a 400 validation error", async () => {
|
test("submitFlow reports a 4xx without a redirect as a null location, and throws on a Kratos 5xx", async () => {
|
||||||
const flow = { id: "f", ui: { action: "x", messages: [{ id: 4000006, text: "invalid credentials", type: "error" }], method: "POST", nodes: [] } };
|
const k = (status: number) => createKratosPublic({ baseUrl: BASE, fetchImpl: recorder(() => res(status, { error: "x" })).fetchImpl });
|
||||||
const { fetchImpl } = recorder(() => res(400, flow));
|
assert.deepEqual(await k(400).submitFlow("verification", "f", { body: "x=1" }), { location: null, setCookie: [], status: 400 });
|
||||||
const out = await createKratosPublic({ baseUrl: BASE, fetchImpl }).submitFlow(`${BASE}/x`, { body: "x=1" });
|
await assert.rejects(k(500).submitFlow("verification", "f", { body: "x=1" }), (e: unknown) => e instanceof KratosError && e.status === 500);
|
||||||
assert.equal(out.ok, false);
|
|
||||||
assert.equal(out.status, 400);
|
|
||||||
assert.deepEqual(out.body, flow);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("submitFlow surfaces the redirect target — Location header or a 422 redirect_browser_to body", async () => {
|
|
||||||
const k = (handler: () => Response) => createKratosPublic({ baseUrl: BASE, fetchImpl: recorder(handler).fetchImpl });
|
|
||||||
const viaHeader = await k(() => new Response(null, { headers: new Headers({ location: "http://app/" }), status: 303 }))
|
|
||||||
.submitFlow(`${BASE}/x`, { body: "x=1" });
|
|
||||||
assert.equal(viaHeader.location, "http://app/");
|
|
||||||
const viaBody = await k(() => res(422, { redirect_browser_to: "http://app/login?flow=next" }))
|
|
||||||
.submitFlow(`${BASE}/x`, { body: "x=1" });
|
|
||||||
assert.equal(viaBody.location, "http://app/login?flow=next");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("whoami returns the session on 200 (cookie forwarded) and null on 401", async () => {
|
test("whoami returns the session on 200 (cookie forwarded) and null on 401", async () => {
|
||||||
|
|||||||
+13
-28
@@ -21,7 +21,7 @@ export interface UiNode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FlowUi {
|
export interface FlowUi {
|
||||||
action: string; // absolute Kratos URL the browser POSTs the form to (Kratos owns its CSRF)
|
action: string; // absolute Kratos URL the form submits to
|
||||||
messages?: UiText[];
|
messages?: UiText[];
|
||||||
method: string;
|
method: string;
|
||||||
nodes: UiNode[];
|
nodes: UiNode[];
|
||||||
@@ -51,9 +51,7 @@ export interface LogoutFlow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface FlowSubmission {
|
export interface FlowSubmission {
|
||||||
body: unknown; // parsed JSON: the re-rendered flow on 400, the success payload on 200
|
location: string | null; // where Kratos sends the browser next
|
||||||
location: string | null; // redirect target (Location header, or a 422 redirect_browser_to)
|
|
||||||
ok: boolean; // status === 200
|
|
||||||
setCookie: string[];
|
setCookie: string[];
|
||||||
status: number;
|
status: number;
|
||||||
}
|
}
|
||||||
@@ -74,23 +72,10 @@ export interface KratosPublic {
|
|||||||
createLogoutFlow(opts?: { cookie?: string }): Promise<LogoutFlow | null>; // null ⇒ no active session (401)
|
createLogoutFlow(opts?: { cookie?: string }): Promise<LogoutFlow | null>; // null ⇒ no active session (401)
|
||||||
getFlow(type: FlowType, id: string, opts?: { cookie?: string }): Promise<Flow>;
|
getFlow(type: FlowType, id: string, opts?: { cookie?: string }): Promise<Flow>;
|
||||||
initBrowserFlow(type: FlowType, opts?: { cookie?: string; returnTo?: string }): Promise<FlowInit>;
|
initBrowserFlow(type: FlowType, opts?: { cookie?: string; returnTo?: string }): Promise<FlowInit>;
|
||||||
submitFlow(action: string, opts: { body: string; contentType?: string; cookie?: string }): Promise<FlowSubmission>;
|
submitFlow(type: FlowType, flowId: string, opts: { body: string; cookie?: string }): Promise<FlowSubmission>;
|
||||||
whoami(opts?: { cookie?: string; tokenizeAs?: string }): Promise<Session | null>;
|
whoami(opts?: { cookie?: string; tokenizeAs?: string }): Promise<Session | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseBody(text: string): unknown {
|
|
||||||
if (!text) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createKratosPublic(config: { baseUrl: string; fetchImpl?: typeof fetch }): KratosPublic {
|
export function createKratosPublic(config: { baseUrl: string; fetchImpl?: typeof fetch }): KratosPublic {
|
||||||
const base = config.baseUrl.replace(/\/+$/, "");
|
const base = config.baseUrl.replace(/\/+$/, "");
|
||||||
const http = config.fetchImpl ?? fetch;
|
const http = config.fetchImpl ?? fetch;
|
||||||
@@ -129,16 +114,16 @@ export function createKratosPublic(config: { baseUrl: string; fetchImpl?: typeof
|
|||||||
return (await res.json()) as Flow;
|
return (await res.json()) as Flow;
|
||||||
},
|
},
|
||||||
|
|
||||||
async submitFlow(action, opts) {
|
async submitFlow(type, flowId, opts) {
|
||||||
const h = headers(opts.cookie);
|
const url = new URL(`${base}/self-service/${type}`);
|
||||||
h["content-type"] = opts.contentType ?? "application/x-www-form-urlencoded";
|
url.searchParams.set("flow", flowId);
|
||||||
// Manual redirect so we can read a 303 Location instead of following it server-side.
|
// Asked for HTML, Kratos answers with the redirect a browser posting to it would get. Its CSRF
|
||||||
const res = await http(action, { body: opts.body, headers: h, method: "POST", redirect: "manual" });
|
// check demands a same-origin Referer on an https request, and this server-side POST has none.
|
||||||
const body = parseBody(await res.text());
|
const h = { ...headers(opts.cookie), accept: "text/html", "content-type": "application/x-www-form-urlencoded", referer: `${base}/` };
|
||||||
const location =
|
const res = await http(url, { body: opts.body, headers: h, method: "POST", redirect: "manual" });
|
||||||
res.headers.get("location") ??
|
if (res.status >= 500) throw new KratosError(`Kratos submit ${type} flow failed (${res.status})`, res.status, await res.text());
|
||||||
(isRecord(body) && typeof body["redirect_browser_to"] === "string" ? body["redirect_browser_to"] : null);
|
await res.body?.cancel();
|
||||||
return { body, location, ok: res.status === 200, setCookie: res.headers.getSetCookie(), status: res.status };
|
return { location: res.headers.get("location"), setCookie: res.headers.getSetCookie(), status: res.status };
|
||||||
},
|
},
|
||||||
|
|
||||||
async whoami(opts = {}) {
|
async whoami(opts = {}) {
|
||||||
|
|||||||
+14
-2
@@ -92,12 +92,24 @@ test("completeLogin returns null and touches nothing when there is no active ses
|
|||||||
assert.equal(touched, false);
|
assert.equal(touched, false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("completeLogin maps a missing email trait to null and throws if the tokenizer yields no JWT", async () => {
|
test("completeLogin throws if the tokenizer yields no JWT", async () => {
|
||||||
const identity: Identity = { id: ID, traits: {} };
|
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
||||||
const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity }) as Session }); // never returns a tokenized JWT
|
const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity }) as Session }); // never returns a tokenized JWT
|
||||||
await assert.rejects(completeLogin({ keto: ketoStub(), kratosAdmin: adminStub(), kratosPublic }, "c"), /tokenizer returned no JWT/);
|
await assert.rejects(completeLogin({ keto: ketoStub(), kratosAdmin: adminStub(), kratosPublic }, "c"), /tokenizer returned no JWT/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// An identity with no email is no session, decided here so /auth/complete and remintSession cannot
|
||||||
|
// disagree: `claimsToUser` reads a token carrying none as anonymous, so minting one would hand the
|
||||||
|
// browser a cookie every later request refuses.
|
||||||
|
test("completeLogin refuses an identity carrying no email, before it mints anything", async () => {
|
||||||
|
const identity: Identity = { id: ID, traits: {} };
|
||||||
|
let touched = false;
|
||||||
|
const kratosAdmin = adminStub({ updateMetadataPublic: async () => { touched = true; return { id: ID }; } });
|
||||||
|
const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity, tokenized: "h.p.s" }) as Session });
|
||||||
|
assert.equal(await completeLogin({ keto: ketoStub(), kratosAdmin, kratosPublic }, "c"), null);
|
||||||
|
assert.equal(touched, false); // no Keto read, no metadata write, no JWT
|
||||||
|
});
|
||||||
|
|
||||||
test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => {
|
test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => {
|
||||||
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
||||||
const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
|
const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
|
||||||
|
|||||||
+9
-3
@@ -31,7 +31,7 @@ export interface LoginDeps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CompletedLogin {
|
export interface CompletedLogin {
|
||||||
email: string | null;
|
email: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
jwt: string;
|
jwt: string;
|
||||||
permissions: string[];
|
permissions: string[];
|
||||||
@@ -61,7 +61,13 @@ export async function completeLogin(deps: LoginDeps, cookie: string | undefined)
|
|||||||
if (!session?.identity) return null;
|
if (!session?.identity) return null;
|
||||||
const userId = session.identity.id;
|
const userId = session.identity.id;
|
||||||
const emailTrait = session.identity.traits?.["email"];
|
const emailTrait = session.identity.traits?.["email"];
|
||||||
const email = typeof emailTrait === "string" ? emailTrait : null;
|
const email = typeof emailTrait === "string" ? emailTrait : "";
|
||||||
|
// No email is no session: `claimsToUser` reads a token carrying none as anonymous, so minting one
|
||||||
|
// would hand the browser a cookie every later request refuses.
|
||||||
|
if (!email) {
|
||||||
|
currentLog()?.warn("session dropped: identity has no email", { sub: userId });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const permissions = await readPermissions(deps.keto, userId);
|
const permissions = await readPermissions(deps.keto, userId);
|
||||||
await deps.kratosAdmin.updateMetadataPublic(userId, { permissions });
|
await deps.kratosAdmin.updateMetadataPublic(userId, { permissions });
|
||||||
@@ -87,7 +93,7 @@ export interface Reminted {
|
|||||||
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
|
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
|
||||||
const completed = await completeLogin(deps, cookie);
|
const completed = await completeLogin(deps, cookie);
|
||||||
if (!completed) return { setCookie: clearSessionCookie(options), user: null };
|
if (!completed) return { setCookie: clearSessionCookie(options), user: null };
|
||||||
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.userId, permissions: completed.permissions } };
|
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email, id: completed.userId, permissions: completed.permissions } };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
|
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
|
||||||
|
|||||||
+10
-3
@@ -1,9 +1,10 @@
|
|||||||
// buildAuthRoutes registers only the endpoints the wired clients support — a missing client
|
// buildAuthRoutes registers only the endpoints the wired clients support — a missing client
|
||||||
// means the path isn't in the table at all (the host then 404s). The endpoints' behaviour is
|
// means the path isn't in the table at all (the host then 404s). The endpoints' behaviour is
|
||||||
// covered end-to-end through the app in src/http/app.test.ts.
|
// covered end-to-end through the app in src/http/app-auth.test.ts.
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { AUTH_FLOWS } from "./flow-view.ts";
|
import { AUTH_FLOWS } from "./flow-view.ts";
|
||||||
|
import { gatesSet } from "./gate.ts";
|
||||||
import type { HydraAdmin } from "./hydra-admin.ts";
|
import type { HydraAdmin } from "./hydra-admin.ts";
|
||||||
import type { KetoClient } from "./keto-client.ts";
|
import type { KetoClient } from "./keto-client.ts";
|
||||||
import type { KratosAdmin } from "./kratos-admin.ts";
|
import type { KratosAdmin } from "./kratos-admin.ts";
|
||||||
@@ -25,12 +26,14 @@ test("nothing wired ⇒ only the flow-error sink exists", () => {
|
|||||||
assert.deepEqual(keys(buildAuthRoutes(deps({}))), ["GET /error"]);
|
assert.deepEqual(keys(buildAuthRoutes(deps({}))), ["GET /error"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("kratos wired ⇒ the themed flow pages + POST /logout; no OAuth2, no /auth/complete", () => {
|
test("kratos wired ⇒ the themed flow pages, the two code-flow submits + POST /logout; no OAuth2, no /auth/complete", () => {
|
||||||
const got = keys(buildAuthRoutes(deps({ kratos })));
|
const got = keys(buildAuthRoutes(deps({ kratos })));
|
||||||
assert.deepEqual(got, keys([
|
assert.deepEqual(got, keys([
|
||||||
...Object.keys(AUTH_FLOWS).map((path) => ({ method: "GET", path })),
|
...Object.keys(AUTH_FLOWS).map((path) => ({ method: "GET", path })),
|
||||||
{ method: "GET", path: "/error" },
|
{ method: "GET", path: "/error" },
|
||||||
{ method: "POST", path: "/logout" },
|
{ method: "POST", path: "/logout" },
|
||||||
|
{ method: "POST", path: "/recovery" },
|
||||||
|
{ method: "POST", path: "/verification" },
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -39,8 +42,12 @@ test("hydra alone ⇒ only RP-initiated logout of the OAuth2 group (login/consen
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("everything wired ⇒ the full group: OAuth2 challenges, consent GET+POST, /auth/complete", () => {
|
test("everything wired ⇒ the full group: OAuth2 challenges, consent GET+POST, /auth/complete", () => {
|
||||||
const got = keys(buildAuthRoutes(deps({ hydra, keto, kratos, kratosAdmin })));
|
const routes = buildAuthRoutes(deps({ hydra, keto, kratos, kratosAdmin }));
|
||||||
|
const got = keys(routes);
|
||||||
for (const key of ["GET /auth/complete", "GET /login", "GET /oauth2/consent", "GET /oauth2/login", "GET /oauth2/logout", "POST /logout", "POST /oauth2/consent"]) {
|
for (const key of ["GET /auth/complete", "GET /login", "GET /oauth2/consent", "GET /oauth2/login", "GET /oauth2/logout", "POST /logout", "POST /oauth2/consent"]) {
|
||||||
assert.ok(got.includes(key), key);
|
assert.ok(got.includes(key), key);
|
||||||
}
|
}
|
||||||
|
// Discovery enforces exactly one gate per plugin declaration; nothing checks the host's own table
|
||||||
|
// at boot, so a route added here without a gate would be silently public.
|
||||||
|
for (const route of routes) assert.deepEqual(gatesSet(route), ["public"], `${route.method} ${route.path}`);
|
||||||
});
|
});
|
||||||
|
|||||||
+84
-47
@@ -1,5 +1,5 @@
|
|||||||
// The built-in auth/OAuth2 endpoints as named handlers on the host's internal route table
|
// The built-in auth/OAuth2 endpoints as named handlers on the host's internal route table
|
||||||
// (src/http/builtin-routes.ts): the themed Kratos self-service pages, the Hydra OAuth2
|
// (src/http/builtin-routes.ts): the themed Kratos self-service pages and their code submits, the Hydra OAuth2
|
||||||
// login/consent/logout challenges, login completion, the first-party logout, and Kratos'
|
// login/consent/logout challenges, login completion, the first-party logout, and Kratos'
|
||||||
// flow-error sink. buildAuthRoutes registers only what the wired clients support — a missing
|
// flow-error sink. buildAuthRoutes registers only what the wired clients support — a missing
|
||||||
// client means the path simply doesn't exist (404). `view` results render the core views.
|
// client means the path simply doesn't exist (404). `view` results render the core views.
|
||||||
@@ -8,11 +8,11 @@ import type { BuiltinRoute, RequestCsrf } from "../http/builtin-routes.ts";
|
|||||||
import type { RequestContext } from "../http/context.ts";
|
import type { RequestContext } from "../http/context.ts";
|
||||||
import { CSRF_FIELD } from "./csrf.ts";
|
import { CSRF_FIELD } from "./csrf.ts";
|
||||||
import { chosenLocale } from "../i18n/locale.ts";
|
import { chosenLocale } from "../i18n/locale.ts";
|
||||||
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
|
import { AUTH_FLOWS, buildFlowView, CODE_FLOWS } from "./flow-view.ts";
|
||||||
import { HydraError, type HydraAdmin } from "./hydra-admin.ts";
|
import { HydraError, type HydraAdmin } from "./hydra-admin.ts";
|
||||||
import type { KetoClient } from "./keto-client.ts";
|
import type { KetoClient } from "./keto-client.ts";
|
||||||
import type { KratosAdmin } from "./kratos-admin.ts";
|
import type { KratosAdmin } from "./kratos-admin.ts";
|
||||||
import { type Flow, type FlowType, KratosError, type KratosPublic } from "./kratos-public.ts";
|
import { type Flow, type FlowSubmission, type FlowType, KratosError, type KratosPublic } from "./kratos-public.ts";
|
||||||
import { clearSessionCookie, completeLogin, sessionCookie } from "./login.ts";
|
import { clearSessionCookie, completeLogin, sessionCookie } from "./login.ts";
|
||||||
import type { MenuConfig } from "../ui/menu-config.ts";
|
import type { MenuConfig } from "../ui/menu-config.ts";
|
||||||
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "./oauth-consent.ts";
|
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "./oauth-consent.ts";
|
||||||
@@ -38,52 +38,36 @@ const FORBIDDEN: RouteResult = { status: 403, view: "403" };
|
|||||||
const selfOrigin = (ctx: RequestContext, secure: boolean): string =>
|
const selfOrigin = (ctx: RequestContext, secure: boolean): string =>
|
||||||
`${secure ? "https" : "http"}://${ctx.req.headers.host ?? "127.0.0.1:3000"}`;
|
`${secure ? "https" : "http"}://${ctx.req.headers.host ?? "127.0.0.1:3000"}`;
|
||||||
|
|
||||||
// Themed Kratos self-service page (one route per AUTH_FLOWS entry: login/registration/recovery/
|
// Where Kratos lands the browser after sign-in, or undefined for its default. A host-relative
|
||||||
// verification/settings).
|
// return_to (the gate's deep link) or a chosen language goes through /auth/complete, which mints the
|
||||||
function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boolean): BuiltinRoute["handler"] {
|
// session JWT first and carries the language across Kratos; any other target (the OAuth2 login
|
||||||
return async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult> => {
|
// challenge) is passed as-is, since Kratos allow-lists it.
|
||||||
const pathname = ctx.url.pathname;
|
function flowReturnTo(ctx: RequestContext, secureCookies: boolean): string | undefined {
|
||||||
// Already signed in? Re-authenticating / re-registering is pointless — send them to the app
|
|
||||||
// dashboard. (/settings, /recovery, /verification stay reachable — a signed-in user can use those.)
|
|
||||||
if (ctx.user && (flowType === "login" || flowType === "registration")) return { redirect: "/dashboard" };
|
|
||||||
const cookie = ctx.req.headers.cookie;
|
|
||||||
const flowId = ctx.url.searchParams.get("flow");
|
|
||||||
// Only the Kratos calls are in the try, so a render/buildFlowView bug below falls through to
|
|
||||||
// the catch-all 500 (with a stack), not the "Ory unreachable" 503.
|
|
||||||
let flow: Flow;
|
|
||||||
try {
|
|
||||||
if (!flowId) {
|
|
||||||
// No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=<id>.
|
|
||||||
// A `return_to` is baked into the flow so Kratos lands there after login instead of the
|
|
||||||
// default completion route. A first-party deep link (host-relative, from the gate's
|
|
||||||
// return_to) is wrapped through /auth/complete so the session JWT is minted before the
|
|
||||||
// user reaches the page; an absolute target (the OAuth2 login challenge) is passed
|
|
||||||
// as-is — Kratos allow-lists it. localPath rejects an off-origin "//evil.com".
|
|
||||||
const raw = ctx.url.searchParams.get("return_to");
|
const raw = ctx.url.searchParams.get("return_to");
|
||||||
const local = localPath(raw);
|
const local = localPath(raw);
|
||||||
const chosen = chosenLocale(ctx);
|
const chosen = chosenLocale(ctx);
|
||||||
let returnTo: string | undefined;
|
if (raw && !local) return raw; // beside a chosen language too: the target wins, the language is lost
|
||||||
if (local || chosen) {
|
if (!local && !chosen) return undefined;
|
||||||
// The flow's return target is the host's, not Kratos' — so the language the visitor picked
|
|
||||||
// on the sign-in page survives the round-trip through Kratos and lands on the page after
|
|
||||||
// it. Without this the most-travelled path in the product (pick Swedish → sign in) drops
|
|
||||||
// straight back to Accept-Language.
|
|
||||||
const complete = new URL(`${selfOrigin(ctx, secureCookies)}/auth/complete`);
|
const complete = new URL(`${selfOrigin(ctx, secureCookies)}/auth/complete`);
|
||||||
if (local) complete.searchParams.set("return_to", ctx.localeHref(local));
|
if (local) complete.searchParams.set("return_to", ctx.localeHref(local));
|
||||||
if (chosen) complete.searchParams.set("locale", chosen);
|
if (chosen) complete.searchParams.set("locale", chosen);
|
||||||
returnTo = complete.toString();
|
return complete.toString();
|
||||||
} else if (raw) returnTo = raw;
|
}
|
||||||
const { flow: initiated, setCookie } = await kratos.initBrowserFlow(flowType, { ...(cookie ? { cookie } : {}), ...(returnTo ? { returnTo } : {}) });
|
|
||||||
if (setCookie.length) ctx.res.appendHeader("set-cookie", setCookie);
|
// What a failed Kratos call returns instead of the flow; rethrows what is genuinely unexpected.
|
||||||
return { redirect: `${pathname}?flow=${initiated.id}` };
|
function flowFailure(err: unknown, ctx: RequestContext, flowType: FlowType): RouteResult {
|
||||||
}
|
const pathname = ctx.url.pathname;
|
||||||
flow = await kratos.getFlow(flowType, flowId, cookie ? { cookie } : {});
|
|
||||||
} catch (err) {
|
|
||||||
// Expired/unknown flow → restart by re-initialising (drop the stale ?flow=).
|
// Expired/unknown flow → restart by re-initialising (drop the stale ?flow=).
|
||||||
if (err instanceof KratosError && [403, 404, 410].includes(err.status)) return { redirect: pathname };
|
if (err instanceof KratosError && [403, 404, 410].includes(err.status)) return { redirect: pathname };
|
||||||
|
// No Kratos session on a page that needs one. /settings is public, since recovery lands there with
|
||||||
|
// a Kratos session but no app JWT, so the gate never sends an anonymous visitor to sign in.
|
||||||
|
// Not for /login itself, which would redirect to itself.
|
||||||
|
if (err instanceof KratosError && err.status === 401 && flowType !== "login") {
|
||||||
|
return { redirect: `/login?return_to=${encodeURIComponent(pathname)}` };
|
||||||
|
}
|
||||||
// Already authenticated at Kratos but no app JWT yet (e.g. straight after registration, whose
|
// Already authenticated at Kratos but no app JWT yet (e.g. straight after registration, whose
|
||||||
// `session` hook signs the user in but routes to verification, not /auth/complete — so ctx.user
|
// `session` hook signs the user in but routes to verification, not /auth/complete — so ctx.user
|
||||||
// is null and the "already signed in" short-circuit above can't fire). Initialising a login/
|
// is null and the "already signed in" short-circuit can't fire). Initialising a login/
|
||||||
// registration flow then returns Kratos 400 `session_already_available`. Recover by completing
|
// registration flow then returns Kratos 400 `session_already_available`. Recover by completing
|
||||||
// login (mint the JWT from the live session), honouring return_to — never a 500.
|
// login (mint the JWT from the live session), honouring return_to — never a 500.
|
||||||
if (err instanceof KratosError && err.status === 400 && err.body.includes("session_already_available")) {
|
if (err instanceof KratosError && err.status === 400 && err.body.includes("session_already_available")) {
|
||||||
@@ -97,6 +81,31 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
|
|||||||
return { status: 503, view: "503" };
|
return { status: 503, view: "503" };
|
||||||
}
|
}
|
||||||
throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected)
|
throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Themed Kratos self-service page (one route per AUTH_FLOWS entry: login/registration/recovery/
|
||||||
|
// verification/settings).
|
||||||
|
function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boolean): BuiltinRoute["handler"] {
|
||||||
|
return async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult> => {
|
||||||
|
// Already signed in? Re-authenticating / re-registering is pointless — send them to the app
|
||||||
|
// dashboard. (/settings, /recovery, /verification stay reachable — a signed-in user can use those.)
|
||||||
|
if (ctx.user && (flowType === "login" || flowType === "registration")) return { redirect: "/dashboard" };
|
||||||
|
const cookie = ctx.req.headers.cookie;
|
||||||
|
const flowId = ctx.url.searchParams.get("flow");
|
||||||
|
// Only the Kratos calls are in the try, so a render/buildFlowView bug below falls through to
|
||||||
|
// the catch-all 500 (with a stack), not the "Ory unreachable" 503.
|
||||||
|
let flow: Flow;
|
||||||
|
try {
|
||||||
|
if (!flowId) {
|
||||||
|
// No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=<id>.
|
||||||
|
const returnTo = flowReturnTo(ctx, secureCookies);
|
||||||
|
const { flow: initiated, setCookie } = await kratos.initBrowserFlow(flowType, { ...(cookie ? { cookie } : {}), ...(returnTo ? { returnTo } : {}) });
|
||||||
|
if (setCookie.length) ctx.res.appendHeader("set-cookie", setCookie);
|
||||||
|
return { redirect: `${ctx.url.pathname}?flow=${initiated.id}` };
|
||||||
|
}
|
||||||
|
flow = await kratos.getFlow(flowType, flowId, cookie ? { cookie } : {});
|
||||||
|
} catch (err) {
|
||||||
|
return flowFailure(err, ctx, flowType);
|
||||||
}
|
}
|
||||||
// Rendered inside the unified app shell, so set a fresh CSRF cookie when minted — the
|
// Rendered inside the unified app shell, so set a fresh CSRF cookie when minted — the
|
||||||
// shell's Sign-out form (shown on /settings, where the user is signed in) needs the token.
|
// shell's Sign-out form (shown on /settings, where the user is signed in) needs the token.
|
||||||
@@ -105,6 +114,33 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A code flow's POST (flow-view.ts → CODE_FLOWS). Trims the pasted one-time code, since Kratos
|
||||||
|
// refuses one with whitespace around it, then passes the form on to Kratos and sends the browser
|
||||||
|
// where Kratos redirects. Kratos' csrf_token field guards the POST, so the host's own CSRF token
|
||||||
|
// isn't checked here.
|
||||||
|
function codeFlowSubmit(kratos: KratosPublic, flowType: FlowType, secureCookies: boolean): BuiltinRoute["handler"] {
|
||||||
|
return async (ctx: RequestContext): Promise<RouteResult> => {
|
||||||
|
const pathname = ctx.url.pathname;
|
||||||
|
const flowId = ctx.url.searchParams.get("flow");
|
||||||
|
if (!flowId) return { redirect: pathname };
|
||||||
|
const form = await readFormBody(ctx.req);
|
||||||
|
const code = form.get("code");
|
||||||
|
if (code !== null) form.set("code", code.trim());
|
||||||
|
const cookie = ctx.req.headers.cookie;
|
||||||
|
let submitted: FlowSubmission;
|
||||||
|
try {
|
||||||
|
submitted = await kratos.submitFlow(flowType, flowId, { body: form.toString(), ...(cookie ? { cookie } : {}) });
|
||||||
|
} catch (err) {
|
||||||
|
return flowFailure(err, ctx, flowType);
|
||||||
|
}
|
||||||
|
if (submitted.setCookie.length) ctx.res.appendHeader("set-cookie", submitted.setCookie);
|
||||||
|
// Kratos names our own pages by absolute URL; made host-relative, the redirect keeps a chosen locale.
|
||||||
|
const own = selfOrigin(ctx, secureCookies);
|
||||||
|
const location = submitted.location?.startsWith(`${own}/`) ? submitted.location.slice(own.length) : submitted.location;
|
||||||
|
return { redirect: location ?? `${pathname}?flow=${encodeURIComponent(flowId)}` };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// OAuth2 login challenge: Hydra hands the browser here when another app logs in *through* us.
|
// OAuth2 login challenge: Hydra hands the browser here when another app logs in *through* us.
|
||||||
// Resolve it via the Kratos session and accept; an unauthenticated user bounces to our themed
|
// Resolve it via the Kratos session and accept; an unauthenticated user bounces to our themed
|
||||||
// login and returns here once signed in. Provider-only.
|
// login and returns here once signed in. Provider-only.
|
||||||
@@ -240,20 +276,21 @@ export function buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secure
|
|||||||
const routes: BuiltinRoute[] = [];
|
const routes: BuiltinRoute[] = [];
|
||||||
if (kratos) {
|
if (kratos) {
|
||||||
for (const [path, flowType] of Object.entries(AUTH_FLOWS)) {
|
for (const [path, flowType] of Object.entries(AUTH_FLOWS)) {
|
||||||
routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path });
|
routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path, public: true });
|
||||||
|
if (CODE_FLOWS.has(flowType)) routes.push({ handler: codeFlowSubmit(kratos, flowType, secureCookies), method: "POST", path, public: true });
|
||||||
}
|
}
|
||||||
routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout" });
|
routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout", public: true });
|
||||||
}
|
}
|
||||||
if (hydra && kratos) {
|
if (hydra && kratos) {
|
||||||
const provider = { hydra, kratos };
|
const provider = { hydra, kratos };
|
||||||
routes.push({ handler: oauthLogin(provider, secureCookies), method: "GET", path: "/oauth2/login" });
|
routes.push({ handler: oauthLogin(provider, secureCookies), method: "GET", path: "/oauth2/login", public: true });
|
||||||
routes.push({ handler: consentScreen(provider, menu.branding.name), method: "GET", path: "/oauth2/consent" });
|
routes.push({ handler: consentScreen(provider, menu.branding.name), method: "GET", path: "/oauth2/consent", public: true });
|
||||||
routes.push({ handler: consentDecision(provider), method: "POST", path: "/oauth2/consent" });
|
routes.push({ handler: consentDecision(provider), method: "POST", path: "/oauth2/consent", public: true });
|
||||||
}
|
}
|
||||||
if (hydra) routes.push({ handler: oauthLogout(hydra), method: "GET", path: "/oauth2/logout" });
|
if (hydra) routes.push({ handler: oauthLogout(hydra), method: "GET", path: "/oauth2/logout", public: true });
|
||||||
if (kratos && kratosAdmin && keto) {
|
if (kratos && kratosAdmin && keto) {
|
||||||
routes.push({ handler: completeAuth({ keto, kratosAdmin, kratosPublic: kratos }, secureCookies), method: "GET", path: "/auth/complete" });
|
routes.push({ handler: completeAuth({ keto, kratosAdmin, kratosPublic: kratos }, secureCookies), method: "GET", path: "/auth/complete", public: true });
|
||||||
}
|
}
|
||||||
routes.push({ handler: errorSink, method: "GET", path: "/error" });
|
routes.push({ handler: errorSink, method: "GET", path: "/error", public: true });
|
||||||
return routes;
|
return routes;
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-2
@@ -29,14 +29,19 @@ test("the commit-hash image is pushed even when the gate no-ops", () => {
|
|||||||
assert.doesNotMatch(step("docker push"), /^\s*if:/m);
|
assert.doesNotMatch(step("docker push"), /^\s*if:/m);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("every E2E suite the gate runs writes its artifacts as the invoking user", () => {
|
test("every E2E runner container the gate starts runs as the invoking user", () => {
|
||||||
// The documented hand-run commands carry the same flag (src/compose.test.ts).
|
// The documented hand-run commands carry the same flag (src/compose.test.ts).
|
||||||
const runs = gate.split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l));
|
const runs = gate.split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l));
|
||||||
assert.equal(runs.length, 2, "the suite helper and the devstack run");
|
assert.equal(runs.length, 3, "the e2e-tests typecheck, the suite helper and the devstack run");
|
||||||
for (const line of runs)
|
for (const line of runs)
|
||||||
assert.match(line, /--user "\$\(id -u\):\$\(id -g\)"/, `runs as the caller: ${line.trim()}`);
|
assert.match(line, /--user "\$\(id -u\):\$\(id -g\)"/, `runs as the caller: ${line.trim()}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the gate typechecks e2e-tests in the runner image, where @playwright/test resolves", () => {
|
||||||
|
assert.match(gate, /docker compose .*\brun\b.*--no-deps\b.*\be2e\b npm run typecheck/);
|
||||||
|
assert.match(read("e2e-tests/package.json"), /"typecheck": "tsc --noEmit"/);
|
||||||
|
});
|
||||||
|
|
||||||
test("only *.md counts as docs; a dirty tree and a rename both count as changed", () => {
|
test("only *.md counts as docs; a dirty tree and a rename both count as changed", () => {
|
||||||
assert.ok(gate.includes("\\.md$"), "the non-docs match is a *.md suffix test");
|
assert.ok(gate.includes("\\.md$"), "the non-docs match is a *.md suffix test");
|
||||||
assert.match(gate, /git status --porcelain --no-renames/, "uncommitted code and a staged rename can never be skipped over");
|
assert.match(gate, /git status --porcelain --no-renames/, "uncommitted code and a staged rename can never be skipped over");
|
||||||
|
|||||||
+1
-1
@@ -129,7 +129,7 @@ test("the E2E runner writes its artifacts as the invoking user, never as root",
|
|||||||
// filter would otherwise leave that command silently unguarded.
|
// filter would otherwise leave that command silently unguarded.
|
||||||
const documented = [read("README.md"), ...composeFiles("e2e-tests/").map(read)]
|
const documented = [read("README.md"), ...composeFiles("e2e-tests/").map(read)]
|
||||||
.join("\n").split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l));
|
.join("\n").split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l));
|
||||||
assert.equal(documented.length, 6, "5 compose headers + 1 README block");
|
assert.equal(documented.length, 7, "5 compose headers + 2 README lines");
|
||||||
for (const l of documented)
|
for (const l of documented)
|
||||||
assert.match(l, /--user "\$\(id -u\):\$\(id -g\)"/, `passes the uid: ${l.trim()}`);
|
assert.match(l, /--user "\$\(id -u\):\$\(id -g\)"/, `passes the uid: ${l.trim()}`);
|
||||||
// An absent mount source is daemon-created as root, and then that uid can't write it at all.
|
// An absent mount source is daemon-created as root, and then that uid can't write it at all.
|
||||||
|
|||||||
@@ -39,6 +39,23 @@ test("PLUGIN_DB_URL carrying credentials is refused, not silently overwritten",
|
|||||||
assert.equal(loadConfig({}).pluginDbUrl, undefined); // unset ⇒ storage off
|
assert.equal(loadConfig({}).pluginDbUrl, undefined); // unset ⇒ storage off
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a malformed URL value is refused by naming the variable, never echoing the value", () => {
|
||||||
|
// PLUGIN_DB_URL and OTLP_ENDPOINT can carry credentials; APP_URL and KRATOS_PUBLIC_URL stand for the other keys, one per reader.
|
||||||
|
const bad: Record<string, string> = {
|
||||||
|
APP_URL: "https://acme:pa/ss@[host",
|
||||||
|
KRATOS_PUBLIC_URL: "http://u:pa/ss@[kratos:4433",
|
||||||
|
OTLP_ENDPOINT: "http://otel:pa/ss@[collector:4318",
|
||||||
|
PLUGIN_DB_URL: "postgres://admin:pa/ss@db:5432/plugins",
|
||||||
|
};
|
||||||
|
for (const [key, value] of Object.entries(bad)) {
|
||||||
|
assert.throws(
|
||||||
|
() => loadConfig({ [key]: value }),
|
||||||
|
(err: Error) => err.message.includes(key) && !err.message.includes("pa/ss") && !err.message.includes(value),
|
||||||
|
key,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("the per-role connection ceiling defaults to 10 and rejects nonsense", () => {
|
test("the per-role connection ceiling defaults to 10 and rejects nonsense", () => {
|
||||||
assert.equal(resolvePluginDbConnectionLimit({}), 10);
|
assert.equal(resolvePluginDbConnectionLimit({}), 10);
|
||||||
assert.equal(resolvePluginDbConnectionLimit({ PLUGIN_DB_CONNECTION_LIMIT: "25" }), 25);
|
assert.equal(resolvePluginDbConnectionLimit({ PLUGIN_DB_CONNECTION_LIMIT: "25" }), 25);
|
||||||
|
|||||||
+11
-10
@@ -98,26 +98,27 @@ function readEnum<T extends string>(env: Env, key: string, allowed: readonly T[]
|
|||||||
throw new Error(`config: ${key} must be one of ${allowed.join(", ")}, got "${value}"`);
|
throw new Error(`config: ${key} must be one of ${allowed.join(", ")}, got "${value}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// An optional absolute URL: unset/empty ⇒ undefined; a set-but-malformed value fails at boot.
|
// Never echoes the value: a DSN whose password breaks URL parsing (a bare "/") would put the
|
||||||
function readOptionalUrl(env: Env, key: string): string | undefined {
|
// password in the boot error and the log.
|
||||||
const value = env[key] || undefined;
|
function assertUrl(key: string, value: string): void {
|
||||||
if (value === undefined) return undefined;
|
|
||||||
try {
|
try {
|
||||||
new URL(value);
|
new URL(value);
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(`config: ${key} is not a valid URL: ${value}`);
|
throw new Error(`config: ${key} must be an absolute URL (its value is not shown; percent-encode special characters in a password)`);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An optional absolute URL: unset/empty ⇒ undefined; a set-but-malformed value fails at boot.
|
||||||
|
function readOptionalUrl(env: Env, key: string): string | undefined {
|
||||||
|
const value = env[key] || undefined;
|
||||||
|
if (value !== undefined) assertUrl(key, value);
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
// An absolute URL: defaults to the Ory service; validated so a typo fails at boot.
|
// An absolute URL: defaults to the Ory service; validated so a typo fails at boot.
|
||||||
function readUrl(env: Env, key: string, devDefault: string): string {
|
function readUrl(env: Env, key: string, devDefault: string): string {
|
||||||
const value = env[key] ?? devDefault;
|
const value = env[key] ?? devDefault;
|
||||||
try {
|
assertUrl(key, value);
|
||||||
new URL(value);
|
|
||||||
} catch {
|
|
||||||
throw new Error(`config: ${key} is not a valid URL: ${value}`);
|
|
||||||
}
|
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
// The limits and the counting are ESLint's defaults: `max-lines-per-function` (blank lines and
|
||||||
|
// comments count, an IIFE is not held to it) and `complexity`. Not ESLint itself: parsing TypeScript
|
||||||
|
// takes typescript-eslint, which needs the compiler API TypeScript 7 no longer ships. So acorn parses
|
||||||
|
// what Node's own type stripping leaves, which is the code Node runs.
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { globSync, readFileSync } from "node:fs";
|
||||||
|
import { stripTypeScriptTypes } from "node:module";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { parse } from "acorn";
|
||||||
|
|
||||||
|
const LIMITS = { lines: 50, complexity: 20 };
|
||||||
|
|
||||||
|
// Each function over a limit, at its measurement. A ceiling follows its function down, and a metric
|
||||||
|
// leaves the entry once it fits, so a function that shrank can't grow back.
|
||||||
|
const grandfathered: Record<string, { lines?: number; complexity?: number }> = {
|
||||||
|
"src/http/app.ts createApp": { lines: 318, complexity: 28 },
|
||||||
|
"src/http/app.ts createApp > handleRequest": { lines: 167, complexity: 43 },
|
||||||
|
"src/auth/flow-view.ts buildFlowView": { complexity: 24 },
|
||||||
|
"src/auth/hydra-admin.ts createHydraAdmin": { lines: 87 },
|
||||||
|
"src/auth/kratos-admin.ts createKratosAdmin": { lines: 68 },
|
||||||
|
"src/auth/kratos-public.ts createKratosPublic": { lines: 60 },
|
||||||
|
"src/plugin-host/discovery.ts discoverPlugins": { lines: 74, complexity: 22 },
|
||||||
|
"src/plugin-host/discovery.ts shapeError": { complexity: 23 },
|
||||||
|
"src/config.ts loadConfig": { lines: 52 },
|
||||||
|
"release-tooling/dockerhub-overview.ts main": { lines: 57 },
|
||||||
|
"examples/plugins/admin/admin-users.ts buildUserFormModel": { complexity: 21 },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AstNode {
|
||||||
|
type: string;
|
||||||
|
loc: { start: { line: number }; end: { line: number } };
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Measured {
|
||||||
|
name: string;
|
||||||
|
lines: number;
|
||||||
|
complexity: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNode = (value: unknown): value is AstNode =>
|
||||||
|
typeof value === "object" && value !== null && typeof (value as { type?: unknown }).type === "string";
|
||||||
|
|
||||||
|
const FUNCTIONS = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
|
||||||
|
const BRANCHES = new Set([
|
||||||
|
"IfStatement", "ConditionalExpression", "LogicalExpression", "ForStatement", "ForInStatement",
|
||||||
|
"ForOfStatement", "WhileStatement", "DoWhileStatement", "CatchClause", "AssignmentPattern",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function branches(node: AstNode): number {
|
||||||
|
if (BRANCHES.has(node.type)) return 1;
|
||||||
|
if (node.type === "SwitchCase") return node.test ? 1 : 0;
|
||||||
|
if (node.type === "AssignmentExpression") return ["&&=", "||=", "??="].includes(String(node.operator)) ? 1 : 0;
|
||||||
|
if (node.type === "MemberExpression" || node.type === "CallExpression") return node.optional === true ? 1 : 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nameOf(fn: AstNode, parent: AstNode | undefined): string {
|
||||||
|
let id = fn.id;
|
||||||
|
if (!id && parent?.type === "VariableDeclarator") id = parent.id;
|
||||||
|
if (!id && (parent?.type === "Property" || parent?.type === "MethodDefinition")) id = parent.key;
|
||||||
|
return isNode(id) && typeof id.name === "string" ? id.name : "anonymous";
|
||||||
|
}
|
||||||
|
|
||||||
|
// A function is named by the path of names from the outermost one, e.g. `createApp > handleRequest`,
|
||||||
|
// so an entry above doesn't depend on line numbers.
|
||||||
|
function measure(source: string): Measured[] {
|
||||||
|
const found: Measured[] = [];
|
||||||
|
const visit = (node: AstNode, parent: AstNode | undefined, enclosing: Measured | undefined): void => {
|
||||||
|
let current = enclosing;
|
||||||
|
if (FUNCTIONS.has(node.type)) {
|
||||||
|
const name = nameOf(node, parent);
|
||||||
|
const iife = parent?.type === "CallExpression" && parent.callee === node;
|
||||||
|
const lines = iife ? 0 : node.loc.end.line - node.loc.start.line + 1;
|
||||||
|
current = { name: enclosing ? `${enclosing.name} > ${name}` : name, lines, complexity: 1 };
|
||||||
|
found.push(current);
|
||||||
|
} else if (current) {
|
||||||
|
current.complexity += branches(node);
|
||||||
|
}
|
||||||
|
for (const value of Object.values(node)) {
|
||||||
|
for (const child of [value].flat()) if (isNode(child)) visit(child, node, current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const program: unknown = parse(stripTypeScriptTypes(source), { ecmaVersion: "latest", sourceType: "module", locations: true });
|
||||||
|
assert.ok(isNode(program));
|
||||||
|
visit(program, undefined, undefined);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = new URL("..", import.meta.url);
|
||||||
|
// The drop-in mounts hold an operator's code, not this repo's. Tests are left out: each is a flat
|
||||||
|
// script, and a `describe` block wraps many of them.
|
||||||
|
const files = globSync("**/*.ts", {
|
||||||
|
cwd: root,
|
||||||
|
exclude: ["**/node_modules/**", "plugins/**", "config/**", "locales/**", "**/*.d.ts", "**/*.test.ts", "**/*.spec.ts"],
|
||||||
|
});
|
||||||
|
// A list, not a map: two unnamed siblings share a key, and each must be checked.
|
||||||
|
const measured = files.flatMap((file) =>
|
||||||
|
measure(readFileSync(new URL(file, root), "utf8")).map((m) => ({ ...m, key: `${file} ${m.name}` })),
|
||||||
|
);
|
||||||
|
const METRICS = ["lines", "complexity"] as const;
|
||||||
|
|
||||||
|
test("measure counts lines and complexity as ESLint does", () => {
|
||||||
|
const source = [
|
||||||
|
"function outer(a = 1): void {",
|
||||||
|
" if (a && b?.c) {}",
|
||||||
|
" for (const x of y) {}",
|
||||||
|
" for (const k in y) {}",
|
||||||
|
" for (;;) break;",
|
||||||
|
" while (a) a--;",
|
||||||
|
" do {} while (a);",
|
||||||
|
" try {} catch {}",
|
||||||
|
" switch (a) { case 1: break; default: }",
|
||||||
|
" a ||= 2; a &&= 3; a ??= 4;",
|
||||||
|
" b?.();",
|
||||||
|
" y.map(() => {});",
|
||||||
|
" const inner = () => (a ? 1 : 2);",
|
||||||
|
" const o = { prop() {}, key: function () {} };",
|
||||||
|
" (function () { if (a) {} })();",
|
||||||
|
"}",
|
||||||
|
"class K { method() {} }",
|
||||||
|
].join("\n");
|
||||||
|
assert.deepEqual(measure(source), [
|
||||||
|
{ name: "outer", lines: 16, complexity: 16 },
|
||||||
|
{ name: "outer > anonymous", lines: 1, complexity: 1 },
|
||||||
|
{ name: "outer > inner", lines: 1, complexity: 2 },
|
||||||
|
{ name: "outer > prop", lines: 1, complexity: 1 },
|
||||||
|
{ name: "outer > key", lines: 1, complexity: 1 },
|
||||||
|
{ name: "outer > anonymous", lines: 0, complexity: 2 },
|
||||||
|
{ name: "method", lines: 1, complexity: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test(`every function fits ${LIMITS.lines} lines and complexity ${LIMITS.complexity}, or its grandfathered ceiling`, () => {
|
||||||
|
assert.ok(files.length > 50, "found too few files: is the glob broken?");
|
||||||
|
const over = measured.flatMap((m) =>
|
||||||
|
METRICS.filter((metric) => m[metric] > (grandfathered[m.key]?.[metric] ?? LIMITS[metric])).map(
|
||||||
|
(metric) => `${m.key}: ${metric} ${m[metric]}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.deepEqual(over, [], "split the function; never raise a ceiling");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every grandfathered ceiling equals its function's measurement, which is over the limit", () => {
|
||||||
|
const stale = Object.entries(grandfathered).flatMap(([key, ceiling]) => {
|
||||||
|
const found = measured.filter((m) => m.key === key);
|
||||||
|
if (found.length === 0) return [`${key}: no such function; rename the entry if it moved, else delete it`];
|
||||||
|
return METRICS.flatMap((metric) => {
|
||||||
|
const allowed = ceiling[metric];
|
||||||
|
if (allowed === undefined) return [];
|
||||||
|
const now = Math.max(...found.map((m) => m[metric]));
|
||||||
|
if (now <= LIMITS[metric]) return [`${key}: ${metric} ${now} fits; delete it`];
|
||||||
|
return now < allowed ? [`${key}: ${metric} ${now}; lower the ceiling to it`] : [];
|
||||||
|
});
|
||||||
|
});
|
||||||
|
assert.deepEqual(stale, []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,609 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { test, type TestContext } from "node:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { createApp, type AppOptions } from "./app.ts";
|
||||||
|
import { createDenylist } from "../auth/denylist.ts";
|
||||||
|
import { CSRF_COOKIE, issueCsrfToken } from "../auth/csrf.ts";
|
||||||
|
import { staticJwks } from "../auth/jwks.ts";
|
||||||
|
import type { OAuth2Client } from "../auth/hydra-admin.ts";
|
||||||
|
import type { RelationTuple } from "../auth/keto-client.ts";
|
||||||
|
import type { Identity } from "../auth/kratos-admin.ts";
|
||||||
|
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||||
|
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||||
|
import adminManifest from "../../examples/plugins/admin/plugin.ts";
|
||||||
|
import { createI18n } from "../i18n/runtime.ts";
|
||||||
|
import { loadI18n } from "../i18n/load.ts";
|
||||||
|
import { ecJwk, fakeKeto, matchesTuple, mintJwt, stubAdmin, stubHydra } from "./app-harness.ts";
|
||||||
|
|
||||||
|
// The HTTP-level admin tests mount the example plugin via createApp — stub Ory clients on
|
||||||
|
// ctx.system, views from examples/plugins — exactly as an operator would after copying it in.
|
||||||
|
const examplesPluginsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "examples", "plugins");
|
||||||
|
const adminPlugin: Plugin = { ...adminManifest, id: "admin" };
|
||||||
|
|
||||||
|
// Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT +
|
||||||
|
// CSRF cookie. get(path, permissions)/post(path, body) carry them; `token` is the matching CSRF field.
|
||||||
|
const ADMIN_CSRF = "admin-secret";
|
||||||
|
async function adminHarness(t: TestContext, opts: AppOptions = {}, sub = "admin1") {
|
||||||
|
// Mount the plugin's catalogs the way server.ts does, so its screens render words, not keys.
|
||||||
|
const i18n = createI18n(await loadI18n({ pluginIds: [adminPlugin.id], pluginsDir: examplesPluginsDir }));
|
||||||
|
const app = createApp({ csrfSecret: ADMIN_CSRF, i18n, jwks: staticJwks([ecJwk]), pluginsDir: examplesPluginsDir, plugins: [adminPlugin], ...opts });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
const token = issueCsrfToken(ADMIN_CSRF);
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
const cookie = (permissions: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, permissions, sub })}; ${CSRF_COOKIE}=${token}`;
|
||||||
|
const get = (path: string, permissions: string[] = ADMIN_ALL) => fetch(url + path, { headers: { cookie: cookie(permissions) }, redirect: "manual" });
|
||||||
|
const post = (path: string, body: string) =>
|
||||||
|
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(ADMIN_ALL) }, method: "POST", redirect: "manual" });
|
||||||
|
return { get, post, token, url };
|
||||||
|
}
|
||||||
|
// What the plugin itself declares — the harness holds every screen's read and write, so a screen
|
||||||
|
// test exercises the screen rather than the gate. assertAdminGate covers the refusals.
|
||||||
|
const ADMIN_ALL = (adminManifest.permissions ?? []).map((p) => p.name);
|
||||||
|
// The permission form's concurrency token, read off the page the way a browser would post it back.
|
||||||
|
const baselineOf = (html: string): string => /name="baseline" value="([0-9a-f]+)"/.exec(html)?.[1] ?? "";
|
||||||
|
// Every admin route is gated: anonymous → /login, a signed-in non-admin → 403.
|
||||||
|
async function assertAdminGate(url: string, get: (path: string, permissions?: string[]) => Promise<Response>, path: string) {
|
||||||
|
const anon = await fetch(url + path, { redirect: "manual" });
|
||||||
|
assert.equal(anon.status, 303);
|
||||||
|
assert.equal(anon.headers.get("location"), `/login?return_to=${encodeURIComponent(path)}`); // remembers the page
|
||||||
|
assert.equal((await get(path, [])).status, 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Built-in Users admin screen: gate + every CRUD action over HTTP against a mock Kratos admin.
|
||||||
|
test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, recovery (CSRF-guarded)", async (t) => {
|
||||||
|
const mk = (email: string, over: Partial<Identity> = {}): Identity =>
|
||||||
|
({ id: randomUUID(), schema_id: "default", state: "active", traits: { email, name: { first: "Ada", last: "Lovelace" } }, ...over });
|
||||||
|
const store: Identity[] = [mk("ada@example.com"), mk("babbage@example.com", { state: "inactive" }), mk("you@example.com", { id: "admin1" })];
|
||||||
|
let lastCreate: { traits?: unknown } | undefined;
|
||||||
|
const kratosAdmin = stubAdmin({
|
||||||
|
createIdentity: async (payload) => { lastCreate = payload as { traits?: unknown }; const created = mk("grace@example.com"); store.push(created); return created; },
|
||||||
|
createRecoveryCode: async (id) => ({ code: "123456", link: `http://kratos/self-service/recovery?flow=flow-${id}` }),
|
||||||
|
deleteIdentity: async (id) => { const i = store.findIndex((x) => x.id === id); if (i >= 0) store.splice(i, 1); },
|
||||||
|
getIdentity: async (id) => store.find((x) => x.id === id) ?? null,
|
||||||
|
listIdentities: async () => ({ identities: store, nextPageToken: null }),
|
||||||
|
updateIdentity: async (id, payload) => { const it = store.find((x) => x.id === id)!; Object.assign(it, payload); return it; },
|
||||||
|
});
|
||||||
|
const denylist = createDenylist(); // a deactivate/delete should revoke the target's live tokens instantly
|
||||||
|
const { get, post, token, url } = await adminHarness(t, { denylist, kratosAdmin });
|
||||||
|
|
||||||
|
await assertAdminGate(url, get, "/admin/users");
|
||||||
|
|
||||||
|
// Nav: the admin plugin's section composes into the one global menu, and each screen is filtered
|
||||||
|
// by its own read permission — proving the drop-in nav fragment. A user holding only users:read
|
||||||
|
// sees Users and nothing else; holding none of the three, composeNav drops the emptied header.
|
||||||
|
assert.match(await (await get("/dashboard")).text(), /href="\/admin\/users"/);
|
||||||
|
const usersOnlyNav = await (await get("/dashboard", ["users:read"])).text();
|
||||||
|
assert.match(usersOnlyNav, /href="\/admin\/users"/);
|
||||||
|
assert.doesNotMatch(usersOnlyNav, /href="\/admin\/groups"/);
|
||||||
|
assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/admin\/users"/);
|
||||||
|
|
||||||
|
// The read/write split: users:read opens the list but is refused on every mutation, and the
|
||||||
|
// resources don't leak — a users holder is not a groups holder.
|
||||||
|
assert.equal((await get("/admin/users", ["users:read"])).status, 200);
|
||||||
|
assert.equal((await get("/admin/groups", ["users:read", "users:write"])).status, 403);
|
||||||
|
const readOnlyPost = await fetch(url + "/admin/users", {
|
||||||
|
body: `_csrf=${token}&email=nope@example.com`,
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${SESSION_COOKIE}=${mintJwt({ email: "r@x", exp: Math.floor(Date.now() / 1000) + 600, permissions: ["users:read"], sub: "reader1" })}; ${CSRF_COOKIE}=${token}` },
|
||||||
|
method: "POST",
|
||||||
|
redirect: "manual",
|
||||||
|
});
|
||||||
|
assert.equal(readOnlyPost.status, 403);
|
||||||
|
assert.equal(store.some((i) => i.traits?.email === "nope@example.com"), false);
|
||||||
|
|
||||||
|
// List: the admin sees the rows + the "add" link; the status filter narrows server-side.
|
||||||
|
const listHtml = await (await get("/admin/users")).text();
|
||||||
|
assert.match(listHtml, /ada@example\.com/);
|
||||||
|
assert.match(listHtml, /href="\/admin\/users\/new"/);
|
||||||
|
assert.doesNotMatch(await (await get("/admin/users?status=inactive")).text(), /ada@example\.com/);
|
||||||
|
|
||||||
|
// Create: the form renders; a valid post creates the identity and redirects to the list.
|
||||||
|
assert.match(await (await get("/admin/users/new")).text(), /Create user/);
|
||||||
|
const created = await post("/admin/users", `_csrf=${token}&email=grace%40example.com&first=Grace&last=Hopper&password=`);
|
||||||
|
assert.equal(created.status, 303);
|
||||||
|
assert.equal(created.headers.get("location"), "/admin/users");
|
||||||
|
assert.deepEqual(lastCreate?.traits, { email: "grace@example.com", name: { first: "Grace", last: "Hopper" } });
|
||||||
|
|
||||||
|
// A create with no CSRF token is refused and creates nothing.
|
||||||
|
const before = store.length;
|
||||||
|
assert.equal((await post("/admin/users", "email=x%40y.z")).status, 403);
|
||||||
|
assert.equal(store.length, before);
|
||||||
|
|
||||||
|
// CSRF also guards a POST to an *existing* target (the per-route :id handlers), not just the
|
||||||
|
// collection: a delete with no token is refused (403) and removes nothing.
|
||||||
|
assert.equal((await post(`/admin/users/${store[1]!.id}/delete`, "")).status, 403);
|
||||||
|
assert.ok(store.some((x) => x.id === store[1]!.id));
|
||||||
|
|
||||||
|
// Edit: email is read-only + prefilled; a post rewrites the name.
|
||||||
|
const target = store[0]!;
|
||||||
|
const editHtml = await (await get(`/admin/users/${target.id}`)).text();
|
||||||
|
assert.match(editHtml, /name="email"[^>]*readonly/);
|
||||||
|
assert.match(editHtml, /value="ada@example\.com"/);
|
||||||
|
const updated = await post(`/admin/users/${target.id}`, `_csrf=${token}&first=Ada&last=King`);
|
||||||
|
assert.equal(updated.status, 303);
|
||||||
|
assert.deepEqual((target.traits as { name: unknown }).name, { first: "Ada", last: "King" });
|
||||||
|
|
||||||
|
// Deactivate (state toggle): active → inactive, and the target's live tokens are revoked at once.
|
||||||
|
await post(`/admin/users/${target.id}/state`, `_csrf=${token}`);
|
||||||
|
assert.equal(target.state, "inactive");
|
||||||
|
assert.equal(denylist.isRevoked(target.id, 0), true);
|
||||||
|
|
||||||
|
// Recovery: renders the edit page (200) showing the generated code, linked to the flow it was minted on.
|
||||||
|
const rec = await post(`/admin/users/${target.id}/recovery`, `_csrf=${token}`);
|
||||||
|
assert.equal(rec.status, 200);
|
||||||
|
const recHtml = await rec.text();
|
||||||
|
assert.match(recHtml, /Recovery code generated/);
|
||||||
|
assert.match(recHtml, /<code>123456<\/code>/);
|
||||||
|
assert.match(recHtml, new RegExp(`<a href="http://kratos/self-service/recovery\\?flow=flow-${target.id}">`));
|
||||||
|
|
||||||
|
// Delete needs a deliberate confirm step (zero-JS): GET renders the interstitial, POST performs it.
|
||||||
|
const confirm = await (await get(`/admin/users/${target.id}/delete`)).text();
|
||||||
|
assert.match(confirm, /Cancel/);
|
||||||
|
assert.match(confirm, new RegExp(`action="/admin/users/${target.id}/delete"`));
|
||||||
|
const del = await post(`/admin/users/${target.id}/delete`, `_csrf=${token}`);
|
||||||
|
assert.equal(del.status, 303);
|
||||||
|
assert.ok(!store.some((x) => x.id === target.id));
|
||||||
|
|
||||||
|
// Self-protection: an admin can't delete or deactivate their own account (JWT sub = admin1).
|
||||||
|
assert.equal((await post(`/admin/users/admin1/delete`, `_csrf=${token}`)).status, 400);
|
||||||
|
assert.ok(store.some((x) => x.id === "admin1"));
|
||||||
|
assert.equal((await post(`/admin/users/admin1/state`, `_csrf=${token}`)).status, 400);
|
||||||
|
assert.equal(store.find((x) => x.id === "admin1")!.state, "active");
|
||||||
|
|
||||||
|
// 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/%ZZ")).status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Built-in Groups admin screen: gate + list/create/membership/delete over HTTP against a
|
||||||
|
// fakeKeto (tuples are the only state) and a stub Kratos admin (resolves member emails).
|
||||||
|
test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-guarded)", async (t) => {
|
||||||
|
const ada = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b01";
|
||||||
|
const grace = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b02";
|
||||||
|
const identities: Identity[] = [
|
||||||
|
{ id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } },
|
||||||
|
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } },
|
||||||
|
];
|
||||||
|
const tuples: RelationTuple[] = [{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }];
|
||||||
|
const keto = fakeKeto(tuples);
|
||||||
|
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
|
const { get, post, token, url } = await adminHarness(t, { keto, kratosAdmin });
|
||||||
|
|
||||||
|
await assertAdminGate(url, get, "/admin/groups");
|
||||||
|
|
||||||
|
// List: the existing group shows + the "add" link.
|
||||||
|
const listHtml = await (await get("/admin/groups")).text();
|
||||||
|
assert.match(listHtml, /href="\/admin\/groups\/eng"/);
|
||||||
|
assert.match(listHtml, /href="\/admin\/groups\/new"/);
|
||||||
|
|
||||||
|
// Create: the form renders; a valid post writes the first-member tuple and redirects to the detail.
|
||||||
|
assert.match(await (await get("/admin/groups/new")).text(), /Create group/);
|
||||||
|
const created = await post("/admin/groups", `_csrf=${token}&name=design&member=user:${grace}`);
|
||||||
|
assert.equal(created.status, 303);
|
||||||
|
assert.equal(created.headers.get("location"), "/admin/groups/design");
|
||||||
|
assert.ok(tuples.some((tp) => tp.object === "design" && tp.subject_id === `user:${grace}`));
|
||||||
|
|
||||||
|
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
|
||||||
|
const before = tuples.length;
|
||||||
|
assert.equal((await post("/admin/groups", `_csrf=${token}&name=Bad Name&member=user:${grace}`)).status, 400);
|
||||||
|
assert.equal((await post("/admin/groups", `_csrf=${token}&name=eng&member=user:${grace}`)).status, 400); // already exists
|
||||||
|
assert.equal((await post("/admin/groups", `name=x&member=user:${grace}`)).status, 403);
|
||||||
|
assert.equal(tuples.length, before);
|
||||||
|
|
||||||
|
// Detail: lists the current member by email.
|
||||||
|
assert.match(await (await get("/admin/groups/eng")).text(), /ada@example\.com/);
|
||||||
|
|
||||||
|
// Add a member, then remove it.
|
||||||
|
await post("/admin/groups/eng/members", `_csrf=${token}&member=user:${grace}`);
|
||||||
|
assert.ok(tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
||||||
|
await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`);
|
||||||
|
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
||||||
|
|
||||||
|
// Give it a permission first, so the delete below has an orphan to avoid leaving behind.
|
||||||
|
const permissionsBaseline = baselineOf(await (await get("/admin/groups/eng")).text());
|
||||||
|
await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${permissionsBaseline}&permission=users%3Aread`);
|
||||||
|
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "users:read" && tp.subject_set?.object === "eng"));
|
||||||
|
|
||||||
|
// Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
||||||
|
assert.match(await (await get("/admin/groups/eng/delete")).text(), /Cancel/);
|
||||||
|
const del = await post("/admin/groups/eng/delete", `_csrf=${token}`);
|
||||||
|
assert.equal(del.status, 303);
|
||||||
|
assert.equal(del.headers.get("location"), "/admin/groups");
|
||||||
|
assert.ok(!tuples.some((tp) => tp.object === "eng"));
|
||||||
|
// …and the permissions it held go with it. A Keto set exists only through its tuples, so an
|
||||||
|
// orphaned grant would resurrect the moment someone re-created a group with the same name.
|
||||||
|
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.subject_set?.object === "eng"));
|
||||||
|
|
||||||
|
// An invalid group name in the path → 404; malformed %-encoding doesn't 500.
|
||||||
|
assert.equal((await get("/admin/groups/Bad%20Name")).status, 404);
|
||||||
|
assert.equal((await get("/admin/groups/%ZZ")).status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Granting permissions over HTTP. The offered set is the host's catalog (ctx.declaredPermissions),
|
||||||
|
// so the checkboxes are a fixed list and the POST is the desired state.
|
||||||
|
test("admin permission grants: the picker offers the declared catalog, and a save is the desired set", async (t) => {
|
||||||
|
const ada = randomUUID();
|
||||||
|
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||||
|
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
|
||||||
|
const keto = fakeKeto(tuples);
|
||||||
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
|
const denylist = createDenylist();
|
||||||
|
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
||||||
|
|
||||||
|
// The user edit page renders one checkbox per declared permission, ticked where already held.
|
||||||
|
const edit = await (await get(`/admin/users/${ada}`)).text();
|
||||||
|
for (const name of ["users:read", "users:write", "groups:read", "groups:write", "oauth2-clients:read", "oauth2-clients:write"]) {
|
||||||
|
assert.match(edit, new RegExp(`value="${name.replace(":", ":")}"`), name);
|
||||||
|
}
|
||||||
|
assert.match(edit, /value="users:read"[^>]*checked/); // held → ticked
|
||||||
|
assert.doesNotMatch(edit, /value="groups:write"[^>]*checked/); // not held → unticked
|
||||||
|
assert.doesNotMatch(edit, /Permissions saved/); // no confirmation before a save
|
||||||
|
|
||||||
|
// Save a new set: users:write is added, users:read is dropped — the POST is the whole truth.
|
||||||
|
const baseline = baselineOf(edit);
|
||||||
|
const saved = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`);
|
||||||
|
assert.equal(saved.status, 303);
|
||||||
|
assert.deepEqual(
|
||||||
|
tuples.filter((tp) => tp.subject_id === `user:${ada}`).map((tp) => tp.object).sort(),
|
||||||
|
["groups:read", "users:write"],
|
||||||
|
);
|
||||||
|
assert.equal(denylist.isRevoked(ada, 0), true); // a change to your own grants revokes live tokens
|
||||||
|
|
||||||
|
// The redirect lands on a page that confirms the save, and shows the new state.
|
||||||
|
const landing = saved.headers.get("location") ?? "";
|
||||||
|
assert.match(landing, new RegExp(`^/admin/users/${ada}\\?info-msg=permissions-saved$`));
|
||||||
|
const confirmed = await (await get(landing)).text();
|
||||||
|
assert.match(confirmed, /Permissions saved/);
|
||||||
|
assert.match(confirmed, /value="users:write"[^>]*checked/);
|
||||||
|
|
||||||
|
// A crafted POST can't grant something no plugin declares.
|
||||||
|
await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(confirmed)}&permission=users%3Awrite&permission=superuser%3Aall`);
|
||||||
|
assert.ok(!tuples.some((tp) => tp.object === "superuser:all"));
|
||||||
|
|
||||||
|
// The same picker on a group writes the group's subject_set, which Keto resolves transitively.
|
||||||
|
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
||||||
|
const groupBaseline = baselineOf(await (await get("/admin/groups/eng")).text());
|
||||||
|
const groupSaved = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread`);
|
||||||
|
assert.equal(groupSaved.headers.get("location"), "/admin/groups/eng?info-msg=permissions-saved");
|
||||||
|
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Two admins open the same page; the second save would silently undo the first.
|
||||||
|
test("admin permission grants: a save from a stale page is refused, and the page re-renders the current state", async (t) => {
|
||||||
|
const ada = randomUUID();
|
||||||
|
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||||
|
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
|
||||||
|
const keto = fakeKeto(tuples);
|
||||||
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
|
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
|
||||||
|
const stale = baselineOf(await (await get(`/admin/users/${ada}`)).text());
|
||||||
|
|
||||||
|
// Another admin grants users:write while this page is open.
|
||||||
|
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
|
||||||
|
const before = JSON.stringify(tuples);
|
||||||
|
const refused = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=groups%3Aread`);
|
||||||
|
assert.equal(refused.status, 409);
|
||||||
|
const page = await refused.text();
|
||||||
|
assert.match(page, /changed while you had the page open/);
|
||||||
|
assert.equal(JSON.stringify(tuples), before, "nothing was applied");
|
||||||
|
assert.match(page, /value="users:write"[^>]*checked/); // the re-render shows the other admin's grant
|
||||||
|
assert.notEqual(baselineOf(page), stale);
|
||||||
|
|
||||||
|
// Saving from the re-rendered page goes through.
|
||||||
|
const retried = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(page)}&permission=users%3Aread&permission=users%3Awrite&permission=groups%3Aread`);
|
||||||
|
assert.equal(retried.status, 303);
|
||||||
|
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === `user:${ada}`));
|
||||||
|
|
||||||
|
// A POST with no token at all (a crafted request, or a form rendered before this check existed) is stale too.
|
||||||
|
assert.equal((await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Aread`)).status, 409);
|
||||||
|
|
||||||
|
// Groups are checked the same way.
|
||||||
|
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
||||||
|
const groupStale = baselineOf(await (await get("/admin/groups/eng")).text());
|
||||||
|
tuples.push({ namespace: "Permission", object: "groups:read", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
||||||
|
const groupRefused = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupStale}&permission=users%3Aread`);
|
||||||
|
assert.equal(groupRefused.status, 409);
|
||||||
|
assert.match(await groupRefused.text(), /changed while you had the page open/);
|
||||||
|
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the group keeps its grant");
|
||||||
|
});
|
||||||
|
|
||||||
|
// A Keto error midway leaves some changes applied; the save says so and still revokes the user's live tokens.
|
||||||
|
test("admin permission grants: a Keto failure midway reports a partial save and keeps what was applied", async (t) => {
|
||||||
|
const ada = randomUUID();
|
||||||
|
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||||
|
const tuples: RelationTuple[] = [];
|
||||||
|
let writes = 0;
|
||||||
|
const keto = fakeKeto(tuples, {
|
||||||
|
writeTuple: async (tp) => {
|
||||||
|
if (++writes === 2) throw new Error("keto unreachable");
|
||||||
|
tuples.push(tp);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
|
const denylist = createDenylist();
|
||||||
|
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
||||||
|
const baseline = baselineOf(await (await get(`/admin/users/${ada}`)).text());
|
||||||
|
|
||||||
|
const res = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Aread&permission=users%3Awrite`);
|
||||||
|
assert.equal(res.status, 502);
|
||||||
|
const page = await res.text();
|
||||||
|
assert.match(page, /stopped part-way/);
|
||||||
|
assert.match(page, /value="groups:read"[^>]*checked/); // the first write landed, and the page shows what is stored
|
||||||
|
assert.doesNotMatch(page, /value="users:write"[^>]*checked/);
|
||||||
|
assert.deepEqual(tuples.map((tp) => tp.object), ["groups:read"]);
|
||||||
|
assert.equal(denylist.isRevoked(ada, 0), true); // the half that was applied still revokes live tokens
|
||||||
|
});
|
||||||
|
|
||||||
|
// A double-clicked Save posts twice from one page; the second post finds the first one's result.
|
||||||
|
// A save that changes nothing has nothing to refuse and nothing to revoke.
|
||||||
|
test("admin permission grants: a save that changes nothing is not refused for a stale page and leaves live tokens alone", async (t) => {
|
||||||
|
const ada = randomUUID();
|
||||||
|
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||||
|
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
|
||||||
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
|
const denylist = createDenylist();
|
||||||
|
const { get, post, token } = await adminHarness(t, { denylist, keto: fakeKeto(tuples), kratosAdmin });
|
||||||
|
const stale = baselineOf(await (await get(`/admin/users/${ada}`)).text());
|
||||||
|
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
|
||||||
|
const before = JSON.stringify(tuples);
|
||||||
|
|
||||||
|
const repeated = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=users%3Awrite`);
|
||||||
|
assert.equal(repeated.status, 303);
|
||||||
|
assert.equal(JSON.stringify(tuples), before);
|
||||||
|
assert.equal(denylist.isRevoked(ada, 0), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keto answered the writes but not the re-read: the report must still reach the admin.
|
||||||
|
test("admin permission grants: a partial save is still reported when Keto cannot be read again", async (t) => {
|
||||||
|
const ada = randomUUID();
|
||||||
|
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||||
|
const tuples: RelationTuple[] = [];
|
||||||
|
let writes = 0;
|
||||||
|
let down = false;
|
||||||
|
let failureTakesKetoDown = true;
|
||||||
|
const keto = fakeKeto(tuples, {
|
||||||
|
listRelations: async (q = {}) => {
|
||||||
|
if (down) throw new Error("keto unreachable");
|
||||||
|
return { nextPageToken: null, tuples: tuples.filter((tp) => matchesTuple(tp, q)) };
|
||||||
|
},
|
||||||
|
writeTuple: async (tp) => {
|
||||||
|
if (++writes === 2) { down = failureTakesKetoDown; throw new Error("keto unreachable"); }
|
||||||
|
tuples.push(tp);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
|
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
|
||||||
|
const baseline = baselineOf(await (await get(`/admin/users/${ada}`)).text());
|
||||||
|
|
||||||
|
const res = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Aread`);
|
||||||
|
assert.equal(res.status, 502);
|
||||||
|
assert.match(await res.text(), /stopped part-way/);
|
||||||
|
assert.deepEqual(tuples.map((tp) => tp.object), ["groups:read"]);
|
||||||
|
|
||||||
|
// The same on a group, whose page needs Keto for its members as well.
|
||||||
|
down = false;
|
||||||
|
writes = 0;
|
||||||
|
tuples.length = 0;
|
||||||
|
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
||||||
|
const groupBaseline = baselineOf(await (await get("/admin/groups/eng")).text());
|
||||||
|
const groupRes = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread&permission=users%3Aread`);
|
||||||
|
assert.equal(groupRes.status, 502);
|
||||||
|
assert.match(await groupRes.text(), /stopped part-way/);
|
||||||
|
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the first write landed");
|
||||||
|
|
||||||
|
// With Keto still answering, the group page itself reports it and shows what is stored.
|
||||||
|
failureTakesKetoDown = false;
|
||||||
|
down = false;
|
||||||
|
writes = 0;
|
||||||
|
tuples.length = 1;
|
||||||
|
const again = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${baselineOf(await (await get("/admin/groups/eng")).text())}&permission=groups%3Aread&permission=users%3Aread`);
|
||||||
|
assert.equal(again.status, 502);
|
||||||
|
const page = await again.text();
|
||||||
|
assert.match(page, /stopped part-way/);
|
||||||
|
assert.match(page, /value="groups:read"[^>]*checked/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Revoking your own grants can remove the last users:write on the deployment, and the instant-revoke
|
||||||
|
// hook lands it on the next request — recovery would be a curl against Keto. Guarded like
|
||||||
|
// self-deactivate and self-delete are. (`admin1` is the harness's own sub.)
|
||||||
|
test("admin permission grants: you can't revoke your own permissions, but you can still grant", async (t) => {
|
||||||
|
const identities: Identity[] = [{ id: "admin1", traits: { email: "you@example.com" } }];
|
||||||
|
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:write", relation: "granted", subject_id: "user:admin1" }];
|
||||||
|
const keto = fakeKeto(tuples);
|
||||||
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
|
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
|
||||||
|
const baseline = baselineOf(await (await get("/admin/users/admin1")).text());
|
||||||
|
|
||||||
|
const refused = await post("/admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}`); // every box cleared
|
||||||
|
assert.equal(refused.status, 400);
|
||||||
|
assert.match(await refused.text(), /lock yourself out/);
|
||||||
|
assert.ok(tuples.some((tp) => tp.object === "users:write" && tp.subject_id === "user:admin1"), "nothing was revoked");
|
||||||
|
|
||||||
|
// Granting yourself more is not a lockout, so it goes through.
|
||||||
|
const granted = await post("/admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`);
|
||||||
|
assert.equal(granted.status, 303);
|
||||||
|
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === "user:admin1"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// A check over the tuple array that expands nested groups.
|
||||||
|
function resolvingCheck(tuples: RelationTuple[]) {
|
||||||
|
const reaches = (q: RelationTuple, ns: string, object: string, relation: string, seen: Set<string>): boolean => {
|
||||||
|
const key = `${ns}:${object}#${relation}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return tuples.some((tp) => tp.namespace === ns && tp.object === object && tp.relation === relation && (
|
||||||
|
(q.subject_id !== undefined && tp.subject_id === q.subject_id) ||
|
||||||
|
(q.subject_set !== undefined && tp.subject_set?.namespace === q.subject_set.namespace && tp.subject_set.object === q.subject_set.object) ||
|
||||||
|
(tp.subject_set !== undefined && reaches(q, tp.subject_set.namespace, tp.subject_set.object, tp.subject_set.relation, seen))));
|
||||||
|
};
|
||||||
|
return async (q: RelationTuple) => reaches(q, q.namespace, q.object, q.relation, new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
// A member value must be a UUID, so this test signs in as `you`.
|
||||||
|
test("admin groups: you can't take away permissions you hold only through a group you are in", async (t) => {
|
||||||
|
const ada = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b01";
|
||||||
|
const you = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b02";
|
||||||
|
const identities: Identity[] = [{ id: you, traits: { email: "you@example.com" } }, { id: ada, traits: { email: "ada@example.com" } }];
|
||||||
|
const member = (group: string, subject: string): RelationTuple => ({ namespace: "Group", object: group, relation: "members", subject_id: subject });
|
||||||
|
const nested = (group: string, child: string): RelationTuple => ({ namespace: "Group", object: group, relation: "members", subject_set: { namespace: "Group", object: child, relation: "members" } });
|
||||||
|
const grant = (permission: string, group: string): RelationTuple => ({ namespace: "Permission", object: permission, relation: "granted", subject_set: { namespace: "Group", object: group, relation: "members" } });
|
||||||
|
const tuples: RelationTuple[] = [
|
||||||
|
member("eng", `user:${you}`), member("eng", `user:${ada}`), grant("users:write", "eng"), grant("groups:write", "eng"),
|
||||||
|
{ namespace: "Permission", object: "groups:write", relation: "granted", subject_id: `user:${you}` }, // also held directly
|
||||||
|
nested("leads", "eng"), grant("oauth2-clients:write", "leads"), // reached through eng
|
||||||
|
member("social", `user:${you}`), // confers nothing
|
||||||
|
member("design", `user:${ada}`), grant("users:write", "design"), // you are not in it
|
||||||
|
];
|
||||||
|
const keto = fakeKeto(tuples, { check: resolvingCheck(tuples) });
|
||||||
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
|
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin }, you);
|
||||||
|
const baseline = baselineOf(await (await get("/admin/groups/eng")).text());
|
||||||
|
// A refusal renders that group's own page and names what you would lose.
|
||||||
|
const refusedWithMessage = async (res: Response, group: string, lost: string) => {
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
const html = await res.text();
|
||||||
|
assert.match(html, new RegExp(`lock you out of ${lost}, which`));
|
||||||
|
assert.match(html, new RegExp(`action="/admin/groups/${group}/members"`));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Unticking users:write on eng would take it from you: refused, nothing revoked.
|
||||||
|
const before = JSON.stringify(tuples);
|
||||||
|
await refusedWithMessage(await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${baseline}&permission=groups%3Awrite`), "eng", "users:write");
|
||||||
|
// Leaving eng, removing eng from leads, or deleting eng (the confirm step too) are refused the same
|
||||||
|
// way; leaving eng also loses what it gets from leads.
|
||||||
|
await refusedWithMessage(await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${you}`), "eng", "oauth2-clients:write, users:write");
|
||||||
|
await refusedWithMessage(await post("/admin/groups/leads/members/delete", `_csrf=${token}&member=group:eng`), "leads", "oauth2-clients:write");
|
||||||
|
await refusedWithMessage(await get("/admin/groups/eng/delete"), "eng", "oauth2-clients:write, users:write");
|
||||||
|
await refusedWithMessage(await post("/admin/groups/eng/delete", `_csrf=${token}`), "eng", "oauth2-clients:write, users:write");
|
||||||
|
assert.equal(JSON.stringify(tuples), before, "nothing changed");
|
||||||
|
|
||||||
|
// You hold groups:write directly too, so unticking it on eng costs you nothing.
|
||||||
|
assert.equal((await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite`)).status, 303);
|
||||||
|
// Removing someone else, leaving a group that confers nothing, and deleting a group you are not in all go through.
|
||||||
|
assert.equal((await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${ada}`)).status, 303);
|
||||||
|
assert.equal((await post("/admin/groups/social/members/delete", `_csrf=${token}&member=user:${you}`)).status, 303);
|
||||||
|
assert.equal((await post("/admin/groups/design/delete", `_csrf=${token}`)).status, 303);
|
||||||
|
assert.ok(!tuples.some((tp) => tp.object === "design" || tp.subject_set?.object === "design"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// The read/write split is only honest if the UI models it: a users:read holder must not be shown
|
||||||
|
// buttons that 403 on submit. The gate already refuses them (asserted above); this is the affordance.
|
||||||
|
test("admin screens render no write affordance for a read-only holder", async (t) => {
|
||||||
|
const ada = randomUUID();
|
||||||
|
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||||
|
const keto = fakeKeto([{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }]);
|
||||||
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
|
// Hydra is wired so the clients screen renders for real — without it the page is a 503 and the
|
||||||
|
// "no Register button" assertion below would pass without proving anything.
|
||||||
|
const reporting = { client_id: "existing", client_name: "Reporting" };
|
||||||
|
const hydra = stubHydra({ getClient: async (id) => (id === reporting.client_id ? reporting : null), listClients: async () => ({ clients: [reporting], nextPageToken: null }) });
|
||||||
|
const { get } = await adminHarness(t, { hydra, keto, kratosAdmin });
|
||||||
|
const readOnly = ["users:read", "groups:read"];
|
||||||
|
|
||||||
|
const list = await (await get("/admin/users", readOnly)).text();
|
||||||
|
assert.doesNotMatch(list, /href="\/admin\/users\/new"/); // no "New user"
|
||||||
|
assert.match(list, /ada@example\.com/); // but the list itself is there — that's the point of :read
|
||||||
|
|
||||||
|
// (The shell's own sign-out is a POST form, so assert on the affordances by name, not on <form>.)
|
||||||
|
const detail = await (await get(`/admin/users/${ada}`, readOnly)).text();
|
||||||
|
assert.doesNotMatch(detail, /Save changes/);
|
||||||
|
assert.doesNotMatch(detail, /Generate recovery code/);
|
||||||
|
assert.doesNotMatch(detail, /Delete user/);
|
||||||
|
assert.doesNotMatch(detail, /Save permissions/);
|
||||||
|
assert.match(detail, /type="checkbox"[^>]*disabled/); // the permissions are shown, just not editable
|
||||||
|
|
||||||
|
const group = await (await get("/admin/groups/eng", readOnly)).text();
|
||||||
|
assert.doesNotMatch(group, /Add a member/);
|
||||||
|
assert.doesNotMatch(group, /Delete group/);
|
||||||
|
assert.doesNotMatch(group, /Save permissions/);
|
||||||
|
|
||||||
|
// The OAuth2-clients screen is held to the same rule.
|
||||||
|
const clientsRes = await get("/admin/clients", ["oauth2-clients:read"]);
|
||||||
|
assert.equal(clientsRes.status, 200); // a real render, not the capability-missing 503
|
||||||
|
const clients = await clientsRes.text();
|
||||||
|
assert.match(clients, /Reporting/); // the list is there — that's what :read buys
|
||||||
|
assert.doesNotMatch(clients, /href="\/admin\/clients\/new"/);
|
||||||
|
// The detail page is where Delete lives, so check it too and not just the list.
|
||||||
|
const clientDetail = await (await get("/admin/clients/existing", ["oauth2-clients:read"])).text();
|
||||||
|
assert.match(clientDetail, /Reporting/);
|
||||||
|
assert.doesNotMatch(clientDetail, /clients\/existing\/delete/);
|
||||||
|
|
||||||
|
// A write-intent GET — a create form or a delete-confirm — refuses a reader outright rather than
|
||||||
|
// rendering a form whose submit would 403.
|
||||||
|
for (const path of ["/admin/users/new", "/admin/groups/new", `/admin/users/${ada}/delete`, "/admin/groups/eng/delete"]) {
|
||||||
|
assert.equal((await get(path, readOnly)).status, 403, path);
|
||||||
|
}
|
||||||
|
assert.equal((await get("/admin/clients/new", ["oauth2-clients:read"])).status, 403);
|
||||||
|
|
||||||
|
// A writer sees the affordances the reader didn't.
|
||||||
|
const writable = await (await get(`/admin/users/${ada}`, ["users:read", "users:write"])).text();
|
||||||
|
assert.match(writable, /Save changes/);
|
||||||
|
assert.match(writable, /Save permissions/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an
|
||||||
|
// in-memory Hydra. Registration shows the one-time client_secret on the post-create page (no PRG).
|
||||||
|
test("admin OAuth2 clients screen: gate, list, register (one-time secret), detail, delete (CSRF-guarded)", async (t) => {
|
||||||
|
const store: OAuth2Client[] = [
|
||||||
|
{ client_id: "existing", client_name: "Reporting", redirect_uris: ["https://reporting.example/cb"], scope: "openid", token_endpoint_auth_method: "client_secret_basic" },
|
||||||
|
];
|
||||||
|
let seq = 0;
|
||||||
|
const hydra = stubHydra({
|
||||||
|
createClient: async (c) => { const created = { ...c, client_id: `gen-${++seq}`, client_secret: `secret-${seq}` }; store.push(created); return created; },
|
||||||
|
deleteClient: async (id) => { const i = store.findIndex((c) => c.client_id === id); if (i >= 0) store.splice(i, 1); },
|
||||||
|
getClient: async (id) => store.find((c) => c.client_id === id) ?? null,
|
||||||
|
listClients: async () => ({ clients: store, nextPageToken: null }),
|
||||||
|
});
|
||||||
|
const { get, post, token, url } = await adminHarness(t, { hydra });
|
||||||
|
|
||||||
|
await assertAdminGate(url, get, "/admin/clients");
|
||||||
|
|
||||||
|
// List: the existing client shows + the "register" link.
|
||||||
|
const listHtml = await (await get("/admin/clients")).text();
|
||||||
|
assert.match(listHtml, /href="\/admin\/clients\/existing"/);
|
||||||
|
assert.match(listHtml, /href="\/admin\/clients\/new"/);
|
||||||
|
assert.match(listHtml, /Reporting/);
|
||||||
|
|
||||||
|
// Register: the form renders (with confidential-vs-public guidance); a valid post creates the
|
||||||
|
// client and shows the one-time secret + id.
|
||||||
|
const formHtml = await (await get("/admin/clients/new")).text();
|
||||||
|
assert.match(formHtml, /Register client/);
|
||||||
|
assert.match(formHtml, /keep a secret/i); // guidance on the public-vs-confidential choice (apostrophes arrive escaped: t() text goes through <%= %>)
|
||||||
|
const created = await post("/admin/clients", `_csrf=${token}&name=Grafana&redirectUris=${encodeURIComponent("https://graf/cb")}&scope=openid+offline_access`);
|
||||||
|
assert.equal(created.status, 200); // not a redirect — the secret is shown once
|
||||||
|
const createdHtml = await created.text();
|
||||||
|
assert.match(createdHtml, /Client registered/);
|
||||||
|
assert.match(createdHtml, /secret-1/); // the one-time client_secret
|
||||||
|
assert.match(createdHtml, /gen-1/); // the generated client_id
|
||||||
|
assert.ok(store.some((c) => c.client_name === "Grafana" && c.token_endpoint_auth_method === "client_secret_basic"));
|
||||||
|
|
||||||
|
// Invalid input (missing redirect URI) and a missing CSRF token are both refused, nothing created.
|
||||||
|
const before = store.length;
|
||||||
|
assert.equal((await post("/admin/clients", `_csrf=${token}&name=NoRedirect&redirectUris=`)).status, 400);
|
||||||
|
assert.equal((await post("/admin/clients", `name=x&redirectUris=${encodeURIComponent("https://x/cb")}`)).status, 403);
|
||||||
|
assert.equal(store.length, before);
|
||||||
|
|
||||||
|
// Detail: read-only info, never the secret again, a delete control.
|
||||||
|
const detail = await (await get("/admin/clients/existing")).text();
|
||||||
|
assert.match(detail, /reporting\.example\/cb/);
|
||||||
|
assert.doesNotMatch(detail, /Client secret/i); // the secret is shown only once, at creation
|
||||||
|
assert.match(detail, /delete and re-register/i); // no edit: the lifecycle guidance is surfaced
|
||||||
|
assert.match(detail, /href="\/admin\/clients\/existing\/delete"/);
|
||||||
|
|
||||||
|
// Delete: a confirm step (GET) then the POST removes the client, back to the list.
|
||||||
|
assert.match(await (await get("/admin/clients/existing/delete")).text(), /Cancel/);
|
||||||
|
const del = await post("/admin/clients/existing/delete", `_csrf=${token}`);
|
||||||
|
assert.equal(del.status, 303);
|
||||||
|
assert.equal(del.headers.get("location"), "/admin/clients");
|
||||||
|
assert.ok(!store.some((c) => c.client_id === "existing"));
|
||||||
|
|
||||||
|
// Unknown id → 404; malformed %-encoding doesn't 500.
|
||||||
|
assert.equal((await get("/admin/clients/does-not-exist")).status, 404);
|
||||||
|
assert.equal((await get("/admin/clients/%ZZ")).status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { createApp } from "./app.ts";
|
||||||
|
import { CSRF_COOKIE, issueCsrfToken } from "../auth/csrf.ts";
|
||||||
|
import { HydraError, type HydraAdmin } from "../auth/hydra-admin.ts";
|
||||||
|
import { staticJwks } from "../auth/jwks.ts";
|
||||||
|
import type { Identity } from "../auth/kratos-admin.ts";
|
||||||
|
import { KratosError, type Flow, type FlowSubmission, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts";
|
||||||
|
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||||
|
import { createI18n } from "../i18n/runtime.ts";
|
||||||
|
import { loadI18n } from "../i18n/load.ts";
|
||||||
|
import { ecJwk, fakeKeto, mintJwt, mockKratos, OAUTH_SUBJECT, stubAdmin, stubHydra, withWhoami } from "./app-harness.ts";
|
||||||
|
|
||||||
|
// A re-rendered login flow: csrf hidden, themed fields, a submit, and a failed-attempt message.
|
||||||
|
const node = (attrs: Record<string, unknown>, label?: string): UiNode => ({ attributes: attrs, group: "default", messages: [], meta: label ? { label: { id: 1, text: label, type: "info" } } : {}, type: "input" });
|
||||||
|
const loginFlow = (id: string): Flow => ({
|
||||||
|
id,
|
||||||
|
ui: {
|
||||||
|
action: `http://127.0.0.1:4433/self-service/login?flow=${id}`,
|
||||||
|
messages: [{ id: 4000006, text: "The provided credentials are invalid.", type: "error" }],
|
||||||
|
method: "post",
|
||||||
|
nodes: [
|
||||||
|
node({ name: "csrf_token", type: "hidden", value: "tok" }),
|
||||||
|
node({ name: "identifier", required: true, type: "email" }, "E-Mail"),
|
||||||
|
node({ name: "password", required: true, type: "password" }, "Password"),
|
||||||
|
node({ name: "method", type: "submit", value: "password" }, "Sign in"),
|
||||||
|
{ attributes: { name: "provider", type: "submit", value: "google" }, group: "oidc", messages: [], meta: { label: { id: 1, text: "Sign in with Google", type: "info" } }, type: "input" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET dispatch for the themed auth pages: the same handler branches on session presence.
|
||||||
|
test("themed auth GET: anonymous inits a flow (CSRF relay, stale→restart); a signed-in user is sent home, except /settings", async (t) => {
|
||||||
|
const app = createApp({ jwks: staticJwks([ecJwk]), kratos: mockKratos(async (_t, id) => { if (id === "stale") throw new KratosError("gone", 410, ""); return loginFlow(id); }) });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
|
||||||
|
// Anonymous, no ?flow= → init one + relay Kratos' CSRF cookie.
|
||||||
|
const init = await fetch(url + "/login", { redirect: "manual" });
|
||||||
|
assert.equal(init.status, 303);
|
||||||
|
assert.equal(init.headers.get("location"), "/login?flow=new1");
|
||||||
|
assert.match(init.headers.get("set-cookie") ?? "", /csrf_token=abc/);
|
||||||
|
// A stale flow id (Kratos 410) bounces back to a fresh init.
|
||||||
|
const stale = await fetch(url + "/login?flow=stale", { redirect: "manual" });
|
||||||
|
assert.equal(stale.status, 303);
|
||||||
|
assert.equal(stale.headers.get("location"), "/login");
|
||||||
|
|
||||||
|
// 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, permissions: [], sub: "u1" })}` }, redirect: "manual" as const };
|
||||||
|
for (const path of ["/login", "/registration"]) {
|
||||||
|
const res = await fetch(url + path, signedIn);
|
||||||
|
assert.equal(res.status, 303, `${path} while signed in → 303`);
|
||||||
|
assert.equal(res.headers.get("location"), "/dashboard");
|
||||||
|
}
|
||||||
|
assert.equal((await fetch(url + "/settings", signedIn)).headers.get("location"), "/settings?flow=new1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("themed auth GET: an existing Kratos session (no app JWT yet) recovers via /auth/complete, never 500", async (t) => {
|
||||||
|
// After registration's `session` hook the user holds a Kratos session but no app JWT — so ctx.user
|
||||||
|
// is null and the "already signed in" short-circuit can't fire. Initialising a login/registration
|
||||||
|
// flow then returns Kratos 400 `session_already_available`; recover by completing login (mint the
|
||||||
|
// JWT from the live session), preserving return_to — never fall through to the catch-all 500.
|
||||||
|
const sessionRace = new KratosError("Kratos init login flow failed (400)", 400, JSON.stringify({ error: { id: "session_already_available" } }));
|
||||||
|
const app = createApp({ jwks: staticJwks([ecJwk]), kratos: { ...mockKratos(async () => loginFlow("x")), initBrowserFlow: async () => { throw sessionRace; } } });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
|
||||||
|
const recover = await fetch(url + "/login", { redirect: "manual" });
|
||||||
|
assert.equal(recover.status, 303);
|
||||||
|
assert.equal(recover.headers.get("location"), "/auth/complete");
|
||||||
|
// return_to is carried through so the deep link still lands after the JWT is minted.
|
||||||
|
const deep = await fetch(url + "/login?return_to=" + encodeURIComponent("/admin/users"), { redirect: "manual" });
|
||||||
|
assert.equal(deep.headers.get("location"), "/auth/complete?return_to=%2Fadmin%2Fusers");
|
||||||
|
|
||||||
|
// A genuinely unexpected Kratos 400 is still surfaced as a 500 (not masked as a session race).
|
||||||
|
const app2 = createApp({ jwks: staticJwks([ecJwk]), kratos: { ...mockKratos(async () => loginFlow("x")), initBrowserFlow: async () => { throw new KratosError("bad", 400, JSON.stringify({ error: { id: "security_csrf_violation" } })); } } });
|
||||||
|
await new Promise<void>((r) => app2.listen(0, r));
|
||||||
|
t.after(() => app2.close());
|
||||||
|
const url2 = `http://localhost:${(app2.address() as AddressInfo).port}`;
|
||||||
|
assert.equal((await fetch(url2 + "/login", { redirect: "manual" })).status, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("themed auth GET: an anonymous hit on /settings (Kratos 401) bounces to sign-in with the page as return_to, never 500", async (t) => {
|
||||||
|
let failure = new KratosError("Kratos init settings flow failed (401)", 401, "");
|
||||||
|
const throwing = async (): Promise<never> => { throw failure; };
|
||||||
|
const app = createApp({ jwks: staticJwks([ecJwk]), kratos: { ...mockKratos(throwing), initBrowserFlow: throwing } });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
|
||||||
|
const init = await fetch(url + "/settings", { redirect: "manual" });
|
||||||
|
assert.equal(init.status, 303);
|
||||||
|
assert.equal(init.headers.get("location"), "/login?return_to=%2Fsettings");
|
||||||
|
const existing = await fetch(url + "/settings?flow=f1", { redirect: "manual" });
|
||||||
|
assert.equal(existing.headers.get("location"), "/login?return_to=%2Fsettings");
|
||||||
|
// /login redirecting to itself would loop, so a 401 there stays the catch-all 500.
|
||||||
|
assert.equal((await fetch(url + "/login", { redirect: "manual" })).status, 500);
|
||||||
|
// Only a 401 means "sign in": an Ory outage on /settings is still the honest 503.
|
||||||
|
failure = new KratosError("Kratos init settings flow failed (500)", 500, "");
|
||||||
|
assert.equal((await fetch(url + "/settings", { redirect: "manual" })).status, 503);
|
||||||
|
});
|
||||||
|
|
||||||
|
// return_to: a deep-link login lands back on the requested page. The gate redirects to
|
||||||
|
// /login?return_to=<host-relative path>; /login bakes that into the Kratos flow so completion
|
||||||
|
// returns there — but a first-party path must route via /auth/complete first (to mint the JWT).
|
||||||
|
test("login return_to: a first-party deep link is wrapped through /auth/complete; an absolute target passes through as-is", async (t) => {
|
||||||
|
let lastReturnTo: string | undefined;
|
||||||
|
const kratos: KratosPublic = {
|
||||||
|
...mockKratos(async (_t, id) => loginFlow(id)),
|
||||||
|
initBrowserFlow: async (_t: FlowType, opts = {}) => { lastReturnTo = opts.returnTo; return { flow: { id: "new1", ui: { action: "", method: "post", nodes: [] } }, setCookie: [] }; },
|
||||||
|
};
|
||||||
|
const app = createApp({ kratos });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
|
||||||
|
// A host-relative deep link → wrapped: Kratos returns to <origin>/auth/complete?return_to=<path>,
|
||||||
|
// so the JWT is minted before the user lands on the page (query preserved, re-encoded).
|
||||||
|
await fetch(url + "/login?return_to=" + encodeURIComponent("/admin/users?q=1"), { redirect: "manual" });
|
||||||
|
assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?return_to=%2Fadmin%2Fusers%3Fq%3D1$/);
|
||||||
|
|
||||||
|
// An absolute target (the OAuth2 login challenge) is passed to Kratos unchanged — Kratos
|
||||||
|
// allow-lists it. A protocol-relative "//evil.com" is likewise not wrapped (Kratos rejects it).
|
||||||
|
const abs = "http://localhost/oauth2/login?login_challenge=abc";
|
||||||
|
await fetch(url + "/login?return_to=" + encodeURIComponent(abs), { redirect: "manual" });
|
||||||
|
assert.equal(lastReturnTo, abs);
|
||||||
|
await fetch(url + "/login?return_to=" + encodeURIComponent("//evil.com"), { redirect: "manual" });
|
||||||
|
assert.equal(lastReturnTo, "//evil.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("login return_to: a chosen language alone still routes through /auth/complete, so it survives the sign-in", async (t) => {
|
||||||
|
let lastReturnTo: string | undefined;
|
||||||
|
const kratos: KratosPublic = {
|
||||||
|
...mockKratos(async (_t, id) => loginFlow(id)),
|
||||||
|
initBrowserFlow: async (_t: FlowType, opts = {}) => { lastReturnTo = opts.returnTo; return { flow: { id: "new1", ui: { action: "", method: "post", nodes: [] } }, setCookie: [] }; },
|
||||||
|
};
|
||||||
|
const app = createApp({ i18n: createI18n(await loadI18n()), kratos });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
|
||||||
|
lastReturnTo = "unset";
|
||||||
|
await fetch(url + "/login", { redirect: "manual" });
|
||||||
|
assert.equal(lastReturnTo, undefined, "no target and no language: Kratos keeps its default");
|
||||||
|
await fetch(url + "/login?locale=sv-SE", { redirect: "manual" });
|
||||||
|
assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?locale=sv-SE$/);
|
||||||
|
await fetch(url + "/login?locale=sv-SE&return_to=" + encodeURIComponent("/admin/users"), { redirect: "manual" });
|
||||||
|
assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?return_to=%2Fadmin%2Fusers%3Flocale%3Dsv-SE&locale=sv-SE$/);
|
||||||
|
|
||||||
|
// An absolute target still reaches Kratos beside a chosen language; the language is lost instead.
|
||||||
|
const abs = "http://localhost/oauth2/login?login_challenge=abc";
|
||||||
|
await fetch(url + "/login?locale=sv-SE&return_to=" + encodeURIComponent(abs), { redirect: "manual" });
|
||||||
|
assert.equal(lastReturnTo, abs);
|
||||||
|
});
|
||||||
|
|
||||||
|
// "Ory down ⇒ no logins" is documented; the auth path should say so honestly (503), not the
|
||||||
|
// generic "error on our end" 500 the catch-all renders.
|
||||||
|
test("auth flow when Ory is unreachable → an honest 503, not the catch-all 500", async (t) => {
|
||||||
|
const boom = () => { throw new KratosError("kratos down", 503, ""); };
|
||||||
|
const down: KratosPublic = { ...mockKratos(async () => boom()), initBrowserFlow: async () => boom() };
|
||||||
|
const app = createApp({ kratos: down });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
|
||||||
|
const init = await fetch(url + "/login", { redirect: "manual" }); // init (no ?flow=) with Kratos down
|
||||||
|
assert.equal(init.status, 503);
|
||||||
|
assert.match(await init.text(), /unavailable/i);
|
||||||
|
assert.equal((await fetch(url + "/login?flow=f1")).status, 503); // fetching a flow, Kratos down
|
||||||
|
|
||||||
|
// A network-level throw (refused/timeout — not a KratosError) is treated the same way.
|
||||||
|
const refused: KratosPublic = { ...mockKratos(async () => { throw new Error("ECONNREFUSED"); }), initBrowserFlow: async () => { throw new Error("ECONNREFUSED"); } };
|
||||||
|
const app2 = createApp({ kratos: refused });
|
||||||
|
await new Promise<void>((r) => app2.listen(0, r));
|
||||||
|
t.after(() => app2.close());
|
||||||
|
assert.equal((await fetch(`http://localhost:${(app2.address() as AddressInfo).port}/login`, { redirect: "manual" })).status, 503);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("renders a fetched flow as the themed auth page: fields post straight to Kratos, errors surface", async (t) => {
|
||||||
|
const app = createApp({ kratos: mockKratos(async (_t, id) => loginFlow(id)) });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const html = await (await fetch(`http://localhost:${(app.address() as AddressInfo).port}/login?flow=f1`)).text();
|
||||||
|
|
||||||
|
// The form posts to flow.ui.action (Kratos owns CSRF); csrf rides as a hidden input.
|
||||||
|
assert.match(html, /<form class="auth-card" method="post" action="http:\/\/127\.0\.0\.1:4433\/self-service\/login\?flow=f1"/);
|
||||||
|
assert.match(html, /<input type="hidden" name="csrf_token" value="tok">/);
|
||||||
|
assert.match(html, /name="identifier"/);
|
||||||
|
assert.match(html, /name="password"[^>]*type="password"/);
|
||||||
|
assert.match(html, /<button type="submit"[^>]*name="method" value="password">Sign in<\/button>/);
|
||||||
|
assert.match(html, /<a href="\/registration">Create one<\/a>/); // alt link to register
|
||||||
|
// Configured OIDC provider → an SSO submit button in the same form (posts provider=google);
|
||||||
|
// `formnovalidate` so it bypasses the required email/password fields (SSO needs neither).
|
||||||
|
assert.match(html, /<div class="sso"/);
|
||||||
|
assert.match(html, /<button type="submit" class="sso-btn" name="provider" value="google" formnovalidate>.*Sign in with Google<\/span><\/button>/s);
|
||||||
|
// The flow-level error renders as an alert.
|
||||||
|
assert.match(html, /class="alert alert-neg"/);
|
||||||
|
assert.match(html, /The credentials are invalid\./); // 4000006 → our wording (README → Translating)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login completion: /auth/complete is where Kratos lands the browser after login.
|
||||||
|
test("login completion (/auth/complete): a live session mints the JWT cookie; no session → /login, no cookie", async (t) => {
|
||||||
|
const identity: Identity = { id: "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55", traits: { email: "admin@plainpages.local" } };
|
||||||
|
let projected: unknown;
|
||||||
|
const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session);
|
||||||
|
const kratosAdmin = stubAdmin({ updateMetadataPublic: async (_id, meta) => { projected = meta; return identity; } });
|
||||||
|
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${identity.id}` }] }) });
|
||||||
|
const complete = async (app: ReturnType<typeof createApp>, cookie?: string, returnTo?: string) => {
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const q = returnTo ? `?return_to=${encodeURIComponent(returnTo)}` : "";
|
||||||
|
return fetch(`http://localhost:${(app.address() as AddressInfo).port}/auth/complete${q}`, { headers: cookie ? { cookie } : {}, redirect: "manual" });
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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");
|
||||||
|
assert.equal(ok.status, 303);
|
||||||
|
assert.equal(ok.headers.get("location"), "/dashboard");
|
||||||
|
assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=h\.p\.s;.*HttpOnly/);
|
||||||
|
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
|
||||||
|
// off-origin one is ignored (open-redirect guard) and falls back to the dashboard.
|
||||||
|
assert.equal((await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s", "/admin/users?q=1")).headers.get("location"), "/admin/users?q=1");
|
||||||
|
assert.equal((await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s", "//evil.com")).headers.get("location"), "/dashboard");
|
||||||
|
|
||||||
|
// No Kratos session: nothing minted, bounce to /login with no cookie.
|
||||||
|
const none = await complete(createApp({ keto: fakeKeto(), kratos: withWhoami(async () => null), kratosAdmin: stubAdmin({}) }));
|
||||||
|
assert.equal(none.status, 303);
|
||||||
|
assert.equal(none.headers.get("location"), "/login");
|
||||||
|
assert.equal(none.headers.get("set-cookie"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("logout (CSRF-guarded POST): valid token revokes the Kratos session + clears our JWT; bad token → 403", async (t) => {
|
||||||
|
const logoutUrl = "http://127.0.0.1:4433/self-service/logout?token=lt";
|
||||||
|
// Real Kratos keys off its own session cookie (plainpages_session), not our always-present CSRF cookie.
|
||||||
|
const kratos: KratosPublic = { ...mockKratos(async () => { throw new Error("unused"); }), createLogoutFlow: async (o) => (o?.cookie?.includes("plainpages_session") ? { logoutToken: "lt", logoutUrl } : null) };
|
||||||
|
const csrfSecret = "logout-secret";
|
||||||
|
const app = createApp({ csrfSecret, kratos });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
const token = issueCsrfToken(csrfSecret);
|
||||||
|
const post = (cookie: string, body: string) =>
|
||||||
|
fetch(url + "/logout", { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie }, method: "POST", redirect: "manual" });
|
||||||
|
|
||||||
|
// Valid double-submit (cookie token === form token) + active session → Kratos logout URL, JWT cleared.
|
||||||
|
const out = await post(`${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=x; plainpages_session=s`, `_csrf=${token}`);
|
||||||
|
assert.equal(out.status, 303);
|
||||||
|
assert.equal(out.headers.get("location"), logoutUrl);
|
||||||
|
assert.match(out.headers.getSetCookie().join("\n"), /plainpages_jwt=;.*Max-Age=0/);
|
||||||
|
|
||||||
|
// No active Kratos session → clear our cookie and land on /login ourselves.
|
||||||
|
const none = await post(`${CSRF_COOKIE}=${token}`, `_csrf=${token}`);
|
||||||
|
assert.equal(none.status, 303);
|
||||||
|
assert.equal(none.headers.get("location"), "/login");
|
||||||
|
assert.match(none.headers.getSetCookie().join("\n"), /plainpages_jwt=;.*Max-Age=0/);
|
||||||
|
|
||||||
|
// Missing field and a forged token are both refused (no Kratos call, no cookie cleared).
|
||||||
|
assert.equal((await post(`${CSRF_COOKIE}=${token}`, "")).status, 403);
|
||||||
|
assert.equal((await post(`${CSRF_COOKIE}=${token}`, "_csrf=forged.sig")).status, 403);
|
||||||
|
assert.equal((await post("", `_csrf=${token}`)).status, 403); // no cookie to match
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const oauthSession = (): Session => ({ active: true, identity: { id: OAUTH_SUBJECT, traits: { email: "ada@x.io" } } });
|
||||||
|
|
||||||
|
test("OAuth2 login challenge (/oauth2/login): a Kratos session accepts via Hydra; no session bounces to /login; missing challenge → 400", async (t) => {
|
||||||
|
const identity = { id: "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55" };
|
||||||
|
let acceptedSubject: string | undefined;
|
||||||
|
const hydra = stubHydra({ acceptLoginRequest: async (_c, b) => { acceptedSubject = b.subject; return { redirect: "http://127.0.0.1:4444/oauth2/auth?login_verifier=v" }; } });
|
||||||
|
|
||||||
|
const signedIn = createApp({ hydra, kratos: withWhoami(async () => ({ active: true, identity }) as Session) });
|
||||||
|
await new Promise<void>((r) => signedIn.listen(0, r));
|
||||||
|
t.after(() => signedIn.close());
|
||||||
|
const base = `http://localhost:${(signedIn.address() as AddressInfo).port}`;
|
||||||
|
|
||||||
|
// Signed in: accept the challenge with the Kratos identity → 303 to Hydra's resume URL.
|
||||||
|
const accept = await fetch(base + "/oauth2/login?login_challenge=chal1", { headers: { cookie: "plainpages_session=s" }, redirect: "manual" });
|
||||||
|
assert.equal(accept.status, 303);
|
||||||
|
assert.match(accept.headers.get("location") ?? "", /\/oauth2\/auth\?login_verifier=v/);
|
||||||
|
assert.equal(acceptedSubject, identity.id);
|
||||||
|
|
||||||
|
// Missing login_challenge → 400 (someone hit the endpoint directly).
|
||||||
|
assert.equal((await fetch(base + "/oauth2/login", { redirect: "manual" })).status, 400);
|
||||||
|
|
||||||
|
// Not signed in: bounce to the themed login, return_to carrying an absolute URL back to here.
|
||||||
|
const anon = createApp({ hydra: stubHydra(), kratos: withWhoami(async () => null) });
|
||||||
|
await new Promise<void>((r) => anon.listen(0, r));
|
||||||
|
t.after(() => anon.close());
|
||||||
|
const bounce = await fetch(`http://localhost:${(anon.address() as AddressInfo).port}/oauth2/login?login_challenge=chal1`, { redirect: "manual" });
|
||||||
|
assert.equal(bounce.status, 303);
|
||||||
|
const loc = bounce.headers.get("location") ?? "";
|
||||||
|
assert.match(loc, /^\/login\?return_to=/);
|
||||||
|
assert.match(decodeURIComponent(loc.split("return_to=")[1]!), /^http:\/\/[^/]+\/oauth2\/login\?login_challenge=chal1$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OAuth2 consent challenge (/oauth2/consent): skip auto-accepts; a third-party shows the screen; allow/deny POST; CSRF-guarded; missing challenge", async (t) => {
|
||||||
|
const csrfSecret = "consent-secret";
|
||||||
|
let granted: { grant_scope?: string[]; session?: unknown } | undefined;
|
||||||
|
const hydra = stubHydra({
|
||||||
|
acceptConsentRequest: async (_c, b) => { granted = b; return { redirect: "http://127.0.0.1:4444/oauth2/auth?consent_verifier=v" }; },
|
||||||
|
rejectConsentRequest: async () => ({ redirect: "http://acme.example/cb?error=access_denied" }),
|
||||||
|
});
|
||||||
|
const app = createApp({ csrfSecret, hydra, kratos: withWhoami(async () => oauthSession()) });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const base = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
const token = issueCsrfToken(csrfSecret);
|
||||||
|
const post = (body: string) =>
|
||||||
|
fetch(base + "/oauth2/consent", { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${CSRF_COOKIE}=${token}` }, method: "POST", redirect: "manual" });
|
||||||
|
|
||||||
|
// Third-party (default stub: not first-party, not skipped) → 200 consent screen listing the
|
||||||
|
// client + scopes, with a CSRF cookie its form echoes back; posts to our own /oauth2/consent.
|
||||||
|
const page = await fetch(base + "/oauth2/consent?consent_challenge=cons1", { redirect: "manual" });
|
||||||
|
assert.equal(page.status, 200);
|
||||||
|
const html = await page.text();
|
||||||
|
assert.match(html, /Authorize Acme Reports/);
|
||||||
|
assert.match(html, /openid/);
|
||||||
|
assert.match(html, /profile/);
|
||||||
|
assert.match(html, /action="\/oauth2\/consent"/);
|
||||||
|
// Informed consent: the screen names the account being authorized + offers a sign-out escape.
|
||||||
|
assert.match(html, /Signed in as.*ada@x\.io/s);
|
||||||
|
assert.match(html, /action="\/logout".*Sign out/s);
|
||||||
|
assert.match(page.headers.get("set-cookie") ?? "", /plainpages_csrf=/);
|
||||||
|
|
||||||
|
// Allow → 303 to Hydra, granting the scopes re-read from the challenge (never form-supplied) +
|
||||||
|
// id_token claims from the Kratos identity.
|
||||||
|
const allow = await post(`_csrf=${token}&consent_challenge=cons1&decision=allow`);
|
||||||
|
assert.equal(allow.status, 303);
|
||||||
|
assert.match(allow.headers.get("location") ?? "", /\/oauth2\/auth\?consent_verifier=v/);
|
||||||
|
assert.deepEqual(granted?.grant_scope, ["openid", "profile"]);
|
||||||
|
assert.deepEqual(granted?.session, { id_token: { email: "ada@x.io" } });
|
||||||
|
|
||||||
|
// Deny → 303 back to the client with access_denied.
|
||||||
|
const deny = await post(`_csrf=${token}&consent_challenge=cons1&decision=deny`);
|
||||||
|
assert.equal(deny.status, 303);
|
||||||
|
assert.equal(deny.headers.get("location"), "http://acme.example/cb?error=access_denied");
|
||||||
|
|
||||||
|
// Forged/missing CSRF → 403 (no Hydra call); missing challenge → 400.
|
||||||
|
assert.equal((await post("decision=allow")).status, 403);
|
||||||
|
assert.equal((await fetch(base + "/oauth2/consent", { redirect: "manual" })).status, 400);
|
||||||
|
|
||||||
|
// A Hydra-skipped client auto-accepts on GET (no screen) → 303 to Hydra.
|
||||||
|
const skip = createApp({ hydra: stubHydra({ getConsentRequest: async () => ({ challenge: "cons1", requested_scope: ["openid"], skip: true, subject: OAUTH_SUBJECT }) }), kratos: withWhoami(async () => oauthSession()) });
|
||||||
|
await new Promise<void>((r) => skip.listen(0, r));
|
||||||
|
t.after(() => skip.close());
|
||||||
|
const auto = await fetch(`http://localhost:${(skip.address() as AddressInfo).port}/oauth2/consent?consent_challenge=cons1`, { redirect: "manual" });
|
||||||
|
assert.equal(auto.status, 303);
|
||||||
|
assert.match(auto.headers.get("location") ?? "", /consent_verifier=v/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OAuth2 RP-initiated logout (/oauth2/logout): accepts the logout challenge → 303 to Hydra; missing → 400", async (t) => {
|
||||||
|
let acceptedChallenge: string | undefined;
|
||||||
|
const hydra = stubHydra({ acceptLogoutRequest: async (c) => { acceptedChallenge = c; return { redirect: "http://acme.example/post-logout" }; } });
|
||||||
|
const app = createApp({ hydra, kratos: withWhoami(async () => null) });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const base = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
|
||||||
|
const ok = await fetch(base + "/oauth2/logout?logout_challenge=lc1", { redirect: "manual" });
|
||||||
|
assert.equal(ok.status, 303);
|
||||||
|
assert.equal(ok.headers.get("location"), "http://acme.example/post-logout");
|
||||||
|
assert.equal(acceptedChallenge, "lc1");
|
||||||
|
|
||||||
|
assert.equal((await fetch(base + "/oauth2/logout", { redirect: "manual" })).status, 400);
|
||||||
|
});
|
||||||
|
|
||||||
|
// All three OAuth2 challenge endpoints share one degrade contract (the documented "byte-identical"
|
||||||
|
// behaviour): a stale/consumed challenge (Hydra 4xx — back button, slow login) → recoverable 400,
|
||||||
|
// a genuine Hydra outage (5xx) → 500.
|
||||||
|
test("OAuth2 challenge endpoints degrade identically: stale Hydra 4xx → 400, outage 5xx → 500", async (t) => {
|
||||||
|
const endpoints: { make: (status: number) => Partial<HydraAdmin>; path: string }[] = [
|
||||||
|
{ make: (s) => ({ getLoginRequest: async () => { throw new HydraError("x", s, ""); } }), path: "/oauth2/login?login_challenge=x" },
|
||||||
|
{ make: (s) => ({ getConsentRequest: async () => { throw new HydraError("x", s, ""); } }), path: "/oauth2/consent?consent_challenge=x" },
|
||||||
|
{ make: (s) => ({ acceptLogoutRequest: async () => { throw new HydraError("x", s, ""); } }), path: "/oauth2/logout?logout_challenge=x" },
|
||||||
|
];
|
||||||
|
for (const { make, path } of endpoints) {
|
||||||
|
for (const [status, expected] of [[410, 400], [503, 500]] as const) {
|
||||||
|
const app = createApp({ hydra: stubHydra(make(status)), kratos: withWhoami(async () => null) });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}${path}`, { redirect: "manual" });
|
||||||
|
assert.equal(res.status, expected, `${path} ${status} → ${expected}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("code-flow POST: the host trims the code, relays the POST to Kratos and Kratos' redirect + cookies back", async (t) => {
|
||||||
|
const calls: { body: string; cookie: string | undefined; flowId: string; type: FlowType }[] = [];
|
||||||
|
let answer: () => Promise<FlowSubmission> = async () =>
|
||||||
|
({ location: "http://localhost:3000/settings?flow=s1", setCookie: ["plainpages_session=s; Path=/; HttpOnly"], status: 303 });
|
||||||
|
const kratos: KratosPublic = {
|
||||||
|
...mockKratos(async () => { throw new Error("unused"); }),
|
||||||
|
submitFlow: async (type, flowId, opts) => { calls.push({ body: opts.body, cookie: opts.cookie, flowId, type }); return answer(); },
|
||||||
|
};
|
||||||
|
const app = createApp({ jwks: staticJwks([ecJwk]), kratos });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
const post = (path: string, body: string) => fetch(url + path, {
|
||||||
|
body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: "csrf_token_x=c" }, method: "POST", redirect: "manual",
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await post("/recovery?flow=f1", "csrf_token=tok&code=%C2%A0123456%09%0A&method=code");
|
||||||
|
assert.equal(res.status, 303);
|
||||||
|
assert.equal(res.headers.get("location"), "http://localhost:3000/settings?flow=s1");
|
||||||
|
assert.match(res.headers.get("set-cookie") ?? "", /plainpages_session=s/);
|
||||||
|
assert.deepEqual(calls[0], { body: "csrf_token=tok&code=123456&method=code", cookie: "csrf_token_x=c", flowId: "f1", type: "recovery" });
|
||||||
|
|
||||||
|
// The first step has no code field; its body passes through unchanged.
|
||||||
|
await post("/verification?flow=f2", "csrf_token=tok&email=a%40b.c&method=code");
|
||||||
|
assert.deepEqual(calls[1], { body: "csrf_token=tok&email=a%40b.c&method=code", cookie: "csrf_token_x=c", flowId: "f2", type: "verification" });
|
||||||
|
|
||||||
|
// No ?flow= → start over rather than post a flow-less submit.
|
||||||
|
const noFlow = await post("/recovery", "code=1");
|
||||||
|
assert.equal(noFlow.headers.get("location"), "/recovery");
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
|
||||||
|
// Kratos' absolute redirect to this host becomes host-relative, so it keeps the chosen locale.
|
||||||
|
answer = async () => ({ location: `${url}/settings?flow=s1`, setCookie: [], status: 303 });
|
||||||
|
assert.equal((await post("/recovery?flow=f1&locale=en-US", "code=1")).headers.get("location"), "/settings?flow=s1&locale=en-US");
|
||||||
|
|
||||||
|
// Kratos answered without a redirect → back to the flow, whose page shows its state.
|
||||||
|
answer = async () => ({ location: null, setCookie: [], status: 400 });
|
||||||
|
assert.equal((await post("/recovery?flow=f1", "code=1")).headers.get("location"), "/recovery?flow=f1");
|
||||||
|
|
||||||
|
// Kratos down → the honest 503 the flow pages render, not a 500.
|
||||||
|
answer = async () => { throw new KratosError("bad gateway", 502, ""); };
|
||||||
|
assert.equal((await post("/recovery?flow=f1", "code=1")).status, 503);
|
||||||
|
answer = async () => { throw new TypeError("fetch failed"); };
|
||||||
|
assert.equal((await post("/recovery?flow=f1", "code=1")).status, 503);
|
||||||
|
|
||||||
|
// Sign-in posts straight to Kratos, so the host has no POST /login.
|
||||||
|
assert.notEqual((await post("/login?flow=f1", "x=1")).status, 303);
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { generateKeyPairSync, sign, type JsonWebKey } from "node:crypto";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import type { TestContext } from "node:test";
|
||||||
|
import { createApp } from "./app.ts";
|
||||||
|
import type { HydraAdmin } from "../auth/hydra-admin.ts";
|
||||||
|
import type { KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
|
||||||
|
import type { KratosAdmin } from "../auth/kratos-admin.ts";
|
||||||
|
import type { FlowType, KratosPublic } from "../auth/kratos-public.ts";
|
||||||
|
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||||
|
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||||
|
|
||||||
|
// A session JWT signed with a throwaway test key; `staticJwks([ecJwk])` is the matching verify side.
|
||||||
|
const ec = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
||||||
|
export const ecJwk: JsonWebKey = { ...(ec.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "test-kid" };
|
||||||
|
const b64url = (i: Buffer | string): string => Buffer.from(i).toString("base64url");
|
||||||
|
export function mintJwt(payload: Record<string, unknown>): string {
|
||||||
|
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 }))}`;
|
||||||
|
}
|
||||||
|
// A session cookie carrying `permissions`, valid for 10 min — the auth most tests need to reach a gated page.
|
||||||
|
export const session = (permissions: string[] = []): string =>
|
||||||
|
`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions, sub: "u1" })}`;
|
||||||
|
|
||||||
|
export async function startApp(t: TestContext, plugins: Plugin[], pluginsDir?: string): Promise<string> {
|
||||||
|
const app = createApp(pluginsDir ? { plugins, pluginsDir } : { plugins });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
return `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mockKratos(getFlow: KratosPublic["getFlow"]): KratosPublic {
|
||||||
|
return {
|
||||||
|
createLogoutFlow: async () => null,
|
||||||
|
getFlow,
|
||||||
|
initBrowserFlow: async (_t: FlowType) => ({ flow: { id: "new1", ui: { action: "", method: "post", nodes: [] } }, setCookie: ["csrf_token=abc; Path=/; HttpOnly"] }),
|
||||||
|
submitFlow: async () => { throw new Error("unused"); },
|
||||||
|
whoami: async () => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stubAdmin = (over: Partial<KratosAdmin>): KratosAdmin => ({
|
||||||
|
createIdentity: async () => { throw new Error("unused"); },
|
||||||
|
createRecoveryCode: async () => ({ code: "000000", link: "http://kratos/recover" }),
|
||||||
|
deleteIdentity: async () => {},
|
||||||
|
getIdentity: async () => null,
|
||||||
|
listIdentities: async () => ({ identities: [], nextPageToken: null }),
|
||||||
|
updateIdentity: async () => { throw new Error("unused"); },
|
||||||
|
updateMetadataPublic: async () => ({ id: "x" }),
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
const sameSet = (a?: SubjectSet, b?: SubjectSet): boolean =>
|
||||||
|
(!a && !b) || (!!a && !!b && a.namespace === b.namespace && a.object === b.object && a.relation === b.relation);
|
||||||
|
export const matchesTuple = (t: RelationTuple, f: Partial<RelationTuple>): boolean =>
|
||||||
|
(f.namespace === undefined || t.namespace === f.namespace) &&
|
||||||
|
(f.object === undefined || t.object === f.object) &&
|
||||||
|
(f.relation === undefined || t.relation === f.relation) &&
|
||||||
|
(f.subject_id === undefined || t.subject_id === f.subject_id) &&
|
||||||
|
(f.subject_set === undefined || sameSet(t.subject_set, f.subject_set));
|
||||||
|
// A stateful in-memory KetoClient over a tuple array (writes mutate it); used by login + the admin screens.
|
||||||
|
export const fakeKeto = (tuples: RelationTuple[] = [], over: Partial<KetoClient> = {}): KetoClient => ({
|
||||||
|
check: async () => false,
|
||||||
|
deleteTuple: async (f) => { for (let i = tuples.length - 1; i >= 0; i--) if (matchesTuple(tuples[i]!, f)) tuples.splice(i, 1); },
|
||||||
|
expand: async () => ({ type: "leaf" }),
|
||||||
|
listRelations: async (q = {}) => ({ nextPageToken: null, tuples: tuples.filter((t) => matchesTuple(t, q)) }),
|
||||||
|
writeTuple: async (tp) => { if (!tuples.some((t) => matchesTuple(t, tp) && sameSet(t.subject_set, tp.subject_set))) tuples.push(tp); },
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
export const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami });
|
||||||
|
|
||||||
|
// OAuth2 login challenge: another app logs in *through* us; Hydra hands the browser here.
|
||||||
|
export const stubHydra = (over: Partial<HydraAdmin> = {}): HydraAdmin => ({
|
||||||
|
acceptConsentRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?consent_verifier=v" }),
|
||||||
|
acceptLoginRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?login_verifier=v" }),
|
||||||
|
acceptLogoutRequest: async () => ({ redirect: "http://acme.example/post-logout" }),
|
||||||
|
createClient: async (c) => ({ ...c, client_id: "c1", client_secret: "s3cr3t" }),
|
||||||
|
deleteClient: async () => {},
|
||||||
|
getClient: async () => null,
|
||||||
|
getConsentRequest: async () => ({ challenge: "cons1", client: { client_name: "Acme Reports" }, requested_scope: ["openid", "profile"], skip: false, subject: OAUTH_SUBJECT }),
|
||||||
|
getLoginRequest: async () => ({ challenge: "chal1", skip: false, subject: "" }),
|
||||||
|
listClients: async () => ({ clients: [], nextPageToken: null }),
|
||||||
|
rejectConsentRequest: async () => ({ redirect: "http://acme.example/cb?error=access_denied" }),
|
||||||
|
rejectLoginRequest: async () => { throw new Error("unused"); },
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
export const OAUTH_SUBJECT = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55";
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { test, type TestContext } from "node:test";
|
||||||
|
import { createApp } from "./app.ts";
|
||||||
|
import { staticJwks } from "../auth/jwks.ts";
|
||||||
|
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||||
|
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||||
|
import { createI18n } from "../i18n/runtime.ts";
|
||||||
|
import type { MenuConfig } from "../ui/menu-config.ts";
|
||||||
|
import { loadI18n } from "../i18n/load.ts";
|
||||||
|
import { ecJwk, mintJwt } from "./app-harness.ts";
|
||||||
|
|
||||||
|
// ---- language (i18n) ----
|
||||||
|
|
||||||
|
// The installed catalogs, as server.ts wires them: the shipped core locales (en-US + sv-SE).
|
||||||
|
async function localeApp(t: TestContext): Promise<string> {
|
||||||
|
const app = createApp({ i18n: createI18n(await loadI18n()), jwks: staticJwks([ecJwk]) });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
return `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("?locale serves that language and carries the choice onto the links the page renders", async (t) => {
|
||||||
|
const url = await localeApp(t);
|
||||||
|
const html = await (await fetch(`${url}/?locale=sv-SE`)).text();
|
||||||
|
|
||||||
|
assert.match(html, /<html lang="sv-SE" dir="ltr">/); // the document says what language it is in
|
||||||
|
assert.match(html, /Logga in/); // the landing page's own words
|
||||||
|
assert.doesNotMatch(html, /Operational web apps/);
|
||||||
|
// The chosen locale rides along, so clicking through the app stays in Swedish without a cookie.
|
||||||
|
assert.match(html, /href="\/login\?locale=sv-SE"/);
|
||||||
|
// …and the picker offers the other installed locale, pointing at this same page.
|
||||||
|
assert.match(html, /hreflang="en-US"/);
|
||||||
|
assert.match(html, /href="\/\?locale=en-US"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Accept-Language decides when the URL doesn't, and a lone language matches its region", async (t) => {
|
||||||
|
const url = await localeApp(t);
|
||||||
|
const swedish = await (await fetch(`${url}/`, { headers: { "accept-language": "sv;q=0.9, en;q=0.4" } })).text();
|
||||||
|
assert.match(swedish, /<html lang="sv-SE"/);
|
||||||
|
// The visitor never asked for a locale in the URL, so the links stay clean.
|
||||||
|
assert.match(swedish, /href="\/login"/);
|
||||||
|
|
||||||
|
const english = await (await fetch(`${url}/`, { headers: { "accept-language": "de-DE" } })).text();
|
||||||
|
assert.match(english, /<html lang="en-US"/); // nothing matches ⇒ the baseline
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an uninstalled or malformed ?locale falls back instead of failing", async (t) => {
|
||||||
|
const url = await localeApp(t);
|
||||||
|
for (const bad of ["sv-FI", "klingon", "../../etc"]) {
|
||||||
|
const res = await fetch(`${url}/?locale=${encodeURIComponent(bad)}`);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.match(await res.text(), /<html lang="en-US"/, `expected en-US for ${bad}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a redirect the host emits keeps the visitor's language", async (t) => {
|
||||||
|
const url = await localeApp(t);
|
||||||
|
const res = await fetch(`${url}/dashboard?locale=sv-SE`, { redirect: "manual" }); // anonymous ⇒ sign in first
|
||||||
|
assert.equal(res.status, 303);
|
||||||
|
const location = res.headers.get("location") ?? "";
|
||||||
|
assert.match(location, /^\/login\?/);
|
||||||
|
assert.match(location, /locale=sv-SE/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the error pages speak the visitor's language too", async (t) => {
|
||||||
|
const url = await localeApp(t);
|
||||||
|
const html = await (await fetch(`${url}/no-such-page?locale=sv-SE`)).text();
|
||||||
|
assert.match(html, /<html lang="sv-SE"/);
|
||||||
|
assert.match(html, /Sidan hittades inte/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a plugin that owns a landing page, or short-circuits a hook, translates from its own catalog", async (t) => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "pp-i18n-plugin-"));
|
||||||
|
mkdirSync(join(dir, "demo", "i18n"), { recursive: true });
|
||||||
|
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||||
|
writeFileSync(join(dir, "demo", "i18n", "en-US.ts"), 'const m = { "demo.hello": "Hello from the plugin" };\nexport default m;\n');
|
||||||
|
|
||||||
|
// Every plugin-owned render path: the public landing, the gated dashboard, and a hook short-circuit.
|
||||||
|
const demo: Plugin = {
|
||||||
|
apiVersion: "1.0.0",
|
||||||
|
dashboard: (ctx) => ({ html: ctx.t("demo.hello") }),
|
||||||
|
home: (ctx) => ({ html: ctx.t("demo.hello") }),
|
||||||
|
hooks: { onRequest: (ctx) => (ctx.url.pathname === "/hooked" ? { html: ctx.t("demo.hello") } : undefined) },
|
||||||
|
id: "demo",
|
||||||
|
};
|
||||||
|
const i18n = createI18n(await loadI18n({ pluginIds: ["demo"], pluginsDir: dir }));
|
||||||
|
const app = createApp({ i18n, jwks: staticJwks([ecJwk]), plugins: [demo], pluginsDir: dir });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
const cookie = `${SESSION_COOKIE}=${mintJwt({ email: "a@b", exp: nowSec + 600, permissions: [], sub: "u1" })}`;
|
||||||
|
|
||||||
|
assert.equal(await (await fetch(`${url}/`)).text(), "Hello from the plugin");
|
||||||
|
assert.equal(await (await fetch(`${url}/hooked`)).text(), "Hello from the plugin");
|
||||||
|
assert.equal(await (await fetch(`${url}/dashboard`, { headers: { cookie } })).text(), "Hello from the plugin");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an error page renders without composing the menu — it exists for when the shell's data is what failed", async (t) => {
|
||||||
|
// The chrome getter is lazy on purpose; a render that reads no chrome must not trigger it, or a
|
||||||
|
// broken menu takes the error pages down with it.
|
||||||
|
let built = 0;
|
||||||
|
const menu: MenuConfig = { branding: { get name() { built++; return "Plainpages"; } }, override: {} };
|
||||||
|
const app = createApp({ jwks: staticJwks([ecJwk]), menu });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
|
||||||
|
const res = await fetch(`${url}/no-such-page`);
|
||||||
|
assert.equal(res.status, 404);
|
||||||
|
assert.match(await res.text(), /Page not found/);
|
||||||
|
assert.equal(built, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a POST-rendered page still offers the language picker, pointed at a page that answers GET", async (t) => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "pp-post-lang-"));
|
||||||
|
mkdirSync(join(dir, "demo", "views"), { recursive: true });
|
||||||
|
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||||
|
// The view renders the picker exactly as the shell does.
|
||||||
|
writeFileSync(join(dir, "demo", "views", "page.ejs"), `<%- include("partials/locale-switch") %>`);
|
||||||
|
const demo: Plugin = {
|
||||||
|
apiVersion: "1.0.0",
|
||||||
|
id: "demo",
|
||||||
|
routes: [
|
||||||
|
{ handler: () => ({ view: "page" }), method: "GET", path: "/thing" },
|
||||||
|
{ handler: () => ({ view: "page" }), method: "POST", path: "/thing" },
|
||||||
|
{ handler: () => ({ view: "page" }), method: "POST", path: "/thing/act" }, // POST-only: no GET sibling
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const app = createApp({ i18n: createI18n(await loadI18n()), plugins: [demo], pluginsDir: dir });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
const post = (path: string, headers: Record<string, string> = {}) => fetch(url + path, { headers, method: "POST" });
|
||||||
|
|
||||||
|
// A POST whose path also answers GET → the picker points at that page.
|
||||||
|
assert.match(await (await post("/demo/thing?locale=sv-SE")).text(), /href="\/demo\/thing\?locale=en-US"/);
|
||||||
|
// A POST-only path → the page the form was submitted from, so the link can't dead-end on a 405.
|
||||||
|
const fromForm = await post("/demo/thing/act?locale=sv-SE", { referer: `${url}/demo/thing?locale=sv-SE` });
|
||||||
|
assert.match(await fromForm.text(), /href="\/demo\/thing\?locale=en-US"/);
|
||||||
|
// …and with no referer to fall back on, the front page.
|
||||||
|
assert.match(await (await post("/demo/thing/act?locale=sv-SE")).text(), /href="\/\?locale=en-US"/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { createApp } from "./app.ts";
|
||||||
|
import { readFormBody } from "./body.ts";
|
||||||
|
import { createDenylist } from "../auth/denylist.ts";
|
||||||
|
import { CSRF_COOKIE, issueCsrfToken } from "../auth/csrf.ts";
|
||||||
|
import { can, check, GuardError, requireSession } from "../auth/guards.ts";
|
||||||
|
import { staticJwks } from "../auth/jwks.ts";
|
||||||
|
import type { Identity } from "../auth/kratos-admin.ts";
|
||||||
|
import { KratosError, type Session } from "../auth/kratos-public.ts";
|
||||||
|
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||||
|
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||||
|
import { ecJwk, fakeKeto, mintJwt, startApp, stubAdmin, withWhoami } from "./app-harness.ts";
|
||||||
|
|
||||||
|
// A test plugin exercising each RouteResult shape, a path param, and the permission gate.
|
||||||
|
const demoPlugin: Plugin = {
|
||||||
|
apiVersion: "1.0.0",
|
||||||
|
id: "demo",
|
||||||
|
routes: [
|
||||||
|
{ handler: (ctx) => ({ html: `<p>Hi ${ctx.params.name}</p>` }), method: "GET", path: "/hello/:name" },
|
||||||
|
{ handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" },
|
||||||
|
{ handler: () => ({ redirect: "/demo/hello/world" }), method: "POST", path: "/go" },
|
||||||
|
{ handler: () => ({ html: "secret" }), method: "GET", path: "/secret", permission: "demo:read" },
|
||||||
|
{ handler: () => ({ html: "open to all" }), method: "GET", path: "/public-page", public: true }, // blessed public
|
||||||
|
{ handler: () => ({ data: { who: "Plainpages" }, view: "page" }), method: "GET", path: "/page" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
test("mounts plugin routes: params, html/json/redirect/view results, and the permission gate", async (t) => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
|
||||||
|
mkdirSync(join(dir, "demo", "views"), { recursive: true });
|
||||||
|
mkdirSync(join(dir, "demo", "public"), { recursive: true });
|
||||||
|
// The view also include()s a core building-block partial, proving plugin views reuse them.
|
||||||
|
writeFileSync(join(dir, "demo", "views", "page.ejs"), `<h1>Hello <%= who %></h1><%- include("partials/theme-switch") %>`);
|
||||||
|
writeFileSync(join(dir, "demo", "public", "app.css"), ".demo{color:red}");
|
||||||
|
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||||
|
const url = await startApp(t, [demoPlugin], dir);
|
||||||
|
|
||||||
|
// Path param + html
|
||||||
|
const hi = await fetch(url + "/demo/hello/world");
|
||||||
|
assert.equal(hi.status, 200);
|
||||||
|
assert.match(await hi.text(), /Hi world/);
|
||||||
|
|
||||||
|
// json
|
||||||
|
const data = await fetch(url + "/demo/data");
|
||||||
|
assert.match(data.headers.get("content-type") ?? "", /application\/json/);
|
||||||
|
assert.deepEqual(await data.json(), { ok: true });
|
||||||
|
|
||||||
|
// redirect (POST → 303 Location)
|
||||||
|
const go = await fetch(url + "/demo/go", { method: "POST", redirect: "manual" });
|
||||||
|
assert.equal(go.status, 303);
|
||||||
|
assert.equal(go.headers.get("location"), "/demo/hello/world");
|
||||||
|
|
||||||
|
// view rendered from the plugin's own views/, including a core partial
|
||||||
|
const page = await (await fetch(url + "/demo/page")).text();
|
||||||
|
assert.match(page, /Hello Plainpages/);
|
||||||
|
assert.match(page, /role="radiogroup"/); // core partials/theme-switch resolved
|
||||||
|
|
||||||
|
// static asset served from the plugin's own public/ at /public/<id>/
|
||||||
|
const css = await fetch(url + "/public/demo/app.css");
|
||||||
|
assert.equal(css.status, 200);
|
||||||
|
assert.match(css.headers.get("content-type") ?? "", /text\/css/);
|
||||||
|
assert.match(await css.text(), /\.demo/);
|
||||||
|
assert.equal((await fetch(url + "/public/demo/..%2f..%2fplugin.ts")).status, 403); // traversal still blocked
|
||||||
|
|
||||||
|
// gated route, anonymous → redirect to sign in (like the built-in screens), not a dead-end 403;
|
||||||
|
// the requested page is preserved as return_to so login lands the user back there.
|
||||||
|
const denied = await fetch(url + "/demo/secret", { redirect: "manual" });
|
||||||
|
assert.equal(denied.status, 303);
|
||||||
|
assert.equal(denied.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
|
||||||
|
|
||||||
|
// a route marked public is reachable anonymously — no gate, no redirect.
|
||||||
|
const open = await fetch(url + "/demo/public-page", { redirect: "manual" });
|
||||||
|
assert.equal(open.status, 200);
|
||||||
|
assert.match(await open.text(), /open to all/);
|
||||||
|
|
||||||
|
// known path + wrong method → 405 with Allow; unknown path → 404
|
||||||
|
const wrong = await fetch(url + "/demo/data", { method: "DELETE" });
|
||||||
|
assert.equal(wrong.status, 405);
|
||||||
|
assert.match(wrong.headers.get("allow") ?? "", /GET/);
|
||||||
|
assert.equal((await fetch(url + "/demo/nope")).status, 404);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a plugin view renders the native chrome; its forms are CSRF-guarded via ctx.verifyCsrf", async (t) => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
|
||||||
|
mkdirSync(join(dir, "panelkit", "views"), { recursive: true });
|
||||||
|
// The view composes the core shell from ctx.chrome — branding, the global nav — and its own
|
||||||
|
// CSRF-guarded form carrying chrome.csrfToken (the representative way a plugin form gets the token,
|
||||||
|
// independent of the shell's auth-dependent profile/sign-out block).
|
||||||
|
writeFileSync(join(dir, "panelkit", "views", "panel.ejs"),
|
||||||
|
`<%- include("partials/shell", { body: '<form method="post" action="/panelkit/save"><input type="hidden" name="_csrf" value="' + chrome.csrfToken + '" /></form>', brand: chrome.brand, csrfToken: chrome.csrfToken, nav: include("partials/nav-tree", { nodes: chrome.nav }), title, user: chrome.user }) %>`);
|
||||||
|
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||||
|
|
||||||
|
const plugin: Plugin = {
|
||||||
|
apiVersion: "1.0.0",
|
||||||
|
id: "panelkit",
|
||||||
|
nav: [{ href: "/panelkit/panel", icon: "i-grid", id: "panelkit", label: "Panel kit" }],
|
||||||
|
routes: [
|
||||||
|
{ handler: (ctx) => ({ data: { chrome: ctx.chrome, title: "Panel" }, view: "panel" }), method: "GET", path: "/panel" },
|
||||||
|
{
|
||||||
|
handler: async (ctx) => {
|
||||||
|
const form = await readFormBody(ctx.req);
|
||||||
|
if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, "bad csrf");
|
||||||
|
return { redirect: "/panelkit/panel" };
|
||||||
|
},
|
||||||
|
method: "POST", path: "/save",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const secret = "test-csrf-secret";
|
||||||
|
const app = createApp({ csrfSecret: secret, plugins: [plugin], pluginsDir: dir });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
|
||||||
|
// GET renders the shell: branding (DEFAULT_MENU), the (ungated) plugin nav, and a CSRF cookie
|
||||||
|
// whose token is embedded in the plugin's own form (double-submit).
|
||||||
|
const res = await fetch(url + "/panelkit/panel");
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
const body = await res.text();
|
||||||
|
assert.match(body, /class="brand-name">Plainpages/);
|
||||||
|
assert.match(body, /Panel kit/);
|
||||||
|
const cookieTok = /plainpages_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
|
||||||
|
assert.ok(cookieTok, "a plugin route issues the CSRF cookie when fresh");
|
||||||
|
assert.equal(/name="_csrf" value="([^"]+)"/.exec(body)?.[1], cookieTok);
|
||||||
|
|
||||||
|
// POST with no token → 403 (ctx.verifyCsrf fails closed); matching cookie + field → 303.
|
||||||
|
assert.equal((await fetch(url + "/panelkit/save", { method: "POST", redirect: "manual" })).status, 403);
|
||||||
|
const tok = issueCsrfToken(secret);
|
||||||
|
const ok = await fetch(url + "/panelkit/save", {
|
||||||
|
body: `_csrf=${encodeURIComponent(tok)}`,
|
||||||
|
headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${CSRF_COOKIE}=${tok}` },
|
||||||
|
method: "POST", redirect: "manual",
|
||||||
|
});
|
||||||
|
assert.equal(ok.status, 303);
|
||||||
|
});
|
||||||
|
|
||||||
|
// JWT middleware: a verified session cookie populates ctx.user/permissions, which the gate reads.
|
||||||
|
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] });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
const secret = (cookie?: string) => fetch(url + "/demo/secret", { redirect: "manual", ...(cookie ? { headers: { cookie } } : {}) });
|
||||||
|
|
||||||
|
// Token carrying the gating permission → the handler runs (200).
|
||||||
|
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(await ok.text(), "secret");
|
||||||
|
|
||||||
|
// No cookie and an expired token both render anonymous → the gate bounces to sign in (303 → /login,
|
||||||
|
// remembering the gated page as return_to).
|
||||||
|
const noCookie = await secret();
|
||||||
|
assert.equal(noCookie.status, 303);
|
||||||
|
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, permissions: ["demo:read"], sub: "u1" })}`)).status, 303);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 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, permissions: ["admin"], sub: "u1" })}` } });
|
||||||
|
assert.equal(dash.status, 200);
|
||||||
|
const anonDash = await fetch(url + "/dashboard", { redirect: "manual" });
|
||||||
|
assert.equal(anonDash.status, 303);
|
||||||
|
assert.equal(anonDash.headers.get("location"), "/login?return_to=%2Fdashboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("revocation denylist: a revoked subject's token stops authorizing on the hot path; a fresh re-login passes", async (t) => {
|
||||||
|
const denylist = createDenylist(); // no Ory clients ⇒ a revoked token drops straight to anonymous (no re-mint)
|
||||||
|
const app = createApp({ denylist, jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
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, permissions: ["demo:read"], sub: "u1" })}` } });
|
||||||
|
|
||||||
|
assert.equal((await secret(nowSec)).status, 200); // before any revoke, the token authorizes
|
||||||
|
|
||||||
|
denylist.revoke("u1");
|
||||||
|
assert.equal((await secret(nowSec - 5)).status, 303); // the pre-revoke token now bounces to /login
|
||||||
|
assert.equal((await secret(nowSec + 5)).status, 200); // a fresh re-login (iat after the revoke) still works
|
||||||
|
});
|
||||||
|
|
||||||
|
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 nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
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 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, 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.
|
||||||
|
const app = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: live, kratosAdmin: stubAdmin({}), plugins: [demoPlugin] });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const ok = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/demo/secret`, { headers: { cookie: expired } });
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
assert.equal(await ok.text(), "secret");
|
||||||
|
assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=/);
|
||||||
|
|
||||||
|
// Kratos session gone: no re-mint, the stale cookie is cleared, the now-anonymous request bounces to sign in.
|
||||||
|
const dead = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: withWhoami(async () => null), kratosAdmin: stubAdmin({}), plugins: [demoPlugin] });
|
||||||
|
await new Promise<void>((r) => dead.listen(0, r));
|
||||||
|
t.after(() => dead.close());
|
||||||
|
const denied = await fetch(`http://localhost:${(dead.address() as AddressInfo).port}/demo/secret`, { headers: { cookie: expired }, redirect: "manual" });
|
||||||
|
assert.equal(denied.status, 303);
|
||||||
|
assert.equal(denied.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
|
||||||
|
assert.match(denied.headers.get("set-cookie") ?? "", /^plainpages_jwt=;.*Max-Age=0/);
|
||||||
|
|
||||||
|
// Ory unreachable (not a dead session): whoami throws → degrade to anonymous (bounce to /login, not 500),
|
||||||
|
// and leave the cookie untouched so the token can re-mint once Ory recovers.
|
||||||
|
const down = createApp({ jwks: staticJwks([ecJwk]), keto, kratos: withWhoami(async () => { throw new KratosError("kratos down", 503, ""); }), kratosAdmin: stubAdmin({}), plugins: [demoPlugin] });
|
||||||
|
await new Promise<void>((r) => down.listen(0, r));
|
||||||
|
t.after(() => down.close());
|
||||||
|
const outage = await fetch(`http://localhost:${(down.address() as AddressInfo).port}/demo/secret`, { headers: { cookie: expired }, redirect: "manual" });
|
||||||
|
assert.equal(outage.status, 303);
|
||||||
|
assert.equal(outage.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
|
||||||
|
assert.equal(outage.headers.get("set-cookie"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("guards map to responses: requireSession → /login, a failed can/check → 403, success runs the handler", async (t) => {
|
||||||
|
const keto = { check: async (tuple: { object: string }) => tuple.object === "open" } as unknown as Parameters<typeof check>[0];
|
||||||
|
const guarded: Plugin = {
|
||||||
|
apiVersion: "1.0.0",
|
||||||
|
id: "guarded",
|
||||||
|
routes: [
|
||||||
|
{ handler: (ctx) => ({ html: `hi ${requireSession(ctx).email}` }), method: "GET", path: "/me" },
|
||||||
|
{ handler: (ctx) => { if (!can(ctx, "admin")) throw new GuardError(403, "no"); return { html: "ok" }; }, method: "GET", path: "/admin-only" },
|
||||||
|
{ handler: async (ctx) => { if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id ?? "", relation: "view" }))) throw new GuardError(403, "no"); return { html: "seen" }; }, method: "GET", path: "/doc/:id" },
|
||||||
|
{ handler: () => ({ html: "gated" }), method: "GET", path: "/gated", permission: "secret:read" }, // declarative route gate
|
||||||
|
{ handler: () => ({ html: "mine" }), method: "GET", path: "/mine", session: true }, // declarative session gate
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [guarded] });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
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.
|
||||||
|
const anon = await fetch(url + "/guarded/me", { redirect: "manual" });
|
||||||
|
assert.equal(anon.status, 303);
|
||||||
|
assert.equal(anon.headers.get("location"), "/login?return_to=%2Fguarded%2Fme");
|
||||||
|
const me = await fetch(url + "/guarded/me", auth([]));
|
||||||
|
assert.equal(me.status, 200);
|
||||||
|
assert.match(await me.text(), /hi a@b\.c/);
|
||||||
|
|
||||||
|
// 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(["admin"]))).status, 200);
|
||||||
|
|
||||||
|
// check (live Keto): the keto verdict gates the handler.
|
||||||
|
assert.equal((await fetch(url + "/guarded/doc/open", auth([]))).status, 200);
|
||||||
|
assert.equal((await fetch(url + "/guarded/doc/shut", auth([]))).status, 403);
|
||||||
|
|
||||||
|
// 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" });
|
||||||
|
assert.equal(gAnon.status, 303);
|
||||||
|
assert.equal(gAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fgated");
|
||||||
|
const gDenied = await fetch(url + "/guarded/gated", auth([]));
|
||||||
|
assert.equal(gDenied.status, 403);
|
||||||
|
assert.match(await gDenied.text(), /403/); // the rendered 403.ejs over HTTP
|
||||||
|
assert.equal((await fetch(url + "/guarded/gated", auth(["secret:read"]))).status, 200);
|
||||||
|
|
||||||
|
// declarative `session` gate: anonymous → sign in, and any signed-in user through, grant or none.
|
||||||
|
const sAnon = await fetch(url + "/guarded/mine", { redirect: "manual" });
|
||||||
|
assert.equal(sAnon.status, 303);
|
||||||
|
assert.equal(sAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fmine");
|
||||||
|
assert.equal((await fetch(url + "/guarded/mine", auth([]))).status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plugin hooks: onRequest can short-circuit a request and onResponse observes the handler result", async (t) => {
|
||||||
|
const seen: string[] = [];
|
||||||
|
const hooked: Plugin = {
|
||||||
|
apiVersion: "1.0.0",
|
||||||
|
hooks: {
|
||||||
|
onRequest: (c) => (c.url.pathname === "/hooked/blocked" ? { html: "blocked by hook", status: 403 } : undefined),
|
||||||
|
onResponse: (c, r) => void seen.push(`${c.url.pathname}:${r && "html" in r ? r.html : "?"}`),
|
||||||
|
},
|
||||||
|
id: "hooked",
|
||||||
|
routes: [{ handler: () => ({ html: "handler ran" }), method: "GET", path: "/ok" }],
|
||||||
|
};
|
||||||
|
const url = await startApp(t, [hooked]);
|
||||||
|
|
||||||
|
// onRequest short-circuits before routing — handler never runs; a fresh CSRF cookie still rides the
|
||||||
|
// response so a form the hook renders has its matching double-submit cookie.
|
||||||
|
const blocked = await fetch(url + "/hooked/blocked");
|
||||||
|
assert.equal(blocked.status, 403);
|
||||||
|
assert.match(await blocked.text(), /blocked by hook/);
|
||||||
|
assert.match(blocked.headers.get("set-cookie") ?? "", /plainpages_csrf=/);
|
||||||
|
|
||||||
|
// A normal route runs the handler; onResponse observed its result.
|
||||||
|
assert.match(await (await fetch(url + "/hooked/ok")).text(), /handler ran/);
|
||||||
|
assert.ok(seen.includes("/hooked/ok:handler ran"));
|
||||||
|
});
|
||||||
|
|
||||||
+88
-1217
File diff suppressed because it is too large
Load Diff
+32
-21
@@ -8,7 +8,6 @@ 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";
|
||||||
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
|
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
|
||||||
import type { Denylist } from "../auth/denylist.ts";
|
import type { Denylist } from "../auth/denylist.ts";
|
||||||
import { buildDashboardModel } from "../ui/dashboard.ts";
|
|
||||||
import { PLUGINS_DIR } from "../plugin-host/discovery.ts";
|
import { PLUGINS_DIR } from "../plugin-host/discovery.ts";
|
||||||
import { GuardError, loginRedirect } from "../auth/guards.ts";
|
import { GuardError, loginRedirect } from "../auth/guards.ts";
|
||||||
import { ENGLISH_I18N } from "../i18n/english.ts";
|
import { ENGLISH_I18N } from "../i18n/english.ts";
|
||||||
@@ -28,19 +27,22 @@ import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
|
|||||||
import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts";
|
import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts";
|
||||||
import type { PluginSettings } from "../plugin-host/settings.ts";
|
import type { PluginSettings } from "../plugin-host/settings.ts";
|
||||||
import type { SystemCapabilities } from "../plugin-host/system.ts";
|
import type { SystemCapabilities } from "../plugin-host/system.ts";
|
||||||
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
|
import { allows, type Gate } from "../auth/gate.ts";
|
||||||
|
import { allowedMethods, matchRoute } from "../plugin-host/router.ts";
|
||||||
import { buildAuthRoutes } from "../auth/routes.ts";
|
import { buildAuthRoutes } from "../auth/routes.ts";
|
||||||
import { securityHeaders } from "./security-headers.ts";
|
import { securityHeaders } from "./security-headers.ts";
|
||||||
import { localPath } from "./safe-url.ts";
|
import { localPath } from "./safe-url.ts";
|
||||||
import { routePublic, serveStatic } from "./static.ts";
|
import { publicAssets } from "./static.ts";
|
||||||
import { renderPluginView } from "../plugin-host/view-resolver.ts";
|
import { renderPluginView } from "../plugin-host/view-resolver.ts";
|
||||||
|
|
||||||
|
const NO_STORE = "private, no-store";
|
||||||
|
|
||||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
|
|
||||||
export interface AppOptions {
|
export interface AppOptions {
|
||||||
appUrl?: string; // canonical public URL (config.appUrl); off-host GET/HEAD visitors are 308'd here. Omitted ⇒ no redirect
|
appUrl?: string; // canonical public URL (config.appUrl); off-host GET/HEAD visitors are 308'd here. Omitted ⇒ no redirect
|
||||||
auth?: VerifyOptions; // expected JWT issuer/audience + clock skew (config); used with jwks
|
auth?: VerifyOptions; // expected JWT issuer/audience + clock skew (config); used with jwks
|
||||||
cache?: boolean; // cache compiled EJS templates (config.cacheTemplates); off ⇒ edits show live
|
cache?: boolean; // cache compiled EJS templates and /public/ asset versions (config.cacheTemplates); off ⇒ edits show live
|
||||||
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
|
||||||
@@ -96,6 +98,7 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
|
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
|
||||||
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
||||||
const publicDir = options.publicDir ?? join(rootDir, "public");
|
const publicDir = options.publicDir ?? join(rootDir, "public");
|
||||||
|
const assets = publicAssets(publicDir, pluginsDir, pluginIds, { cache });
|
||||||
const viewsDir = options.viewsDir ?? join(rootDir, "views");
|
const viewsDir = options.viewsDir ?? join(rootDir, "views");
|
||||||
// Response security headers, fixed at boot (only HSTS depends on the https deployment signal).
|
// Response security headers, fixed at boot (only HSTS depends on the https deployment signal).
|
||||||
const secHeaderEntries = Object.entries(securityHeaders({ secure: secureCookies }));
|
const secHeaderEntries = Object.entries(securityHeaders({ secure: secureCookies }));
|
||||||
@@ -129,10 +132,10 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
t: ctx.t,
|
t: ctx.t,
|
||||||
url: ctx.url,
|
url: ctx.url,
|
||||||
});
|
});
|
||||||
// i18n locals go last: their names are reserved, so a handler's colliding key loses instead of
|
// i18n locals and `assetHref` go last: their names are reserved, so a handler's
|
||||||
// breaking the shell around it.
|
// colliding key loses instead of breaking the shell around it.
|
||||||
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)) });
|
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)), assetHref: assets.href });
|
||||||
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)) });
|
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)), assetHref: assets.href });
|
||||||
|
|
||||||
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
|
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
|
||||||
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
||||||
@@ -156,7 +159,6 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
// "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in
|
// "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in
|
||||||
// starter page.
|
// starter page.
|
||||||
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
|
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
|
||||||
if (!ctx.user) return { redirect: loginRedirect(ctx), status: 303 };
|
|
||||||
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
|
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
|
||||||
csrf.setCookie();
|
csrf.setCookie();
|
||||||
if (dashboardPlugin) {
|
if (dashboardPlugin) {
|
||||||
@@ -166,15 +168,15 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, dashboardPlugin.id), pluginCtx.localeHref);
|
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, dashboardPlugin.id), pluginCtx.localeHref);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, user: ctx.user, nav: ctx.chrome.nav, t: ctx.t }) }, view: "index" };
|
return { data: { chrome: ctx.chrome }, 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/
|
||||||
// routes.ts, capability-gated on the wired clients) plus the two landing slots above.
|
// routes.ts, capability-gated on the wired clients) plus the two landing slots above.
|
||||||
const builtinRoutes: BuiltinRoute[] = [
|
const builtinRoutes: BuiltinRoute[] = [
|
||||||
...buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }),
|
...buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }),
|
||||||
{ handler: serveHome, method: "GET", path: "/" },
|
{ handler: serveHome, method: "GET", path: "/", public: true },
|
||||||
{ handler: serveDashboard, method: "GET", path: "/dashboard" },
|
{ handler: serveDashboard, method: "GET", path: "/dashboard", session: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
// The request handler. Run inside runWithLog (below) so the per-request logger is ambient: every
|
// The request handler. Run inside runWithLog (below) so the per-request logger is ambient: every
|
||||||
@@ -183,7 +185,7 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
const handleRequest = async (req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<void> => {
|
const handleRequest = async (req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<void> => {
|
||||||
// Error pages can render before this request has a context at all (a throw on the way to one),
|
// Error pages can render before this request has a context at all (a throw on the way to one),
|
||||||
// so they start on the built-in English and switch to the visitor's locale once it is resolved.
|
// so they start on the built-in English and switch to the visitor's locale once it is resolved.
|
||||||
let renderPage: ViewRenderer = (view, data) => render(view, { ...data, ...ENGLISH_LOCALS });
|
let renderPage: ViewRenderer = (view, data) => render(view, { ...data, ...ENGLISH_LOCALS, assetHref: assets.href });
|
||||||
try {
|
try {
|
||||||
const method = req.method ?? "GET";
|
const method = req.method ?? "GET";
|
||||||
const url = new URL(req.url ?? "/", "http://localhost");
|
const url = new URL(req.url ?? "/", "http://localhost");
|
||||||
@@ -195,14 +197,18 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
|
|
||||||
// Before auth: assets don't need a verified user, and the JWT cookie rides every request.
|
// Before auth: assets don't need a verified user, and the JWT cookie rides every request.
|
||||||
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
|
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
|
||||||
const { dir, subPath } = routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds);
|
await assets.serve(url, req, res, (err) => reqLog.error("static file error", { error: String(err) }));
|
||||||
await serveStatic(dir, subPath, res, method === "HEAD", (err) => reqLog.error("static stream error", { error: String(err) }));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A cache in front of us must key on the language. Set after the static branch: an asset is
|
// A cache in front of us must key on the language. Set after the static branch: an asset is
|
||||||
// the same bytes in every language, and a Vary there fragments its entry per raw header.
|
// the same bytes in every language, and a Vary there fragments its entry per raw header.
|
||||||
res.setHeader("vary", "accept-language");
|
res.setHeader("vary", "accept-language");
|
||||||
|
// Fail closed: a cache in front of us must not keep a page that may carry a user's data. Not
|
||||||
|
// even the signed-out `/` is shared: it and the signed-in page have one URL, and a cache keyed
|
||||||
|
// on URL and Accept-Language would hand the signed-out one to a signed-in visitor. A route that
|
||||||
|
// is the same for everyone sets its own value in RouteResult.headers; the host doesn't police it.
|
||||||
|
res.setHeader("cache-control", NO_STORE);
|
||||||
|
|
||||||
// Canonical host (APP_URL): send an off-host visitor to the configured origin so the browser,
|
// Canonical host (APP_URL): send an off-host visitor to the configured origin so the browser,
|
||||||
// the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the
|
// the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the
|
||||||
@@ -278,15 +284,19 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply lacks
|
||||||
|
// the permission gets the 403 page.
|
||||||
|
const refuse = async (gate: Gate, gateCtx: RequestContext): Promise<void> => {
|
||||||
|
if (!gateCtx.user) { res.writeHead(303, { location: carryLocale(loginRedirect(gateCtx)) }).end(); return; }
|
||||||
|
reqLog.warn("forbidden: missing permission", { path: pathname, required: gate.permission ?? "", sub: gateCtx.user.id });
|
||||||
|
sendHtml(res, 403, await renderPage("403", {}));
|
||||||
|
};
|
||||||
|
|
||||||
const match = matchRoute(plugins, method, pathname);
|
const match = matchRoute(plugins, method, pathname);
|
||||||
if (match) {
|
if (match) {
|
||||||
const routeCtx = contextFor(match.plugin.id, match.params);
|
const routeCtx = contextFor(match.plugin.id, match.params);
|
||||||
if (!isAuthorized(match.route, routeCtx.permissions)) {
|
if (!allows(match.route, routeCtx.user)) {
|
||||||
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply
|
await refuse(match.route, routeCtx);
|
||||||
// lacks the permission gets the 403 page.
|
|
||||||
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
|
|
||||||
reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
|
|
||||||
sendHtml(res, 403, await renderPage("403", {}));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
csrfMint.setCookie();
|
csrfMint.setCookie();
|
||||||
@@ -300,6 +310,7 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
|
|
||||||
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
|
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
|
||||||
if (builtin) {
|
if (builtin) {
|
||||||
|
if (!allows(builtin, ctx.user)) { await refuse(builtin, ctx); return; }
|
||||||
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
|
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
// mint (host-only — a plugin reads the token via ctx.chrome instead). app.ts matches this table
|
// mint (host-only — a plugin reads the token via ctx.chrome instead). app.ts matches this table
|
||||||
// after plugin routes — exact path, a GET route also answering HEAD like the plugin router — and
|
// after plugin routes — exact path, a GET route also answering HEAD like the plugin router — and
|
||||||
// pipes the result through sendResult against the core views.
|
// pipes the result through sendResult against the core views.
|
||||||
|
import type { Gate } from "../auth/gate.ts";
|
||||||
import type { RequestContext } from "./context.ts";
|
import type { RequestContext } from "./context.ts";
|
||||||
import type { RouteResult } from "../plugin-host/plugin.ts";
|
import type { RouteResult } from "../plugin-host/plugin.ts";
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ export interface RequestCsrf {
|
|||||||
// own context — otherwise the plugin's keys render as bare keys on the pages it owns.
|
// own context — otherwise the plugin's keys render as bare keys on the pages it owns.
|
||||||
export type PluginContextFactory = (pluginId: string) => RequestContext;
|
export type PluginContextFactory = (pluginId: string) => RequestContext;
|
||||||
|
|
||||||
export interface BuiltinRoute {
|
export interface BuiltinRoute extends Gate {
|
||||||
// Returns a RouteResult, or null when the handler wrote to ctx.res itself
|
// Returns a RouteResult, or null when the handler wrote to ctx.res itself
|
||||||
// (the landing slots dispatch a plugin's own result against that plugin's views).
|
// (the landing slots dispatch a plugin's own result against that plugin's views).
|
||||||
handler: (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory) => Promise<RouteResult | null> | RouteResult | null;
|
handler: (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory) => Promise<RouteResult | null> | RouteResult | null;
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { after, before, test } from "node:test";
|
||||||
|
import { contentTypeFor, publicAssets, resolveStaticPath, routePublic } from "./static.ts";
|
||||||
|
|
||||||
|
const IMMUTABLE = "public, max-age=31536000, immutable";
|
||||||
|
const SHORT = "public, max-age=300";
|
||||||
|
|
||||||
|
const root = mkdtempSync(join(tmpdir(), "static-test-"));
|
||||||
|
const publicDir = join(root, "public");
|
||||||
|
const pluginsDir = join(root, "plugins");
|
||||||
|
mkdirSync(join(publicDir, "css"), { recursive: true });
|
||||||
|
mkdirSync(join(pluginsDir, "demo", "public"), { recursive: true });
|
||||||
|
writeFileSync(join(publicDir, "css", "a.css"), "body { color: red }");
|
||||||
|
writeFileSync(join(pluginsDir, "demo", "public", "app.css"), ".demo {}");
|
||||||
|
const assets = publicAssets(publicDir, pluginsDir, new Set(["demo"]), { cache: false });
|
||||||
|
const assetHref = assets.href;
|
||||||
|
|
||||||
|
const readErrors: Error[] = [];
|
||||||
|
let base = "";
|
||||||
|
const server = createServer((req, res) => void assets.serve(new URL(req.url ?? "/", "http://localhost"), req, res, (err) => readErrors.push(err)));
|
||||||
|
before(() => new Promise<void>((r) => server.listen(0, () => {
|
||||||
|
base = `http://localhost:${(server.address() as AddressInfo).port}`;
|
||||||
|
r();
|
||||||
|
})));
|
||||||
|
after(() => {
|
||||||
|
server.close();
|
||||||
|
rmSync(root, { force: true, recursive: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveStaticPath blocks traversal and control chars, allows nested files", () => {
|
||||||
|
assert.equal(resolveStaticPath("/srv/public", "../app.ts"), null);
|
||||||
|
assert.equal(resolveStaticPath("/srv/public", "a\x00b"), null);
|
||||||
|
assert.equal(resolveStaticPath("/srv/public", "css/styles.css"), "/srv/public/css/styles.css");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("contentTypeFor maps known and unknown extensions", () => {
|
||||||
|
assert.match(contentTypeFor("a.css"), /text\/css/);
|
||||||
|
assert.equal(contentTypeFor("a.bin"), "application/octet-stream");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("routePublic sends a plugin-id segment to its public/ dir, everything else to core", () => {
|
||||||
|
const ids = new Set(["scheduling"]);
|
||||||
|
assert.deepEqual(routePublic("scheduling/app.css", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "app.css" });
|
||||||
|
assert.deepEqual(routePublic("scheduling/img/logo.svg", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "img/logo.svg" });
|
||||||
|
assert.deepEqual(routePublic("scheduling", "/core", "/plugins", ids), { dir: "/plugins/scheduling/public", subPath: "" }); // bare /public/<id>, no file
|
||||||
|
assert.deepEqual(routePublic("css/styles.css", "/core", "/plugins", ids), { dir: "/core", subPath: "css/styles.css" }); // not a plugin → core
|
||||||
|
});
|
||||||
|
|
||||||
|
test("assetHref versions a /public/ file, core or plugin, and leaves every other href alone", () => {
|
||||||
|
assert.match(assetHref("/public/css/a.css"), /^\/public\/css\/a\.css\?v=[\w-]+$/);
|
||||||
|
assert.match(assetHref("/public/demo/app.css"), /^\/public\/demo\/app\.css\?v=[\w-]+$/);
|
||||||
|
assert.notEqual(assetHref("/public/css/a.css"), assetHref("/public/demo/app.css"));
|
||||||
|
for (const href of ["/public/css/missing.css", "/public/css", "/public/css/a.css?x=1", "/public/css/a.css#top", "/public/..%2f..%2fetc/passwd", "/public/%E0%A4%A", "https://cdn.example/a.css", "data:image/svg+xml,<svg/>", "/elsewhere/a.css"]) {
|
||||||
|
assert.equal(assetHref(href), href, href);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("assetHref changes when the file's content does", () => {
|
||||||
|
const file = join(publicDir, "css", "changing.css");
|
||||||
|
writeFileSync(file, "a {}");
|
||||||
|
const first = assetHref("/public/css/changing.css");
|
||||||
|
assert.equal(assetHref("/public/css/changing.css"), first);
|
||||||
|
writeFileSync(file, "a { color: blue }");
|
||||||
|
assert.notEqual(assetHref("/public/css/changing.css"), first);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("assetHref changes when the file's size does, even with its mtime restored", () => {
|
||||||
|
const file = join(publicDir, "css", "resized.css");
|
||||||
|
writeFileSync(file, "a {}");
|
||||||
|
const { atime, mtime } = statSync(file);
|
||||||
|
const first = assetHref("/public/css/resized.css");
|
||||||
|
writeFileSync(file, "a { color: blue }");
|
||||||
|
utimesSync(file, atime, mtime);
|
||||||
|
assert.notEqual(assetHref("/public/css/resized.css"), first);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("with cache on, assetHref keeps a known version; a request naming it after an edit gets the short cache and the new ETag", async () => {
|
||||||
|
const cached = publicAssets(publicDir, pluginsDir, new Set(), { cache: true });
|
||||||
|
const file = join(publicDir, "css", "cached.css");
|
||||||
|
writeFileSync(file, "a {}");
|
||||||
|
const stale = cached.href("/public/css/cached.css");
|
||||||
|
writeFileSync(file, "a { color: green }");
|
||||||
|
assert.equal(cached.href("/public/css/cached.css"), stale);
|
||||||
|
|
||||||
|
const res = await fetch(base + stale);
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.equal(await res.text(), "a { color: green }");
|
||||||
|
assert.equal(res.headers.get("cache-control"), SHORT);
|
||||||
|
const fresh = assetHref("/public/css/cached.css");
|
||||||
|
assert.notEqual(fresh, stale);
|
||||||
|
assert.equal(res.headers.get("etag"), `"${fresh.split("?v=")[1]}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a version hashed while rendering equals the ETag hashed while serving the same bytes", async () => {
|
||||||
|
writeFileSync(join(publicDir, "css", "served-first.css"), "p {}");
|
||||||
|
writeFileSync(join(publicDir, "css", "rendered-first.css"), "p {}");
|
||||||
|
const etag = (await fetch(base + "/public/css/served-first.css")).headers.get("etag");
|
||||||
|
assert.equal(etag, `"${assetHref("/public/css/rendered-first.css").split("?v=")[1]}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a URL carrying the current version is cached for a year as immutable", async () => {
|
||||||
|
for (const method of ["GET", "HEAD"]) {
|
||||||
|
const res = await fetch(base + assetHref("/public/css/a.css"), { method });
|
||||||
|
assert.equal(res.status, 200, method);
|
||||||
|
assert.equal(res.headers.get("cache-control"), IMMUTABLE, method);
|
||||||
|
}
|
||||||
|
assert.equal((await fetch(base + assetHref("/public/demo/app.css"))).headers.get("cache-control"), IMMUTABLE);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unversioned or stale-versioned URL is cached briefly, with an ETag and Last-Modified to revalidate", async () => {
|
||||||
|
const lastModified = statSync(join(publicDir, "css", "a.css")).mtime.toUTCString();
|
||||||
|
for (const path of ["/public/css/a.css", "/public/css/a.css?v=stale"]) {
|
||||||
|
const res = await fetch(base + path);
|
||||||
|
assert.equal(res.status, 200, path);
|
||||||
|
assert.equal(await res.text(), "body { color: red }", path);
|
||||||
|
assert.equal(res.headers.get("cache-control"), SHORT, path);
|
||||||
|
assert.match(res.headers.get("etag") ?? "", /^"[\w-]+"$/, path);
|
||||||
|
assert.equal(res.headers.get("last-modified"), lastModified, path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("If-None-Match naming the current ETag answers 304 with no body, for GET and HEAD", async () => {
|
||||||
|
const etag = (await fetch(base + "/public/css/a.css")).headers.get("etag") ?? "";
|
||||||
|
for (const method of ["GET", "HEAD"]) {
|
||||||
|
for (const ifNoneMatch of [etag, `W/${etag}`, `"other", ${etag}`, "*"]) {
|
||||||
|
const res = await fetch(base + "/public/css/a.css", { headers: { "if-none-match": ifNoneMatch }, method });
|
||||||
|
assert.equal(res.status, 304, `${method} ${ifNoneMatch}`);
|
||||||
|
assert.equal(await res.text(), "");
|
||||||
|
assert.equal(res.headers.get("etag"), etag);
|
||||||
|
assert.equal(res.headers.get("cache-control"), SHORT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const versioned = await fetch(base + assetHref("/public/css/a.css"), { headers: { "if-none-match": etag } });
|
||||||
|
assert.equal(versioned.status, 304);
|
||||||
|
assert.equal(versioned.headers.get("cache-control"), IMMUTABLE);
|
||||||
|
|
||||||
|
assert.equal((await fetch(base + "/public/css/a.css", { headers: { "if-none-match": '"other"' } })).status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("If-Modified-Since at or after the file's mtime answers 304; before it, or beside a failed If-None-Match, 200", async () => {
|
||||||
|
const lastModified = (await fetch(base + "/public/css/a.css")).headers.get("last-modified") ?? "";
|
||||||
|
for (const method of ["GET", "HEAD"]) {
|
||||||
|
const res = await fetch(base + "/public/css/a.css", { headers: { "if-modified-since": lastModified }, method });
|
||||||
|
assert.equal(res.status, 304, method);
|
||||||
|
assert.equal(await res.text(), "");
|
||||||
|
}
|
||||||
|
const earlier = new Date(Date.parse(lastModified) - 1000).toUTCString();
|
||||||
|
assert.equal((await fetch(base + "/public/css/a.css", { headers: { "if-modified-since": earlier } })).status, 200);
|
||||||
|
assert.equal((await fetch(base + "/public/css/a.css", { headers: { "if-modified-since": "not a date" } })).status, 200);
|
||||||
|
assert.equal((await fetch(base + "/public/css/a.css", { headers: { "if-modified-since": lastModified, "if-none-match": '"other"' } })).status, 200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("unsafe or missing paths answer 400, 403 and 404", async () => {
|
||||||
|
assert.equal((await fetch(base + "/public/%E0%A4%A")).status, 400);
|
||||||
|
assert.equal((await fetch(base + "/public/..%2f..%2fetc%2fpasswd")).status, 403);
|
||||||
|
assert.equal((await fetch(base + "/public/css/missing.css")).status, 404);
|
||||||
|
assert.equal((await fetch(base + "/public/css/a.css/x")).status, 404); // ENOTDIR
|
||||||
|
assert.equal((await fetch(base + "/public/css")).status, 404);
|
||||||
|
assert.deepEqual(readErrors, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a read failure other than a missing file answers 500 and reaches onError", async () => {
|
||||||
|
symlinkSync("loop.css", join(publicDir, "css", "loop.css")); // root reads a mode-000 file, so a symlink loop makes the failure
|
||||||
|
const res = await fetch(base + "/public/css/loop.css");
|
||||||
|
assert.equal(res.status, 500);
|
||||||
|
assert.deepEqual(readErrors.splice(0).map((err) => (err as NodeJS.ErrnoException).code), ["ELOOP"]);
|
||||||
|
});
|
||||||
+106
-13
@@ -1,6 +1,7 @@
|
|||||||
import { createReadStream } from "node:fs";
|
import { createHash, type Hash } from "node:crypto";
|
||||||
|
import { createReadStream, readFileSync, statSync, type Stats } from "node:fs";
|
||||||
import { stat } from "node:fs/promises";
|
import { stat } from "node:fs/promises";
|
||||||
import type { ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import { extname, isAbsolute, join, relative } from "node:path";
|
import { extname, isAbsolute, join, relative } from "node:path";
|
||||||
|
|
||||||
const contentTypes: Record<string, string> = {
|
const contentTypes: Record<string, string> = {
|
||||||
@@ -38,8 +39,8 @@ export interface StaticRoute {
|
|||||||
|
|
||||||
// Route a `/public/<rest>` request to a base dir + sub-path: a leading segment naming a discovered
|
// Route a `/public/<rest>` request to a base dir + sub-path: a leading segment naming a discovered
|
||||||
// plugin serves from plugins/<id>/public/, anything else from the core public/. Plugin ids are
|
// plugin serves from plugins/<id>/public/, anything else from the core public/. Plugin ids are
|
||||||
// URL-safe (no %-encoding), so the raw segment compares directly to the id set; serveStatic decodes
|
// URL-safe (no %-encoding), so the raw segment compares directly to the id set; the caller decodes
|
||||||
// and traversal-guards the sub-path as before.
|
// and traversal-guards the sub-path.
|
||||||
export function routePublic(restPath: string, publicDir: string, pluginsDir: string, pluginIds: Set<string>): StaticRoute {
|
export function routePublic(restPath: string, publicDir: string, pluginsDir: string, pluginIds: Set<string>): StaticRoute {
|
||||||
const slash = restPath.indexOf("/");
|
const slash = restPath.indexOf("/");
|
||||||
const first = slash === -1 ? restPath : restPath.slice(0, slash);
|
const first = slash === -1 ? restPath : restPath.slice(0, slash);
|
||||||
@@ -49,28 +50,103 @@ export function routePublic(restPath: string, publicDir: string, pluginsDir: str
|
|||||||
return { dir: publicDir, subPath: restPath };
|
return { dir: publicDir, subPath: restPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Caching: the host renders every /public/ href through assetHref, which appends `?v=<hash of the
|
||||||
|
// file's content>`. A request whose `v` is the current hash is cached for a year as immutable, so a
|
||||||
|
// repeat page view downloads no asset; a changed file gets a new hash, and with it a new URL. Any other
|
||||||
|
// request — an href a plugin's own markup emits, a stale `v` mid-deploy — is cached for five minutes,
|
||||||
|
// then revalidated against the ETag (that same hash) or Last-Modified. A plugin has no way to version
|
||||||
|
// its own hrefs yet: `assetHref` is a reserved local but not contract, since offering it is a minor.
|
||||||
|
const IMMUTABLE = "public, max-age=31536000, immutable";
|
||||||
|
const SHORT = "public, max-age=300";
|
||||||
|
|
||||||
|
// An entry holds while the file's mtime and size match, so a file edited in place (a dev bind mount)
|
||||||
|
// gets a new hash.
|
||||||
|
const hashes = new Map<string, { hash: string; mtimeMs: number; size: number }>();
|
||||||
|
|
||||||
|
function memoized(filePath: string, info: Stats): string | undefined {
|
||||||
|
const known = hashes.get(filePath);
|
||||||
|
return known?.mtimeMs === info.mtimeMs && known.size === info.size ? known.hash : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one definition of a version. Two readers feed it. A view renders synchronously, so hashFileSync
|
||||||
|
// reads the file whole; hashFile streams it, so a large file doesn't stall every other request.
|
||||||
|
function remember(filePath: string, info: Stats, sha256: Hash): string {
|
||||||
|
const hash = sha256.digest("base64url").slice(0, 16);
|
||||||
|
hashes.set(filePath, { hash, mtimeMs: info.mtimeMs, size: info.size });
|
||||||
|
return hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashFileSync(filePath: string, info: Stats): string {
|
||||||
|
return memoized(filePath, info) ?? remember(filePath, info, createHash("sha256").update(readFileSync(filePath)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hashFile(filePath: string, info: Stats): Promise<string> {
|
||||||
|
const known = memoized(filePath, info);
|
||||||
|
if (known !== undefined) return known;
|
||||||
|
const sha256 = createHash("sha256");
|
||||||
|
for await (const chunk of createReadStream(filePath)) sha256.update(chunk);
|
||||||
|
return remember(filePath, info, sha256);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without `cache`, a stat per href picks up an edited file. With it, a known version skips the stat:
|
||||||
|
// an edit reaches the hrefs once a request for the file hashes it again.
|
||||||
|
function versioned(href: string, route: StaticRoute, cache: boolean): string {
|
||||||
|
try {
|
||||||
|
const filePath = resolveStaticPath(route.dir, decodeURIComponent(route.subPath));
|
||||||
|
if (filePath === null) return href;
|
||||||
|
const known = cache ? hashes.get(filePath) : undefined;
|
||||||
|
if (known) return `${href}?v=${known.hash}`;
|
||||||
|
const info = statSync(filePath);
|
||||||
|
return info.isFile() ? `${href}?v=${hashFileSync(filePath, info)}` : href;
|
||||||
|
} catch {
|
||||||
|
return href; // malformed %-encoding, or a missing or unreadable file: serving it answers the error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 9110 §13.1.2, weak comparison: `W/"x"` matches `"x"`, and `*` matches any existing file.
|
||||||
|
function etagMatches(ifNoneMatch: string, etag: string): boolean {
|
||||||
|
return ifNoneMatch.split(",").some((tag) => {
|
||||||
|
const t = tag.trim();
|
||||||
|
return t === "*" || t.replace(/^W\//, "") === etag;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// RFC 9110 §13.2.2: If-None-Match, when present, decides alone; HTTP dates have whole seconds.
|
||||||
|
function notModified(req: IncomingMessage, etag: string, info: Stats): boolean {
|
||||||
|
const ifNoneMatch = req.headers["if-none-match"];
|
||||||
|
if (ifNoneMatch !== undefined) return etagMatches(ifNoneMatch, etag);
|
||||||
|
const since = Date.parse(req.headers["if-modified-since"] ?? "");
|
||||||
|
return !Number.isNaN(since) && Math.floor(info.mtimeMs / 1000) * 1000 <= since;
|
||||||
|
}
|
||||||
|
|
||||||
function plain(res: ServerResponse, status: number, body: string): void {
|
function plain(res: ServerResponse, status: number, body: string): void {
|
||||||
res.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(body);
|
res.writeHead(status, { "content-type": "text/plain; charset=utf-8" }).end(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
// onError handles a mid-stream read failure (headers already sent); defaults to console.error so
|
interface StaticRequest extends StaticRoute {
|
||||||
// static.ts stays standalone, while app.ts passes the request logger for structured output.
|
version: string | null; // the request's `v` query parameter
|
||||||
export async function serveStatic(dir: string, requestedPath: string, res: ServerResponse, head = false, onError: (err: Error) => void = (err) => console.error(err)): Promise<void> {
|
}
|
||||||
|
|
||||||
|
async function serveStatic(route: StaticRequest, req: IncomingMessage, res: ServerResponse, onError: (err: Error) => void): Promise<void> {
|
||||||
let decoded: string;
|
let decoded: string;
|
||||||
try {
|
try {
|
||||||
decoded = decodeURIComponent(requestedPath);
|
decoded = decodeURIComponent(route.subPath);
|
||||||
} catch {
|
} catch {
|
||||||
return plain(res, 400, "Bad Request");
|
return plain(res, 400, "Bad Request");
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = resolveStaticPath(dir, decoded);
|
const filePath = resolveStaticPath(route.dir, decoded);
|
||||||
if (filePath === null) return plain(res, 403, "Forbidden");
|
if (filePath === null) return plain(res, 403, "Forbidden");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const info = await stat(filePath);
|
const info = await stat(filePath);
|
||||||
if (!info.isFile()) return plain(res, 404, "Not Found");
|
if (!info.isFile()) return plain(res, 404, "Not Found");
|
||||||
res.writeHead(200, { "content-length": info.size, "content-type": contentTypeFor(filePath) });
|
const hash = await hashFile(filePath, info);
|
||||||
if (head) return void res.end(); // headers only — skip opening the file
|
const etag = `"${hash}"`;
|
||||||
|
const validators = { "cache-control": route.version === hash ? IMMUTABLE : SHORT, etag, "last-modified": info.mtime.toUTCString() };
|
||||||
|
if (notModified(req, etag, info)) return void res.writeHead(304, validators).end();
|
||||||
|
res.writeHead(200, { ...validators, "content-length": info.size, "content-type": contentTypeFor(filePath) });
|
||||||
|
if (req.method === "HEAD") return void res.end();
|
||||||
// Headers are already sent, so a mid-stream read error can't become an HTTP status —
|
// Headers are already sent, so a mid-stream read error can't become an HTTP status —
|
||||||
// log and destroy the response to signal a truncated body, not a hung socket.
|
// log and destroy the response to signal a truncated body, not a hung socket.
|
||||||
createReadStream(filePath)
|
createReadStream(filePath)
|
||||||
@@ -79,7 +155,24 @@ export async function serveStatic(dir: string, requestedPath: string, res: Serve
|
|||||||
res.destroy();
|
res.destroy();
|
||||||
})
|
})
|
||||||
.pipe(res);
|
.pipe(res);
|
||||||
} catch {
|
} catch (err) {
|
||||||
plain(res, 404, "Not Found");
|
const { code } = err as NodeJS.ErrnoException;
|
||||||
|
if (code === "ENOENT" || code === "ENOTDIR") return plain(res, 404, "Not Found");
|
||||||
|
onError(err as Error);
|
||||||
|
plain(res, 500, "Internal Server Error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PublicAssets {
|
||||||
|
href: (href: string) => string; // a /public/ href with its version; any other href unchanged
|
||||||
|
// onError receives every failure to read a file except a missing one, which answers 404.
|
||||||
|
serve: (url: URL, req: IncomingMessage, res: ServerResponse, onError: (err: Error) => void) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function publicAssets(publicDir: string, pluginsDir: string, pluginIds: Set<string>, { cache }: { cache: boolean }): PublicAssets {
|
||||||
|
const route = (pathname: string): StaticRoute => routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds);
|
||||||
|
return {
|
||||||
|
href: (href) => (href.startsWith("/public/") && !/[?#]/.test(href) ? versioned(href, route(href), cache) : href),
|
||||||
|
serve: (url, req, res, onError) => serveStatic({ ...route(url.pathname), version: url.searchParams.get("v") }, req, res, onError),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ const messages = {
|
|||||||
"auth.continue": "Continue",
|
"auth.continue": "Continue",
|
||||||
// Kratos labels its own form fields; these translate the ones the built-in identity schema uses,
|
// Kratos labels its own form fields; these translate the ones the built-in identity schema uses,
|
||||||
// keyed on the input name. A deployment's extra traits keep Kratos' label until a plugin covers them.
|
// keyed on the input name. A deployment's extra traits keep Kratos' label until a plugin covers them.
|
||||||
"auth.field.code.hint": "Digits only — no spaces.",
|
"auth.field.code.hint": "Digits only.",
|
||||||
"auth.field.email": "Email",
|
"auth.field.email": "Email",
|
||||||
"auth.field.identifier": "Email",
|
"auth.field.identifier": "Email",
|
||||||
"auth.field.password": "Password",
|
"auth.field.password": "Password",
|
||||||
@@ -100,6 +100,7 @@ const messages = {
|
|||||||
"filter.remove": "Remove {{label}} filter",
|
"filter.remove": "Remove {{label}} filter",
|
||||||
"filter.reset": "Reset",
|
"filter.reset": "Reset",
|
||||||
"filter.search": "Search",
|
"filter.search": "Search",
|
||||||
|
"filter.selected": "{{label}}, {{count}} selected",
|
||||||
"filter.to": "To",
|
"filter.to": "To",
|
||||||
"filter.toSeparator": "to",
|
"filter.toSeparator": "to",
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { CoreMessages } from "./en-US.ts";
|
|||||||
|
|
||||||
const messages: CoreMessages = {
|
const messages: CoreMessages = {
|
||||||
"auth.continue": "Fortsätt",
|
"auth.continue": "Fortsätt",
|
||||||
"auth.field.code.hint": "Endast siffror — inga mellanslag.",
|
"auth.field.code.hint": "Endast siffror.",
|
||||||
"auth.field.email": "E-postadress",
|
"auth.field.email": "E-postadress",
|
||||||
"auth.field.identifier": "E-postadress",
|
"auth.field.identifier": "E-postadress",
|
||||||
"auth.field.password": "Lösenord",
|
"auth.field.password": "Lösenord",
|
||||||
@@ -91,6 +91,7 @@ const messages: CoreMessages = {
|
|||||||
"filter.remove": "Ta bort filtret {{label}}",
|
"filter.remove": "Ta bort filtret {{label}}",
|
||||||
"filter.reset": "Återställ",
|
"filter.reset": "Återställ",
|
||||||
"filter.search": "Sök",
|
"filter.search": "Sök",
|
||||||
|
"filter.selected": "{{label}}, {{count}} valda",
|
||||||
"filter.to": "Till",
|
"filter.to": "Till",
|
||||||
"filter.toSeparator": "till",
|
"filter.toSeparator": "till",
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ function scaffold(t: TestContext, files: Record<string, string>): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const full = (id: string): string =>
|
const full = (id: string): string =>
|
||||||
`export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "${id}:root", label: "${id}" }], ` +
|
`export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "${id}:root", label: "${id}", public: true }], ` +
|
||||||
`routes: [{ method: "GET", path: "/", handler: () => ({ html: "${id}" }) }] };`;
|
`routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "${id}" }) }] };`;
|
||||||
|
|
||||||
test("a missing plugins/ dir means zero plugins, not an error (clean clone)", async () => {
|
test("a missing plugins/ dir means zero plugins, not an error (clean clone)", async () => {
|
||||||
assert.deepEqual(await discoverPlugins({ dir: join(tmpdir(), "pp-does-not-exist-xyz") }), []);
|
assert.deepEqual(await discoverPlugins({ dir: join(tmpdir(), "pp-does-not-exist-xyz") }), []);
|
||||||
@@ -62,6 +62,15 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
|
|||||||
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
|
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
|
||||||
{ name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s },
|
{ name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s },
|
||||||
{ name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: true, permission: "x:read" }] };` }, match: /contranav.*public.*permission/s },
|
{ name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: true, permission: "x:read" }] };` }, match: /contranav.*public.*permission/s },
|
||||||
|
{ name: "a route marked session AND permission is contradictory", files: { "contrasess/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", session: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contrasess.*session.*permission/s },
|
||||||
|
{ name: "a route marked public AND session is contradictory", files: { "contrapub/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, session: true, handler: () => ({ html: "x" }) }] };` }, match: /contrapub.*public.*session/s },
|
||||||
|
{ name: "a route whose session flag is a truthy non-boolean is refused, not read as ungated", files: { "truthy/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", session: "yes", handler: () => ({ html: "x" }) }] };` }, match: /truthy.*session.*true/s },
|
||||||
|
{ name: "a nav node whose public flag is a truthy non-boolean is refused too", files: { "truthynav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: 1 }] };` }, match: /truthynav.*public.*true/s },
|
||||||
|
{ name: "a nav node marked session AND permission is contradictory", files: { "contrasessnav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", session: true, permission: "x:read" }] };` }, match: /contrasessnav.*session.*permission/s },
|
||||||
|
// A gate is named, never forgotten: a route or node without one would be an open page nobody chose.
|
||||||
|
{ name: "a route naming no gate at all is refused, not served to everyone", files: { "nogate/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", handler: () => ({ html: "x" }) }] };` }, match: /nogate.*names no gate/s },
|
||||||
|
{ name: "a nav node naming no gate at all is refused too — a section header says `public` outright", files: { "nogatenav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N" }] };` }, match: /nogatenav.*names no gate/s },
|
||||||
|
{ name: "a gate set to false is refused — it reads as a gate but sets none", files: { "falsegate/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: false, handler: () => ({ html: "x" }) }] };` }, match: /falsegate.*public.*true/s },
|
||||||
// A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not
|
// A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not
|
||||||
// only in the admin GUI, so it holds for a plugin installed without that GUI.
|
// only in the admin GUI, so it holds for a plugin installed without that GUI.
|
||||||
{ name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s },
|
{ name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s },
|
||||||
@@ -96,12 +105,19 @@ test("a discovery failure tells the operator their plugins/ copy may just be out
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a route + nav node may be marked public and load fine", async (t) => {
|
test("a route + nav node may be marked public, or session, and load fine", async (t) => {
|
||||||
const dir = scaffold(t, { "pub/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };` });
|
const dir = scaffold(t, {
|
||||||
|
"pub/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };`,
|
||||||
|
"sess/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ href: "/sess", id: "s", label: "S", session: true }], routes: [{ method: "GET", path: "/", session: true, handler: () => ({ html: "x" }) }] };`,
|
||||||
|
});
|
||||||
const plugins = await discoverPlugins({ dir });
|
const plugins = await discoverPlugins({ dir });
|
||||||
assert.equal(plugins.length, 1);
|
assert.equal(plugins.length, 2);
|
||||||
assert.equal(plugins[0]?.routes?.[0]?.public, true);
|
const pub = plugins.find((p) => p.id === "pub");
|
||||||
assert.equal(plugins[0]?.nav?.[0]?.public, true);
|
const sess = plugins.find((p) => p.id === "sess");
|
||||||
|
assert.equal(pub?.routes?.[0]?.public, true);
|
||||||
|
assert.equal(pub?.nav?.[0]?.public, true);
|
||||||
|
assert.equal(sess?.routes?.[0]?.session, true);
|
||||||
|
assert.equal(sess?.nav?.[0]?.session, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("`admin` is not reserved — the admin screens ship as a drop-in plugin mounted at /admin", async (t) => {
|
test("`admin` is not reserved — the admin screens ship as a drop-in plugin mounted at /admin", async (t) => {
|
||||||
@@ -127,7 +143,7 @@ test("a plugin may carry its own package.json, node_modules and dependencies", a
|
|||||||
"shop/node_modules/price-tag/package.json": `{ "name": "price-tag", "version": "1.0.0", "type": "module", "exports": "./index.js" }`,
|
"shop/node_modules/price-tag/package.json": `{ "name": "price-tag", "version": "1.0.0", "type": "module", "exports": "./index.js" }`,
|
||||||
"shop/node_modules/price-tag/index.js": `export default (n) => \`\${n} kr\`;`,
|
"shop/node_modules/price-tag/index.js": `export default (n) => \`\${n} kr\`;`,
|
||||||
"shop/plugin.ts": `import { definePlugin } from "@plainpages/plugin-api";\nimport price from "price-tag";\n` +
|
"shop/plugin.ts": `import { definePlugin } from "@plainpages/plugin-api";\nimport price from "price-tag";\n` +
|
||||||
`export default definePlugin({ apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", handler: () => ({ html: price(20) }) }] });`,
|
`export default definePlugin({ apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: price(20) }) }] });`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const plugins = await discoverPlugins({ dir });
|
const plugins = await discoverPlugins({ dir });
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
import { type Gate, gatesSet } from "../auth/gate.ts";
|
||||||
import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
|
import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
|
||||||
import { settingsDeclError } from "./settings.ts";
|
import { settingsDeclError } from "./settings.ts";
|
||||||
import { isValidStoragePluginId, MAX_STORAGE_PLUGIN_ID_LENGTH } from "./storage.ts";
|
import { isValidStoragePluginId, MAX_STORAGE_PLUGIN_ID_LENGTH } from "./storage.ts";
|
||||||
@@ -146,46 +147,43 @@ function shapeError(manifest: PluginManifest): string | null {
|
|||||||
const settings = settingsDeclError(manifest.settings);
|
const settings = settingsDeclError(manifest.settings);
|
||||||
if (settings) return settings;
|
if (settings) return settings;
|
||||||
}
|
}
|
||||||
// `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
|
|
||||||
// "needs this permission". Refuse rather than silently pick one, so the author's intent is unambiguous.
|
|
||||||
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
||||||
if (route?.public === true && route.permission != null) return `route "${route.method} ${route.path}" sets both public and permission — they are mutually exclusive`;
|
const gate = gateError(`route "${route?.method} ${route?.path}"`, route);
|
||||||
}
|
if (gate) return gate;
|
||||||
const navContradiction = findPublicNavContradiction(manifest.nav);
|
|
||||||
if (navContradiction) return navContradiction;
|
|
||||||
// Every permission name the manifest mentions — gated on or declared — must be `<resource>:<action>`.
|
|
||||||
// A bare word names a role, and roles are groups here (README → Naming a permission).
|
|
||||||
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
|
||||||
if (route?.permission != null && !isValidPermissionName(route.permission)) {
|
|
||||||
return `route "${route.method} ${route.path}" gates on "${route.permission}"; a permission name is <resource>:<action>, e.g. "things:read"`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
const navGate = findNavGateError(manifest.nav);
|
||||||
|
if (navGate) return navGate;
|
||||||
for (const decl of Array.isArray(manifest.permissions) ? manifest.permissions : []) {
|
for (const decl of Array.isArray(manifest.permissions) ? manifest.permissions : []) {
|
||||||
if (decl?.name == null || !isValidPermissionName(decl.name)) {
|
if (decl?.name == null || !isValidPermissionName(decl.name)) {
|
||||||
return `declared permission "${decl?.name}" is not <resource>:<action>, e.g. "things:read"`;
|
return `declared permission "${decl?.name}" is not <resource>:<action>, e.g. "things:read"`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const navPermission = findInvalidNavPermission(manifest.nav);
|
|
||||||
if (navPermission) return navPermission;
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recurse the nav fragment: a node that is both `public` and `permission`-gated is contradictory.
|
// Every rule a declaration's gate must satisfy. Exactly one gate, always: a missing one would be an
|
||||||
function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null {
|
// open page nobody chose, and anything but `true` (a `false`, a `"yes"`) sets no gate while looking
|
||||||
for (const node of Array.isArray(nodes) ? nodes : []) {
|
// like it does. A permission name is `<resource>:<action>` because a bare word names a role, and
|
||||||
if (node?.public === true && node.permission != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and permission — they are mutually exclusive`;
|
// roles are groups here (README → Naming a permission).
|
||||||
const inChild = findPublicNavContradiction(node?.children);
|
function gateError(what: string, gate: Gate | null | undefined): string | null {
|
||||||
if (inChild) return inChild;
|
for (const flag of ["public", "session"] as const) {
|
||||||
|
const value = gate?.[flag];
|
||||||
|
if (value !== undefined && value !== true) return `${what} sets ${flag} to ${JSON.stringify(value)}; a gate is declared with \`true\``;
|
||||||
|
}
|
||||||
|
const gates = gatesSet(gate);
|
||||||
|
if (gates.length === 0) return `${what} names no gate; name exactly one — public, session or permission`;
|
||||||
|
if (gates.length > 1) return `${what} sets ${gates.join(" and ")}; name exactly one — public, session or permission`;
|
||||||
|
if (gate?.permission != null && !isValidPermissionName(gate.permission)) {
|
||||||
|
return `${what} gates on "${gate.permission}"; a permission name is <resource>:<action>, e.g. "things:read"`;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findInvalidNavPermission(nodes: PluginManifest["nav"]): string | null {
|
function findNavGateError(nodes: PluginManifest["nav"]): string | null {
|
||||||
for (const node of Array.isArray(nodes) ? nodes : []) {
|
for (const node of Array.isArray(nodes) ? nodes : []) {
|
||||||
if (node?.permission != null && !isValidPermissionName(node.permission)) {
|
const err = gateError(`nav node "${node?.label ?? node?.id ?? "?"}"`, node);
|
||||||
return `nav node "${node.label ?? node.id ?? "?"}" gates on "${node.permission}"; a permission name is <resource>:<action>, e.g. "things:read"`;
|
if (err) return err;
|
||||||
}
|
const inChild = findNavGateError(node?.children);
|
||||||
const inChild = findInvalidNavPermission(node?.children);
|
|
||||||
if (inChild) return inChild;
|
if (inChild) return inChild;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export type { RequestContext, User } from "../http/context.ts";
|
|||||||
export type { PageChrome } from "../ui/chrome.ts";
|
export type { PageChrome } from "../ui/chrome.ts";
|
||||||
export type { NavNode } from "../ui/nav.ts";
|
export type { NavNode } from "../ui/nav.ts";
|
||||||
export { can, check, GuardError, requireSession } from "../auth/guards.ts";
|
export { can, check, GuardError, requireSession } from "../auth/guards.ts";
|
||||||
|
// The three coarse gates a route or nav node may declare — `Route` and `NavNode` both extend it.
|
||||||
|
export type { Gate } from "../auth/gate.ts";
|
||||||
// Translation: `ctx.t` and the view-level `t(...)` do the work at runtime — these are for
|
// Translation: `ctx.t` and the view-level `t(...)` do the work at runtime — these are for
|
||||||
// authoring a plugin's own catalogs (plugins/<id>/i18n/<locale>.ts) and for building a translator
|
// authoring a plugin's own catalogs (plugins/<id>/i18n/<locale>.ts) and for building a translator
|
||||||
// in a unit test. `PluralMessage` types a message that varies with a count.
|
// in a unit test. `PluralMessage` types a message that varies with a count.
|
||||||
|
|||||||
@@ -4,13 +4,14 @@
|
|||||||
// A plugin's identity is its folder under plugins/: folder name = `id` (isValidPluginId), mount =
|
// A plugin's identity is its folder under plugins/: folder name = `id` (isValidPluginId), mount =
|
||||||
// `/<id>`. Neither is in the manifest — the host derives them, so they can't drift or be claimed twice.
|
// `/<id>`. Neither is in the manifest — the host derives them, so they can't drift or be claimed twice.
|
||||||
|
|
||||||
|
import type { Gate } from "../auth/gate.ts";
|
||||||
import type { RequestContext } from "../http/context.ts";
|
import type { RequestContext } from "../http/context.ts";
|
||||||
import type { NavNode } from "../ui/nav.ts";
|
import type { NavNode } from "../ui/nav.ts";
|
||||||
import { envName, type SettingDecl, type SettingsOf } from "./settings.ts";
|
import { envName, type SettingDecl, type SettingsOf } from "./settings.ts";
|
||||||
import type { StorageCredentials } from "./storage.ts";
|
import type { StorageCredentials } from "./storage.ts";
|
||||||
|
|
||||||
// The Plainpages release this contract ships in — see README → Contract versioning.
|
// The Plainpages release this contract ships in — see README → Contract versioning.
|
||||||
export const HOST_API_VERSION = "0.2.0";
|
export const HOST_API_VERSION = "0.4.0";
|
||||||
|
|
||||||
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
|
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
|
||||||
|
|
||||||
@@ -24,14 +25,10 @@ export type RouteResult =
|
|||||||
|
|
||||||
export type RouteHandler = (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void;
|
export type RouteHandler = (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void;
|
||||||
|
|
||||||
export interface Route {
|
export interface Route extends Gate {
|
||||||
handler: RouteHandler;
|
handler: RouteHandler;
|
||||||
method: HttpMethod;
|
method: HttpMethod;
|
||||||
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
|
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
|
||||||
permission?: string; // coarse gate — the Keto Permission the caller must hold; checked before the handler runs
|
|
||||||
// Same as omitting `permission`, but stated outright so public is a deliberate choice rather than
|
|
||||||
// a forgotten gate. Mutually exclusive with `permission` (discovery refuses both).
|
|
||||||
public?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A Keto Permission this plugin gates on — declared for docs/seeding. Names are a shared global
|
// A Keto Permission this plugin gates on — declared for docs/seeding. Names are a shared global
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import type { Plugin, Route } from "./plugin.ts";
|
import type { Plugin, Route } from "./plugin.ts";
|
||||||
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
|
import { allowedMethods, matchRoute } from "./router.ts";
|
||||||
|
|
||||||
const noop: Route["handler"] = () => ({ html: "x" });
|
const noop: Route["handler"] = () => ({ html: "x" });
|
||||||
|
|
||||||
@@ -54,14 +54,3 @@ test("allowedMethods lists methods at a path (GET implies HEAD); empty when the
|
|||||||
assert.deepEqual(allowedMethods(plugins, "/x/a"), ["GET", "HEAD", "POST"]);
|
assert.deepEqual(allowedMethods(plugins, "/x/a"), ["GET", "HEAD", "POST"]);
|
||||||
assert.deepEqual(allowedMethods(plugins, "/x/missing"), []);
|
assert.deepEqual(allowedMethods(plugins, "/x/missing"), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("isAuthorized: open routes pass; gated routes require the permission token; public is explicitly open", () => {
|
|
||||||
const open: Route = { handler: noop, method: "GET", path: "/" };
|
|
||||||
const gated: Route = { handler: noop, method: "GET", path: "/", permission: "x:read" };
|
|
||||||
const pub: Route = { handler: noop, method: "GET", path: "/", public: true }; // blessed public alias
|
|
||||||
assert.equal(isAuthorized(open, []), true);
|
|
||||||
assert.equal(isAuthorized(gated, []), false);
|
|
||||||
assert.equal(isAuthorized(gated, ["x:read"]), true);
|
|
||||||
assert.equal(isAuthorized(gated, ["other"]), false);
|
|
||||||
assert.equal(isAuthorized(pub, []), true); // open to anonymous, like omitting permission — but stated outright
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -73,10 +73,3 @@ export function allowedMethods(plugins: Plugin[], pathname: string): string[] {
|
|||||||
}
|
}
|
||||||
return [...methods].sort();
|
return [...methods].sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coarse permission gate: a route marked `public` (or one with no `permission`) is open; otherwise
|
|
||||||
// the user's permissions (from the session JWT) must include the token. The same rule composeNav uses
|
|
||||||
// for the menu. `public` and `permission` are mutually exclusive (discovery refuses both).
|
|
||||||
export function isAuthorized(route: Route, permissions: string[]): boolean {
|
|
||||||
return route.public === true || route.permission == null || permissions.includes(route.permission);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -109,12 +109,27 @@ test("each type coerces from the environment, and a bad value fails loud", () =>
|
|||||||
PLUGIN_SETTING_P_MODE: "loose",
|
PLUGIN_SETTING_P_MODE: "loose",
|
||||||
});
|
});
|
||||||
assert.equal(bad.errors.length, 4);
|
assert.equal(bad.errors.length, 4);
|
||||||
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_COUNT.*number/);
|
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_COUNT.*number, got "twelve"/);
|
||||||
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_FLAG.*"true".*"false"/);
|
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_FLAG.*"true".*"false", got "yes"/);
|
||||||
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_MODE.*strict, lenient/);
|
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_MODE.*strict, lenient, got "loose"/);
|
||||||
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_BASE.*URL/);
|
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_BASE.*URL, got "not a url"/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const SECRET_CASES: [SettingDecl, string, RegExp][] = [
|
||||||
|
[{ key: "s", secret: true, type: "boolean" }, "hunter2-bool", /PLUGIN_SETTING_P_S.*"true".*"false"/],
|
||||||
|
[{ key: "s", secret: true, type: "enum", values: ["a", "b"] }, "hunter2-enum", /PLUGIN_SETTING_P_S.*a, b/],
|
||||||
|
[{ key: "s", secret: true, type: "number" }, "hunter2-number", /PLUGIN_SETTING_P_S.*number/],
|
||||||
|
[{ key: "s", secret: true, type: "url" }, "postgres user:hunter2-url@db", /PLUGIN_SETTING_P_S.*URL/],
|
||||||
|
];
|
||||||
|
for (const [decl, raw, names] of SECRET_CASES) {
|
||||||
|
test(`a secret ${decl.type}'s bad value never reaches the boot error`, () => {
|
||||||
|
const errors = resolveSettings([pluginWith("p", [decl])], { PLUGIN_SETTING_P_S: raw }).errors;
|
||||||
|
assert.equal(errors.length, 1);
|
||||||
|
assert.match(errors[0] ?? "", names);
|
||||||
|
assert.ok(!(errors[0] ?? "").includes("hunter2"));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test("a boolean is only \"true\"/\"false\" — a typo never degrades to false", () => {
|
test("a boolean is only \"true\"/\"false\" — a typo never degrades to false", () => {
|
||||||
const plugins = [pluginWith("p", [{ default: true, key: "flag", type: "boolean" }])];
|
const plugins = [pluginWith("p", [{ default: true, key: "flag", type: "boolean" }])];
|
||||||
assert.equal(resolveSettings(plugins, { PLUGIN_SETTING_P_FLAG: "false" }).values.get("p")?.["flag"], false);
|
assert.equal(resolveSettings(plugins, { PLUGIN_SETTING_P_FLAG: "false" }).values.get("p")?.["flag"], false);
|
||||||
|
|||||||
@@ -233,24 +233,25 @@ function summarize(decl: SettingDecl, name: string, source: SettingSummary["sour
|
|||||||
|
|
||||||
// A coerced value, or the boot error naming the variable and what it accepts.
|
// A coerced value, or the boot error naming the variable and what it accepts.
|
||||||
function coerce(decl: SettingDecl, raw: string, name: string): { value: SettingValue } | string {
|
function coerce(decl: SettingDecl, raw: string, name: string): { value: SettingValue } | string {
|
||||||
|
const got = decl.secret === true ? "" : `, got "${raw}"`;
|
||||||
switch (decl.type) {
|
switch (decl.type) {
|
||||||
case "boolean":
|
case "boolean":
|
||||||
if (raw === "true") return { value: true };
|
if (raw === "true") return { value: true };
|
||||||
if (raw === "false") return { value: false };
|
if (raw === "false") return { value: false };
|
||||||
return `${name} must be "true" or "false", got "${raw}"`;
|
return `${name} must be "true" or "false"${got}`;
|
||||||
case "enum":
|
case "enum":
|
||||||
return (decl.values ?? []).includes(raw)
|
return (decl.values ?? []).includes(raw)
|
||||||
? { value: raw }
|
? { value: raw }
|
||||||
: `${name} must be one of ${(decl.values ?? []).join(", ")}, got "${raw}"`;
|
: `${name} must be one of ${(decl.values ?? []).join(", ")}${got}`;
|
||||||
case "number": {
|
case "number": {
|
||||||
const value = Number(raw);
|
const value = Number(raw);
|
||||||
return Number.isFinite(value) ? { value } : `${name} must be a number, got "${raw}"`;
|
return Number.isFinite(value) ? { value } : `${name} must be a number${got}`;
|
||||||
}
|
}
|
||||||
case "url":
|
case "url":
|
||||||
try {
|
try {
|
||||||
new URL(raw);
|
new URL(raw);
|
||||||
} catch {
|
} catch {
|
||||||
return `${name} is not a valid URL: ${raw}`;
|
return `${name} must be a valid URL${got}`;
|
||||||
}
|
}
|
||||||
return { value: raw };
|
return { value: raw };
|
||||||
case "string":
|
case "string":
|
||||||
|
|||||||
@@ -16,10 +16,20 @@ export interface ProvisionResult {
|
|||||||
provisioned: string[];
|
provisioned: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The driver's error for a DSN it can't parse carries the whole string (`input`) — password included —
|
||||||
|
// so it is swallowed here. The driver, not `new URL`, decides what parses: it takes a multi-host DSN.
|
||||||
|
function connect(adminUrl: string): ReturnType<typeof postgres> {
|
||||||
|
try {
|
||||||
|
return postgres(adminUrl, { connect_timeout: 10, max: 1 });
|
||||||
|
} catch {
|
||||||
|
throw new Error("PLUGIN_DB_ADMIN_URL must be a Postgres URL (its value is not shown; percent-encode special characters in a password)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function provisionStorage(options: ProvisionOptions): Promise<ProvisionResult> {
|
export async function provisionStorage(options: ProvisionOptions): Promise<ProvisionResult> {
|
||||||
// Notices are left to surface: a REVOKE the account cannot perform only *warns*, and silencing
|
// Notices are left to surface: a REVOKE the account cannot perform only *warns*, and silencing
|
||||||
// that would mean reporting a locked-down database that is still open to PUBLIC.
|
// that would mean reporting a locked-down database that is still open to PUBLIC.
|
||||||
const sql = postgres(options.adminUrl, { connect_timeout: 10, max: 1 });
|
const sql = connect(options.adminUrl);
|
||||||
try {
|
try {
|
||||||
const provisioned: string[] = [];
|
const provisioned: string[] = [];
|
||||||
for (const pluginId of options.pluginIds) {
|
for (const pluginId of options.pluginIds) {
|
||||||
|
|||||||
@@ -108,6 +108,13 @@ test("a connection limit that is not a positive integer is refused, not interpol
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a malformed admin DSN is refused by name, without echoing its password", async () => {
|
||||||
|
await assert.rejects(
|
||||||
|
provisionStorage({ adminUrl: "postgres://admin:pa/ss@db:5432/plugins", connectionLimit: 10, pluginIds: [], secret: SECRET }),
|
||||||
|
(err: Error) => err.message.includes("PLUGIN_DB_ADMIN_URL") && !err.message.includes("pa/ss"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// --- Integration: the statements above, against a real Postgres -----------------------
|
// --- Integration: the statements above, against a real Postgres -----------------------
|
||||||
// Opt-in via PLUGIN_DB_ADMIN_URL (a superuser DSN); the unit gate runs no Postgres. What the unit
|
// Opt-in via PLUGIN_DB_ADMIN_URL (a superuser DSN); the unit gate runs no Postgres. What the unit
|
||||||
// tests cannot prove lives here: the owner may create tables, and a peer role is locked out.
|
// tests cannot prove lives here: the owner may create tables, and a peer role is locked out.
|
||||||
|
|||||||
+14
-1
@@ -1,6 +1,6 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import { buildPluginChrome } from "./chrome.ts";
|
import { buildPluginChrome, shellUser } from "./chrome.ts";
|
||||||
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
||||||
import type { NavNode } from "./nav.ts";
|
import type { NavNode } from "./nav.ts";
|
||||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||||
@@ -74,3 +74,16 @@ test("branding logo + default theme flow through when set", () => {
|
|||||||
assert.equal(chrome.brand.name, "Acme");
|
assert.equal(chrome.brand.name, "Acme");
|
||||||
assert.equal(chrome.theme, "dark");
|
assert.equal(chrome.theme, "dark");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("branding name and sub are translated; an unknown key renders as itself", () => {
|
||||||
|
const t = (key: string): string => (key === "brand.sub" ? "Undertitel" : key);
|
||||||
|
const chrome = buildPluginChrome({ menu: { branding: { name: "Acme", sub: "brand.sub" }, override: {} }, t });
|
||||||
|
assert.equal(chrome.brand.name, "Acme");
|
||||||
|
assert.equal(chrome.brand.sub, "Undertitel");
|
||||||
|
assert.equal(buildPluginChrome({ menu: { branding: { name: "Acme" }, override: {} } }).brand.sub, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shellUser derives the profile from the real user; anonymous → Guest", () => {
|
||||||
|
assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "Guest" });
|
||||||
|
assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", permissions: [] }), { email: "ada@example.com", initials: "AD", name: "ada" });
|
||||||
|
});
|
||||||
|
|||||||
+29
-6
@@ -8,9 +8,14 @@ import type { Translate } from "../i18n/translate.ts";
|
|||||||
import { type MenuConfig } from "./menu-config.ts";
|
import { type MenuConfig } from "./menu-config.ts";
|
||||||
import { composeNav, type NavNode } from "./nav.ts";
|
import { composeNav, type NavNode } from "./nav.ts";
|
||||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||||
import { branding, shellUser, type ShellUser } from "./shell-context.ts";
|
|
||||||
|
|
||||||
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard" };
|
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard", session: true };
|
||||||
|
|
||||||
|
export interface ShellUser {
|
||||||
|
email: string;
|
||||||
|
initials: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PageChrome {
|
export interface PageChrome {
|
||||||
brand: { logo?: string; name: string; sub?: string };
|
brand: { logo?: string; name: string; sub?: string };
|
||||||
@@ -35,8 +40,7 @@ export interface ChromeOptions {
|
|||||||
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||||
const t = opts.t ?? ENGLISH;
|
const t = opts.t ?? ENGLISH;
|
||||||
const carryLocale = opts.localeHref ?? ((href: string) => href);
|
const carryLocale = opts.localeHref ?? ((href: string) => href);
|
||||||
// Dashboard is gated, so an anonymous click would only dead-end at /login.
|
const fragments: NavNode[][] = [[DASHBOARD_NAV]];
|
||||||
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
|
|
||||||
// A plugin's nav labels are keys in *its* catalog, so translate each fragment with that plugin's
|
// A plugin's nav labels are keys in *its* catalog, so translate each fragment with that plugin's
|
||||||
// translator before merging. composeNav then runs the core one over the result; already-translated
|
// translator before merging. composeNav then runs the core one over the result; already-translated
|
||||||
// text passes through it.
|
// text passes through it.
|
||||||
@@ -44,8 +48,7 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
|||||||
if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t));
|
if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t));
|
||||||
}
|
}
|
||||||
|
|
||||||
const permissions = opts.user?.permissions ?? [];
|
const nav = composeNav(fragments, opts.menu.override, opts.user ?? null, t);
|
||||||
const nav = composeNav(fragments, opts.menu.override, permissions, t);
|
|
||||||
if (opts.currentPath) {
|
if (opts.currentPath) {
|
||||||
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
|
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
|
||||||
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard.
|
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard.
|
||||||
@@ -67,6 +70,26 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The JWT holds only id/email/permissions, so the profile name is the email's local part.
|
||||||
|
export function shellUser(user: User | null | undefined, t: Translate = ENGLISH): ShellUser {
|
||||||
|
if (!user) {
|
||||||
|
const guest = t("shell.guest");
|
||||||
|
return { email: "", initials: ([...guest][0] ?? "?").toUpperCase(), name: guest }; // by character: the word is translated
|
||||||
|
}
|
||||||
|
const local = user.email.split("@")[0] || user.email;
|
||||||
|
return { email: user.email, initials: (local.slice(0, 2) || "U").toUpperCase(), name: local };
|
||||||
|
}
|
||||||
|
|
||||||
|
function branding(menu: MenuConfig, t: Translate): { logo?: string; name: string; sub?: string; theme?: string } {
|
||||||
|
const b = menu.branding;
|
||||||
|
return {
|
||||||
|
...(b.logo != null ? { logo: b.logo } : {}),
|
||||||
|
name: t(b.name),
|
||||||
|
...(b.sub != null ? { sub: t(b.sub) } : {}),
|
||||||
|
...(b.theme != null ? { theme: b.theme } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function translateNav(nodes: NavNode[], t: Translate): NavNode[] {
|
function translateNav(nodes: NavNode[], t: Translate): NavNode[] {
|
||||||
return nodes.map((node) => ({
|
return nodes.map((node) => ({
|
||||||
...node,
|
...node,
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { test } from "node:test";
|
|
||||||
import { buildDashboardModel } from "./dashboard.ts";
|
|
||||||
import type { NavNode } from "./nav.ts";
|
|
||||||
|
|
||||||
// The default /dashboard is an instructional starter: no mock data, just the unified
|
|
||||||
// menu + shell. The host passes the one global menu (ctx.chrome.nav); the model passes it through.
|
|
||||||
const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }];
|
|
||||||
|
|
||||||
test("dashboard model: titled shell, passes the unified nav + csrf + user through", () => {
|
|
||||||
const m = buildDashboardModel({ csrfToken: "tok.sig", user: { email: "ada@x.io", id: "u1", permissions: ["admin"] }, nav: NAV });
|
|
||||||
assert.equal(m.shell.title, "Dashboard");
|
|
||||||
assert.equal(m.shell.csrfToken, "tok.sig");
|
|
||||||
assert.equal(m.shell.user.name, "ada"); // real signed-in identity, not a demo profile
|
|
||||||
assert.deepEqual(m.nav, NAV); // the host's menu is used verbatim — the dashboard builds no nav of its own
|
|
||||||
});
|
|
||||||
|
|
||||||
test("dashboard model: sensible defaults (empty nav, no token, Guest) and branding from menu", () => {
|
|
||||||
const m = buildDashboardModel();
|
|
||||||
assert.deepEqual(m.nav, []);
|
|
||||||
assert.equal(m.shell.csrfToken, "");
|
|
||||||
assert.equal(m.shell.user.name, "Guest");
|
|
||||||
assert.equal(m.shell.brand.name, "Plainpages");
|
|
||||||
|
|
||||||
const branded = buildDashboardModel({ menu: { branding: { name: "Acme Ops", sub: "Admin", theme: "dark" }, override: {} } });
|
|
||||||
assert.equal(branded.shell.brand.name, "Acme Ops");
|
|
||||||
assert.equal(branded.shell.theme, "dark");
|
|
||||||
});
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
// Dashboard view model: the gated "/dashboard" app home. By default a short instructional
|
|
||||||
// starter — what the dashboard is and how to replace it by exporting a `dashboard` handler from a
|
|
||||||
// plugin (views/index.ejs holds the prose). A plugin owns the real one (PluginManifest.dashboard);
|
|
||||||
// this placeholder renders until then. Pure: `nav` is the one global menu (ctx.chrome.nav), built
|
|
||||||
// once per request by the host, so the dashboard shows the exact same menu as every other page.
|
|
||||||
|
|
||||||
import type { User } from "../http/context.ts";
|
|
||||||
import { ENGLISH } from "../i18n/english.ts";
|
|
||||||
import type { Translate } from "../i18n/translate.ts";
|
|
||||||
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
|
||||||
import type { NavNode } from "./nav.ts";
|
|
||||||
import { buildShellContext } from "./shell-context.ts";
|
|
||||||
|
|
||||||
export function buildDashboardModel(opts: { csrfToken?: string; menu?: MenuConfig; nav?: NavNode[]; t?: Translate; user?: User | null } = {}) {
|
|
||||||
const t = opts.t ?? ENGLISH;
|
|
||||||
return {
|
|
||||||
nav: opts.nav ?? [],
|
|
||||||
shell: buildShellContext({
|
|
||||||
breadcrumbs: [{ label: t("dashboard.title") }],
|
|
||||||
csrfToken: opts.csrfToken ?? "",
|
|
||||||
menu: opts.menu ?? DEFAULT_MENU,
|
|
||||||
t,
|
|
||||||
title: t("dashboard.title"),
|
|
||||||
user: opts.user ?? null,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DashboardModel = ReturnType<typeof buildDashboardModel>;
|
|
||||||
@@ -37,6 +37,15 @@ const config = {
|
|||||||
options: [{ value: "engineering", label: "Engineering" }, { value: "design", label: "Design" }, { value: "oncall", label: "On-call" }],
|
options: [{ value: "engineering", label: "Engineering" }, { value: "design", label: "Design" }, { value: "oncall", label: "On-call" }],
|
||||||
},
|
},
|
||||||
{ type: "daterange", legend: "Joined", from: { name: "joined_from", value: "2026-01-01", label: "Joined from" }, to: { name: "joined_to", value: "2026-06-14", label: "Joined to" } },
|
{ type: "daterange", legend: "Joined", from: { name: "joined_from", value: "2026-01-01", label: "Joined from" }, to: { name: "joined_to", value: "2026-06-14", label: "Joined to" } },
|
||||||
|
{
|
||||||
|
type: "multiselect",
|
||||||
|
name: "owner",
|
||||||
|
legend: "Owner",
|
||||||
|
note: "30 of 45",
|
||||||
|
value: ["ann"],
|
||||||
|
options: [{ value: "ann", label: "Ann Berg" }, { value: "bo", label: "Bo Falk" }],
|
||||||
|
},
|
||||||
|
{ type: "multiselect", name: "room", legend: "Room", options: [{ value: "lab", label: "Lab" }] },
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
pills: [{ label: "Team", value: "Engineering", remove: "?tag=oncall" }],
|
pills: [{ label: "Team", value: "Engineering", remove: "?tag=oncall" }],
|
||||||
@@ -64,6 +73,14 @@ test("filter-bar renders a GET form with every control type, reflecting current
|
|||||||
assert.match(html, /<input type="checkbox" name="tag" value="oncall" checked>On-call/);
|
assert.match(html, /<input type="checkbox" name="tag" value="oncall" checked>On-call/);
|
||||||
assert.match(html, /<input type="checkbox" name="tag" value="design">Design/);
|
assert.match(html, /<input type="checkbox" name="tag" value="design">Design/);
|
||||||
|
|
||||||
|
// multiselect — a disclosure: the trigger names the filter and counts what is chosen, the options
|
||||||
|
// are checkboxes in a fieldset the popover holds, so a long list costs one line of the bar.
|
||||||
|
assert.match(html, /<div class="menu"><button class="btn btn-menu" type="button" popovertarget="f-owner-menu" aria-label="Owner, 1 selected">Owner<span class="badge">1<\/span><\/button><div id="f-owner-menu" class="menu-pop left" popover>/);
|
||||||
|
assert.match(html, /popover><div class="menu-head">30 of 45<\/div><fieldset class="menu-field"><legend class="menu-head">Owner<\/legend><label class="menu-check"><input type="checkbox" name="owner" value="ann" checked>Ann Berg<\/label><label class="menu-check"><input type="checkbox" name="owner" value="bo">Bo Falk<\/label><\/fieldset>/);
|
||||||
|
|
||||||
|
// Nothing chosen — no badge and no count in the accessible name.
|
||||||
|
assert.match(html, /<button class="btn btn-menu" type="button" popovertarget="f-room-menu">Room<\/button>/);
|
||||||
|
|
||||||
// daterange — calendar icon + two date inputs with values.
|
// daterange — calendar icon + two date inputs with values.
|
||||||
assert.match(html, /<div class="daterange"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"\s*\/?><\/svg>.*?<input type="date" id="f-joined_from" name="joined_from" value="2026-01-01">.*?<span class="to" aria-hidden="true">to<\/span>.*?<input type="date" id="f-joined_to" name="joined_to" value="2026-06-14">/);
|
assert.match(html, /<div class="daterange"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"\s*\/?><\/svg>.*?<input type="date" id="f-joined_from" name="joined_from" value="2026-01-01">.*?<span class="to" aria-hidden="true">to<\/span>.*?<input type="date" id="f-joined_to" name="joined_to" value="2026-06-14">/);
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user