Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a00d86fb83 | |||
| e8fac7fd5c | |||
| 42661b111f | |||
| ecdf9f3464 | |||
| d6a33e8cc9 | |||
| 0bf7901a14 | |||
| a0ad0ddf2d | |||
| 5088352fcf | |||
| 58f85baa8f | |||
| 428800fd62 | |||
| 9385f291a0 | |||
| add2e98eb9 | |||
| 23b5f717d7 | |||
| 0727d15964 | |||
| ed48c5a8fd | |||
| 2b19ed27d2 | |||
| 63c90ea3d6 | |||
| 2de7e4d79c | |||
| 4d2e7b59fd | |||
| 1cc98017d2 | |||
| 216089c067 | |||
| 56807434e8 | |||
| 037112bc2c | |||
| e328832374 | |||
| b8a510a1c5 | |||
| 8324a9c59e | |||
| dd39c7a07a | |||
| 05da1d7897 | |||
| 34dfbea156 | |||
| 3179be0d11 | |||
| 4c8ba083ce | |||
| 7ad46d0adf | |||
| be498eecd3 | |||
| b05d678c5f | |||
| 9b52b7e5ff | |||
| f8a611992a | |||
| fdef624300 | |||
| 903dde8a24 | |||
| 9b1b201f65 | |||
| 9ded502be3 | |||
| ea6c32a7db | |||
| c4eb9dde1f | |||
| 5e00bba85e | |||
| b4579131a2 | |||
| dd95c258df | |||
| 77e3d46769 | |||
| b7e9780437 | |||
| 1b53b01333 | |||
| 8544de831a | |||
| 523e8b3724 | |||
| 6767a5d7bd | |||
| a558f8340f | |||
| 777c0bd86d | |||
| 6ba21b8409 | |||
| be8197dcfa | |||
| 14a07dd879 | |||
| c3ca92713e | |||
| bb134a35d7 | |||
| 9a4ce279af | |||
| 1219513b79 | |||
| d6a44b333d | |||
| b19b43685c | |||
| 85219b081a | |||
| a69593eb72 | |||
| 996eef2dc5 | |||
| 6ca63c6c0f | |||
| 0b48f71d65 | |||
| 3a3bd77bb8 | |||
| 4b44d5bfde | |||
| 17cd516ace | |||
| 6a58ce3e81 | |||
| 9af29ffdb9 | |||
| 80070762e7 | |||
| 404f2584ac | |||
| 01f368d663 | |||
| 84368ea20b | |||
| 20b4d23855 | |||
| 28d6fc4774 | |||
| 4239ee12f0 | |||
| a2493edff2 | |||
| 5aa62e2742 | |||
| 99d5928b1d |
@@ -57,7 +57,7 @@ jobs:
|
|||||||
COMMIT=$(git rev-parse 'HEAD^{commit}')
|
COMMIT=$(git rev-parse 'HEAD^{commit}')
|
||||||
VERSION=${GIT_TAG#v}
|
VERSION=${GIT_TAG#v}
|
||||||
[ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \
|
[ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \
|
||||||
|| { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (README -> CI/CD)"; exit 1; }
|
|| { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (docs/ci-cd.md)"; exit 1; }
|
||||||
printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin
|
printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin
|
||||||
TAGS="$VERSION ${VERSION%.*} latest"
|
TAGS="$VERSION ${VERSION%.*} latest"
|
||||||
if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi
|
if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi
|
||||||
@@ -69,8 +69,9 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
# Cleanup, and the runner's Docker config is shared (AGENTS.md) — a lost race here must not
|
# This is cleanup: it must not fail a release that published, nor skip the overview job
|
||||||
# fail a release that published, nor skip the overview job that follows.
|
# that follows. The runner's Docker config is shared, so a logout can lose a race
|
||||||
|
# (docs/decisions.md → "CI docker logins share the runner host's Docker config").
|
||||||
docker logout gitea.larvit.se || true
|
docker logout gitea.larvit.se || true
|
||||||
docker logout docker.io || true
|
docker logout docker.io || true
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ change, sweep the prose you touched — this file, `README.md`, the example READ
|
|||||||
comments — and cut it back to what a competent reader could not infer:
|
comments — and cut it back to what a competent reader could not infer:
|
||||||
|
|
||||||
- **Delete history.** Git holds it. No "this moved from X", "used to be Y", "was tried and
|
- **Delete history.** Git holds it. No "this moved from X", "used to be Y", "was tried and
|
||||||
rejected", "(declined twice)", dated changelog entries, or the symptom that prompted a fix. Record
|
rejected", "(declined twice)", dated changelog entries (a decision's date in `docs/decisions.md`
|
||||||
the decision and the reason it *currently* turns on, nothing else.
|
is not one), or the symptom that prompted a fix. Record the decision and the reason it
|
||||||
|
*currently* turns on, nothing else.
|
||||||
- **Delete restatement.** A comment that says what the adjacent line says, a doc paragraph that
|
- **Delete restatement.** A comment that says what the adjacent line says, a doc paragraph that
|
||||||
re-explains a table above it, a file-map entry that expands the filename. The fix is deletion,
|
re-explains a table above it, a file-map entry that expands the filename. The fix is deletion,
|
||||||
not trimming.
|
not trimming.
|
||||||
@@ -67,289 +68,72 @@ branch, create a PR and merge it when the CI/CD turns green.
|
|||||||
**fails loud at boot/discovery** rather than sandboxing at runtime. Runtime crash-isolation is a
|
**fails loud at boot/discovery** rather than sandboxing at runtime. Runtime crash-isolation is a
|
||||||
deliberate **non-goal**.
|
deliberate **non-goal**.
|
||||||
|
|
||||||
## Deliberate architectural deviations (don't re-flag)
|
## Decisions
|
||||||
|
|
||||||
Intentional, reasoned choices — an architecture review should honor them, not re-raise them.
|
Each title below is an entry in `docs/decisions.md`, with its reasoning and the premise it rests
|
||||||
Revisit only if the stated reason stops holding.
|
on; a review honours them.
|
||||||
|
|
||||||
### Structure & contracts
|
### Structure & contracts
|
||||||
|
|
||||||
- **`src/` is grouped by concern**, not flat — `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`,
|
- `src/` is grouped by concern, and the core ships no domain screens
|
||||||
with `server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` at the root; tests are
|
- Plugins and config import the host only through the two barrels
|
||||||
co-located. Add a new module to the folder owning its concern. The core ships **no domain
|
- The Ory client types are contract-visible through the barrel
|
||||||
screens** — even the admin GUI is a drop-in plugin (`examples/plugins/admin/`).
|
- The barrel is a package, so a plugin folder may carry its own `package.json`
|
||||||
- **Plugins and config import the host only through a barrel** — `@plainpages/plugin-api` →
|
- Plugin storage hands over credentials, not a client
|
||||||
`plugin-api/index.ts` → `src/plugin-host/plugin-api.ts`, `#menu-config` → `src/ui/menu-config.ts`,
|
- Plugin settings are declared, not discovered
|
||||||
never a relative `../../src/*` path. These two barrels are the whole contract surface; don't "fix"
|
- The trust boundary is the `web` process, not the plugin
|
||||||
either back to a relative path. Three consequences:
|
- `ory/postgres/init/init.sql` is the only home for the Ory databases' ACL
|
||||||
- `@plainpages/plugin-api` re-exports the Ory client types (`KratosAdmin`/`KetoClient`/`HydraAdmin` + their
|
- `bootstrap.ts` stays under `src/auth/`
|
||||||
DTOs and error classes), so those shapes are **contract-visible** — changing them needs a major
|
- `BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook
|
||||||
`apiVersion` bump, not a free refactor.
|
- `config/` is a plain dir with no `package.json` of its own
|
||||||
- **The barrel is a package, not a `#`-import, so a plugin folder may carry its own
|
- A plugin `package.json` without `"type": "module"` is refused, not warned
|
||||||
`package.json`** and depend on npm packages (README → Plugin dependencies). The Dockerfile links
|
- `examples/` mirrors the drop-in mount dirs
|
||||||
it into `/node_modules`, above every plugin scope. Never let a copy reach a plugin's own
|
- `ctx.chrome` is lazily memoized, never built unconditionally
|
||||||
`node_modules`: two instances of the barrel break `instanceof` across the boundary, which
|
- A plugin-owned render always runs on that plugin's context
|
||||||
`plugin-api.test.ts` guards by asserting both paths reach one module.
|
- Email is delegated to Kratos
|
||||||
- **Plugin storage hands over credentials, not a client** (README → Plugin storage). The host takes
|
|
||||||
`postgres` to run the provisioning DDL, and `storage-provisioning.ts` is the only module importing
|
|
||||||
it — `storage.ts` beside it stays pure so `web` never loads a driver (`src/postgres.test.ts` guards
|
|
||||||
both halves, because one value imported from the wrong module breaks it invisibly). It is never
|
|
||||||
re-exported through the barrel, so no driver shape enters the contract. Three properties hold the design together, so
|
|
||||||
don't trade one away in isolation: passwords are `HMAC-SHA256(PLUGIN_DB_SECRET, id)` rather than
|
|
||||||
stored, which is what keeps the host stateless — whoever holds that secret holds every plugin
|
|
||||||
database, so it ranks with the DB password itself; the provisioning DSN reaches `bootstrap` only
|
|
||||||
(`src/compose.test.ts` guards the split); and provisioning never drops anything, so uninstalling a
|
|
||||||
plugin cannot destroy data — boot logs the orphans instead. Because the host's copy sits in the
|
|
||||||
ambient `/node_modules`, a plugin can `import "postgres"` without declaring it — incidental, not a
|
|
||||||
packaging promise, and a plugin must still depend on its own driver.
|
|
||||||
- **Plugin settings are declared, not discovered** (README → Plugin settings). `settings.ts` is pure and
|
|
||||||
takes the env as an argument, so the whole matrix unit-tests without a stack. Four rules carry the
|
|
||||||
design: the prefix is `PLUGIN_SETTING_`, never bare `PLUGIN_`, because a plugin id `db` with key
|
|
||||||
`url` would otherwise name the host's own `PLUGIN_DB_URL`; keys are camelCase so the
|
|
||||||
`camelCase → SNAKE_CASE` mapping is total and no two keys collide, with the residual cross-plugin
|
|
||||||
collision caught by `findConflicts`; `required` and `default` are mutually exclusive, which is what
|
|
||||||
lets `SettingsOf` type a declared key as present rather than `T | undefined`, so no plugin author
|
|
||||||
casts; and a secret's value reaches the plugin but never a log, an error or `ctx.declaredSettings`
|
|
||||||
— not even as a mask or a length. An author mistake is refused at discovery, a bad operator value
|
|
||||||
refuses the boot, and a stray `PLUGIN_SETTING_` variable only warns (the orphan-database precedent).
|
|
||||||
- **The trust boundary is the `web` process, not the plugin.** Per-plugin databases and roles bound
|
|
||||||
*accidents*, not hostile plugins: `PLUGIN_DB_SECRET` is in `web`'s environment during `onBoot`, and
|
|
||||||
a plugin already holds `ctx.system`'s Ory admin clients — so cross-plugin DB isolation is
|
|
||||||
containment, and README says so rather than implying a sandbox. Consistent with priority #7
|
|
||||||
(crash-isolation is a non-goal). `server.ts` still deletes the secret from `process.env` right
|
|
||||||
after `loadConfig`, which is before discovery imports any plugin module — the ordering is the whole
|
|
||||||
point, so move it earlier if anything, **never later**. **Valid while plugins are
|
|
||||||
operator-installed code, not third-party uploads.**
|
|
||||||
- **`ory/postgres/init/init.sql` is the only home for the Ory databases' ACL** — don't re-assert the
|
|
||||||
`REVOKE CONNECT` from `bootstrap`. `REVOKE` only *warns* when the caller doesn't own the database,
|
|
||||||
so under the least-privilege provisioning account the README recommends it would report success
|
|
||||||
while changing nothing, and it hard-fails whenever `PLUGIN_DB_ADMIN_URL` names a server with no
|
|
||||||
`kratos`. It runs only on **first init**, so a revoke added to it later never reaches a volume that
|
|
||||||
already exists — `docker compose down -v` is the dev remedy, a deployed install needs a migration.
|
|
||||||
- **`bootstrap.ts` stays under `src/auth/`** even though it now provisions plugin databases as well
|
|
||||||
as seeding Ory. It is the one-shot service's entrypoint, not an auth module; moving it to
|
|
||||||
`src/bootstrap.ts` would edit `compose.yml`, five e2e compose files and `src/compose.test.ts` for a
|
|
||||||
rename. Reconsider when a third seeding concern lands.
|
|
||||||
- **`BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook.** Adding
|
|
||||||
to the context costs a minor bump and removing one a major, so the shape errs small elsewhere. Pools
|
|
||||||
handed to a plugin are reaped on process exit — revisit if a plugin ever needs an orderly drain.
|
|
||||||
- **`config/` is still a plain dir — no `package.json` of its own**, or `#menu-config` resolves
|
|
||||||
against that instead and boot fails loud. An operator's menu override has no use for
|
|
||||||
dependencies; if that changes, it needs the same package treatment.
|
|
||||||
- **A plugin `package.json` without `"type": "module"` is refused, not warned.** Allowing it costs a
|
|
||||||
warning and a re-parse per file, not a break — Node detects module syntax, so even a `.js` helper
|
|
||||||
loads — and an operator on a read-only third-party mount cannot apply the remedy. Refused anyway
|
|
||||||
because the direction is safe: refuse→warn relaxes freely, warn→refuse breaks installed plugins.
|
|
||||||
**Valid while nothing is installed in the wild.**
|
|
||||||
- **`examples/` mirrors the drop-in mount dirs** — `examples/plugins/<id>/` copies to
|
|
||||||
`plugins/<id>/`, `examples/config/menu.ts` to `config/menu.ts`. Both mirrors are in
|
|
||||||
`tsconfig.include` and resolve the host through the barrels, so each typechecks in place *and*
|
|
||||||
copies across unchanged. Never commit real plugins/config into the root mount dirs — they ship empty.
|
|
||||||
- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the base request
|
|
||||||
context. It protects the I/O-free hot path on the public, bot-hit landing (`/`).
|
|
||||||
- **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`,
|
|
||||||
`dashboard`) and an `onRequest` short-circuit build their context with `contextFor(pluginId)`
|
|
||||||
exactly as a plugin route does — otherwise `ctx.t` is the core translator and the plugin's own keys
|
|
||||||
render as bare keys on the pages it owns.
|
|
||||||
- **Email is delegated to Kratos** (it renders + sends recovery/verification mail); `web` never
|
|
||||||
touches SMTP. Customization is Kratos' `courier.template_override_path`, not app code.
|
|
||||||
|
|
||||||
### Authorization
|
### Authorization
|
||||||
|
|
||||||
- **Vocabulary: `User` → `Group` → `Permission`, and there is no `Role`.** Keto ships no namespaces —
|
- The vocabulary is `User` → `Group` → `Permission`, and there is no `Role`
|
||||||
all four in `ory/keto/namespaces.keto.ts` are ours. A permission is one operation ("read shifts");
|
- A permission name is always `<resource>:<action>`, enforced at discovery
|
||||||
a role is a *bundle*, which here is just a group with several grants (groups nest). Ory's own
|
- Permission names are authored in plugin code; only grants live in Keto
|
||||||
"permission" (the `Resource` `permits`: view/edit/delete) is the separate per-row tier.
|
- A permission's `<resource>` is global, not plugin-scoped
|
||||||
- **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare
|
- Declaring a permission stays optional
|
||||||
word names *who someone is* (a role), and roles are groups here. **Enforced at discovery**
|
- `ADMIN_PERMISSIONS` defaults to empty, and an unusable value is dropped with a warning
|
||||||
(`isValidPermissionName` in `plugin-host/plugin.ts`, checked by `shapeError` over every route/nav
|
- `actionForMethod` is plugin-local and stays out of `@plainpages/plugin-api`
|
||||||
`permission` and every declared name), fail-loud like any other manifest rule — not only in the
|
- A gate is one of `public`, `session` or `permission`, named exactly once
|
||||||
admin GUI, which an operator removes by not copying it in.
|
- The reference plugin's two shift pages duplicate a view model and markup
|
||||||
- **Names are authored in plugin code; only grants live in Keto.** The host collects every installed
|
- A `:read`-only holder is never shown a write affordance
|
||||||
plugin's declarations into one catalog (`declaredPermissions` → `ctx.declaredPermissions`), and
|
- `users:write` and `groups:write` each amount to full administrative access
|
||||||
that catalog *is* the list the admin screens offer. Hence **no Permissions admin screen**: nothing
|
- The CSRF token is unbound to the session
|
||||||
in a GUI invents a name, and holding one is a property of a user or group, edited as a checkbox
|
- Plainpages says "user" everywhere, where Ory says "identity"
|
||||||
list there. A Keto tuple naming something no installed plugin declares gates nothing, is not
|
|
||||||
offered, and is never revoked by an unrelated save — the picker only speaks for what it showed.
|
|
||||||
- `<resource>` is **global, not plugin-scoped** (hence `oauth2-clients`, not `clients`): users are
|
|
||||||
the *host's*, and cross-plugin sharing is a goal. Cost: collision-freedom is a convention rather
|
|
||||||
than structural. Accepted — the alternative penalizes the sharing case.
|
|
||||||
- **Declaring a permission stays optional.** Mandatory declaration would let `findConflicts` see all
|
|
||||||
overlaps, but would then warn on exactly that legitimate sharing case. Shape is enforced;
|
|
||||||
declaration is not.
|
|
||||||
- `ADMIN_PERMISSIONS` **defaults to empty**, and **an unusable value is dropped with a warning,
|
|
||||||
never fatal** — fail-loud belongs at the manifest boundary where a developer authored the
|
|
||||||
mistake, whereas `bootstrap` gates `web`, so refusing operator env takes the whole stack down
|
|
||||||
(`e2e-tests/compose.auth.yml` seeds a bad value to prove the container survives one). The seed is
|
|
||||||
a function of what `bootstrap` discovers, so a plugin dropped in after first boot needs
|
|
||||||
`docker compose up -d`, not `restart web`. `bootstrap`'s matching `./plugins` mount belongs in
|
|
||||||
`compose.override.yml` and nowhere else: in the base file it would desynchronise prod and collide
|
|
||||||
with the e2e stacks, which bind individual plugins *inside* `/app/plugins` (a nested mount into a
|
|
||||||
read-only parent is EROFS and the container never starts). Valid while bootstrap is the only
|
|
||||||
writer of grants.
|
|
||||||
- **`actionForMethod` is plugin-local and must not migrate into `@plainpages/plugin-api`.** Inside the admin
|
|
||||||
example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
|
|
||||||
× 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
|
|
||||||
verb — a route table must answer "what does this need?" on its own.
|
|
||||||
- **A gate is one of three, 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
|
|
||||||
`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
|
|
||||||
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
|
|
||||||
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. The group paths are
|
|
||||||
guarded too: 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 you would lose a permission you
|
|
||||||
don't hold directly.
|
|
||||||
- **`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
|
|
||||||
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.
|
|
||||||
- **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
|
|
||||||
concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors
|
|
||||||
Kratos' wire shape — don't rename it.
|
|
||||||
|
|
||||||
### i18n
|
### i18n
|
||||||
|
|
||||||
- **The locale lives in the URL, never in a cookie.** `?locale=sv-SE` → `Accept-Language` → `en-US`,
|
- The locale lives in the URL, never in a cookie
|
||||||
and when the URL asked for one the host carries it onto the links it renders. A cookie would make a
|
- The core building blocks carry the locale, so a plugin doesn't have to
|
||||||
page's language invisible in its address and unshareable; the cost is that a plugin wraps its own
|
- `locale` and the i18n view locals are reserved by the host
|
||||||
hrefs. Matching is exact on a full tag (`sv-FI` ≠ `sv-SE`), except that a lone language from
|
- Catalogs are checked at boot, not at render
|
||||||
`Accept-Language` takes the first regional catalog for it.
|
- `locales/` at the repo root is a drop-in mount
|
||||||
- **The core building blocks carry the locale; a plugin doesn't have to.** The shell, `pagination`,
|
- The language picker is on every page, POST-rendered ones included
|
||||||
`filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every href in
|
- An unknown translation key renders as itself
|
||||||
`localeHref`; nav and sign-in are wrapped in `chrome.ts`; the two GET forms carry it as a hidden
|
- `t()` returns raw text, and the view escapes it
|
||||||
`locale` input, since a GET submit replaces the whole query string. **A form's `action` counts as a
|
- RTL is out of scope until there is a real use case
|
||||||
link** — sign-out, consent and auth-card forms carry it too, or picking a language and then saving
|
|
||||||
anything drops back to `Accept-Language`. The obligation stays on the building block, never on each
|
|
||||||
call site. `ctx.localeHref` remains for hrefs a plugin's own markup emits. The one round-trip that
|
|
||||||
cannot carry it is the Kratos sign-in POST (absolute off-site URL).
|
|
||||||
- **`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`,
|
|
||||||
`localeHref`, `localeParam`, `localeSwitch`, `dir`) are likewise reserved, merged after a handler's
|
|
||||||
`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`
|
|
||||||
— 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
|
|
||||||
`en-US` per key), never one the host lacks.
|
|
||||||
- **`locales/` at the repo root is a drop-in mount**, like `plugins/` and `config/` — `locales/<tag>.ts`
|
|
||||||
for the core, `locales/plugins/<id>/<tag>.ts` for a plugin; a new tag adds a language, an existing
|
|
||||||
one replaces that catalog wholesale. Adding a language must never require forking the image. The
|
|
||||||
SHIPPED `en-US` stays the parity baseline even when the mount replaces it, so a mounted catalog is
|
|
||||||
checked rather than trusted (one compared only against itself would boot green with the whole UI
|
|
||||||
rendering keys).
|
|
||||||
- **The language picker is on every page, POST-rendered ones included.** A POST-rendered URL often
|
|
||||||
answers no GET (`POST /admin/users/:id/recovery`), so the host resolves the picker's target
|
|
||||||
(`app.ts` → `switchBase`): this path when it answers GET, else the same-origin Referer, else `/`.
|
|
||||||
Accepted cost: switching language there leaves that POST's own result behind. Valid while the picker
|
|
||||||
is expected on literally every page — if that softens, hiding it after a POST is simpler.
|
|
||||||
- **An unknown translation key renders as itself.** That single rule lets a nav label, branding, or a
|
|
||||||
menu `rename` be either a key or plain text without a second field or a migration. Don't "fix" it
|
|
||||||
into a loud failure: a manifest with plain labels must keep working.
|
|
||||||
- **`t()` returns raw text; the view escapes it.** Messages go through `<%= %>` like any other value;
|
|
||||||
one carrying markup uses `<%- %>`, and then its `{{vars}}` are escaped at the call site. Don't move
|
|
||||||
escaping into `t()` — every other value in a view would become the odd one out.
|
|
||||||
- **RTL is out of scope until there is a real use case.** `textDirection` sets `<html dir>` because
|
|
||||||
that is free and correct, but the stylesheet keeps physical `left`/`right` properties; a genuine RTL
|
|
||||||
locale needs those moved to logical ones first. Valid while no deployment needs an RTL language.
|
|
||||||
|
|
||||||
### UI
|
### UI
|
||||||
|
|
||||||
- **A dropdown is a `<button popovertarget>` + `[popover]`, never a `<details>`.** The browser then
|
- A dropdown is a `<button popovertarget>` and a `[popover]`, never a `<details>`
|
||||||
owns open/close — the only zero-JS way to dismiss by clicking outside — and the panel sits in the
|
- The document is the one scroller
|
||||||
top layer, so a row kebab is not clipped by `.table-wrap`'s `overflow`. Four rules hold it
|
- `ICON_NAMES` is a host-owned registry, not a plugin contract
|
||||||
together: the panel carries **`position-anchor: auto`** (a bare `anchor()` resolves to nothing in
|
|
||||||
all three engines); it stays the trigger's **next sibling inside the `.menu` wrapper**, which the
|
|
||||||
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 —
|
|
||||||
nondeterministic HTML forecloses the caching decision); and **neither `aria-expanded` nor
|
|
||||||
`aria-haspopup` is written**: every engine maintains the first itself on a declarative
|
|
||||||
`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
|
|
||||||
profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
|
|
||||||
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
|
|
||||||
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
|
|
||||||
unknown sprite id renders blank instead of failing loud (the `every icon <use> resolves` e2e test
|
|
||||||
catches anything reaching the nav).
|
|
||||||
|
|
||||||
### Build, test & release
|
### Build, test & release
|
||||||
|
|
||||||
- **Deps install to `/node_modules`, above `WORKDIR /app`** — Node resolves upward, so dev's `.:/app`
|
- Dependencies install to `/node_modules`, above `WORKDIR /app`
|
||||||
bind mount has nothing to shadow. Not a volume at `/app/node_modules`: the daemon creates a mount
|
- A container whose output a human then edits or deletes runs as the invoking user
|
||||||
destination as root whatever `--user` says, leaving a root-owned dir in the checkout. Nothing may
|
- Anything the browser logs fails the E2E test that provoked it
|
||||||
sit at that path now — it shadows `/node_modules` silently (`src/compose.test.ts` guards the compose
|
- The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium
|
||||||
files, `.dockerignore` the image).
|
- The docs-only CI skip is `*.md` anywhere in the tree
|
||||||
- **A container whose output a human then edits or deletes runs as `--user "$(id -u):$(id -g)"`** —
|
- CI docker logins share the runner host's Docker config
|
||||||
the E2E runner (artifacts) and a lockfile edit, or the output is root-owned and needs `sudo`, which
|
|
||||||
a dev box may not have. Not universal: `bootstrap` writes `jwks.json` as root when it is absent on
|
|
||||||
first boot; the committed dev key makes that rare, and when it happens the rotation runbook's
|
|
||||||
host-side `>` needs the file re-owned first (valid while the dev key ships committed). Three
|
|
||||||
consequences: `e2e-tests/artifacts/` is *tracked* (`.gitkeep`), since an absent bind-mount source is
|
|
||||||
daemon-created as root and that uid then cannot write it (README → Upgrading); the runner image sets
|
|
||||||
`HOME=/tmp`, since an arbitrary uid has no passwd entry and would land on an unwritable `/`; and
|
|
||||||
rootless Docker wants the flag *dropped*, container root already being the invoking user. Baking a
|
|
||||||
`USER` in instead does not work — the image's `pwuser` is 1001 and no fixed uid matches every host.
|
|
||||||
`src/compose.test.ts` guards every documented command, `src/ci-gate.test.ts` the gate's own.
|
|
||||||
- **Anything the browser logs fails the E2E test that provoked it.** Every spec takes its `test` from
|
|
||||||
`e2e-tests/console-guard.ts`, which fails a test on a console error/warning or uncaught exception on
|
|
||||||
any page it opened. A zero-JS app has nothing to say in the console, so the bar is *zero* rather than
|
|
||||||
a curated tolerance list; the two exceptions are narrow — a module-level allowance for the COOP header
|
|
||||||
Chromium drops (the e2e stacks serve plain http over container hostnames), and `allowConsole(re)` for
|
|
||||||
a test whose own page provokes a message on purpose. `src/e2e-console-guard.test.ts` locks the wiring
|
|
||||||
in the *unit* gate, since a spec importing `test` straight from Playwright — or minting a page with
|
|
||||||
a raw `newPage()` instead of `watchedPage()` — would run unwatched and green. Accepted cost: a page
|
|
||||||
outliving its test can log late and fail the next one.
|
|
||||||
- **The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium.**
|
|
||||||
`visual.spec.ts` + `language.spec.ts` are side-effect-free, so parallel runs don't collide, and a
|
|
||||||
console message only appears in the engine that renders the page (`ORY_FREE` in
|
|
||||||
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
|
|
||||||
so widening them means a stack per engine.
|
|
||||||
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** Both git channels in
|
|
||||||
`ci.sh`'s `docs_only()` pass `--no-renames`: rename detection names only the destination, so
|
|
||||||
`git mv src/app.ts notes.md` would otherwise read as docs and skip the gate over a source file that
|
|
||||||
was gone. `src/ci-gate.test.ts` locks the flags as a
|
|
||||||
*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
|
|
||||||
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 tests read, and a README-only change skips them. Accepted:
|
|
||||||
the `apiVersion` samples `release-tooling/contract-version.test.ts` checks are illustrative, and
|
|
||||||
the copies that matter (`examples/`, `views/`, the template) are gated. A Hub page command the
|
|
||||||
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
|
|
||||||
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: concurrent jobs
|
|
||||||
can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that
|
|
||||||
file between login and logout. Same class: concurrent runs share the workspace dir, so ci.sh's
|
|
||||||
web-image build races another run's container creation on the `<project>-web` tag. Accepted for a
|
|
||||||
single-maintainer cadence; serialize with a workflow `concurrency` group if it ever bites.
|
|
||||||
|
|
||||||
## Docker only — no host tooling
|
## Docker only — no host tooling
|
||||||
|
|
||||||
@@ -434,7 +218,7 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
|
|||||||
`{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed `%`-encoding).
|
`{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed `%`-encoding).
|
||||||
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/auth-admin/`.
|
||||||
- **A function stays within 50 lines and complexity 20** (ESLint's defaults, measured by
|
- **A function stays within 50 lines and complexity 20** (ESLint's defaults, measured by
|
||||||
`src/function-limits.test.ts`). Split one that outgrows them; never add it to the test's
|
`src/function-limits.test.ts`). Split one that outgrows them; never add it to the test's
|
||||||
`grandfathered` list or raise a ceiling there.
|
`grandfathered` list or raise a ceiling there.
|
||||||
|
|||||||
+95
-6
@@ -6,6 +6,96 @@ at boot. Entries start at 0.3.0.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Plugin contract 0.5: set `apiVersion` to `"0.5.0"`
|
||||||
|
|
||||||
|
The host's contract version is now 0.5.0, so discovery refuses a plugin whose `apiVersion` is still
|
||||||
|
`0.4.x`. Change the literal in each plugin's manifest when you upgrade, and add `assetHref` to any
|
||||||
|
`RequestContext` your tests build by hand: `assetHref: (href) => href`.
|
||||||
|
|
||||||
|
### The core dashboard view is `dashboard`, not `index`
|
||||||
|
|
||||||
|
`views/index.ejs` is now `views/dashboard.ejs`. The `apiVersion` check can't see this: a plugin view
|
||||||
|
that still does `include("index")` fails when it renders, with the 500 page. Change it to
|
||||||
|
`include("dashboard")`.
|
||||||
|
|
||||||
|
### `KetoClient.transactTuples` applies inserts and deletes as one transaction
|
||||||
|
|
||||||
|
`transactTuples({ delete, insert })` sends both lists to Keto in one `PATCH /admin/relation-tuples`,
|
||||||
|
so all of them apply or none does. A `KetoClient` you build by hand, such as a test stub, must add the
|
||||||
|
method. The admin example saves a user's or group's permissions through it, so a Keto failure leaves
|
||||||
|
every grant as it was and answers with the error page.
|
||||||
|
|
||||||
|
### The admin example deletes a group's grants and members in one Keto transaction
|
||||||
|
|
||||||
|
A Keto failure while deleting a group now leaves the group as it was, with its grants and members,
|
||||||
|
and answers with the error page. It used to leave some grants gone and the members still listed.
|
||||||
|
|
||||||
|
### The admin example keeps a user's permissions section when it refuses a self-deactivate or self-delete
|
||||||
|
|
||||||
|
The refusal page used to drop the permissions section of the user form.
|
||||||
|
|
||||||
|
### The admin example lists a user's or group's grants that no installed plugin declares, with a Remove
|
||||||
|
|
||||||
|
A direct grant of a name no installed plugin declares, such as one left by an uninstalled plugin,
|
||||||
|
used to stay invisible and unchangeable, and reinstalling the plugin silently reactivated it. The
|
||||||
|
user and group pages now list these apart from the checkboxes, each with a Remove button (hidden from
|
||||||
|
a `:read`-only holder). The example's catalogs gain `admin.grants.remove`, `admin.grants.removed`,
|
||||||
|
`admin.grants.undeclaredHint` and `admin.grants.undeclaredLegend`, and its routes gain
|
||||||
|
`POST /auth-admin/users/:id/permissions/remove` and `POST /auth-admin/groups/:name/permissions/remove`.
|
||||||
|
|
||||||
|
### An operator can hide or rename a plugin's profile-menu entry
|
||||||
|
|
||||||
|
A `profileMenu` entry takes an optional `id`, and `config/menu.ts`'s `override.hide` and
|
||||||
|
`override.rename` apply to it as they do to a nav node. Nav nodes and profile-menu entries share one
|
||||||
|
set of ids, so the boot refuses an entry whose `id` another entry or nav node already uses.
|
||||||
|
|
||||||
|
### The admin example plugin is now `auth-admin`, and its screens live at `/auth-admin/*`
|
||||||
|
|
||||||
|
The Users, Groups, OAuth2-clients and Plugin-settings screens move from `/admin/*` to
|
||||||
|
`/auth-admin/*`, and nothing redirects the old paths, so update any proxy rule, bookmark, alert or
|
||||||
|
log query that matches `/admin/`. An installed `plugins/admin/` still declares `apiVersion` 0.4, so
|
||||||
|
the boot refuses it: delete it, copy `examples/plugins/auth-admin/` into `plugins/`, and move any
|
||||||
|
`locales/plugins/admin/` to `locales/plugins/auth-admin/`.
|
||||||
|
|
||||||
|
### The `request` log line renames `method`, `path`, `status` and `ms`, and gains the caller, site and route
|
||||||
|
|
||||||
|
Update dashboards and log queries: the `request` line and the request span now use OpenTelemetry's
|
||||||
|
HTTP names. `method`, `path` and `status` become `http.request.method`, `url.path` and
|
||||||
|
`http.response.status_code`. `ms` is gone: latency is the span's duration, so it needs
|
||||||
|
`OTLP_ENDPOINT`. New are `http.route` (when a route matched), `server.address` (the `Host`),
|
||||||
|
`user_agent.original` and `client.address`. A plugin's own lines for a request carry them too,
|
||||||
|
except the status.
|
||||||
|
|
||||||
|
`client.address` is the socket's peer. Set `TRUSTED_PROXIES` (comma-separated IPs or CIDR ranges) to
|
||||||
|
the proxies in front of the host and it follows `X-Forwarded-For`; a bad entry refuses the boot.
|
||||||
|
README → Observability says which ranges to list. The log now holds callers' IPs and user agents,
|
||||||
|
which are personal data: say so in your privacy policy.
|
||||||
|
|
||||||
|
### `buildPaginationModel` builds the pagination partial's model
|
||||||
|
|
||||||
|
Call `buildPaginationModel(page, hrefFor, { label?, pageSizes?, pageParam?, pageSizeParam? })` from
|
||||||
|
`@plainpages/plugin-api` in place of mapping `paginate()`'s result to `partials/pagination` by hand.
|
||||||
|
`hrefFor(n)` is your list's URL on page n, and the rows-per-page form carries the same list state as
|
||||||
|
the links. A list that renamed its params in `parseListQuery` passes the same names here.
|
||||||
|
|
||||||
|
### A plugin can add links to the profile menu
|
||||||
|
|
||||||
|
Declare `profileMenu: [{ href, label, icon?, …gate }]` in the manifest. Each entry names exactly one
|
||||||
|
gate like a nav node, and shows above Sign out to a signed-in visitor who passes it. Discovery
|
||||||
|
refuses an entry without an `href`, a `label`, or exactly one gate.
|
||||||
|
|
||||||
|
### A plugin can version its own `/public/` files
|
||||||
|
|
||||||
|
Wrap the href of a script, image or other file your own markup links in `assetHref("/public/<id>/…")` in a view,
|
||||||
|
or `ctx.assetHref(…)` in a handler. The href gets the file's content hash, and the browser caches the
|
||||||
|
file for a year instead of revalidating it every five minutes.
|
||||||
|
|
||||||
|
### Discovery warns when a gate names a permission no plugin declares
|
||||||
|
|
||||||
|
A route or nav node that gates on a permission no installed plugin declares now logs a boot warning
|
||||||
|
naming the plugin and the permission. The admin screens offer only declared permissions, so nobody can
|
||||||
|
be granted such a gate from them. Add the name to the plugin's `permissions` to silence the warning.
|
||||||
|
|
||||||
### Discovery refuses a plugin with the id `error`
|
### Discovery refuses a plugin with the id `error`
|
||||||
|
|
||||||
`/error` is the page Kratos sends a failed self-service flow to, and a plugin named `error` would
|
`/error` is the page Kratos sends a failed self-service flow to, and a plugin named `error` would
|
||||||
@@ -93,20 +183,19 @@ Two administrators editing one user's or group's permissions no longer overwrite
|
|||||||
form carries a token for the grants it showed. A save that would change something, made after those
|
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
|
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
|
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
|
`/auth-admin/users/:id/permissions` or `/auth-admin/groups/:name/permissions` must first read the token from the
|
||||||
page's `baseline` field.
|
page's `baseline` field.
|
||||||
|
|
||||||
### The admin example confirms a permission save and reports a partial one
|
### 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
|
A save now lands on the page with "Permissions saved." On a user, a changed grant reaches the user's
|
||||||
page says the save stopped part-way instead of showing the generic error page. On a user, the
|
live sessions at once when the revocation denylist is on.
|
||||||
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 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
|
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
|
example's catalogs gain `admin.grants.saved`, `admin.grants.stale` and
|
||||||
`admin.users.view`, so a translated catalog mounted under `locales/plugins/admin/` must add them.
|
`admin.users.view`, so a translated catalog mounted under `locales/plugins/auth-admin/` must add them.
|
||||||
|
|
||||||
### Every page carries a smaller icon sprite
|
### Every page carries a smaller icon sprite
|
||||||
|
|
||||||
|
|||||||
@@ -28,17 +28,17 @@ docker compose up -d # http://localhost:3000, live-reloads on source chan
|
|||||||
**`admin@plainpages.local` / `admin`**.
|
**`admin@plainpages.local` / `admin`**.
|
||||||
|
|
||||||
**3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups
|
**3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups
|
||||||
/ OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/admin/*`:
|
/ OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/auth-admin/*`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp -r examples/plugins/admin plugins/admin
|
cp -r examples/plugins/auth-admin plugins/auth-admin
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
The bootstrap grants the seeded admin every permission the installed plugins declare, so the
|
The bootstrap grants the seeded admin every permission the installed plugins declare, so the
|
||||||
**Admin** section now shows in the menu. Use `up -d`, not `restart web` — the seed runs in the
|
**Admin** section now shows in the menu. Use `up -d`, not `restart web` — the seed runs in the
|
||||||
one-shot `bootstrap` service, and only `up` re-runs it. See
|
one-shot `bootstrap` service, and only `up` re-runs it. See
|
||||||
[`examples/plugins/admin/`](examples/plugins/admin/).
|
[`examples/plugins/auth-admin/`](examples/plugins/auth-admin/).
|
||||||
|
|
||||||
**4. Add your first plugin.** The clone is bind-mounted into the container, so a new
|
**4. Add your first plugin.** The clone is bind-mounted into the container, so a new
|
||||||
folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.ts`:
|
folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.ts`:
|
||||||
@@ -47,7 +47,7 @@ folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.
|
|||||||
import { definePlugin } from "@plainpages/plugin-api";
|
import { definePlugin } from "@plainpages/plugin-api";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0",
|
apiVersion: "0.5.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>" }) },
|
||||||
@@ -126,7 +126,6 @@ docker compose up -d
|
|||||||
- [end-to-end](#end-to-end-playwright)
|
- [end-to-end](#end-to-end-playwright)
|
||||||
- [the full gate](#the-full-gate-one-command)
|
- [the full gate](#the-full-gate-one-command)
|
||||||
- [CI/CD](#cicd)
|
- [CI/CD](#cicd)
|
||||||
- [one-time setup](#one-time-ci-setup)
|
|
||||||
- [Production & deployment](#production--deployment)
|
- [Production & deployment](#production--deployment)
|
||||||
- [Upgrading](#upgrading)
|
- [Upgrading](#upgrading)
|
||||||
- [Observability](#observability)
|
- [Observability](#observability)
|
||||||
@@ -201,11 +200,11 @@ public front with an authenticated area behind it.
|
|||||||
|
|
||||||
- **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design
|
- **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design
|
||||||
system + app shell, the config-driven menu, sessions, and access control. No domain screens.
|
system + app shell, the config-driven menu, sessions, and access control. No domain screens.
|
||||||
- **Opt-in admin plugin:** the **users, groups, and OAuth2-clients** screens ship as
|
- **Opt-in auth-admin plugin:** the **users, groups, and OAuth2-clients** screens ship as
|
||||||
[`examples/plugins/admin/`](examples/plugins/admin/) — an ordinary plugin, reaching Ory through
|
[`examples/plugins/auth-admin/`](examples/plugins/auth-admin/) — an ordinary plugin, reaching Ory through
|
||||||
the privileged [`ctx.system`](#system-capabilities-the-ctxsystem-surface) surface.
|
the privileged [`ctx.system`](#system-capabilities-the-ctxsystem-surface) surface.
|
||||||
- **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 auth-admin plugin uses.
|
||||||
|
|
||||||
Identity, sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar
|
Identity, sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar
|
||||||
services. The shape doesn't change as it grows: every plugin is the same self-contained folder,
|
services. The shape doesn't change as it grows: every plugin is the same self-contained folder,
|
||||||
@@ -322,9 +321,9 @@ Against the reference plugins' actual routes:
|
|||||||
| `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` |
|
||||||
| `GET /admin/users` | `users:read` | 403 | 403 | ✅ | → `/login` |
|
| `GET /auth-admin/users` | `users:read` | 403 | 403 | ✅ | → `/login` |
|
||||||
| `POST /admin/users` | `users:write` | 403 | 403 | ✅ | → `/login` |
|
| `POST /auth-admin/users` | `users:write` | 403 | 403 | ✅ | → `/login` |
|
||||||
| `GET /admin/groups` | `groups:read` | 403 | 403 | 403 | → `/login` |
|
| `GET /auth-admin/groups` | `groups:read` | 403 | 403 | 403 | → `/login` |
|
||||||
|
|
||||||
Bob reaches the shifts list with no direct grant — `support` → `staff` → `scheduling:read`, two hops
|
Bob reaches the shifts list with no direct grant — `support` → `staff` → `scheduling:read`, two hops
|
||||||
resolved by Keto at his login. An anonymous visitor gets a **redirect** carrying `return_to`, not a
|
resolved by Keto at his login. An anonymous visitor gets a **redirect** carrying `return_to`, not a
|
||||||
@@ -334,7 +333,7 @@ door they cannot open.
|
|||||||
|
|
||||||
### Granting a permission
|
### Granting a permission
|
||||||
|
|
||||||
Write the tuple. The admin plugin's **Users** and **Groups** screens do exactly this — each offers
|
Write the tuple. The auth-admin plugin's **Users** and **Groups** screens do exactly this — each offers
|
||||||
the declared permissions as a checkbox list — or use Keto's write API directly:
|
the declared permissions as a checkbox list — or use Keto's write API directly:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -419,7 +418,7 @@ URL/path-safe (`isValidPluginId`: lowercase `a–z`, digits, dashes) and also na
|
|||||||
`RESERVED_PLUGIN_IDS` are refused at discovery — the gated `dashboard`, the Kratos auth flows
|
`RESERVED_PLUGIN_IDS` are refused at discovery — the gated `dashboard`, the Kratos auth flows
|
||||||
(`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`) and their `error` page, the `oauth2`
|
(`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`) and their `error` page, the `oauth2`
|
||||||
provider routes, and `public` — since plugin routes resolve first and a folder claiming one would
|
provider routes, and `public` — since plugin routes resolve first and a folder claiming one would
|
||||||
silently shadow a built-in. `admin` is **not** reserved: the admin screens are themselves a plugin.
|
silently shadow a built-in.
|
||||||
|
|
||||||
Installing a plugin is "drop the folder, restart"; removing one is "delete the folder, restart".
|
Installing a plugin is "drop the folder, restart"; removing one is "delete the folder, restart".
|
||||||
|
|
||||||
@@ -437,7 +436,7 @@ import { definePlugin } from "@plainpages/plugin-api";
|
|||||||
import { listThings, createThings } from "./handlers.ts";
|
import { listThings, createThings } from "./handlers.ts";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0", // semver string of the host contract this plugin was built against (see Versioning)
|
apiVersion: "0.5.0", // semver string of the host contract this plugin was built against (see Versioning)
|
||||||
|
|
||||||
// Nav fragment, merged into the global menu and gate-filtered per user.
|
// Nav fragment, merged into the global menu and 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).
|
||||||
@@ -468,6 +467,7 @@ folder-derived `id` to produce the loaded `Plugin`.
|
|||||||
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
||||||
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Every node names [exactly one gate](#public-pages--menu-items). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. |
|
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). 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). |
|
||||||
|
| `profileMenu` | no | `ProfileMenuEntry[]`: `{ href, label, icon?, id?, …gate }` links added above Sign out in the profile dropdown, shown only to a signed-in visitor. Each names [exactly one gate](#public-pages--menu-items); `icon`, `id` and `label` follow the same rules as a `nav` node. |
|
||||||
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
||||||
| `hooks` | no | See [Hooks](#hooks). |
|
| `hooks` | no | See [Hooks](#hooks). |
|
||||||
| `settings` | no | Configuration this plugin accepts, one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, resolved and validated at boot and handed to `onBoot`. See [Plugin settings](#plugin-settings). |
|
| `settings` | no | Configuration this plugin accepts, one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, resolved and validated at boot and handed to `onBoot`. See [Plugin settings](#plugin-settings). |
|
||||||
@@ -521,8 +521,10 @@ export async function listThings(ctx: RequestContext) {
|
|||||||
`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
|
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
|
any cache in front of Plainpages keep the file for a year. Get the same for a script or image your
|
||||||
is kept for five minutes, then revalidated.
|
own markup links by wrapping its href: `<script src="<%= assetHref("/public/<id>/x.js") %>" defer>`
|
||||||
|
in a view, `ctx.assetHref(href)` in a handler. An unwrapped `/public/` URL 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
|
||||||
@@ -561,7 +563,7 @@ import { definePlugin } from "@plainpages/plugin-api";
|
|||||||
import { landing, board } from "./pages.ts";
|
import { landing, board } from "./pages.ts";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0",
|
apiVersion: "0.5.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
|
||||||
});
|
});
|
||||||
@@ -593,6 +595,7 @@ interface RequestContext {
|
|||||||
locale: string; // the locale being served, e.g. "sv-SE"
|
locale: string; // the locale being served, e.g. "sv-SE"
|
||||||
locales: string[]; // every installed locale, sorted
|
locales: string[]; // every installed locale, sorted
|
||||||
localeHref(href): string; // carry an explicitly chosen locale onto a link this page renders
|
localeHref(href): string; // carry an explicitly chosen locale onto a link this page renders
|
||||||
|
assetHref(href): string; // version a /public/ href so it is cached for a year; any other href unchanged
|
||||||
query: URLSearchParams; // alias of url.searchParams
|
query: URLSearchParams; // alias of url.searchParams
|
||||||
req: IncomingMessage;
|
req: IncomingMessage;
|
||||||
res: ServerResponse;
|
res: ServerResponse;
|
||||||
@@ -604,7 +607,7 @@ interface RequestContext {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**`ctx.chrome`** — `{ brand, csrfToken, nav, signInHref, theme, user }`. Hand each field to the
|
**`ctx.chrome`** — `{ brand, csrfToken, nav, signInHref, theme, user }` (`user.profileLinks` holds the profile-dropdown links). Hand each field to the
|
||||||
matching `partials/shell` local and a `view` result renders the **native app shell**, exactly as
|
matching `partials/shell` local and a `view` result renders the **native app shell**, exactly as
|
||||||
`examples/plugins/scheduling/views/overview.ejs` does; a field you omit falls back to its shell
|
`examples/plugins/scheduling/views/overview.ejs` does; a field you omit falls back to its shell
|
||||||
default rather than erroring. `chrome.nav` is the whole global menu — every installed plugin's
|
default rather than erroring. `chrome.nav` is the whole global menu — every installed plugin's
|
||||||
@@ -648,7 +651,7 @@ interface SystemCapabilities { // every field optional — present only
|
|||||||
|
|
||||||
`ctx.system` is **`undefined` unless the host wired at least one** of these. A system plugin treats
|
`ctx.system` is **`undefined` unless the host wired at least one** of these. A system plugin treats
|
||||||
every field as optional and **degrades when absent** — the host never fails a request over it. The
|
every field as optional and **degrades when absent** — the host never fails a request over it. The
|
||||||
**admin plugin** ([`examples/plugins/admin/`](examples/plugins/admin/)) is the reference consumer:
|
**auth-admin plugin** ([`examples/plugins/auth-admin/`](examples/plugins/auth-admin/)) is the reference consumer:
|
||||||
Users uses `kratosAdmin`, Groups and the permission pickers `keto`, OAuth2 clients `hydra`, and a
|
Users uses `kratosAdmin`, Groups and the permission pickers `keto`, OAuth2 clients `hydra`, and a
|
||||||
deactivate/delete or user permission-change calls `revoke` so the change lands before the JWT TTL;
|
deactivate/delete or user permission-change calls `revoke` so the change lands before the JWT TTL;
|
||||||
a missing capability renders a themed 503.
|
a missing capability renders a themed 503.
|
||||||
@@ -728,14 +731,15 @@ The host detects collisions across all discovered plugins with `findConflicts` a
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `id` | error | Two plugins share an `id` (folder name). Ids must be globally unique — they namespace the mount path, views/static, and the override target. |
|
| `id` | error | Two plugins share an `id` (folder name). Ids must be globally unique — they namespace the mount path, views/static, and the override target. |
|
||||||
| `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. |
|
| `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. |
|
||||||
| `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. |
|
| `nav-id` | error | A nav node or profile-menu entry `id` is used more than once — the central override targets ids, so they must be unique across both. |
|
||||||
|
| `setting` | error | Two settings resolve to the same `PLUGIN_SETTING_` variable name; rename a key or a plugin folder ([Plugin settings](#plugin-settings)). |
|
||||||
| `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). |
|
| `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). |
|
||||||
| `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; pick a more specific [`<resource>`](#naming-a-permission) if unintended. |
|
| `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; pick a more specific [`<resource>`](#naming-a-permission) if unintended. Also warned: a route, nav node or profile-menu entry gates on a permission no installed plugin declares, so the admin screens can't grant it; declare it in that plugin's `permissions`. |
|
||||||
|
|
||||||
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`/`permissions`/`profileMenu`/`routes`, a non-function
|
||||||
`home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a
|
`home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a
|
||||||
route/nav node that does not name [exactly one gate](#public-pages--menu-items).
|
route, nav node or profile-menu entry that does not name [exactly one gate](#public-pages--menu-items).
|
||||||
|
|
||||||
### Hooks
|
### Hooks
|
||||||
|
|
||||||
@@ -850,7 +854,7 @@ camel humps both becoming underscores — so `upstream` on the `scheduling` plug
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0",
|
apiVersion: "0.5.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 },
|
||||||
@@ -887,7 +891,7 @@ installed plugin declares is only *reported* — it is usually a typo in the one
|
|||||||
set, and naming it turns two unrelated-looking errors into one. Declaring settings without an
|
set, and naming it turns two unrelated-looking errors into one. Declaring settings without an
|
||||||
`onBoot` warns too: they resolve, but nothing receives them.
|
`onBoot` warns too: they resolve, but nothing receives them.
|
||||||
|
|
||||||
**Reading what a deployment is configured with.** The admin plugin's **Plugin settings** screen
|
**Reading what a deployment is configured with.** The auth-admin plugin's **Plugin settings** screen
|
||||||
(`plugin-settings:read`) lists every installed plugin, its declared keys, the variable that sets
|
(`plugin-settings:read`) lists every installed plugin, its declared keys, the variable that sets
|
||||||
each, and whether the value came from the environment or the declared default.
|
each, and whether the value came from the environment or the declared default.
|
||||||
|
|
||||||
@@ -904,7 +908,7 @@ import { definePlugin } from "@plainpages/plugin-api";
|
|||||||
let sql: ReturnType<typeof postgres>;
|
let sql: ReturnType<typeof postgres>;
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0",
|
apiVersion: "0.5.0",
|
||||||
storage: true,
|
storage: true,
|
||||||
hooks: {
|
hooks: {
|
||||||
onBoot: async (boot) => {
|
onBoot: async (boot) => {
|
||||||
@@ -999,9 +1003,9 @@ The menu is **driven entirely by config** and assembled from two sources:
|
|||||||
|
|
||||||
1. **Plugin fragments** — each plugin contributes its own `nav` (above).
|
1. **Plugin fragments** — each plugin contributes its own `nav` (above).
|
||||||
2. **A central override** — `config/menu.ts` (loaded by `src/ui/menu-config.ts`, validated at
|
2. **A central override** — `config/menu.ts` (loaded by `src/ui/menu-config.ts`, validated at
|
||||||
boot) — where the operator reorders, renames, groups, or hides items (by node `id`), and
|
boot) — where the operator reorders, renames, groups or hides nav nodes and renames or hides
|
||||||
sets branding (app name, logo, default theme). The override always wins, applied before
|
profile-menu entries, each by its `id`, and sets branding (app name, logo, default theme). The
|
||||||
the per-user filter. A clean clone needs no `config/menu.ts`; defaults apply.
|
override always wins, applied before the per-user filter. A clean clone needs no `config/menu.ts`; defaults apply.
|
||||||
|
|
||||||
`config/` is an **empty drop-in mount point** (like `plugins/`): it ships empty, and you
|
`config/` is an **empty drop-in mount point** (like `plugins/`): it ships empty, and you
|
||||||
supply `config/menu.ts` by copying the template ([`examples/config/menu.ts`](examples/config/menu.ts))
|
supply `config/menu.ts` by copying the template ([`examples/config/menu.ts`](examples/config/menu.ts))
|
||||||
@@ -1039,10 +1043,26 @@ fully styled and zero-JS:
|
|||||||
- **Partials:** app shell, nav tree, filter bar, data table (sort / select / row actions),
|
- **Partials:** app shell, nav tree, filter bar, data table (sort / select / row actions),
|
||||||
pagination, form fields, badges, menus, auth cards.
|
pagination, form fields, badges, menus, auth cards.
|
||||||
- **Helpers:** `composeNav` (menu from config), `parseListQuery`
|
- **Helpers:** `composeNav` (menu from config), `parseListQuery`
|
||||||
(`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math), and the
|
(`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math),
|
||||||
|
`buildPaginationModel` (page math → the pagination partial, sketched below), and the
|
||||||
auth guards (`src/auth/guards.ts`): `requireSession`, `can(permission)` (coarse JWT-claim check,
|
auth guards (`src/auth/guards.ts`): `requireSession`, `can(permission)` (coarse JWT-claim check,
|
||||||
zero I/O), `check(relation, object)` (the one live Keto call).
|
zero I/O), `check(relation, object)` (the one live Keto call).
|
||||||
|
|
||||||
|
A list page's pagination, from URL to partial (`examples/plugins/auth-admin/admin-users.ts` is the full
|
||||||
|
page):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const query = parseListQuery(ctx.url); // { q, filters, sort, page, pageSize }
|
||||||
|
const page = paginate(rows.length, query.page, query.pageSize); // or an upstream's total
|
||||||
|
const visible = rows.slice(page.from - 1, page.to);
|
||||||
|
const pagination = buildPaginationModel(page, (n) => listHref({ ...query, page: n }), { pageSizes: [25, 50, 100] });
|
||||||
|
// view: <%- include("partials/pagination", pagination) %>
|
||||||
|
```
|
||||||
|
|
||||||
|
`listHref` is yours: the list's URL with all its state. The rows-per-page form's hidden inputs are
|
||||||
|
read off it, so the form carries the same filters, search and sort as the page links. A list that
|
||||||
|
renamed its params in `parseListQuery` passes the same `pageParam`/`pageSizeParam` here.
|
||||||
|
|
||||||
## Interactivity: zero-JS spine
|
## Interactivity: zero-JS spine
|
||||||
|
|
||||||
The core and all building blocks **work with zero JavaScript** — theme switching and filtering are
|
The core and all building blocks **work with zero JavaScript** — theme switching and filtering are
|
||||||
@@ -1064,7 +1084,7 @@ stylesheet using a feature Chromium 87, Safari 15 or Firefox 115 lacks, unless a
|
|||||||
around it names that feature. It checks every stylesheet in `public/`, `examples/` and `plugins/`,
|
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
|
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
|
`@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.
|
browser without it skips the query; `src/test-tooling/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
|
**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.
|
choice is in the URL, and nothing typed is lost to one because nothing is typed until they are made.
|
||||||
@@ -1117,7 +1137,7 @@ catalog for it. There is **no locale cookie**: the URL is the only place a choic
|
|||||||
link is shareable and a page is what its address says it is. When the URL asked for a language, the
|
link is shareable and a page is what its address says it is. When the URL asked for a language, the
|
||||||
host carries `?locale=` onto every link *it* renders and `ctx.localeHref(href)` does the same for a
|
host carries `?locale=` onto every link *it* renders and `ctx.localeHref(href)` does the same for a
|
||||||
plugin's. The picker lists every installed locale and renders **on every page**. After a POST the
|
plugin's. The picker lists every installed locale and renders **on every page**. After a POST the
|
||||||
current URL may answer no GET (`POST /admin/users/:id/recovery` has no GET sibling), so the host
|
current URL may answer no GET (`POST /auth-admin/users/:id/recovery` has no GET sibling), so the host
|
||||||
points the picker at this path when it answers GET, else the page the form was submitted from, else
|
points the picker at this path when it answers GET, else the page the form was submitted from, else
|
||||||
`/` — switching language there leaves that POST's own result behind.
|
`/` — switching language there leaves that POST's own result behind.
|
||||||
|
|
||||||
@@ -1188,7 +1208,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`, and the host-internal `assetHref`. They are merged after your `data`, so a colliding key of yours is ignored rather than
|
`dir`, and `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
|
||||||
@@ -1219,6 +1239,7 @@ The app is **environment-agnostic**: no `NODE_ENV`, every behaviour its own expl
|
|||||||
| `SERVICE_NAME` | `plainpages` | OTLP `service.name` on every log + span — brand it as your own deployment |
|
| `SERVICE_NAME` | `plainpages` | OTLP `service.name` on every log + span — brand it as your own deployment |
|
||||||
| `OTLP_ENDPOINT` | _unset_ | OpenTelemetry Collector HTTP base URI; set ⇒ export logs + traces (unset ⇒ console only) |
|
| `OTLP_ENDPOINT` | _unset_ | OpenTelemetry Collector HTTP base URI; set ⇒ export logs + traces (unset ⇒ console only) |
|
||||||
| `OTLP_PROTOCOL` | `http/json` | OTLP wire format: `http/json` or `http/protobuf` |
|
| `OTLP_PROTOCOL` | `http/json` | OTLP wire format: `http/json` or `http/protobuf` |
|
||||||
|
| `TRUSTED_PROXIES` | _unset_ | comma-separated IPs/CIDR ranges of the reverse proxies in front of the host; only a request from one lets `X-Forwarded-For` name the caller in logs and spans ([Observability](#observability)) |
|
||||||
| `KRATOS_PUBLIC_URL` / `KRATOS_ADMIN_URL` | `http://kratos:4433` / `:4434` | identity (self-service / admin) |
|
| `KRATOS_PUBLIC_URL` / `KRATOS_ADMIN_URL` | `http://kratos:4433` / `:4434` | identity (self-service / admin) |
|
||||||
| `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | authorization check / write |
|
| `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | authorization check / write |
|
||||||
| `HYDRA_ADMIN_URL` | `http://hydra:4445` | OAuth2 provider admin API (login/consent handshake) |
|
| `HYDRA_ADMIN_URL` | `http://hydra:4445` | OAuth2 provider admin API (login/consent handshake) |
|
||||||
@@ -1404,7 +1425,7 @@ Nothing in the menu or first-party pages needs Hydra.
|
|||||||
post-logout redirect. The first-party `POST /logout` still owns ending the Kratos session and our
|
post-logout redirect. The first-party `POST /logout` still owns ending the Kratos session and our
|
||||||
JWT cookie.
|
JWT cookie.
|
||||||
|
|
||||||
Clients are registered from the admin plugin's **OAuth2 clients** screen (`/admin/clients`): Hydra
|
Clients are registered from the auth-admin plugin's **OAuth2 clients** screen (`/auth-admin/clients`): Hydra
|
||||||
shows the generated `client_secret` **once**, on the confirmation page. Confidential vs public
|
shows the generated `client_secret` **once**, on the confirmation page. Confidential vs public
|
||||||
(PKCE) and the first-party auto-consent flag are set at registration; writes go only to Hydra.
|
(PKCE) and the first-party auto-consent flag are set at registration; writes go only to Hydra.
|
||||||
|
|
||||||
@@ -1563,109 +1584,7 @@ gate runs them serially, one stack up/down per suite.
|
|||||||
|
|
||||||
## CI/CD
|
## CI/CD
|
||||||
|
|
||||||
Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs
|
[The pipeline, releases and CI setup](docs/ci-cd.md).
|
||||||
[`ci.sh`](#the-full-gate-one-command) — the exact gate you run locally:
|
|
||||||
|
|
||||||
| Workflow | Trigger | Does |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`, a no-op on a docs-only branch), then build + push the app image |
|
|
||||||
| `release.yml` | push of a `vX.Y.Z` tag, or manual | check the tag against `HOST_API_VERSION`, re-tag that commit's image as `X.Y.Z`, `X.Y`, `latest` (plus `X` once major ≥ 1), sync those tags to Docker Hub; a second job publishes the Hub overview, and runs alone on a manual trigger |
|
|
||||||
| `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags (pruning deleted ones) to the [GitHub mirror](https://github.com/larvit/plainpages) |
|
|
||||||
| `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head |
|
|
||||||
| `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut a release tag for what merged |
|
|
||||||
|
|
||||||
`main` is not re-tested on push — its commits are meant to arrive already green from a
|
|
||||||
gated branch, so the status check to gate a merge on is `CI / full-gate (push)`.
|
|
||||||
|
|
||||||
**Merge gate on `main`** (Gitea branch protection + repo merge settings, set via the API —
|
|
||||||
no repo files involved): direct pushes are blocked, changes land via PR only, the
|
|
||||||
`CI / full-gate (push)` status must be green (admins included), and the only merge style is
|
|
||||||
**fast-forward-only** — history stays linear and `main`'s head is the exact commit hash of
|
|
||||||
the merged branch, which is why the branch's push-triggered status carries over.
|
|
||||||
|
|
||||||
**Container images** — after a green gate, `ci.yml` builds the app image and pushes it as
|
|
||||||
`gitea.larvit.se/larvit/plainpages:<full commit hash>`. Because merges are fast-forward-only, the
|
|
||||||
image for any `main` commit already exists from that commit's branch gate — build once, promote by
|
|
||||||
re-tagging. The build runs inside the required gate, so a missing/expired token or a registry outage
|
|
||||||
blocks **all** merges until restored; use a non-expiring token or track its expiry. Hash tags
|
|
||||||
accumulate one image per gated push, so nightly `registry-cleanup.yml` prunes them
|
|
||||||
([`registry-cleanup/cleanup.ts`](registry-cleanup/cleanup.ts) defines what survives). Don't add a
|
|
||||||
pattern-based org cleanup rule for this package — its age/count heuristics can't see branch heads or
|
|
||||||
release tags and would delete images the workflow protects.
|
|
||||||
|
|
||||||
**Releases** — pushing a semver git tag (`git tag v1.2.3 && git push origin v1.2.3`) runs
|
|
||||||
`release.yml`, which pulls that commit's hash image and re-tags it `1.2.3`, `1.2`, `latest` and —
|
|
||||||
once the major reaches `1` — `1`; nothing is rebuilt, so the released image is byte-identical to the
|
|
||||||
gated one. While the major is `0` the bare-major tag is skipped, because a `0.x` minor is a contract
|
|
||||||
break and a moving `:0` would carry one. It fails loud if no
|
|
||||||
hash image exists — release tags must point at a commit that went through the gate. The same four
|
|
||||||
tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only.
|
|
||||||
|
|
||||||
The [contract check](#contract-versioning) guards the tag before anything is published, refusing one
|
|
||||||
whose `major.minor` disagrees with `HOST_API_VERSION` and naming the value to set.
|
|
||||||
|
|
||||||
**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
|
|
||||||
`{{VERSION}}` rendered to the release, so the Plainpages tag it tells adopters to pull cannot go
|
|
||||||
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.
|
|
||||||
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
|
|
||||||
workflow manually with an `overview_version` input to republish it without cutting a release. That
|
|
||||||
input goes through the same contract check as a tag: a non-semver value, or one whose `major.minor`
|
|
||||||
disagrees with the tree being published, is refused. It uses
|
|
||||||
the same `DOCKERHUB_TOKEN` the image push uses, which is why that token needs the **delete** scope.
|
|
||||||
|
|
||||||
**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is
|
|
||||||
read-only; after every merge `mirror.yml` force-pushes `main` and all tags, overwriting any drift.
|
|
||||||
Tags go with `--prune`, so deleting one here deletes it there on the next run, and a tag *created* on
|
|
||||||
GitHub is swept away — cut releases on Gitea, never on the mirror. Branches other than `main` match
|
|
||||||
no refspec and are left alone.
|
|
||||||
|
|
||||||
**Dependency updates** — `renovate.yml` runs [Renovate](https://docs.renovatebot.com) nightly
|
|
||||||
(self-hosted, this repo only) against [`renovate.json`](renovate.json), bumping npm deps, Docker base
|
|
||||||
images, Gitea action versions, and the image tags pinned inside workflow `run:` steps (a custom regex
|
|
||||||
manager, so nothing pinned drifts unmanaged). Version-locked sets move together in one PR — the Ory
|
|
||||||
images, and the Playwright runner + its browser image — and every bump keeps the **exact semver pin**
|
|
||||||
exact. Each PR runs the normal gate on its `renovate/*` branch and automerges once
|
|
||||||
`CI / full-gate (push)` is green; only a red gate needs a human.
|
|
||||||
|
|
||||||
**Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`) cuts **one**
|
|
||||||
`vX.Y.Z` tag per run covering the renovate-bot commits merged to `main` since the last tag, and
|
|
||||||
**skips** when the tip isn't a Renovate commit, nothing new merged, or nothing that merged carried a
|
|
||||||
trailer — a dependency update that cannot reach the app releases nothing. Renovate stamps a
|
|
||||||
`Release-Bump: <updateType>` trailer onto the updates that reach a running Plainpages — the rules in
|
|
||||||
[`renovate.json`](renovate.json) name them — and
|
|
||||||
[`release-tooling/next-version.ts`](release-tooling/next-version.ts) turns the highest one into the next
|
|
||||||
version; pre-1.0 it never auto-crosses into `1.0.0`. Because the contract version *is* the release
|
|
||||||
version, an update big enough to reach a **minor** stops the job rather than tagging: bump
|
|
||||||
`HOST_API_VERSION` in a PR, merge, then tag by hand. Pre-1.0 that covers a dependency *major*, since
|
|
||||||
`nextVersion` shifts it down to a `0.x` minor. `updateType` rates the *dependency's* own jump,
|
|
||||||
so the trailer is an allowlist: an update outside those rules carries none and rides the next patch
|
|
||||||
release instead of escalating it. It is **tag-only**: the tag hands off to
|
|
||||||
`release.yml`, and is pushed with renovate-bot's PAT so that workflow actually fires (a tag pushed by
|
|
||||||
the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never touched here.
|
|
||||||
|
|
||||||
### One-time CI setup
|
|
||||||
|
|
||||||
| Actions var / secret | Value |
|
|
||||||
| --- | --- |
|
|
||||||
| `DOCKER_REGISTRY_USER` (var) + `DOCKER_REGISTRY_TOKEN` (secret) | A Gitea account with package write in the `larvit` org, and its access token with `read:package` + `write:package`. Reused by `registry-cleanup.yml`. |
|
|
||||||
| `DOCKERHUB_USER` (var) + `DOCKERHUB_TOKEN` (secret) | The public `larvit/plainpages` Docker Hub repo, and a **read/write/delete** token **scoped to that repository** (an org access token, or one on a dedicated account — an account-wide PAT reaches every repo under it, and delete is destructive). Delete is what publishing the overview needs; pushing images alone would not. |
|
|
||||||
| `MIRROR_GITHUB_TOKEN` (secret) | A fine-grained PAT (Contents: read & write) for a GitHub machine account with write access to the mirror. Its `main` must not block force-pushes and must carry no tag protection, which would reject the prune. |
|
|
||||||
| `RENOVATE_TOKEN` (secret) | The shared `renovate@larvit.se` bot's Gitea PAT, with write access to this repo. |
|
|
||||||
| `RENOVATE_GITHUB_TOKEN` (secret) | A **scopeless** (read-only) github.com PAT, so Renovate's lookups of github.com-hosted deps run authenticated instead of tripping the anonymous 60-req/hour limit. |
|
|
||||||
|
|
||||||
Gitea rejects secret names prefixed `GITEA_`/`GITHUB_`. Each job fails loud until its secret exists.
|
|
||||||
The container package is **org-owned**, so it lists under `larvit/-/packages` — link it to the repo's
|
|
||||||
Packages tab once: `POST /api/v1/packages/larvit/container/plainpages/-/link/plainpages`.
|
|
||||||
|
|
||||||
**The runner** — register one
|
|
||||||
[act_runner](https://docs.gitea.com/usage/actions/act-runner) in host mode labelled `docker-host`
|
|
||||||
(`labels: ["docker-host:host"]`) on a machine with Docker Engine + Compose, git, and Node +
|
|
||||||
github.com access. Runs must **never overlap** — the e2e stacks use fixed compose project names, the
|
|
||||||
devstack suite uses host networking, and the workflows share the Docker daemon's registry login — so
|
|
||||||
keep exactly one runner at capacity 1, with host ports 3000/4433 free.
|
|
||||||
|
|
||||||
## Production & deployment
|
## Production & deployment
|
||||||
|
|
||||||
@@ -1716,7 +1635,7 @@ tightened a manifest rule since. Discovery fails loud at boot rather than runnin
|
|||||||
honour, naming the plugin and the rule:
|
honour, naming the plugin and the rule:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
rm -rf plugins/admin && cp -r examples/plugins/admin plugins/admin
|
rm -rf plugins/auth-admin && cp -r examples/plugins/auth-admin plugins/auth-admin
|
||||||
docker compose up -d --build
|
docker compose up -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1743,9 +1662,32 @@ Logging is **structured** and **OTLP-native**, on
|
|||||||
handler (an `AsyncLocalStorage`), so logs and traces correlate. Three explicit toggles:
|
handler (an `AsyncLocalStorage`), so logs and traces correlate. Three explicit toggles:
|
||||||
`LOG_LEVEL`, `LOG_FORMAT` (`text` dev / `json` prod) and `SERVICE_NAME`.
|
`LOG_LEVEL`, `LOG_FORMAT` (`text` dev / `json` prod) and `SERVICE_NAME`.
|
||||||
|
|
||||||
Every request emits one access line (`method`, `path` — the query is dropped, it can carry tokens —
|
Every request emits one access line, and its span carries the same fields, named by
|
||||||
`status`, `ms`, `requestId`); login/logout, admin writes and missing-permission/CSRF rejections log
|
OpenTelemetry's HTTP conventions:
|
||||||
at `info`/`warn`, the catch-all 500 and Ory-unreachable re-mint at `error`/`warn`. An inbound W3C
|
|
||||||
|
- `http.request.method`, `url.path` (the query is dropped, it can carry tokens),
|
||||||
|
`http.response.status_code`, `server.address` (the `Host`), `user_agent.original`,
|
||||||
|
`client.address` and `requestId`.
|
||||||
|
- `http.route`: the matched pattern, such as `/auth-admin/users/:id`. It is absent when no route
|
||||||
|
matched (a 404, a 405, a `/public/` file, a redirect) and on a plugin's `onRequest` short-circuit.
|
||||||
|
- Latency is the span's duration, so it reaches you only with `OTLP_ENDPOINT` set. OTLP carries every
|
||||||
|
attribute as a string.
|
||||||
|
|
||||||
|
A plugin's own lines for a request carry them too, except the status, which is known only when
|
||||||
|
the response closes.
|
||||||
|
|
||||||
|
**These are personal data.** An IP and a user agent identify a caller, so a deployment's privacy
|
||||||
|
policy must say the host logs them.
|
||||||
|
|
||||||
|
`client.address` is the socket's peer. Behind a reverse proxy that is the proxy, so set
|
||||||
|
`TRUSTED_PROXIES` to its IPs or CIDR ranges: for a request from a listed peer, the address is the
|
||||||
|
nearest one in `X-Forwarded-For` that is not itself listed. Anything else in the header is
|
||||||
|
ignored. List only addresses a caller cannot send from. In Docker, `compose.yml` publishes the port
|
||||||
|
on every interface, so a range that includes the bridge gateway lets a direct caller write any
|
||||||
|
`client.address`.
|
||||||
|
|
||||||
|
Login/logout, admin writes and missing-permission/CSRF rejections log at `info`/`warn`, the
|
||||||
|
catch-all 500 and Ory-unreachable re-mint at `error`/`warn`. An inbound W3C
|
||||||
`traceparent` is **adopted**, continuing a trace started upstream.
|
`traceparent` is **adopted**, continuing a trace started upstream.
|
||||||
|
|
||||||
**Distributed tracing covers every outbound call.** Because the request logger is ambient, all
|
**Distributed tracing covers every outbound call.** Because the request logger is ambient, all
|
||||||
@@ -1830,8 +1772,8 @@ 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 Guards with no source counterpart (compose/kratos/keto/hydra/postgres/ci-gate/function-limits)
|
*.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-route, context, body, cookie,
|
||||||
static, safe-url (safeUrl + localPath), security-headers
|
static, safe-url (safeUrl + localPath), security-headers, request-fields · request-tracking (span/log attributes)
|
||||||
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) ·
|
||||||
login · guards · csrf · denylist · flow-view · oauth-login · oauth-consent ·
|
login · guards · csrf · denylist · flow-view · oauth-login · oauth-consent ·
|
||||||
routes · bootstrap, and the Ory REST clients (kratos-public/admin, keto, hydra,
|
routes · bootstrap, and the Ory REST clients (kratos-public/admin, keto, hydra,
|
||||||
@@ -1839,13 +1781,15 @@ src/ The app — strict tsc, no build step. *.test.ts sit beside
|
|||||||
i18n/ catalog (parity rules) · locale (resolution) · translate · load · runtime ·
|
i18n/ catalog (parity rules) · locale (resolution) · translate · load · runtime ·
|
||||||
english · view-locals · locales/ (the core en-US + sv-SE catalogs)
|
english · view-locals · locales/ (the core en-US + sv-SE catalogs)
|
||||||
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 · conflicts · 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 (`ctx.chrome`: menu, brand, user) · nav (composeNav) · menu-config
|
ui/ chrome (`ctx.chrome`: menu, brand, user) · nav (composeNav) · menu-config
|
||||||
(`#menu-config`) · icons (lucide sprite builder) · list-query · paginate
|
(`#menu-config`) · icons (lucide sprite builder) · list-query · paginate
|
||||||
|
test-tooling/ Test-only: app-harness (starts `createApp` over fakes for the `src/http` tests) ·
|
||||||
|
stylesheet-floor (the browser-floor check)
|
||||||
|
|
||||||
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, dashboard, auth, oauth-consent, error, 403/404/500/503,
|
||||||
and partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card,
|
and partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card,
|
||||||
alert, menu/popover, theme switch, language picker, icon sprite). Domain screens live
|
alert, menu/popover, theme switch, language picker, icon sprite). Domain screens live
|
||||||
in plugins.
|
in plugins.
|
||||||
@@ -1855,7 +1799,7 @@ locales/ Drop-in mount: extra or replacement catalogs. Ships empty.
|
|||||||
plugins/ Drop-in mount: plugin folders, scanned at /app/plugins. Ships empty.
|
plugins/ Drop-in mount: plugin folders, scanned at /app/plugins. Ships empty.
|
||||||
ory/ Ory config — kratos/ (identity schema, oidc claims mapper, tokenizer + dev JWKS),
|
ory/ Ory config — kratos/ (identity schema, oidc claims mapper, tokenizer + dev JWKS),
|
||||||
keto/ (namespaces.keto.ts OPL), hydra/, postgres/init/
|
keto/ (namespaces.keto.ts OPL), hydra/, postgres/init/
|
||||||
examples/ Copy-in reference mirroring the mount dirs: plugins/scheduling/, plugins/admin/,
|
examples/ Copy-in reference mirroring the mount dirs: plugins/scheduling/, plugins/auth-admin/,
|
||||||
config/menu.ts, and shifts-upstream/ (the dev mock backend)
|
config/menu.ts, and shifts-upstream/ (the dev mock backend)
|
||||||
e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,auth,oauth,full,devstack}.yml;
|
e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,auth,oauth,full,devstack}.yml;
|
||||||
proxy.ts (same-origin gateway) and mock-oidc.ts back full-flow
|
proxy.ts (same-origin gateway) and mock-oidc.ts back full-flow
|
||||||
@@ -1864,7 +1808,7 @@ release-tooling/ Everything the release runs: next-version (the bump math),
|
|||||||
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
|
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 docs/ci-cd.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Extending the core
|
## Extending the core
|
||||||
|
|||||||
@@ -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.33.0
|
image: axllent/mailpit:v1.33.1
|
||||||
ports:
|
ports:
|
||||||
- "8025:8025"
|
- "8025:8025"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ services:
|
|||||||
PLUGIN_DB_URL: ${PLUGIN_DB_URL:-}
|
PLUGIN_DB_URL: ${PLUGIN_DB_URL:-}
|
||||||
REQUIRE_SECURE_SECRETS: "true"
|
REQUIRE_SECURE_SECRETS: "true"
|
||||||
SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure
|
SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure
|
||||||
|
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-} # IPs/CIDRs of your reverse proxy; empty ⇒ X-Forwarded-For is ignored
|
||||||
# Wait for the services the app talks to (kratos + keto + hydra for the OAuth2 login/
|
# Wait for the services the app talks to (kratos + keto + hydra for the OAuth2 login/
|
||||||
# consent handler) + the one-shot bootstrap (admin + JWKS seed). Postgres too: a plugin that
|
# consent handler) + the one-shot bootstrap (admin + JWKS seed). Postgres too: a plugin that
|
||||||
# declares `storage` opens its connection in onBoot, before the server listens.
|
# declares `storage` opens its connection in onBoot, before the server listens.
|
||||||
|
|||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
# CI/CD
|
||||||
|
|
||||||
|
Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs
|
||||||
|
[`ci.sh`](../README.md#the-full-gate-one-command) — the exact gate you run locally:
|
||||||
|
|
||||||
|
| Workflow | Trigger | Does |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`, a no-op on a docs-only branch), then build + push the app image |
|
||||||
|
| `release.yml` | push of a `vX.Y.Z` tag, or manual | check the tag against `HOST_API_VERSION`, re-tag that commit's image as `X.Y.Z`, `X.Y`, `latest` (plus `X` once major ≥ 1), sync those tags to Docker Hub; a second job publishes the Hub overview, and runs alone on a manual trigger |
|
||||||
|
| `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags (pruning deleted ones) to the [GitHub mirror](https://github.com/larvit/plainpages) |
|
||||||
|
| `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head |
|
||||||
|
| `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut a release tag for what merged |
|
||||||
|
|
||||||
|
`main` is not re-tested on push — its commits are meant to arrive already green from a
|
||||||
|
gated branch, so the status check to gate a merge on is `CI / full-gate (push)`.
|
||||||
|
|
||||||
|
**Merge gate on `main`** (Gitea branch protection + repo merge settings, set via the API —
|
||||||
|
no repo files involved): direct pushes are blocked, changes land via PR only, the
|
||||||
|
`CI / full-gate (push)` status must be green (admins included), and the only merge style is
|
||||||
|
**fast-forward-only** — history stays linear and `main`'s head is the exact commit hash of
|
||||||
|
the merged branch, which is why the branch's push-triggered status carries over.
|
||||||
|
|
||||||
|
**Container images** — after a green gate, `ci.yml` builds the app image and pushes it as
|
||||||
|
`gitea.larvit.se/larvit/plainpages:<full commit hash>`. Because merges are fast-forward-only, the
|
||||||
|
image for any `main` commit already exists from that commit's branch gate — build once, promote by
|
||||||
|
re-tagging. The build runs inside the required gate, so a missing/expired token or a registry outage
|
||||||
|
blocks **all** merges until restored; use a non-expiring token or track its expiry. Hash tags
|
||||||
|
accumulate one image per gated push, so nightly `registry-cleanup.yml` prunes them
|
||||||
|
([`registry-cleanup/select-versions.ts`](../registry-cleanup/select-versions.ts) defines what survives). Don't add a
|
||||||
|
pattern-based org cleanup rule for this package — its age/count heuristics can't see branch heads or
|
||||||
|
release tags and would delete images the workflow protects.
|
||||||
|
|
||||||
|
**Releases** — pushing a semver git tag (`git tag v1.2.3 && git push origin v1.2.3`) runs
|
||||||
|
`release.yml`, which pulls that commit's hash image and re-tags it `1.2.3`, `1.2`, `latest` and —
|
||||||
|
once the major reaches `1` — `1`; nothing is rebuilt, so the released image is byte-identical to the
|
||||||
|
gated one. While the major is `0` the bare-major tag is skipped, because a `0.x` minor is a contract
|
||||||
|
break and a moving `:0` would carry one. It fails loud if no
|
||||||
|
hash image exists — release tags must point at a commit that went through the gate. The same four
|
||||||
|
tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only.
|
||||||
|
|
||||||
|
The [contract check](../README.md#contract-versioning) guards the tag before anything is published, refusing one
|
||||||
|
whose `major.minor` disagrees with `HOST_API_VERSION` and naming the value to set.
|
||||||
|
|
||||||
|
**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
|
||||||
|
`{{VERSION}}` rendered to the release, so the Plainpages tag it tells adopters to pull cannot go
|
||||||
|
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.
|
||||||
|
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
|
||||||
|
workflow manually with an `overview_version` input to republish it without cutting a release. That
|
||||||
|
input goes through the same contract check as a tag: a non-semver value, or one whose `major.minor`
|
||||||
|
disagrees with the tree being published, is refused. It uses
|
||||||
|
the same `DOCKERHUB_TOKEN` the image push uses, which is why that token needs the **delete** scope.
|
||||||
|
|
||||||
|
**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is
|
||||||
|
read-only; after every merge `mirror.yml` force-pushes `main` and all tags, overwriting any drift.
|
||||||
|
Tags go with `--prune`, so deleting one here deletes it there on the next run, and a tag *created* on
|
||||||
|
GitHub is swept away — cut releases on Gitea, never on the mirror. Branches other than `main` match
|
||||||
|
no refspec and are left alone.
|
||||||
|
|
||||||
|
**Dependency updates** — `renovate.yml` runs [Renovate](https://docs.renovatebot.com) nightly
|
||||||
|
(self-hosted, this repo only) against [`renovate.json`](../renovate.json), bumping npm deps, Docker base
|
||||||
|
images, Gitea action versions, and the image tags pinned inside workflow `run:` steps (a custom regex
|
||||||
|
manager, so nothing pinned drifts unmanaged). Version-locked sets move together in one PR — the Ory
|
||||||
|
images, and the Playwright runner + its browser image — and every bump keeps the **exact semver pin**
|
||||||
|
exact. Each PR runs the normal gate on its `renovate/*` branch and automerges once
|
||||||
|
`CI / full-gate (push)` is green; only a red gate needs a human.
|
||||||
|
|
||||||
|
**Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`) cuts **one**
|
||||||
|
`vX.Y.Z` tag per run covering the renovate-bot commits merged to `main` since the last tag, and
|
||||||
|
**skips** when the tip isn't a Renovate commit, nothing new merged, or nothing that merged carried a
|
||||||
|
trailer — a dependency update that cannot reach the app releases nothing. Renovate stamps a
|
||||||
|
`Release-Bump: <updateType>` trailer onto the updates that reach a running Plainpages — the rules in
|
||||||
|
[`renovate.json`](../renovate.json) name them — and
|
||||||
|
[`release-tooling/next-version.ts`](../release-tooling/next-version.ts) turns the highest one into the next
|
||||||
|
version; pre-1.0 it never auto-crosses into `1.0.0`. Because the contract version *is* the release
|
||||||
|
version, an update big enough to reach a **minor** stops the job rather than tagging: bump
|
||||||
|
`HOST_API_VERSION` in a PR, merge, then tag by hand. Pre-1.0 that covers a dependency *major*, since
|
||||||
|
`nextVersion` shifts it down to a `0.x` minor. `updateType` rates the *dependency's* own jump,
|
||||||
|
so the trailer is an allowlist: an update outside those rules carries none and rides the next patch
|
||||||
|
release instead of escalating it. It is **tag-only**: the tag hands off to
|
||||||
|
`release.yml`, and is pushed with renovate-bot's PAT so that workflow actually fires (a tag pushed by
|
||||||
|
the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never touched here.
|
||||||
|
|
||||||
|
### One-time CI setup
|
||||||
|
|
||||||
|
| Actions var / secret | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| `DOCKER_REGISTRY_USER` (var) + `DOCKER_REGISTRY_TOKEN` (secret) | A Gitea account with package write in the `larvit` org, and its access token with `read:package` + `write:package`. Reused by `registry-cleanup.yml`. |
|
||||||
|
| `DOCKERHUB_USER` (var) + `DOCKERHUB_TOKEN` (secret) | The public `larvit/plainpages` Docker Hub repo, and a **read/write/delete** token **scoped to that repository** (an org access token, or one on a dedicated account — an account-wide PAT reaches every repo under it, and delete is destructive). Delete is what publishing the overview needs; pushing images alone would not. |
|
||||||
|
| `MIRROR_GITHUB_TOKEN` (secret) | A fine-grained PAT (Contents: read & write) for a GitHub machine account with write access to the mirror. Its `main` must not block force-pushes and must carry no tag protection, which would reject the prune. |
|
||||||
|
| `RENOVATE_TOKEN` (secret) | The shared `renovate@larvit.se` bot's Gitea PAT, with write access to this repo. |
|
||||||
|
| `RENOVATE_GITHUB_TOKEN` (secret) | A **scopeless** (read-only) github.com PAT, so Renovate's lookups of github.com-hosted deps run authenticated instead of tripping the anonymous 60-req/hour limit. |
|
||||||
|
|
||||||
|
Gitea rejects secret names prefixed `GITEA_`/`GITHUB_`. Each job fails loud until its secret exists.
|
||||||
|
The container package is **org-owned**, so it lists under `larvit/-/packages` — link it to the repo's
|
||||||
|
Packages tab once: `POST /api/v1/packages/larvit/container/plainpages/-/link/plainpages`.
|
||||||
|
|
||||||
|
**The runner** — register one
|
||||||
|
[act_runner](https://docs.gitea.com/usage/actions/act-runner) in host mode labelled `docker-host`
|
||||||
|
(`labels: ["docker-host:host"]`) on a machine with Docker Engine + Compose, git, and Node +
|
||||||
|
github.com access. Runs must **never overlap** — the e2e stacks use fixed compose project names, the
|
||||||
|
devstack suite uses host networking, and the workflows share the Docker daemon's registry login — so
|
||||||
|
keep exactly one runner at capacity 1, with host ports 3000/4433 free.
|
||||||
@@ -290,3 +290,220 @@ 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
|
- 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/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/`
|
- Shape, Navigation: `src/ui/stylesheet-floor.ts:15` `FLOOR`: the 3am rule's home is a test-only module under `ui/`
|
||||||
|
|
||||||
|
## 2026-11-08T13:22:47Z, PR #214 at 84368ea, against c2389f8
|
||||||
|
|
||||||
|
Ruling: better
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Mid A | better | same | better | better | better |
|
||||||
|
| Inherited architect | better | same | better | better | better |
|
||||||
|
|
||||||
|
Mid A, decided by:
|
||||||
|
|
||||||
|
- Navigation, Shape, Overall: `src/test-tooling/stylesheet-floor.ts:1` `floorViolations`: test-only modules no longer sit beside runtime code in `ui/` and `http/`, and `AGENTS.md:77` names the folder
|
||||||
|
- Self-sufficiency: `src/auth/login.ts:40` `readPermissions`: the comment now names `#granted`, which the code queries
|
||||||
|
- Shape: `src/test-tooling/stylesheet-floor.ts:122` `positive`: grouping and the per-group decision are separate named units
|
||||||
|
- Self-sufficiency: `src/test-tooling/stylesheet-floor.ts:10` file header: says why a custom property's value is checked for functions and units
|
||||||
|
- Self-sufficiency: `src/plugin-host/plugin.ts:234` `undeclaredGates`: its comment says why an undeclared gate matters
|
||||||
|
|
||||||
|
Inherited architect, decided by:
|
||||||
|
|
||||||
|
- Self-sufficiency, Shape, Overall: `src/auth/login.ts:40` `readPermissions`: the comment now names the relation the code queries
|
||||||
|
- Self-sufficiency: `src/auth/bootstrap.ts:31` `permissionTuple`: the contradicting comment is gone
|
||||||
|
- Navigation: `src/test-tooling/stylesheet-floor.ts:1` `floorViolations`: the folder is named in AGENTS.md and the README file map
|
||||||
|
- Shape: `src/test-tooling/stylesheet-floor.ts:122` `positive`: one loop split into `parenGroups`, `Group` and `keepGroup`
|
||||||
|
- Self-sufficiency: `src/auth/routes.ts:117` `codeFlowSubmit`: a comment says which `flowFailure` branch can fire there
|
||||||
|
|
||||||
|
## 2026-11-08T13:44:57Z, PR #215 at 3a3bd77, against 01f368d
|
||||||
|
|
||||||
|
Ruling: better
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Mid A | better | better | better | same | better |
|
||||||
|
| Inherited architect | better | better | better | better | better |
|
||||||
|
|
||||||
|
Mid A, decided by:
|
||||||
|
|
||||||
|
- Locality, Shape: `src/ui/paginate.ts:68` `buildPaginationModel`: the rows form's hidden inputs are read off hrefFor, so form and links can't drift, where each page used to list them by hand
|
||||||
|
- Shape, Navigation: `examples/plugins/admin/admin-users.ts:140` `buildUsersListModel`: three near-identical listPagination copies become one call each to a named helper
|
||||||
|
- Locality: `src/http/app.ts:271` `shared`: ctx and contextFor spread one option object instead of spelling the same list twice
|
||||||
|
- Shape: `src/ui/paginate.ts:1` header: names both steps truthfully, and PaginationModel types the partial's input
|
||||||
|
- Navigation: `src/http/context.ts:28` `RequestContext.assetHref`: versioning a plugin's asset is a documented contract member, not a host-internal local
|
||||||
|
|
||||||
|
Inherited architect, decided by:
|
||||||
|
|
||||||
|
- Shape, Locality, Navigation: `src/ui/paginate.ts:68` `buildPaginationModel`: one typed function in the module owning the page math replaces three hand-written copies in the example plugins
|
||||||
|
- Locality: `examples/plugins/admin/admin-users.ts:188` `listPagination`: the older version built the hidden inputs field by field, apart from listHref; the round trip through new URL is a little clever, paid for by its comment
|
||||||
|
- Locality, Shape: `src/http/app.ts:271` `shared`: the two buildContext calls differ only in the translator and params
|
||||||
|
- Self-sufficiency: `src/http/context.ts:28` `RequestContext.assetHref`: the contract field says on the spot what it does and how it differs from the view local
|
||||||
|
- Self-sufficiency: `README.md:1050` pagination sketch: URL to partial in five lines, with the param-renaming rule
|
||||||
|
|
||||||
|
## 2026-11-08T14:10:47Z, PR #216 at bb134a3, against 6ca63c6
|
||||||
|
|
||||||
|
Ruling: better
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Mid A | better | same | better | same | better |
|
||||||
|
| Inherited architect | same | better | better | same | better |
|
||||||
|
|
||||||
|
Mid A, decided by:
|
||||||
|
|
||||||
|
- Navigation, Shape: `src/http/request-tracking.ts:9` `trackRequest`: the per-request logger, span and access line now live in a named module instead of the tail of the 318-line `createApp`
|
||||||
|
- Navigation, Shape: `src/http/request-fields.ts:33` `clientAddress`: the caller-IP and X-Forwarded-For rule has its own pure function in a file named for it
|
||||||
|
|
||||||
|
Inherited architect, decided by:
|
||||||
|
|
||||||
|
- Locality, Shape: `src/http/request-tracking.ts:9` `trackRequest`: the exactly-once finalize and close listener are one named unit, changeable without reading `createApp`'s other state
|
||||||
|
- Locality, Shape: `src/http/request-fields.ts:33` `clientAddress`: pure function of peer, header and list, with the reasons for reading from the right beside it
|
||||||
|
|
||||||
|
## 2026-11-08T18:34:54Z, PR #220 at 1b53b01, against 14a07dd
|
||||||
|
|
||||||
|
Ruling: same
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Mid A | same | same | same | same | same |
|
||||||
|
| Inherited architect | same | same | worse | same | same |
|
||||||
|
|
||||||
|
Inherited architect, decided by:
|
||||||
|
|
||||||
|
- Shape: `views/partials/shell.ejs:74` `user.menu`: a third meaning of `menu` beside the sidebar switch at line 19 and the `.menu` popover class
|
||||||
|
- Shape: `src/plugin-host/plugin.ts:9` `import type { ProfileMenuEntry }`: a second contract type defined under `ui/`, with a type-only import cycle through `chrome.ts`
|
||||||
|
|
||||||
|
## 2026-11-08T19:50:52Z, PR #223 at f8a6119, against b7e9780
|
||||||
|
|
||||||
|
Ruling: better
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Mid A | better | better | better | same | better |
|
||||||
|
| Inherited architect | better | better | better | same | better |
|
||||||
|
|
||||||
|
Mid A, decided by:
|
||||||
|
|
||||||
|
- Navigation, Locality, Shape: `src/http/app.ts:183` `handleRequest`: a 20-line list of named stages replaces a 170-line closure
|
||||||
|
- Locality: `src/http/app.ts:213` `Host`: 19 fields passed to every stage, broader than any stage needs
|
||||||
|
- Shape, Navigation: `src/ui/chrome.ts:29` `ShellUser.profileLinks`: the name says what the field holds
|
||||||
|
- Navigation, Shape: `src/plugin-host/conflicts.ts:15` `findConflicts`: collision rules have their own file
|
||||||
|
|
||||||
|
Inherited architect, decided by:
|
||||||
|
|
||||||
|
- Locality, Shape, Navigation: `src/http/app.ts:183` `handleRequest`: named stages replace one 165-line closure
|
||||||
|
- Locality: `src/http/app.ts:213` `Host`: a 19-field bag wider than any stage needs
|
||||||
|
- Shape: `src/ui/chrome.ts:29` `ShellUser.profileLinks`: no longer collides with `MenuConfig` and the `menu` partial
|
||||||
|
- Navigation: `.gitea/workflows/release.yml:60` error pointer: names `docs/ci-cd.md`, one hop from the failure
|
||||||
|
|
||||||
|
## 2026-11-08T21:24:33Z, PR #228 at ed48c5a
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Junior A | 7.5 | 6 | 7 | 7 | 6.5 |
|
||||||
|
| Mid A | 7 | 6 | 6.5 | 6.5 | 6.5 |
|
||||||
|
| Maintainability senior | 7.5 | 6.5 | 7 | 6.5 | 7 |
|
||||||
|
| Inherited architect | 6 | 6 | 6.5 | 7 | 6.5 |
|
||||||
|
| Mean | 7.00 | 6.13 | 6.75 | 6.75 | 6.63 |
|
||||||
|
|
||||||
|
Junior A, hardest first:
|
||||||
|
|
||||||
|
- Locality, Overall: `src/http/app.ts:67` `createApp`: a closure bag whose order matters; `switchBase` uses `builtinRoutes` declared further down
|
||||||
|
- Locality, Overall: `src/http/app.ts:274` `prepareRequest`: builds the context, the factory and the memoized chrome together, and every page path must remember to set the CSRF cookie
|
||||||
|
- Locality, Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:285` `lostToSelf`: the self-lockout rule is spread over four call sites and no single place states it
|
||||||
|
- Shape, Overall: `examples/plugins/auth-admin/admin-grants.ts:99` `buildPermissionPicker`: checked, inherited, read-only and undeclared rows interact and need a truth table
|
||||||
|
- Self-sufficiency: `src/auth/routes.ts:45` `flowReturnTo`: locale and return-to inputs interact, and no table gives the result per input
|
||||||
|
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: four Kratos error branches that need Ory knowledge the repo doesn't hold
|
||||||
|
- Locality: `src/ui/chrome.ts:55` `buildPluginChrome`: the locale rule is put into effect in three places with no single owner
|
||||||
|
- Self-sufficiency: `src/plugin-host/router.ts:4` header: promises order-independent specificity, but ties fall to discovery order
|
||||||
|
|
||||||
|
Mid A, hardest first:
|
||||||
|
|
||||||
|
- Locality, Overall: `examples/plugins/auth-admin/admin-groups.ts:285` `lostToSelf`: needs Keto's transitive group resolution rebuilt by hand, and over-reports
|
||||||
|
- Shape, Locality: `examples/plugins/auth-admin/admin-groups.ts:435` `groupsDelete`: deletes grants one at a time, so it can stop part-way
|
||||||
|
- Shape, Overall: `examples/plugins/auth-admin/admin-grants.ts:99` `buildPermissionPicker`: four overlapping ideas; `checked` means direct or effective
|
||||||
|
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: Kratos status codes map to different outcomes and need outside knowledge
|
||||||
|
- Shape, Locality: `src/http/app.ts:183` `handleRequest`: the 19-field `Host` bag and the state threaded between stages
|
||||||
|
- Shape: `src/test-tooling/stylesheet-floor.ts:122` `positive`: a hand-rolled parser for `@supports` conditions
|
||||||
|
- Self-sufficiency: `src/plugin-host/settings.ts:35` `ValueOfDecl`: type-level inference with a `const` generic
|
||||||
|
- Self-sufficiency: `src/auth/jwt-middleware.ts:75` `verifyToken`: a denylisted token is thrown as expired so it gets re-minted
|
||||||
|
- Self-sufficiency: `examples/plugins/auth-admin/plugin.ts:22` `on`: each write-intent GET states its `"write"` override in the route table and in the handler
|
||||||
|
|
||||||
|
Maintainability senior, hardest first:
|
||||||
|
|
||||||
|
- Locality, Overall: `examples/plugins/auth-admin/admin-groups.ts:285` `lostToSelf`: the self-lockout guards rebuild Keto's transitive membership and no sentence states the invariant
|
||||||
|
- Shape, Overall: `src/http/app.ts:67` `createApp`: a long closure factory that is safe to read but not safe to change
|
||||||
|
- Shape, Self-sufficiency: `examples/plugins/auth-admin/admin-grants.ts:99` `buildPermissionPicker`: direct, effective and undeclared grant sets with a staleness token on top
|
||||||
|
- Self-sufficiency: `src/plugin-host/settings.ts:35` `ValueOfDecl`: type-level inference whose comments say what it does, not how
|
||||||
|
- Self-sufficiency: `src/auth/routes.ts:45` `flowFailure`: a matrix of Kratos statuses and flow states
|
||||||
|
- Locality: `src/auth/jwks.ts:68` `cachingJwks`: subtle cache state, and the denylist reuses the `expired` flag
|
||||||
|
- Shape: `src/test-tooling/stylesheet-floor.ts:122` `positive`: a hand-written parser for `@supports` conditions
|
||||||
|
- Locality: `src/auth/bootstrap.ts:86` `seedAdmin`: calls Kratos and Keto with raw fetch instead of the client modules
|
||||||
|
- Self-sufficiency: `examples/plugins/auth-admin/views/partials/group-detail-body.ejs:2` locals header: documents `identity` and `del`, omits `canWrite` and `permissions`
|
||||||
|
|
||||||
|
Inherited architect, would restructure first:
|
||||||
|
|
||||||
|
- Navigation, Overall: `src/auth/routes.ts:1` `src/auth`: 19 modules holding sessions, Ory clients, flows, the OAuth2 provider and one-shot CLIs
|
||||||
|
- Navigation: `src/auth/bootstrap.ts:1` `bootstrap.ts`: a one-shot process that also provisions plugin storage, placed under auth
|
||||||
|
- Navigation, Locality: `src/ui/chrome.ts:55` `buildPluginChrome`: builds the chrome for every page, the host's own included
|
||||||
|
- Navigation: `views/index.ejs:1` `index.ejs`: the built-in /dashboard, and `home.ejs` is "/", and neither filename says so
|
||||||
|
- Locality: `public/css/styles.css:326` `.btn-primary`: one visual fact spans the partial, the rule, the tokens and the floor policy in `src/test-tooling/`
|
||||||
|
- Shape, Locality: `examples/plugins/auth-admin/admin-groups.ts:113` `admin-groups.ts`: pure view models, Keto queries, lockout policy and handlers in one 463-line file
|
||||||
|
- Shape: `src/http/app.ts:213` `Host`: a 19-field bag that every cross-cutting feature edits
|
||||||
|
- Self-sufficiency: `src/plugin-host/system.ts:1` header: calls the built-in admin screens the reference consumer, but they are a drop-in plugin
|
||||||
|
|
||||||
|
## 2026-11-08T21:30:30Z, PR #228 rerun at 0727d15
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Junior A | 7.5 | 6 | 6.5 | 6.5 | 6.5 |
|
||||||
|
| Mid A | 7 | 6 | 6.5 | 7 | 6.5 |
|
||||||
|
| Maintainability senior | 7.5 | 6.5 | 7 | 7 | 7 |
|
||||||
|
| Inherited architect | 7 | 6.5 | 6 | 7.5 | 6.5 |
|
||||||
|
| Mean | 7.25 | 6.25 | 6.50 | 7.00 | 6.63 |
|
||||||
|
|
||||||
|
Junior A, hardest first:
|
||||||
|
|
||||||
|
- Locality, Shape, Overall: `src/http/app.ts:274` `prepareRequest`: a 19-field `Host` bag and a twice-memoized chrome threaded through one closure, and two return conventions (send and return null, or return a result)
|
||||||
|
- Locality, Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:285` `lostToSelf`: the self-lockout rule differs from the users side and nothing says whether that is deliberate
|
||||||
|
- Shape, Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:18` `LIST_FETCH_SIZE`: the comment says a Keto page but the call is Kratos `listIdentities`
|
||||||
|
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: branches on Kratos status and body text that need Ory knowledge
|
||||||
|
- Shape, Self-sufficiency: `src/auth/jwt-middleware.ts:75` `verifyToken`: a revoked token is reported as expired, with the effect in another file
|
||||||
|
- Locality: `src/i18n/load.ts:43` `loadI18n`: four catalog sources kept apart with a nested spread on one line
|
||||||
|
- Self-sufficiency: `src/server.ts:25` boot order: deleting `PLUGIN_DB_SECRET` is order-dependent and its reason sits in the decision log
|
||||||
|
- Self-sufficiency: `src/plugin-host/system.ts:1` header: calls the built-in admin screens the reference consumer
|
||||||
|
|
||||||
|
Mid A, hardest first:
|
||||||
|
|
||||||
|
- Locality, Shape, Overall: `src/http/app.ts:274` `prepareRequest`: the request order lives only in the sequence of a long closure, and `serveHome` skips `sendResult`
|
||||||
|
- Shape: `src/test-tooling/stylesheet-floor.ts:122` `positive`: a hand-written, regex-heavy reader of `@supports` conditions
|
||||||
|
- Locality: `src/auth/jwt-middleware.ts:75` `verifyToken`: revocation reported as expired works only through `resolveUser` in another file
|
||||||
|
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: Kratos statuses that need outside knowledge
|
||||||
|
- Locality: `src/auth/jwks.ts:68` `cachingJwks`: four interacting behaviours in one closure
|
||||||
|
- Shape: `src/i18n/load.ts:43` `loadI18n`: merges four catalog sources, with the chain order described in two files
|
||||||
|
- Locality: `src/i18n/locale.ts:81` `chosenLocale`: infers an explicit choice from the output of a URL builder
|
||||||
|
- Shape, Self-sufficiency: `examples/plugins/scheduling/plugin.ts:37` header: says "Roles" and `<id>:<action>` where the project has neither
|
||||||
|
- Self-sufficiency: `compose.yml:166` comment: says consent comes next, but `src/auth/oauth-consent.ts` exists
|
||||||
|
|
||||||
|
Maintainability senior, hardest first:
|
||||||
|
|
||||||
|
- Shape, Overall: `src/test-tooling/stylesheet-floor.ts:122` `positive`: a regex-driven mini-parser whose header states the rules but not how the helpers carry them
|
||||||
|
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: five branches that assume Kratos' error vocabulary
|
||||||
|
- Locality: `src/http/app.ts:274` `prepareRequest`: where the language picker points after a POST is spread over four units in three files
|
||||||
|
- Shape: `src/i18n/load.ts:43` `loadI18n`: three catalog sets, each with its own baseline, and a nested spread
|
||||||
|
- Locality: `src/auth/jwt-middleware.ts:84` `verifyToken`: the denylist travels as `expired`, so `resolveSession` can't show it
|
||||||
|
- Shape: `src/ui/chrome.ts:55` `buildPluginChrome`: labels are translated twice and `markCurrent` mutates the nodes
|
||||||
|
- Locality, Shape: `examples/plugins/auth-admin/admin-users.ts:348` `usersPermissions`: the stale-page, self-revoke and diff guards interact across two files, and `pickerOrNothing`'s promise is broken on the self-delete paths
|
||||||
|
- Shape: `src/plugin-host/router.ts:4` header: promises order-independent specificity, but ties fall to discovery order
|
||||||
|
|
||||||
|
Inherited architect, would restructure first:
|
||||||
|
|
||||||
|
- Shape, Navigation, Overall: `src/auth/bootstrap.ts:1` `src/auth`: 19 modules across the session, Kratos flows, OAuth2 and a second process entrypoint
|
||||||
|
- Navigation, Self-sufficiency: `src/auth/bootstrap.ts:1` header: lists the JWKS, admin and Keto steps but not the plugin storage step, and the file reads the environment outside `config.ts`
|
||||||
|
- Shape, Navigation: `views/index.ejs:1` `index.ejs`: the dashboard, with `home.ejs` as the landing page, and neither name says so
|
||||||
|
- Shape: `src/ui/chrome.ts:55` `buildPluginChrome`: builds the chrome for every page, not only plugin pages
|
||||||
|
- Locality, Navigation: `src/test-tooling/stylesheet-floor.ts:10` header: the custom-property footgun sits in test tooling instead of beside the tokens at `public/css/styles.css:9`
|
||||||
|
- Locality: `src/auth/bootstrap.ts:170` `provisionPluginStorage`: plugin storage lives in three places, across `storage.ts`, `storage-provisioning.ts` and `bootstrap.ts`
|
||||||
|
- Locality, Shape: `src/http/app.ts:183` `handleRequest`: every new manifest field or slot adds a stage here and a field to `Host`
|
||||||
|
- Self-sufficiency: `src/plugin-host/system.ts:1` header: calls the built-in admin screens the reference consumer
|
||||||
|
|||||||
@@ -0,0 +1,522 @@
|
|||||||
|
# Decisions
|
||||||
|
|
||||||
|
"Goal N" is README → Goals. "Code health" is the goal every project shares: the code is easy to
|
||||||
|
understand and change safely.
|
||||||
|
|
||||||
|
## `src/` is grouped by concern, and the core ships no domain screens
|
||||||
|
|
||||||
|
2026-06-24, the maintainer. Code health and goal 3. Valid while each concern folder stays one a
|
||||||
|
reader can name.
|
||||||
|
|
||||||
|
The folders are `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`, and `test-tooling/` (modules only
|
||||||
|
tests import), with `server.ts`, `config.ts`, `logger.ts` and the topology-guard `*.test.ts` at the
|
||||||
|
root. Tests are co-located. Add a new module to the folder owning its concern. Even the admin GUI
|
||||||
|
is a drop-in plugin (`examples/plugins/auth-admin/`).
|
||||||
|
|
||||||
|
## Plugins and config import the host only through the two barrels
|
||||||
|
|
||||||
|
2026-08-17, the maintainer. Goal 7. Valid while plugin authors typecheck against a host tree rather
|
||||||
|
than a published package.
|
||||||
|
|
||||||
|
`@plainpages/plugin-api` → `plugin-api/index.ts` → `src/plugin-host/plugin-api.ts`, and
|
||||||
|
`#menu-config` → `src/ui/menu-config.ts`; never a relative `../../src/*` path. These two barrels are
|
||||||
|
the whole contract surface, so don't "fix" either back to a relative path.
|
||||||
|
|
||||||
|
## The Ory client types are contract-visible through the barrel
|
||||||
|
|
||||||
|
2026-07-02, the maintainer. Goal 7. Valid while plugins get Ory admin clients through `ctx.system`.
|
||||||
|
|
||||||
|
`@plainpages/plugin-api` re-exports `KratosAdmin`, `KetoClient`, `HydraAdmin`, their DTOs and their
|
||||||
|
error classes. Changing those shapes needs a major `apiVersion` bump, not a free refactor.
|
||||||
|
|
||||||
|
## The barrel is a package, so a plugin folder may carry its own `package.json`
|
||||||
|
|
||||||
|
2026-08-17, the maintainer. Goal 7. Valid while plugins may depend on npm packages (README → Plugin
|
||||||
|
dependencies).
|
||||||
|
|
||||||
|
The Dockerfile links the barrel into `/node_modules`, above every plugin scope. Never let a copy
|
||||||
|
reach a plugin's own `node_modules`: two instances of the barrel break `instanceof` across the
|
||||||
|
boundary. `plugin-api.test.ts` guards this by asserting both paths reach one module.
|
||||||
|
|
||||||
|
## Plugin storage hands over credentials, not a client
|
||||||
|
|
||||||
|
2026-08-18, the maintainer. Goals 3 and 7. Valid while the host stays stateless (AGENTS.md →
|
||||||
|
priority 2).
|
||||||
|
|
||||||
|
README → Plugin storage describes the contract. The host takes `postgres` only to run the
|
||||||
|
provisioning DDL, and `storage-provisioning.ts` is the only module importing it. `storage.ts`
|
||||||
|
beside it stays pure, so `web` never loads a driver. `src/postgres.test.ts` guards both halves,
|
||||||
|
because one value imported from the wrong module breaks this invisibly. The driver is never
|
||||||
|
re-exported through the barrel, so no driver shape enters the contract.
|
||||||
|
|
||||||
|
Three properties hold the design together, so don't trade one away in isolation:
|
||||||
|
|
||||||
|
- Passwords are `HMAC-SHA256(PLUGIN_DB_SECRET, id)` rather than stored, which keeps the host
|
||||||
|
stateless. Whoever holds that secret holds every plugin database, so it ranks with the DB password
|
||||||
|
itself.
|
||||||
|
- The provisioning DSN reaches `bootstrap` only (`src/compose.test.ts` guards the split).
|
||||||
|
- Provisioning never drops anything, so uninstalling a plugin cannot destroy data; boot logs the
|
||||||
|
orphans instead.
|
||||||
|
|
||||||
|
Because the host's copy sits in the ambient `/node_modules`, a plugin can `import "postgres"`
|
||||||
|
without declaring it. That is incidental, not a packaging promise: a plugin must still depend on its
|
||||||
|
own driver.
|
||||||
|
|
||||||
|
## Plugin settings are declared, not discovered
|
||||||
|
|
||||||
|
2026-08-23, the maintainer. Goals 7 and 10. Valid while settings reach plugins through the
|
||||||
|
environment (README → Plugin settings).
|
||||||
|
|
||||||
|
`settings.ts` is pure and takes the env as an argument, so the whole matrix unit-tests without a
|
||||||
|
stack. Four rules carry the design:
|
||||||
|
|
||||||
|
- The prefix is `PLUGIN_SETTING_`, never bare `PLUGIN_`: a plugin id `db` with key `url` would
|
||||||
|
otherwise name the host's own `PLUGIN_DB_URL`.
|
||||||
|
- Keys are camelCase, so the `camelCase → SNAKE_CASE` mapping is total and no two keys collide.
|
||||||
|
`findConflicts` (`plugin-host/conflicts.ts`) catches the residual cross-plugin collision.
|
||||||
|
- `required` and `default` are mutually exclusive. That lets `SettingsOf` type a declared key as
|
||||||
|
present rather than `T | undefined`, so no plugin author casts.
|
||||||
|
- A secret's value reaches the plugin but never a log, an error or `ctx.declaredSettings`, not even
|
||||||
|
as a mask or a length.
|
||||||
|
|
||||||
|
An author mistake is refused at discovery, a bad operator value refuses the boot, and a stray
|
||||||
|
`PLUGIN_SETTING_` variable only warns, as an orphan database does.
|
||||||
|
|
||||||
|
## The trust boundary is the `web` process, not the plugin
|
||||||
|
|
||||||
|
2026-08-19, the maintainer. Goal 7. Valid while plugins are operator-installed code, not third-party
|
||||||
|
uploads.
|
||||||
|
|
||||||
|
Per-plugin databases and roles bound accidents, not hostile plugins: `PLUGIN_DB_SECRET` is in
|
||||||
|
`web`'s environment during `onBoot`, and a plugin already holds `ctx.system`'s Ory admin clients. So
|
||||||
|
cross-plugin DB isolation is containment, and README says so. This follows AGENTS.md → priority 7:
|
||||||
|
crash-isolation is a non-goal.
|
||||||
|
|
||||||
|
`server.ts` still deletes the secret from `process.env` right after `loadConfig`, before discovery
|
||||||
|
imports any plugin module. That ordering is the whole point: move the line earlier if anything,
|
||||||
|
never later.
|
||||||
|
|
||||||
|
## `ory/postgres/init/init.sql` is the only home for the Ory databases' ACL
|
||||||
|
|
||||||
|
2026-08-19, the maintainer. Code health. Valid while Ory's databases are created by Postgres' first
|
||||||
|
init.
|
||||||
|
|
||||||
|
Don't re-assert the `REVOKE CONNECT` from `bootstrap`. `REVOKE` only warns when the caller doesn't
|
||||||
|
own the database, so under the least-privilege provisioning account the README recommends it would
|
||||||
|
report success while changing nothing. It also hard-fails whenever `PLUGIN_DB_ADMIN_URL` names a
|
||||||
|
server with no `kratos`. The script runs only on first init, so a revoke added to it later never
|
||||||
|
reaches a volume that already exists: `docker compose down -v` is the dev remedy, and a deployed
|
||||||
|
install needs a migration.
|
||||||
|
|
||||||
|
## `bootstrap.ts` stays under `src/auth/`
|
||||||
|
|
||||||
|
2026-08-19, the maintainer. Code health. Valid until a third seeding concern lands.
|
||||||
|
|
||||||
|
It provisions plugin databases as well as seeding Ory, but it is the one-shot service's entrypoint,
|
||||||
|
not an auth module. Moving it to `src/bootstrap.ts` would edit `compose.yml`, five e2e compose files
|
||||||
|
and `src/compose.test.ts` for a rename.
|
||||||
|
|
||||||
|
## `BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook
|
||||||
|
|
||||||
|
2026-08-19, the maintainer. Goal 7. Valid while no plugin needs an orderly drain of its pools.
|
||||||
|
|
||||||
|
Removing any field from the context costs a major bump, so all six stay. Adding one costs only a
|
||||||
|
minor, so the context otherwise gains a field only when a plugin needs it. Pools handed to a plugin
|
||||||
|
are reaped on process exit.
|
||||||
|
|
||||||
|
## `config/` is a plain dir with no `package.json` of its own
|
||||||
|
|
||||||
|
2026-07-01, the maintainer. Goal 7. Valid while an operator's menu override needs no dependencies.
|
||||||
|
|
||||||
|
With a `package.json` there, `#menu-config` resolves against it instead and boot fails loud. If the
|
||||||
|
override ever needs dependencies, `config/` needs the package treatment `plugins/` has.
|
||||||
|
|
||||||
|
## A plugin `package.json` without `"type": "module"` is refused, not warned
|
||||||
|
|
||||||
|
2026-08-18, the maintainer. Goal 7. Valid while nothing is installed in the wild.
|
||||||
|
|
||||||
|
Refusing is the safe direction: refuse→warn relaxes freely, while warn→refuse breaks installed
|
||||||
|
plugins. Allowing it would cost only a warning and a re-parse per file, not a break: Node detects
|
||||||
|
module syntax, so even a `.js` helper loads. The cost of refusing is that an operator on a read-only
|
||||||
|
third-party mount cannot apply the remedy.
|
||||||
|
|
||||||
|
## `examples/` mirrors the drop-in mount dirs
|
||||||
|
|
||||||
|
2026-07-01, the maintainer. Goal 9. Valid while the examples are meant to be copied in unchanged.
|
||||||
|
|
||||||
|
`examples/plugins/<id>/` copies to `plugins/<id>/`, and `examples/config/menu.ts` to
|
||||||
|
`config/menu.ts`. Both mirrors are in `tsconfig.include` and resolve the host through the barrels,
|
||||||
|
so each typechecks in place and copies across unchanged. Never commit real plugins or config into
|
||||||
|
the root mount dirs: they ship empty.
|
||||||
|
|
||||||
|
## `ctx.chrome` is lazily memoized, never built unconditionally
|
||||||
|
|
||||||
|
2026-06-23, the maintainer. Goal 8. Valid while the public landing (`/`) renders without I/O.
|
||||||
|
|
||||||
|
Don't make it unconditional or move it into the base request context: it protects the I/O-free hot
|
||||||
|
path on the public, bot-hit landing.
|
||||||
|
|
||||||
|
## A plugin-owned render always runs on that plugin's context
|
||||||
|
|
||||||
|
2026-08-03, the maintainer. Goal 7. Valid while each plugin has its own translator.
|
||||||
|
|
||||||
|
The landing slots (`home`, `dashboard`) and an `onRequest` short-circuit build their context with
|
||||||
|
`contextFor(pluginId)`, exactly as a plugin route does. Otherwise `ctx.t` is the core translator and
|
||||||
|
the plugin's own keys render as bare keys on the pages it owns.
|
||||||
|
|
||||||
|
## Email is delegated to Kratos
|
||||||
|
|
||||||
|
2026-06-23, the maintainer. Goal 3. Valid while Kratos owns the recovery and verification flows.
|
||||||
|
|
||||||
|
`web` never touches SMTP. Mail is customized through Kratos' `courier.template_override_path`.
|
||||||
|
|
||||||
|
## The vocabulary is `User` → `Group` → `Permission`, and there is no `Role`
|
||||||
|
|
||||||
|
2026-08-03, the maintainer. Code health. Valid while Keto holds no namespace of its own.
|
||||||
|
|
||||||
|
Keto ships no namespaces: all four in `ory/keto/namespaces.keto.ts` are ours. A permission is one
|
||||||
|
operation ("read shifts"). A role is a bundle, which here is just a group with several grants, and
|
||||||
|
groups nest. Ory's own "permission" (the `Resource` `permits`: view, edit, delete) is the separate
|
||||||
|
per-row tier.
|
||||||
|
|
||||||
|
## A permission name is always `<resource>:<action>`, enforced at discovery
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Goal 7. Valid while permissions are named by plugin authors.
|
||||||
|
|
||||||
|
Examples: `scheduling:read`, `users:write`. A bare word names who someone is (a role), and roles are
|
||||||
|
groups here. `shapeError` checks every route, nav and profile-menu `permission`, and every declared
|
||||||
|
name, with `isValidPermissionName` (`plugin-host/plugin.ts`). A bad name refuses the boot like any
|
||||||
|
other manifest rule. The check is not left to the admin GUI, which an operator removes by not
|
||||||
|
copying it in.
|
||||||
|
|
||||||
|
## Permission names are authored in plugin code; only grants live in Keto
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Goal 2. Valid while the admin screens offer only declared names.
|
||||||
|
|
||||||
|
The host collects every installed plugin's declarations into one catalog (`declaredPermissions` →
|
||||||
|
`ctx.declaredPermissions`), and that catalog is the list the admin screens offer. Hence there is no
|
||||||
|
Permissions admin screen: nothing in a GUI invents a name, and holding one is a property of a user
|
||||||
|
or group, edited as a checkbox list there. A Keto tuple naming something no installed plugin
|
||||||
|
declares is not offered as a checkbox, and is never revoked by an unrelated save: the picker only
|
||||||
|
speaks for what it showed. A plugin may still gate on such a name, so it can be live. The user's or
|
||||||
|
group's page lists such a direct grant separately, each with its own Remove button.
|
||||||
|
|
||||||
|
## A permission's `<resource>` is global, not plugin-scoped
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Goal 7. Valid while cross-plugin sharing of permissions is wanted.
|
||||||
|
|
||||||
|
Hence `oauth2-clients`, not `clients`. Users are the host's, and plugins share them. The cost is
|
||||||
|
that collision-freedom is a convention rather than structural; the alternative penalizes the
|
||||||
|
sharing case.
|
||||||
|
|
||||||
|
## Declaring a permission stays optional
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Goal 7. Valid while plugins may share a permission.
|
||||||
|
|
||||||
|
Mandatory declaration would let `findConflicts` see every overlap, but it would then warn on exactly
|
||||||
|
the legitimate sharing case. Shape is enforced; declaration is not.
|
||||||
|
|
||||||
|
## `ADMIN_PERMISSIONS` defaults to empty, and an unusable value is dropped with a warning
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Goal 10. Valid while `bootstrap` is the only writer of grants.
|
||||||
|
|
||||||
|
Fail-loud belongs at the manifest boundary, where a developer authored the mistake. `bootstrap`
|
||||||
|
gates `web`, so refusing operator env would take the whole stack down;
|
||||||
|
`e2e-tests/compose.auth.yml` seeds a bad value to prove the container survives one.
|
||||||
|
|
||||||
|
The seed is a function of what `bootstrap` discovers, so a plugin dropped in after first boot needs
|
||||||
|
`docker compose up -d`, not `restart web`. `bootstrap`'s matching `./plugins` mount belongs in
|
||||||
|
`compose.override.yml` and nowhere else. In the base file it would desynchronise prod and collide
|
||||||
|
with the e2e stacks, which bind individual plugins inside `/app/plugins`: a nested mount into a
|
||||||
|
read-only parent is EROFS, and the container never starts.
|
||||||
|
|
||||||
|
## `actionForMethod` is plugin-local and stays out of `@plainpages/plugin-api`
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Code health. Valid while route tables name their permission per route.
|
||||||
|
|
||||||
|
Inside the auth-admin example it keeps the route table and the in-handler guard deriving from one
|
||||||
|
function, so 29 routes × 2 gate sites cannot drift. Generalised, it would make authorization a
|
||||||
|
function of the transport verb, and a route table must answer "what does this need?" on its own.
|
||||||
|
|
||||||
|
## A gate is one of `public`, `session` or `permission`, named exactly once
|
||||||
|
|
||||||
|
2026-09-02, the maintainer. Goal 7. Valid while routes and nav nodes are declared in plugin
|
||||||
|
manifests.
|
||||||
|
|
||||||
|
A route or nav node names exactly one gate. 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 that the plugin router, the host's own route table and the menu all read.
|
||||||
|
The rule binds manifests only; the comment on `allows` says why a node without a gate passes it.
|
||||||
|
|
||||||
|
`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
|
||||||
|
|
||||||
|
2026-09-02, the maintainer. Goal 9. Valid while `examples/plugins/scheduling` stays a teaching
|
||||||
|
artifact rather than a maintained product.
|
||||||
|
|
||||||
|
Each page then reads top to bottom on its own, and an example rarely changes, so the two copies cost
|
||||||
|
little.
|
||||||
|
|
||||||
|
## A `:read`-only holder is never shown a write affordance
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Goal 2. Valid while the admin GUI splits `:read` from `:write`.
|
||||||
|
|
||||||
|
The list and detail models carry `canWrite`, and the views drop create, save, delete, add and
|
||||||
|
remove. The permission picker still renders, 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`.
|
||||||
|
|
||||||
|
Three guards go with it:
|
||||||
|
|
||||||
|
- You cannot revoke your own direct grants, since undoing that self-lockout needs a `curl` against
|
||||||
|
Keto.
|
||||||
|
- A permission held through a group renders ticked but disabled, because unticked would state the
|
||||||
|
opposite of the truth.
|
||||||
|
- 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 you would lose a permission you don't hold
|
||||||
|
directly. Undoing that self-lockout would need a `curl` against Keto too.
|
||||||
|
|
||||||
|
## `users:write` and `groups:write` each amount to full administrative access
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Goal 2. Valid while the admin GUI splits its write permissions by
|
||||||
|
resource.
|
||||||
|
|
||||||
|
`groups:write` 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 a safe helpdesk grant. Don't let the per-resource naming imply otherwise in docs.
|
||||||
|
|
||||||
|
## The CSRF token is unbound to the session
|
||||||
|
|
||||||
|
2026-10-30, the maintainer. Goal 5. Valid while the session cookie stays `SameSite=Lax` or stricter.
|
||||||
|
|
||||||
|
Leaving the token unbound is the usual cost of a signed double-submit token (`src/auth/csrf.ts`;
|
||||||
|
README → Security model). `SameSite=Lax` and HSTS cover that cost.
|
||||||
|
|
||||||
|
## Plainpages says "user" everywhere, where Ory says "identity"
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Code health. Valid while Kratos' wire shape names the DTO `Identity`.
|
||||||
|
|
||||||
|
House style, not a renamed concept. The single exception is the `Identity` DTO in
|
||||||
|
`src/auth/kratos-admin.ts`, which mirrors Kratos' wire shape: don't rename it.
|
||||||
|
|
||||||
|
## The locale lives in the URL, never in a cookie
|
||||||
|
|
||||||
|
2026-08-03, the maintainer. Goal 5. Valid while a page's language should show in its address.
|
||||||
|
|
||||||
|
The host resolves the locale from `?locale=sv-SE`, then `Accept-Language`, then `en-US`. When the
|
||||||
|
URL asked for a locale, the host carries it onto the links it renders. A cookie would make a page's
|
||||||
|
language invisible in its address and unshareable; the cost is that a plugin wraps its own hrefs.
|
||||||
|
Matching is exact on a full tag (`sv-FI` ≠ `sv-SE`), except that a lone language from
|
||||||
|
`Accept-Language` takes the first regional catalog for it.
|
||||||
|
|
||||||
|
## The core building blocks carry the locale, so a plugin doesn't have to
|
||||||
|
|
||||||
|
2026-08-03, the maintainer. Goal 1. Valid while the locale lives in the URL.
|
||||||
|
|
||||||
|
The shell, `pagination`, `filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu`
|
||||||
|
wrap every href in `localeHref`. Nav and sign-in are wrapped in `chrome.ts`. The two GET forms carry
|
||||||
|
the locale as a hidden `locale` input, since a GET submit replaces the whole query string.
|
||||||
|
|
||||||
|
A form's `action` counts as a link: sign-out, consent and auth-card forms carry the locale too, or
|
||||||
|
picking a language and then saving anything drops back to `Accept-Language`. The obligation stays on
|
||||||
|
the building block, never on each call site. `ctx.localeHref` remains for hrefs a plugin's own
|
||||||
|
markup emits. The one round-trip that cannot carry the locale is the Kratos sign-in POST, to an
|
||||||
|
absolute off-site URL.
|
||||||
|
|
||||||
|
## `locale` and the i18n view locals are reserved by the host
|
||||||
|
|
||||||
|
2026-08-03, the maintainer. Goal 7. Valid while the locale lives in the URL.
|
||||||
|
|
||||||
|
`locale` is in `parseListQuery`'s reserved set, so a localized list page doesn't hand a plugin a
|
||||||
|
phantom `locale` filter. The view locals `t`, `locale`, `locales`, `localeHref`, `localeParam`,
|
||||||
|
`localeSwitch`, `dir` and `assetHref` are merged after a handler's `data`, so a collision loses the
|
||||||
|
key instead of breaking the shell.
|
||||||
|
|
||||||
|
## Catalogs are checked at boot, not at render
|
||||||
|
|
||||||
|
2026-08-03, the maintainer. Goal 7. Valid while catalogs are TypeScript modules loaded at boot.
|
||||||
|
|
||||||
|
Every locale is compared against its set's `en-US`: keys, string-vs-plural kind, and the plural
|
||||||
|
categories `Intl.PluralRules` requires. A mismatch stops startup. A plugin may ship fewer locales
|
||||||
|
than the host, with its strings falling back to `en-US` per key, but never one the host lacks.
|
||||||
|
|
||||||
|
## `locales/` at the repo root is a drop-in mount
|
||||||
|
|
||||||
|
2026-08-03, the maintainer. Goal 7. Valid while adding a language must not require forking the
|
||||||
|
image.
|
||||||
|
|
||||||
|
Like `plugins/` and `config/`: `locales/<tag>.ts` for the core, `locales/plugins/<id>/<tag>.ts` for
|
||||||
|
a plugin. A new tag adds a language; an existing one replaces that catalog wholesale. The shipped
|
||||||
|
`en-US` stays the parity baseline even when the mount replaces it, so a mounted catalog is checked
|
||||||
|
rather than trusted. One compared only against itself would boot green with the whole UI rendering
|
||||||
|
keys.
|
||||||
|
|
||||||
|
## The language picker is on every page, POST-rendered ones included
|
||||||
|
|
||||||
|
2026-08-04, the maintainer. Goal 2. Valid while the picker is expected on literally every page; if
|
||||||
|
that softens, hiding it after a POST is simpler.
|
||||||
|
|
||||||
|
A POST-rendered URL often answers no GET (`POST /auth-admin/users/:id/recovery`), so the host resolves
|
||||||
|
the picker's target (`app.ts` → `switchBase`): this path when it answers GET, else the same-origin
|
||||||
|
Referer, else `/`. The accepted cost: switching language there leaves that POST's own result behind.
|
||||||
|
|
||||||
|
## An unknown translation key renders as itself
|
||||||
|
|
||||||
|
2026-08-03, the maintainer. Goal 7. Valid while labels may be either a key or plain text.
|
||||||
|
|
||||||
|
That single rule lets a nav label, branding, or a menu `rename` be either a key or plain text,
|
||||||
|
without a second field or a migration. Don't "fix" it into a loud failure: a manifest with plain
|
||||||
|
labels must keep working.
|
||||||
|
|
||||||
|
## `t()` returns raw text, and the view escapes it
|
||||||
|
|
||||||
|
2026-08-03, the maintainer. Code health. Valid while views escape with `<%= %>`.
|
||||||
|
|
||||||
|
Messages go through `<%= %>` like any other value. One carrying markup uses `<%- %>`, and then its
|
||||||
|
`{{vars}}` are escaped at the call site. Don't move escaping into `t()`: every other value in a view
|
||||||
|
would become the odd one out.
|
||||||
|
|
||||||
|
## RTL is out of scope until there is a real use case
|
||||||
|
|
||||||
|
2026-08-04, the maintainer. Goal 6. Valid while no deployment needs an RTL language.
|
||||||
|
|
||||||
|
`textDirection` sets `<html dir>` because that is free and correct, but the stylesheet keeps
|
||||||
|
physical `left` and `right` properties. A genuine RTL locale needs those moved to logical ones
|
||||||
|
first.
|
||||||
|
|
||||||
|
## A dropdown is a `<button popovertarget>` and a `[popover]`, never a `<details>`
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Goals 4 and 6. Valid while the popover API is the only zero-JS way to
|
||||||
|
dismiss on an outside click.
|
||||||
|
|
||||||
|
The browser owns open and close, and the panel sits in the top layer, so a row kebab is not clipped
|
||||||
|
by `.table-wrap`'s `overflow`. Four rules hold it together:
|
||||||
|
|
||||||
|
- The panel carries `position-anchor: auto`: a bare `anchor()` resolves to nothing in all three
|
||||||
|
engines.
|
||||||
|
- The panel stays the trigger's next sibling inside the `.menu` wrapper, which the 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: nondeterministic HTML forecloses caching.
|
||||||
|
- Neither `aria-expanded` nor `aria-haspopup` is written. Every engine maintains the first itself on
|
||||||
|
a declarative `popovertarget` invoker, so a hand-written one replaces a live state with a static
|
||||||
|
lie. The second would promise `role="menu"` semantics these panels don't implement.
|
||||||
|
|
||||||
|
That `aria-expanded` guarantee is the declarative attribute's alone: open a panel from script and no
|
||||||
|
engine applies it, so "enhancing" one of these triggers 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 profile menu, and its comment says why. Keep the two in step.
|
||||||
|
|
||||||
|
## The document is the one scroller
|
||||||
|
|
||||||
|
2026-09-09, the maintainer. Goal 4. Valid while AGENTS.md → priority 1 holds that a page is a
|
||||||
|
document.
|
||||||
|
|
||||||
|
`.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 make the off-canvas panel usable with a long tree.
|
||||||
|
`#nav-toggle` is `position: fixed` because a label click focuses it, and a browser scrolls a focused
|
||||||
|
element into view.
|
||||||
|
|
||||||
|
## `ICON_NAMES` is a host-owned registry, not a plugin contract
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Goal 7. Valid while the `every icon <use> resolves` e2e test guards the
|
||||||
|
nav.
|
||||||
|
|
||||||
|
`src/ui/icons.ts` 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. The accepted cost: an unknown sprite id renders blank instead of failing loud.
|
||||||
|
|
||||||
|
## Dependencies install to `/node_modules`, above `WORKDIR /app`
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Code health. Valid while dev bind-mounts the checkout at `/app`.
|
||||||
|
|
||||||
|
Node resolves upward, so dev's `.:/app` bind mount has nothing to shadow. A volume at
|
||||||
|
`/app/node_modules` would not do: the daemon creates a mount destination as root whatever `--user`
|
||||||
|
says, leaving a root-owned dir in the checkout. Nothing may sit at that path, since it silently
|
||||||
|
shadows `/node_modules`; `src/compose.test.ts` guards the compose files, and `.dockerignore` the
|
||||||
|
image.
|
||||||
|
|
||||||
|
## A container whose output a human then edits or deletes runs as the invoking user
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Code health. Valid while a dev box may lack `sudo`.
|
||||||
|
|
||||||
|
That means `--user "$(id -u):$(id -g)"` for the E2E runner (artifacts) and a lockfile edit;
|
||||||
|
otherwise the output is root-owned. It is not universal: `bootstrap` writes `jwks.json` as root when
|
||||||
|
it is absent on first boot. The committed dev key makes that rare, and when it happens the rotation
|
||||||
|
runbook's host-side `>` needs the file re-owned first. That exception is valid while the dev key
|
||||||
|
ships committed.
|
||||||
|
|
||||||
|
Three consequences:
|
||||||
|
|
||||||
|
- `e2e-tests/artifacts/` is tracked (`.gitkeep`), since an absent bind-mount source is
|
||||||
|
daemon-created as root and that uid then cannot write it (README → Upgrading).
|
||||||
|
- The runner image sets `HOME=/tmp`, since an arbitrary uid has no passwd entry and would land on an
|
||||||
|
unwritable `/`.
|
||||||
|
- Rootless Docker wants the flag dropped, container root already being the invoking user.
|
||||||
|
|
||||||
|
Baking a `USER` into the image does not work: the image's `pwuser` is 1001, and no fixed uid matches
|
||||||
|
every host. `src/compose.test.ts` guards every documented command, and `src/ci-gate.test.ts` the
|
||||||
|
gate's own.
|
||||||
|
|
||||||
|
## Anything the browser logs fails the E2E test that provoked it
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Goal 4. Valid while pages ship no JavaScript of their own.
|
||||||
|
|
||||||
|
Every spec takes its `test` from `e2e-tests/console-guard.ts`, which fails a test on a console error
|
||||||
|
or warning, or an uncaught exception, on any page it opened. A zero-JS app has nothing to say in the
|
||||||
|
console, so the bar is zero rather than a curated tolerance list. The two exceptions are narrow: a
|
||||||
|
module-level allowance for the COOP header Chromium drops (the e2e stacks serve plain http over
|
||||||
|
container hostnames), and `allowConsole(re)` for a test whose own page provokes a message on
|
||||||
|
purpose.
|
||||||
|
|
||||||
|
`src/e2e-console-guard.test.ts` locks the wiring in the unit gate, since a spec importing `test`
|
||||||
|
straight from Playwright, or minting a page with a raw `newPage()` instead of `watchedPage()`, would
|
||||||
|
run unwatched and green. The accepted cost: a page outliving its test can log late and fail the
|
||||||
|
next one.
|
||||||
|
|
||||||
|
## The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Goal 4. Valid while the Ory-backed specs share one backend.
|
||||||
|
|
||||||
|
`visual.spec.ts` and `language.spec.ts` are side-effect-free, so parallel runs don't collide, and a
|
||||||
|
console message only appears in the engine that renders the page (`ORY_FREE` in
|
||||||
|
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
|
||||||
|
so widening them means a stack per engine.
|
||||||
|
|
||||||
|
## The docs-only CI skip is `*.md` anywhere in the tree
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Code health. Valid while no markdown file is rendered or executed.
|
||||||
|
|
||||||
|
Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`: rename detection names only the
|
||||||
|
destination, so `git mv src/app.ts notes.md` would otherwise read as docs and skip the gate over a
|
||||||
|
source file that was gone. `src/ci-gate.test.ts` locks the flags as a text guard, since the test
|
||||||
|
image ships neither `git` nor `bash`.
|
||||||
|
|
||||||
|
Because a `.md` change skips the gate, the Docker Hub overview is
|
||||||
|
`release-tooling/dockerhub-overview.md.tmpl`. A release reads it and a unit test guards it, so under
|
||||||
|
a `.md` name a broken `{{VERSION}}` would merge with that guard skipped.
|
||||||
|
|
||||||
|
`README.md` is the one markdown file tests read, so a README-only change skips them. That leaves two
|
||||||
|
gaps:
|
||||||
|
|
||||||
|
- An `apiVersion` sample in the README can go stale unchecked by
|
||||||
|
`release-tooling/contract-version.test.ts`. Accepted, because the README's samples are
|
||||||
|
illustrative; the copies authors build from (`examples/`, `views/`, the template) are gated.
|
||||||
|
- A README-only change that drops a command the Hub page shows fails only the next gated change, in
|
||||||
|
`release-tooling/dockerhub-overview.test.ts`.
|
||||||
|
|
||||||
|
## CI docker logins share the runner host's Docker config
|
||||||
|
|
||||||
|
2026-07-04, the maintainer. Code health. Valid while releases keep a single-maintainer cadence; if
|
||||||
|
these races start costing re-runs, serialize the workflows with a `concurrency` group.
|
||||||
|
|
||||||
|
The act_runner is host-mode, so `docker login` and `logout` in the workflows mutate one shared
|
||||||
|
`~/.docker/config.json`. Concurrent jobs can race: one job's logout can 401 another's push, and the
|
||||||
|
recovery is a re-run. Tokens sit in that file between login and logout. In the same class,
|
||||||
|
concurrent runs share the workspace dir, so `ci.sh`'s web-image build races another run's container
|
||||||
|
creation on the `<project>-web` tag.
|
||||||
+194
-1
@@ -103,7 +103,7 @@ Read most (opens and searches):
|
|||||||
- `src/http/app.test.ts` 1
|
- `src/http/app.test.ts` 1
|
||||||
- `src/ui/chrome.test.ts` 1
|
- `src/ui/chrome.test.ts` 1
|
||||||
- `src/ui/chrome.ts` 1
|
- `src/ui/chrome.ts` 1
|
||||||
- `views/index.ejs` 1
|
- `views/dashboard.ejs` 1
|
||||||
- `views/partials/shell.ejs` 1
|
- `views/partials/shell.ejs` 1
|
||||||
|
|
||||||
## Reading cost, chunk e4d3bb0ac092, finished 2026-10-31T08:02:27Z
|
## Reading cost, chunk e4d3bb0ac092, finished 2026-10-31T08:02:27Z
|
||||||
@@ -320,3 +320,196 @@ Read most (opens and searches):
|
|||||||
- `src/config.ts` 1
|
- `src/config.ts` 1
|
||||||
- `src/plugin-host/storage-provisioning.ts` 1
|
- `src/plugin-host/storage-provisioning.ts` 1
|
||||||
- `src/plugin-host/storage.ts` 1
|
- `src/plugin-host/storage.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk 80e22d10c810, finished 2026-11-08T13:06:23Z
|
||||||
|
|
||||||
|
130001 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `README.md` 6
|
||||||
|
- `todo.md` 4
|
||||||
|
- `examples/plugins/admin/admin-groups.ts` 2
|
||||||
|
- `src/auth/bootstrap.ts` 2
|
||||||
|
- `src/auth/login.ts` 2
|
||||||
|
- `src/http/app-admin.test.ts` 2
|
||||||
|
- `AGENTS.md` 1
|
||||||
|
- `CHANGELOG.md` 1
|
||||||
|
- `examples/plugins/admin/i18n/sv-SE.ts` 1
|
||||||
|
- `src/auth/routes.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk b94ae3839c24, finished 2026-11-08T13:27:14Z
|
||||||
|
|
||||||
|
87251 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `todo.md` 9
|
||||||
|
- `README.md` 7
|
||||||
|
- `src/plugin-host/plugin.test.ts` 6
|
||||||
|
- `src/plugin-host/plugin.ts` 5
|
||||||
|
- `src/test-tooling/stylesheet-floor.ts` 4
|
||||||
|
- `AGENTS.md` 3
|
||||||
|
- `src/test-tooling/app-harness.ts` 3
|
||||||
|
- `src/plugin-host/discovery.ts` 2
|
||||||
|
- `src/test-tooling/stylesheet-floor.test.ts` 2
|
||||||
|
- `.dockerignore` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk dcd9bc01df44, finished 2026-11-08T13:55:26Z
|
||||||
|
|
||||||
|
212195 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `README.md` 10
|
||||||
|
- `todo.md` 8
|
||||||
|
- `src/http/app.ts` 4
|
||||||
|
- `src/http/app.test.ts` 3
|
||||||
|
- `src/http/static.ts` 3
|
||||||
|
- `CHANGELOG.md` 2
|
||||||
|
- `examples/plugins/admin/admin-users.ts` 2
|
||||||
|
- `release-tooling/contract-version.test.ts` 2
|
||||||
|
- `examples/plugins/admin/admin-clients.ts` 1
|
||||||
|
- `examples/plugins/admin/admin-groups.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk a6f3d52327a4, finished 2026-11-08T14:17:22Z
|
||||||
|
|
||||||
|
50555 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `todo.md` 4
|
||||||
|
|
||||||
|
## Reading cost, chunk 177d8e8081a6, finished 2026-11-08T18:14:49Z
|
||||||
|
|
||||||
|
66196 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `todo.md` 5
|
||||||
|
- `examples/plugins/admin/plugin.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk dd4d721e79d4, finished 2026-11-08T18:52:37Z
|
||||||
|
|
||||||
|
90532 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `todo.md` 8
|
||||||
|
- `README.md` 7
|
||||||
|
- `src/plugin-host/plugin.ts` 6
|
||||||
|
- `src/plugin-host/plugin.test.ts` 4
|
||||||
|
- `src/ui/chrome.ts` 4
|
||||||
|
- `AGENTS.md` 3
|
||||||
|
- `examples/plugins/scheduling/plugin.ts` 3
|
||||||
|
- `src/plugin-host/plugin-api.ts` 3
|
||||||
|
- `src/ui/chrome.test.ts` 3
|
||||||
|
- `CHANGELOG.md` 2
|
||||||
|
|
||||||
|
## Reading cost, chunk f4e8ff50cb2a, finished 2026-11-08T19:37:35Z
|
||||||
|
|
||||||
|
73698 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `src/http/app.ts` 7
|
||||||
|
- `src/function-limits.test.ts` 2
|
||||||
|
- `src/http/context.ts` 2
|
||||||
|
- `src/http/static.ts` 2
|
||||||
|
- `src/plugin-host/plugin.ts` 2
|
||||||
|
- `todo.md` 2
|
||||||
|
- `AGENTS.md` 1
|
||||||
|
- `src/http/builtin-route.ts` 1
|
||||||
|
- `src/plugin-host/hooks.ts` 1
|
||||||
|
|
||||||
|
## Reading cost reset, 2026-11-08T19:39:42Z
|
||||||
|
|
||||||
|
## Reading cost, chunk 385be0aa8aef, finished 2026-11-08T19:57:05Z
|
||||||
|
|
||||||
|
87493 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `README.md` 19
|
||||||
|
- `src/plugin-host/plugin.ts` 14
|
||||||
|
- `src/plugin-host/plugin.test.ts` 9
|
||||||
|
- `todo.md` 9
|
||||||
|
- `AGENTS.md` 4
|
||||||
|
- `docs/ci-cd.md` 4
|
||||||
|
- `src/compose.test.ts` 2
|
||||||
|
- `src/ci-gate.test.ts` 1
|
||||||
|
- `src/function-limits.test.ts` 1
|
||||||
|
- `src/plugin-host/discovery.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk 7100f96a1d8d, finished 2026-11-08T20:19:54Z
|
||||||
|
|
||||||
|
96357 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `AGENTS.md` 11
|
||||||
|
- `README.md` 4
|
||||||
|
- `docs/decisions.md` 4
|
||||||
|
- `.gitea/workflows/release.yml` 2
|
||||||
|
- `examples/plugins/admin/admin-shared.ts` 2
|
||||||
|
- `src/postgres.test.ts` 2
|
||||||
|
- `todo.md` 2
|
||||||
|
- `src/auth/gate.ts` 1
|
||||||
|
- `src/auth/jwt.ts` 1
|
||||||
|
- `src/server.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk ac2b7411e145, finished 2026-11-08T20:50:21Z
|
||||||
|
|
||||||
|
105677 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `README.md` 15
|
||||||
|
- `src/http/app-admin.test.ts` 7
|
||||||
|
- `todo.md` 5
|
||||||
|
- `CHANGELOG.md` 3
|
||||||
|
- `ci.sh` 3
|
||||||
|
- `examples/README.md` 3
|
||||||
|
- `examples/plugins/auth-admin/admin-shared.test.ts` 3
|
||||||
|
- `src/plugin-host/conflicts.ts` 3
|
||||||
|
- `AGENTS.md` 2
|
||||||
|
- `compose.yml` 2
|
||||||
|
|
||||||
|
## Reading cost, chunk 0d9d4ffb47aa, finished 2026-11-08T20:58:38Z
|
||||||
|
|
||||||
|
48347 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `src/http/app.ts` 4
|
||||||
|
- `src/http/app.test.ts` 2
|
||||||
|
- `todo.md` 2
|
||||||
|
- `src/plugin-host/plugin.ts` 1
|
||||||
|
|
||||||
|
## Reading cost, chunk 8d5c5ad2ab7c, finished 2026-11-08T21:39:34Z
|
||||||
|
|
||||||
|
145906 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `src/http/app-admin.test.ts` 8
|
||||||
|
- `README.md` 6
|
||||||
|
- `CHANGELOG.md` 5
|
||||||
|
- `compose.yml` 5
|
||||||
|
- `docs/decisions.md` 5
|
||||||
|
- `examples/plugins/auth-admin/admin-grants.test.ts` 4
|
||||||
|
- `examples/plugins/auth-admin/admin-users.ts` 4
|
||||||
|
- `src/auth/keto-client.ts` 4
|
||||||
|
- `todo.md` 4
|
||||||
|
- `examples/plugins/auth-admin/README.md` 3
|
||||||
|
|
||||||
|
## Reading cost, chunk ef984469151f, finished 2026-11-08T22:01:41Z
|
||||||
|
|
||||||
|
38668 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `todo.md` 7
|
||||||
|
- `AGENTS.md` 1
|
||||||
|
- `README.md` 1
|
||||||
|
- `docs/decisions.md` 1
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
# docker compose -f compose.yml -f e2e-tests/compose.full.yml down -v # tear down after
|
# docker compose -f compose.yml -f e2e-tests/compose.full.yml down -v # tear down after
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
# The base's full depends_on applies (Hydra included — the admin plugin's OAuth2-clients
|
# The base's full depends_on applies (Hydra included — the auth-admin plugin's OAuth2-clients
|
||||||
# screen needs it); only the reference plugin's upstream is added. SSO is enabled here only
|
# screen needs it); only the reference plugin's upstream is added. SSO is enabled here only
|
||||||
# (clean clone stays password-only): the mock provider's whole array is the env-settable form
|
# (clean clone stays password-only): the mock provider's whole array is the env-settable form
|
||||||
# Kratos offers, mapped through the committed claims jsonnet.
|
# Kratos offers, mapped through the committed claims jsonnet.
|
||||||
@@ -24,18 +24,18 @@ services:
|
|||||||
timeout: 4s
|
timeout: 4s
|
||||||
retries: 30
|
retries: 30
|
||||||
# plugins/ is empty in the image; bind the example plugins in so the browser flow can open the
|
# plugins/ is empty in the image; bind the example plugins in so the browser flow can open the
|
||||||
# gated /scheduling/shifts page and the /admin/* screens (the admin screens ship as a drop-in
|
# gated /scheduling/shifts page and the /auth-admin/* screens (the admin screens ship as a drop-in
|
||||||
# plugin, mounted at /app/plugins/admin, reaching the host's Ory clients via ctx.system).
|
# plugin, mounted at /app/plugins/auth-admin, reaching the host's Ory clients via ctx.system).
|
||||||
volumes:
|
volumes:
|
||||||
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
|
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
|
||||||
- ./examples/plugins/admin:/app/plugins/admin:ro
|
- ./examples/plugins/auth-admin:/app/plugins/auth-admin:ro
|
||||||
|
|
||||||
# bootstrap grants the demo admin every discovered plugin's permission names, so it needs the
|
# bootstrap grants the demo admin every discovered plugin's permission names, so it needs the
|
||||||
# example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403.
|
# example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403.
|
||||||
bootstrap:
|
bootstrap:
|
||||||
volumes:
|
volumes:
|
||||||
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
|
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
|
||||||
- ./examples/plugins/admin:/app/plugins/admin:ro
|
- ./examples/plugins/auth-admin:/app/plugins/auth-admin:ro
|
||||||
|
|
||||||
# Browser-facing URLs (base_url, every ui_url, the after-login redirect) move to the gateway host.
|
# Browser-facing URLs (base_url, every ui_url, the after-login redirect) move to the gateway host.
|
||||||
# `--dev`: the browser hits the gateway over http, but Kratos marks cookies Secure for a
|
# `--dev`: the browser hits the gateway over http, but Kratos marks cookies Secure for a
|
||||||
@@ -81,7 +81,7 @@ services:
|
|||||||
|
|
||||||
# Catches the courier's mail (kratos.yml → smtp://mailpit:1025), so a test can read an emailed code.
|
# Catches the courier's mail (kratos.yml → smtp://mailpit:1025), so a test can read an emailed code.
|
||||||
mailpit:
|
mailpit:
|
||||||
image: axllent/mailpit:v1.33.0
|
image: axllent/mailpit:v1.33.1
|
||||||
mem_limit: 128m
|
mem_limit: 128m
|
||||||
memswap_limit: 128m
|
memswap_limit: 128m
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -122,6 +122,7 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
BASE_URL: http://proxy
|
BASE_URL: http://proxy
|
||||||
|
KETO_WRITE_URL: http://keto:4467
|
||||||
KRATOS_ADMIN_URL: http://kratos:4434
|
KRATOS_ADMIN_URL: http://kratos:4434
|
||||||
MAILPIT_URL: http://mailpit:8025
|
MAILPIT_URL: http://mailpit:8025
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
+44
-28
@@ -15,6 +15,7 @@ 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 KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
|
||||||
|
const KETO_WRITE = process.env.KETO_WRITE_URL ?? "http://keto:4467";
|
||||||
const MAILPIT = process.env.MAILPIT_URL ?? "http://mailpit:8025";
|
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.
|
||||||
@@ -55,7 +56,7 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
// The list screens rebuild their query from the list state (sort/page/filter), so they are where a
|
// The list screens rebuild their query from the list state (sort/page/filter), so they are where a
|
||||||
// chosen language is most easily dropped; the core building blocks carry it through.
|
// chosen language is most easily dropped; the core building blocks carry it through.
|
||||||
test("a sorted, paged admin list keeps the visitor's language", async () => {
|
test("a sorted, paged admin list keeps the visitor's language", async () => {
|
||||||
await page.goto("/admin/users?locale=sv-SE");
|
await page.goto("/auth-admin/users?locale=sv-SE");
|
||||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||||
await expect(page.getByRole("heading", { name: "Användare" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Användare" })).toBeVisible();
|
||||||
|
|
||||||
@@ -80,7 +81,7 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
// A POST that re-renders a page: the write must keep the language, and the picker — which is on
|
// A POST that re-renders a page: the write must keep the language, and the picker — which is on
|
||||||
// every page — must point somewhere that answers GET rather than at the POST-only URL.
|
// every page — must point somewhere that answers GET rather than at the POST-only URL.
|
||||||
test("a write keeps the visitor's language, and the picker still works on the POST-rendered page", async () => {
|
test("a write keeps the visitor's language, and the picker still works on the POST-rendered page", async () => {
|
||||||
await page.goto("/admin/users?locale=sv-SE");
|
await page.goto("/auth-admin/users?locale=sv-SE");
|
||||||
await page.getByRole("link", { name: "Ny användare" }).click();
|
await page.getByRole("link", { name: "Ny användare" }).click();
|
||||||
await page.fill('input[name="email"]', `lang-${suffix}@plainpages.local`);
|
await page.fill('input[name="email"]', `lang-${suffix}@plainpages.local`);
|
||||||
await page.getByRole("button", { name: "Skapa användare" }).click();
|
await page.getByRole("button", { name: "Skapa användare" }).click();
|
||||||
@@ -89,7 +90,7 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
|
|
||||||
// Open the new user's edit page the way the CRUD test does — the row's Edit link carries the id.
|
// Open the new user's edit page the way the CRUD test does — the row's Edit link carries the id.
|
||||||
const row = page.locator("tr", { hasText: `lang-${suffix}@plainpages.local` });
|
const row = page.locator("tr", { hasText: `lang-${suffix}@plainpages.local` });
|
||||||
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
|
const editHref = await row.locator('a[href^="/auth-admin/users/"]').first().getAttribute("href");
|
||||||
await page.goto(`${editHref}`);
|
await page.goto(`${editHref}`);
|
||||||
await expect(page.locator('button[aria-label="Språk"]')).toHaveCount(1);
|
await expect(page.locator('button[aria-label="Språk"]')).toHaveCount(1);
|
||||||
await page.getByRole("button", { name: "Skapa återställningskod" }).click(); // POST-only route
|
await page.getByRole("button", { name: "Skapa återställningskod" }).click(); // POST-only route
|
||||||
@@ -108,33 +109,33 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
// seeds exactly those), so both gated sections are present in the menu (collapsed by default →
|
// seeds exactly those), so both gated sections are present in the menu (collapsed by default →
|
||||||
// assert they're in the DOM, not necessarily visible).
|
// assert they're in the DOM, not necessarily visible).
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(1);
|
await expect(page.locator('.sidebar a[href="/auth-admin/users"]')).toHaveCount(1);
|
||||||
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(1);
|
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("users CRUD: create a user, see it listed, then delete it via the confirm step", async () => {
|
test("users CRUD: create a user, see it listed, then delete it via the confirm step", async () => {
|
||||||
const email = `e2e-${suffix}@plainpages.local`;
|
const email = `e2e-${suffix}@plainpages.local`;
|
||||||
await page.goto("/admin/users/new");
|
await page.goto("/auth-admin/users/new");
|
||||||
await page.fill('input[name="email"]', email);
|
await page.fill('input[name="email"]', email);
|
||||||
await page.fill('input[name="first"]', "E2E");
|
await page.fill('input[name="first"]', "E2E");
|
||||||
await page.fill('input[name="last"]', "User");
|
await page.fill('input[name="last"]', "User");
|
||||||
await page.locator('.form-card button[type="submit"]').click();
|
await page.locator('.form-card button[type="submit"]').click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/admin\/users(\?|$)/); // PRG back to the list
|
await expect(page).toHaveURL(/\/auth-admin\/users(\?|$)/); // PRG back to the list
|
||||||
const row = page.locator("tr", { hasText: email });
|
const row = page.locator("tr", { hasText: email });
|
||||||
await expect(row).toBeVisible();
|
await expect(row).toBeVisible();
|
||||||
|
|
||||||
// Row actions sit behind the kebab popover: opening it reveals them, in the top layer, so the
|
// Row actions sit behind the kebab popover: opening it reveals them, in the top layer, so the
|
||||||
// scrolling table around the row cannot clip the panel.
|
// scrolling table around the row cannot clip the panel.
|
||||||
await row.locator("button.kebab").click();
|
await row.locator("button.kebab").click();
|
||||||
await expect(row.locator('a[href^="/admin/users/"]').first()).toBeVisible();
|
await expect(row.locator('a[href^="/auth-admin/users/"]').first()).toBeVisible();
|
||||||
|
|
||||||
// Delete through the confirm interstitial (the row's Edit link carries the id).
|
// Delete through the confirm interstitial (the row's Edit link carries the id).
|
||||||
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
|
const editHref = await row.locator('a[href^="/auth-admin/users/"]').first().getAttribute("href");
|
||||||
await page.goto(`${editHref}/delete`);
|
await page.goto(`${editHref}/delete`);
|
||||||
await page.getByRole("button", { name: "Delete user" }).click(); // the confirm form's danger button
|
await page.getByRole("button", { name: "Delete user" }).click(); // the confirm form's danger button
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/admin\/users(\?|$)/);
|
await expect(page).toHaveURL(/\/auth-admin\/users(\?|$)/);
|
||||||
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
|
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -143,8 +144,8 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
const email = `admin-recovery-${suffix}@plainpages.local`;
|
const email = `admin-recovery-${suffix}@plainpages.local`;
|
||||||
await createIdentity(email);
|
await createIdentity(email);
|
||||||
await page.goto("/admin/users");
|
await page.goto("/auth-admin/users");
|
||||||
const editHref = await page.locator("tr", { hasText: email }).locator('a[href^="/admin/users/"]').first().getAttribute("href");
|
const editHref = await page.locator("tr", { hasText: email }).locator('a[href^="/auth-admin/users/"]').first().getAttribute("href");
|
||||||
await page.goto(editHref!);
|
await page.goto(editHref!);
|
||||||
await page.getByRole("button", { name: "Generate recovery code" }).click();
|
await page.getByRole("button", { name: "Generate recovery code" }).click();
|
||||||
const code = await page.locator(".recovery-code code").innerText();
|
const code = await page.locator(".recovery-code code").innerText();
|
||||||
@@ -162,27 +163,27 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
// 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}`;
|
||||||
await page.goto("/admin/groups/new");
|
await page.goto("/auth-admin/groups/new");
|
||||||
await page.fill('input[name="name"]', group);
|
await page.fill('input[name="name"]', group);
|
||||||
await page.locator('select[name="member"]').selectOption({ index: 1 });
|
await page.locator('select[name="member"]').selectOption({ index: 1 });
|
||||||
await page.locator('.form-card button[type="submit"]').click();
|
await page.locator('.form-card button[type="submit"]').click();
|
||||||
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
|
await expect(page).toHaveURL(/\/auth-admin\/groups(\?|\/|$)/);
|
||||||
await expect(page.locator("main")).toContainText(group);
|
await expect(page.locator("main")).toContainText(group);
|
||||||
|
|
||||||
// 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(`/auth-admin/groups/${group}`);
|
||||||
allowConsole(/status of 409 .*\/admin\/groups\/[^/]+\/permissions/); // the stale save below, refused on purpose
|
allowConsole(/status of 409 .*\/auth-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.
|
// 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() }));
|
const staleTab = await watchedPage(await browser.newContext({ storageState: await page.context().storageState() }));
|
||||||
await staleTab.goto(`/admin/groups/${group}`);
|
await staleTab.goto(`/auth-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();
|
||||||
await scheduling.check();
|
await scheduling.check();
|
||||||
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(`/auth-admin/groups/${group}`));
|
||||||
await expect(page.getByText("Permissions saved.")).toBeVisible();
|
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();
|
||||||
|
|
||||||
@@ -193,11 +194,26 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await expect(staleTab.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
|
await expect(staleTab.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
|
||||||
await expect(staleTab.locator('input[name="permission"][value="scheduling:write"]')).not.toBeChecked();
|
await expect(staleTab.locator('input[name="permission"][value="scheduling:write"]')).not.toBeChecked();
|
||||||
await staleTab.context().close();
|
await staleTab.context().close();
|
||||||
|
|
||||||
|
// No screen can grant a name no plugin declares, so write one to Keto directly.
|
||||||
|
const stray = "retired:write";
|
||||||
|
const put = await fetch(`${KETO_WRITE}/admin/relation-tuples`, {
|
||||||
|
body: JSON.stringify({ namespace: "Permission", object: stray, relation: "granted", subject_set: { namespace: "Group", object: group, relation: "members" } }),
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
method: "PUT",
|
||||||
|
});
|
||||||
|
expect(put.ok).toBe(true);
|
||||||
|
await page.goto(`/auth-admin/groups/${group}`);
|
||||||
|
await expect(page.getByRole("heading", { name: "Permissions no installed plugin declares" })).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: `Remove ${stray}` }).click();
|
||||||
|
await expect(page.getByText("Permission removed.")).toBeVisible();
|
||||||
|
await expect(page.getByText(stray)).toHaveCount(0);
|
||||||
|
await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked(); // the declared grant stays
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
const name = `e2e-client-${suffix}`;
|
const name = `e2e-client-${suffix}`;
|
||||||
await page.goto("/admin/clients");
|
await page.goto("/auth-admin/clients");
|
||||||
await page.getByRole("link", { name: "Register client" }).click();
|
await page.getByRole("link", { name: "Register client" }).click();
|
||||||
await page.fill('input[name="name"]', name);
|
await page.fill('input[name="name"]', name);
|
||||||
await page.fill('textarea[name="redirectUris"]', "https://app.example.com/callback");
|
await page.fill('textarea[name="redirectUris"]', "https://app.example.com/callback");
|
||||||
@@ -210,17 +226,17 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await expect(page.locator("#csecret")).toHaveValue(/.+/);
|
await expect(page.locator("#csecret")).toHaveValue(/.+/);
|
||||||
|
|
||||||
// Listed; the row header links to the plain detail, which never shows the secret again.
|
// Listed; the row header links to the plain detail, which never shows the secret again.
|
||||||
await page.goto("/admin/clients");
|
await page.goto("/auth-admin/clients");
|
||||||
const row = page.locator("tr", { hasText: name });
|
const row = page.locator("tr", { hasText: name });
|
||||||
await expect(row).toBeVisible();
|
await expect(row).toBeVisible();
|
||||||
await row.getByRole("link", { name }).click();
|
await row.getByRole("link", { name }).click();
|
||||||
await expect(page).toHaveURL(new RegExp(`/admin/clients/${clientId}`));
|
await expect(page).toHaveURL(new RegExp(`/auth-admin/clients/${clientId}`));
|
||||||
await expect(page.locator("#csecret")).toHaveCount(0);
|
await expect(page.locator("#csecret")).toHaveCount(0);
|
||||||
|
|
||||||
// Delete through the confirm interstitial (danger link on the detail → confirm form's button).
|
// Delete through the confirm interstitial (danger link on the detail → confirm form's button).
|
||||||
await page.getByRole("link", { name: "Delete client" }).click();
|
await page.getByRole("link", { name: "Delete client" }).click();
|
||||||
await page.getByRole("button", { name: "Delete client" }).click();
|
await page.getByRole("button", { name: "Delete client" }).click();
|
||||||
await expect(page).toHaveURL(/\/admin\/clients(\?|$)/);
|
await expect(page).toHaveURL(/\/auth-admin\/clients(\?|$)/);
|
||||||
await expect(page.locator("tr", { hasText: name })).toHaveCount(0);
|
await expect(page.locator("tr", { hasText: name })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -237,7 +253,7 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
await page.goto("/admin/plugin-settings");
|
await page.goto("/auth-admin/plugin-settings");
|
||||||
await expect(page.locator("h1")).toHaveText("Plugin settings");
|
await expect(page.locator("h1")).toHaveText("Plugin settings");
|
||||||
// The reference plugin's one declared setting, and the variable an operator would set for it.
|
// The reference plugin's one declared setting, and the variable an operator would set for it.
|
||||||
const scheduling = page.locator("table").filter({ hasText: "PLUGIN_SETTING_SCHEDULING_UPSTREAM" });
|
const scheduling = page.locator("table").filter({ hasText: "PLUGIN_SETTING_SCHEDULING_UPSTREAM" });
|
||||||
@@ -250,14 +266,14 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
test("logout: signing out ends the session and returns to the login page", async () => {
|
test("logout: signing out ends the session and returns to the login page", async () => {
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
await page.locator("button.profile").click(); // open the profile dropdown
|
await page.locator("button.profile").click(); // open the profile dropdown
|
||||||
// Sign out is the only item in it — the menu offers nothing that goes nowhere.
|
await expect(page.locator("#profile-menu .menu-item")).toHaveText(["My shifts", "Sign out"]);
|
||||||
await expect(page.locator("#profile-menu .menu-item")).toHaveText(["Sign out"]);
|
await expect(page.locator('#profile-menu a.menu-item[href="/scheduling/mine"]')).toHaveCount(1);
|
||||||
await page.locator('form[action="/logout"] button[type="submit"]').click();
|
await page.locator('form[action="/logout"] button[type="submit"]').click();
|
||||||
await page.waitForURL(/\/login(\?|$)/);
|
await page.waitForURL(/\/login(\?|$)/);
|
||||||
// The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav).
|
// The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav).
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
await expect(page).toHaveURL(/\/login(\?|$)/);
|
await expect(page).toHaveURL(/\/login(\?|$)/);
|
||||||
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
|
await expect(page.locator('.sidebar a[href="/auth-admin/users"]')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -265,13 +281,13 @@ test("return_to: a deep link while logged out returns to that page after login",
|
|||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
// A gated deep link, logged out → bounced to the themed login (return_to is baked into the Kratos
|
// A gated deep link, logged out → bounced to the themed login (return_to is baked into the Kratos
|
||||||
// flow server-side, so it's consumed, not shown in the settled URL).
|
// flow server-side, so it's consumed, not shown in the settled URL).
|
||||||
await page.goto("/admin/users");
|
await page.goto("/auth-admin/users");
|
||||||
await expect(page).toHaveURL(/\/login(\?|$)/);
|
await expect(page).toHaveURL(/\/login(\?|$)/);
|
||||||
await page.fill('input[name="identifier"]', ADMIN_EMAIL);
|
await page.fill('input[name="identifier"]', ADMIN_EMAIL);
|
||||||
await page.fill('input[name="password"]', ADMIN_PASSWORD);
|
await page.fill('input[name="password"]', ADMIN_PASSWORD);
|
||||||
await page.locator('.auth-form button[type="submit"]').click();
|
await page.locator('.auth-form button[type="submit"]').click();
|
||||||
// Completion routes through /auth/complete (mints the JWT) and on to the requested page, not the dashboard.
|
// Completion routes through /auth/complete (mints the JWT) and on to the requested page, not the dashboard.
|
||||||
await expect(page).toHaveURL(/\/admin\/users(\?|$)/);
|
await expect(page).toHaveURL(/\/auth-admin\/users(\?|$)/);
|
||||||
await expect(page.locator("h1")).toHaveText("Users");
|
await expect(page.locator("h1")).toHaveText("Users");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -294,7 +310,7 @@ test("mocked SSO login: the provider button signs a user in via OIDC", async ({
|
|||||||
// Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in.
|
// Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in.
|
||||||
await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL);
|
await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL);
|
||||||
// A fresh SSO identity holds no 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="/auth-admin/users"]')).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The newest one-time code Kratos' courier mailed to `to`, read from the mail catcher (compose.full.yml).
|
// The newest one-time code Kratos' courier mailed to `to`, read from the mail catcher (compose.full.yml).
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ test("the reference plugin: public Overview is open to all, My shifts takes any
|
|||||||
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
|
await expect(page.locator('.sidebar nav 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
|
// 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.
|
// and never a claim about what is assigned.
|
||||||
|
|||||||
+1
-1
@@ -6,6 +6,6 @@ across (or bind-mount your own) and restart.
|
|||||||
| Path | Copy into | Example of |
|
| Path | Copy into | Example of |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `@plainpages/plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
|
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `@plainpages/plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
|
||||||
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
|
| [`plugins/auth-admin/`](plugins/auth-admin/) | `plugins/auth-admin/` | The system-admin plugin: the Users / Groups / OAuth2-clients / Plugin-settings screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/auth-admin/README.md). |
|
||||||
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
|
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
|
||||||
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `PLUGIN_SETTING_SCHEDULING_UPSTREAM` at the real thing instead. |
|
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `PLUGIN_SETTING_SCHEDULING_UPSTREAM` at the real thing instead. |
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
// cp examples/config/menu.ts config/menu.ts
|
// cp examples/config/menu.ts config/menu.ts
|
||||||
// Absent config = built-in defaults.
|
// Absent config = built-in defaults.
|
||||||
//
|
//
|
||||||
// Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins — the
|
// Brand the app, reorder/rename/group/hide nav nodes and rename/hide profile-menu entries (by their
|
||||||
// override always wins, applied before the per-user permission filter. Every field is optional.
|
// `id`) across all plugins — the override always wins, applied before the per-user permission
|
||||||
|
// filter. Every field is optional.
|
||||||
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README → The menu system.
|
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README → The menu system.
|
||||||
|
|
||||||
import { defineMenu } from "#menu-config";
|
import { defineMenu } from "#menu-config";
|
||||||
@@ -16,7 +17,8 @@ export default defineMenu({
|
|||||||
// theme: "auto", // default color theme: auto | light | dark
|
// theme: "auto", // default color theme: auto | light | dark
|
||||||
},
|
},
|
||||||
|
|
||||||
// Operator override (rename → group → order → hide), keyed by node id.
|
// Operator override (rename → group → order → hide), keyed by nav-node id; `rename` and `hide` also
|
||||||
|
// take a profile-menu entry's id, such as "scheduling:profile-mine".
|
||||||
override: {
|
override: {
|
||||||
// rename: { people: "Staff" }, // node id → new label (or a catalog key)
|
// rename: { people: "Staff" }, // node id → new label (or a catalog key)
|
||||||
// groups: [{ id: "admin", label: "Admin", children: ["users", "groups"] }],
|
// groups: [{ id: "admin", label: "Admin", children: ["users", "groups"] }],
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# Admin — the system-administration plugin
|
# auth-admin — the plugin that administers users, groups, permissions, OAuth2 clients and plugin settings
|
||||||
|
|
||||||
The Users / Groups / OAuth2-clients screens for running Plainpages itself, shipped as a **drop-in
|
The Users / Groups / OAuth2-clients screens for running Plainpages itself, shipped as a **drop-in
|
||||||
example plugin** so a fresh clone has no admin GUI until you opt in. Copy this folder into `plugins/`
|
example plugin** so a fresh clone has no admin GUI until you opt in. Copy this folder into `plugins/`
|
||||||
(it keeps the id and mount path `admin`, so the screens live at `/admin/*`) and restart:
|
(its folder name `auth-admin` is its id and mount path, so the screens live at `/auth-admin/*`) and restart:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp -r examples/plugins/admin plugins/admin
|
cp -r examples/plugins/auth-admin plugins/auth-admin
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ Admin section entirely.
|
|||||||
There is **no Permissions screen**. Permission names are declared in plugin code, not created in a
|
There is **no Permissions screen**. Permission names are declared in plugin code, not created in a
|
||||||
GUI, so the host's catalog (`ctx.declaredPermissions`) is the fixed list — and holding one is a
|
GUI, so the host's catalog (`ctx.declaredPermissions`) is the fixed list — and holding one is a
|
||||||
property of a user or a group, edited as a checkbox list on those two screens (`admin-grants.ts`).
|
property of a user or a group, edited as a checkbox list on those two screens (`admin-grants.ts`).
|
||||||
|
A grant of a name no installed plugin declares is listed apart on the same page, with a Remove.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
+5
-5
@@ -59,15 +59,15 @@ test("validateClientInput requires a name, ≥1 redirect URI, and absolute redir
|
|||||||
test("buildClientsListModel filters by search, paginates; the name links to the detail page", () => {
|
test("buildClientsListModel filters by search, paginates; the name links to the detail page", () => {
|
||||||
const clients = Array.from({ length: 30 }, (_, i) => ({ client_id: `id-${String(i).padStart(2, "0")}`, client_name: `app-${String(i).padStart(2, "0")}`, token_endpoint_auth_method: "client_secret_basic" }));
|
const clients = Array.from({ length: 30 }, (_, i) => ({ client_id: `id-${String(i).padStart(2, "0")}`, client_name: `app-${String(i).padStart(2, "0")}`, token_endpoint_auth_method: "client_secret_basic" }));
|
||||||
|
|
||||||
const all = buildClientsListModel({ clients, url: "http://x/admin/clients" });
|
const all = buildClientsListModel({ clients, url: "http://x/auth-admin/clients" });
|
||||||
assert.equal(all.pagination.summary.total, 30);
|
assert.equal(all.pagination.summary.total, 30);
|
||||||
assert.equal(all.table.rows.length, 25); // default page size
|
assert.equal(all.table.rows.length, 25); // default page size
|
||||||
assert.equal(all.title, "OAuth2 clients");
|
assert.equal(all.title, "OAuth2 clients");
|
||||||
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
||||||
assert.equal(first.rowHeader.text, "app-00");
|
assert.equal(first.rowHeader.text, "app-00");
|
||||||
assert.equal(first.rowHeader.href, "/admin/clients/id-00");
|
assert.equal(first.rowHeader.href, "/auth-admin/clients/id-00");
|
||||||
|
|
||||||
const one = buildClientsListModel({ clients, url: "http://x/admin/clients?q=app-07" });
|
const one = buildClientsListModel({ clients, url: "http://x/auth-admin/clients?q=app-07" });
|
||||||
assert.equal(one.pagination.summary.total, 1);
|
assert.equal(one.pagination.summary.total, 1);
|
||||||
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
||||||
});
|
});
|
||||||
@@ -75,7 +75,7 @@ test("buildClientsListModel filters by search, paginates; the name links to the
|
|||||||
test("buildClientFormModel: a register form with name + scope fields; values reflected on error", () => {
|
test("buildClientFormModel: a register form with name + scope fields; values reflected on error", () => {
|
||||||
const m = buildClientFormModel({ csrfToken: "tok.sig" });
|
const m = buildClientFormModel({ csrfToken: "tok.sig" });
|
||||||
assert.equal(m.title, "Register client");
|
assert.equal(m.title, "Register client");
|
||||||
assert.equal(m.form.action, "/admin/clients");
|
assert.equal(m.form.action, "/auth-admin/clients");
|
||||||
assert.equal(m.form.submitLabel, "Register client");
|
assert.equal(m.form.submitLabel, "Register client");
|
||||||
assert.equal(m.form.csrfToken, "tok.sig");
|
assert.equal(m.form.csrfToken, "tok.sig");
|
||||||
assert.equal(m.form.nameField.required, true);
|
assert.equal(m.form.nameField.required, true);
|
||||||
@@ -94,7 +94,7 @@ test("buildClientDetailModel: client info + delete action; the one-time secret +
|
|||||||
|
|
||||||
const plain = buildClientDetailModel({ client });
|
const plain = buildClientDetailModel({ client });
|
||||||
assert.equal(plain.title, "Acme");
|
assert.equal(plain.title, "Acme");
|
||||||
assert.equal(plain.delete.action, "/admin/clients/c1/delete");
|
assert.equal(plain.delete.action, "/auth-admin/clients/c1/delete");
|
||||||
assert.equal(plain.created, false);
|
assert.equal(plain.created, false);
|
||||||
assert.equal(plain.secret, undefined);
|
assert.equal(plain.secret, undefined);
|
||||||
|
|
||||||
+8
-24
@@ -5,7 +5,7 @@
|
|||||||
// PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin
|
// PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin
|
||||||
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
|
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
|
||||||
|
|
||||||
import { can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
|
import { buildPaginationModel, can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
|
||||||
import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||||
import type { FieldConfig } from "./admin-users.ts";
|
import type { FieldConfig } from "./admin-users.ts";
|
||||||
|
|
||||||
@@ -122,7 +122,7 @@ export function buildClientsListModel(opts: {
|
|||||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.nav.section") }, { label: t("admin.clients.title") }],
|
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.nav.section") }, { label: t("admin.clients.title") }],
|
||||||
canWrite: opts.canWrite !== false,
|
canWrite: opts.canWrite !== false,
|
||||||
filterBar: listFilterBar(state, t),
|
filterBar: listFilterBar(state, t),
|
||||||
pagination: listPagination(state, page, t),
|
pagination: buildPaginationModel(page, (n) => listHref(state, { page: n }), { label: t("admin.clients.pagination"), pageSizes: PAGE_SIZES }),
|
||||||
table: listTable(rows, t),
|
table: listTable(rows, t),
|
||||||
title: t("admin.clients.title"),
|
title: t("admin.clients.title"),
|
||||||
};
|
};
|
||||||
@@ -158,22 +158,6 @@ function listFilterBar(state: ListState, t: Translate) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
|
|
||||||
const hidden: { name: string; value: string }[] = [];
|
|
||||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
|
||||||
return {
|
|
||||||
label: t("admin.clients.pagination"),
|
|
||||||
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
|
||||||
pages: page.pages.map((p) =>
|
|
||||||
p.ellipsis ? { ellipsis: true }
|
|
||||||
: p.current ? { current: true, label: String(p.page) }
|
|
||||||
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
|
|
||||||
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
|
|
||||||
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
|
|
||||||
summary: { from: page.from, to: page.to, total: page.total },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- register form + detail view models ----
|
// ---- register form + detail view models ----
|
||||||
|
|
||||||
export function buildClientFormModel(opts: {
|
export function buildClientFormModel(opts: {
|
||||||
@@ -273,13 +257,13 @@ const canWriteClients = (ctx: RequestContext): boolean => can(ctx, permissionNam
|
|||||||
const clientDetailResult = (ctx: RequestContext, client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): RouteResult =>
|
const clientDetailResult = (ctx: RequestContext, client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): RouteResult =>
|
||||||
({ data: { chrome: ctx.chrome, model: buildClientDetailModel({ canWrite: canWriteClients(ctx), client: toClientView(client), csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-detail" });
|
({ data: { chrome: ctx.chrome, model: buildClientDetailModel({ canWrite: canWriteClients(ctx), client: toClientView(client), csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-detail" });
|
||||||
|
|
||||||
// GET /admin/clients — the list.
|
// GET /auth-admin/clients — the list.
|
||||||
export const clientsList = withClients(async ({ ctx, hydra }) => {
|
export const clientsList = withClients(async ({ ctx, hydra }) => {
|
||||||
const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE });
|
const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE });
|
||||||
return { data: { chrome: ctx.chrome, model: buildClientsListModel({ canWrite: canWriteClients(ctx), clients, csrfToken: ctx.chrome.csrfToken, t: ctx.t, url: ctx.url }) }, view: "clients" };
|
return { data: { chrome: ctx.chrome, model: buildClientsListModel({ canWrite: canWriteClients(ctx), clients, csrfToken: ctx.chrome.csrfToken, t: ctx.t, url: ctx.url }) }, view: "clients" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/clients — register; on success show the one-time secret directly (no PRG, Hydra never
|
// POST /auth-admin/clients — register; on success show the one-time secret directly (no PRG, Hydra never
|
||||||
// returns it again). A Hydra 4xx (bad redirect/scope) re-renders the form (400); a 5xx rethrows → 500.
|
// returns it again). A Hydra 4xx (bad redirect/scope) re-renders the form (400); a 5xx rethrows → 500.
|
||||||
export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
|
export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
|
||||||
const input = readClientInput((await guardedForm(ctx))!);
|
const input = readClientInput((await guardedForm(ctx))!);
|
||||||
@@ -296,13 +280,13 @@ export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
|
|||||||
return clientDetailResult(ctx, created, { created: true, ...(created.client_secret ? { secret: created.client_secret } : {}) });
|
return clientDetailResult(ctx, created, { created: true, ...(created.client_secret ? { secret: created.client_secret } : {}) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /admin/clients/new — the register form.
|
// GET /auth-admin/clients/new — the register form.
|
||||||
export const clientsNewForm = withClients(({ ctx }) => Promise.resolve(clientFormResult(ctx, {})), "write");
|
export const clientsNewForm = withClients(({ ctx }) => Promise.resolve(clientFormResult(ctx, {})), "write");
|
||||||
|
|
||||||
// GET /admin/clients/:id — the detail (read-only; the secret is shown only once, at creation).
|
// GET /auth-admin/clients/:id — the detail (read-only; the secret is shown only once, at creation).
|
||||||
export const clientsDetail = withClient((deps, client) => Promise.resolve(clientDetailResult(deps.ctx, client)));
|
export const clientsDetail = withClient((deps, client) => Promise.resolve(clientDetailResult(deps.ctx, client)));
|
||||||
|
|
||||||
// GET /admin/clients/:id/delete — the deliberate confirm step.
|
// GET /auth-admin/clients/:id/delete — the deliberate confirm step.
|
||||||
export const clientsDeleteConfirm = withClient((deps, client, id) => {
|
export const clientsDeleteConfirm = withClient((deps, client, id) => {
|
||||||
const base = detailHref(id);
|
const base = detailHref(id);
|
||||||
const name = toClientView(client).name;
|
const name = toClientView(client).name;
|
||||||
@@ -314,7 +298,7 @@ export const clientsDeleteConfirm = withClient((deps, client, id) => {
|
|||||||
}) }, view: "confirm" });
|
}) }, view: "confirm" });
|
||||||
}, "write");
|
}, "write");
|
||||||
|
|
||||||
// POST /admin/clients/:id/delete — perform it.
|
// POST /auth-admin/clients/:id/delete — perform it.
|
||||||
export const clientsDelete = withClient(async ({ ctx, hydra, user }, _client, id) => {
|
export const clientsDelete = withClient(async ({ ctx, hydra, user }, _client, id) => {
|
||||||
await guardedForm(ctx); // CSRF-verify the POST
|
await guardedForm(ctx); // CSRF-verify the POST
|
||||||
await hydra.deleteClient(id);
|
await hydra.deleteClient(id);
|
||||||
+46
-17
@@ -4,7 +4,7 @@ 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 type { KetoClient } from "@plainpages/plugin-api";
|
import type { KetoClient } from "@plainpages/plugin-api";
|
||||||
import { applyGrants, baselineToken, buildPermissionPicker, grantDiff, grantTuple, groupSubject, isStale, userSubject } from "./admin-grants.ts";
|
import { applyGrants, baselineToken, buildPermissionPicker, grantDiff, grantTuple, groupSubject, isStale, removeUndeclaredGrant, userSubject } from "./admin-grants.ts";
|
||||||
|
|
||||||
const declared: PermissionDecl[] = [
|
const declared: PermissionDecl[] = [
|
||||||
{ description: "View users", name: "users:read" },
|
{ description: "View users", name: "users:read" },
|
||||||
@@ -36,8 +36,8 @@ test("grantDiff ignores anything the plugins don't declare, in both directions",
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("buildPermissionPicker ticks what is held and carries each declaration's description", () => {
|
test("buildPermissionPicker ticks what is held and carries each declaration's description", () => {
|
||||||
const picker = buildPermissionPicker({ action: "/admin/users/u1/permissions", declared, direct: ["users:write"] });
|
const picker = buildPermissionPicker({ action: "/auth-admin/users/u1/permissions", declared, direct: ["users:write"] });
|
||||||
assert.equal(picker.action, "/admin/users/u1/permissions");
|
assert.equal(picker.action, "/auth-admin/users/u1/permissions");
|
||||||
assert.deepEqual(picker.choices.map((c) => c.name), ["users:read", "users:write", "groups:read"]);
|
assert.deepEqual(picker.choices.map((c) => c.name), ["users:read", "users:write", "groups:read"]);
|
||||||
assert.deepEqual(picker.choices.map((c) => c.checked), [false, true, false]);
|
assert.deepEqual(picker.choices.map((c) => c.checked), [false, true, false]);
|
||||||
assert.equal(picker.choices[0]?.description, "View users");
|
assert.equal(picker.choices[0]?.description, "View users");
|
||||||
@@ -106,24 +106,53 @@ test("isStale refuses a missing or old token, but only for a save that would cha
|
|||||||
assert.equal(isStale(old, declared, held, nothing), false); // a repeated save has nothing to refuse
|
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 () => {
|
test("applyGrants sends the whole change as one Keto transaction, and a failure applies none of it", async () => {
|
||||||
const log: string[] = [];
|
const calls: { delete: string[]; insert: string[] }[] = [];
|
||||||
const keto = (failOn?: string): KetoClient => ({
|
const keto = (fail = false): KetoClient => ({
|
||||||
check: async () => false,
|
check: async () => false,
|
||||||
deleteTuple: async (tp) => { if (tp.object === failOn) throw new Error("boom"); log.push(`delete ${tp.object}`); },
|
deleteTuple: async () => { throw new Error("unused"); },
|
||||||
expand: async () => ({ type: "leaf" }),
|
expand: async () => ({ type: "leaf" }),
|
||||||
listRelations: async () => ({ nextPageToken: null, tuples: [] }),
|
listRelations: async () => ({ nextPageToken: null, tuples: [] }),
|
||||||
writeTuple: async (tp) => { if (tp.object === failOn) throw new Error("boom"); log.push(`write ${tp.object}`); },
|
transactTuples: async (c) => {
|
||||||
|
if (fail) throw new Error("boom");
|
||||||
|
calls.push({ delete: c.delete.map((tp) => tp.object), insert: c.insert.map((tp) => tp.object) });
|
||||||
|
},
|
||||||
|
writeTuple: async () => { throw new Error("unused"); },
|
||||||
});
|
});
|
||||||
const diff = { grant: ["a:read", "b:read"], revoke: ["c:read"] };
|
const diff = { grant: ["a:read", "b:read"], revoke: ["c:read"] };
|
||||||
|
|
||||||
const ok = await applyGrants(keto(), userSubject("u1"), diff);
|
await applyGrants(keto(), userSubject("u1"), diff);
|
||||||
assert.deepEqual(ok, { applied: diff });
|
assert.deepEqual(calls, [{ delete: ["c:read"], insert: ["a:read", "b:read"] }]);
|
||||||
assert.deepEqual(log, ["write a:read", "write b:read", "delete c:read"]);
|
await assert.rejects(applyGrants(keto(true), userSubject("u1"), diff), /boom/);
|
||||||
|
});
|
||||||
log.length = 0;
|
|
||||||
const failed = await applyGrants(keto("b:read"), userSubject("u1"), diff);
|
test("buildPermissionPicker lists direct grants no plugin declares, with a remove action only for a writer", () => {
|
||||||
assert.deepEqual(failed.applied, { grant: ["a:read"], revoke: [] });
|
const direct = ["zed:read", "users:read", "old:write"];
|
||||||
assert.ok(failed.error instanceof Error);
|
const picker = buildPermissionPicker({ action: "/auth-admin/users/u1/permissions", declared, direct });
|
||||||
assert.deepEqual(log, ["write a:read"]); // nothing after the failure was attempted
|
assert.deepEqual(picker.undeclared?.names, ["old:write", "zed:read"]);
|
||||||
|
assert.equal(picker.undeclared?.action, "/auth-admin/users/u1/permissions/remove");
|
||||||
|
assert.ok(picker.undeclared?.remove);
|
||||||
|
assert.equal(buildPermissionPicker({ action: "/x", declared, direct, readOnly: true }).undeclared?.remove, undefined);
|
||||||
|
assert.equal(buildPermissionPicker({ action: "/x", declared, direct: ["users:read"] }).undeclared, undefined);
|
||||||
|
assert.equal(picker.baseline, buildPermissionPicker({ action: "/x", declared, direct: ["users:read"] }).baseline, "an undeclared grant doesn't make a form stale");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removeUndeclaredGrant deletes one held, undeclared grant and refuses anything else", async () => {
|
||||||
|
const subject = userSubject("u1");
|
||||||
|
const tuples = [grantTuple("old:write", subject), grantTuple("users:read", subject), grantTuple("old:write", userSubject("u2"))];
|
||||||
|
const deleted: string[] = [];
|
||||||
|
const keto: KetoClient = {
|
||||||
|
check: async () => false,
|
||||||
|
deleteTuple: async (tp) => { deleted.push(`${tp.object} ${tp.subject_id}`); },
|
||||||
|
expand: async () => ({ type: "leaf" }),
|
||||||
|
listRelations: async (q = {}) => ({ nextPageToken: null, tuples: tuples.filter((tp) => tp.subject_id === q.subject_id) }),
|
||||||
|
transactTuples: async () => { throw new Error("unused"); },
|
||||||
|
writeTuple: async () => { throw new Error("unused"); },
|
||||||
|
};
|
||||||
|
assert.equal(await removeUndeclaredGrant(keto, subject, declared, "old:write"), "old:write");
|
||||||
|
assert.deepEqual(deleted, ["old:write user:u1"]);
|
||||||
|
assert.equal(await removeUndeclaredGrant(keto, subject, declared, "users:read"), undefined, "a declared name is the picker's to change");
|
||||||
|
assert.equal(await removeUndeclaredGrant(keto, subject, declared, "never:held"), undefined);
|
||||||
|
assert.equal(await removeUndeclaredGrant(keto, subject, declared, null), undefined);
|
||||||
|
assert.deepEqual(deleted, ["old:write user:u1"]);
|
||||||
});
|
});
|
||||||
+48
-17
@@ -3,8 +3,8 @@
|
|||||||
// resolves a group's grant transitively at login.
|
// resolves a group's grant transitively at login.
|
||||||
//
|
//
|
||||||
// The set of permissions that *exist* is `ctx.declaredPermissions` — the host's catalog, built from
|
// The set of permissions that *exist* is `ctx.declaredPermissions` — the host's catalog, built from
|
||||||
// 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: a grant is a property of a
|
||||||
// Permissions screen is gone: a grant is a property of a user or a group, edited where they are.
|
// user or a group, edited where they are.
|
||||||
|
|
||||||
import { createHash } from "node:crypto";
|
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";
|
||||||
@@ -12,11 +12,17 @@ import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate }
|
|||||||
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 REMOVE_FIELD = "remove"; // the hidden field naming the undeclared grant a Remove form drops
|
||||||
export const BASELINE_FIELD = "baseline"; // the hidden field carrying baselineToken
|
export const BASELINE_FIELD = "baseline"; // the hidden field carrying baselineToken
|
||||||
export const SAVED_NOTICE = "permissions-saved"; // the `info-msg` a successful save redirects with
|
export const SAVED_NOTICE = "permissions-saved"; // the `info-msg` a successful save redirects with
|
||||||
|
export const REMOVED_NOTICE = "permission-removed"; // the `info-msg` a Remove redirects with; its own words, since the checkboxes were not saved
|
||||||
|
|
||||||
// Only that exact value counts, so a crafted link can't put words on the page.
|
// Only these exact values count, 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 function grantsNotice(url: URL, t: Translate): string | undefined {
|
||||||
|
const msg = url.searchParams.get("info-msg");
|
||||||
|
if (msg === SAVED_NOTICE) return t("admin.grants.saved");
|
||||||
|
return msg === REMOVED_NOTICE ? t("admin.grants.removed") : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export type GrantSubject = { subject_id: string } | { subject_set: SubjectSet };
|
export type GrantSubject = { subject_id: string } | { subject_set: SubjectSet };
|
||||||
|
|
||||||
@@ -73,6 +79,18 @@ export interface PermissionPicker {
|
|||||||
pending: string | undefined;
|
pending: string | undefined;
|
||||||
readOnly: boolean; // the viewer holds :read but not :write — show the state, offer no save
|
readOnly: boolean; // the viewer holds :read but not :write — show the state, offer no save
|
||||||
submit: string;
|
submit: string;
|
||||||
|
undeclared: UndeclaredGrants | undefined; // set when the subject holds a name no installed plugin declares
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direct grants of names nobody declares (their plugin was uninstalled, or the name was written to
|
||||||
|
// Keto by hand). grantDiff never touches them, so they get their own list and a Remove per name.
|
||||||
|
export interface UndeclaredGrants {
|
||||||
|
action: string; // where each Remove posts
|
||||||
|
field: string;
|
||||||
|
hint: string;
|
||||||
|
legend: string;
|
||||||
|
names: string[];
|
||||||
|
remove: string | undefined; // the button label; unset for a reader, who is shown the names only
|
||||||
}
|
}
|
||||||
|
|
||||||
// The checkbox list: every declared permission, ticked where this subject holds it. A fixed list
|
// The checkbox list: every declared permission, ticked where this subject holds it. A fixed list
|
||||||
@@ -89,6 +107,7 @@ export function buildPermissionPicker(opts: {
|
|||||||
}): PermissionPicker {
|
}): PermissionPicker {
|
||||||
const t = opts.t ?? ((k: string) => k);
|
const t = opts.t ?? ((k: string) => k);
|
||||||
const directSet = new Set(opts.direct);
|
const directSet = new Set(opts.direct);
|
||||||
|
const undeclared = undeclaredHeld(opts.declared, opts.direct);
|
||||||
const effectiveSet = new Set(opts.effective ?? opts.direct);
|
const effectiveSet = new Set(opts.effective ?? opts.direct);
|
||||||
const choices = opts.declared.map((decl) => ({
|
const choices = opts.declared.map((decl) => ({
|
||||||
checked: directSet.has(decl.name) || effectiveSet.has(decl.name),
|
checked: directSet.has(decl.name) || effectiveSet.has(decl.name),
|
||||||
@@ -110,9 +129,22 @@ export function buildPermissionPicker(opts: {
|
|||||||
pending: opts.transitive === true ? t("admin.grants.pending") : undefined,
|
pending: opts.transitive === true ? t("admin.grants.pending") : undefined,
|
||||||
readOnly: opts.readOnly === true,
|
readOnly: opts.readOnly === true,
|
||||||
submit: t("admin.grants.save"),
|
submit: t("admin.grants.save"),
|
||||||
|
undeclared: undeclared.length === 0 ? undefined : {
|
||||||
|
action: `${opts.action}/remove`,
|
||||||
|
field: REMOVE_FIELD,
|
||||||
|
hint: t("admin.grants.undeclaredHint"),
|
||||||
|
legend: t("admin.grants.undeclaredLegend"),
|
||||||
|
names: undeclared,
|
||||||
|
remove: opts.readOnly === true ? undefined : t("admin.grants.remove"),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const undeclaredHeld = (declared: readonly PermissionDecl[], held: readonly string[]): string[] => {
|
||||||
|
const offered = new Set(declared.map((d) => d.name));
|
||||||
|
return held.filter((name) => !offered.has(name)).sort();
|
||||||
|
};
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -126,6 +158,16 @@ export function grantDiff(declared: readonly PermissionDecl[], held: string[], w
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deletes one held grant of a name no plugin declares and returns the name. Deletes nothing for a
|
||||||
|
// declared name, which the picker owns (so a Remove can't take away this plugin's own gates and needs
|
||||||
|
// no self-lockout guard), or for one not held, as after a double click.
|
||||||
|
export async function removeUndeclaredGrant(keto: KetoClient, subject: GrantSubject, declared: readonly PermissionDecl[], name: string | null): Promise<string | undefined> {
|
||||||
|
if (name === null || declared.some((d) => d.name === name)) return undefined;
|
||||||
|
if (!(await heldPermissions(keto, subject)).includes(name)) return undefined;
|
||||||
|
await keto.deleteTuple(grantTuple(name, subject));
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
// A token for the direct grants a form was rendered from. Like grantDiff it covers only the declared
|
// A token for the direct grants a form was rendered from. Like grantDiff it covers only the declared
|
||||||
// names the form offered. It is a digest so an empty set is still a token; it is a guard against a
|
// names the form offered. It is a digest so an empty set is still a token; it is a guard against a
|
||||||
// stale page, not a secret, since anyone who can read the page can compute it.
|
// stale page, not a secret, since anyone who can read the page can compute it.
|
||||||
@@ -142,16 +184,5 @@ export const isStale = (form: { get(name: string): string | null }, declared: re
|
|||||||
|
|
||||||
export interface GrantChange { grant: string[]; revoke: string[] }
|
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
|
export const applyGrants = (keto: KetoClient, subject: GrantSubject, diff: GrantChange): Promise<void> =>
|
||||||
// failure. What was applied by then is returned beside the error, so the caller can still revoke live
|
keto.transactTuples({ delete: diff.revoke.map((name) => grantTuple(name, subject)), insert: diff.grant.map((name) => grantTuple(name, subject)) });
|
||||||
// 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 };
|
|
||||||
}
|
|
||||||
+8
-8
@@ -56,22 +56,22 @@ test("memberView resolves a user subject to its email (else the raw id) and a su
|
|||||||
test("buildGroupsListModel filters by search, sorts, paginates; the name links to the detail page", () => {
|
test("buildGroupsListModel filters by search, sorts, paginates; the name links to the detail page", () => {
|
||||||
const groups = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `team-${String(i).padStart(2, "0")}` }));
|
const groups = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `team-${String(i).padStart(2, "0")}` }));
|
||||||
|
|
||||||
const all = buildGroupsListModel({ groups, url: "http://x/admin/groups" });
|
const all = buildGroupsListModel({ groups, url: "http://x/auth-admin/groups" });
|
||||||
assert.equal(all.pagination.summary.total, 30);
|
assert.equal(all.pagination.summary.total, 30);
|
||||||
assert.equal(all.table.rows.length, 25); // default page size
|
assert.equal(all.table.rows.length, 25); // default page size
|
||||||
assert.equal(all.title, "Groups");
|
assert.equal(all.title, "Groups");
|
||||||
// The group name is the row header, linking to its detail page.
|
// The group name is the row header, linking to its detail page.
|
||||||
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
||||||
assert.equal(first.rowHeader.text, "team-00");
|
assert.equal(first.rowHeader.text, "team-00");
|
||||||
assert.equal(first.rowHeader.href, "/admin/groups/team-00");
|
assert.equal(first.rowHeader.href, "/auth-admin/groups/team-00");
|
||||||
|
|
||||||
// Search narrows + shows a pill.
|
// Search narrows + shows a pill.
|
||||||
const one = buildGroupsListModel({ groups, url: "http://x/admin/groups?q=team-07" });
|
const one = buildGroupsListModel({ groups, url: "http://x/auth-admin/groups?q=team-07" });
|
||||||
assert.equal(one.pagination.summary.total, 1);
|
assert.equal(one.pagination.summary.total, 1);
|
||||||
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
||||||
|
|
||||||
// Sort by members descending puts the biggest group first.
|
// Sort by members descending puts the biggest group first.
|
||||||
const desc = buildGroupsListModel({ groups, url: "http://x/admin/groups?sort=-members" });
|
const desc = buildGroupsListModel({ groups, url: "http://x/auth-admin/groups?sort=-members" });
|
||||||
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "team-29");
|
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "team-29");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ test("buildGroupFormModel: a create form with a required name field + member opt
|
|||||||
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
||||||
const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
||||||
assert.equal(m.title, "New group");
|
assert.equal(m.title, "New group");
|
||||||
assert.equal(m.form.action, "/admin/groups");
|
assert.equal(m.form.action, "/auth-admin/groups");
|
||||||
assert.equal(m.form.submitLabel, "Create group");
|
assert.equal(m.form.submitLabel, "Create group");
|
||||||
assert.equal(m.form.csrfToken, "tok.sig");
|
assert.equal(m.form.csrfToken, "tok.sig");
|
||||||
assert.equal(m.form.nameField.name, "name");
|
assert.equal(m.form.nameField.name, "name");
|
||||||
@@ -105,8 +105,8 @@ test("buildGroupDetailModel: members → rows, add-options exclude current membe
|
|||||||
const m = buildGroupDetailModel({ candidates, group: { name: "eng" }, members });
|
const m = buildGroupDetailModel({ candidates, group: { name: "eng" }, members });
|
||||||
assert.equal(m.title, "eng");
|
assert.equal(m.title, "eng");
|
||||||
assert.equal(m.members.rows.length, 2);
|
assert.equal(m.members.rows.length, 2);
|
||||||
assert.equal(m.members.action, "/admin/groups/eng/members/delete");
|
assert.equal(m.members.action, "/auth-admin/groups/eng/members/delete");
|
||||||
assert.equal(m.add.action, "/admin/groups/eng/members");
|
assert.equal(m.add.action, "/auth-admin/groups/eng/members");
|
||||||
assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]);
|
assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]);
|
||||||
assert.equal(m.delete.action, "/admin/groups/eng/delete");
|
assert.equal(m.delete.action, "/auth-admin/groups/eng/delete");
|
||||||
});
|
});
|
||||||
+31
-43
@@ -6,9 +6,9 @@
|
|||||||
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
|
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
|
||||||
// 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 { buildPaginationModel, 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, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
|
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, grantsNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, REMOVE_FIELD, removeUndeclaredGrant, REMOVED_NOTICE, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
|
||||||
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, 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";
|
||||||
@@ -143,7 +143,7 @@ export function buildGroupsListModel(opts: {
|
|||||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.nav.section") }, { label: t("admin.groups.title") }],
|
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.nav.section") }, { label: t("admin.groups.title") }],
|
||||||
canWrite: opts.canWrite !== false,
|
canWrite: opts.canWrite !== false,
|
||||||
filterBar: listFilterBar(state, t),
|
filterBar: listFilterBar(state, t),
|
||||||
pagination: listPagination(state, page, t),
|
pagination: buildPaginationModel(page, (n) => listHref(state, { page: n }), { label: t("admin.groups.pagination"), pageSizes: PAGE_SIZES }),
|
||||||
table: listTable(rows, state, sort, t),
|
table: listTable(rows, state, sort, t),
|
||||||
title: t("admin.groups.title"),
|
title: t("admin.groups.title"),
|
||||||
};
|
};
|
||||||
@@ -179,23 +179,6 @@ function listFilterBar(state: ListState, t: Translate) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
|
|
||||||
const hidden: { name: string; value: string }[] = [];
|
|
||||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
|
||||||
if (state.sort) hidden.push({ name: "sort", value: state.sort });
|
|
||||||
return {
|
|
||||||
label: t("admin.groups.pagination"),
|
|
||||||
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
|
||||||
pages: page.pages.map((p) =>
|
|
||||||
p.ellipsis ? { ellipsis: true }
|
|
||||||
: p.current ? { current: true, label: String(p.page) }
|
|
||||||
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
|
|
||||||
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
|
|
||||||
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
|
|
||||||
summary: { from: page.from, to: page.to, total: page.total },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- create form + detail view models ----
|
// ---- create form + detail view models ----
|
||||||
|
|
||||||
export function buildGroupFormModel(opts: {
|
export function buildGroupFormModel(opts: {
|
||||||
@@ -348,13 +331,13 @@ const groupFormResult = async (deps: GroupsDeps, extra: { error?: string; values
|
|||||||
return { data: { chrome: deps.ctx.chrome, model: buildGroupFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, t: deps.ctx.t, ...extra }) }, view: "group-form" };
|
return { data: { chrome: deps.ctx.chrome, model: buildGroupFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, t: deps.ctx.t, ...extra }) }, view: "group-form" };
|
||||||
};
|
};
|
||||||
|
|
||||||
// GET /admin/groups — the list.
|
// GET /auth-admin/groups — the list.
|
||||||
export const groupsList = withGroups(async ({ ctx, keto }) => {
|
export const groupsList = withGroups(async ({ ctx, keto }) => {
|
||||||
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
|
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
|
||||||
return { data: { chrome: ctx.chrome, model: buildGroupsListModel({ canWrite: can(ctx, permissionName("groups", "write")), csrfToken: ctx.chrome.csrfToken, groups, t: ctx.t, url: ctx.url }) }, view: "groups" };
|
return { data: { chrome: ctx.chrome, model: buildGroupsListModel({ canWrite: can(ctx, permissionName("groups", "write")), csrfToken: ctx.chrome.csrfToken, groups, t: ctx.t, url: ctx.url }) }, view: "groups" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple).
|
// POST /auth-admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple).
|
||||||
export const groupsCreate = withGroups(async (deps) => {
|
export const groupsCreate = withGroups(async (deps) => {
|
||||||
const { ctx, keto, user } = deps;
|
const { ctx, keto, user } = deps;
|
||||||
const form = (await guardedForm(ctx))!;
|
const form = (await guardedForm(ctx))!;
|
||||||
@@ -370,7 +353,7 @@ export const groupsCreate = withGroups(async (deps) => {
|
|||||||
return { redirect: detailHref(name) };
|
return { redirect: detailHref(name) };
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /admin/groups/new — the create form.
|
// GET /auth-admin/groups/new — the create form.
|
||||||
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write");
|
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write");
|
||||||
|
|
||||||
// The detail + membership page. `notice` and `error` ride the permission picker; `pageError` tops the
|
// The detail + membership page. `notice` and `error` ride the permission picker; `pageError` tops the
|
||||||
@@ -396,11 +379,11 @@ async function groupDetailPage({ ctx, keto, kratosAdmin }: GroupsDeps, name: str
|
|||||||
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" };
|
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.
|
// GET /auth-admin/groups/:name — the detail + membership page.
|
||||||
export const groupsDetail = withGroupName((deps, name) =>
|
export const groupsDetail = withGroupName((deps, name) =>
|
||||||
groupDetailPage(deps, name, isSavedNotice(deps.ctx.url) ? { notice: deps.ctx.t("admin.grants.saved") } : {}));
|
groupDetailPage(deps, name, { notice: grantsNotice(deps.ctx.url, deps.ctx.t) ?? "" }));
|
||||||
|
|
||||||
// POST /admin/groups/:name/permissions — the submitted checkboxes are the desired set. Members hold
|
// POST /auth-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 (deps, name) => {
|
export const groupsPermissions = withGroupName(async (deps, name) => {
|
||||||
@@ -412,18 +395,22 @@ export const groupsPermissions = withGroupName(async (deps, name) => {
|
|||||||
if (isStale(form, ctx.declaredPermissions, held, diff)) return { ...(await groupDetailPage(deps, name, { error: ctx.t("admin.grants.stale") })), status: 409 };
|
if (isStale(form, ctx.declaredPermissions, held, diff)) return { ...(await groupDetailPage(deps, name, { error: ctx.t("admin.grants.stale") })), status: 409 };
|
||||||
const lost = diff.revoke.length > 0 && await isMember(keto, user.id, name) ? await lostToSelf(keto, user.id, diff.revoke) : [];
|
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);
|
if (lost.length > 0) return refuseSelfLockout(deps, name, "save", lost);
|
||||||
const { applied, error } = await applyGrants(keto, subject, diff);
|
await applyGrants(keto, subject, diff);
|
||||||
if (applied.grant.length > 0 || applied.revoke.length > 0) {
|
if (diff.grant.length > 0 || diff.revoke.length > 0) {
|
||||||
ctx.log.info("admin: group permissions changed", { actor: user.id, granted: applied.grant.join(","), group: name, revoked: applied.revoke.join(",") });
|
ctx.log.info("admin: group permissions changed", { actor: user.id, granted: diff.grant.join(","), group: name, revoked: diff.revoke.join(",") });
|
||||||
}
|
|
||||||
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}` };
|
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 /auth-admin/groups/:name/permissions/remove — drop one grant no installed plugin declares.
|
||||||
|
export const groupsPermissionsRemove = withGroupName(async ({ ctx, keto, user }, name) => {
|
||||||
|
const form = (await guardedForm(ctx))!;
|
||||||
|
const removed = await removeUndeclaredGrant(keto, groupSubject(name), ctx.declaredPermissions, form.get(REMOVE_FIELD));
|
||||||
|
if (removed) ctx.log.info("admin: undeclared group permission removed", { actor: user.id, group: name, removed });
|
||||||
|
return { redirect: `${detailHref(name)}?info-msg=${REMOVED_NOTICE}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /auth-admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
|
||||||
export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
|
export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
|
||||||
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());
|
||||||
@@ -431,7 +418,7 @@ export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
|
|||||||
return { redirect: detailHref(name) };
|
return { redirect: detailHref(name) };
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /admin/groups/:name/delete — the deliberate confirm step.
|
// GET /auth-admin/groups/:name/delete — the deliberate confirm step.
|
||||||
export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
|
export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
|
||||||
const lost = await lostOnDelete(deps, name);
|
const lost = await lostOnDelete(deps, name);
|
||||||
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
|
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
|
||||||
@@ -444,24 +431,25 @@ export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
|
|||||||
}) }, view: "confirm" };
|
}) }, view: "confirm" };
|
||||||
}, "write");
|
}, "write");
|
||||||
|
|
||||||
// POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
|
// POST /auth-admin/groups/:name/delete — one Keto transaction removes the group's member tuples
|
||||||
|
// and the grants it holds, so a Keto failure leaves the group as it was.
|
||||||
export const groupsDelete = withGroupName(async (deps, name) => {
|
export const groupsDelete = withGroupName(async (deps, name) => {
|
||||||
const { ctx, keto, user } = deps;
|
const { ctx, keto, user } = deps;
|
||||||
await guardedForm(ctx); // CSRF-verify the POST
|
await guardedForm(ctx); // CSRF-verify the POST
|
||||||
const lost = await lostOnDelete(deps, name);
|
const lost = await lostOnDelete(deps, name);
|
||||||
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
|
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
|
// A Keto set exists only through its tuples, so leaving the grants behind would resurrect every
|
||||||
// tuples, so leaving the grants behind would resurrect every permission the moment someone
|
// permission the moment someone re-created a group with the same name.
|
||||||
// re-created a group with the same name.
|
|
||||||
const subject = groupSubject(name);
|
const subject = groupSubject(name);
|
||||||
const held = await heldPermissions(keto, subject);
|
const held = await heldPermissions(keto, subject);
|
||||||
for (const permission of held) await keto.deleteTuple(grantTuple(permission, subject));
|
const grants = held.map((permission) => grantTuple(permission, subject));
|
||||||
await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS });
|
const members = await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS });
|
||||||
|
await keto.transactTuples({ delete: [...grants, ...members], insert: [] });
|
||||||
ctx.log.info("admin: group deleted", { actor: user.id, group: name, revoked: held.join(",") });
|
ctx.log.info("admin: group deleted", { actor: user.id, group: name, revoked: held.join(",") });
|
||||||
return { redirect: ADMIN_GROUPS_BASE };
|
return { redirect: ADMIN_GROUPS_BASE };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/groups/:name/members/delete — remove one member.
|
// POST /auth-admin/groups/:name/members/delete — remove one member.
|
||||||
export const groupsRemoveMember = withGroupName(async (deps, name) => {
|
export const groupsRemoveMember = withGroupName(async (deps, name) => {
|
||||||
const { ctx, keto, user } = deps;
|
const { ctx, keto, user } = deps;
|
||||||
const form = (await guardedForm(ctx))!;
|
const form = (await guardedForm(ctx))!;
|
||||||
+1
-1
@@ -68,7 +68,7 @@ function valueLabel(setting: SettingSummary, t: Translate): string {
|
|||||||
return setting.value ?? t("admin.pluginSettings.unset");
|
return setting.value ?? t("admin.pluginSettings.unset");
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /admin/plugin-settings
|
// GET /auth-admin/plugin-settings
|
||||||
export const pluginSettingsList: RouteHandler = (ctx) => {
|
export const pluginSettingsList: RouteHandler = (ctx) => {
|
||||||
requirePermission(ctx, "plugin-settings");
|
requirePermission(ctx, "plugin-settings");
|
||||||
return { data: { chrome: ctx.chrome, model: buildPluginSettingsModel({ chrome: ctx.chrome, settings: ctx.declaredSettings, t: ctx.t }) }, view: "plugin-settings" };
|
return { data: { chrome: ctx.chrome, model: buildPluginSettingsModel({ chrome: ctx.chrome, settings: ctx.declaredSettings, t: ctx.t }) }, view: "plugin-settings" };
|
||||||
+4
-4
@@ -15,11 +15,11 @@ const member: User = { email: "bo@x.io", id: "u2", permissions: ["scheduling:rea
|
|||||||
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
|
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
|
||||||
|
|
||||||
function fakeCtx(opts: { body?: string; method?: string; user?: User | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
function fakeCtx(opts: { body?: string; method?: string; user?: User | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||||
const url = new URL("http://localhost/admin/users");
|
const url = new URL("http://localhost/auth-admin/users");
|
||||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||||
req.method = opts.method ?? "GET";
|
req.method = opts.method ?? "GET";
|
||||||
return {
|
return {
|
||||||
chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {},
|
assetHref: (href) => href, chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {},
|
||||||
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url,
|
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url,
|
||||||
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||||
};
|
};
|
||||||
@@ -36,7 +36,7 @@ test("ADMIN_NAV: an ungated Admin header whose screens each gate on their own re
|
|||||||
assert.equal(ADMIN_NAV.permission, undefined);
|
assert.equal(ADMIN_NAV.permission, undefined);
|
||||||
assert.equal(ADMIN_NAV.href, undefined);
|
assert.equal(ADMIN_NAV.href, undefined);
|
||||||
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
|
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
|
||||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/clients", "/admin/plugin-settings"]);
|
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/auth-admin/users", "/auth-admin/groups", "/auth-admin/clients", "/auth-admin/plugin-settings"]);
|
||||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read", "plugin-settings:read"]);
|
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read", "plugin-settings:read"]);
|
||||||
// Labels are catalog keys; the host translates them with this plugin's catalog when it composes
|
// Labels are catalog keys; the host translates them with this plugin's catalog when it composes
|
||||||
// the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys.
|
// the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys.
|
||||||
@@ -64,7 +64,7 @@ test("actionForMethod: read for GET/HEAD, write for every mutation", () => {
|
|||||||
// ---- auth gates ----
|
// ---- auth gates ----
|
||||||
|
|
||||||
test("requirePermission: anonymous → 401→/login, wrong permission → 403, and read never grants write", () => {
|
test("requirePermission: anonymous → 401→/login, wrong permission → 403, and read never grants write", () => {
|
||||||
assert.throws(() => requirePermission(fakeCtx({ user: null }), "users"), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // bounce remembers the page
|
assert.throws(() => requirePermission(fakeCtx({ user: null }), "users"), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fauth-admin%2Fusers"); // bounce remembers the page
|
||||||
assert.throws(() => requirePermission(fakeCtx({ user: member }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
|
assert.throws(() => requirePermission(fakeCtx({ user: member }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
|
||||||
assert.equal(requirePermission(fakeCtx({ user: reader }), "users"), reader);
|
assert.equal(requirePermission(fakeCtx({ user: reader }), "users"), reader);
|
||||||
// The whole point of the split: users:read opens the list but not the create/delete POSTs.
|
// The whole point of the split: users:read opens the list but not the create/delete POSTs.
|
||||||
+7
-15
@@ -9,10 +9,10 @@ import enUS from "./i18n/en-US.ts";
|
|||||||
// i.e. its unit tests. At runtime the handlers pass ctx.t instead.
|
// i.e. its unit tests. At runtime the handlers pass ctx.t instead.
|
||||||
export const ADMIN_EN: Translate = englishTranslator(enUS);
|
export const ADMIN_EN: Translate = englishTranslator(enUS);
|
||||||
|
|
||||||
export const ADMIN_USERS_BASE = "/admin/users";
|
export const ADMIN_USERS_BASE = "/auth-admin/users";
|
||||||
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
export const ADMIN_GROUPS_BASE = "/auth-admin/groups";
|
||||||
export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
export const ADMIN_CLIENTS_BASE = "/auth-admin/clients";
|
||||||
export const ADMIN_PLUGIN_SETTINGS_BASE = "/admin/plugin-settings";
|
export const ADMIN_PLUGIN_SETTINGS_BASE = "/auth-admin/plugin-settings";
|
||||||
|
|
||||||
// One resource per screen — the `<resource>` half of every permission this plugin gates on.
|
// One resource per screen — the `<resource>` half of every permission this plugin gates on.
|
||||||
// `oauth2-clients` rather than `clients` because permission names are one global namespace.
|
// `oauth2-clients` rather than `clients` because permission names are one global namespace.
|
||||||
@@ -29,7 +29,9 @@ export function permissionName(resource: AdminResource, action: AdminAction): st
|
|||||||
|
|
||||||
// Every screen reads on GET/HEAD and mutates on POST. The route table and the in-handler guard both
|
// Every screen reads on GET/HEAD and mutates on POST. The route table and the in-handler guard both
|
||||||
// go through this rather than each spelling the permission out, so they cannot drift. Deliberately
|
// go through this rather than each spelling the permission out, so they cannot drift. Deliberately
|
||||||
// local: generalised, it would make authorization a function of the transport verb (AGENTS.md).
|
// local: generalised, it would make authorization a function of the transport verb
|
||||||
|
// (docs/decisions.md → "`actionForMethod` is plugin-local and stays out of
|
||||||
|
// `@plainpages/plugin-api`").
|
||||||
export function actionForMethod(method: string): AdminAction {
|
export function actionForMethod(method: string): AdminAction {
|
||||||
const verb = method.toUpperCase();
|
const verb = method.toUpperCase();
|
||||||
return verb === "GET" || verb === "HEAD" ? "read" : "write";
|
return verb === "GET" || verb === "HEAD" ? "read" : "write";
|
||||||
@@ -83,16 +85,6 @@ 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: {
|
||||||
+10
-10
@@ -38,42 +38,42 @@ test("toUserView maps traits → name/initials/email/state; falls back to the em
|
|||||||
test("buildUsersListModel filters by search + status, sorts, and paginates", () => {
|
test("buildUsersListModel filters by search + status, sorts, and paginates", () => {
|
||||||
const people = Array.from({ length: 30 }, (_, i) => identity(i + 1, { state: i % 2 ? "inactive" : "active" }));
|
const people = Array.from({ length: 30 }, (_, i) => identity(i + 1, { state: i % 2 ? "inactive" : "active" }));
|
||||||
|
|
||||||
const all = buildUsersListModel({ identities: people, url: "http://x/admin/users" });
|
const all = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users" });
|
||||||
assert.equal(all.pagination.summary.total, 30);
|
assert.equal(all.pagination.summary.total, 30);
|
||||||
assert.equal(all.table.rows.length, 25); // default page size
|
assert.equal(all.table.rows.length, 25); // default page size
|
||||||
assert.equal(all.title, "Users");
|
assert.equal(all.title, "Users");
|
||||||
|
|
||||||
// Search narrows to one and shows a pill.
|
// Search narrows to one and shows a pill.
|
||||||
const one = buildUsersListModel({ identities: people, url: "http://x/admin/users?q=user7%40example.com" });
|
const one = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users?q=user7%40example.com" });
|
||||||
assert.equal(one.pagination.summary.total, 1);
|
assert.equal(one.pagination.summary.total, 1);
|
||||||
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
||||||
|
|
||||||
// Status filter keeps only inactive identities.
|
// Status filter keeps only inactive identities.
|
||||||
const inactive = buildUsersListModel({ identities: people, url: "http://x/admin/users?status=inactive" });
|
const inactive = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users?status=inactive" });
|
||||||
assert.equal(inactive.pagination.summary.total, 15);
|
assert.equal(inactive.pagination.summary.total, 15);
|
||||||
assert.deepEqual(inactive.filterBar.pills.map((p) => p.label), ["Status"]);
|
assert.deepEqual(inactive.filterBar.pills.map((p) => p.label), ["Status"]);
|
||||||
|
|
||||||
// Sort by email descending reverses the order.
|
// Sort by email descending reverses the order.
|
||||||
const emailOf = (m: ReturnType<typeof buildUsersListModel>, i: number) => (m.table.rows[i]!.cells[1]) as string;
|
const emailOf = (m: ReturnType<typeof buildUsersListModel>, i: number) => (m.table.rows[i]!.cells[1]) as string;
|
||||||
const desc = buildUsersListModel({ identities: people, url: "http://x/admin/users?sort=-email" });
|
const desc = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users?sort=-email" });
|
||||||
assert.ok(emailOf(desc, 0) > emailOf(desc, 1));
|
assert.ok(emailOf(desc, 0) > emailOf(desc, 1));
|
||||||
|
|
||||||
// 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(`/auth-admin/users/${people[0]!.id}$`));
|
||||||
assert.deepEqual([all.table.rows[0]!.actions![0]!.icon, all.table.rows[0]!.actions![0]!.label], ["i-edit", "Edit"]);
|
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.
|
// 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", () => {
|
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 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]!;
|
const action = buildUsersListModel({ canWrite: false, identities: people, url: "http://x/auth-admin/users" }).table.rows[0]!.actions![0]!;
|
||||||
assert.deepEqual([action.icon, action.label, action.href], ["i-eye", "View", "/admin/users/u1"]);
|
assert.deepEqual([action.icon, action.label, action.href], ["i-eye", "View", "/auth-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", () => {
|
||||||
const m = buildUserFormModel({ csrfToken: "tok.sig" });
|
const m = buildUserFormModel({ csrfToken: "tok.sig" });
|
||||||
assert.equal(m.title, "New user");
|
assert.equal(m.title, "New user");
|
||||||
assert.equal(m.form.action, "/admin/users");
|
assert.equal(m.form.action, "/auth-admin/users");
|
||||||
assert.equal(m.form.submitLabel, "Create user");
|
assert.equal(m.form.submitLabel, "Create user");
|
||||||
assert.equal(m.form.csrfToken, "tok.sig");
|
assert.equal(m.form.csrfToken, "tok.sig");
|
||||||
const names = m.form.fields.map((f) => f.name);
|
const names = m.form.fields.map((f) => f.name);
|
||||||
@@ -85,7 +85,7 @@ test("buildUserFormModel: create mode has an editable email + password, no edit
|
|||||||
test("buildUserFormModel: edit mode prefills, locks email, and exposes state/delete/recovery actions", () => {
|
test("buildUserFormModel: edit mode prefills, locks email, and exposes state/delete/recovery actions", () => {
|
||||||
const m = buildUserFormModel({ identity: identity(3) });
|
const m = buildUserFormModel({ identity: identity(3) });
|
||||||
assert.equal(m.title, "Edit user");
|
assert.equal(m.title, "Edit user");
|
||||||
assert.equal(m.form.action, `/admin/users/${id(3)}`);
|
assert.equal(m.form.action, `/auth-admin/users/${id(3)}`);
|
||||||
assert.equal(m.form.submitLabel, "Save changes");
|
assert.equal(m.form.submitLabel, "Save changes");
|
||||||
const email = m.form.fields.find((f) => f.name === "email")!;
|
const email = m.form.fields.find((f) => f.name === "email")!;
|
||||||
assert.equal(email.value, "user3@example.com");
|
assert.equal(email.value, "user3@example.com");
|
||||||
@@ -109,7 +109,7 @@ test("buildUserFormModel: the title and breadcrumb say New, Edit, or View for a
|
|||||||
for (const [opts, title, crumb] of cases) {
|
for (const [opts, title, crumb] of cases) {
|
||||||
const m = buildUserFormModel(opts);
|
const m = buildUserFormModel(opts);
|
||||||
assert.equal(m.title, title);
|
assert.equal(m.title, title);
|
||||||
assert.deepEqual(m.breadcrumbs, [{ href: "/admin/users", label: "Users" }, { label: crumb }]);
|
assert.deepEqual(m.breadcrumbs, [{ href: "/auth-admin/users", label: "Users" }, { label: crumb }]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
+45
-48
@@ -3,9 +3,9 @@
|
|||||||
// into building-block view models; below them are thin per-route handlers keyed on ctx.params, over
|
// into building-block view models; below them are thin per-route handlers keyed on ctx.params, over
|
||||||
// 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 { buildPaginationModel, 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, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
|
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, grantsNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, REMOVE_FIELD, removeUndeclaredGrant, REMOVED_NOTICE, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
|
||||||
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, 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;
|
||||||
@@ -137,7 +137,7 @@ export function buildUsersListModel(opts: {
|
|||||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.nav.section") }, { label: t("admin.users.title") }],
|
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.nav.section") }, { label: t("admin.users.title") }],
|
||||||
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: buildPaginationModel(page, (n) => listHref(state, { page: n }), { label: t("admin.users.pagination"), pageSizes: PAGE_SIZES }),
|
||||||
table: listTable(rows, state, sort, t, opts.canWrite !== false),
|
table: listTable(rows, state, sort, t, opts.canWrite !== false),
|
||||||
title: t("admin.users.title"),
|
title: t("admin.users.title"),
|
||||||
};
|
};
|
||||||
@@ -185,24 +185,6 @@ function listFilterBar(state: ListState, total: number, t: Translate) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
|
|
||||||
const hidden: { name: string; value: string }[] = [];
|
|
||||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
|
||||||
if (state.status !== "all") hidden.push({ name: "status", value: state.status });
|
|
||||||
if (state.sort) hidden.push({ name: "sort", value: state.sort });
|
|
||||||
return {
|
|
||||||
label: t("admin.users.pagination"),
|
|
||||||
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
|
||||||
pages: page.pages.map((p) =>
|
|
||||||
p.ellipsis ? { ellipsis: true }
|
|
||||||
: p.current ? { current: true, label: String(p.page) }
|
|
||||||
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
|
|
||||||
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
|
|
||||||
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
|
|
||||||
summary: { from: page.from, to: page.to, total: page.total },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FieldConfig {
|
export interface FieldConfig {
|
||||||
autocomplete?: string;
|
autocomplete?: string;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
@@ -306,13 +288,13 @@ function withTarget(inner: (deps: UsersDeps, identity: Identity, id: string) =>
|
|||||||
const formResult = (ctx: RequestContext, extra: Parameters<typeof buildUserFormModel>[0]): RouteResult =>
|
const formResult = (ctx: RequestContext, extra: Parameters<typeof buildUserFormModel>[0]): RouteResult =>
|
||||||
({ data: { chrome: ctx.chrome, model: buildUserFormModel({ csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "user-form" });
|
({ data: { chrome: ctx.chrome, model: buildUserFormModel({ csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "user-form" });
|
||||||
|
|
||||||
// GET /admin/users — the filtered/sorted/paged list.
|
// GET /auth-admin/users — the filtered/sorted/paged list.
|
||||||
export const usersList = withUser(async ({ ctx, kratosAdmin }) => {
|
export const usersList = withUser(async ({ ctx, kratosAdmin }) => {
|
||||||
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
|
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
|
||||||
return { data: { chrome: ctx.chrome, model: buildUsersListModel({ canWrite: canWriteUsers(ctx), csrfToken: ctx.chrome.csrfToken, identities, t: ctx.t, url: ctx.url }) }, view: "users" };
|
return { data: { chrome: ctx.chrome, model: buildUsersListModel({ canWrite: canWriteUsers(ctx), csrfToken: ctx.chrome.csrfToken, identities, t: ctx.t, url: ctx.url }) }, view: "users" };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/users — create; a Kratos 4xx re-renders the form (400), keeping the input.
|
// POST /auth-admin/users — create; a Kratos 4xx re-renders the form (400), keeping the input.
|
||||||
export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
|
export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
|
||||||
const input = readUserInput((await guardedForm(ctx))!);
|
const input = readUserInput((await guardedForm(ctx))!);
|
||||||
try {
|
try {
|
||||||
@@ -325,12 +307,12 @@ export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
|
|||||||
return { redirect: ADMIN_USERS_BASE };
|
return { redirect: ADMIN_USERS_BASE };
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /admin/users/new — the empty create form.
|
// GET /auth-admin/users/new — the empty create form.
|
||||||
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})), "write");
|
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})), "write");
|
||||||
|
|
||||||
// GET /admin/users/:id — the edit form, prefilled.
|
// GET /auth-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, undefined, isSavedNotice(deps.ctx.url) ? deps.ctx.t("admin.grants.saved") : undefined);
|
const permissions = await userPermissionPicker(deps, id, undefined, grantsNotice(deps.ctx.url, deps.ctx.t));
|
||||||
return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) });
|
return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -360,7 +342,7 @@ async function userPermissionPicker(deps: UsersDeps, id: string, error?: string,
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /admin/users/:id/permissions — the submitted checkboxes are the desired set of *direct*
|
// POST /auth-admin/users/:id/permissions — the submitted checkboxes are the desired set of *direct*
|
||||||
// grants; grant what's newly ticked, revoke what's newly unticked. A change to a user's own grants
|
// grants; grant what's newly ticked, revoke what's newly unticked. A change to a user's own grants
|
||||||
// revokes their live tokens so it lands now rather than at the next re-mint.
|
// revokes their live tokens so it lands now rather than at the next re-mint.
|
||||||
export const usersPermissions = withTarget(async (deps, identity, id) => {
|
export const usersPermissions = withTarget(async (deps, identity, id) => {
|
||||||
@@ -382,37 +364,45 @@ export const usersPermissions = withTarget(async (deps, identity, id) => {
|
|||||||
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(",") });
|
||||||
return refuse(ctx.t("admin.grants.selfRevoke"), 400);
|
return refuse(ctx.t("admin.grants.selfRevoke"), 400);
|
||||||
}
|
}
|
||||||
const { applied, error } = await applyGrants(keto, subject, diff);
|
await applyGrants(keto, subject, diff);
|
||||||
if (applied.grant.length > 0 || applied.revoke.length > 0) {
|
if (diff.grant.length > 0 || diff.revoke.length > 0) {
|
||||||
revoke?.(id);
|
revoke?.(id);
|
||||||
ctx.log.info("admin: user permissions changed", { actor: user.id, granted: applied.grant.join(","), revoked: applied.revoke.join(","), target: id });
|
ctx.log.info("admin: user permissions changed", { actor: user.id, granted: diff.grant.join(","), revoked: diff.revoke.join(","), target: 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}` };
|
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 /auth-admin/users/:id/permissions/remove — drop one direct grant no installed plugin declares.
|
||||||
|
export const usersPermissionsRemove = withTarget(async ({ ctx, keto, revoke, user }, _identity, id) => {
|
||||||
|
const form = (await guardedForm(ctx))!;
|
||||||
|
if (!keto) return unavailable(ctx, ctx.t("admin.capability.keto"));
|
||||||
|
const removed = await removeUndeclaredGrant(keto, userSubject(id), ctx.declaredPermissions, form.get(REMOVE_FIELD));
|
||||||
|
if (removed) {
|
||||||
|
revoke?.(id);
|
||||||
|
ctx.log.info("admin: undeclared user permission removed", { actor: user.id, removed, target: id });
|
||||||
|
}
|
||||||
|
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}?info-msg=${REMOVED_NOTICE}` };
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /auth-admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
|
||||||
export const usersUpdate = withTarget(async (deps, identity, id) => {
|
export const usersUpdate = withTarget(async (deps, identity, id) => {
|
||||||
const { ctx, kratosAdmin } = deps;
|
const { ctx, kratosAdmin } = deps;
|
||||||
const input = readUserInput((await guardedForm(ctx))!);
|
const input = readUserInput((await guardedForm(ctx))!);
|
||||||
try {
|
try {
|
||||||
await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input));
|
await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Re-render with the picker, or the permissions section vanishes off the page on a failed save.
|
if (err instanceof KratosError) return refuseForm(deps, identity, id, ctx.t("admin.users.error.save"));
|
||||||
if (err instanceof KratosError) return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), error: ctx.t("admin.users.error.save"), identity, ...(await pickerOrNothing(deps, id)) }), status: 400 };
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/users/:id/state — toggle active/inactive; a deactivation revokes the target's live
|
// POST /auth-admin/users/:id/state — toggle active/inactive; a deactivation revokes the target's live
|
||||||
// tokens now (not after the JWT TTL). Self-protection: an admin can't deactivate their own account.
|
// tokens now (not after the JWT TTL). Self-protection: an admin can't deactivate their own account.
|
||||||
export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
|
export const usersState = withTarget(async (deps, identity, id) => {
|
||||||
|
const { ctx, kratosAdmin, revoke, user } = deps;
|
||||||
await guardedForm(ctx); // CSRF-verify the POST (no fields read)
|
await guardedForm(ctx); // CSRF-verify the POST (no fields read)
|
||||||
if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDeactivate"), identity }), status: 400 };
|
if (id === user.id) return refuseForm(deps, identity, id, ctx.t("admin.users.error.selfDeactivate"));
|
||||||
const nextState = identity.state === "inactive" ? "active" : "inactive";
|
const nextState = identity.state === "inactive" ? "active" : "inactive";
|
||||||
await kratosAdmin.updateIdentity(id, setStatePayload(identity, nextState));
|
await kratosAdmin.updateIdentity(id, setStatePayload(identity, nextState));
|
||||||
if (nextState === "inactive") revoke?.(id);
|
if (nextState === "inactive") revoke?.(id);
|
||||||
@@ -420,9 +410,9 @@ export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user },
|
|||||||
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /admin/users/:id/delete — the deliberate confirm step (zero-JS). Refuses self-delete.
|
// GET /auth-admin/users/:id/delete — the deliberate confirm step (zero-JS). Refuses self-delete.
|
||||||
export const usersDeleteConfirm = withTarget((deps, identity, id) => {
|
export const usersDeleteConfirm = withTarget((deps, identity, id) => {
|
||||||
if (id === deps.user.id) return Promise.resolve({ ...formResult(deps.ctx, { error: deps.ctx.t("admin.users.error.selfDelete"), identity }), status: 400 });
|
if (id === deps.user.id) return refuseForm(deps, identity, id, deps.ctx.t("admin.users.error.selfDelete"));
|
||||||
const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}`;
|
const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}`;
|
||||||
const view = toUserView(identity);
|
const view = toUserView(identity);
|
||||||
const tt = deps.ctx.t;
|
const tt = deps.ctx.t;
|
||||||
@@ -433,17 +423,18 @@ export const usersDeleteConfirm = withTarget((deps, identity, id) => {
|
|||||||
}) }, view: "confirm" });
|
}) }, view: "confirm" });
|
||||||
}, "write");
|
}, "write");
|
||||||
|
|
||||||
// POST /admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete.
|
// POST /auth-admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete.
|
||||||
export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
|
export const usersDelete = withTarget(async (deps, identity, id) => {
|
||||||
|
const { ctx, kratosAdmin, revoke, user } = deps;
|
||||||
await guardedForm(ctx); // CSRF-verify the POST
|
await guardedForm(ctx); // CSRF-verify the POST
|
||||||
if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDelete"), identity }), status: 400 };
|
if (id === user.id) return refuseForm(deps, identity, id, ctx.t("admin.users.error.selfDelete"));
|
||||||
await kratosAdmin.deleteIdentity(id);
|
await kratosAdmin.deleteIdentity(id);
|
||||||
revoke?.(id);
|
revoke?.(id);
|
||||||
ctx.log.info("admin: user deleted", { actor: user.id, target: id });
|
ctx.log.info("admin: user deleted", { actor: user.id, target: id });
|
||||||
return { redirect: ADMIN_USERS_BASE };
|
return { redirect: ADMIN_USERS_BASE };
|
||||||
});
|
});
|
||||||
|
|
||||||
// POST /admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page.
|
// POST /auth-admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page.
|
||||||
export const usersRecovery = withTarget(async (deps, identity, id) => {
|
export const usersRecovery = withTarget(async (deps, identity, id) => {
|
||||||
const { ctx, kratosAdmin } = deps;
|
const { ctx, kratosAdmin } = deps;
|
||||||
await guardedForm(ctx); // CSRF-verify the POST
|
await guardedForm(ctx); // CSRF-verify the POST
|
||||||
@@ -451,7 +442,13 @@ export const usersRecovery = withTarget(async (deps, identity, id) => {
|
|||||||
return formResult(ctx, { canWrite: canWriteUsers(ctx), identity, recovery, ...(await pickerOrNothing(deps, id)) });
|
return formResult(ctx, { canWrite: canWriteUsers(ctx), identity, recovery, ...(await pickerOrNothing(deps, id)) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// The picker as a spreadable fragment, so a re-render never silently drops the section.
|
// The 400 re-render of the user's form with `error` above it. `usersPermissions` refuses through its
|
||||||
|
// own `refuse`, which puts the error in the permissions section instead.
|
||||||
|
async function refuseForm(deps: UsersDeps, identity: Identity, id: string, error: string): Promise<RouteResult> {
|
||||||
|
return { ...formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), error, identity, ...(await pickerOrNothing(deps, id)) }), status: 400 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The picker as a spreadable fragment, so every form re-render keeps the section.
|
||||||
async function pickerOrNothing(deps: UsersDeps, id: string): Promise<{ permissions?: PermissionPicker }> {
|
async function pickerOrNothing(deps: UsersDeps, id: string): Promise<{ permissions?: PermissionPicker }> {
|
||||||
const permissions = await userPermissionPicker(deps, id);
|
const permissions = await userPermissionPicker(deps, id);
|
||||||
return permissions ? { permissions } : {};
|
return permissions ? { permissions } : {};
|
||||||
@@ -53,12 +53,15 @@ 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.remove": "Remove",
|
||||||
|
"admin.grants.removed": "Permission removed.",
|
||||||
"admin.grants.save": "Save permissions",
|
"admin.grants.save": "Save permissions",
|
||||||
"admin.grants.saved": "Permissions saved.",
|
"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.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.grants.undeclaredHint": "These permissions are held here, but no installed plugin declares them, so the list above can't change them. A plugin may still check one without declaring it.",
|
||||||
|
"admin.grants.undeclaredLegend": "Permissions no installed plugin declares",
|
||||||
|
|
||||||
"admin.groups.actions": "Group actions",
|
"admin.groups.actions": "Group actions",
|
||||||
"admin.groups.addMember": "Add a member",
|
"admin.groups.addMember": "Add a member",
|
||||||
@@ -53,12 +53,15 @@ 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.remove": "Ta bort",
|
||||||
|
"admin.grants.removed": "Behörigheten är borttagen.",
|
||||||
"admin.grants.save": "Spara behörigheter",
|
"admin.grants.save": "Spara behörigheter",
|
||||||
"admin.grants.saved": "Behörigheterna är sparade.",
|
"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.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.grants.undeclaredHint": "Behörigheterna innehas här, men ingen installerad plugin deklarerar dem, så listan ovan kan inte ändra dem. En plugin kan ändå kontrollera någon av dem utan att deklarera den.",
|
||||||
|
"admin.grants.undeclaredLegend": "Behörigheter som ingen installerad plugin deklarerar",
|
||||||
|
|
||||||
"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",
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
// Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system. Copy
|
// Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system. Copy
|
||||||
// this folder to plugins/admin (then restart) to enable it — see README → Quick start.
|
// this folder to plugins/auth-admin (then restart) to enable it — see README → Quick start.
|
||||||
//
|
//
|
||||||
// It is a *system* plugin: its handlers reach the host's Ory admin clients and the instant-revoke
|
// It is a *system* plugin: its handlers reach the host's Ory admin clients and the instant-revoke
|
||||||
// hook via ctx.system. Where a capability is absent the screen degrades to a themed 503.
|
// hook via ctx.system. Where a capability is absent the screen degrades to a themed 503.
|
||||||
|
|
||||||
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "@plainpages/plugin-api";
|
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "@plainpages/plugin-api";
|
||||||
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
|
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
|
||||||
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts";
|
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsPermissionsRemove, groupsRemoveMember } from "./admin-groups.ts";
|
||||||
import { pluginSettingsList } from "./admin-plugin-settings.ts";
|
import { pluginSettingsList } from "./admin-plugin-settings.ts";
|
||||||
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersPermissionsRemove, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
||||||
import { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } from "./admin-shared.ts";
|
import { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } from "./admin-shared.ts";
|
||||||
|
|
||||||
// One route factory per screen: a GET gates on `<resource>:read` and a POST on `<resource>:write`,
|
// One route factory per screen: a GET gates on `<resource>:read` and a POST on `<resource>:write`,
|
||||||
@@ -28,7 +28,7 @@ const clients = on("oauth2-clients");
|
|||||||
const pluginSettings = on("plugin-settings");
|
const pluginSettings = on("plugin-settings");
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
apiVersion: "0.5.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||||
|
|
||||||
nav: [ADMIN_NAV],
|
nav: [ADMIN_NAV],
|
||||||
|
|
||||||
@@ -54,6 +54,7 @@ export default definePlugin({
|
|||||||
users("POST", "/users/:id/delete", usersDelete),
|
users("POST", "/users/:id/delete", usersDelete),
|
||||||
users("POST", "/users/:id/recovery", usersRecovery),
|
users("POST", "/users/:id/recovery", usersRecovery),
|
||||||
users("POST", "/users/:id/permissions", usersPermissions),
|
users("POST", "/users/:id/permissions", usersPermissions),
|
||||||
|
users("POST", "/users/:id/permissions/remove", usersPermissionsRemove),
|
||||||
// Groups
|
// Groups
|
||||||
groups("GET", "/groups", groupsList),
|
groups("GET", "/groups", groupsList),
|
||||||
groups("POST", "/groups", groupsCreate),
|
groups("POST", "/groups", groupsCreate),
|
||||||
@@ -64,6 +65,7 @@ export default definePlugin({
|
|||||||
groups("POST", "/groups/:name/delete", groupsDelete),
|
groups("POST", "/groups/:name/delete", groupsDelete),
|
||||||
groups("POST", "/groups/:name/members/delete", groupsRemoveMember),
|
groups("POST", "/groups/:name/members/delete", groupsRemoveMember),
|
||||||
groups("POST", "/groups/:name/permissions", groupsPermissions),
|
groups("POST", "/groups/:name/permissions", groupsPermissions),
|
||||||
|
groups("POST", "/groups/:name/permissions/remove", groupsPermissionsRemove),
|
||||||
// OAuth2 clients
|
// OAuth2 clients
|
||||||
clients("GET", "/clients", clientsList),
|
clients("GET", "/clients", clientsList),
|
||||||
clients("POST", "/clients", clientsCreate),
|
clients("POST", "/clients", clientsCreate),
|
||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
const table = include("partials/data-table", model.table);
|
const table = include("partials/data-table", model.table);
|
||||||
const pager = include("partials/pagination", model.pagination);
|
const pager = include("partials/pagination", model.pagination);
|
||||||
// Only offer "Register client" to an oauth2-clients:write holder — a :read one would get the 403 page.
|
// Only offer "Register client" to an oauth2-clients:write holder — a :read one would get the 403 page.
|
||||||
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/clients/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.clients.registerClient") + '</a>';
|
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/auth-admin/clients/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.clients.registerClient") + '</a>';
|
||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
actions,
|
actions,
|
||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
const table = include("partials/data-table", model.table);
|
const table = include("partials/data-table", model.table);
|
||||||
const pager = include("partials/pagination", model.pagination);
|
const pager = include("partials/pagination", model.pagination);
|
||||||
// Only offer "New group" to a groups:write holder — a groups:read one would get the 403 page.
|
// Only offer "New group" to a groups:write holder — a groups:read one would get the 403 page.
|
||||||
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/groups/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.groups.new") + '</a>';
|
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/auth-admin/groups/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.groups.new") + '</a>';
|
||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
actions,
|
actions,
|
||||||
+22
-1
@@ -7,7 +7,8 @@
|
|||||||
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, baseline, choices, empty, error, field, hint, inheritedNote, legend, notice, pending, readOnly, submit }).
|
Locals: csrfToken, permissions ({ action, baseline, choices, empty, error, field, hint, inheritedNote, legend, notice, pending, readOnly, submit, undeclared }).
|
||||||
|
|
||||||
%>
|
%>
|
||||||
<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>
|
||||||
@@ -50,5 +51,25 @@
|
|||||||
<% if (permissions.pending) { -%>
|
<% if (permissions.pending) { -%>
|
||||||
<p class="cell-muted"><%= permissions.pending %></p>
|
<p class="cell-muted"><%= permissions.pending %></p>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
|
<% } -%>
|
||||||
|
<% if (permissions.undeclared) { const u = permissions.undeclared; -%>
|
||||||
|
<h3 class="card-title" id="undeclared-h"><%= u.legend %></h3>
|
||||||
|
<p class="cell-muted"><%= u.hint %></p>
|
||||||
|
<ul class="plain-list" aria-labelledby="undeclared-h">
|
||||||
|
<% u.names.forEach((name) => { -%>
|
||||||
|
<li>
|
||||||
|
<% if (u.remove) { -%>
|
||||||
|
<form method="post" action="<%= localeHref(u.action + '#permissions-h') %>">
|
||||||
|
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||||
|
<input type="hidden" name="<%= u.field %>" value="<%= name %>">
|
||||||
|
<span class="cell-muted"><%= name %></span>
|
||||||
|
<button class="btn btn-danger" type="submit"><%= u.remove %><span class="sr-only"> <%= name %></span></button>
|
||||||
|
</form>
|
||||||
|
<% } else { -%>
|
||||||
|
<span class="cell-muted"><%= name %></span>
|
||||||
|
<% } -%>
|
||||||
|
</li>
|
||||||
|
<% }) -%>
|
||||||
|
</ul>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
</section>
|
</section>
|
||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
const table = include("partials/data-table", model.table);
|
const table = include("partials/data-table", model.table);
|
||||||
const pager = include("partials/pagination", model.pagination);
|
const pager = include("partials/pagination", model.pagination);
|
||||||
// Only offer "New user" to a users:write holder — a users:read one would get the 403 page.
|
// Only offer "New user" to a users:write holder — a users:read one would get the 403 page.
|
||||||
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/users/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.users.new") + '</a>';
|
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/auth-admin/users/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.users.new") + '</a>';
|
||||||
-%>
|
-%>
|
||||||
<%- include("partials/shell", {
|
<%- include("partials/shell", {
|
||||||
actions,
|
actions,
|
||||||
@@ -10,7 +10,8 @@ What it demonstrates:
|
|||||||
service and renders the rows with the core building blocks (`shifts.ejs` → app shell, filter-bar,
|
service and renders the rows with the core building blocks (`shifts.ejs` → app shell, filter-bar,
|
||||||
data-table). Search round-trips the URL; zero-JS. (It fetches **all** rows for brevity — for a
|
data-table). Search round-trips the URL; zero-JS. (It fetches **all** rows for brevity — for a
|
||||||
large list, parse `page`/`pageSize` from `parseListQuery`, forward them upstream as a `?limit`/
|
large list, parse `page`/`pageSize` from `parseListQuery`, forward them upstream as a `?limit`/
|
||||||
`?offset`, and render `pagination.ejs` with `paginate()`, exactly as the admin example plugin does.)
|
`?offset`, and render `pagination.ejs` with `paginate()` and `buildPaginationModel()`, as the admin example plugin
|
||||||
|
does.)
|
||||||
- **A form that forwards a write upstream** — `GET /scheduling/shifts/new` renders the form,
|
- **A form that forwards a write upstream** — `GET /scheduling/shifts/new` renders the form,
|
||||||
`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`,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ let upstreamUrl = "";
|
|||||||
const upstream = createUpstream(() => upstreamUrl);
|
const upstream = createUpstream(() => upstreamUrl);
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
apiVersion: "0.5.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.
|
||||||
@@ -40,6 +40,9 @@ export default definePlugin({
|
|||||||
{ description: "Create and edit shifts", name: WRITE },
|
{ description: "Create and edit shifts", name: WRITE },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// Also offered in the profile dropdown above Sign out, since the page is the visitor's own.
|
||||||
|
profileMenu: [{ href: MINE_PATH, icon: "i-cal", id: "scheduling:profile-mine", label: "scheduling.nav.mine", session: true }],
|
||||||
|
|
||||||
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: myShifts(upstream), method: "GET", path: "/mine", session: true },
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; us
|
|||||||
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
||||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||||
return {
|
return {
|
||||||
chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
|
assetHref: (href) => href, 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),
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
+8
-8
@@ -6,8 +6,8 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "plainpages",
|
"name": "plainpages",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@larvit/log": "2.3.0",
|
"@larvit/log": "2.4.0",
|
||||||
"ejs": "6.0.1",
|
"ejs": "7.0.2",
|
||||||
"lucide-static": "1.70.0",
|
"lucide-static": "1.70.0",
|
||||||
"postgres": "3.4.9"
|
"postgres": "3.4.9"
|
||||||
},
|
},
|
||||||
@@ -23,9 +23,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@larvit/log": {
|
"node_modules/@larvit/log": {
|
||||||
"version": "2.3.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/@larvit/log/-/log-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@larvit/log/-/log-2.4.0.tgz",
|
||||||
"integrity": "sha512-gLRMDWrFnoMGdAr9NXMXRKOUeceU6Qd/CW0c6qDO+DLFT1xWrqO2ubsHyPqPzlZHVUO3w3Bk7kOWo6C2Ykn4ZQ==",
|
"integrity": "sha512-0BA1S0eSzQMOP527Vy2DL9Zb68TiCgyDACr0gDiI2b/1zfY6Axq9XhR/ZJnF9URIAhQwFPYs3W6lh/T9K8vqgQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
@@ -409,9 +409,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ejs": {
|
"node_modules/ejs": {
|
||||||
"version": "6.0.1",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ejs/-/ejs-7.0.2.tgz",
|
||||||
"integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
|
"integrity": "sha512-LcDSEP6Oclb9sTiSyG0MpG0RuKvLDRxCOOt+2sIk+kdk1rMkFU8SRnT0qaNt5zvU0LOV1YXXOvIoZL/sKwabvw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"ejs": "bin/cli.js"
|
"ejs": "bin/cli.js"
|
||||||
|
|||||||
+2
-2
@@ -16,8 +16,8 @@
|
|||||||
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"release-tooling/**/*.test.ts\""
|
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"release-tooling/**/*.test.ts\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@larvit/log": "2.3.0",
|
"@larvit/log": "2.4.0",
|
||||||
"ejs": "6.0.1",
|
"ejs": "7.0.2",
|
||||||
"lucide-static": "1.70.0",
|
"lucide-static": "1.70.0",
|
||||||
"postgres": "3.4.9"
|
"postgres": "3.4.9"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ test("readHostApiVersion pulls the constant out of the real source, and returns
|
|||||||
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
|
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
|
||||||
// Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
|
// Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
|
||||||
// makes an accidental edit fail here rather than at release time.
|
// makes an accidental edit fail here rather than at release time.
|
||||||
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.4.0");
|
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.5.0");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("every author-facing apiVersion sample matches the shipped contract", () => {
|
test("every author-facing apiVersion sample matches the shipped contract", () => {
|
||||||
@@ -23,10 +23,10 @@ test("every author-facing apiVersion sample matches the shipped contract", () =>
|
|||||||
const [major, minor] = host.split(".");
|
const [major, minor] = host.split(".");
|
||||||
for (const file of [
|
for (const file of [
|
||||||
"README.md",
|
"README.md",
|
||||||
"examples/plugins/admin/plugin.ts",
|
"examples/plugins/auth-admin/plugin.ts",
|
||||||
"examples/plugins/scheduling/plugin.ts",
|
"examples/plugins/scheduling/plugin.ts",
|
||||||
"release-tooling/dockerhub-overview.md.tmpl",
|
"release-tooling/dockerhub-overview.md.tmpl",
|
||||||
"views/index.ejs",
|
"views/dashboard.ejs",
|
||||||
]) {
|
]) {
|
||||||
const found = [...readFileSync(file, "utf8").matchAll(/apiVersion: "(\d+\.\d+\.\d+)"/g)].map((m) => m[1]);
|
const found = [...readFileSync(file, "utf8").matchAll(/apiVersion: "(\d+\.\d+\.\d+)"/g)].map((m) => m[1]);
|
||||||
assert.ok(found.length > 0, `${file} should carry at least one apiVersion sample`);
|
assert.ok(found.length > 0, `${file} should carry at least one apiVersion sample`);
|
||||||
|
|||||||
@@ -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.33.0
|
image: axllent/mailpit:v1.33.1
|
||||||
ports:
|
ports:
|
||||||
- "8025:8025"
|
- "8025:8025"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -182,7 +182,7 @@ into the app. Create `plugins/hello/plugin.ts`:
|
|||||||
import { definePlugin } from "@plainpages/plugin-api";
|
import { definePlugin } from "@plainpages/plugin-api";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.4.0",
|
apiVersion: "0.5.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>" }) },
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ async function main(): Promise<number> {
|
|||||||
if (!version || !repo || !user || !token) {
|
if (!version || !repo || !user || !token) {
|
||||||
return fail(
|
return fail(
|
||||||
"usage: dockerhub-overview.ts <X.Y.Z>; needs DOCKERHUB_REPO, DOCKERHUB_USER and " +
|
"usage: dockerhub-overview.ts <X.Y.Z>; needs DOCKERHUB_REPO, DOCKERHUB_USER and " +
|
||||||
"DOCKERHUB_TOKEN (README -> CI/CD)",
|
"DOCKERHUB_TOKEN (docs/ci-cd.md)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// The page is public, so never render a version that resolves to no image.
|
// The page is public, so never render a version that resolves to no image.
|
||||||
@@ -94,7 +94,7 @@ async function main(): Promise<number> {
|
|||||||
`Docker Hub description and 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 (docs/ci-cd.md)."
|
||||||
: ""),
|
: ""),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Pure release-version math for the Renovate auto-release (see renovate.yml → auto-release job,
|
// Pure release-version math for the Renovate auto-release (see renovate.yml → auto-release job,
|
||||||
// README → CI/CD). Renovate stamps each commit with a `Release-Bump: <updateType>` trailer; the
|
// docs/ci-cd.md). Renovate stamps each commit with a `Release-Bump: <updateType>` trailer; the
|
||||||
// workflow feeds those values here to pick the next `vX.Y.Z` tag. Kept side-effect-free and unit
|
// workflow feeds those values here to pick the next `vX.Y.Z` tag. Kept side-effect-free and unit
|
||||||
// tested (next-version.test.ts) — the git/tag/push side lives in the workflow shell.
|
// tested (next-version.test.ts) — the git/tag/push side lives in the workflow shell.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
"automerge": true,
|
"automerge": true,
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"description": "The host's own runtime deps. Release-Bump is opt-in per surface (README → CI/CD): updateType rates the dependency's own jump, not whether it reaches a running Plainpages",
|
"description": "The host's own runtime deps. Release-Bump is opt-in per surface (docs/ci-cd.md): updateType rates the dependency's own jump, not whether it reaches a running Plainpages",
|
||||||
"matchDepTypes": ["dependencies"],
|
"matchDepTypes": ["dependencies"],
|
||||||
"matchFileNames": ["package.json"],
|
"matchFileNames": ["package.json"],
|
||||||
"matchManagers": ["npm"],
|
"matchManagers": ["npm"],
|
||||||
|
|||||||
+5
-1
@@ -10,10 +10,14 @@ export interface Gate {
|
|||||||
session?: boolean; // any signed-in user, no grant to hold; anonymous is sent to /login
|
session?: boolean; // any signed-in user, no grant to hold; anonymous is sent to /login
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A gate naming none of the three allows everyone. Discovery refuses one on a plugin's route, nav
|
||||||
|
// node or profile-menu entry, so only the header nodes `config/menu.ts`'s `groups` builds get here
|
||||||
|
// without a gate, and failing closed would hide every section an operator grouped.
|
||||||
export function allows(gate: Gate, user: User | null): boolean {
|
export function allows(gate: Gate, user: User | null): boolean {
|
||||||
if (gate.public === true) return true;
|
if (gate.public === true) return true;
|
||||||
if (gate.session === true) return user !== null;
|
if (gate.session === true) return user !== null;
|
||||||
return gate.permission == null || (user?.permissions.includes(gate.permission) ?? false);
|
if (gate.permission == null) return true;
|
||||||
|
return user?.permissions.includes(gate.permission) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function gatesSet(gate: Gate | null | undefined): string[] {
|
export function gatesSet(gate: Gate | null | undefined): string[] {
|
||||||
|
|||||||
@@ -104,3 +104,29 @@ test("deleteTuple DELETEs the write API by query params (204 resolves; non-204 t
|
|||||||
(e: unknown) => e instanceof KetoError && e.status === 404,
|
(e: unknown) => e instanceof KetoError && e.status === 404,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("transactTuples PATCHes every insert and delete in one request (204 resolves; non-204 throws; empty sends nothing)", async () => {
|
||||||
|
const add = { namespace: "Permission", object: "a:read", relation: "granted", subject_id: USER };
|
||||||
|
const drop = { namespace: "Permission", object: "b:read", relation: "granted", subject_id: USER };
|
||||||
|
const { calls, fetchImpl } = recorder(() => res(204));
|
||||||
|
await keto(fetchImpl).transactTuples({ delete: [drop], insert: [add] });
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(calls[0]!.method, "PATCH");
|
||||||
|
assert.equal(calls[0]!.url, `${WRITE}/admin/relation-tuples`);
|
||||||
|
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ action: "insert", relation_tuple: add }, { action: "delete", relation_tuple: drop }]);
|
||||||
|
await keto(fetchImpl).transactTuples({ delete: [], insert: [] });
|
||||||
|
assert.equal(calls.length, 1, "an empty change is not a request");
|
||||||
|
await assert.rejects(
|
||||||
|
keto((async () => res(400, { error: "bad" })) as typeof fetch).transactTuples({ delete: [], insert: [add] }),
|
||||||
|
(e: unknown) => e instanceof KetoError && e.status === 400,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("trailing slashes on the read and write URLs are stripped before a path is appended", async () => {
|
||||||
|
const { calls, fetchImpl } = recorder((url) => (url.startsWith("http://r") ? res(200, {}) : res(204)));
|
||||||
|
const client = createKetoClient({ fetchImpl, readUrl: "http://r//", writeUrl: "http://w/" });
|
||||||
|
await client.listRelations();
|
||||||
|
await client.transactTuples({ delete: [], insert: [{ namespace: "Permission", object: "a:read", relation: "granted", subject_id: USER }] });
|
||||||
|
assert.match(calls[0]!.url, /^http:\/\/r\/relation-tuples\?/);
|
||||||
|
assert.equal(calls[1]!.url, "http://w/admin/relation-tuples");
|
||||||
|
});
|
||||||
|
|||||||
+38
-17
@@ -1,7 +1,7 @@
|
|||||||
// Keto client: typed `fetch` wrappers over Ory Keto's relation-tuple APIs —
|
// Keto client: typed `fetch` wrappers over Ory Keto's relation-tuple APIs —
|
||||||
// `check` a permission, `listRelations`/`expand` to inspect them (read API), `writeTuple`/
|
// `check` a permission, `listRelations`/`expand` to inspect them (read API), `writeTuple`/
|
||||||
// `deleteTuple` to grant/revoke them (write API). Built-in `fetch` only, no SDK dep (AGENTS.md);
|
// `deleteTuple`/`transactTuples` to grant/revoke them (write API). Built-in `fetch` only, no SDK dep
|
||||||
// `fetchImpl`-injectable like the kratos clients. Read/write split onto the two ports config.ts
|
// (AGENTS.md); `fetchImpl`-injectable like the kratos clients. Read/write split onto the two ports config.ts
|
||||||
// targets (ketoReadUrl 4466 / ketoWriteUrl 4467).
|
// targets (ketoReadUrl 4466 / ketoWriteUrl 4467).
|
||||||
|
|
||||||
// A subject set: a relation on another object (e.g. Group:eng#members), resolved
|
// A subject set: a relation on another object (e.g. Group:eng#members), resolved
|
||||||
@@ -32,7 +32,7 @@ export interface RelationList {
|
|||||||
|
|
||||||
// Keto's expand tree: a node is a set operation (union/…) or a leaf. The resolved subject
|
// Keto's expand tree: a node is a set operation (union/…) or a leaf. The resolved subject
|
||||||
// (subject_id xor subject_set) rides on `tuple`, not the node itself — verified against Keto
|
// (subject_id xor subject_set) rides on `tuple`, not the node itself — verified against Keto
|
||||||
// v26.2.0. A `subject_set` node carries its members as `children` ("effective access" view).
|
// v26.2.0. A `subject_set` node carries its members as `children`.
|
||||||
export interface ExpandTree {
|
export interface ExpandTree {
|
||||||
children?: ExpandTree[];
|
children?: ExpandTree[];
|
||||||
tuple?: RelationTuple;
|
tuple?: RelationTuple;
|
||||||
@@ -56,6 +56,8 @@ export interface KetoClient {
|
|||||||
deleteTuple(tuple: RelationTuple): Promise<void>;
|
deleteTuple(tuple: RelationTuple): Promise<void>;
|
||||||
expand(set: SubjectSet, opts?: { maxDepth?: number }): Promise<ExpandTree>;
|
expand(set: SubjectSet, opts?: { maxDepth?: number }): Promise<ExpandTree>;
|
||||||
listRelations(query?: RelationQuery): Promise<RelationList>;
|
listRelations(query?: RelationQuery): Promise<RelationList>;
|
||||||
|
// Inserts and deletes in one Keto transaction: all of them apply or none does.
|
||||||
|
transactTuples(changes: { delete: RelationTuple[]; insert: RelationTuple[] }): Promise<void>;
|
||||||
writeTuple(tuple: RelationTuple): Promise<void>;
|
writeTuple(tuple: RelationTuple): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,16 +77,14 @@ function tupleParams(t: Partial<RelationTuple>): URLSearchParams {
|
|||||||
return p;
|
return p;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createKetoClient(config: { fetchImpl?: typeof fetch; readUrl: string; writeUrl: string }): KetoClient {
|
async function fail(action: string, res: Response): Promise<never> {
|
||||||
const read = config.readUrl.replace(/\/+$/, "");
|
throw new KetoError(`Keto ${action} failed (${res.status})`, res.status, await res.text());
|
||||||
const write = config.writeUrl.replace(/\/+$/, "");
|
}
|
||||||
const http = config.fetchImpl ?? fetch;
|
|
||||||
const tuples = `${write}/admin/relation-tuples`;
|
|
||||||
|
|
||||||
async function fail(action: string, res: Response): Promise<never> {
|
type ReadApi = Pick<KetoClient, "check" | "expand" | "listRelations">;
|
||||||
throw new KetoError(`Keto ${action} failed (${res.status})`, res.status, await res.text());
|
type WriteApi = Pick<KetoClient, "deleteTuple" | "transactTuples" | "writeTuple">;
|
||||||
}
|
|
||||||
|
|
||||||
|
function readApi(http: typeof fetch, read: string): ReadApi {
|
||||||
return {
|
return {
|
||||||
async check(tuple, opts = {}) {
|
async check(tuple, opts = {}) {
|
||||||
const params = tupleParams(tuple);
|
const params = tupleParams(tuple);
|
||||||
@@ -95,11 +95,6 @@ export function createKetoClient(config: { fetchImpl?: typeof fetch; readUrl: st
|
|||||||
return ((await res.json()) as { allowed?: boolean }).allowed === true;
|
return ((await res.json()) as { allowed?: boolean }).allowed === true;
|
||||||
},
|
},
|
||||||
|
|
||||||
async deleteTuple(tuple) {
|
|
||||||
const res = await http(`${tuples}?${tupleParams(tuple)}`, { method: "DELETE" });
|
|
||||||
if (res.status !== 204) await fail("delete tuple", res);
|
|
||||||
},
|
|
||||||
|
|
||||||
async expand(set, opts = {}) {
|
async expand(set, opts = {}) {
|
||||||
const params = tupleParams(set);
|
const params = tupleParams(set);
|
||||||
if (opts.maxDepth !== undefined) params.set("max-depth", String(opts.maxDepth));
|
if (opts.maxDepth !== undefined) params.set("max-depth", String(opts.maxDepth));
|
||||||
@@ -117,11 +112,37 @@ export function createKetoClient(config: { fetchImpl?: typeof fetch; readUrl: st
|
|||||||
const body = (await res.json()) as { next_page_token?: string; relation_tuples?: RelationTuple[] };
|
const body = (await res.json()) as { next_page_token?: string; relation_tuples?: RelationTuple[] };
|
||||||
return { nextPageToken: body.next_page_token || null, tuples: body.relation_tuples ?? [] };
|
return { nextPageToken: body.next_page_token || null, tuples: body.relation_tuples ?? [] };
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeApi(http: typeof fetch, write: string): WriteApi {
|
||||||
|
const tuples = `${write}/admin/relation-tuples`;
|
||||||
|
const json = { "content-type": "application/json" };
|
||||||
|
return {
|
||||||
|
async deleteTuple(tuple) {
|
||||||
|
const res = await http(`${tuples}?${tupleParams(tuple)}`, { method: "DELETE" });
|
||||||
|
if (res.status !== 204) await fail("delete tuple", res);
|
||||||
|
},
|
||||||
|
|
||||||
|
async transactTuples(changes) {
|
||||||
|
const patches = [
|
||||||
|
...changes.insert.map((relation_tuple) => ({ action: "insert", relation_tuple })),
|
||||||
|
...changes.delete.map((relation_tuple) => ({ action: "delete", relation_tuple })),
|
||||||
|
];
|
||||||
|
if (patches.length === 0) return;
|
||||||
|
const res = await http(tuples, { body: JSON.stringify(patches), headers: json, method: "PATCH" });
|
||||||
|
if (res.status !== 204) await fail("transact tuples", res);
|
||||||
|
},
|
||||||
|
|
||||||
// PUT is idempotent — re-asserting an existing tuple is a no-op grant.
|
// PUT is idempotent — re-asserting an existing tuple is a no-op grant.
|
||||||
async writeTuple(tuple) {
|
async writeTuple(tuple) {
|
||||||
const res = await http(tuples, { body: JSON.stringify(tuple), headers: { "content-type": "application/json" }, method: "PUT" });
|
const res = await http(tuples, { body: JSON.stringify(tuple), headers: json, method: "PUT" });
|
||||||
if (!res.ok) await fail("write tuple", res);
|
if (!res.ok) await fail("write tuple", res);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createKetoClient(config: { fetchImpl?: typeof fetch; readUrl: string; writeUrl: string }): KetoClient {
|
||||||
|
const http = config.fetchImpl ?? fetch;
|
||||||
|
return { ...readApi(http, config.readUrl.replace(/\/+$/, "")), ...writeApi(http, config.writeUrl.replace(/\/+$/, "")) };
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
|
|||||||
deleteTuple: async () => {},
|
deleteTuple: async () => {},
|
||||||
expand: async () => ({ type: "leaf" }),
|
expand: async () => ({ type: "leaf" }),
|
||||||
listRelations: async () => ({ nextPageToken: null, tuples: [] }),
|
listRelations: async () => ({ nextPageToken: null, tuples: [] }),
|
||||||
|
transactTuples: async () => {},
|
||||||
writeTuple: async () => {},
|
writeTuple: async () => {},
|
||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
// 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 and their code submits, the Hydra OAuth2
|
// (src/http/builtin-route.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.
|
||||||
import { readFormBody } from "../http/body.ts";
|
import { readFormBody } from "../http/body.ts";
|
||||||
import type { BuiltinRoute, RequestCsrf } from "../http/builtin-routes.ts";
|
import type { BuiltinRoute, RequestCsrf } from "../http/builtin-route.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";
|
||||||
|
|||||||
+2
-1
@@ -110,7 +110,8 @@ test("a one-shot bootstrap seeds the stack before web starts", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("deps live above WORKDIR, so no mount creates a root-owned dir in the checkout", () => {
|
test("deps live above WORKDIR, so no mount creates a root-owned dir in the checkout", () => {
|
||||||
// A volume at /app/node_modules would leave a root-owned dir in the checkout (AGENTS.md).
|
// A volume at /app/node_modules would leave a root-owned dir in the checkout
|
||||||
|
// (docs/decisions.md → "Dependencies install to `/node_modules`, above `WORKDIR /app`").
|
||||||
const dockerfile = read("Dockerfile");
|
const dockerfile = read("Dockerfile");
|
||||||
// split() returns the whole file when the marker is missing, widening "before" to "anywhere".
|
// split() returns the whole file when the marker is missing, widening "before" to "anywhere".
|
||||||
assert.ok(dockerfile.includes("WORKDIR /app"), "the app dir is /app");
|
assert.ok(dockerfile.includes("WORKDIR /app"), "the app dir is /app");
|
||||||
|
|||||||
@@ -97,6 +97,13 @@ test("SERVICE_NAME is overridable so an implementer brands their own logs/traces
|
|||||||
assert.equal(loadConfig({ SERVICE_NAME: "" }).serviceName, "plainpages"); // empty ⇒ default
|
assert.equal(loadConfig({ SERVICE_NAME: "" }).serviceName, "plainpages"); // empty ⇒ default
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("TRUSTED_PROXIES: a comma-separated list of IPs/CIDRs, empty by default, a bad entry refuses the boot", () => {
|
||||||
|
assert.deepEqual(loadConfig({}).trustedProxies, []); // trust nobody's X-Forwarded-For unless told
|
||||||
|
assert.deepEqual(loadConfig({ TRUSTED_PROXIES: "" }).trustedProxies, []); // compose passes ${TRUSTED_PROXIES:-}
|
||||||
|
assert.deepEqual(loadConfig({ TRUSTED_PROXIES: "10.0.0.0/8, ::1" }).trustedProxies, ["10.0.0.0/8", "::1"]);
|
||||||
|
assert.throws(() => loadConfig({ TRUSTED_PROXIES: "10.0.0.1,proxy.internal" }), /TRUSTED_PROXIES/);
|
||||||
|
});
|
||||||
|
|
||||||
test("LOG_LEVEL/LOG_FORMAT/OTLP_PROTOCOL are validated enums; OTLP_ENDPOINT an optional URL", () => {
|
test("LOG_LEVEL/LOG_FORMAT/OTLP_PROTOCOL are validated enums; OTLP_ENDPOINT an optional URL", () => {
|
||||||
assert.equal(loadConfig({ LOG_LEVEL: "debug" }).logLevel, "debug");
|
assert.equal(loadConfig({ LOG_LEVEL: "debug" }).logLevel, "debug");
|
||||||
assert.equal(loadConfig({ LOG_LEVEL: "none" }).logLevel, "none");
|
assert.equal(loadConfig({ LOG_LEVEL: "none" }).logLevel, "none");
|
||||||
|
|||||||
+11
-2
@@ -2,6 +2,8 @@
|
|||||||
// secret, a bad URL or an out-of-range port throws here, never at request time. Every value has a
|
// secret, a bad URL or an out-of-range port throws here, never at request time. Every value has a
|
||||||
// working dev default, so `docker compose up` runs with zero config.
|
// working dev default, so `docker compose up` runs with zero config.
|
||||||
|
|
||||||
|
import { parseTrustedProxies } from "./http/request-fields.ts";
|
||||||
|
|
||||||
// Log verbosity, most→least severe; "none" silences everything (matches @larvit/log's levels).
|
// Log verbosity, most→least severe; "none" silences everything (matches @larvit/log's levels).
|
||||||
export const LOG_LEVELS = ["error", "warn", "info", "verbose", "debug", "silly", "none"] as const;
|
export const LOG_LEVELS = ["error", "warn", "info", "verbose", "debug", "silly", "none"] as const;
|
||||||
export type LogLevel = (typeof LOG_LEVELS)[number];
|
export type LogLevel = (typeof LOG_LEVELS)[number];
|
||||||
@@ -61,6 +63,7 @@ export interface Config {
|
|||||||
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
|
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
|
||||||
secureCookies: boolean;
|
secureCookies: boolean;
|
||||||
serviceName: string; // OTLP service.name — an implementer brands their own logs/traces
|
serviceName: string; // OTLP service.name — an implementer brands their own logs/traces
|
||||||
|
trustedProxies: string[]; // IPs/CIDRs whose X-Forwarded-For names the caller; empty ⇒ the socket peer is the caller
|
||||||
}
|
}
|
||||||
|
|
||||||
type Env = Record<string, string | undefined>;
|
type Env = Record<string, string | undefined>;
|
||||||
@@ -149,6 +152,12 @@ function readPosInt(env: Env, key: string, devDefault: number): number {
|
|||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readTrustedProxies(env: Env): string[] {
|
||||||
|
const entries = (env["TRUSTED_PROXIES"] ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
|
||||||
|
parseTrustedProxies(entries); // throws on a bad entry; the app parses the list again
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
export function loadConfig(env: Env = process.env): Config {
|
export function loadConfig(env: Env = process.env): Config {
|
||||||
const requireSecure = readBool(env, "REQUIRE_SECURE_SECRETS", false);
|
const requireSecure = readBool(env, "REQUIRE_SECURE_SECRETS", false);
|
||||||
return {
|
return {
|
||||||
@@ -176,8 +185,7 @@ export function loadConfig(env: Env = process.env): Config {
|
|||||||
ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"),
|
ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"),
|
||||||
kratosAdminUrl: readUrl(env, "KRATOS_ADMIN_URL", "http://kratos:4434"),
|
kratosAdminUrl: readUrl(env, "KRATOS_ADMIN_URL", "http://kratos:4434"),
|
||||||
kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"),
|
kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"),
|
||||||
// observability. Console-only by default (clean clone). Setting OTLP_ENDPOINT to an
|
// Console-only unless OTLP_ENDPOINT names a collector.
|
||||||
// OpenTelemetry Collector exports structured logs + per-request spans there (Loki/Tempo).
|
|
||||||
logFormat: readEnum(env, "LOG_FORMAT", ["json", "text"] as const, "text"),
|
logFormat: readEnum(env, "LOG_FORMAT", ["json", "text"] as const, "text"),
|
||||||
logLevel: readEnum(env, "LOG_LEVEL", LOG_LEVELS, "info"),
|
logLevel: readEnum(env, "LOG_LEVEL", LOG_LEVELS, "info"),
|
||||||
oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5),
|
oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5),
|
||||||
@@ -199,5 +207,6 @@ export function loadConfig(env: Env = process.env): Config {
|
|||||||
// Set Secure on our session/CSRF cookies. Off by default (dev runs http); prod (https) sets it.
|
// Set Secure on our session/CSRF cookies. Off by default (dev runs http); prod (https) sets it.
|
||||||
secureCookies: readBool(env, "SECURE_COOKIES", false),
|
secureCookies: readBool(env, "SECURE_COOKIES", false),
|
||||||
serviceName: env["SERVICE_NAME"] || "plainpages", // OTLP service.name; empty ⇒ default
|
serviceName: env["SERVICE_NAME"] || "plainpages", // OTLP service.name; empty ⇒ default
|
||||||
|
trustedProxies: readTrustedProxies(env),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,17 +13,15 @@ const LIMITS = { lines: 50, complexity: 20 };
|
|||||||
// Each function over a limit, at its measurement. A ceiling follows its function down, and a metric
|
// 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.
|
// leaves the entry once it fits, so a function that shrank can't grow back.
|
||||||
const grandfathered: Record<string, { lines?: number; complexity?: number }> = {
|
const grandfathered: Record<string, { lines?: number; complexity?: number }> = {
|
||||||
"src/http/app.ts createApp": { lines: 318, complexity: 28 },
|
"src/http/app.ts createApp": { lines: 144, complexity: 26 },
|
||||||
"src/http/app.ts createApp > handleRequest": { lines: 167, complexity: 43 },
|
|
||||||
"src/auth/flow-view.ts buildFlowView": { complexity: 24 },
|
"src/auth/flow-view.ts buildFlowView": { complexity: 24 },
|
||||||
"src/auth/hydra-admin.ts createHydraAdmin": { lines: 87 },
|
"src/auth/hydra-admin.ts createHydraAdmin": { lines: 87 },
|
||||||
"src/auth/kratos-admin.ts createKratosAdmin": { lines: 68 },
|
"src/auth/kratos-admin.ts createKratosAdmin": { lines: 68 },
|
||||||
"src/auth/kratos-public.ts createKratosPublic": { lines: 60 },
|
"src/auth/kratos-public.ts createKratosPublic": { lines: 60 },
|
||||||
"src/plugin-host/discovery.ts discoverPlugins": { lines: 74, complexity: 22 },
|
"src/plugin-host/discovery.ts discoverPlugins": { lines: 74, complexity: 22 },
|
||||||
"src/plugin-host/discovery.ts shapeError": { complexity: 23 },
|
|
||||||
"src/config.ts loadConfig": { lines: 52 },
|
"src/config.ts loadConfig": { lines: 52 },
|
||||||
"release-tooling/dockerhub-overview.ts main": { lines: 57 },
|
"release-tooling/dockerhub-overview.ts main": { lines: 57 },
|
||||||
"examples/plugins/admin/admin-users.ts buildUserFormModel": { complexity: 21 },
|
"examples/plugins/auth-admin/admin-users.ts buildUserFormModel": { complexity: 21 },
|
||||||
};
|
};
|
||||||
|
|
||||||
interface AstNode {
|
interface AstNode {
|
||||||
|
|||||||
+249
-165
@@ -11,17 +11,18 @@ import { staticJwks } from "../auth/jwks.ts";
|
|||||||
import type { OAuth2Client } from "../auth/hydra-admin.ts";
|
import type { OAuth2Client } from "../auth/hydra-admin.ts";
|
||||||
import type { RelationTuple } from "../auth/keto-client.ts";
|
import type { RelationTuple } from "../auth/keto-client.ts";
|
||||||
import type { Identity } from "../auth/kratos-admin.ts";
|
import type { Identity } from "../auth/kratos-admin.ts";
|
||||||
|
import { KratosError } from "../auth/kratos-public.ts";
|
||||||
import { SESSION_COOKIE } from "../auth/login.ts";
|
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||||
import adminManifest from "../../examples/plugins/admin/plugin.ts";
|
import adminManifest from "../../examples/plugins/auth-admin/plugin.ts";
|
||||||
import { createI18n } from "../i18n/runtime.ts";
|
import { createI18n } from "../i18n/runtime.ts";
|
||||||
import { loadI18n } from "../i18n/load.ts";
|
import { loadI18n } from "../i18n/load.ts";
|
||||||
import { ecJwk, fakeKeto, matchesTuple, mintJwt, stubAdmin, stubHydra } from "./app-harness.ts";
|
import { ecJwk, fakeKeto, mintJwt, stubAdmin, stubHydra } from "../test-tooling/app-harness.ts";
|
||||||
|
|
||||||
// The HTTP-level admin tests mount the example plugin via createApp — stub Ory clients on
|
// 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.
|
// 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 examplesPluginsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "examples", "plugins");
|
||||||
const adminPlugin: Plugin = { ...adminManifest, id: "admin" };
|
const adminPlugin: Plugin = { ...adminManifest, id: "auth-admin" };
|
||||||
|
|
||||||
// Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT +
|
// Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT +
|
||||||
// CSRF cookie. get(path, permissions)/post(path, body) carry them; `token` is the matching CSRF field.
|
// CSRF cookie. get(path, permissions)/post(path, body) carry them; `token` is the matching CSRF field.
|
||||||
@@ -72,22 +73,22 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
|
|||||||
const denylist = createDenylist(); // a deactivate/delete should revoke the target's live tokens instantly
|
const denylist = createDenylist(); // a deactivate/delete should revoke the target's live tokens instantly
|
||||||
const { get, post, token, url } = await adminHarness(t, { denylist, kratosAdmin });
|
const { get, post, token, url } = await adminHarness(t, { denylist, kratosAdmin });
|
||||||
|
|
||||||
await assertAdminGate(url, get, "/admin/users");
|
await assertAdminGate(url, get, "/auth-admin/users");
|
||||||
|
|
||||||
// Nav: the admin plugin's section composes into the one global menu, and each screen is filtered
|
// 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
|
// 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.
|
// 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"/);
|
assert.match(await (await get("/dashboard")).text(), /href="\/auth-admin\/users"/);
|
||||||
const usersOnlyNav = await (await get("/dashboard", ["users:read"])).text();
|
const usersOnlyNav = await (await get("/dashboard", ["users:read"])).text();
|
||||||
assert.match(usersOnlyNav, /href="\/admin\/users"/);
|
assert.match(usersOnlyNav, /href="\/auth-admin\/users"/);
|
||||||
assert.doesNotMatch(usersOnlyNav, /href="\/admin\/groups"/);
|
assert.doesNotMatch(usersOnlyNav, /href="\/auth-admin\/groups"/);
|
||||||
assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/admin\/users"/);
|
assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/auth-admin\/users"/);
|
||||||
|
|
||||||
// The read/write split: users:read opens the list but is refused on every mutation, and the
|
// 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.
|
// 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("/auth-admin/users", ["users:read"])).status, 200);
|
||||||
assert.equal((await get("/admin/groups", ["users:read", "users:write"])).status, 403);
|
assert.equal((await get("/auth-admin/groups", ["users:read", "users:write"])).status, 403);
|
||||||
const readOnlyPost = await fetch(url + "/admin/users", {
|
const readOnlyPost = await fetch(url + "/auth-admin/users", {
|
||||||
body: `_csrf=${token}&email=nope@example.com`,
|
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}` },
|
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",
|
method: "POST",
|
||||||
@@ -97,44 +98,44 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
|
|||||||
assert.equal(store.some((i) => i.traits?.email === "nope@example.com"), false);
|
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.
|
// List: the admin sees the rows + the "add" link; the status filter narrows server-side.
|
||||||
const listHtml = await (await get("/admin/users")).text();
|
const listHtml = await (await get("/auth-admin/users")).text();
|
||||||
assert.match(listHtml, /ada@example\.com/);
|
assert.match(listHtml, /ada@example\.com/);
|
||||||
assert.match(listHtml, /href="\/admin\/users\/new"/);
|
assert.match(listHtml, /href="\/auth-admin\/users\/new"/);
|
||||||
assert.doesNotMatch(await (await get("/admin/users?status=inactive")).text(), /ada@example\.com/);
|
assert.doesNotMatch(await (await get("/auth-admin/users?status=inactive")).text(), /ada@example\.com/);
|
||||||
|
|
||||||
// Create: the form renders; a valid post creates the identity and redirects to the list.
|
// 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/);
|
assert.match(await (await get("/auth-admin/users/new")).text(), /Create user/);
|
||||||
const created = await post("/admin/users", `_csrf=${token}&email=grace%40example.com&first=Grace&last=Hopper&password=`);
|
const created = await post("/auth-admin/users", `_csrf=${token}&email=grace%40example.com&first=Grace&last=Hopper&password=`);
|
||||||
assert.equal(created.status, 303);
|
assert.equal(created.status, 303);
|
||||||
assert.equal(created.headers.get("location"), "/admin/users");
|
assert.equal(created.headers.get("location"), "/auth-admin/users");
|
||||||
assert.deepEqual(lastCreate?.traits, { email: "grace@example.com", name: { first: "Grace", last: "Hopper" } });
|
assert.deepEqual(lastCreate?.traits, { email: "grace@example.com", name: { first: "Grace", last: "Hopper" } });
|
||||||
|
|
||||||
// A create with no CSRF token is refused and creates nothing.
|
// A create with no CSRF token is refused and creates nothing.
|
||||||
const before = store.length;
|
const before = store.length;
|
||||||
assert.equal((await post("/admin/users", "email=x%40y.z")).status, 403);
|
assert.equal((await post("/auth-admin/users", "email=x%40y.z")).status, 403);
|
||||||
assert.equal(store.length, before);
|
assert.equal(store.length, before);
|
||||||
|
|
||||||
// CSRF also guards a POST to an *existing* target (the per-route :id handlers), not just the
|
// 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.
|
// 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.equal((await post(`/auth-admin/users/${store[1]!.id}/delete`, "")).status, 403);
|
||||||
assert.ok(store.some((x) => x.id === store[1]!.id));
|
assert.ok(store.some((x) => x.id === store[1]!.id));
|
||||||
|
|
||||||
// Edit: email is read-only + prefilled; a post rewrites the name.
|
// Edit: email is read-only + prefilled; a post rewrites the name.
|
||||||
const target = store[0]!;
|
const target = store[0]!;
|
||||||
const editHtml = await (await get(`/admin/users/${target.id}`)).text();
|
const editHtml = await (await get(`/auth-admin/users/${target.id}`)).text();
|
||||||
assert.match(editHtml, /name="email"[^>]*readonly/);
|
assert.match(editHtml, /name="email"[^>]*readonly/);
|
||||||
assert.match(editHtml, /value="ada@example\.com"/);
|
assert.match(editHtml, /value="ada@example\.com"/);
|
||||||
const updated = await post(`/admin/users/${target.id}`, `_csrf=${token}&first=Ada&last=King`);
|
const updated = await post(`/auth-admin/users/${target.id}`, `_csrf=${token}&first=Ada&last=King`);
|
||||||
assert.equal(updated.status, 303);
|
assert.equal(updated.status, 303);
|
||||||
assert.deepEqual((target.traits as { name: unknown }).name, { first: "Ada", last: "King" });
|
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.
|
// Deactivate (state toggle): active → inactive, and the target's live tokens are revoked at once.
|
||||||
await post(`/admin/users/${target.id}/state`, `_csrf=${token}`);
|
await post(`/auth-admin/users/${target.id}/state`, `_csrf=${token}`);
|
||||||
assert.equal(target.state, "inactive");
|
assert.equal(target.state, "inactive");
|
||||||
assert.equal(denylist.isRevoked(target.id, 0), true);
|
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.
|
// 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}`);
|
const rec = await post(`/auth-admin/users/${target.id}/recovery`, `_csrf=${token}`);
|
||||||
assert.equal(rec.status, 200);
|
assert.equal(rec.status, 200);
|
||||||
const recHtml = await rec.text();
|
const recHtml = await rec.text();
|
||||||
assert.match(recHtml, /Recovery code generated/);
|
assert.match(recHtml, /Recovery code generated/);
|
||||||
@@ -142,22 +143,22 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
|
|||||||
assert.match(recHtml, new RegExp(`<a href="http://kratos/self-service/recovery\\?flow=flow-${target.id}">`));
|
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.
|
// 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();
|
const confirm = await (await get(`/auth-admin/users/${target.id}/delete`)).text();
|
||||||
assert.match(confirm, /Cancel/);
|
assert.match(confirm, /Cancel/);
|
||||||
assert.match(confirm, new RegExp(`action="/admin/users/${target.id}/delete"`));
|
assert.match(confirm, new RegExp(`action="/auth-admin/users/${target.id}/delete"`));
|
||||||
const del = await post(`/admin/users/${target.id}/delete`, `_csrf=${token}`);
|
const del = await post(`/auth-admin/users/${target.id}/delete`, `_csrf=${token}`);
|
||||||
assert.equal(del.status, 303);
|
assert.equal(del.status, 303);
|
||||||
assert.ok(!store.some((x) => x.id === target.id));
|
assert.ok(!store.some((x) => x.id === target.id));
|
||||||
|
|
||||||
// Self-protection: an admin can't delete or deactivate their own account (JWT sub = admin1).
|
// 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.equal((await post(`/auth-admin/users/admin1/delete`, `_csrf=${token}`)).status, 400);
|
||||||
assert.ok(store.some((x) => x.id === "admin1"));
|
assert.ok(store.some((x) => x.id === "admin1"));
|
||||||
assert.equal((await post(`/admin/users/admin1/state`, `_csrf=${token}`)).status, 400);
|
assert.equal((await post(`/auth-admin/users/admin1/state`, `_csrf=${token}`)).status, 400);
|
||||||
assert.equal(store.find((x) => x.id === "admin1")!.state, "active");
|
assert.equal(store.find((x) => x.id === "admin1")!.state, "active");
|
||||||
|
|
||||||
// Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/permissions/clients.
|
// Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/permissions/clients.
|
||||||
assert.equal((await get(`/admin/users/${randomUUID()}`)).status, 404);
|
assert.equal((await get(`/auth-admin/users/${randomUUID()}`)).status, 404);
|
||||||
assert.equal((await get("/admin/users/%ZZ")).status, 404);
|
assert.equal((await get("/auth-admin/users/%ZZ")).status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Built-in Groups admin screen: gate + list/create/membership/delete over HTTP against a
|
// Built-in Groups admin screen: gate + list/create/membership/delete over HTTP against a
|
||||||
@@ -174,56 +175,62 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
|
|||||||
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
|
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
const { get, post, token, url } = await adminHarness(t, { keto, kratosAdmin });
|
const { get, post, token, url } = await adminHarness(t, { keto, kratosAdmin });
|
||||||
|
|
||||||
await assertAdminGate(url, get, "/admin/groups");
|
await assertAdminGate(url, get, "/auth-admin/groups");
|
||||||
|
|
||||||
// List: the existing group shows + the "add" link.
|
// List: the existing group shows + the "add" link.
|
||||||
const listHtml = await (await get("/admin/groups")).text();
|
const listHtml = await (await get("/auth-admin/groups")).text();
|
||||||
assert.match(listHtml, /href="\/admin\/groups\/eng"/);
|
assert.match(listHtml, /href="\/auth-admin\/groups\/eng"/);
|
||||||
assert.match(listHtml, /href="\/admin\/groups\/new"/);
|
assert.match(listHtml, /href="\/auth-admin\/groups\/new"/);
|
||||||
|
|
||||||
// Create: the form renders; a valid post writes the first-member tuple and redirects to the detail.
|
// 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/);
|
assert.match(await (await get("/auth-admin/groups/new")).text(), /Create group/);
|
||||||
const created = await post("/admin/groups", `_csrf=${token}&name=design&member=user:${grace}`);
|
const created = await post("/auth-admin/groups", `_csrf=${token}&name=design&member=user:${grace}`);
|
||||||
assert.equal(created.status, 303);
|
assert.equal(created.status, 303);
|
||||||
assert.equal(created.headers.get("location"), "/admin/groups/design");
|
assert.equal(created.headers.get("location"), "/auth-admin/groups/design");
|
||||||
assert.ok(tuples.some((tp) => tp.object === "design" && tp.subject_id === `user:${grace}`));
|
assert.ok(tuples.some((tp) => tp.object === "design" && tp.subject_id === `user:${grace}`));
|
||||||
|
|
||||||
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
|
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
|
||||||
const before = tuples.length;
|
const before = tuples.length;
|
||||||
assert.equal((await post("/admin/groups", `_csrf=${token}&name=Bad Name&member=user:${grace}`)).status, 400);
|
assert.equal((await post("/auth-admin/groups", `_csrf=${token}&name=Bad Name&member=user:${grace}`)).status, 400);
|
||||||
const duplicate = await post("/admin/groups?locale=sv-SE", `_csrf=${token}&name=eng&member=user:${grace}`);
|
const duplicate = await post("/auth-admin/groups?locale=sv-SE", `_csrf=${token}&name=eng&member=user:${grace}`);
|
||||||
assert.equal(duplicate.status, 400);
|
assert.equal(duplicate.status, 400);
|
||||||
assert.match(await duplicate.text(), /Det finns redan en grupp med det namnet\./);
|
assert.match(await duplicate.text(), /Det finns redan en grupp med det namnet\./);
|
||||||
assert.equal((await post("/admin/groups", `name=x&member=user:${grace}`)).status, 403);
|
assert.equal((await post("/auth-admin/groups", `name=x&member=user:${grace}`)).status, 403);
|
||||||
assert.equal(tuples.length, before);
|
assert.equal(tuples.length, before);
|
||||||
|
|
||||||
// Detail: lists the current member by email.
|
// Detail: lists the current member by email.
|
||||||
assert.match(await (await get("/admin/groups/eng")).text(), /ada@example\.com/);
|
assert.match(await (await get("/auth-admin/groups/eng")).text(), /ada@example\.com/);
|
||||||
|
|
||||||
// Add a member, then remove it.
|
// Add a member, then remove it.
|
||||||
await post("/admin/groups/eng/members", `_csrf=${token}&member=user:${grace}`);
|
await post("/auth-admin/groups/eng/members", `_csrf=${token}&member=user:${grace}`);
|
||||||
assert.ok(tuples.some((tp) => tp.object === "eng" && tp.subject_id === `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}`);
|
await post("/auth-admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`);
|
||||||
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `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.
|
// 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());
|
const permissionsBaseline = baselineOf(await (await get("/auth-admin/groups/eng")).text());
|
||||||
await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${permissionsBaseline}&permission=users%3Aread`);
|
await post("/auth-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"));
|
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.
|
// 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/);
|
assert.match(await (await get("/auth-admin/groups/eng/delete")).text(), /Cancel/);
|
||||||
const del = await post("/admin/groups/eng/delete", `_csrf=${token}`);
|
const bystanders: RelationTuple[] = [
|
||||||
|
{ namespace: "Group", object: "ops", relation: "members", subject_id: `user:${ada}` },
|
||||||
|
{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` },
|
||||||
|
];
|
||||||
|
tuples.push(...bystanders);
|
||||||
|
const del = await post("/auth-admin/groups/eng/delete", `_csrf=${token}`);
|
||||||
assert.equal(del.status, 303);
|
assert.equal(del.status, 303);
|
||||||
assert.equal(del.headers.get("location"), "/admin/groups");
|
assert.equal(del.headers.get("location"), "/auth-admin/groups");
|
||||||
assert.ok(!tuples.some((tp) => tp.object === "eng"));
|
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
|
// …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.
|
// 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"));
|
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.subject_set?.object === "eng"));
|
||||||
|
assert.ok(bystanders.every((tp) => tuples.includes(tp)), "only the deleted group's own tuples go");
|
||||||
|
|
||||||
// An invalid group name in the path → 404; malformed %-encoding doesn't 500.
|
// 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("/auth-admin/groups/Bad%20Name")).status, 404);
|
||||||
assert.equal((await get("/admin/groups/%ZZ")).status, 404);
|
assert.equal((await get("/auth-admin/groups/%ZZ")).status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Granting permissions over HTTP. The offered set is the host's catalog (ctx.declaredPermissions),
|
// Granting permissions over HTTP. The offered set is the host's catalog (ctx.declaredPermissions),
|
||||||
@@ -238,7 +245,7 @@ test("admin permission grants: the picker offers the declared catalog, and a sav
|
|||||||
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
||||||
|
|
||||||
// The user edit page renders one checkbox per declared permission, ticked where already held.
|
// The user edit page renders one checkbox per declared permission, ticked where already held.
|
||||||
const edit = await (await get(`/admin/users/${ada}`)).text();
|
const edit = await (await get(`/auth-admin/users/${ada}`)).text();
|
||||||
for (const name of ["users:read", "users:write", "groups:read", "groups:write", "oauth2-clients:read", "oauth2-clients:write"]) {
|
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, new RegExp(`value="${name.replace(":", ":")}"`), name);
|
||||||
}
|
}
|
||||||
@@ -248,7 +255,7 @@ test("admin permission grants: the picker offers the declared catalog, and a sav
|
|||||||
|
|
||||||
// Save a new set: users:write is added, users:read is dropped — the POST is the whole truth.
|
// Save a new set: users:write is added, users:read is dropped — the POST is the whole truth.
|
||||||
const baseline = baselineOf(edit);
|
const baseline = baselineOf(edit);
|
||||||
const saved = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`);
|
const saved = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`);
|
||||||
assert.equal(saved.status, 303);
|
assert.equal(saved.status, 303);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
tuples.filter((tp) => tp.subject_id === `user:${ada}`).map((tp) => tp.object).sort(),
|
tuples.filter((tp) => tp.subject_id === `user:${ada}`).map((tp) => tp.object).sort(),
|
||||||
@@ -258,23 +265,55 @@ test("admin permission grants: the picker offers the declared catalog, and a sav
|
|||||||
|
|
||||||
// The redirect lands on a page that confirms the save, and shows the new state.
|
// The redirect lands on a page that confirms the save, and shows the new state.
|
||||||
const landing = saved.headers.get("location") ?? "";
|
const landing = saved.headers.get("location") ?? "";
|
||||||
assert.match(landing, new RegExp(`^/admin/users/${ada}\\?info-msg=permissions-saved$`));
|
assert.match(landing, new RegExp(`^/auth-admin/users/${ada}\\?info-msg=permissions-saved$`));
|
||||||
const confirmed = await (await get(landing)).text();
|
const confirmed = await (await get(landing)).text();
|
||||||
assert.match(confirmed, /Permissions saved/);
|
assert.match(confirmed, /Permissions saved/);
|
||||||
assert.match(confirmed, /value="users:write"[^>]*checked/);
|
assert.match(confirmed, /value="users:write"[^>]*checked/);
|
||||||
|
|
||||||
// A crafted POST can't grant something no plugin declares.
|
// 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`);
|
await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(confirmed)}&permission=users%3Awrite&permission=superuser%3Aall`);
|
||||||
assert.ok(!tuples.some((tp) => tp.object === "superuser:all"));
|
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.
|
// 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}` });
|
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
||||||
const groupBaseline = baselineOf(await (await get("/admin/groups/eng")).text());
|
const groupBaseline = baselineOf(await (await get("/auth-admin/groups/eng")).text());
|
||||||
const groupSaved = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread`);
|
const groupSaved = await post("/auth-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.equal(groupSaved.headers.get("location"), "/auth-admin/groups/eng?info-msg=permissions-saved");
|
||||||
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng"));
|
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Either half of a change reaches the user's live sessions, not just a save that does both.
|
||||||
|
test("admin permission grants: a grant-only and a revoke-only user save each revoke live tokens", async (t) => {
|
||||||
|
const [grantee, revokee] = [randomUUID(), randomUUID()];
|
||||||
|
const identities: Identity[] = [{ id: grantee, traits: { email: "g@example.com" } }, { id: revokee, traits: { email: "r@example.com" } }];
|
||||||
|
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${revokee}` }];
|
||||||
|
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 save = async (id: string, permissions: string) =>
|
||||||
|
post(`/auth-admin/users/${id}/permissions`, `_csrf=${token}&baseline=${baselineOf(await (await get(`/auth-admin/users/${id}`)).text())}${permissions}`);
|
||||||
|
|
||||||
|
await save(grantee, "&permission=users%3Aread");
|
||||||
|
assert.equal(denylist.isRevoked(grantee, 0), true);
|
||||||
|
await save(revokee, "");
|
||||||
|
assert.equal(denylist.isRevoked(revokee, 0), true);
|
||||||
|
assert.equal(tuples.filter((tp) => tp.subject_id === `user:${revokee}`).length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove needs Keto, but not the revocation denylist.
|
||||||
|
test("admin permission grants: Remove answers 503 without Keto, and still deletes where no denylist is wired", async (t) => {
|
||||||
|
const ada = randomUUID();
|
||||||
|
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||||
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
|
const noKeto = await adminHarness(t, { kratosAdmin });
|
||||||
|
assert.equal((await noKeto.post(`/auth-admin/users/${ada}/permissions/remove`, `_csrf=${noKeto.token}&remove=old%3Awrite`)).status, 503);
|
||||||
|
|
||||||
|
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "old:write", relation: "granted", subject_id: `user:${ada}` }];
|
||||||
|
const noDenylist = await adminHarness(t, { keto: fakeKeto(tuples), kratosAdmin });
|
||||||
|
assert.equal((await noDenylist.post(`/auth-admin/users/${ada}/permissions/remove`, `_csrf=${noDenylist.token}&remove=old%3Awrite`)).status, 303);
|
||||||
|
assert.equal(tuples.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
// Two admins open the same page; the second save would silently undo the first.
|
// 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) => {
|
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 ada = randomUUID();
|
||||||
@@ -283,12 +322,12 @@ test("admin permission grants: a save from a stale page is refused, and the page
|
|||||||
const keto = fakeKeto(tuples);
|
const keto = fakeKeto(tuples);
|
||||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
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 { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
|
||||||
const stale = baselineOf(await (await get(`/admin/users/${ada}`)).text());
|
const stale = baselineOf(await (await get(`/auth-admin/users/${ada}`)).text());
|
||||||
|
|
||||||
// Another admin grants users:write while this page is open.
|
// Another admin grants users:write while this page is open.
|
||||||
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
|
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
|
||||||
const before = JSON.stringify(tuples);
|
const before = JSON.stringify(tuples);
|
||||||
const refused = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=groups%3Aread`);
|
const refused = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=groups%3Aread`);
|
||||||
assert.equal(refused.status, 409);
|
assert.equal(refused.status, 409);
|
||||||
const page = await refused.text();
|
const page = await refused.text();
|
||||||
assert.match(page, /changed while you had the page open/);
|
assert.match(page, /changed while you had the page open/);
|
||||||
@@ -297,48 +336,95 @@ test("admin permission grants: a save from a stale page is refused, and the page
|
|||||||
assert.notEqual(baselineOf(page), stale);
|
assert.notEqual(baselineOf(page), stale);
|
||||||
|
|
||||||
// Saving from the re-rendered page goes through.
|
// 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`);
|
const retried = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(page)}&permission=users%3Aread&permission=users%3Awrite&permission=groups%3Aread`);
|
||||||
assert.equal(retried.status, 303);
|
assert.equal(retried.status, 303);
|
||||||
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === `user:${ada}`));
|
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.
|
// 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);
|
assert.equal((await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Aread`)).status, 409);
|
||||||
|
|
||||||
// Groups are checked the same way.
|
// Groups are checked the same way.
|
||||||
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
||||||
const groupStale = baselineOf(await (await get("/admin/groups/eng")).text());
|
const groupStale = baselineOf(await (await get("/auth-admin/groups/eng")).text());
|
||||||
tuples.push({ namespace: "Permission", object: "groups:read", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
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`);
|
const groupRefused = await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupStale}&permission=users%3Aread`);
|
||||||
assert.equal(groupRefused.status, 409);
|
assert.equal(groupRefused.status, 409);
|
||||||
assert.match(await groupRefused.text(), /changed while you had the page open/);
|
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");
|
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 applies none of the changes and revokes no live token", async (t) => {
|
||||||
test("admin permission grants: a Keto failure midway reports a partial save and keeps what was applied", async (t) => {
|
|
||||||
const ada = randomUUID();
|
const ada = randomUUID();
|
||||||
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||||
const tuples: RelationTuple[] = [];
|
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
|
||||||
let writes = 0;
|
const keto = fakeKeto(tuples, { transactTuples: async () => { throw new Error("keto unreachable"); } });
|
||||||
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 kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
const denylist = createDenylist();
|
const denylist = createDenylist();
|
||||||
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
||||||
const baseline = baselineOf(await (await get(`/admin/users/${ada}`)).text());
|
const baseline = baselineOf(await (await get(`/auth-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`);
|
const res = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Awrite`);
|
||||||
assert.equal(res.status, 502);
|
assert.equal(res.status, 500);
|
||||||
const page = await res.text();
|
assert.deepEqual(tuples.map((tp) => tp.object), ["users:read"]);
|
||||||
assert.match(page, /stopped part-way/);
|
assert.equal(denylist.isRevoked(ada, 0), false);
|
||||||
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/);
|
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
||||||
assert.deepEqual(tuples.map((tp) => tp.object), ["groups:read"]);
|
const groupBaseline = baselineOf(await (await get("/auth-admin/groups/eng")).text());
|
||||||
assert.equal(denylist.isRevoked(ada, 0), true); // the half that was applied still revokes live tokens
|
const groupRes = await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread`);
|
||||||
|
assert.equal(groupRes.status, 500);
|
||||||
|
assert.equal(tuples.filter((tp) => tp.subject_set?.object === "eng").length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admin users: a refused self-deactivate or self-delete keeps the permissions section", async (t) => {
|
||||||
|
const identities: Identity[] = [{ id: "admin1", traits: { email: "admin@x" } }];
|
||||||
|
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: fakeKeto(), kratosAdmin });
|
||||||
|
assert.notEqual(baselineOf(await (await get("/auth-admin/users/admin1")).text()), "", "the plain form carries the section");
|
||||||
|
|
||||||
|
const refusals: [Response, RegExp][] = [
|
||||||
|
[await post("/auth-admin/users/admin1/state", `_csrf=${token}`), /deactivate your own account/],
|
||||||
|
[await get("/auth-admin/users/admin1/delete"), /delete your own account/],
|
||||||
|
[await post("/auth-admin/users/admin1/delete", `_csrf=${token}`), /delete your own account/],
|
||||||
|
];
|
||||||
|
for (const [res, reason] of refusals) {
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
const html = await res.text();
|
||||||
|
assert.match(html, reason);
|
||||||
|
assert.notEqual(baselineOf(html), "");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admin users: a Kratos rejection of a save re-renders the form with its permissions, any other failure is a 500", async (t) => {
|
||||||
|
const identities: Identity[] = [{ id: "ada", traits: { email: "ada@example.com" } }];
|
||||||
|
let failure: Error = new KratosError("rejected", 400, "");
|
||||||
|
const kratosAdmin = stubAdmin({
|
||||||
|
getIdentity: async (id) => identities.find((i) => i.id === id) ?? null,
|
||||||
|
listIdentities: async () => ({ identities, nextPageToken: null }),
|
||||||
|
updateIdentity: async () => { throw failure; },
|
||||||
|
});
|
||||||
|
const { post, token } = await adminHarness(t, { keto: fakeKeto(), kratosAdmin });
|
||||||
|
|
||||||
|
const rejected = await post("/auth-admin/users/ada", `_csrf=${token}&first=Ada`);
|
||||||
|
assert.equal(rejected.status, 400);
|
||||||
|
assert.notEqual(baselineOf(await rejected.text()), "");
|
||||||
|
failure = new Error("kratos unreachable");
|
||||||
|
assert.equal((await post("/auth-admin/users/ada", `_csrf=${token}&first=Ada`)).status, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("admin group delete: a Keto failure leaves the group's grants and members in place", async (t) => {
|
||||||
|
const ada = randomUUID();
|
||||||
|
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||||
|
const tuples: RelationTuple[] = [
|
||||||
|
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` },
|
||||||
|
{ namespace: "Permission", object: "users:read", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } },
|
||||||
|
];
|
||||||
|
const keto = fakeKeto(tuples, { transactTuples: async () => { throw new Error("keto unreachable"); } });
|
||||||
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
|
const { post, token } = await adminHarness(t, { keto, kratosAdmin });
|
||||||
|
|
||||||
|
const res = await post("/auth-admin/groups/eng/delete", `_csrf=${token}`);
|
||||||
|
assert.equal(res.status, 500);
|
||||||
|
assert.equal(tuples.length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
// A double-clicked Save posts twice from one page; the second post finds the first one's result.
|
// A double-clicked Save posts twice from one page; the second post finds the first one's result.
|
||||||
@@ -350,64 +436,62 @@ test("admin permission grants: a save that changes nothing is not refused for a
|
|||||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||||
const denylist = createDenylist();
|
const denylist = createDenylist();
|
||||||
const { get, post, token } = await adminHarness(t, { denylist, keto: fakeKeto(tuples), kratosAdmin });
|
const { get, post, token } = await adminHarness(t, { denylist, keto: fakeKeto(tuples), kratosAdmin });
|
||||||
const stale = baselineOf(await (await get(`/admin/users/${ada}`)).text());
|
const stale = baselineOf(await (await get(`/auth-admin/users/${ada}`)).text());
|
||||||
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
|
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
|
||||||
const before = JSON.stringify(tuples);
|
const before = JSON.stringify(tuples);
|
||||||
|
|
||||||
const repeated = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=users%3Awrite`);
|
const repeated = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=users%3Awrite`);
|
||||||
assert.equal(repeated.status, 303);
|
assert.equal(repeated.status, 303);
|
||||||
assert.equal(JSON.stringify(tuples), before);
|
assert.equal(JSON.stringify(tuples), before);
|
||||||
assert.equal(denylist.isRevoked(ada, 0), false);
|
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 grant no plugin declares is listed with a Remove, which deletes it and revokes live tokens", async (t) => {
|
||||||
test("admin permission grants: a partial save is still reported when Keto cannot be read again", async (t) => {
|
|
||||||
const ada = randomUUID();
|
const ada = randomUUID();
|
||||||
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||||
const tuples: RelationTuple[] = [];
|
const held = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Permission", object, relation: "granted", ...subject });
|
||||||
let writes = 0;
|
const eng = { subject_set: { namespace: "Group", object: "eng", relation: "members" } };
|
||||||
let down = false;
|
const tuples: RelationTuple[] = [
|
||||||
let failureTakesKetoDown = true;
|
held("users:read", { subject_id: `user:${ada}` }), held("old:write", { subject_id: `user:${ada}` }),
|
||||||
const keto = fakeKeto(tuples, {
|
held("old:write", eng), held("old:write", { subject_id: "user:someone-else" }),
|
||||||
listRelations: async (q = {}) => {
|
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` },
|
||||||
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 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 denylist = createDenylist();
|
||||||
const baseline = baselineOf(await (await get(`/admin/users/${ada}`)).text());
|
const { get, post, token } = await adminHarness(t, { denylist, keto: fakeKeto(tuples), kratosAdmin });
|
||||||
|
|
||||||
const res = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Aread`);
|
const page = await (await get(`/auth-admin/users/${ada}`)).text();
|
||||||
assert.equal(res.status, 502);
|
assert.match(page, /Permissions no installed plugin declares/);
|
||||||
assert.match(await res.text(), /stopped part-way/);
|
assert.match(page, new RegExp(`action="/auth-admin/users/${ada}/permissions/remove#permissions-h"`));
|
||||||
assert.deepEqual(tuples.map((tp) => tp.object), ["groups:read"]);
|
assert.match(page, /name="remove" value="old:write"/);
|
||||||
|
assert.doesNotMatch(page, /name="remove" value="users:read"/, "a declared name has no Remove");
|
||||||
|
const readerPage = await (await get(`/auth-admin/users/${ada}`, ["users:read"])).text();
|
||||||
|
assert.match(readerPage, /old:write/);
|
||||||
|
assert.doesNotMatch(readerPage, /permissions\/remove/);
|
||||||
|
|
||||||
// The same on a group, whose page needs Keto for its members as well.
|
const declared = await post(`/auth-admin/users/${ada}/permissions/remove`, `_csrf=${token}&remove=users%3Aread`);
|
||||||
down = false;
|
assert.equal(declared.status, 303);
|
||||||
writes = 0;
|
assert.equal(tuples.length, 5);
|
||||||
tuples.length = 0;
|
assert.equal(denylist.isRevoked(ada, 0), false);
|
||||||
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.
|
const removed = await post(`/auth-admin/users/${ada}/permissions/remove`, `_csrf=${token}&remove=old%3Awrite`);
|
||||||
failureTakesKetoDown = false;
|
assert.equal(removed.status, 303);
|
||||||
down = false;
|
assert.equal(removed.headers.get("location"), `/auth-admin/users/${ada}?info-msg=permission-removed`);
|
||||||
writes = 0;
|
assert.match(await (await get(removed.headers.get("location")!)).text(), /Permission removed\./);
|
||||||
tuples.length = 1;
|
assert.equal(tuples.length, 4);
|
||||||
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.ok(!tuples.some((tp) => tp.object === "old:write" && tp.subject_id === `user:${ada}`));
|
||||||
assert.equal(again.status, 502);
|
assert.ok(tuples.some((tp) => tp.object === "old:write" && tp.subject_set?.object === "eng"), "the group's grant is untouched");
|
||||||
const page = await again.text();
|
assert.equal(denylist.isRevoked(ada, 0), true);
|
||||||
assert.match(page, /stopped part-way/);
|
assert.equal((await post(`/auth-admin/users/${ada}/permissions/remove`, `_csrf=${token}&remove=old%3Awrite`)).status, 303, "a second click is harmless");
|
||||||
assert.match(page, /value="groups:read"[^>]*checked/);
|
assert.equal((await post(`/auth-admin/users/${ada}/permissions/remove`, "remove=old%3Awrite")).status, 403, "CSRF-guarded");
|
||||||
|
|
||||||
|
const groupPage = await (await get("/auth-admin/groups/eng")).text();
|
||||||
|
assert.match(groupPage, /action="\/auth-admin\/groups\/eng\/permissions\/remove#permissions-h"/);
|
||||||
|
const groupRemoved = await post("/auth-admin/groups/eng/permissions/remove", `_csrf=${token}&remove=old%3Awrite`);
|
||||||
|
assert.equal(groupRemoved.status, 303);
|
||||||
|
assert.equal(groupRemoved.headers.get("location"), "/auth-admin/groups/eng?info-msg=permission-removed");
|
||||||
|
assert.ok(!tuples.some((tp) => tp.object === "old:write" && tp.subject_set?.object === "eng"));
|
||||||
|
assert.ok(tuples.some((tp) => tp.object === "old:write" && tp.subject_id === "user:someone-else"), "another subject's grant is untouched");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Revoking your own grants can remove the last users:write on the deployment, and the instant-revoke
|
// Revoking your own grants can remove the last users:write on the deployment, and the instant-revoke
|
||||||
@@ -419,15 +503,15 @@ test("admin permission grants: you can't revoke your own permissions, but you ca
|
|||||||
const keto = fakeKeto(tuples);
|
const keto = fakeKeto(tuples);
|
||||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
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 { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
|
||||||
const baseline = baselineOf(await (await get("/admin/users/admin1")).text());
|
const baseline = baselineOf(await (await get("/auth-admin/users/admin1")).text());
|
||||||
|
|
||||||
const refused = await post("/admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}`); // every box cleared
|
const refused = await post("/auth-admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}`); // every box cleared
|
||||||
assert.equal(refused.status, 400);
|
assert.equal(refused.status, 400);
|
||||||
assert.match(await refused.text(), /lock yourself out/);
|
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");
|
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.
|
// 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`);
|
const granted = await post("/auth-admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`);
|
||||||
assert.equal(granted.status, 303);
|
assert.equal(granted.status, 303);
|
||||||
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === "user:admin1"));
|
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === "user:admin1"));
|
||||||
});
|
});
|
||||||
@@ -464,32 +548,32 @@ test("admin groups: you can't take away permissions you hold only through a grou
|
|||||||
const keto = fakeKeto(tuples, { check: resolvingCheck(tuples) });
|
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 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 { get, post, token } = await adminHarness(t, { keto, kratosAdmin }, you);
|
||||||
const baseline = baselineOf(await (await get("/admin/groups/eng")).text());
|
const baseline = baselineOf(await (await get("/auth-admin/groups/eng")).text());
|
||||||
// A refusal renders that group's own page and names what you would lose.
|
// A refusal renders that group's own page and names what you would lose.
|
||||||
const refusedWithMessage = async (res: Response, group: string, lost: string) => {
|
const refusedWithMessage = async (res: Response, group: string, lost: string) => {
|
||||||
assert.equal(res.status, 400);
|
assert.equal(res.status, 400);
|
||||||
const html = await res.text();
|
const html = await res.text();
|
||||||
assert.match(html, new RegExp(`lock you out of ${lost}, which`));
|
assert.match(html, new RegExp(`lock you out of ${lost}, which`));
|
||||||
assert.match(html, new RegExp(`action="/admin/groups/${group}/members"`));
|
assert.match(html, new RegExp(`action="/auth-admin/groups/${group}/members"`));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Unticking users:write on eng would take it from you: refused, nothing revoked.
|
// Unticking users:write on eng would take it from you: refused, nothing revoked.
|
||||||
const before = JSON.stringify(tuples);
|
const before = JSON.stringify(tuples);
|
||||||
await refusedWithMessage(await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${baseline}&permission=groups%3Awrite`), "eng", "users:write");
|
await refusedWithMessage(await post("/auth-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
|
// 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.
|
// 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("/auth-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 post("/auth-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 get("/auth-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");
|
await refusedWithMessage(await post("/auth-admin/groups/eng/delete", `_csrf=${token}`), "eng", "oauth2-clients:write, users:write");
|
||||||
assert.equal(JSON.stringify(tuples), before, "nothing changed");
|
assert.equal(JSON.stringify(tuples), before, "nothing changed");
|
||||||
|
|
||||||
// You hold groups:write directly too, so unticking it on eng costs you nothing.
|
// 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);
|
assert.equal((await post("/auth-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.
|
// 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("/auth-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("/auth-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.equal((await post("/auth-admin/groups/design/delete", `_csrf=${token}`)).status, 303);
|
||||||
assert.ok(!tuples.some((tp) => tp.object === "design" || tp.subject_set?.object === "design"));
|
assert.ok(!tuples.some((tp) => tp.object === "design" || tp.subject_set?.object === "design"));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -507,43 +591,43 @@ test("admin screens render no write affordance for a read-only holder", async (t
|
|||||||
const { get } = await adminHarness(t, { hydra, keto, kratosAdmin });
|
const { get } = await adminHarness(t, { hydra, keto, kratosAdmin });
|
||||||
const readOnly = ["users:read", "groups:read"];
|
const readOnly = ["users:read", "groups:read"];
|
||||||
|
|
||||||
const list = await (await get("/admin/users", readOnly)).text();
|
const list = await (await get("/auth-admin/users", readOnly)).text();
|
||||||
assert.doesNotMatch(list, /href="\/admin\/users\/new"/); // no "New user"
|
assert.doesNotMatch(list, /href="\/auth-admin\/users\/new"/); // no "New user"
|
||||||
assert.match(list, /ada@example\.com/); // but the list itself is there — that's the point of :read
|
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>.)
|
// (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();
|
const detail = await (await get(`/auth-admin/users/${ada}`, readOnly)).text();
|
||||||
assert.doesNotMatch(detail, /Save changes/);
|
assert.doesNotMatch(detail, /Save changes/);
|
||||||
assert.doesNotMatch(detail, /Generate recovery code/);
|
assert.doesNotMatch(detail, /Generate recovery code/);
|
||||||
assert.doesNotMatch(detail, /Delete user/);
|
assert.doesNotMatch(detail, /Delete user/);
|
||||||
assert.doesNotMatch(detail, /Save permissions/);
|
assert.doesNotMatch(detail, /Save permissions/);
|
||||||
assert.match(detail, /type="checkbox"[^>]*disabled/); // the permissions are shown, just not editable
|
assert.match(detail, /type="checkbox"[^>]*disabled/); // the permissions are shown, just not editable
|
||||||
|
|
||||||
const group = await (await get("/admin/groups/eng", readOnly)).text();
|
const group = await (await get("/auth-admin/groups/eng", readOnly)).text();
|
||||||
assert.doesNotMatch(group, /Add a member/);
|
assert.doesNotMatch(group, /Add a member/);
|
||||||
assert.doesNotMatch(group, /Delete group/);
|
assert.doesNotMatch(group, /Delete group/);
|
||||||
assert.doesNotMatch(group, /Save permissions/);
|
assert.doesNotMatch(group, /Save permissions/);
|
||||||
|
|
||||||
// The OAuth2-clients screen is held to the same rule.
|
// The OAuth2-clients screen is held to the same rule.
|
||||||
const clientsRes = await get("/admin/clients", ["oauth2-clients:read"]);
|
const clientsRes = await get("/auth-admin/clients", ["oauth2-clients:read"]);
|
||||||
assert.equal(clientsRes.status, 200); // a real render, not the capability-missing 503
|
assert.equal(clientsRes.status, 200); // a real render, not the capability-missing 503
|
||||||
const clients = await clientsRes.text();
|
const clients = await clientsRes.text();
|
||||||
assert.match(clients, /Reporting/); // the list is there — that's what :read buys
|
assert.match(clients, /Reporting/); // the list is there — that's what :read buys
|
||||||
assert.doesNotMatch(clients, /href="\/admin\/clients\/new"/);
|
assert.doesNotMatch(clients, /href="\/auth-admin\/clients\/new"/);
|
||||||
// The detail page is where Delete lives, so check it too and not just the list.
|
// 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();
|
const clientDetail = await (await get("/auth-admin/clients/existing", ["oauth2-clients:read"])).text();
|
||||||
assert.match(clientDetail, /Reporting/);
|
assert.match(clientDetail, /Reporting/);
|
||||||
assert.doesNotMatch(clientDetail, /clients\/existing\/delete/);
|
assert.doesNotMatch(clientDetail, /clients\/existing\/delete/);
|
||||||
|
|
||||||
// A write-intent GET — a create form or a delete-confirm — refuses a reader outright rather than
|
// A write-intent GET — a create form or a delete-confirm — refuses a reader outright rather than
|
||||||
// rendering a form whose submit would 403.
|
// 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"]) {
|
for (const path of ["/auth-admin/users/new", "/auth-admin/groups/new", `/auth-admin/users/${ada}/delete`, "/auth-admin/groups/eng/delete"]) {
|
||||||
assert.equal((await get(path, readOnly)).status, 403, path);
|
assert.equal((await get(path, readOnly)).status, 403, path);
|
||||||
}
|
}
|
||||||
assert.equal((await get("/admin/clients/new", ["oauth2-clients:read"])).status, 403);
|
assert.equal((await get("/auth-admin/clients/new", ["oauth2-clients:read"])).status, 403);
|
||||||
|
|
||||||
// A writer sees the affordances the reader didn't.
|
// A writer sees the affordances the reader didn't.
|
||||||
const writable = await (await get(`/admin/users/${ada}`, ["users:read", "users:write"])).text();
|
const writable = await (await get(`/auth-admin/users/${ada}`, ["users:read", "users:write"])).text();
|
||||||
assert.match(writable, /Save changes/);
|
assert.match(writable, /Save changes/);
|
||||||
assert.match(writable, /Save permissions/);
|
assert.match(writable, /Save permissions/);
|
||||||
});
|
});
|
||||||
@@ -563,20 +647,20 @@ test("admin OAuth2 clients screen: gate, list, register (one-time secret), detai
|
|||||||
});
|
});
|
||||||
const { get, post, token, url } = await adminHarness(t, { hydra });
|
const { get, post, token, url } = await adminHarness(t, { hydra });
|
||||||
|
|
||||||
await assertAdminGate(url, get, "/admin/clients");
|
await assertAdminGate(url, get, "/auth-admin/clients");
|
||||||
|
|
||||||
// List: the existing client shows + the "register" link.
|
// List: the existing client shows + the "register" link.
|
||||||
const listHtml = await (await get("/admin/clients")).text();
|
const listHtml = await (await get("/auth-admin/clients")).text();
|
||||||
assert.match(listHtml, /href="\/admin\/clients\/existing"/);
|
assert.match(listHtml, /href="\/auth-admin\/clients\/existing"/);
|
||||||
assert.match(listHtml, /href="\/admin\/clients\/new"/);
|
assert.match(listHtml, /href="\/auth-admin\/clients\/new"/);
|
||||||
assert.match(listHtml, /Reporting/);
|
assert.match(listHtml, /Reporting/);
|
||||||
|
|
||||||
// Register: the form renders (with confidential-vs-public guidance); a valid post creates the
|
// Register: the form renders (with confidential-vs-public guidance); a valid post creates the
|
||||||
// client and shows the one-time secret + id.
|
// client and shows the one-time secret + id.
|
||||||
const formHtml = await (await get("/admin/clients/new")).text();
|
const formHtml = await (await get("/auth-admin/clients/new")).text();
|
||||||
assert.match(formHtml, /Register client/);
|
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 <%= %>)
|
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`);
|
const created = await post("/auth-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
|
assert.equal(created.status, 200); // not a redirect — the secret is shown once
|
||||||
const createdHtml = await created.text();
|
const createdHtml = await created.text();
|
||||||
assert.match(createdHtml, /Client registered/);
|
assert.match(createdHtml, /Client registered/);
|
||||||
@@ -586,26 +670,26 @@ test("admin OAuth2 clients screen: gate, list, register (one-time secret), detai
|
|||||||
|
|
||||||
// Invalid input (missing redirect URI) and a missing CSRF token are both refused, nothing created.
|
// Invalid input (missing redirect URI) and a missing CSRF token are both refused, nothing created.
|
||||||
const before = store.length;
|
const before = store.length;
|
||||||
assert.equal((await post("/admin/clients", `_csrf=${token}&name=NoRedirect&redirectUris=`)).status, 400);
|
assert.equal((await post("/auth-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((await post("/auth-admin/clients", `name=x&redirectUris=${encodeURIComponent("https://x/cb")}`)).status, 403);
|
||||||
assert.equal(store.length, before);
|
assert.equal(store.length, before);
|
||||||
|
|
||||||
// Detail: read-only info, never the secret again, a delete control.
|
// Detail: read-only info, never the secret again, a delete control.
|
||||||
const detail = await (await get("/admin/clients/existing")).text();
|
const detail = await (await get("/auth-admin/clients/existing")).text();
|
||||||
assert.match(detail, /reporting\.example\/cb/);
|
assert.match(detail, /reporting\.example\/cb/);
|
||||||
assert.doesNotMatch(detail, /Client secret/i); // the secret is shown only once, at creation
|
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, /delete and re-register/i); // no edit: the lifecycle guidance is surfaced
|
||||||
assert.match(detail, /href="\/admin\/clients\/existing\/delete"/);
|
assert.match(detail, /href="\/auth-admin\/clients\/existing\/delete"/);
|
||||||
|
|
||||||
// Delete: a confirm step (GET) then the POST removes the client, back to the list.
|
// 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/);
|
assert.match(await (await get("/auth-admin/clients/existing/delete")).text(), /Cancel/);
|
||||||
const del = await post("/admin/clients/existing/delete", `_csrf=${token}`);
|
const del = await post("/auth-admin/clients/existing/delete", `_csrf=${token}`);
|
||||||
assert.equal(del.status, 303);
|
assert.equal(del.status, 303);
|
||||||
assert.equal(del.headers.get("location"), "/admin/clients");
|
assert.equal(del.headers.get("location"), "/auth-admin/clients");
|
||||||
assert.ok(!store.some((c) => c.client_id === "existing"));
|
assert.ok(!store.some((c) => c.client_id === "existing"));
|
||||||
|
|
||||||
// Unknown id → 404; malformed %-encoding doesn't 500.
|
// Unknown id → 404; malformed %-encoding doesn't 500.
|
||||||
assert.equal((await get("/admin/clients/does-not-exist")).status, 404);
|
assert.equal((await get("/auth-admin/clients/does-not-exist")).status, 404);
|
||||||
assert.equal((await get("/admin/clients/%ZZ")).status, 404);
|
assert.equal((await get("/auth-admin/clients/%ZZ")).status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { KratosError, type Flow, type FlowSubmission, type FlowType, type Kratos
|
|||||||
import { SESSION_COOKIE } from "../auth/login.ts";
|
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||||
import { createI18n } from "../i18n/runtime.ts";
|
import { createI18n } from "../i18n/runtime.ts";
|
||||||
import { loadI18n } from "../i18n/load.ts";
|
import { loadI18n } from "../i18n/load.ts";
|
||||||
import { ecJwk, fakeKeto, mintJwt, mockKratos, OAUTH_SUBJECT, stubAdmin, stubHydra, withWhoami } from "./app-harness.ts";
|
import { ecJwk, fakeKeto, mintJwt, mockKratos, OAUTH_SUBJECT, stubAdmin, stubHydra, withWhoami } from "../test-tooling/app-harness.ts";
|
||||||
|
|
||||||
// A re-rendered login flow: csrf hidden, themed fields, a submit, and a failed-attempt message.
|
// 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 node = (attrs: Record<string, unknown>, label?: string): UiNode => ({ attributes: attrs, group: "default", messages: [], meta: label ? { label: { id: 1, text: label, type: "info" } } : {}, type: "input" });
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import type { Plugin } from "../plugin-host/plugin.ts";
|
|||||||
import { createI18n } from "../i18n/runtime.ts";
|
import { createI18n } from "../i18n/runtime.ts";
|
||||||
import type { MenuConfig } from "../ui/menu-config.ts";
|
import type { MenuConfig } from "../ui/menu-config.ts";
|
||||||
import { loadI18n } from "../i18n/load.ts";
|
import { loadI18n } from "../i18n/load.ts";
|
||||||
import { ecJwk, mintJwt } from "./app-harness.ts";
|
import { ecJwk, mintJwt } from "../test-tooling/app-harness.ts";
|
||||||
|
|
||||||
// ---- language (i18n) ----
|
// ---- language (i18n) ----
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ import { staticJwks } from "../auth/jwks.ts";
|
|||||||
import type { Identity } from "../auth/kratos-admin.ts";
|
import type { Identity } from "../auth/kratos-admin.ts";
|
||||||
import { KratosError, type Session } from "../auth/kratos-public.ts";
|
import { KratosError, type Session } from "../auth/kratos-public.ts";
|
||||||
import { SESSION_COOKIE } from "../auth/login.ts";
|
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||||
|
import { createLogger } from "../logger.ts";
|
||||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||||
import { ecJwk, fakeKeto, mintJwt, startApp, stubAdmin, withWhoami } from "./app-harness.ts";
|
import { ecJwk, fakeKeto, mintJwt, startApp, stubAdmin, withWhoami } from "../test-tooling/app-harness.ts";
|
||||||
|
|
||||||
// A test plugin exercising each RouteResult shape, a path param, and the permission gate.
|
// A test plugin exercising each RouteResult shape, a path param, and the permission gate.
|
||||||
const demoPlugin: Plugin = {
|
const demoPlugin: Plugin = {
|
||||||
@@ -224,6 +225,48 @@ test("session re-mint: an expired JWT backed by a live Kratos session is silentl
|
|||||||
assert.equal(outage.headers.get("set-cookie"), null);
|
assert.equal(outage.headers.get("set-cookie"), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("session re-mint runs only for an expired token with all three Ory clients wired; the new cookie is Secure under SECURE_COOKIES", 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" });
|
||||||
|
let whoamiCalls = 0;
|
||||||
|
const kratos = withWhoami(async (o) => { whoamiCalls++; return (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session; });
|
||||||
|
const keto = fakeKeto([{ namespace: "Permission", object: "demo:read", relation: "granted", subject_id: "user:u1" }], { check: async () => true });
|
||||||
|
const kratosAdmin = stubAdmin({});
|
||||||
|
const logged: string[] = [];
|
||||||
|
const log = createLogger({ format: "json", level: "info", stderr: (l) => logged.push(l), stdout: (l) => logged.push(l) });
|
||||||
|
const valid = `${SESSION_COOKIE}=${freshJwt}`;
|
||||||
|
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["demo:read"], sub: "u1" })}`;
|
||||||
|
const get = async (options: Parameters<typeof createApp>[0], cookie: string): Promise<Response> => {
|
||||||
|
const app = createApp({ jwks: staticJwks([ecJwk]), log, plugins: [demoPlugin], ...options });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
return fetch(`http://localhost:${(app.address() as AddressInfo).port}/demo/secret`, { headers: { cookie }, redirect: "manual" });
|
||||||
|
};
|
||||||
|
const sessionCookieOf = (res: Response): string | undefined => res.headers.getSetCookie().find((c) => c.startsWith(`${SESSION_COOKIE}=`));
|
||||||
|
|
||||||
|
// A valid token, or one that is invalid but not expired, never reaches Ory.
|
||||||
|
const ok = await get({ keto, kratos, kratosAdmin }, valid);
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
assert.equal(sessionCookieOf(ok), undefined);
|
||||||
|
assert.equal((await get({ keto, kratos, kratosAdmin }, `${SESSION_COOKIE}=not-a-jwt`)).status, 303);
|
||||||
|
assert.equal(whoamiCalls, 0);
|
||||||
|
|
||||||
|
// An expired token with any one of the three clients missing stays anonymous, without trying.
|
||||||
|
for (const wired of [{ kratos, kratosAdmin }, { keto, kratosAdmin }, { keto, kratos }]) {
|
||||||
|
const res = await get(wired, expired);
|
||||||
|
assert.equal(res.status, 303, Object.keys(wired).join("+"));
|
||||||
|
assert.equal(sessionCookieOf(res), undefined);
|
||||||
|
}
|
||||||
|
assert.equal(whoamiCalls, 0);
|
||||||
|
assert.ok(!logged.some((l) => l.includes("session re-mint failed")), "no re-mint was attempted");
|
||||||
|
|
||||||
|
// All three wired on an https deployment: the re-minted cookie carries Secure.
|
||||||
|
const reminted = await get({ keto, kratos, kratosAdmin, secureCookies: true }, expired);
|
||||||
|
assert.equal(reminted.status, 200);
|
||||||
|
assert.match(sessionCookieOf(reminted) ?? "", /; Secure(;|$)/);
|
||||||
|
});
|
||||||
|
|
||||||
test("guards map to responses: requireSession → /login, a failed can/check → 403, success runs the handler", async (t) => {
|
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 keto = { check: async (tuple: { object: string }) => tuple.object === "open" } as unknown as Parameters<typeof check>[0];
|
||||||
const guarded: Plugin = {
|
const guarded: Plugin = {
|
||||||
@@ -285,19 +328,42 @@ test("plugin hooks: onRequest can short-circuit a request and onResponse observe
|
|||||||
onResponse: (c, r) => void seen.push(`${c.url.pathname}:${r && "html" in r ? r.html : "?"}`),
|
onResponse: (c, r) => void seen.push(`${c.url.pathname}:${r && "html" in r ? r.html : "?"}`),
|
||||||
},
|
},
|
||||||
id: "hooked",
|
id: "hooked",
|
||||||
routes: [{ handler: () => ({ html: "handler ran" }), method: "GET", path: "/ok" }],
|
routes: [
|
||||||
|
{ handler: () => ({ html: "handler ran" }), method: "GET", path: "/ok" },
|
||||||
|
{ handler: () => { seen.push("blocked handler ran"); return { html: "behind the hook" }; }, method: "GET", path: "/blocked" },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
const url = await startApp(t, [hooked]);
|
const url = await startApp(t, [hooked]);
|
||||||
|
|
||||||
// onRequest short-circuits before routing — handler never runs; a fresh CSRF cookie still rides the
|
// onRequest short-circuits before routing — the route behind it never runs; a fresh CSRF cookie
|
||||||
// response so a form the hook renders has its matching double-submit cookie.
|
// still rides the response so a form the hook renders has its matching double-submit cookie.
|
||||||
const blocked = await fetch(url + "/hooked/blocked");
|
const blocked = await fetch(url + "/hooked/blocked");
|
||||||
assert.equal(blocked.status, 403);
|
assert.equal(blocked.status, 403);
|
||||||
assert.match(await blocked.text(), /blocked by hook/);
|
assert.match(await blocked.text(), /blocked by hook/);
|
||||||
assert.match(blocked.headers.get("set-cookie") ?? "", /plainpages_csrf=/);
|
assert.match(blocked.headers.get("set-cookie") ?? "", /plainpages_csrf=/);
|
||||||
|
// Again with that cookie, so no Set-Cookie is due once the response is sent.
|
||||||
|
await (await fetch(url + "/hooked/blocked", { headers: { cookie: blocked.headers.get("set-cookie")?.split(";")[0] ?? "" } })).text();
|
||||||
|
assert.ok(!seen.includes("blocked handler ran"));
|
||||||
|
|
||||||
// A normal route runs the handler; onResponse observed its result.
|
// A normal route runs the handler; onResponse observed its result.
|
||||||
assert.match(await (await fetch(url + "/hooked/ok")).text(), /handler ran/);
|
assert.match(await (await fetch(url + "/hooked/ok")).text(), /handler ran/);
|
||||||
assert.ok(seen.includes("/hooked/ok:handler ran"));
|
assert.ok(seen.includes("/hooked/ok:handler ran"));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("plugin hooks: the request routes by the method and path it arrived with, whatever onRequest does to req", async (t) => {
|
||||||
|
const rewriter: Plugin = {
|
||||||
|
apiVersion: "1.0.0",
|
||||||
|
hooks: { onRequest: (c) => { c.req.method = "POST"; c.req.url = "/rewriter/other"; } },
|
||||||
|
id: "rewriter",
|
||||||
|
routes: [
|
||||||
|
{ handler: () => ({ html: "original route" }), method: "GET", path: "/original" },
|
||||||
|
{ handler: () => ({ html: "rewritten route" }), method: "POST", path: "/other" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const url = await startApp(t, [rewriter]);
|
||||||
|
|
||||||
|
const res = await fetch(url + "/rewriter/original");
|
||||||
|
assert.equal(res.status, 200);
|
||||||
|
assert.match(await res.text(), /original route/);
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import { test, type TestContext } from "node:test";
|
||||||
|
import { createLogger } from "../logger.ts";
|
||||||
|
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||||
|
import { createApp } from "./app.ts";
|
||||||
|
|
||||||
|
// The `request` line, parsed, once the server has logged it (it logs on the response's close).
|
||||||
|
async function requestLineOf(lines: string[]): Promise<Record<string, unknown>> {
|
||||||
|
for (let i = 0; i < 200; i++) {
|
||||||
|
const line = lines.map((l) => JSON.parse(l) as Record<string, unknown>).find((rec) => rec["message"] === "request" || rec["msg"] === "request");
|
||||||
|
if (line) return line;
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
}
|
||||||
|
throw new Error("no request line was logged");
|
||||||
|
}
|
||||||
|
|
||||||
|
// `lines` is stdout (info); `problems` is stderr (warn and error).
|
||||||
|
async function serve(t: TestContext, trustedProxies: string[], plugins: Plugin[] = []): Promise<{ lines: string[]; problems: string[]; url: string }> {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const problems: string[] = [];
|
||||||
|
const log = createLogger({ format: "json", level: "info", stderr: (l: string) => problems.push(l), stdout: (l: string) => lines.push(l) });
|
||||||
|
const app = createApp({ log, plugins, trustedProxies });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
return { lines, problems, url: `http://localhost:${(app.address() as AddressInfo).port}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the request line names the caller, the site and the matched route in OpenTelemetry's terms", async (t) => {
|
||||||
|
const { lines, url } = await serve(t, []);
|
||||||
|
await (await fetch(`${url}/?token=secret`, { headers: { "user-agent": "TV/1.0", "x-forwarded-for": "198.51.100.7" } })).text();
|
||||||
|
const line = await requestLineOf(lines);
|
||||||
|
|
||||||
|
assert.equal(line["http.request.method"], "GET");
|
||||||
|
assert.equal(line["url.path"], "/"); // never the query
|
||||||
|
assert.equal(line["http.response.status_code"], 200);
|
||||||
|
assert.equal(line["http.route"], "/");
|
||||||
|
assert.equal(line["server.address"], "localhost");
|
||||||
|
assert.equal(line["user_agent.original"], "TV/1.0");
|
||||||
|
assert.match(String(line["client.address"]), /^(127\.0\.0\.1|::1)$/); // the forged header is not believed
|
||||||
|
assert.equal(line["service.name"], "plainpages");
|
||||||
|
assert.ok(line["requestId"], "carries a requestId for log↔trace correlation");
|
||||||
|
for (const old of ["method", "path", "status", "ms"]) assert.equal(line[old], undefined, old);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("http.route is the full pattern, plugin prefix included, so two plugins' routes never merge", async (t) => {
|
||||||
|
const plugin: Plugin = {
|
||||||
|
apiVersion: "1.0.0",
|
||||||
|
id: "demo",
|
||||||
|
routes: [{ handler: () => ({ json: {} }), method: "GET", path: "/items/:id", public: true }],
|
||||||
|
};
|
||||||
|
const { lines, url } = await serve(t, [], [plugin]);
|
||||||
|
await (await fetch(`${url}/demo/items/7`)).text();
|
||||||
|
const line = await requestLineOf(lines);
|
||||||
|
|
||||||
|
assert.equal(line["http.route"], "/demo/items/:id");
|
||||||
|
assert.equal(line["url.path"], "/demo/items/7");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a listed proxy lets X-Forwarded-For name the caller; an unmatched path carries no route", async (t) => {
|
||||||
|
const { lines, url } = await serve(t, ["127.0.0.0/8", "::1"]);
|
||||||
|
await (await fetch(`${url}/no-such-page`, { headers: { "x-forwarded-for": "198.51.100.7" } })).text();
|
||||||
|
const line = await requestLineOf(lines);
|
||||||
|
|
||||||
|
assert.equal(line["client.address"], "198.51.100.7");
|
||||||
|
assert.equal(line["http.response.status_code"], 404);
|
||||||
|
assert.equal(line["http.route"], undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an answered request goes no further: a refused visitor never reaches the handler, and nothing logs a problem", async (t) => {
|
||||||
|
const ran: string[] = [];
|
||||||
|
const plugin: Plugin = {
|
||||||
|
apiVersion: "1.0.0",
|
||||||
|
id: "demo",
|
||||||
|
routes: [
|
||||||
|
{ handler: () => ({ html: "open" }), method: "GET", path: "/open", public: true },
|
||||||
|
{ handler: () => { ran.push("gated"); return { html: "gated" }; }, method: "GET", path: "/gated", permission: "demo:read" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const { lines, problems, url } = await serve(t, [], [plugin]);
|
||||||
|
// A plugin route, a refused plugin route, a built-in route, a refused built-in route, an asset.
|
||||||
|
const paths = ["/demo/open", "/demo/gated", "/", "/dashboard", "/public/css/styles.css"];
|
||||||
|
for (const path of paths) await (await fetch(url + path, { redirect: "manual" })).text();
|
||||||
|
// A request that fell through to a later stage would log its error after its response closed.
|
||||||
|
while (lines.filter((l) => l.includes('"request"')).length < paths.length) await new Promise((r) => setTimeout(r, 10));
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
|
assert.deepEqual(ran, []);
|
||||||
|
assert.deepEqual(problems, []);
|
||||||
|
});
|
||||||
+78
-33
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { request as httpRequest } from "node:http";
|
import { request as httpRequest } from "node:http";
|
||||||
import type { AddressInfo } from "node:net";
|
import { type AddressInfo, connect } from "node:net";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { after, before, test } from "node:test";
|
import { after, before, test } from "node:test";
|
||||||
@@ -14,7 +14,7 @@ import { CSRF_COOKIE } from "../auth/csrf.ts";
|
|||||||
import { staticJwks } from "../auth/jwks.ts";
|
import { staticJwks } from "../auth/jwks.ts";
|
||||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||||
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
|
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
|
||||||
import { ecJwk, session, startApp } from "./app-harness.ts";
|
import { ecJwk, session, startApp } from "../test-tooling/app-harness.ts";
|
||||||
|
|
||||||
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
|
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
|
||||||
|
|
||||||
@@ -115,31 +115,6 @@ test("renders branding from the menu config into the shell: logo + default theme
|
|||||||
assert.match(html, /id="theme-dark"\s+checked/); // config default theme reaches the switch
|
assert.match(html, /id="theme-dark"\s+checked/); // config default theme reaches the switch
|
||||||
});
|
});
|
||||||
|
|
||||||
test("emits a structured access-log line per request (the injected logger)", async (t) => {
|
|
||||||
const lines: string[] = [];
|
|
||||||
const app = createApp({ log: createLogger({ format: "json", level: "info", stderr: () => {}, stdout: (m) => lines.push(m) }) });
|
|
||||||
await new Promise<void>((r) => app.listen(0, r));
|
|
||||||
t.after(() => app.close());
|
|
||||||
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/?q=zz`); // the public "/" — no auth
|
|
||||||
assert.equal(res.status, 200);
|
|
||||||
await res.text(); // consume the body so the connection closes (the access line emits on close)
|
|
||||||
|
|
||||||
// The line is emitted on connection close (after the body is sent) — poll briefly for it.
|
|
||||||
let line: string | undefined;
|
|
||||||
for (let i = 0; i < 50 && !line; i++) {
|
|
||||||
line = lines.find((l) => l.includes('"msg":"request"'));
|
|
||||||
if (!line) await new Promise((r) => setTimeout(r, 10));
|
|
||||||
}
|
|
||||||
assert.ok(line, "an access line is logged for the request");
|
|
||||||
const rec = JSON.parse(line!);
|
|
||||||
assert.equal(rec.method, "GET");
|
|
||||||
assert.equal(rec.path, "/"); // pathname only — the ?q=… query is dropped (may carry tokens)
|
|
||||||
assert.equal(rec.status, 200);
|
|
||||||
assert.equal(rec["service.name"], "plainpages");
|
|
||||||
assert.equal(typeof rec.ms, "number");
|
|
||||||
assert.ok(rec.requestId, "carries a requestId for log↔trace correlation");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("ctx.log: a handler logs in the request trace, and ctx.log.fetch continues the inbound trace", async (t) => {
|
test("ctx.log: a handler logs in the request trace, and ctx.log.fetch continues the inbound trace", async (t) => {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
const upstream: { traceparent: string | undefined; url: string }[] = [];
|
const upstream: { traceparent: string | undefined; url: string }[] = [];
|
||||||
@@ -194,14 +169,19 @@ test("ctx.log after a client abort doesn't throw: the request log is ended only
|
|||||||
// deferred until the handler settles (regression for the abort race).
|
// deferred until the handler settles (regression for the abort race).
|
||||||
let afterCloseOk = false;
|
let afterCloseOk = false;
|
||||||
let afterCloseErr: string | undefined;
|
let afterCloseErr: string | undefined;
|
||||||
|
const started = Promise.withResolvers<void>();
|
||||||
|
const closed = Promise.withResolvers<void>();
|
||||||
|
const logged = Promise.withResolvers<void>();
|
||||||
const plugin: Plugin = {
|
const plugin: Plugin = {
|
||||||
apiVersion: "1.0.0",
|
apiVersion: "1.0.0",
|
||||||
id: "slow",
|
id: "slow",
|
||||||
routes: [{
|
routes: [{
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
await new Promise((r) => setTimeout(r, 120)); // outlasts the client abort below
|
started.resolve();
|
||||||
|
await closed.promise;
|
||||||
try { ctx.log.info("after abort", {}); afterCloseOk = true; } // would throw if end() already ran
|
try { ctx.log.info("after abort", {}); afterCloseOk = true; } // would throw if end() already ran
|
||||||
catch (e) { afterCloseErr = String(e); }
|
catch (e) { afterCloseErr = String(e); }
|
||||||
|
logged.resolve();
|
||||||
return { json: { ok: true } };
|
return { json: { ok: true } };
|
||||||
},
|
},
|
||||||
method: "GET",
|
method: "GET",
|
||||||
@@ -209,16 +189,20 @@ test("ctx.log after a client abort doesn't throw: the request log is ended only
|
|||||||
}],
|
}],
|
||||||
};
|
};
|
||||||
const app = createApp({ log: createLogger({ level: "none" }), plugins: [plugin] });
|
const app = createApp({ log: createLogger({ level: "none" }), plugins: [plugin] });
|
||||||
|
// Added after the host's request listener, so this "close" listener fires after the host's.
|
||||||
|
app.once("request", (_req, res) => res.on("close", () => closed.resolve()));
|
||||||
await new Promise<void>((r) => app.listen(0, r));
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
t.after(() => app.close());
|
t.after(() => app.close());
|
||||||
const base = `http://localhost:${(app.address() as AddressInfo).port}`;
|
const base = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||||
|
|
||||||
// Abort the request mid-handler (well before the 120ms), forcing res "close" while it still runs.
|
// Abort the request mid-handler.
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
setTimeout(() => ac.abort(), 20);
|
const aborted = assert.rejects(fetch(base + "/slow/go", { signal: ac.signal })); // the client sees the abort
|
||||||
await assert.rejects(fetch(base + "/slow/go", { signal: ac.signal })); // the client sees the abort
|
await started.promise;
|
||||||
|
ac.abort();
|
||||||
|
await aborted;
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 200)); // let the handler finish post-abort
|
await logged.promise;
|
||||||
assert.equal(afterCloseErr, undefined, "ctx.log did not throw after the client disconnected");
|
assert.equal(afterCloseErr, undefined, "ctx.log did not throw after the client disconnected");
|
||||||
assert.ok(afterCloseOk, "the handler logged successfully after close");
|
assert.ok(afterCloseOk, "the handler logged successfully after close");
|
||||||
|
|
||||||
@@ -239,6 +223,9 @@ test("static serving: GET sends body + content-type, HEAD headers only, unsafe p
|
|||||||
// Encoded traversal and a NUL byte are refused before touching the filesystem.
|
// Encoded traversal and a NUL byte are refused before touching the filesystem.
|
||||||
assert.equal((await fetch(base + "/public/..%2f..%2fapp.ts")).status, 403);
|
assert.equal((await fetch(base + "/public/..%2f..%2fapp.ts")).status, 403);
|
||||||
assert.equal((await fetch(base + "/public/%00")).status, 403);
|
assert.equal((await fetch(base + "/public/%00")).status, 403);
|
||||||
|
|
||||||
|
// Only GET/HEAD read an asset; any other method is routed like a page, and nothing answers it.
|
||||||
|
assert.equal((await fetch(base + "/public/css/styles.css", { method: "POST" })).status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("every response carries the security headers; HSTS follows SECURE_COOKIES", async (t) => {
|
test("every response carries the security headers; HSTS follows SECURE_COOKIES", async (t) => {
|
||||||
@@ -260,6 +247,20 @@ test("every response carries the security headers; HSTS follows SECURE_COOKIES",
|
|||||||
assert.match(res.headers.get("strict-transport-security") ?? "", /max-age=\d+/);
|
assert.match(res.headers.get("strict-transport-security") ?? "", /max-age=\d+/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("the CSRF cookie is set only when the request carries no valid token, and is Secure under SECURE_COOKIES", async (t) => {
|
||||||
|
const first = await fetch(base + "/");
|
||||||
|
const csrf = first.headers.getSetCookie().find((c) => c.startsWith(`${CSRF_COOKIE}=`)) ?? "";
|
||||||
|
assert.ok(csrf, "a first visit mints the token");
|
||||||
|
assert.doesNotMatch(csrf, /; Secure/); // http dev
|
||||||
|
assert.deepEqual((await fetch(base + "/", { headers: { cookie: csrf.split(";")[0] ?? "" } })).headers.getSetCookie(), []);
|
||||||
|
|
||||||
|
const secure = createApp({ secureCookies: true });
|
||||||
|
await new Promise<void>((r) => secure.listen(0, r));
|
||||||
|
t.after(() => secure.close());
|
||||||
|
const res = await fetch(`http://localhost:${(secure.address() as AddressInfo).port}/`);
|
||||||
|
assert.match(res.headers.getSetCookie().find((c) => c.startsWith(`${CSRF_COOKIE}=`)) ?? "", /; Secure(;|$)/);
|
||||||
|
});
|
||||||
|
|
||||||
test("Cache-Control: pages and JSON are private, no-store unless the route sets its own", async (t) => {
|
test("Cache-Control: pages and JSON are private, no-store unless the route sets its own", async (t) => {
|
||||||
const NO_STORE = "private, no-store";
|
const NO_STORE = "private, no-store";
|
||||||
const csrf = (await fetch(base + "/")).headers.get("set-cookie")?.split(";")[0] ?? "";
|
const csrf = (await fetch(base + "/")).headers.get("set-cookie")?.split(";")[0] ?? "";
|
||||||
@@ -330,6 +331,18 @@ function rawGet(port: number, path: string, host: string, method = "GET"): Promi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An HTTP/1.0 request with no Host header (Node refuses a Host-less HTTP/1.1 one with a 400);
|
||||||
|
// resolves to the raw response head.
|
||||||
|
function rawHttp10(port: number, path: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let raw = "";
|
||||||
|
const socket = connect(port, "127.0.0.1", () => socket.write(`GET ${path} HTTP/1.0\r\n\r\n`));
|
||||||
|
socket.on("data", (c) => (raw += c));
|
||||||
|
socket.on("end", () => resolve(raw));
|
||||||
|
socket.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test("APP_URL canonical-host redirect: an off-host visitor is 308'd to the configured origin (path+query kept)", async (t) => {
|
test("APP_URL canonical-host redirect: an off-host visitor is 308'd to the configured origin (path+query kept)", async (t) => {
|
||||||
// Reach the app on any host and it sends you to APP_URL's, so the browser, the themed form and the
|
// Reach the app on any host and it sends you to APP_URL's, so the browser, the themed form and the
|
||||||
// cross-origin Kratos POST share ONE cookie host. Same-host requests pass straight through.
|
// cross-origin Kratos POST share ONE cookie host. Same-host requests pass straight through.
|
||||||
@@ -354,6 +367,22 @@ test("APP_URL canonical-host redirect: an off-host visitor is 308'd to the confi
|
|||||||
// A 308 must not replay a cross-host POST — non-GET/HEAD is left alone (not canonicalised).
|
// A 308 must not replay a cross-host POST — non-GET/HEAD is left alone (not canonicalised).
|
||||||
const post = await rawGet(port, "/dashboard", `127.0.0.1:${port}`, "POST");
|
const post = await rawGet(port, "/dashboard", `127.0.0.1:${port}`, "POST");
|
||||||
assert.notEqual(post.status, 308);
|
assert.notEqual(post.status, 308);
|
||||||
|
|
||||||
|
// HEAD is canonicalised like GET.
|
||||||
|
const head = await rawGet(port, "/dashboard", `127.0.0.1:${port}`, "HEAD");
|
||||||
|
assert.equal(head.status, 308);
|
||||||
|
assert.equal(head.location, "http://canonical.example:3000/dashboard");
|
||||||
|
|
||||||
|
// An HTTP/1.0 request may omit Host; with no host to compare it is served as-is.
|
||||||
|
assert.match(await rawHttp10(port, "/missing"), /^HTTP\/1\.[01] 404 /);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an APP_URL with no host (file:///srv) ⇒ no canonical redirect", async (t) => {
|
||||||
|
const app = createApp({ appUrl: "file:///srv" });
|
||||||
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
|
t.after(() => app.close());
|
||||||
|
const port = (app.address() as AddressInfo).port;
|
||||||
|
assert.equal((await rawGet(port, "/missing", `127.0.0.1:${port}`)).status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("no APP_URL configured ⇒ no canonical redirect (unit-test apps and host-agnostic deploys unaffected)", async () => {
|
test("no APP_URL configured ⇒ no canonical redirect (unit-test apps and host-agnostic deploys unaffected)", async () => {
|
||||||
@@ -377,7 +406,7 @@ test("/error renders a themed sign-in error page (Kratos' flow error sink), not
|
|||||||
test("renders the 500 HTML page when a handler throws", async () => {
|
test("renders the 500 HTML page when a handler throws", async () => {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "pp-views-"));
|
const dir = mkdtempSync(join(tmpdir(), "pp-views-"));
|
||||||
cpSync(viewsDir, dir, { recursive: true }); // the real views: 500.ejs includes the language picker
|
cpSync(viewsDir, dir, { recursive: true }); // the real views: 500.ejs includes the language picker
|
||||||
writeFileSync(join(dir, "index.ejs"), "<% throw new Error('boom'); %>"); // …but the dashboard view throws
|
writeFileSync(join(dir, "dashboard.ejs"), "<% throw new Error('boom'); %>"); // …but the dashboard view throws
|
||||||
const app = createApp({ jwks: staticJwks([ecJwk]), viewsDir: dir });
|
const app = createApp({ jwks: staticJwks([ecJwk]), viewsDir: dir });
|
||||||
try {
|
try {
|
||||||
await new Promise<void>((resolve) => app.listen(0, resolve));
|
await new Promise<void>((resolve) => app.listen(0, resolve));
|
||||||
@@ -392,6 +421,22 @@ test("renders the 500 HTML page when a handler throws", async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a plugin versions the /public/ files its own markup links, through the assetHref view local or ctx.assetHref", async (t) => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
|
||||||
|
mkdirSync(join(dir, "drag", "views"), { recursive: true });
|
||||||
|
mkdirSync(join(dir, "drag", "public"), { recursive: true });
|
||||||
|
writeFileSync(join(dir, "drag", "views", "page.ejs"), `<script src="<%= assetHref("/public/drag/board.js") %>"></script><img src="<%= logo %>" alt="">`);
|
||||||
|
writeFileSync(join(dir, "drag", "public", "board.js"), "void 0;");
|
||||||
|
writeFileSync(join(dir, "drag", "public", "logo.svg"), "<svg/>");
|
||||||
|
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||||
|
const drag: Plugin = { apiVersion: "1.0.0", id: "drag", routes: [{ handler: (ctx) => ({ data: { logo: ctx.assetHref("/public/drag/logo.svg") }, view: "page" }), method: "GET", path: "/page", public: true }] };
|
||||||
|
const url = await startApp(t, [drag], dir);
|
||||||
|
|
||||||
|
const html = await (await fetch(url + "/drag/page")).text();
|
||||||
|
assert.match(html, /src="\/public\/drag\/board\.js\?v=[\w-]+"/);
|
||||||
|
assert.match(html, /src="\/public\/drag\/logo\.svg\?v=[\w-]+"/);
|
||||||
|
});
|
||||||
|
|
||||||
// Every /public/ reference in the page, as written in the HTML.
|
// Every /public/ reference in the page, as written in the HTML.
|
||||||
const publicRefs = (html: string): string[] => [...html.matchAll(/(?:href|src)="(\/public\/[^"]*)"/g)].map((m) => m[1] ?? "");
|
const publicRefs = (html: string): string[] => [...html.matchAll(/(?:href|src)="(\/public\/[^"]*)"/g)].map((m) => m[1] ?? "");
|
||||||
|
|
||||||
|
|||||||
+227
-199
@@ -1,9 +1,9 @@
|
|||||||
import { randomBytes, randomUUID } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import ejs from "ejs";
|
import ejs from "ejs";
|
||||||
import { type BuiltinRoute, matchBuiltinRoute, type PluginContextFactory, type RequestCsrf } from "./builtin-routes.ts";
|
import { type BuiltinRoute, matchBuiltinRoute, type PluginContextFactory, type RequestCsrf } from "./builtin-route.ts";
|
||||||
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
|
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
|
||||||
import { buildContext, type RequestContext, type User } from "./context.ts";
|
import { buildContext, type RequestContext, type User } from "./context.ts";
|
||||||
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
|
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
|
||||||
@@ -21,18 +21,20 @@ import { resolveSession, type VerifyOptions } from "../auth/jwt-middleware.ts";
|
|||||||
import type { KetoClient } from "../auth/keto-client.ts";
|
import type { KetoClient } from "../auth/keto-client.ts";
|
||||||
import type { KratosAdmin } from "../auth/kratos-admin.ts";
|
import type { KratosAdmin } from "../auth/kratos-admin.ts";
|
||||||
import type { KratosPublic } from "../auth/kratos-public.ts";
|
import type { KratosPublic } from "../auth/kratos-public.ts";
|
||||||
import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts";
|
import { createLogger, type Log } from "../logger.ts";
|
||||||
import { remintSession } from "../auth/login.ts";
|
import { remintSession } from "../auth/login.ts";
|
||||||
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
|
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, fullPath, type PermissionDecl, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts";
|
||||||
import type { PluginSettings } from "../plugin-host/settings.ts";
|
import type { PluginSettings } from "../plugin-host/settings.ts";
|
||||||
import type { SystemCapabilities } from "../plugin-host/system.ts";
|
import type { SystemCapabilities } from "../plugin-host/system.ts";
|
||||||
import { allows, type Gate } from "../auth/gate.ts";
|
import { allows, type Gate } from "../auth/gate.ts";
|
||||||
import { allowedMethods, matchRoute } from "../plugin-host/router.ts";
|
import { allowedMethods, matchRoute } from "../plugin-host/router.ts";
|
||||||
import { buildAuthRoutes } from "../auth/routes.ts";
|
import { buildAuthRoutes } from "../auth/routes.ts";
|
||||||
|
import { parseTrustedProxies } from "./request-fields.ts";
|
||||||
|
import { trackRequest } from "./request-tracking.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 { publicAssets } from "./static.ts";
|
import { type PublicAssets, 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 NO_STORE = "private, no-store";
|
||||||
@@ -58,6 +60,7 @@ export interface AppOptions {
|
|||||||
publicDir?: string;
|
publicDir?: string;
|
||||||
secureCookies?: boolean; // set Secure on our session/CSRF cookies (config.secureCookies; off in dev http)
|
secureCookies?: boolean; // set Secure on our session/CSRF cookies (config.secureCookies; off in dev http)
|
||||||
settingsCatalog?: readonly PluginSettings[]; // resolved at boot (server.ts, needs the env); → ctx.declaredSettings
|
settingsCatalog?: readonly PluginSettings[]; // resolved at boot (server.ts, needs the env); → ctx.declaredSettings
|
||||||
|
trustedProxies?: readonly string[]; // IPs/CIDRs whose X-Forwarded-For names the caller (config.trustedProxies); omitted ⇒ the socket peer
|
||||||
viewsDir?: string;
|
viewsDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,10 +72,9 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
const revoke = denylist ? (sub: string): void => denylist.revoke(sub) : undefined;
|
const revoke = denylist ? (sub: string): void => denylist.revoke(sub) : undefined;
|
||||||
const cache = options.cache ?? false;
|
const cache = options.cache ?? false;
|
||||||
const canonical = options.appUrl ? new URL(options.appUrl) : undefined;
|
const canonical = options.appUrl ? new URL(options.appUrl) : undefined;
|
||||||
const canonicalHost = canonical?.host; // host[:port], default ports omitted — matches the Host header
|
|
||||||
const canonicalOrigin = canonical?.origin; // scheme + host[:port], no trailing slash
|
|
||||||
const csrfSecret = options.csrfSecret ?? randomBytes(32).toString("hex"); // server passes config; tests pass their own
|
const csrfSecret = options.csrfSecret ?? randomBytes(32).toString("hex"); // server passes config; tests pass their own
|
||||||
const secureCookies = options.secureCookies ?? false;
|
const secureCookies = options.secureCookies ?? false;
|
||||||
|
const trustedProxies = parseTrustedProxies(options.trustedProxies);
|
||||||
const hydra = options.hydra;
|
const hydra = options.hydra;
|
||||||
const i18n = options.i18n ?? ENGLISH_I18N;
|
const i18n = options.i18n ?? ENGLISH_I18N;
|
||||||
const jwks = options.jwks;
|
const jwks = options.jwks;
|
||||||
@@ -111,7 +113,7 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
|
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
|
||||||
|
|
||||||
// Where the language picker points. Normally the page itself; after a POST that URL may answer no
|
// Where the language picker points. Normally the page itself; after a POST that URL may answer no
|
||||||
// GET (POST /admin/users/:id/delete has no GET sibling), so fall back to the page the form was
|
// GET (POST /auth-admin/users/:id/recovery has no GET sibling), so fall back to the page the form was
|
||||||
// submitted from, then to the front page — the picker is on every page, so every link must land.
|
// submitted from, then to the front page — the picker is on every page, so every link must land.
|
||||||
const switchBase = (req: IncomingMessage, url: URL): string => {
|
const switchBase = (req: IncomingMessage, url: URL): string => {
|
||||||
const method = (req.method ?? "GET").toUpperCase();
|
const method = (req.method ?? "GET").toUpperCase();
|
||||||
@@ -137,11 +139,6 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)), assetHref: assets.href });
|
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)), assetHref: assets.href });
|
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 => {
|
|
||||||
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
|
||||||
res.end(html);
|
|
||||||
};
|
|
||||||
|
|
||||||
// The public landing "/", ungated. A plugin may own it via `home`; else the built-in intro page.
|
// The public landing "/", ungated. A plugin may own it via `home`; else the built-in intro page.
|
||||||
const serveHome = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
|
const serveHome = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
|
||||||
csrf.setCookie();
|
csrf.setCookie();
|
||||||
@@ -168,7 +165,7 @@ 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: { chrome: ctx.chrome }, view: "index" };
|
return { data: { chrome: ctx.chrome }, view: "dashboard" };
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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/
|
||||||
@@ -179,9 +176,10 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
{ handler: serveDashboard, method: "GET", path: "/dashboard", session: true },
|
{ handler: serveDashboard, method: "GET", path: "/dashboard", session: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
// The request handler. Run inside runWithLog (below) so the per-request logger is ambient: every
|
const host: Host = { anyRequestHooks, anyResponseHooks, assets, authOptions, builtinRoutes, csrfSecret, i18n, jwks, keto, kratos, kratosAdmin, menu, permissionCatalog, pluginViewsFor, plugins, secureCookies, settingsCatalog, system, viewsFor };
|
||||||
// outbound fetch (the Ory clients via tracedFetch) and any deep module joins this request's trace
|
|
||||||
// and correlation with no logger threaded through their signatures.
|
// A stage that returns a boolean returns true once it has answered the request, and false to
|
||||||
|
// pass the request to the next stage.
|
||||||
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.
|
||||||
@@ -189,195 +187,225 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
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");
|
||||||
const pathname = url.pathname;
|
|
||||||
|
|
||||||
// Set before any branch so every response — static/redirect/error included — inherits them
|
// Set before any branch so every response — static/redirect/error included — inherits them
|
||||||
// (writeHead merges these with its own headers; a plugin's RouteResult.headers can override).
|
// (writeHead merges these with its own headers; a plugin's RouteResult.headers can override).
|
||||||
for (const [name, value] of secHeaderEntries) res.setHeader(name, value);
|
for (const [name, value] of secHeaderEntries) res.setHeader(name, value);
|
||||||
|
// Before the session is resolved: an asset needs no user, and verifying the JWT cookie that
|
||||||
// Before auth: assets don't need a verified user, and the JWT cookie rides every request.
|
// every asset request carries would be wasted work.
|
||||||
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
|
if (await serveStatic(assets, method, url, req, res, reqLog)) return;
|
||||||
await assets.serve(url, req, res, (err) => reqLog.error("static file error", { error: String(err) }));
|
setPageCacheHeaders(res);
|
||||||
return;
|
if (redirectToCanonicalHost(canonical, method, req, res)) return;
|
||||||
}
|
const state = await prepareRequest(host, req, res, method, url, reqLog);
|
||||||
|
renderPage = viewsFor(state.ctx);
|
||||||
// A cache in front of us must key on the language. Set after the static branch: an asset is
|
if (await answerFromHook(host, state)) return;
|
||||||
// the same bytes in every language, and a Vary there fragments its entry per raw header.
|
if (await answerPluginRoute(host, state)) return;
|
||||||
res.setHeader("vary", "accept-language");
|
if (await answerBuiltinRoute(host, state)) return;
|
||||||
// Fail closed: a cache in front of us must not keep a page that may carry a user's data. Not
|
await answerUnrouted(host, state);
|
||||||
// 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,
|
|
||||||
// the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the
|
|
||||||
// host-scoped Kratos CSRF cookie is lost and login dumps onto /error. GET/HEAD only: a 308
|
|
||||||
// must not replay a cross-host POST.
|
|
||||||
if (canonicalHost && (method === "GET" || method === "HEAD")) {
|
|
||||||
const host = req.headers.host;
|
|
||||||
if (host !== undefined && host !== canonicalHost) {
|
|
||||||
res.writeHead(308, { location: canonicalOrigin + (req.url ?? "/") }).end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// `explicit` (the URL asked for a locale) is what makes the choice travel: the chrome, this
|
|
||||||
// request's redirects and ctx.localeHref then carry ?locale onto the links they emit.
|
|
||||||
const { explicit, locale } = i18n.resolve({ acceptLanguage: req.headers["accept-language"], param: url.searchParams.get("locale") });
|
|
||||||
const carryLocale = (href: string): string => localeHref(href, explicit ? locale : null);
|
|
||||||
const t = i18n.translator(locale);
|
|
||||||
|
|
||||||
// A lapsed token still backed by a live Kratos session is silently re-minted — "stay signed
|
|
||||||
// in". The only place the hot path touches Ory.
|
|
||||||
let user: User | null = null;
|
|
||||||
if (jwks) {
|
|
||||||
const auth = await resolveSession(req.headers.cookie, jwks, authOptions);
|
|
||||||
user = auth.user;
|
|
||||||
if (!user && auth.expired && keto && kratos && kratosAdmin) {
|
|
||||||
try {
|
|
||||||
const reminted = await remintSession({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie, { secure: secureCookies });
|
|
||||||
user = reminted.user;
|
|
||||||
res.appendHeader("set-cookie", reminted.setCookie);
|
|
||||||
} catch (err) {
|
|
||||||
// Ory unreachable — degrade to anonymous instead of 500ing every lapsed request. Leave
|
|
||||||
// the cookie alone: it can re-mint once Ory recovers.
|
|
||||||
reqLog.warn("session re-mint failed (Ory unreachable?)", { error: String(err) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const csrf = ensureCsrfToken(req.headers.cookie, csrfSecret);
|
|
||||||
const csrfMint: RequestCsrf = {
|
|
||||||
setCookie: (): void => { if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies })); },
|
|
||||||
token: csrf.token,
|
|
||||||
};
|
|
||||||
const verifyCsrf = (submitted: string | null | undefined): boolean =>
|
|
||||||
verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted });
|
|
||||||
// Chrome composes the whole menu, so it is memoized and resolved lazily — a json/redirect
|
|
||||||
// handler, or the public "/" with a standalone home, never pays for it.
|
|
||||||
let chromeMemo: PageChrome | undefined;
|
|
||||||
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, localeHref: carryLocale, menu, plugins, t, translatorFor: (id) => i18n.translator(locale, id), user }));
|
|
||||||
|
|
||||||
// A plugin's context gets the plugin's own translator — its catalog first, then core.
|
|
||||||
const i18nFor = (pluginId?: string) => ({
|
|
||||||
locale,
|
|
||||||
localeHref: carryLocale,
|
|
||||||
locales: i18n.available,
|
|
||||||
t: pluginId === undefined ? t : i18n.translator(locale, pluginId),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Base context (no route params), for the built-in routes. Every plugin-owned render — a
|
|
||||||
// landing slot, a hook short-circuit, a plugin route — gets `contextFor(id)` instead.
|
|
||||||
const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
|
||||||
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
|
|
||||||
buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
|
|
||||||
renderPage = viewsFor(ctx);
|
|
||||||
|
|
||||||
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
|
||||||
if (anyRequestHooks) {
|
|
||||||
const short = await runRequestHooks(plugins, contextFor);
|
|
||||||
if (short) {
|
|
||||||
// Like every other page-emitting path, so a form the hook renders has its matching cookie.
|
|
||||||
csrfMint.setCookie();
|
|
||||||
await sendResult(res, short.result, pluginViewsFor(short.ctx, short.plugin.id), carryLocale);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply lacks
|
|
||||||
// the permission gets the 403 page.
|
|
||||||
const refuse = async (gate: Gate, gateCtx: RequestContext): Promise<void> => {
|
|
||||||
if (!gateCtx.user) { res.writeHead(303, { location: carryLocale(loginRedirect(gateCtx)) }).end(); return; }
|
|
||||||
reqLog.warn("forbidden: missing permission", { path: pathname, required: gate.permission ?? "", sub: gateCtx.user.id });
|
|
||||||
sendHtml(res, 403, await renderPage("403", {}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const match = matchRoute(plugins, method, pathname);
|
|
||||||
if (match) {
|
|
||||||
const routeCtx = contextFor(match.plugin.id, match.params);
|
|
||||||
if (!allows(match.route, routeCtx.user)) {
|
|
||||||
await refuse(match.route, routeCtx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
csrfMint.setCookie();
|
|
||||||
const result = (await match.route.handler(routeCtx)) ?? null;
|
|
||||||
// The responding plugin observes its own route, params and all; the others get a plain
|
|
||||||
// context for their own id (never another plugin's params).
|
|
||||||
if (anyResponseHooks) await runResponseHooks(plugins, (id) => (id === match.plugin.id ? routeCtx : contextFor(id)), result);
|
|
||||||
await sendResult(res, result, pluginViewsFor(routeCtx, match.plugin.id), carryLocale);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
|
|
||||||
if (builtin) {
|
|
||||||
if (!allows(builtin, ctx.user)) { await refuse(builtin, ctx); return; }
|
|
||||||
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Known path, wrong method → 405 with Allow; otherwise nothing here → 404.
|
|
||||||
const allow = allowedMethods(plugins, pathname);
|
|
||||||
if (allow.length) {
|
|
||||||
res.writeHead(405, { allow: allow.join(", "), "content-type": "text/plain; charset=utf-8" }).end("Method Not Allowed");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sendHtml(res, 404, await renderPage("404", {}));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A guard thrown anywhere in handling maps to a response (not a 500): a `location` ⇒ a
|
await answerError(err, res, reqLog, renderPage);
|
||||||
// redirect (requireSession → /login), otherwise the status renders the error page.
|
|
||||||
if (err instanceof GuardError) {
|
|
||||||
if (res.headersSent) return void res.end();
|
|
||||||
if (err.location) return void res.writeHead(303, { location: err.location }).end();
|
|
||||||
try {
|
|
||||||
return void sendHtml(res, err.status, await renderPage("403", {}));
|
|
||||||
} catch (renderErr) {
|
|
||||||
// Same last resort as the 500 branch below: a throw here would leave the socket open
|
|
||||||
// (this catch is the one that would have handled it), so end the response ourselves.
|
|
||||||
reqLog.error("error page render failed", { error: renderErr instanceof Error ? (renderErr.stack ?? renderErr.message) : String(renderErr) });
|
|
||||||
return void res.writeHead(err.status, { "content-type": "text/plain; charset=utf-8" }).end("Forbidden");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
reqLog.error("unhandled request error", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) });
|
|
||||||
if (res.headersSent) return void res.end(); // a partial body is already on the wire
|
|
||||||
try {
|
|
||||||
// Render before writing: if the 500 page itself throws, headers stay unsent
|
|
||||||
// and we fall back to plain text below instead of a half-written response.
|
|
||||||
sendHtml(res, 500, await renderPage("500", {}));
|
|
||||||
} catch (renderErr) {
|
|
||||||
reqLog.error("error page render failed", { error: renderErr instanceof Error ? (renderErr.stack ?? renderErr.message) : String(renderErr) });
|
|
||||||
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end("Internal Server Error");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return createServer((req, res) => {
|
return createServer((req, res) => trackRequest(log, trustedProxies, req, res, handleRequest));
|
||||||
// "close" (not "finish") fires on both a completed response and a premature disconnect, so an
|
}
|
||||||
// aborted request is still logged and its span flushed.
|
|
||||||
const startMs = Date.now();
|
// What the request stages read from createApp, all fixed at boot.
|
||||||
const reqLog = requestLogger(log, {
|
interface Host {
|
||||||
requestId: randomUUID(),
|
anyRequestHooks: boolean;
|
||||||
...(typeof req.headers.traceparent === "string" ? { traceparent: req.headers.traceparent } : {}),
|
anyResponseHooks: boolean;
|
||||||
});
|
assets: PublicAssets;
|
||||||
// end() must run exactly once, after BOTH the handler has unwound AND the response has closed.
|
authOptions: VerifyOptions;
|
||||||
// Earlier would throw "already ended" from a still-running handler's ctx.log on a client abort,
|
builtinRoutes: BuiltinRoute[];
|
||||||
// or drop the access line on the happy path (the handler settles before close).
|
csrfSecret: string;
|
||||||
let settled = false;
|
i18n: I18n;
|
||||||
let closed = false;
|
jwks: JwksProvider | undefined;
|
||||||
const finalize = (): void => { if (settled && closed) void reqLog.end().catch(() => {}); };
|
keto: KetoClient | undefined;
|
||||||
res.on("close", () => {
|
kratos: KratosPublic | undefined;
|
||||||
closed = true;
|
kratosAdmin: KratosAdmin | undefined;
|
||||||
try {
|
menu: MenuConfig;
|
||||||
// path only (no query — it may carry tokens); method/status are header-safe here.
|
permissionCatalog: PermissionDecl[];
|
||||||
reqLog.info("request", { method: req.method ?? "GET", ms: Date.now() - startMs, path: (req.url ?? "/").split("?", 1)[0] ?? "/", status: res.statusCode });
|
pluginViewsFor: (ctx: RequestContext, id: string) => ViewRenderer;
|
||||||
} catch { /* never let logging crash a served request */ }
|
plugins: Plugin[];
|
||||||
finalize();
|
secureCookies: boolean;
|
||||||
});
|
settingsCatalog: readonly PluginSettings[];
|
||||||
// Make reqLog ambient for the whole handler so all outbound fetch is traced. The .catch logs a
|
system: SystemCapabilities | undefined;
|
||||||
// pathological escape via the app logger — not reqLog, which may be the thing that broke.
|
viewsFor: (ctx: RequestContext) => ViewRenderer;
|
||||||
void runWithLog(reqLog, () => handleRequest(req, res, reqLog))
|
}
|
||||||
.catch((err) => log.error("request handler escaped its try/catch", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) }))
|
|
||||||
.finally(() => { settled = true; finalize(); });
|
// A request once its visitor is known: what every stage after prepareRequest reads.
|
||||||
});
|
interface RequestState {
|
||||||
|
carryLocale: (href: string) => string;
|
||||||
|
contextFor: (pluginId: string, params?: Record<string, string>) => RequestContext;
|
||||||
|
csrf: RequestCsrf;
|
||||||
|
ctx: RequestContext; // the base context (no route params), for the built-in routes
|
||||||
|
// Read before the onRequest hooks run, so a hook that edits `req` doesn't reroute the request.
|
||||||
|
method: string;
|
||||||
|
pathname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serveStatic(assets: PublicAssets, method: string, url: URL, req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<boolean> {
|
||||||
|
if (!url.pathname.startsWith("/public/") || (method !== "GET" && method !== "HEAD")) return false;
|
||||||
|
await assets.serve(url, req, res, (err) => reqLog.error("static file error", { error: String(err) }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPageCacheHeaders(res: ServerResponse): void {
|
||||||
|
// A cache in front of us must key on the language. Set after serveStatic: an asset is
|
||||||
|
// the same bytes in every language, and a Vary there fragments its entry per raw header.
|
||||||
|
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,
|
||||||
|
// the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the
|
||||||
|
// host-scoped Kratos CSRF cookie is lost and login dumps onto /error. GET/HEAD only: a 308
|
||||||
|
// must not replay a cross-host POST. `URL.host` omits a default port, as the Host header does.
|
||||||
|
function redirectToCanonicalHost(canonical: URL | undefined, method: string, req: IncomingMessage, res: ServerResponse): boolean {
|
||||||
|
if (!canonical?.host || (method !== "GET" && method !== "HEAD") || req.headers.host === undefined || req.headers.host === canonical.host) return false;
|
||||||
|
res.writeHead(308, { location: canonical.origin + (req.url ?? "/") }).end();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolves the visitor's locale, user and CSRF token, and builds the contexts handlers run on.
|
||||||
|
async function prepareRequest(host: Host, req: IncomingMessage, res: ServerResponse, method: string, url: URL, reqLog: Log): Promise<RequestState> {
|
||||||
|
const { i18n } = host;
|
||||||
|
// `explicit` (the URL asked for a locale) is what makes the choice travel: the chrome, this
|
||||||
|
// request's redirects and ctx.localeHref then carry ?locale onto the links they emit.
|
||||||
|
const { explicit, locale } = i18n.resolve({ acceptLanguage: req.headers["accept-language"], param: url.searchParams.get("locale") });
|
||||||
|
const carryLocale = (href: string): string => localeHref(href, explicit ? locale : null);
|
||||||
|
const t = i18n.translator(locale);
|
||||||
|
const user = await resolveUser(host, req, res, reqLog);
|
||||||
|
const { fresh, token } = ensureCsrfToken(req.headers.cookie, host.csrfSecret);
|
||||||
|
const csrf: RequestCsrf = {
|
||||||
|
setCookie: (): void => { if (fresh) res.appendHeader("set-cookie", csrfCookie(token, { secure: host.secureCookies })); },
|
||||||
|
token,
|
||||||
|
};
|
||||||
|
const verifyCsrf = (submitted: string | null | undefined): boolean =>
|
||||||
|
verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: host.csrfSecret, submitted });
|
||||||
|
// Chrome composes the whole menu, so it is memoized and resolved lazily — a json/redirect
|
||||||
|
// handler, or the public "/" with a standalone home, never pays for it.
|
||||||
|
let chromeMemo: PageChrome | undefined;
|
||||||
|
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: token, currentPath: url.pathname, localeHref: carryLocale, menu: host.menu, plugins: host.plugins, t, translatorFor: (id) => i18n.translator(locale, id), user }));
|
||||||
|
const shared = { assetHref: host.assets.href, chrome, declaredPermissions: host.permissionCatalog, declaredSettings: host.settingsCatalog, locale, localeHref: carryLocale, locales: i18n.available, log: reqLog, user, verifyCsrf, ...(host.system ? { system: host.system } : {}) };
|
||||||
|
return {
|
||||||
|
carryLocale,
|
||||||
|
// The plugin's own translator: its catalog first, then core.
|
||||||
|
contextFor: (pluginId, params) => buildContext(req, res, { ...shared, t: i18n.translator(locale, pluginId), ...(params ? { params } : {}) }),
|
||||||
|
csrf,
|
||||||
|
ctx: buildContext(req, res, { ...shared, t }),
|
||||||
|
method,
|
||||||
|
pathname: url.pathname,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// A lapsed token still backed by a live Kratos session is silently re-minted — "stay signed
|
||||||
|
// in". The only place the hot path touches Ory.
|
||||||
|
async function resolveUser(host: Host, req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<User | null> {
|
||||||
|
const { jwks, keto, kratos, kratosAdmin } = host;
|
||||||
|
if (!jwks) return null;
|
||||||
|
const auth = await resolveSession(req.headers.cookie, jwks, host.authOptions);
|
||||||
|
if (auth.user || !auth.expired || !keto || !kratos || !kratosAdmin) return auth.user;
|
||||||
|
try {
|
||||||
|
const reminted = await remintSession({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie, { secure: host.secureCookies });
|
||||||
|
res.appendHeader("set-cookie", reminted.setCookie);
|
||||||
|
return reminted.user;
|
||||||
|
} catch (err) {
|
||||||
|
// Ory unreachable — degrade to anonymous instead of 500ing every lapsed request. Leave
|
||||||
|
// the cookie alone: it can re-mint once Ory recovers.
|
||||||
|
reqLog.warn("session re-mint failed (Ory unreachable?)", { error: String(err) });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function answerFromHook(host: Host, { carryLocale, contextFor, csrf, ctx }: RequestState): Promise<boolean> {
|
||||||
|
if (!host.anyRequestHooks) return false;
|
||||||
|
const short = await runRequestHooks(host.plugins, contextFor);
|
||||||
|
if (!short) return false;
|
||||||
|
// Like every other page-emitting path, so a form the hook renders has its matching cookie.
|
||||||
|
csrf.setCookie();
|
||||||
|
await sendResult(ctx.res, short.result, host.pluginViewsFor(short.ctx, short.plugin.id), carryLocale);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function answerPluginRoute(host: Host, state: RequestState): Promise<boolean> {
|
||||||
|
const { carryLocale, contextFor, csrf, ctx } = state;
|
||||||
|
const match = matchRoute(host.plugins, state.method, state.pathname);
|
||||||
|
if (!match) return false;
|
||||||
|
ctx.log.context["http.route"] = fullPath(match.plugin.id, match.route.path);
|
||||||
|
const routeCtx = contextFor(match.plugin.id, match.params);
|
||||||
|
if (!allows(match.route, routeCtx.user)) {
|
||||||
|
await refuse(host, state, match.route, routeCtx);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
csrf.setCookie();
|
||||||
|
const result = (await match.route.handler(routeCtx)) ?? null;
|
||||||
|
// The responding plugin observes its own route, params and all; the others get a plain
|
||||||
|
// context for their own id (never another plugin's params).
|
||||||
|
if (host.anyResponseHooks) await runResponseHooks(host.plugins, (id) => (id === match.plugin.id ? routeCtx : contextFor(id)), result);
|
||||||
|
await sendResult(ctx.res, result, host.pluginViewsFor(routeCtx, match.plugin.id), carryLocale);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function answerBuiltinRoute(host: Host, state: RequestState): Promise<boolean> {
|
||||||
|
const { carryLocale, contextFor, csrf, ctx } = state;
|
||||||
|
const builtin = matchBuiltinRoute(host.builtinRoutes, state.method, state.pathname);
|
||||||
|
if (!builtin) return false;
|
||||||
|
ctx.log.context["http.route"] = builtin.path;
|
||||||
|
if (!allows(builtin, ctx.user)) await refuse(host, state, builtin, ctx);
|
||||||
|
else await sendResult(ctx.res, await builtin.handler(ctx, csrf, contextFor), host.viewsFor(ctx), carryLocale);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function answerUnrouted(host: Host, { ctx, pathname }: RequestState): Promise<void> {
|
||||||
|
const allow = allowedMethods(host.plugins, pathname);
|
||||||
|
if (allow.length) {
|
||||||
|
ctx.res.writeHead(405, { allow: allow.join(", "), "content-type": "text/plain; charset=utf-8" }).end("Method Not Allowed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sendHtml(ctx.res, 404, await host.viewsFor(ctx)("404", {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply lacks
|
||||||
|
// the permission gets the 403 page.
|
||||||
|
async function refuse(host: Host, { carryLocale, ctx, pathname }: RequestState, gate: Gate, gateCtx: RequestContext): Promise<void> {
|
||||||
|
if (!gateCtx.user) { ctx.res.writeHead(303, { location: carryLocale(loginRedirect(gateCtx)) }).end(); return; }
|
||||||
|
ctx.log.warn("forbidden: missing permission", { path: pathname, required: gate.permission ?? "", sub: gateCtx.user.id });
|
||||||
|
sendHtml(ctx.res, 403, await host.viewsFor(ctx)("403", {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function answerError(err: unknown, res: ServerResponse, reqLog: Log, renderPage: ViewRenderer): Promise<void> {
|
||||||
|
// A GuardError maps to its redirect (requireSession → /login) or its status page, not a 500.
|
||||||
|
if (err instanceof GuardError) {
|
||||||
|
if (res.headersSent) return void res.end();
|
||||||
|
if (err.location) return void res.writeHead(303, { location: err.location }).end();
|
||||||
|
try {
|
||||||
|
return void sendHtml(res, err.status, await renderPage("403", {}));
|
||||||
|
} catch (renderErr) {
|
||||||
|
// answerError runs inside handleRequest's catch, so a throw here would escape and leave the
|
||||||
|
// socket open. End the response here, as the 500 branch below does.
|
||||||
|
reqLog.error("error page render failed", { error: renderErr instanceof Error ? (renderErr.stack ?? renderErr.message) : String(renderErr) });
|
||||||
|
return void res.writeHead(err.status, { "content-type": "text/plain; charset=utf-8" }).end("Forbidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reqLog.error("unhandled request error", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) });
|
||||||
|
if (res.headersSent) return void res.end(); // a partial body is already on the wire
|
||||||
|
try {
|
||||||
|
// Render before writing: if the 500 page itself throws, headers stay unsent
|
||||||
|
// and we fall back to plain text below instead of a half-written response.
|
||||||
|
sendHtml(res, 500, await renderPage("500", {}));
|
||||||
|
} catch (renderErr) {
|
||||||
|
reqLog.error("error page render failed", { error: renderErr instanceof Error ? (renderErr.stack ?? renderErr.message) : String(renderErr) });
|
||||||
|
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end("Internal Server Error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendHtml(res: ServerResponse, status: number, html: string): void {
|
||||||
|
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
||||||
|
res.end(html);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The Referer as a host-relative path, when it is one of ours — the page a form was submitted
|
// The Referer as a host-relative path, when it is one of ours — the page a form was submitted
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// router), method-distinct entries on the same path.
|
// router), method-distinct entries on the same path.
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { type BuiltinRoute, matchBuiltinRoute } from "./builtin-routes.ts";
|
import { type BuiltinRoute, matchBuiltinRoute } from "./builtin-route.ts";
|
||||||
|
|
||||||
const entry = (method: "GET" | "POST", path: string): BuiltinRoute => ({ handler: () => null, method, path });
|
const entry = (method: "GET" | "POST", path: string): BuiltinRoute => ({ handler: () => null, method, path });
|
||||||
|
|
||||||
@@ -25,6 +25,7 @@ test("buildContext parses the URL, exposes query, and defaults to an anonymous u
|
|||||||
assert.equal(ctx.user, null);
|
assert.equal(ctx.user, null);
|
||||||
assert.deepEqual(ctx.permissions, []);
|
assert.deepEqual(ctx.permissions, []);
|
||||||
assert.deepEqual(ctx.params, {});
|
assert.deepEqual(ctx.params, {});
|
||||||
|
assert.equal(ctx.assetHref("/public/x.css"), "/public/x.css");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("buildContext threads path params supplied by the router", () => {
|
test("buildContext threads path params supplied by the router", () => {
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export interface User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RequestContext {
|
export interface RequestContext {
|
||||||
|
// A /public/ href with `?v=<content hash>`, so the browser caches the file for a year; any other
|
||||||
|
// href, or one already carrying a query or fragment, comes back unchanged. Views get the same
|
||||||
|
// function as the `assetHref` local; this one is for hrefs a handler builds into `data`.
|
||||||
|
assetHref(href: string): string;
|
||||||
// Page chrome (brand/global-nav/user/theme/csrf) a plugin view hands to partials/shell so its
|
// Page chrome (brand/global-nav/user/theme/csrf) a plugin view hands to partials/shell so its
|
||||||
// page renders the native app shell; the host builds it per request (anonymous default otherwise).
|
// page renders the native app shell; the host builds it per request (anonymous default otherwise).
|
||||||
chrome: PageChrome;
|
chrome: PageChrome;
|
||||||
@@ -66,6 +70,7 @@ export interface RequestContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildContextOptions {
|
export interface BuildContextOptions {
|
||||||
|
assetHref?: (href: string) => string;
|
||||||
// Lazy chrome factory: composing the global menu is only paid for if the handler actually reads
|
// Lazy chrome factory: composing the global menu is only paid for if the handler actually reads
|
||||||
// ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing).
|
// ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing).
|
||||||
// The host's factory is memoised, so the menu composes at most once per request across contexts.
|
// The host's factory is memoised, so the menu composes at most once per request across contexts.
|
||||||
@@ -99,6 +104,7 @@ export function buildContext(
|
|||||||
const buildChrome = options.chrome;
|
const buildChrome = options.chrome;
|
||||||
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
|
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
|
||||||
return {
|
return {
|
||||||
|
assetHref: options.assetHref ?? ((href) => href),
|
||||||
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
|
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
|
||||||
declaredPermissions: options.declaredPermissions ?? [],
|
declaredPermissions: options.declaredPermissions ?? [],
|
||||||
declaredSettings: options.declaredSettings ?? [],
|
declaredSettings: options.declaredSettings ?? [],
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import type { IncomingMessage } from "node:http";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { clientAddress, parseTrustedProxies, requestFields } from "./request-fields.ts";
|
||||||
|
|
||||||
|
const proxies = parseTrustedProxies(["10.0.0.0/8", "192.168.1.5", "fd00::/8"]);
|
||||||
|
const none = parseTrustedProxies([]);
|
||||||
|
|
||||||
|
test("clientAddress: the socket peer, unless the peer is a listed proxy", () => {
|
||||||
|
assert.equal(clientAddress("203.0.113.9", "198.51.100.1", proxies), "203.0.113.9"); // header ignored from a stranger
|
||||||
|
assert.equal(clientAddress("10.1.2.3", "198.51.100.1", proxies), "198.51.100.1");
|
||||||
|
assert.equal(clientAddress("10.1.2.3", "198.51.100.1", none), "10.1.2.3"); // nothing trusted by default
|
||||||
|
assert.equal(clientAddress("192.168.1.5", undefined, proxies), "192.168.1.5"); // trusted peer, no header
|
||||||
|
assert.equal(clientAddress(undefined, "198.51.100.1", proxies), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clientAddress: walks the chain from the right, so a forged leftmost entry is not believed", () => {
|
||||||
|
assert.equal(clientAddress("10.0.0.1", "6.6.6.6, 198.51.100.1, 10.0.0.2", proxies), "198.51.100.1");
|
||||||
|
assert.equal(clientAddress("10.0.0.1", "10.0.0.7, 10.0.0.2", proxies), "10.0.0.7"); // every hop trusted → the origin
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clientAddress: a malformed hop or a mapped IPv4 peer never yields garbage", () => {
|
||||||
|
assert.equal(clientAddress("10.0.0.1", "not-an-ip", proxies), "10.0.0.1");
|
||||||
|
assert.equal(clientAddress("::ffff:10.0.0.1", "198.51.100.1", proxies), "198.51.100.1");
|
||||||
|
assert.equal(clientAddress("::ffff:203.0.113.9", undefined, none), "203.0.113.9");
|
||||||
|
assert.equal(clientAddress("fd00::1", "2001:db8::7", proxies), "2001:db8::7");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseTrustedProxies: a range covers exactly its prefix, and the extremes are legal", () => {
|
||||||
|
const one = parseTrustedProxies(["10.1.2.3/32", "fd00::7/128", "192.168.0.0/16"]);
|
||||||
|
assert.equal(clientAddress("10.1.2.3", "198.51.100.1", one), "198.51.100.1");
|
||||||
|
assert.equal(clientAddress("10.1.2.4", "198.51.100.1", one), "10.1.2.4"); // /32 is one address
|
||||||
|
assert.equal(clientAddress("fd00::8", "2001:db8::7", one), "fd00::8"); // /128 is one address
|
||||||
|
assert.equal(clientAddress("192.168.255.1", "198.51.100.1", one), "198.51.100.1");
|
||||||
|
assert.equal(clientAddress("192.169.0.1", "198.51.100.1", one), "192.169.0.1");
|
||||||
|
assert.equal(clientAddress("203.0.113.9", "198.51.100.1", parseTrustedProxies(["0.0.0.0/0"])), "198.51.100.1"); // /0 is a choice, not a typo
|
||||||
|
});
|
||||||
|
|
||||||
|
test("clientAddress: a malformed hop left of a trusted one is not believed over the peer", () => {
|
||||||
|
assert.equal(clientAddress("10.0.0.1", "garbage, 10.0.0.2", proxies), "10.0.0.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parseTrustedProxies: refuses an entry that is not an IP or a CIDR range", () => {
|
||||||
|
for (const bad of ["proxy.internal", "10.0.0.0/33", "10.0.0.0/x", "::1/129", "", "10.0.0.1/", "10.0.0.0/ ", "fd00::1/", "10.0.0.0//8", "10.0.0.0/8/8", "10.0.0.0/-1", "10.0.0.0/1e1"]) {
|
||||||
|
assert.throws(() => parseTrustedProxies([bad]), /TRUSTED_PROXIES/, bad);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const request = (headers: Record<string, string>, remoteAddress = "203.0.113.9", url = "/a/b?token=secret", method = "POST") =>
|
||||||
|
({ headers, method, socket: { remoteAddress }, url }) as unknown as IncomingMessage;
|
||||||
|
|
||||||
|
test("requestFields: OpenTelemetry names for the caller, the site and the request, without the query", () => {
|
||||||
|
const fields = requestFields(request({ host: "Example.com:8080", "user-agent": "TV/1.0" }), none);
|
||||||
|
assert.deepEqual(fields, {
|
||||||
|
"client.address": "203.0.113.9",
|
||||||
|
"http.request.method": "POST",
|
||||||
|
"server.address": "example.com",
|
||||||
|
"url.path": "/a/b",
|
||||||
|
"user_agent.original": "TV/1.0",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("requestFields: drops what the request lacks or sent malformed, and reads X-Forwarded-For only via a trusted peer", () => {
|
||||||
|
assert.deepEqual(requestFields(request({ host: "bad host" }, "10.0.0.1", "/"), proxies), {
|
||||||
|
"client.address": "10.0.0.1", "http.request.method": "POST", "url.path": "/",
|
||||||
|
});
|
||||||
|
const fields = requestFields(request({ "x-forwarded-for": "198.51.100.1" }, "10.0.0.1"), proxies);
|
||||||
|
assert.equal(fields["client.address"], "198.51.100.1");
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { IncomingMessage } from "node:http";
|
||||||
|
import { BlockList, isIP } from "node:net";
|
||||||
|
|
||||||
|
export type TrustedProxies = BlockList;
|
||||||
|
|
||||||
|
// Each entry is an IP address or a CIDR range (`10.0.0.0/8`). A bad entry refuses the boot: a
|
||||||
|
// misread one could trust every caller's X-Forwarded-For.
|
||||||
|
export function parseTrustedProxies(entries: readonly string[] = []): TrustedProxies {
|
||||||
|
const list = new BlockList();
|
||||||
|
for (const entry of entries) {
|
||||||
|
const [address = "", prefix, ...rest] = entry.split("/");
|
||||||
|
const family = isIP(address);
|
||||||
|
const length = prefix === undefined ? undefined : Number(prefix);
|
||||||
|
const maxLength = family === 4 ? 32 : 128;
|
||||||
|
// `Number("")` is 0, so a trailing slash must be refused before the range check sees it.
|
||||||
|
const badPrefix = prefix !== undefined && !(/^\d{1,3}$/.test(prefix) && length! <= maxLength);
|
||||||
|
if (family === 0 || rest.length > 0 || badPrefix) {
|
||||||
|
throw new Error(`config: TRUSTED_PROXIES entry "${entry}" is not an IP address or CIDR range`);
|
||||||
|
}
|
||||||
|
const kind = family === 4 ? "ipv4" : "ipv6";
|
||||||
|
if (length === undefined) list.addAddress(address, kind);
|
||||||
|
else list.addSubnet(address, length, kind);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A dual-stack socket reports an IPv4 peer as `::ffff:a.b.c.d`; log the plain form.
|
||||||
|
const plain = (address: string): string => (address.startsWith("::ffff:") && isIP(address.slice(7)) === 4 ? address.slice(7) : address);
|
||||||
|
|
||||||
|
// The socket's peer, unless the peer is a listed proxy: then the nearest address in X-Forwarded-For
|
||||||
|
// that is not itself a listed proxy. Reading from the right is what makes a forged leftmost entry
|
||||||
|
// harmless — only a hop a trusted proxy appended is believed.
|
||||||
|
export function clientAddress(peer: string | undefined, forwardedFor: string | undefined, trusted: TrustedProxies): string | undefined {
|
||||||
|
if (peer === undefined) return undefined;
|
||||||
|
const isTrusted = (address: string): boolean => trusted.check(address, isIP(address) === 4 ? "ipv4" : "ipv6");
|
||||||
|
const direct = plain(peer);
|
||||||
|
if (!isTrusted(direct)) return direct;
|
||||||
|
const hops = (forwardedFor ?? "").split(",").map((hop) => plain(hop.trim())).filter(Boolean);
|
||||||
|
let client = direct;
|
||||||
|
for (let i = hops.length - 1; i >= 0; i--) {
|
||||||
|
const hop = hops[i]!;
|
||||||
|
if (isIP(hop) === 0) return direct;
|
||||||
|
client = hop;
|
||||||
|
if (!isTrusted(hop)) break;
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// OpenTelemetry HTTP semantic-convention attributes every request carries on its span and `request`
|
||||||
|
// line. The path only: the query may carry tokens.
|
||||||
|
export function requestFields(req: IncomingMessage, trusted: TrustedProxies): Record<string, string> {
|
||||||
|
const fields: Record<string, string> = {
|
||||||
|
"http.request.method": req.method ?? "GET",
|
||||||
|
"url.path": (req.url ?? "/").split("?", 1)[0] ?? "/",
|
||||||
|
};
|
||||||
|
const client = clientAddress(req.socket.remoteAddress, headerValue(req.headers["x-forwarded-for"]), trusted);
|
||||||
|
if (client !== undefined) fields["client.address"] = client;
|
||||||
|
const host = headerValue(req.headers.host);
|
||||||
|
if (host !== undefined) {
|
||||||
|
try { fields["server.address"] = new URL(`http://${host}`).hostname; } catch { /* malformed Host: leave it off */ }
|
||||||
|
}
|
||||||
|
const agent = headerValue(req.headers["user-agent"]);
|
||||||
|
if (agent !== undefined) fields["user_agent.original"] = agent;
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerValue = (value: string | string[] | undefined): string | undefined => (Array.isArray(value) ? value.join(", ") : value);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import { type Log, requestLogger, runWithLog } from "../logger.ts";
|
||||||
|
import { requestFields, type TrustedProxies } from "./request-fields.ts";
|
||||||
|
|
||||||
|
// Gives one request its own logger and span, runs `handle` under it, and writes the `request` line
|
||||||
|
// when the response closes. The handler adds `http.route` to the logger's context once routing has
|
||||||
|
// matched.
|
||||||
|
export function trackRequest(
|
||||||
|
log: Log,
|
||||||
|
trusted: TrustedProxies,
|
||||||
|
req: IncomingMessage,
|
||||||
|
res: ServerResponse,
|
||||||
|
handle: (req: IncomingMessage, res: ServerResponse, reqLog: Log) => Promise<void>,
|
||||||
|
): void {
|
||||||
|
const reqLog = requestLogger(log, {
|
||||||
|
attributes: requestFields(req, trusted),
|
||||||
|
requestId: randomUUID(),
|
||||||
|
...(typeof req.headers.traceparent === "string" ? { traceparent: req.headers.traceparent } : {}),
|
||||||
|
});
|
||||||
|
// end() must run exactly once, after BOTH the handler has unwound AND the response has closed.
|
||||||
|
// Earlier would throw "already ended" from a still-running handler's ctx.log on a client abort,
|
||||||
|
// or drop the access line on the happy path (the handler settles before close).
|
||||||
|
let settled = false;
|
||||||
|
let closed = false;
|
||||||
|
const finalize = (): void => { if (settled && closed) void reqLog.end().catch(() => {}); };
|
||||||
|
// "close" (not "finish") fires on both a completed response and a premature disconnect, so an
|
||||||
|
// aborted request is still logged and its span flushed.
|
||||||
|
res.on("close", () => {
|
||||||
|
closed = true;
|
||||||
|
try {
|
||||||
|
// The context is what the span exports, so the status goes there.
|
||||||
|
reqLog.context["http.response.status_code"] = res.statusCode;
|
||||||
|
reqLog.info("request");
|
||||||
|
} catch { /* never let logging crash a served request */ }
|
||||||
|
finalize();
|
||||||
|
});
|
||||||
|
// Make reqLog ambient for the whole handler so all outbound fetch is traced. The .catch logs a
|
||||||
|
// pathological escape via the app logger — not reqLog, which may be the thing that broke.
|
||||||
|
void runWithLog(reqLog, () => handle(req, res, reqLog))
|
||||||
|
.catch((err) => log.error("request handler escaped its try/catch", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) }))
|
||||||
|
.finally(() => { settled = true; finalize(); });
|
||||||
|
}
|
||||||
+4
-6
@@ -50,12 +50,10 @@ 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
|
// Caching: every /public/ href rendered through assetHref gets `?v=<hash of the file's content>`. A request whose `v` is the current
|
||||||
// file's content>`. A request whose `v` is the current hash is cached for a year as immutable, so a
|
// hash is cached for a year as immutable, so a repeat page view downloads no asset; a changed file gets
|
||||||
// repeat page view downloads no asset; a changed file gets a new hash, and with it a new URL. Any other
|
// a new hash, and with it a new URL. Any other request — an unwrapped href, a stale `v` mid-deploy — is
|
||||||
// request — an href a plugin's own markup emits, a stale `v` mid-deploy — is cached for five minutes,
|
// cached for five minutes, then revalidated against the ETag (that same hash) or Last-Modified.
|
||||||
// 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 IMMUTABLE = "public, max-age=31536000, immutable";
|
||||||
const SHORT = "public, max-age=300";
|
const SHORT = "public, max-age=300";
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -56,7 +56,7 @@ test("createLogger: a set endpoint actually exports log records over OTLP/HTTP",
|
|||||||
try {
|
try {
|
||||||
const log = createLogger({ otlpEndpoint: "http://collector:4318", stderr: () => {}, stdout: () => {} });
|
const log = createLogger({ otlpEndpoint: "http://collector:4318", stderr: () => {}, stdout: () => {} });
|
||||||
log.info("exported");
|
log.info("exported");
|
||||||
await new Promise((r) => setTimeout(r, 50)); // export is fire-and-forget in the background
|
await log.flush(); // the queue batches records for up to a second
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = orig;
|
globalThis.fetch = orig;
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-4
@@ -38,7 +38,7 @@ export function createLogger(opts: LoggerOptions = {}): Log {
|
|||||||
// The ambient per-request Log (see the header); set by runWithLog, read by currentLog/tracedFetch.
|
// The ambient per-request Log (see the header); set by runWithLog, read by currentLog/tracedFetch.
|
||||||
const requestStore = new AsyncLocalStorage<Log>();
|
const requestStore = new AsyncLocalStorage<Log>();
|
||||||
|
|
||||||
// Run `fn` with `log` as the ambient request logger (app.ts wraps each request). currentLog() reads
|
// Run `fn` with `log` as the ambient request logger (request-tracking.ts wraps each request). currentLog() reads
|
||||||
// it back; returns undefined outside any request (boot, tests) so callers use `currentLog()?.info(…)`.
|
// it back; returns undefined outside any request (boot, tests) so callers use `currentLog()?.info(…)`.
|
||||||
export function runWithLog<T>(log: Log, fn: () => T): T {
|
export function runWithLog<T>(log: Log, fn: () => T): T {
|
||||||
return requestStore.run(log, fn);
|
return requestStore.run(log, fn);
|
||||||
@@ -50,7 +50,7 @@ export function currentLog(): Log | undefined {
|
|||||||
// A drop-in `fetch` that traces through the active request log — a client span under the request
|
// A drop-in `fetch` that traces through the active request log — a client span under the request
|
||||||
// span, with a W3C `traceparent` injected so the downstream service continues the same trace.
|
// span, with a W3C `traceparent` injected so the downstream service continues the same trace.
|
||||||
// Outside a request, or for a non-string/URL input, it is a plain `fetch`. Note log.fetch throws
|
// Outside a request, or for a non-string/URL input, it is a plain `fetch`. Note log.fetch throws
|
||||||
// synchronously once the request log has ended; app.ts ends it only after the handler unwinds.
|
// synchronously once the request log has ended; request-tracking.ts ends it only after the handler unwinds.
|
||||||
export const tracedFetch: typeof fetch = (input, init) => {
|
export const tracedFetch: typeof fetch = (input, init) => {
|
||||||
const log = currentLog();
|
const log = currentLog();
|
||||||
if (log && (typeof input === "string" || input instanceof URL)) return log.fetch(input, init);
|
if (log && (typeof input === "string" || input instanceof URL)) return log.fetch(input, init);
|
||||||
@@ -61,9 +61,10 @@ export const tracedFetch: typeof fetch = (input, init) => {
|
|||||||
// request its own root trace, so requests aren't all nested under one app-lifetime span, while
|
// request its own root trace, so requests aren't all nested under one app-lifetime span, while
|
||||||
// inheriting the parent's level/format/streams/OTLP. A valid upstream `traceparent` is adopted;
|
// inheriting the parent's level/format/streams/OTLP. A valid upstream `traceparent` is adopted;
|
||||||
// malformed ⇒ ignored, a fresh trace starts. `end()` on response finish exports the span.
|
// malformed ⇒ ignored, a fresh trace starts. `end()` on response finish exports the span.
|
||||||
export function requestLogger(appLog: Log, opts: { requestId: string; traceparent?: string | undefined }): Log {
|
// `attributes` ride the context, so they land on the span and on every line the request logs.
|
||||||
|
export function requestLogger(appLog: Log, opts: { attributes?: Record<string, string>; requestId: string; traceparent?: string | undefined }): Log {
|
||||||
return appLog.clone({
|
return appLog.clone({
|
||||||
context: { ...appLog.context, requestId: opts.requestId },
|
context: { ...appLog.context, ...opts.attributes, requestId: opts.requestId },
|
||||||
spanName: "request",
|
spanName: "request",
|
||||||
...(opts.traceparent ? { traceparent: opts.traceparent } : {}),
|
...(opts.traceparent ? { traceparent: opts.traceparent } : {}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { findConflicts } from "./conflicts.ts";
|
||||||
|
import type { Plugin } from "./plugin.ts";
|
||||||
|
|
||||||
|
// A minimal discovered plugin (id = folder name; mount path is the derived `/<id>`), per case.
|
||||||
|
const p = (over: Partial<Plugin> & Pick<Plugin, "id">): Plugin => ({ apiVersion: "1.0.0", ...over });
|
||||||
|
|
||||||
|
test("findConflicts: a clean set has none", () => {
|
||||||
|
assert.deepEqual(findConflicts([p({ id: "a" }), p({ id: "b" })]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findConflicts: a duplicate id and a colliding route are loud errors", () => {
|
||||||
|
const dupId = findConflicts([p({ id: "a" }), p({ id: "a" })]);
|
||||||
|
assert.ok(dupId.some((c) => c.kind === "id" && c.level === "error"));
|
||||||
|
|
||||||
|
// Cross-plugin routes can't collide (unique `/<id>` prefix); two identical routes in one can.
|
||||||
|
const noop = () => {};
|
||||||
|
const dupRoute = findConflicts([p({
|
||||||
|
id: "a",
|
||||||
|
routes: [{ handler: noop, method: "GET", path: "/t" }, { handler: noop, method: "GET", path: "/t" }],
|
||||||
|
})]);
|
||||||
|
assert.ok(dupRoute.some((c) => c.kind === "route" && c.level === "error" && c.message.includes("/a/t")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findConflicts: duplicate nav id is an error, a shared permission name only warns", () => {
|
||||||
|
const navDup = findConflicts([
|
||||||
|
p({ id: "a", nav: [{ id: "dup", label: "A" }] }),
|
||||||
|
p({ id: "b", nav: [{ id: "dup", label: "B" }] }),
|
||||||
|
]);
|
||||||
|
assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||||
|
// The override targets profile-menu entries by the same ids, so one namespace covers both.
|
||||||
|
const profileDup = findConflicts([
|
||||||
|
p({ id: "a", nav: [{ id: "dup", label: "A" }] }),
|
||||||
|
p({ id: "b", profileMenu: [{ href: "/b", id: "dup", label: "B", session: true }] }),
|
||||||
|
]);
|
||||||
|
assert.ok(profileDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||||
|
|
||||||
|
// Sharing a permission across plugins is legitimate → warn, not error.
|
||||||
|
const permissionDup = findConflicts([
|
||||||
|
p({ id: "a", permissions: [{ name: "shared:read" }] }),
|
||||||
|
p({ id: "b", permissions: [{ name: "shared:read" }] }),
|
||||||
|
]);
|
||||||
|
assert.ok(permissionDup.some((c) => c.kind === "permission" && c.level === "warn"));
|
||||||
|
});
|
||||||
|
|
||||||
|
const noopHandler = () => {};
|
||||||
|
|
||||||
|
test("findConflicts: a route, nav node or profile entry gating on a permission no plugin declares warns once per plugin and name, sorted", () => {
|
||||||
|
const gated = findConflicts([
|
||||||
|
p({ id: "a", nav: [{ children: [{ id: "a:x", label: "X", permission: "navonly:read" }], id: "a:root", label: "A", session: true }], routes: [
|
||||||
|
{ handler: noopHandler, method: "GET", path: "/x", permission: "routeonly:read" },
|
||||||
|
{ handler: noopHandler, method: "GET", path: "/y", permission: "both:read" },
|
||||||
|
{ handler: noopHandler, method: "POST", path: "/y", permission: "both:read" },
|
||||||
|
{ handler: noopHandler, method: "GET", path: "/z", permission: "declared:write" },
|
||||||
|
], permissions: [], profileMenu: [{ href: "/p", label: "P", permission: "profileonly:read" }, { href: "/q", label: "Q", session: true }, { href: "/r", label: "R", public: true }] }),
|
||||||
|
p({ id: "b", nav: [{ id: "b:y", label: "Y", permission: "both:read" }], permissions: [{ name: "declared:write" }] }),
|
||||||
|
]);
|
||||||
|
assert.ok(gated.every((c) => c.kind === "permission" && c.level === "warn"));
|
||||||
|
assert.deepEqual(gated.map((c) => c.plugins), [["a"], ["a"], ["a"], ["a"], ["b"]]);
|
||||||
|
assert.deepEqual(gated.map((c) => /"([^"]+:[a-z]+)"/.exec(c.message)?.[1]), ["both:read", "navonly:read", "profileonly:read", "routeonly:read", "both:read"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findConflicts: a gate on a permission another plugin declares, or on none, stays quiet", () => {
|
||||||
|
assert.deepEqual(findConflicts([
|
||||||
|
p({ id: "a", permissions: [{ name: "a:read" }], routes: [{ handler: noopHandler, method: "GET", path: "/x", permission: "a:read" }] }),
|
||||||
|
p({ id: "b", routes: [{ handler: noopHandler, method: "GET", path: "/y", permission: "a:read" }, { handler: noopHandler, method: "GET", path: "/z", session: true }] }),
|
||||||
|
]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => {
|
||||||
|
const handler = () => ({ html: "x" });
|
||||||
|
const homeDup = findConflicts([p({ id: "a", home: handler }), p({ id: "b", home: handler })]);
|
||||||
|
assert.ok(homeDup.some((c) => c.kind === "home" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||||
|
const dashDup = findConflicts([p({ id: "a", dashboard: handler }), p({ id: "b", dashboard: handler })]);
|
||||||
|
assert.ok(dashDup.some((c) => c.kind === "dashboard" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||||
|
// One owner of each (even both on one plugin) is fine.
|
||||||
|
assert.deepEqual(findConflicts([p({ id: "a", dashboard: handler, home: handler }), p({ id: "b" })]).filter((c) => c.kind === "home" || c.kind === "dashboard"), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findConflicts: two settings that map to one environment variable are an error", () => {
|
||||||
|
// `a-b` + `c` and `a` + `bC` both become A_B_C.
|
||||||
|
const clash = findConflicts([
|
||||||
|
p({ id: "a-b", settings: [{ key: "c", type: "string" }] }),
|
||||||
|
p({ id: "a", settings: [{ key: "bC", type: "string" }] }),
|
||||||
|
]);
|
||||||
|
assert.ok(clash.some((c) => c.kind === "setting" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("a-b")));
|
||||||
|
assert.deepEqual(findConflicts([
|
||||||
|
p({ id: "a", settings: [{ key: "x", type: "string" }] }),
|
||||||
|
p({ id: "b", settings: [{ key: "x", type: "string" }] }),
|
||||||
|
]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findConflicts: nav nodes without an id never collide, at any depth", () => {
|
||||||
|
assert.deepEqual(findConflicts([
|
||||||
|
p({ id: "a", nav: [{ children: [{ label: "A1" }], label: "A" }] }),
|
||||||
|
p({ id: "b", nav: [{ children: [{ label: "B1" }], label: "B" }] }),
|
||||||
|
]), []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import type { NavNode } from "../ui/nav.ts";
|
||||||
|
import { declaredPermissions, fullPath, type Plugin } from "./plugin.ts";
|
||||||
|
import { envName } from "./settings.ts";
|
||||||
|
|
||||||
|
export interface PluginConflict {
|
||||||
|
kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route" | "setting";
|
||||||
|
level: "error" | "warn";
|
||||||
|
message: string;
|
||||||
|
plugins: string[]; // unique ids involved
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loud resolution, never last-write-wins: discovery throws on any "error" and logs every "warn".
|
||||||
|
// Mount-path uniqueness needs no rule of its own — it follows from the id check. Shared permission
|
||||||
|
// names are the one intentional overlap, so they warn rather than error.
|
||||||
|
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
||||||
|
const out: PluginConflict[] = [];
|
||||||
|
|
||||||
|
const idCounts = new Map<string, number>();
|
||||||
|
for (const plugin of plugins) idCounts.set(plugin.id, (idCounts.get(plugin.id) ?? 0) + 1);
|
||||||
|
for (const [id, n] of idCounts) {
|
||||||
|
if (n > 1) out.push({ kind: "id", level: "error", message: `${n} plugins share id "${id}"; ids must be globally unique`, plugins: [id] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The landing pages are single slots: "/" (home) and "/dashboard" (dashboard) take one owner
|
||||||
|
// each — two plugins claiming either is a loud error, not a race.
|
||||||
|
for (const slot of ["home", "dashboard"] as const) {
|
||||||
|
const owners = plugins.filter((plugin) => plugin[slot]).map((plugin) => plugin.id);
|
||||||
|
if (owners.length > 1) out.push({ kind: slot, level: "error", message: `${owners.length} plugins claim "${slot}" (${owners.join(", ")}); only one may own that page`, plugins: uniq(owners) });
|
||||||
|
}
|
||||||
|
|
||||||
|
collect(plugins, (plugin, push) => {
|
||||||
|
for (const route of plugin.routes ?? []) push(`${route.method} ${fullPath(plugin.id, route.path)}`);
|
||||||
|
}).forEach((owners, key) => {
|
||||||
|
if (owners.length > 1) out.push({ kind: "route", level: "error", message: `${owners.length} routes resolve to "${key}"`, plugins: uniq(owners) });
|
||||||
|
});
|
||||||
|
|
||||||
|
const overrideTargets = (plugin: Plugin, push: (id: string) => void): void => {
|
||||||
|
collectNavIds(plugin.nav, push);
|
||||||
|
for (const entry of plugin.profileMenu ?? []) if (entry.id != null) push(entry.id);
|
||||||
|
};
|
||||||
|
collect(plugins, overrideTargets).forEach((owners, id) => {
|
||||||
|
if (owners.length > 1) out.push({ kind: "nav-id", level: "error", message: `id "${id}" used ${owners.length}× across nav nodes and profile-menu entries; the menu override targets ids, so they must be unique`, plugins: uniq(owners) });
|
||||||
|
});
|
||||||
|
|
||||||
|
collect(plugins, (plugin, push) => {
|
||||||
|
for (const decl of plugin.permissions ?? []) push(decl.name);
|
||||||
|
}).forEach((owners, name) => {
|
||||||
|
if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; pick a more specific "<resource>" unless shared on purpose`, plugins: uniq(owners) });
|
||||||
|
});
|
||||||
|
|
||||||
|
out.push(...undeclaredGates(plugins));
|
||||||
|
|
||||||
|
// Both the id's dashes and the key's camel humps become underscores, so plugin "a-b" key "c" and
|
||||||
|
// plugin "a" key "bC" name one variable — one plugin would silently read the other's value.
|
||||||
|
collect(plugins, (plugin, push) => {
|
||||||
|
for (const decl of plugin.settings ?? []) push(envName(plugin.id, decl.key));
|
||||||
|
}).forEach((owners, name) => {
|
||||||
|
if (owners.length > 1) out.push({ kind: "setting", level: "error", message: `${owners.length} settings resolve to "${name}"; rename a key or a plugin folder`, plugins: uniq(owners) });
|
||||||
|
});
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declaring is optional, but the admin screens offer only declared names: a route gated on one nobody
|
||||||
|
// declares answers 403 (a nav node is hidden) until the Keto tuple is written by hand or
|
||||||
|
// ADMIN_PERMISSIONS seeds it.
|
||||||
|
function undeclaredGates(plugins: Plugin[]): PluginConflict[] {
|
||||||
|
const declared = new Set(declaredPermissions(plugins).map((decl) => decl.name));
|
||||||
|
const out: PluginConflict[] = [];
|
||||||
|
for (const plugin of plugins) {
|
||||||
|
const gated = new Set<string>();
|
||||||
|
for (const route of plugin.routes ?? []) if (route.permission != null) gated.add(route.permission);
|
||||||
|
collectNavPermissions(plugin.nav, (name) => gated.add(name));
|
||||||
|
for (const entry of plugin.profileMenu ?? []) if (entry.permission != null) gated.add(entry.permission);
|
||||||
|
for (const name of [...gated].sort()) {
|
||||||
|
if (!declared.has(name)) out.push({ kind: "permission", level: "warn", message: `plugin "${plugin.id}" gates on "${name}", which no installed plugin declares, so the admin screens can't grant it; declare it in that plugin's \`permissions\``, plugins: [plugin.id] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectNavPermissions(nodes: NavNode[] | undefined, push: (name: string) => void): void {
|
||||||
|
for (const node of nodes ?? []) {
|
||||||
|
if (node.permission != null) push(node.permission);
|
||||||
|
collectNavPermissions(node.children, push);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map each emitted key → the plugin ids that emitted it (repeats kept, so within-plugin dups count).
|
||||||
|
function collect(plugins: Plugin[], emit: (plugin: Plugin, push: (key: string) => void) => void): Map<string, string[]> {
|
||||||
|
const owners = new Map<string, string[]>();
|
||||||
|
for (const plugin of plugins) emit(plugin, (key) => owners.set(key, [...(owners.get(key) ?? []), plugin.id]));
|
||||||
|
return owners;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectNavIds(nodes: NavNode[] | undefined, push: (id: string) => void): void {
|
||||||
|
for (const node of nodes ?? []) {
|
||||||
|
if (node.id != null) push(node.id);
|
||||||
|
collectNavIds(node.children, push);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniq(xs: string[]): string[] {
|
||||||
|
return [...new Set(xs)];
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
|
|||||||
// The folder name becomes a Postgres identifier, which truncates past 63 bytes.
|
// The folder name becomes a Postgres identifier, which truncates past 63 bytes.
|
||||||
{ name: "a storage plugin whose folder name overflows a Postgres identifier", files: { [`${"a".repeat(57)}/plugin.ts`]: `export default { apiVersion: "${HOST_API_VERSION}", storage: true };` }, match: /storage.*56 characters/s },
|
{ name: "a storage plugin whose folder name overflows a Postgres identifier", files: { [`${"a".repeat(57)}/plugin.ts`]: `export default { apiVersion: "${HOST_API_VERSION}", storage: true };` }, match: /storage.*56 characters/s },
|
||||||
{ name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s },
|
{ name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s },
|
||||||
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
|
{ name: "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: /id "dup" used 2× across nav nodes and profile-menu entries/ },
|
||||||
{ 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 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 },
|
||||||
@@ -74,6 +74,14 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
|
|||||||
// 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 },
|
||||||
|
{ name: "a profile-menu entry naming no gate", files: { "nogateprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "/x", label: "X" }] };` }, match: /nogateprofile.*profile-menu entry "X".*names no gate/s },
|
||||||
|
{ name: "a profile-menu entry gating on a bare word", files: { "bareprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "/x", label: "X", permission: "admin" }] };` }, match: /bareprofile.*admin.*<resource>:<action>/s },
|
||||||
|
{ name: "a profile-menu entry without an href", files: { "nohrefprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ label: "X", session: true }] };` }, match: /nohrefprofile.*profile-menu entry "X".*href/s },
|
||||||
|
{ name: "a profile-menu entry with an empty href", files: { "emptyhref/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "", label: "X", session: true }] };` }, match: /emptyhref.*needs an href/s },
|
||||||
|
{ name: "a profile-menu entry without a label", files: { "nolabel/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "/x", session: true }] };` }, match: /nolabel.*needs a label/s },
|
||||||
|
{ name: "an ungated profile-menu entry after a valid one", files: { "secondbad/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [{ href: "/a", label: "A", session: true }, { href: "/b", label: "B" }] };` }, match: /secondbad.*"B".*names no gate/s },
|
||||||
|
{ name: "a null profile-menu entry", files: { "nullentry/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: [null] };` }, match: /nullentry.*needs an href/s },
|
||||||
|
{ name: "profileMenu that is not an array", files: { "objprofile/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", profileMenu: {} };` }, match: /objprofile.*"profileMenu" must be an array/s },
|
||||||
{ name: "a nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*admin.*<resource>:<action>/s },
|
{ name: "a nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*admin.*<resource>:<action>/s },
|
||||||
{ name: "a declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", permissions: [{ name: "admin" }] };` }, match: /baredecl.*admin.*<resource>:<action>/s },
|
{ name: "a declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", permissions: [{ name: "admin" }] };` }, match: /baredecl.*admin.*<resource>:<action>/s },
|
||||||
{ name: "a plugin shipping its own copy of the barrel", files: { "shadow/node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "shadow/plugin.ts": full("shadow") }, match: /shadow.*@plainpages\/plugin-api/s },
|
{ name: "a plugin shipping its own copy of the barrel", files: { "shadow/node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "shadow/plugin.ts": full("shadow") }, match: /shadow.*@plainpages\/plugin-api/s },
|
||||||
@@ -120,11 +128,11 @@ test("a route + nav node may be marked public, or session, and load fine", async
|
|||||||
assert.equal(sess?.nav?.[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("`auth-admin` is not reserved — the admin screens ship as a drop-in plugin mounted at /auth-admin", async (t) => {
|
||||||
const dir = scaffold(t, { "admin/plugin.ts": full("admin") });
|
const dir = scaffold(t, { "auth-admin/plugin.ts": full("auth-admin") });
|
||||||
const plugins = await discoverPlugins({ dir });
|
const plugins = await discoverPlugins({ dir });
|
||||||
assert.equal(plugins.length, 1);
|
assert.equal(plugins.length, 1);
|
||||||
assert.equal(plugins[0]?.id, "admin");
|
assert.equal(plugins[0]?.id, "auth-admin");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => {
|
test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Plugin discovery: scan plugins/, import each folder's plugin.ts default export,
|
// Plugin discovery: scan plugins/, import each folder's plugin.ts default export,
|
||||||
// validate it, assemble the loaded Plugin[]. The imperative shell over plugin.ts's pure rules
|
// validate it, assemble the loaded Plugin[]. The imperative shell over the pure rules in plugin.ts
|
||||||
// (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin problem and
|
// (isValidPluginId, checkApiVersion) and conflicts.ts (findConflicts). Fails loud: every per-plugin problem and
|
||||||
// error-level conflict is collected into one boot-stopping Error; warn-level diagnostics
|
// error-level conflict is collected into one boot-stopping Error; warn-level diagnostics
|
||||||
// (older-minor apiVersion, shared permission name) log and load continues. Folder name = id.
|
// (older-minor apiVersion, shared permission name) log and load continues. Folder name = id.
|
||||||
|
|
||||||
@@ -8,7 +8,8 @@ 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 { type Gate, gatesSet } from "../auth/gate.ts";
|
||||||
import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
|
import { findConflicts } from "./conflicts.ts";
|
||||||
|
import { checkApiVersion, 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";
|
||||||
|
|
||||||
@@ -133,7 +134,7 @@ function asManifest(value: unknown): PluginManifest | null {
|
|||||||
|
|
||||||
// The collection fields feed findConflicts, which iterates them — a non-array crashes it opaquely.
|
// The collection fields feed findConflicts, which iterates them — a non-array crashes it opaquely.
|
||||||
function shapeError(manifest: PluginManifest): string | null {
|
function shapeError(manifest: PluginManifest): string | null {
|
||||||
for (const field of ["nav", "permissions", "routes"] as const) {
|
for (const field of ["nav", "permissions", "profileMenu", "routes"] as const) {
|
||||||
if (manifest[field] !== undefined && !Array.isArray(manifest[field])) return `"${field}" must be an array`;
|
if (manifest[field] !== undefined && !Array.isArray(manifest[field])) return `"${field}" must be an array`;
|
||||||
}
|
}
|
||||||
// `home` / `dashboard` (the landing-page overrides) are route handlers; the host calls them, so
|
// `home` / `dashboard` (the landing-page overrides) are route handlers; the host calls them, so
|
||||||
@@ -151,9 +152,11 @@ function shapeError(manifest: PluginManifest): string | null {
|
|||||||
const gate = gateError(`route "${route?.method} ${route?.path}"`, route);
|
const gate = gateError(`route "${route?.method} ${route?.path}"`, route);
|
||||||
if (gate) return gate;
|
if (gate) return gate;
|
||||||
}
|
}
|
||||||
const navGate = findNavGateError(manifest.nav);
|
return findNavGateError(manifest.nav) ?? findProfileMenuError(manifest.profileMenu) ?? findPermissionNameError(manifest.permissions);
|
||||||
if (navGate) return navGate;
|
}
|
||||||
for (const decl of Array.isArray(manifest.permissions) ? manifest.permissions : []) {
|
|
||||||
|
function findPermissionNameError(decls: PluginManifest["permissions"]): string | null {
|
||||||
|
for (const decl of Array.isArray(decls) ? decls : []) {
|
||||||
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"`;
|
||||||
}
|
}
|
||||||
@@ -189,6 +192,17 @@ function findNavGateError(nodes: PluginManifest["nav"]): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findProfileMenuError(entries: PluginManifest["profileMenu"]): string | null {
|
||||||
|
for (const entry of Array.isArray(entries) ? entries : []) {
|
||||||
|
const what = `profile-menu entry "${entry?.label ?? "?"}"`;
|
||||||
|
if (typeof entry?.href !== "string" || entry.href === "") return `${what} needs an href`;
|
||||||
|
if (typeof entry.label !== "string" || entry.label === "") return `${what} needs a label`;
|
||||||
|
const err = gateError(what, entry);
|
||||||
|
if (err) return err;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function messageOf(err: unknown): string {
|
function messageOf(err: unknown): string {
|
||||||
return err instanceof Error ? err.message : String(err);
|
return err instanceof Error ? err.message : String(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ export type { BootContext, HttpMethod, Plugin, PluginHooks, PluginManifest, Perm
|
|||||||
export type { PluginSettings, SettingDecl, SettingSummary, SettingType, SettingValue } from "./settings.ts";
|
export type { PluginSettings, SettingDecl, SettingSummary, SettingType, SettingValue } from "./settings.ts";
|
||||||
export type { StorageCredentials } from "./storage.ts";
|
export type { StorageCredentials } from "./storage.ts";
|
||||||
export type { RequestContext, User } from "../http/context.ts";
|
export type { RequestContext, User } from "../http/context.ts";
|
||||||
export type { PageChrome } from "../ui/chrome.ts";
|
export type { PageChrome, ProfileMenuEntry } 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.
|
// The three coarse gates a route, nav node or profile-menu entry may declare — `Route`, `NavNode` and `ProfileMenuEntry` all extend it.
|
||||||
export type { Gate } from "../auth/gate.ts";
|
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
|
||||||
@@ -32,13 +32,13 @@ export type { Catalog, PluralMessage } from "../i18n/catalog.ts";
|
|||||||
// missing key is a type error in the editor rather than a wall of boot errors.
|
// missing key is a type error in the editor rather than a wall of boot errors.
|
||||||
export type { CoreMessages } from "../i18n/locales/en-US.ts";
|
export type { CoreMessages } from "../i18n/locales/en-US.ts";
|
||||||
export { parseListQuery } from "../ui/list-query.ts";
|
export { parseListQuery } from "../ui/list-query.ts";
|
||||||
export { paginate } from "../ui/paginate.ts";
|
export { buildPaginationModel, paginate } from "../ui/paginate.ts";
|
||||||
export type { PageModel } from "../ui/paginate.ts";
|
export type { PageModel, PaginationModel, PaginationModelOptions } from "../ui/paginate.ts";
|
||||||
export { readFormBody } from "../http/body.ts";
|
export { readFormBody } from "../http/body.ts";
|
||||||
export { CSRF_FIELD } from "../auth/csrf.ts";
|
export { CSRF_FIELD } from "../auth/csrf.ts";
|
||||||
// System capabilities for a privileged/system plugin (ctx.system) — the Ory admin clients + the
|
// System capabilities for a privileged/system plugin (ctx.system) — the Ory admin clients + the
|
||||||
// instant-revoke hook. Undefined unless the host wired them; the built-in admin plugin is the
|
// instant-revoke hook. Undefined unless the host wired them; the auth-admin example plugin is
|
||||||
// reference consumer. The Ory client types + their error classes are re-exported so a system
|
// the reference consumer. The Ory client types + their error classes are re-exported so a system
|
||||||
// plugin can type against them and `instanceof`-match their errors. See README → System capabilities.
|
// plugin can type against them and `instanceof`-match their errors. See README → System capabilities.
|
||||||
export type { SystemCapabilities } from "./system.ts";
|
export type { SystemCapabilities } from "./system.ts";
|
||||||
export type { Identity, KratosAdmin, RecoveryCode } from "../auth/kratos-admin.ts";
|
export type { Identity, KratosAdmin, RecoveryCode } from "../auth/kratos-admin.ts";
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
checkApiVersion,
|
checkApiVersion,
|
||||||
declaredPermissions,
|
declaredPermissions,
|
||||||
definePlugin,
|
definePlugin,
|
||||||
findConflicts,
|
|
||||||
HOST_API_VERSION,
|
HOST_API_VERSION,
|
||||||
isValidPermissionName,
|
isValidPermissionName,
|
||||||
isValidPluginId,
|
isValidPluginId,
|
||||||
@@ -94,54 +93,9 @@ test("checkApiVersion: semver compat — equal/patch ok, older minor warns, newe
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// A minimal discovered plugin (id = folder name; mount path is the derived `/<id>`), per case.
|
|
||||||
const p = (over: Partial<Plugin> & Pick<Plugin, "id">): Plugin => ({ apiVersion: "1.0.0", ...over });
|
|
||||||
|
|
||||||
test("findConflicts: a clean set has none", () => {
|
|
||||||
assert.deepEqual(findConflicts([p({ id: "a" }), p({ id: "b" })]), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("findConflicts: a duplicate id and a colliding route are loud errors", () => {
|
|
||||||
const dupId = findConflicts([p({ id: "a" }), p({ id: "a" })]);
|
|
||||||
assert.ok(dupId.some((c) => c.kind === "id" && c.level === "error"));
|
|
||||||
|
|
||||||
// Cross-plugin routes can't collide (unique `/<id>` prefix); two identical routes in one can.
|
|
||||||
const noop = () => {};
|
|
||||||
const dupRoute = findConflicts([p({
|
|
||||||
id: "a",
|
|
||||||
routes: [{ handler: noop, method: "GET", path: "/t" }, { handler: noop, method: "GET", path: "/t" }],
|
|
||||||
})]);
|
|
||||||
assert.ok(dupRoute.some((c) => c.kind === "route" && c.level === "error" && c.message.includes("/a/t")));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("findConflicts: duplicate nav id is an error, a shared permission name only warns", () => {
|
|
||||||
const navDup = findConflicts([
|
|
||||||
p({ id: "a", nav: [{ id: "dup", label: "A" }] }),
|
|
||||||
p({ id: "b", nav: [{ id: "dup", label: "B" }] }),
|
|
||||||
]);
|
|
||||||
assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
|
||||||
|
|
||||||
// Sharing a permission across plugins is legitimate → warn, not error.
|
|
||||||
const permissionDup = findConflicts([
|
|
||||||
p({ id: "a", permissions: [{ name: "shared:read" }] }),
|
|
||||||
p({ id: "b", permissions: [{ name: "shared:read" }] }),
|
|
||||||
]);
|
|
||||||
assert.ok(permissionDup.some((c) => c.kind === "permission" && c.level === "warn"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => {
|
|
||||||
const handler = () => ({ html: "x" });
|
|
||||||
const homeDup = findConflicts([p({ id: "a", home: handler }), p({ id: "b", home: handler })]);
|
|
||||||
assert.ok(homeDup.some((c) => c.kind === "home" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
|
||||||
const dashDup = findConflicts([p({ id: "a", dashboard: handler }), p({ id: "b", dashboard: handler })]);
|
|
||||||
assert.ok(dashDup.some((c) => c.kind === "dashboard" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
|
||||||
// One owner of each (even both on one plugin) is fine.
|
|
||||||
assert.deepEqual(findConflicts([p({ id: "a", dashboard: handler, home: handler }), p({ id: "b" })]).filter((c) => c.kind === "home" || c.kind === "dashboard"), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Drift guard: every mount but the auth flows is listed by hand, so a new top-level built-in route
|
// Drift guard: every mount but the auth flows is listed by hand, so a new top-level built-in route
|
||||||
// needs a line here too.
|
// needs a line here too.
|
||||||
test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / field) and `admin` (a plugin) are NOT reserved", () => {
|
test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / field), `admin` and `auth-admin` (plugins) are NOT reserved", () => {
|
||||||
const seg = (path: string): string => path.split("/")[1] ?? ""; // first segment of "/x/y"
|
const seg = (path: string): string => path.split("/")[1] ?? ""; // first segment of "/x/y"
|
||||||
const builtins = new Set<string>([
|
const builtins = new Set<string>([
|
||||||
...Object.keys(AUTH_FLOWS).map(seg), // /login, /recovery, /registration, /settings, /verification
|
...Object.keys(AUTH_FLOWS).map(seg), // /login, /recovery, /registration, /settings, /verification
|
||||||
@@ -154,8 +108,10 @@ test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / f
|
|||||||
]);
|
]);
|
||||||
for (const id of builtins) assert.ok(RESERVED_PLUGIN_IDS.has(id), `built-in mount "${id}" must be a reserved plugin id`);
|
for (const id of builtins) assert.ok(RESERVED_PLUGIN_IDS.has(id), `built-in mount "${id}" must be a reserved plugin id`);
|
||||||
// "/" is owned by the `home` manifest field (not a /<id> route), so it cannot be shadowed and is
|
// "/" is owned by the `home` manifest field (not a /<id> route), so it cannot be shadowed and is
|
||||||
// deliberately not reserved — a plugin folder named "home" is legal. `admin` is likewise free: the
|
// deliberately not reserved — a plugin folder named "home" is legal. `auth-admin` is likewise free:
|
||||||
// admin screens ship as a drop-in plugin mounted at /admin, not a built-in route.
|
// the admin screens ship as a drop-in plugin mounted at /auth-admin, not a built-in route; and so
|
||||||
|
// is `admin`, the id an operator's pre-0.5 copy of that plugin still carries.
|
||||||
assert.equal(RESERVED_PLUGIN_IDS.has("home"), false);
|
assert.equal(RESERVED_PLUGIN_IDS.has("home"), false);
|
||||||
|
assert.equal(RESERVED_PLUGIN_IDS.has("auth-admin"), false);
|
||||||
assert.equal(RESERVED_PLUGIN_IDS.has("admin"), false);
|
assert.equal(RESERVED_PLUGIN_IDS.has("admin"), false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,12 +6,13 @@
|
|||||||
|
|
||||||
import type { Gate } from "../auth/gate.ts";
|
import type { Gate } from "../auth/gate.ts";
|
||||||
import type { RequestContext } from "../http/context.ts";
|
import type { RequestContext } from "../http/context.ts";
|
||||||
|
import type { ProfileMenuEntry } from "../ui/chrome.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 type { SettingDecl, SettingsOf } from "./settings.ts";
|
||||||
import type { StorageCredentials } from "./storage.ts";
|
import type { StorageCredentials } from "./storage.ts";
|
||||||
|
|
||||||
// The Plainpages release this contract ships in — see README → Contract versioning.
|
// The Plainpages release this contract ships in — see README → Contract versioning.
|
||||||
export const HOST_API_VERSION = "0.4.0";
|
export const HOST_API_VERSION = "0.5.0";
|
||||||
|
|
||||||
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
|
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
|
||||||
|
|
||||||
@@ -49,7 +50,7 @@ export function isValidPermissionName(name: string): boolean {
|
|||||||
|
|
||||||
// Every permission the installed plugins declare, deduped by name and sorted — the fixed list the
|
// Every permission the installed plugins declare, deduped by name and sorted — the fixed list the
|
||||||
// admin screens offer when granting. Permissions are authored in code, never invented in the GUI, so
|
// admin screens offer when granting. Permissions are authored in code, never invented in the GUI, so
|
||||||
// this *is* the catalog; a name in Keto that no plugin declares gates nothing and is not offered.
|
// this *is* the catalog; a name in Keto that no plugin declares is not offered.
|
||||||
// First declaration of a name wins its description (shared names are legitimate, findConflicts warns).
|
// First declaration of a name wins its description (shared names are legitimate, findConflicts warns).
|
||||||
export function declaredPermissions(plugins: Plugin[]): PermissionDecl[] {
|
export function declaredPermissions(plugins: Plugin[]): PermissionDecl[] {
|
||||||
const byName = new Map<string, PermissionDecl>();
|
const byName = new Map<string, PermissionDecl>();
|
||||||
@@ -89,6 +90,8 @@ export interface PluginManifest<S extends readonly SettingDecl[] = readonly Sett
|
|||||||
hooks?: PluginHooks<S>;
|
hooks?: PluginHooks<S>;
|
||||||
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique
|
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique
|
||||||
permissions?: PermissionDecl[];
|
permissions?: PermissionDecl[];
|
||||||
|
// Links above Sign out in the profile dropdown, each gated like a nav node; only a signed-in visitor sees any.
|
||||||
|
profileMenu?: ProfileMenuEntry[];
|
||||||
routes?: Route[];
|
routes?: Route[];
|
||||||
// Operator-supplied configuration, one PLUGIN_SETTING_<ID>_<KEY> variable per key; the resolved
|
// Operator-supplied configuration, one PLUGIN_SETTING_<ID>_<KEY> variable per key; the resolved
|
||||||
// values arrive on onBoot's BootContext, typed from these declarations (settings.ts).
|
// values arrive on onBoot's BootContext, typed from these declarations (settings.ts).
|
||||||
@@ -176,79 +179,8 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO
|
|||||||
return { level: "ok", message: `apiVersion ${pluginVersion}` };
|
return { level: "ok", message: `apiVersion ${pluginVersion}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginConflict {
|
|
||||||
kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route" | "setting";
|
|
||||||
level: "error" | "warn";
|
|
||||||
message: string;
|
|
||||||
plugins: string[]; // unique ids involved
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loud resolution, never last-write-wins: discovery throws on any "error" and logs every "warn".
|
|
||||||
// Mount-path uniqueness needs no rule of its own — it follows from the id check. Shared permission
|
|
||||||
// names are the one intentional overlap, so they warn rather than error.
|
|
||||||
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
|
||||||
const out: PluginConflict[] = [];
|
|
||||||
|
|
||||||
const idCounts = new Map<string, number>();
|
|
||||||
for (const plugin of plugins) idCounts.set(plugin.id, (idCounts.get(plugin.id) ?? 0) + 1);
|
|
||||||
for (const [id, n] of idCounts) {
|
|
||||||
if (n > 1) out.push({ kind: "id", level: "error", message: `${n} plugins share id "${id}"; ids must be globally unique`, plugins: [id] });
|
|
||||||
}
|
|
||||||
|
|
||||||
// The landing pages are single slots: "/" (home) and "/dashboard" (dashboard) take one owner
|
|
||||||
// each — two plugins claiming either is a loud error, not a race.
|
|
||||||
for (const slot of ["home", "dashboard"] as const) {
|
|
||||||
const owners = plugins.filter((plugin) => plugin[slot]).map((plugin) => plugin.id);
|
|
||||||
if (owners.length > 1) out.push({ kind: slot, level: "error", message: `${owners.length} plugins claim "${slot}" (${owners.join(", ")}); only one may own that page`, plugins: uniq(owners) });
|
|
||||||
}
|
|
||||||
|
|
||||||
collect(plugins, (plugin, push) => {
|
|
||||||
for (const route of plugin.routes ?? []) push(`${route.method} ${fullPath(plugin.id, route.path)}`);
|
|
||||||
}).forEach((owners, key) => {
|
|
||||||
if (owners.length > 1) out.push({ kind: "route", level: "error", message: `${owners.length} routes resolve to "${key}"`, plugins: uniq(owners) });
|
|
||||||
});
|
|
||||||
|
|
||||||
collect(plugins, (plugin, push) => collectNavIds(plugin.nav, push)).forEach((owners, id) => {
|
|
||||||
if (owners.length > 1) out.push({ kind: "nav-id", level: "error", message: `nav id "${id}" used ${owners.length}×; override targets ids, so they must be unique`, plugins: uniq(owners) });
|
|
||||||
});
|
|
||||||
|
|
||||||
collect(plugins, (plugin, push) => {
|
|
||||||
for (const decl of plugin.permissions ?? []) push(decl.name);
|
|
||||||
}).forEach((owners, name) => {
|
|
||||||
if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; pick a more specific "<resource>" unless shared on purpose`, plugins: uniq(owners) });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Both the id's dashes and the key's camel humps become underscores, so plugin "a-b" key "c" and
|
|
||||||
// plugin "a" key "bC" name one variable — one plugin would silently read the other's value.
|
|
||||||
collect(plugins, (plugin, push) => {
|
|
||||||
for (const decl of plugin.settings ?? []) push(envName(plugin.id, decl.key));
|
|
||||||
}).forEach((owners, name) => {
|
|
||||||
if (owners.length > 1) out.push({ kind: "setting", level: "error", message: `${owners.length} settings resolve to "${name}"; rename a key or a plugin folder`, plugins: uniq(owners) });
|
|
||||||
});
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map each emitted key → the plugin ids that emitted it (repeats kept, so within-plugin dups count).
|
|
||||||
function collect(plugins: Plugin[], emit: (plugin: Plugin, push: (key: string) => void) => void): Map<string, string[]> {
|
|
||||||
const owners = new Map<string, string[]>();
|
|
||||||
for (const plugin of plugins) emit(plugin, (key) => owners.set(key, [...(owners.get(key) ?? []), plugin.id]));
|
|
||||||
return owners;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectNavIds(nodes: NavNode[] | undefined, push: (id: string) => void): void {
|
|
||||||
for (const node of nodes ?? []) {
|
|
||||||
if (node.id != null) push(node.id);
|
|
||||||
collectNavIds(node.children, push);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A route's full path = the plugin's mount path `/<id>` + the route path. The single source of
|
// A route's full path = the plugin's mount path `/<id>` + the route path. The single source of
|
||||||
// truth for both conflict detection (here) and the router, so they can't disagree.
|
// truth for both conflict detection and the router, so they can't disagree.
|
||||||
export function fullPath(id: string, path: string): string {
|
export function fullPath(id: string, path: string): string {
|
||||||
return `/${id}${path.startsWith("/") ? path : `/${path}`}`;
|
return `/${id}${path.startsWith("/") ? path : `/${path}`}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniq(xs: string[]): string[] {
|
|
||||||
return [...new Set(xs)];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ test("init SQL gives each Ory service its own database, and leaves plugin databa
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// AGENTS.md records that the driver runs the provisioning DDL in bootstrap and nothing else. A
|
// A single value imported from the wrong module puts the driver in web's graph without changing
|
||||||
// single value imported from the wrong module puts it in web's graph without changing behaviour,
|
// behaviour, so nothing but this would notice (docs/decisions.md → "Plugin storage hands over
|
||||||
// so nothing but this would notice.
|
// credentials, not a client").
|
||||||
test("the Postgres driver reaches bootstrap only, never web's import graph", () => {
|
test("the Postgres driver reaches bootstrap only, never web's import graph", () => {
|
||||||
const files = sourceFiles();
|
const files = sourceFiles();
|
||||||
assert.ok(files.length > 40, "walks the source tree");
|
assert.ok(files.length > 40, "walks the source tree");
|
||||||
|
|||||||
+4
-2
@@ -20,10 +20,11 @@ import { buildCredentials, storagePluginIds, type StorageCredentials } from "./p
|
|||||||
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
|
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
|
||||||
// The storage secret is in `config` now, so drop it from the environment before ANY plugin code
|
// The storage secret is in `config` now, so drop it from the environment before ANY plugin code
|
||||||
// runs: a plugin module's top level evaluates during discovery, long before onBoot. Defence in
|
// runs: a plugin module's top level evaluates during discovery, long before onBoot. Defence in
|
||||||
// depth, not a boundary (AGENTS.md) — and only ever move this line earlier, never later.
|
// depth, not a boundary (docs/decisions.md → "The trust boundary is the `web` process, not the
|
||||||
|
// plugin") — and only ever move this line earlier, never later.
|
||||||
delete process.env["PLUGIN_DB_SECRET"];
|
delete process.env["PLUGIN_DB_SECRET"];
|
||||||
// App-level logger: structured, OTLP-capable when OTLP_ENDPOINT is set. The hot path clones it
|
// App-level logger: structured, OTLP-capable when OTLP_ENDPOINT is set. The hot path clones it
|
||||||
// per request for access logging + a trace span (src/http/app.ts); console-only otherwise.
|
// per request for access logging + a trace span (src/http/request-tracking.ts); console-only otherwise.
|
||||||
const log = createLogger({ format: config.logFormat, level: config.logLevel, otlpEndpoint: config.otlpEndpoint, otlpProtocol: config.otlpProtocol, serviceName: config.serviceName });
|
const log = createLogger({ format: config.logFormat, level: config.logLevel, otlpEndpoint: config.otlpEndpoint, otlpProtocol: config.otlpProtocol, serviceName: config.serviceName });
|
||||||
const menu = await loadMenuConfig(); // config/menu.ts override + branding — fails loud if malformed
|
const menu = await loadMenuConfig(); // config/menu.ts override + branding — fails loud if malformed
|
||||||
// Every outbound Ory call is traced through the active request's logger (a client span continuing
|
// Every outbound Ory call is traced through the active request's logger (a client span continuing
|
||||||
@@ -107,6 +108,7 @@ const server = createApp({
|
|||||||
plugins,
|
plugins,
|
||||||
secureCookies: config.secureCookies,
|
secureCookies: config.secureCookies,
|
||||||
settingsCatalog: settings.catalog,
|
settingsCatalog: settings.catalog,
|
||||||
|
trustedProxies: config.trustedProxies,
|
||||||
}).listen(config.port, () => {
|
}).listen(config.port, () => {
|
||||||
log.info("listening", { apiVersion: HOST_API_VERSION, port: config.port, url: config.appUrl ?? `http://localhost:${config.port}` });
|
log.info("listening", { apiVersion: HOST_API_VERSION, port: config.port, url: config.appUrl ?? `http://localhost:${config.port}` });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { generateKeyPairSync, sign, type JsonWebKey } from "node:crypto";
|
import { generateKeyPairSync, sign, type JsonWebKey } from "node:crypto";
|
||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
import type { TestContext } from "node:test";
|
import type { TestContext } from "node:test";
|
||||||
import { createApp } from "./app.ts";
|
|
||||||
import type { HydraAdmin } from "../auth/hydra-admin.ts";
|
import type { HydraAdmin } from "../auth/hydra-admin.ts";
|
||||||
import type { KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
|
import type { KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
|
||||||
import type { KratosAdmin } from "../auth/kratos-admin.ts";
|
import type { KratosAdmin } from "../auth/kratos-admin.ts";
|
||||||
import type { FlowType, KratosPublic } from "../auth/kratos-public.ts";
|
import type { FlowType, KratosPublic } from "../auth/kratos-public.ts";
|
||||||
import { SESSION_COOKIE } from "../auth/login.ts";
|
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||||
|
import { createApp } from "../http/app.ts";
|
||||||
import type { Plugin } from "../plugin-host/plugin.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.
|
// A session JWT signed with a throwaway test key; `staticJwks([ecJwk])` is the matching verify side.
|
||||||
@@ -57,14 +57,19 @@ export const matchesTuple = (t: RelationTuple, f: Partial<RelationTuple>): boole
|
|||||||
(f.subject_id === undefined || t.subject_id === f.subject_id) &&
|
(f.subject_id === undefined || t.subject_id === f.subject_id) &&
|
||||||
(f.subject_set === undefined || sameSet(t.subject_set, f.subject_set));
|
(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.
|
// 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 => ({
|
export const fakeKeto = (tuples: RelationTuple[] = [], over: Partial<KetoClient> = {}): KetoClient => {
|
||||||
check: async () => false,
|
const remove = (f: Partial<RelationTuple>): void => { for (let i = tuples.length - 1; i >= 0; i--) if (matchesTuple(tuples[i]!, f)) tuples.splice(i, 1); };
|
||||||
deleteTuple: async (f) => { for (let i = tuples.length - 1; i >= 0; i--) if (matchesTuple(tuples[i]!, f)) tuples.splice(i, 1); },
|
const add = (tp: RelationTuple): void => { if (!tuples.some((t) => matchesTuple(t, tp) && sameSet(t.subject_set, tp.subject_set))) tuples.push(tp); };
|
||||||
expand: async () => ({ type: "leaf" }),
|
return {
|
||||||
listRelations: async (q = {}) => ({ nextPageToken: null, tuples: tuples.filter((t) => matchesTuple(t, q)) }),
|
check: async () => false,
|
||||||
writeTuple: async (tp) => { if (!tuples.some((t) => matchesTuple(t, tp) && sameSet(t.subject_set, tp.subject_set))) tuples.push(tp); },
|
deleteTuple: async (f) => remove(f),
|
||||||
...over,
|
expand: async () => ({ type: "leaf" }),
|
||||||
});
|
listRelations: async (q = {}) => ({ nextPageToken: null, tuples: tuples.filter((t) => matchesTuple(t, q)) }),
|
||||||
|
transactTuples: async (c) => { c.insert.forEach(add); c.delete.forEach(remove); },
|
||||||
|
writeTuple: async (tp) => add(tp),
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
};
|
||||||
export const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami });
|
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.
|
// OAuth2 login challenge: another app logs in *through* us; Hydra hands the browser here.
|
||||||
@@ -7,7 +7,9 @@
|
|||||||
// as false; media range and `or` syntax fail. A partial implementation counts as support, since MDN
|
// as false; media range and `or` syntax fail. A partial implementation counts as support, since MDN
|
||||||
// marks edge gaps that way, such as `outline` ignoring `border-radius` before Chrome 94. A
|
// marks edge gaps that way, such as `outline` ignoring `border-radius` before Chrome 94. A
|
||||||
// vendor-prefixed name is one engine's by design and is skipped, as are `font-family` names: an engine
|
// vendor-prefixed name is one engine's by design and is skipped, as are `font-family` names: an engine
|
||||||
// skips a family it lacks.
|
// skips a family it lacks. A custom property's value is checked for functions and units, though not
|
||||||
|
// keywords: one a floor engine can't parse is invalid at computed-value time, so every declaration
|
||||||
|
// reading it through `var()` computes as `unset`, and a fallback declared before it doesn't help.
|
||||||
// Not checked: the body of an at-rule other than @media and @supports.
|
// Not checked: the body of an at-rule other than @media and @supports.
|
||||||
import bcd from "@mdn/browser-compat-data" with { type: "json" };
|
import bcd from "@mdn/browser-compat-data" with { type: "json" };
|
||||||
import type { CompatStatement, Identifier, SimpleSupportStatement, SupportBlock } from "@mdn/browser-compat-data";
|
import type { CompatStatement, Identifier, SimpleSupportStatement, SupportBlock } from "@mdn/browser-compat-data";
|
||||||
@@ -114,23 +116,37 @@ function mediaQuery(sheet: Sheet, guards: string[], condition: string, offset: n
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The parts of an @supports condition that must hold for its block to apply: neither a `not (…)`
|
// The parts of an @supports condition that must hold for its block to apply, as text a
|
||||||
// group nor an operand of an `or`, at any depth.
|
// `Feature.guard` can search: the condition without its `not (…)` groups and without any group
|
||||||
|
// joined by `or`, at every depth.
|
||||||
function positive(condition: string): string {
|
function positive(condition: string): string {
|
||||||
let top = "";
|
const { groups, tail } = parenGroups(condition);
|
||||||
let kept = "";
|
if (OR.test(groups.map((g) => g.head).join("") + tail)) return "";
|
||||||
|
return groups.map(keepGroup).join("") + tail;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Group {
|
||||||
|
// The text before this group's "(", back to the previous group or the start of the condition.
|
||||||
|
head: string;
|
||||||
|
inner: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The top-level parenthesised groups of `condition`, and the text after the last.
|
||||||
|
function parenGroups(condition: string): { groups: Group[]; tail: string } {
|
||||||
|
const groups: Group[] = [];
|
||||||
let from = 0;
|
let from = 0;
|
||||||
for (let open = condition.indexOf("("); open >= 0; open = condition.indexOf("(", from)) {
|
for (let open = condition.indexOf("("); open >= 0; open = condition.indexOf("(", from)) {
|
||||||
const close = closing(condition, open);
|
const close = closing(condition, open);
|
||||||
const head = condition.slice(from, open);
|
groups.push({ head: condition.slice(from, open), inner: condition.slice(open + 1, close) });
|
||||||
const inner = condition.slice(open + 1, close);
|
|
||||||
top += head;
|
|
||||||
if (NEGATED.test(head)) kept += head.replace(NEGATED, "");
|
|
||||||
else if (/[\w-]$/.test(head)) kept += `${head}(${inner})`; // a function such as selector()
|
|
||||||
else kept += `${head}(${positive(inner)})`;
|
|
||||||
from = close + 1;
|
from = close + 1;
|
||||||
}
|
}
|
||||||
return OR.test(top + condition.slice(from)) ? "" : kept + condition.slice(from);
|
return { groups, tail: condition.slice(from) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function keepGroup({ head, inner }: Group): string {
|
||||||
|
if (NEGATED.test(head)) return head.replace(NEGATED, "");
|
||||||
|
if (/[\w-]$/.test(head)) return `${head}(${inner})`; // a function such as selector(): its argument names the feature
|
||||||
|
return `${head}(${positive(inner)})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OR = /(?<![\w-])or\s/i;
|
const OR = /(?<![\w-])or\s/i;
|
||||||
@@ -87,3 +87,48 @@ test("shellUser derives the profile from the real user; anonymous → Guest", ()
|
|||||||
assert.deepEqual(shellUser(null), { email: "", initials: "G", name: "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" });
|
assert.deepEqual(shellUser({ email: "ada@example.com", id: "u1", permissions: [] }), { email: "ada@example.com", initials: "AD", name: "ada" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const account: Plugin = {
|
||||||
|
apiVersion: "1.0.0", id: "account",
|
||||||
|
profileMenu: [
|
||||||
|
{ href: "/account/settings", icon: "i-user", label: "account.settings", session: true },
|
||||||
|
{ href: "/account/billing", label: "account.billing", permission: "billing:read" },
|
||||||
|
{ href: "/account/help", label: "Help", public: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const entries = (opts: { permissions?: string[]; signedIn?: boolean }): { href: string; icon?: string; label: string }[] =>
|
||||||
|
buildPluginChrome({
|
||||||
|
localeHref: (href) => `${href}?locale=sv-SE`,
|
||||||
|
menu: DEFAULT_MENU,
|
||||||
|
plugins: [account],
|
||||||
|
translatorFor: () => (key) => (key === "account.settings" ? "Inställningar" : key),
|
||||||
|
...(opts.signedIn === false ? {} : { user: { email: "ada@x.io", id: "u1", permissions: opts.permissions ?? [] } }),
|
||||||
|
}).user.profileLinks!;
|
||||||
|
|
||||||
|
test("profile menu entries are gated like nav nodes, translated by their plugin and carry the locale", () => {
|
||||||
|
assert.deepEqual(entries({}), [
|
||||||
|
{ href: "/account/settings?locale=sv-SE", icon: "i-user", label: "Inställningar" },
|
||||||
|
{ href: "/account/help?locale=sv-SE", label: "Help" },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(entries({ permissions: ["billing:read"] }).map((e) => e.label), ["Inställningar", "account.billing", "Help"]);
|
||||||
|
assert.deepEqual(entries({ signedIn: false }).map((e) => e.label), []); // an anonymous visitor has no profile menu
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the central override hides and renames a profile-menu entry by its id; the host translates the new label", () => {
|
||||||
|
const links = buildPluginChrome({
|
||||||
|
menu: { ...DEFAULT_MENU, override: { hide: ["help"], rename: { settings: "Preferences" } } },
|
||||||
|
plugins: [{
|
||||||
|
apiVersion: "1.0.0", id: "account",
|
||||||
|
profileMenu: [
|
||||||
|
{ href: "/account/settings", id: "settings", label: "account.settings", session: true },
|
||||||
|
{ href: "/account/help", id: "help", label: "Help", public: true },
|
||||||
|
{ href: "/account/keys", id: "toString", label: "Keys", session: true },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
t: (key) => `host:${key}`,
|
||||||
|
translatorFor: () => (key) => `plugin:${key}`,
|
||||||
|
user: { email: "ada@x.io", id: "u1", permissions: [] },
|
||||||
|
}).user.profileLinks;
|
||||||
|
// An id that names an Object.prototype member is renamed only when the override says so.
|
||||||
|
assert.deepEqual(links, [{ href: "/account/settings", label: "host:Preferences" }, { href: "/account/keys", label: "plugin:Keys" }]);
|
||||||
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user