Cut non-essential prose from docs and comments, and require the same of every future change #63
@@ -3,20 +3,27 @@
|
|||||||
Guidance for AI agents and contributors working in this repo. Read `README.md` for
|
Guidance for AI agents and contributors working in this repo. Read `README.md` for
|
||||||
commands and layout.
|
commands and layout.
|
||||||
|
|
||||||
## Maintaining this file
|
## Prose discipline
|
||||||
|
|
||||||
Every agent session reads this file in full, so its length is a cost paid on every task.
|
Every word in this repo is read again on every future task, so prose is a recurring cost. On **any**
|
||||||
Keep it the shortest thing that still changes what someone does.
|
change, sweep the prose you touched — this file, `README.md`, the example READMEs, and code
|
||||||
|
comments — and cut it back to what a competent reader could not infer:
|
||||||
|
|
||||||
- **Trim as you add.** After any edit, re-read the whole file and compress: merge overlapping
|
- **Delete history.** Git holds it. No "this moved from X", "used to be Y", "was tried and
|
||||||
entries, cut prose that restates a rule, drop what the code or `README.md` already says.
|
rejected", "(declined twice)", dated changelog entries, or the symptom that prompted a fix. Record
|
||||||
Question each section — same information, fewer words.
|
the decision and the reason it *currently* turns on, nothing else.
|
||||||
- **Record the decision and the reason it turns on, nothing else.** Not the investigation, not
|
- **Delete restatement.** A comment that says what the adjacent line says, a doc paragraph that
|
||||||
what was tried first, not how it was verified — that belongs in the PR that made the change.
|
re-explains a table above it, a file-map entry that expands the filename. The fix is deletion,
|
||||||
|
not trimming.
|
||||||
|
- **Delete the self-evident** and anything already stated once elsewhere. **One home per fact** —
|
||||||
|
link to it instead of repeating it; the same sentence in five files is five chances to drift.
|
||||||
- **Give every accepted risk an expiry** ("valid while X"), and delete the entry once X stops
|
- **Give every accepted risk an expiry** ("valid while X"), and delete the entry once X stops
|
||||||
holding.
|
holding.
|
||||||
- **One home per fact.** Link to it rather than restating it — the same sentence in five files
|
- **Keep** the surprising why, the footgun, the invariant, the external constraint, and the one-time
|
||||||
is five things to update and five chances to drift.
|
setup a reader cannot dig out of the code. Once a line has earned its place, make it short and
|
||||||
|
information-dense.
|
||||||
|
|
||||||
|
Trimming is not a separate task to schedule — do it in the same change, every time.
|
||||||
|
|
||||||
## How to work with tasks
|
## How to work with tasks
|
||||||
|
|
||||||
@@ -31,29 +38,23 @@ branch, create a PR and merge it when the CI/CD turns green.
|
|||||||
1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable.
|
1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable.
|
||||||
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`).
|
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`).
|
||||||
Prefer the Node standard library; justify any new dependency; do not add frameworks. The app is
|
Prefer the Node standard library; justify any new dependency; do not add frameworks. The app is
|
||||||
**stateless — no database**. Auth/identity/OAuth are **Ory sidecar services** (Kratos/Keto/Hydra,
|
**stateless — no database**. Auth/identity/OAuth are **Ory sidecar services** reached over their
|
||||||
backed by Postgres), reached over their REST APIs with built-in `fetch` — no SDK. New
|
REST APIs with built-in `fetch` — no SDK. New capabilities ship as **plugin folders** under
|
||||||
capabilities ship as **plugin folders** under `plugins/` that fetch their data from upstream
|
`plugins/` that fetch their data from upstream services, not as core code.
|
||||||
services, not as core code.
|
|
||||||
3. **Strict TypeScript** — `tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`,
|
3. **Strict TypeScript** — `tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`,
|
||||||
`exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer exact types;
|
`exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer exact types;
|
||||||
limit nullable and multi-option types.
|
limit nullable and multi-option types.
|
||||||
4. **Environment-agnostic** — the app never asks *which environment* it runs in; no `NODE_ENV`
|
4. **Environment-agnostic** — no `NODE_ENV` branching. Every behaviour is an **explicit config
|
||||||
branching. Every behaviour is an **explicit config toggle** (e.g. `CACHE_TEMPLATES`,
|
toggle** read once in `src/config.ts`; compose files set them per deployment.
|
||||||
`REQUIRE_SECURE_SECRETS`), read once in `src/config.ts`. Compose files set them per deployment.
|
5. **Semantic, accessible DOM** — the right element for the job (landmarks, one `<h1>` per page +
|
||||||
5. **Semantic, accessible DOM** — use the right element for the job (landmarks, one `<h1>` per page
|
sane heading order, lists, `<table>` with row/column headers, `<fieldset>`/`<legend>`, `<button>`
|
||||||
+ sane heading order, lists, `<table>` with row/column headers, `<fieldset>`/`<legend>`,
|
vs `<a>`); ARIA only to fill real gaps. Classes/ids name *meaning*, not looks.
|
||||||
`<button>` vs `<a>`); add ARIA only to fill real gaps (`aria-current`, `aria-sort`, labels).
|
6. **Full, parallel E2E** — every user-facing flow has a Playwright test, shipped in the same change
|
||||||
Classes/ids name *meaning*, not looks. Prefer native semantics over `div` + ARIA. New views and
|
as the surface. Tests stay independent and side-effect-free so the suite runs `fullyParallel`.
|
||||||
partials keep this bar.
|
7. **Powerful, fail-loud plugins** — the plugin API is the product's main surface and the only way to
|
||||||
6. **Full, parallel E2E** — every user-facing flow (each page, form, guard, plugin route) has a
|
add domain features. It optimises for being powerful, predictable and overloadable, and the host
|
||||||
Playwright E2E test, shipped in the same change as the surface. Tests stay independent and
|
**fails loud at boot/discovery** rather than sandboxing at runtime. Runtime crash-isolation is a
|
||||||
side-effect-free so the suite runs `fullyParallel` — never serialise on shared state.
|
deliberate **non-goal**.
|
||||||
7. **Powerful, fail-loud plugins** — the plugin API is the product's main surface and the only way
|
|
||||||
to add domain features. It optimises for being **powerful, predictable, and overloadable** (a
|
|
||||||
plugin can take over as much of a page as it wants), and the host **fails loud at boot/discovery**
|
|
||||||
(bad manifest, version mismatch, conflict) rather than sandboxing at runtime. Runtime
|
|
||||||
crash-isolation is a deliberate **non-goal** — diagnose at deploy time, not in production.
|
|
||||||
|
|
||||||
## Deliberate architectural deviations (don't re-flag)
|
## Deliberate architectural deviations (don't re-flag)
|
||||||
|
|
||||||
@@ -62,36 +63,32 @@ Revisit only if the stated reason stops holding.
|
|||||||
|
|
||||||
### Structure & contracts
|
### Structure & contracts
|
||||||
|
|
||||||
- **`src/` is grouped by concern**, not flat — `http/` (request pipeline), `auth/` (session-JWT hot
|
- **`src/` is grouped by concern**, not flat — `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`,
|
||||||
path, guards, Ory REST clients), `i18n/` (locale resolution + catalogs), `plugin-host/`
|
with `server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` at the root; tests are
|
||||||
(discovery/router/hooks/view-resolver + the `plugin-api.ts` author barrel + `system.ts` behind
|
co-located. Add a new module to the folder owning its concern. The core ships **no domain
|
||||||
`ctx.system`), `ui/` (design-system view-models + menu/chrome). `server.ts`/`config.ts`/`logger.ts`
|
screens** — even the admin GUI is a drop-in plugin (`examples/plugins/admin/`).
|
||||||
and the topology-guard `*.test.ts` stay at the root; tests are co-located. Add a new module to the
|
|
||||||
folder owning its concern; don't reintroduce a flat tree. The core ships **no domain screens** —
|
|
||||||
even the admin GUI is a drop-in plugin (`examples/plugins/admin/`).
|
|
||||||
- **Plugins and config import the host only via package.json `imports`** — `#plugin-api` →
|
- **Plugins and config import the host only via package.json `imports`** — `#plugin-api` →
|
||||||
`src/plugin-host/plugin-api.ts`, `#menu-config` → `src/ui/menu-config.ts`, never a relative
|
`src/plugin-host/plugin-api.ts`, `#menu-config` → `src/ui/menu-config.ts`, never a relative
|
||||||
`../../src/*` path. These two barrels are the whole contract surface; the `src/*` behind them may
|
`../../src/*` path. These two barrels are the whole contract surface; don't "fix" a `#`-import
|
||||||
be refactored freely. Don't "fix" a `#`-import back to a relative path. Two consequences:
|
back to a relative path. Two consequences:
|
||||||
- `#plugin-api` re-exports the Ory client types (`KratosAdmin`/`KetoClient`/`HydraAdmin` + their
|
- `#plugin-api` re-exports the Ory client types (`KratosAdmin`/`KetoClient`/`HydraAdmin` + their
|
||||||
DTOs and error classes), so those shapes are **contract-visible** — changing them needs a major
|
DTOs and error classes), so those shapes are **contract-visible** — changing them needs a major
|
||||||
`apiVersion` bump, not a free refactor.
|
`apiVersion` bump, not a free refactor.
|
||||||
- **A plugin/config folder must stay a plain folder — no `package.json` of its own**, which would
|
- **A plugin/config folder must stay a plain folder — no `package.json` of its own**, which would
|
||||||
become its own scope and stop `#`-specifiers resolving. A plugin kept in its own repo therefore
|
become its own scope and stop `#`-specifiers resolving. A plugin kept in its own repo therefore
|
||||||
typechecks against the barrel only when mounted under the host tree (or with a vendored stub).
|
typechecks against the barrel only when mounted under the host tree (or with a vendored stub).
|
||||||
- **`examples/` mirrors the drop-in mount dirs** — `examples/plugins/<id>/` copies to `plugins/<id>/`,
|
- **`examples/` mirrors the drop-in mount dirs** — `examples/plugins/<id>/` copies to
|
||||||
`examples/config/menu.ts` to `config/menu.ts`. Both mirrors are in `tsconfig.include` and resolve
|
`plugins/<id>/`, `examples/config/menu.ts` to `config/menu.ts`. Both mirrors are in
|
||||||
the host via `#`-imports, so each typechecks in place *and* copies across unchanged. Never commit
|
`tsconfig.include` and resolve the host via `#`-imports, so each typechecks in place *and* copies
|
||||||
real plugins/config into the root mount dirs — they ship empty.
|
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
|
- **`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 (`/`). (Declined twice.)
|
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`,
|
- **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)`
|
`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
|
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.
|
render as bare keys on the pages it owns.
|
||||||
- **Email is delegated to Kratos** (it renders + sends recovery/verification mail); `web` never
|
- **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 — keeping
|
touches SMTP. Customization is Kratos' `courier.template_override_path`, not app code.
|
||||||
`web` stateless and dependency-light.
|
|
||||||
|
|
||||||
### Authorization
|
### Authorization
|
||||||
|
|
||||||
@@ -100,10 +97,10 @@ Revisit only if the stated reason stops holding.
|
|||||||
a role is a *bundle*, which here is just a group with several grants (groups nest). Ory's own
|
a role is a *bundle*, which here is just a group with several grants (groups nest). Ory's own
|
||||||
"permission" (the `Resource` `permits`: view/edit/delete) is the separate per-row tier.
|
"permission" (the `Resource` `permits`: view/edit/delete) is the separate per-row tier.
|
||||||
- **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare
|
- **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare
|
||||||
word names *who someone is* (a role), and roles are groups here; the old catch-all `admin` was
|
word names *who someone is* (a role), and roles are groups here. **Enforced at discovery**
|
||||||
exactly that mistake. **Enforced at discovery** (`isValidPermissionName` in `plugin-host/plugin.ts`,
|
(`isValidPermissionName` in `plugin-host/plugin.ts`, checked by `shapeError` over every route/nav
|
||||||
checked by `shapeError` over every route/nav `permission` and every declared name), fail-loud like
|
`permission` and every declared name), fail-loud like any other manifest rule — not only in the
|
||||||
any other manifest rule — not only in the admin GUI, which an operator removes by not copying it in.
|
admin GUI, which an operator removes by not copying it in.
|
||||||
- **Names are authored in plugin code; only grants live in Keto.** The host collects every installed
|
- **Names are authored in plugin code; only grants live in Keto.** The host collects every installed
|
||||||
plugin's declarations into one catalog (`declaredPermissions` → `ctx.declaredPermissions`), and
|
plugin's declarations into one catalog (`declaredPermissions` → `ctx.declaredPermissions`), and
|
||||||
that catalog *is* the list the admin screens offer. Hence **no Permissions admin screen**: nothing
|
that catalog *is* the list the admin screens offer. Hence **no Permissions admin screen**: nothing
|
||||||
@@ -116,16 +113,16 @@ Revisit only if the stated reason stops holding.
|
|||||||
- **Declaring a permission stays optional.** Mandatory declaration would let `findConflicts` see all
|
- **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;
|
overlaps, but would then warn on exactly that legitimate sharing case. Shape is enforced;
|
||||||
declaration is not.
|
declaration is not.
|
||||||
- `ADMIN_PERMISSIONS` **defaults to empty** (every permission is owned by the plugin gating on it),
|
- `ADMIN_PERMISSIONS` **defaults to empty**, and **an unusable value is dropped with a warning,
|
||||||
and **an unusable value is dropped with a warning, never fatal** — fail-loud belongs at the
|
never fatal** — fail-loud belongs at the manifest boundary where a developer authored the
|
||||||
manifest boundary where a developer authored the mistake, whereas `bootstrap` gates `web`, so
|
mistake, whereas `bootstrap` gates `web`, so refusing operator env takes the whole stack down
|
||||||
refusing operator env takes the whole stack down. `e2e-tests/compose.auth.yml` seeds a bad value
|
(`e2e-tests/compose.auth.yml` seeds a bad value to prove the container survives one). The seed is
|
||||||
so the container proves it survives one. The seed is a function of what `bootstrap` discovers, so
|
a function of what `bootstrap` discovers, so a plugin dropped in after first boot needs
|
||||||
a plugin dropped in after first boot needs `docker compose up -d` (re-runs the one-shot), not
|
`docker compose up -d`, not `restart web`. `bootstrap`'s matching `./plugins` mount belongs in
|
||||||
`restart web`. `bootstrap`'s matching `./plugins` mount belongs in `compose.override.yml` and
|
`compose.override.yml` and nowhere else: in the base file it would desynchronise prod and collide
|
||||||
nowhere else: in the base file it would desynchronise prod and collide with the e2e stacks, which
|
with the e2e stacks, which bind individual plugins *inside* `/app/plugins` (a nested mount into a
|
||||||
bind individual plugins *inside* `/app/plugins` (a nested mount into a read-only parent is EROFS
|
read-only parent is EROFS and the container never starts). Valid while bootstrap is the only
|
||||||
and the container never starts). Valid while bootstrap is the only writer of grants.
|
writer of grants.
|
||||||
- **`actionForMethod` is plugin-local and must not migrate into `#plugin-api`.** Inside the admin
|
- **`actionForMethod` is plugin-local and must not migrate into `#plugin-api`.** Inside the admin
|
||||||
example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
|
example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
|
||||||
× 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
|
× 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
|
||||||
@@ -133,21 +130,20 @@ Revisit only if the stated reason stops holding.
|
|||||||
- **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry
|
- **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry
|
||||||
`canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders,
|
`canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders,
|
||||||
disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create
|
disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create
|
||||||
form, a delete-confirm page) is the exception to `actionForMethod` and gates on `:write`, since a
|
form, a delete-confirm page) is the exception to `actionForMethod` and gates on `:write`. Two
|
||||||
page whose only purpose is to start a write should refuse a reader rather than render a form whose
|
grant-specific guards go with it: you cannot revoke your own **direct** grants (self-lockout would
|
||||||
submit 403s. Two grant-specific guards go with it: you cannot revoke your own **direct** grants
|
need a `curl` against Keto to undo), and a permission held *through a group* renders
|
||||||
(self-lockout would need a `curl` against Keto to undo), and a permission held *through a group*
|
ticked-but-disabled, because unticked stated the opposite of the truth. **Known gap:** the group
|
||||||
renders ticked-but-disabled, because unticked stated the opposite of the truth. **Known gap:** the
|
paths are unguarded — unticking a permission on a group you belong to, leaving it, or deleting it
|
||||||
group paths are unguarded — unticking a permission on a group you belong to, leaving it, or deleting
|
can still strip your own access. The robust "last effective holder" check needs a reverse Keto
|
||||||
it can still strip your own access. The robust "last effective holder" check needs a reverse Keto
|
|
||||||
query and is deferred.
|
query and is deferred.
|
||||||
- **`users:write` and `groups:write` are equivalent to full administrative access**: `groups:write`
|
- **`users:write` and `groups:write` are equivalent to full administrative access**: `groups:write`
|
||||||
adds you to any group, including one holding every permission; `users:write` mints a recovery code
|
adds you to any group, including one holding every permission; `users:write` mints a recovery code
|
||||||
for any account. The containment the split buys is real on the **read** half only (`users:read` is
|
for any account. The containment the split buys is real on the **read** half only (`users:read` is
|
||||||
a safe helpdesk grant). Don't let the per-resource naming imply otherwise in docs.
|
a safe helpdesk grant). Don't let the per-resource naming imply otherwise in docs.
|
||||||
- **Plainpages says "user" everywhere; Ory's word is "identity".** Ory's own docs use the terms
|
- **Plainpages says "user" everywhere; Ory's word is "identity".** House style, not a renamed
|
||||||
interchangeably, so this is house style, not a renamed concept. The single exception is the
|
concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors
|
||||||
`Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors Kratos' wire shape — don't rename it.
|
Kratos' wire shape — don't rename it.
|
||||||
|
|
||||||
### i18n
|
### i18n
|
||||||
|
|
||||||
@@ -156,15 +152,14 @@ Revisit only if the stated reason stops holding.
|
|||||||
page's language invisible in its address and unshareable; the cost is that a plugin wraps its own
|
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
|
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.
|
`Accept-Language` takes the first regional catalog for it.
|
||||||
- **The core building blocks carry the locale; a plugin doesn't have to.** The shell (breadcrumbs),
|
- **The core building blocks carry the locale; a plugin doesn't have to.** The shell, `pagination`,
|
||||||
`pagination`, `filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every
|
`filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every href in
|
||||||
href in `localeHref`; nav and sign-in are wrapped in `chrome.ts`; the two GET forms carry it as a
|
`localeHref`; nav and sign-in are wrapped in `chrome.ts`; the two GET forms carry it as a hidden
|
||||||
hidden `locale` input, since a GET submit replaces the whole query string. **A form's `action`
|
`locale` input, since a GET submit replaces the whole query string. **A form's `action` counts as a
|
||||||
counts as a link** — sign-out, consent and auth-card forms carry it too, or picking a language and
|
link** — sign-out, consent and auth-card forms carry it too, or picking a language and then saving
|
||||||
then saving anything drops back to `Accept-Language`. Putting the obligation on each call site was
|
anything drops back to `Accept-Language`. The obligation stays on the building block, never on each
|
||||||
tried and missed five of eight sites in one commit. `ctx.localeHref` remains for hrefs a plugin's
|
call site. `ctx.localeHref` remains for hrefs a plugin's own markup emits. The one round-trip that
|
||||||
own markup emits. The one round-trip that cannot carry it is the Kratos sign-in POST (absolute
|
cannot carry it is the Kratos sign-in POST (absolute off-site URL).
|
||||||
off-site URL).
|
|
||||||
- **`locale` is a host-owned query param** — in `parseListQuery`'s reserved set, so a localized list
|
- **`locale` is a host-owned query param** — in `parseListQuery`'s reserved set, so a localized list
|
||||||
page doesn't hand a plugin a phantom `locale` filter. The i18n view locals (`t`, `locale`, `locales`,
|
page doesn't hand a plugin a phantom `locale` filter. The i18n view locals (`t`, `locale`, `locales`,
|
||||||
`localeHref`, `localeParam`, `localeSwitch`, `dir`) are likewise reserved, merged after a handler's
|
`localeHref`, `localeParam`, `localeSwitch`, `dir`) are likewise reserved, merged after a handler's
|
||||||
@@ -192,30 +187,28 @@ Revisit only if the stated reason stops holding.
|
|||||||
escaping into `t()` — every other value in a view would become the odd one out.
|
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
|
- **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
|
that is free and correct, but the stylesheet keeps physical `left`/`right` properties; a genuine RTL
|
||||||
locale needs those moved to logical ones first. Don't convert the CSS or file findings about it on
|
locale needs those moved to logical ones first. Valid while no deployment needs an RTL language.
|
||||||
spec. 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>` + `[popover]`, never a `<details>`.** The browser then
|
||||||
owns open/close — the only zero-JS way to dismiss by clicking outside — and the panel sits in the
|
owns open/close — the only zero-JS way to dismiss by clicking outside — and the panel sits in the
|
||||||
top layer, so a row kebab is no longer clipped by `.table-wrap`'s `overflow`. Four rules hold it
|
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
|
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
|
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`**
|
open-state style and the old-browser fallback both read; the partial **requires a caller-named
|
||||||
and fails loud without one, since that is the `popovertarget` idref (generated ids were tried and
|
`id`** and fails loud without one, since that is the `popovertarget` idref (never generate one —
|
||||||
rejected — nondeterministic HTML forecloses the caching decision); and **neither `aria-expanded` nor
|
nondeterministic HTML forecloses the caching decision); and **neither `aria-expanded` nor
|
||||||
`aria-haspopup` is written**, because a zero-JS invoker cannot keep the first truthful and the second
|
`aria-haspopup` is written**, because a zero-JS invoker cannot keep the first truthful and the
|
||||||
would promise `role="menu"` semantics these panels don't implement. `<details>` stays where it means
|
second would promise `role="menu"` semantics these panels don't implement. `<details>` stays where
|
||||||
disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the profile
|
it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
|
||||||
menu (its trigger composes escaped user values and its one item is a CSRF POST form, neither of which
|
profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
|
||||||
the partial's `Item` shapes cover) — keep the two in step.
|
the two in step.
|
||||||
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract.** It is
|
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it
|
||||||
deliberately not re-exported from `#plugin-api`; README → Nav & permission gates tells an author that
|
is deliberately not re-exported from `#plugin-api`. The palette may narrow when the last reference
|
||||||
a new icon means registering it there. So the palette may narrow when the last reference to an id
|
to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an
|
||||||
goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an unknown
|
unknown sprite id renders blank instead of failing loud (the `every icon <use> resolves` e2e test
|
||||||
sprite id renders blank instead of failing loud (the `every icon <use> resolves` e2e test catches
|
catches anything reaching the nav).
|
||||||
anything reaching the nav). Removing an id is a core edit — weigh it per icon rather than sweeping.
|
|
||||||
|
|
||||||
### Build, test & release
|
### Build, test & release
|
||||||
|
|
||||||
@@ -226,16 +219,14 @@ Revisit only if the stated reason stops holding.
|
|||||||
files, `.dockerignore` the image).
|
files, `.dockerignore` the image).
|
||||||
- **A container whose output a human then edits or deletes runs as `--user "$(id -u):$(id -g)"`** —
|
- **A container whose output a human then edits or deletes runs as `--user "$(id -u):$(id -g)"`** —
|
||||||
the E2E runner (artifacts) and a lockfile edit, or the output is root-owned and needs `sudo`, which
|
the E2E runner (artifacts) and a lockfile edit, or the output is root-owned and needs `sudo`, which
|
||||||
a dev box may not have at all. Not universal: `bootstrap` writes `jwks.json` as root when it is
|
a dev box may not have. Not universal: `bootstrap` writes `jwks.json` as root when it is absent on
|
||||||
absent on first boot — the committed dev key makes that rare, and when it happens the rotation
|
first boot; the committed dev key makes that rare, and when it happens the rotation runbook's
|
||||||
runbook's host-side `>` needs the file re-owned first. Valid while the dev key ships committed.
|
host-side `>` needs the file re-owned first (valid while the dev key ships committed). Three
|
||||||
Three consequences.
|
consequences: `e2e-tests/artifacts/` is *tracked* (`.gitkeep`), since an absent bind-mount source is
|
||||||
`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
|
||||||
daemon-created as root and that uid then cannot write it — which also makes a root-owned leftover
|
`HOME=/tmp`, since an arbitrary uid has no passwd entry and would land on an unwritable `/`; and
|
||||||
an upgrade hazard (README → Breaking changes). The runner image sets `HOME=/tmp`, since an
|
rootless Docker wants the flag *dropped*, container root already being the invoking user. Baking a
|
||||||
arbitrary uid has no passwd entry and would land on an unwritable `/`. And rootless Docker wants
|
`USER` in instead does not work — the image's `pwuser` is 1001 and no fixed uid matches every host.
|
||||||
the flag *dropped* — container root is already the invoking user there. 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.
|
`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
|
- **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
|
`e2e-tests/console-guard.ts`, which fails a test on a console error/warning or uncaught exception on
|
||||||
@@ -244,42 +235,38 @@ Revisit only if the stated reason stops holding.
|
|||||||
Chromium drops (the e2e stacks serve plain http over container hostnames), and `allowConsole(re)` for
|
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
|
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
|
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 buffer clears at
|
a raw `newPage()` instead of `watchedPage()` — would run unwatched and green. Accepted cost: a page
|
||||||
teardown so a `beforeAll` is watched too; accepted cost is that a page outliving its test can log
|
outliving its test can log late and fail the next one.
|
||||||
late and fail the next one.
|
|
||||||
- **The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium.**
|
- **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
|
`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
|
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,
|
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
|
||||||
so widening them means a stack per engine. Screenshots are written per project name.
|
so widening them means a stack per engine.
|
||||||
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** No test, build step or
|
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root** — no test, build step or
|
||||||
workflow reads a markdown file, so a nested `examples/plugins/admin/README.md` edit is as safe to
|
workflow reads a markdown file. Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`:
|
||||||
skip as `README.md`. Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`: rename
|
rename detection names only the destination, so `git mv src/app.ts notes.md` would otherwise read as
|
||||||
detection names only the destination, so `git mv src/app.ts notes.md` otherwise read as docs and
|
docs and skip the gate over a source file that was gone. `src/ci-gate.test.ts` locks the flags as a
|
||||||
skipped the gate over a source file that was gone. `src/ci-gate.test.ts` locks the flags as a *text*
|
*text* guard — the test image ships neither `git` nor `bash`. Revisit if a `.md` ever becomes
|
||||||
guard — the test image ships neither `git` nor `bash`. Revisit if a `.md` ever becomes load-bearing.
|
load-bearing.
|
||||||
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
|
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
|
||||||
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: concurrent jobs
|
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: concurrent jobs
|
||||||
can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that
|
can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that
|
||||||
file between login and logout. Same class: concurrent runs share the workspace dir, so ci.sh's
|
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
|
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.
|
single-maintainer cadence; serialize with a workflow `concurrency` group if it ever bites.
|
||||||
- **Plainpages is pre-announcement: no tags, no releases.** All tags and semver container tags were
|
- **Plainpages is pre-announcement: no tags, no releases.** `auto-release` is gated behind the
|
||||||
deleted, and `auto-release` is gated behind the `AUTO_RELEASE` Actions variable (unset ⇒ skipped,
|
`AUTO_RELEASE` Actions variable (unset ⇒ skipped, the fail-safe direction on every unknown-`vars`
|
||||||
the fail-safe direction on every unknown-`vars` path). A version only communicates to consumers and
|
path) — a version only communicates to consumers and there are none. Two couplings:
|
||||||
there are none — the same reasoning that freezes `HOST_API_VERSION`. Two couplings: `registry-cleanup`
|
`registry-cleanup` keeps a hash image only while its commit is a branch head *or* release-tagged, so
|
||||||
keeps a hash image only while its commit is a branch head *or* release-tagged, so with zero tags a
|
with zero tags a hand-cut tag must sit on `main`'s tip; and `mirror.yml` pushes tags with `--prune`
|
||||||
hand-cut tag must sit on `main`'s tip; and `mirror.yml` pushes tags with `--prune` (so its
|
(its `fetch-tags: true` is load-bearing), so a tag or Release created on GitHub is swept away and
|
||||||
`fetch-tags: true` is load-bearing), meaning a tag or Release created on GitHub is swept away and
|
releases are cut on Gitea only. Valid until the maintainer says Plainpages is ready to show people.
|
||||||
releases are cut on Gitea only. Valid until the
|
- **A stricter manifest rule breaks already-copied plugins**, and while `HOST_API_VERSION` is frozen
|
||||||
maintainer says Plainpages is ready to show people.
|
the failure names a symptom rather than the cause — `checkApiVersion` would refuse a stale plugin by
|
||||||
- **A stricter manifest rule breaks already-copied plugins, and while `HOST_API_VERSION` is frozen the
|
*version*, but only once the freeze lifts. Until then a stricter rule ships with a README →
|
||||||
failure names a symptom rather than the cause.** `plugins/` is an operator-owned drop-in mount, so an
|
Upgrading entry and a re-copy hint in the discovery error. Fail-loud stays right either way: the
|
||||||
operator's copy is whatever version they took. `checkApiVersion` is the right mechanism — a breaking
|
alternative is a route gating on a name nobody can be granted, i.e. a permanent silent 403.
|
||||||
manifest change bumps the major and a stale plugin is refused by *version* — but that only works once
|
**Valid while `HOST_API_VERSION` stays frozen.**
|
||||||
the freeze lifts. Until then a stricter rule ships with a README → Upgrading entry and a re-copy hint
|
|
||||||
in the discovery error. Fail-loud stays right either way: the alternative is a route gating on a name
|
|
||||||
nobody can be granted, i.e. a permanent silent 403. **Valid while `HOST_API_VERSION` stays frozen.**
|
|
||||||
|
|
||||||
## Docker only — no host tooling
|
## Docker only — no host tooling
|
||||||
|
|
||||||
@@ -298,8 +285,8 @@ docker compose -f compose.yml up --build -d # production
|
|||||||
`README.md` serves two readers, in this order — preserve it when editing:
|
`README.md` serves two readers, in this order — preserve it when editing:
|
||||||
|
|
||||||
1. **First-time reader (top).** A one/two-sentence tagline, then a **Quick start** that gets the
|
1. **First-time reader (top).** A one/two-sentence tagline, then a **Quick start** that gets the
|
||||||
stack up and a *minimal* plugin live. Nothing comes before Quick start — no philosophy, no
|
stack up and a *minimal* plugin live. Nothing comes before Quick start. Keep its commands
|
||||||
rationale. Keep its commands copy-pasteable; deeper detail lives in its own section, linked.
|
copy-pasteable; deeper detail lives in its own section, linked.
|
||||||
2. **Returning developer (rest).** A **Contents** ToC right after Quick start, then sections ordered
|
2. **Returning developer (rest).** A **Contents** ToC right after Quick start, then sections ordered
|
||||||
by **what an adopter reaches for first**, not by architectural layering: Overview → Users, groups
|
by **what an adopter reaches for first**, not by architectural layering: Overview → Users, groups
|
||||||
& permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
|
& permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
|
||||||
@@ -308,29 +295,23 @@ docker compose -f compose.yml up --build -d # production
|
|||||||
permissions precedes Building plugins** because a manifest's `permission:` gate is unreadable
|
permissions precedes Building plugins** because a manifest's `permission:` gate is unreadable
|
||||||
without the model, and it is the one home for that model.
|
without the model, and it is the one home for that model.
|
||||||
|
|
||||||
When editing: put content in the section it belongs to; keep the ToC in sync when you add/rename/
|
Keep the ToC in sync when you add/rename/remove an `H2`/`H3`. **Don't document internals** — how a
|
||||||
remove an `H2`/`H3`; state each fact in one home and link to it.
|
script reaches a decision, what a function guards; a developer reads that off the code in seconds.
|
||||||
|
The README earns its length on how to use and operate Plainpages, the external contracts, and
|
||||||
**Don't document internals here.** How a script reaches a decision, what a function guards — a
|
one-time setup. A file-map or table row gets a clause, not a paragraph.
|
||||||
developer can read that off the code in seconds, and it only makes the README longer for humans and
|
|
||||||
machines alike. It belongs in the code, or nowhere. The README earns its length on what you cannot
|
|
||||||
dig out: how to use and operate Plainpages, the external contracts, and one-time setup. Same test
|
|
||||||
before adding a row to a table or the file map — a clause, not a paragraph.
|
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable**
|
- Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable**
|
||||||
(`erasableSyntaxOnly` is on): no `enum`, `namespace`, parameter properties, or decorators. Import
|
(`erasableSyntaxOnly` is on): no `enum`, `namespace`, parameter properties, or decorators. Import
|
||||||
local modules with their `.ts` extension.
|
local modules with their `.ts` extension.
|
||||||
- **No `.mjs`.** Write modules as `.ts` — even standalone scripts run in bare `node:24` containers
|
- **No `.mjs`.** Write modules as `.ts` — even standalone scripts run in bare `node:24` containers.
|
||||||
(the e2e mock servers, `examples/shifts-upstream/server.ts`). If a file genuinely must be plain
|
If a file genuinely must be plain JavaScript, use `.js`; `"type": "module"` is set in both
|
||||||
JavaScript, use `.js`; `"type": "module"` is set in both `package.json`s, so `.js` is ESM.
|
`package.json`s, so `.js` is ESM.
|
||||||
- **No build step** and no compiled artifacts — do not add a bundler or `tsc` emit.
|
- **No build step** and no compiled artifacts — do not add a bundler or `tsc` emit.
|
||||||
- Before finishing a change, run the typecheck and tests above; both must pass.
|
- Before finishing a change, run the typecheck and tests above; both must pass.
|
||||||
- Tests use the built-in `node --test` runner — no test framework dependency.
|
- Tests use the built-in `node --test` runner — no test framework dependency.
|
||||||
- English everywhere. Keep code comments short and information-dense; self-explained code with no
|
- English everywhere.
|
||||||
comment at all is preferred.
|
|
||||||
- Do not comment about history ("this moved from X"), or about the absence of things.
|
|
||||||
- Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never
|
- Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never
|
||||||
ranges (`^`, `~`) and never digests. npm deps via `.npmrc` (`save-exact=true`) + `npm ci`; images
|
ranges (`^`, `~`) and never digests. npm deps via `.npmrc` (`save-exact=true`) + `npm ci`; images
|
||||||
by tag.
|
by tag.
|
||||||
@@ -370,3 +351,10 @@ before adding a row to a table or the file map — a clause, not a paragraph.
|
|||||||
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POSTing it, for
|
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POSTing it, for
|
||||||
example on list pages with filters and pagination. Do `ids=x&ids=y`, not `ids[]=x&ids[]=y` and not
|
example on list pages with filters and pagination. Do `ids=x&ids=y`, not `ids[]=x&ids[]=y` and not
|
||||||
`ids=x,y`.
|
`ids=x,y`.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
Default to **no comment**. Delete one that restates the adjacent code, repeats a convention used
|
||||||
|
elsewhere, justifies self-evident code, or records history. Write one only for what a competent
|
||||||
|
reader of *this* codebase could not infer: a surprising why, a footgun, an invariant, an external
|
||||||
|
constraint. See [Prose discipline](#prose-discipline).
|
||||||
|
|||||||
@@ -1,20 +1,13 @@
|
|||||||
import { expect, test } from "./console-guard.ts";
|
import { expect, test } from "./console-guard.ts";
|
||||||
|
|
||||||
// Regression: the from-scratch dev experience the README/banner advertises must work. `docker compose
|
// The from-scratch dev experience the banner advertises: `docker compose up`, open the printed
|
||||||
// up`, open the printed login URL (http://localhost:3000), sign in as the seeded admin → you land on
|
// login URL, sign in as the seeded admin, land on the dashboard. A host-scoped Kratos CSRF cookie
|
||||||
// the dashboard, signed in. Originally this dumped the user on http://127.0.0.1:3000/error?id=…
|
// cannot cross `localhost`↔`127.0.0.1`, so a cross-host login POST loses it and Kratos redirects to
|
||||||
// ("Page not found"): the banner printed `localhost` but kratos.yml hard-coded `127.0.0.1`, and a
|
// its error sink; APP_URL canonicalises every off-host visitor onto one cookie host instead.
|
||||||
// host-scoped Kratos CSRF cookie can't cross `localhost`↔`127.0.0.1`, so the cross-host login POST
|
|
||||||
// lost it and Kratos redirected to its error sink.
|
|
||||||
//
|
//
|
||||||
// The fix makes APP_URL the single source for the public host: the web app canonicalises every
|
// The runner is on the host network against the plain `docker compose up` topology, so it sees
|
||||||
// off-host visitor onto it (so localhost / 127.0.0.1 / any alias funnel to one cookie host), Kratos'
|
// http://localhost:3000 and http://127.0.0.1:4433 exactly as a host browser does. The proxied
|
||||||
// browser URLs derive from it, and a real /error page replaces the 404.
|
// full-flow suite cannot catch this — it fronts web + Kratos on one origin.
|
||||||
//
|
|
||||||
// This is faithful to the user's environment: the runner uses the host network
|
|
||||||
// (e2e-tests/compose.devstack.yml) against the plain `docker compose up` topology, so it sees
|
|
||||||
// http://localhost:3000 (web) and http://127.0.0.1:4433 (Kratos public) exactly as a host browser
|
|
||||||
// does. The proxied full-flow suite can't catch this regression — it fronts web + Kratos on one origin.
|
|
||||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap
|
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap
|
||||||
const ADMIN_PASSWORD = "admin";
|
const ADMIN_PASSWORD = "admin";
|
||||||
|
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
});
|
});
|
||||||
test.afterAll(async () => { await page.context().close(); });
|
test.afterAll(async () => { await page.context().close(); });
|
||||||
|
|
||||||
// The list screens rebuild their query from the list state (sort/page/filter), so they are where
|
// The list screens rebuild their query from the list state (sort/page/filter), so they are where a
|
||||||
// a chosen language used to get dropped — the core building blocks carry it now.
|
// 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("/admin/users?locale=sv-SE");
|
||||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||||
|
|||||||
@@ -56,9 +56,8 @@ test("every icon <use> resolves to a defined <symbol> (no broken graphics)", asy
|
|||||||
expect(missing).toEqual([]);
|
expect(missing).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// (The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component
|
// The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component and
|
||||||
// (list-query/data-table/filter-bar) and exercised live with real data by the full-flow E2E's admin
|
// exercised live by the full-flow E2E's admin Users list, so it has no Ory-free counterpart here.
|
||||||
// Users list. The mock-data dashboard that used to host it in this Ory-free suite is gone.)
|
|
||||||
|
|
||||||
test("theme switch flips the palette with no JavaScript", async ({ page }) => {
|
test("theme switch flips the palette with no JavaScript", async ({ page }) => {
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
// Reference config/menu.ts — copy into the (empty) config/ mount at the repo root:
|
// Reference config/menu.ts — copy into the empty config/ mount at the repo root:
|
||||||
// cp examples/config/menu.ts config/menu.ts
|
// cp examples/config/menu.ts config/menu.ts
|
||||||
// config/ ships empty; mount your own or copy this in. 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 —
|
// Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins — the
|
||||||
// the override always wins, applied before the per-user permission filter. Every field is
|
// override always wins, applied before the per-user permission filter. Every field is optional.
|
||||||
// optional; delete one to fall back to the default.
|
// 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.md (The menu system).
|
|
||||||
|
|
||||||
import { defineMenu } from "#menu-config";
|
import { defineMenu } from "#menu-config";
|
||||||
|
|
||||||
|
|||||||
@@ -1,27 +1,22 @@
|
|||||||
# Admin — the system-administration plugin
|
# Admin — the system-administration plugin
|
||||||
|
|
||||||
The Users / Groups / OAuth2-clients screens for running Plainpages itself. These used to be
|
The Users / Groups / OAuth2-clients screens for running Plainpages itself, shipped as a **drop-in
|
||||||
built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI
|
example plugin** so a fresh clone has no admin GUI until you opt in. Copy this folder into `plugins/`
|
||||||
until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the
|
(it keeps the id and mount path `admin`, so the screens live at `/admin/*`) and restart:
|
||||||
screens live at `/admin/*`) and restart:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp -r examples/plugins/admin plugins/admin
|
cp -r examples/plugins/admin plugins/admin
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
The bootstrap grants the seeded `admin@plainpages.local` every permission this plugin declares, so the
|
The bootstrap grants the seeded `admin@plainpages.local` every permission this plugin declares, so
|
||||||
section appears in the menu and the screens work immediately.
|
the section appears in the menu and the screens work immediately. An older copy already in
|
||||||
|
`plugins/` is yours — the host never updates it — so re-copy after a pull; a stale one stops the boot
|
||||||
|
with a message naming it ([README → Upgrading](../../../README.md#upgrading)).
|
||||||
|
|
||||||
> **Already have `plugins/admin` from an earlier version?** Re-copy it. Your copy is yours — the host
|
Every string it renders comes from its own catalogs (`i18n/en-US.ts`, `i18n/sv-SE.ts`), the nav
|
||||||
> never updates it — and this plugin's permissions changed on 2026-08-05 (`admin` → `users:`/`groups:`/
|
labels included. Each pure view-model builder takes an optional `t` defaulting to the plugin's own
|
||||||
> `oauth2-clients:` × `read`/`write`). A stale copy stops the boot with a message naming it; see
|
English, so a unit test reads in words rather than keys.
|
||||||
> [README → Upgrading](../../../README.md#upgrading).
|
|
||||||
|
|
||||||
Every string it renders comes from its own catalogs (`i18n/en-US.ts`, `i18n/sv-SE.ts`) — the nav
|
|
||||||
labels included, which are catalog keys in `admin-shared.ts`. Each pure view-model builder takes an
|
|
||||||
optional `t`; the handlers pass `ctx.t`, and the default is the plugin's own English so a unit test
|
|
||||||
reads in words rather than keys. (README → [Languages](../../../README.md#languages-i18n).)
|
|
||||||
|
|
||||||
## What it demonstrates — a *system* plugin
|
## What it demonstrates — a *system* plugin
|
||||||
|
|
||||||
@@ -35,15 +30,14 @@ stack**, so they use the privileged **`ctx.system`** surface the host exposes to
|
|||||||
- **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a
|
- **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a
|
||||||
user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL.
|
user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL.
|
||||||
|
|
||||||
`ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto,
|
`ctx.system` is populated only when the host wired those services. Where a capability is absent the
|
||||||
and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather
|
screen degrades to a themed 503 rather than crashing. Everything else is an ordinary plugin:
|
||||||
than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered,
|
folder-discovered, gated per route by its screen's `<resource>:<action>` permission, rendering the
|
||||||
gated per route by its screen's `<resource>:<action>` permission, rendering the core building blocks
|
core building blocks in `views/`.
|
||||||
in `views/`.
|
|
||||||
|
|
||||||
Each screen is its own resource — `users`, `groups`, `oauth2-clients` — and each splits into `:read`
|
Each screen is its own resource — `users`, `groups`, `oauth2-clients` — split into `:read` and
|
||||||
and `:write`, so a helpdesk account can be given `users:read` alone. The nav is filtered by the same
|
`:write`, so a helpdesk account can be given `users:read` alone. Holding none of the six hides the
|
||||||
permissions: holding none of the three hides the Admin section entirely.
|
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
|
||||||
|
|||||||
@@ -46,9 +46,9 @@ test("buildPermissionPicker ticks what is held and carries each declaration's de
|
|||||||
assert.equal(picker.inheritedNote, undefined); // nothing is group-held here
|
assert.equal(picker.inheritedNote, undefined); // nothing is group-held here
|
||||||
});
|
});
|
||||||
|
|
||||||
// The failure this prevents: a permission held through a group used to render unticked, so the page
|
// An inherited permission rendered unticked would say "not held" about a grant that reaches the JWT,
|
||||||
// said "not held" about a grant that reaches the JWT — and unticking it wrote nothing, which read as
|
// and unticking it writes nothing, reading as a successful revoke. So inherited rows are ticked,
|
||||||
// a successful revoke. Inherited rows are ticked, disabled, and never posted.
|
// disabled, and never posted.
|
||||||
test("buildPermissionPicker distinguishes a direct grant from one inherited through a group", () => {
|
test("buildPermissionPicker distinguishes a direct grant from one inherited through a group", () => {
|
||||||
const picker = buildPermissionPicker({ action: "/x", declared, direct: ["users:write"], effective: ["users:read", "users:write"] });
|
const picker = buildPermissionPicker({ action: "/x", declared, direct: ["users:write"], effective: ["users:read", "users:write"] });
|
||||||
assert.deepEqual(picker.choices.map((c) => [c.name, c.checked, c.inherited]), [
|
assert.deepEqual(picker.choices.map((c) => [c.name, c.checked, c.inherited]), [
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
// Shared plumbing for the admin example plugin: the section nav fragment, the admin-only gate, the
|
// Shared plumbing for the admin example plugin: the section nav fragment, the screen gate, the
|
||||||
// CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers
|
// CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers.
|
||||||
// (themed not-found / capability-unavailable). Ported from the former built-in admin screens;
|
// Everything imports the host only through the #plugin-api barrel.
|
||||||
// everything imports the host only through the #plugin-api barrel.
|
|
||||||
|
|
||||||
import { can, CSRF_FIELD, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "#plugin-api";
|
import { can, CSRF_FIELD, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "#plugin-api";
|
||||||
import enUS from "./i18n/en-US.ts";
|
import enUS from "./i18n/en-US.ts";
|
||||||
|
|
||||||
// This plugin's English (its catalog, then the host's — the screens reuse core words like Cancel and
|
// This plugin's English — its catalog, then the host's — for a view model built outside a request,
|
||||||
// Search), for a view model built outside a request: its unit tests. At runtime the handlers pass
|
// i.e. its unit tests. At runtime the handlers pass ctx.t instead.
|
||||||
// ctx.t, which reads this catalog in the visitor's locale first, then the host's.
|
|
||||||
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 = "/admin/users";
|
||||||
@@ -28,11 +26,9 @@ export function permissionName(resource: AdminResource, action: AdminAction): st
|
|||||||
return `${resource}:${action}`;
|
return `${resource}:${action}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This plugin's mapping from method to action: every screen reads on GET/HEAD and mutates on POST.
|
// Every screen reads on GET/HEAD and mutates on POST. The route table and the in-handler guard both
|
||||||
// The manifest's route table and the in-handler guard both go through it rather than each spelling
|
// go through this rather than each spelling the permission out, so they cannot drift. Deliberately
|
||||||
// the permission out, so they cannot drift into gating on different names. Deliberately local — as
|
// local: generalised, it would make authorization a function of the transport verb (AGENTS.md).
|
||||||
// a general mechanism it would make authorization a function of the transport verb, and a route
|
|
||||||
// table should answer "what does this need?" on its own (AGENTS.md).
|
|
||||||
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";
|
||||||
@@ -54,13 +50,10 @@ export const ADMIN_NAV: NavNode = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
|
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
|
||||||
// declares the same permission, so the host enforces it before the handler runs; this is
|
// declares the same permission, so this is defence-in-depth and what a direct unit test relies on.
|
||||||
// defence-in-depth and what a direct unit test relies on. Returns the (non-null) user for the
|
// `action` defaults to the method's, and is passed explicitly by a *write-intent GET* — a create
|
||||||
// handler to thread on. GuardError → /login or 403.
|
// form or a delete-confirm page — which refuses a reader rather than rendering a form whose submit
|
||||||
// `action` defaults to the method's, and is passed explicitly by a *write-intent GET* — a create form
|
// would 403. The route table declares the same override, so the two cannot disagree.
|
||||||
// or a delete-confirm page, whose only purpose is to start a write. Those refuse a reader honestly
|
|
||||||
// instead of rendering a form whose submit would 403; the route table declares the same override, so
|
|
||||||
// the two still cannot disagree.
|
|
||||||
export function requirePermission(ctx: RequestContext, resource: AdminResource, action?: AdminAction): User {
|
export function requirePermission(ctx: RequestContext, resource: AdminResource, action?: AdminAction): User {
|
||||||
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
|
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
|
||||||
const permission = permissionName(resource, action ?? actionForMethod(ctx.req.method ?? "GET"));
|
const permission = permissionName(resource, action ?? actionForMethod(ctx.req.method ?? "GET"));
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
// Users admin screen: list Kratos identities (filter/sort/paginate) +
|
// Users admin screen: list Kratos identities (filter/sort/paginate) +
|
||||||
// create/edit/deactivate/delete/trigger-recovery. Writes go only to Kratos via the admin client
|
// create/edit/deactivate/delete/trigger-recovery. Pure builders turn identities + the request URL
|
||||||
// (README "stateless"). Pure builders turn identities + the request URL into building-block view
|
// into building-block view models; below them are thin per-route handlers keyed on ctx.params, over
|
||||||
// models; below them are thin per-route handlers (keyed on ctx.params) over a shared `withUser` gate
|
// a shared `withUser` gate.
|
||||||
// — admin-only, CSRF-guarded, each returning a RouteResult (a view, or a redirect after a write — PRG).
|
|
||||||
|
|
||||||
import { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api";
|
import { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api";
|
||||||
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD, userSubject } from "./admin-grants.ts";
|
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD, userSubject } from "./admin-grants.ts";
|
||||||
@@ -370,7 +369,7 @@ export const usersPermissions = withTarget(async (deps, identity, id) => {
|
|||||||
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
|
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
|
||||||
// Self-lockout guard, matching the self-deactivate/self-delete ones: revoking your own grants can
|
// Self-lockout guard, matching the self-deactivate/self-delete ones: revoking your own grants can
|
||||||
// remove the last `users:write` on the deployment, and the instant-revoke hook lands it on the very
|
// remove the last `users:write` on the deployment, and the instant-revoke hook lands it on the very
|
||||||
// next request. Recovery would be a curl against Keto — not something the operator persona can do.
|
// next request — leaving a `curl` against Keto as the only way back in.
|
||||||
if (id === user.id && diff.revoke.length > 0) {
|
if (id === user.id && diff.revoke.length > 0) {
|
||||||
ctx.log.warn("admin: refused a self-revoke of permissions", { actor: user.id, refused: diff.revoke.join(",") });
|
ctx.log.warn("admin: refused a self-revoke of permissions", { actor: user.id, refused: diff.revoke.join(",") });
|
||||||
const permissions = await userPermissionPicker(deps, id, ctx.t("admin.grants.selfRevoke"));
|
const permissions = await userPermissionPicker(deps, id, ctx.t("admin.grants.selfRevoke"));
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
// Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system.
|
// Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system. Copy
|
||||||
// These used to ship inside the core; they were extracted here so a fresh clone has no built-in admin
|
// this folder to plugins/admin (then restart) to enable it — see README → Quick start.
|
||||||
// GUI. Copy this folder to plugins/admin (then restart) to enable it — see README → Quick start.
|
|
||||||
//
|
//
|
||||||
// It is a *system* plugin: its handlers reach the host's Ory admin clients (Kratos/Keto/Hydra) and the
|
// It is a *system* plugin: its handlers reach the host's Ory admin clients and the instant-revoke
|
||||||
// instant-revoke hook via ctx.system, which the host populates when those services are wired (the dev
|
// hook via ctx.system. Where a capability is absent the screen degrades to a themed 503.
|
||||||
// stack wires all of them). Where a capability is absent the screen degrades to a themed 503.
|
|
||||||
|
|
||||||
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api";
|
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api";
|
||||||
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
|
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
|
||||||
|
|||||||
@@ -42,10 +42,9 @@ test("seedPermissions unions ADMIN_PERMISSIONS (empty by default) with the disco
|
|||||||
assert.deepEqual(names(",, ", [" scheduling:read ", ""]), ["scheduling:read"]); // blanks dropped, names trimmed (both sides)
|
assert.deepEqual(names(",, ", [" scheduling:read ", ""]), ["scheduling:read"]); // blanks dropped, names trimmed (both sides)
|
||||||
});
|
});
|
||||||
|
|
||||||
// The regression this pins: an earlier revision *threw* here, so `ADMIN_PERMISSIONS=admin` — this
|
// Bootstrap gates `web`, so it must never refuse to start over operator env — a leftover
|
||||||
// setting's own default until 2026-08-05 — exited bootstrap 1, and bootstrap gates `web`, so a
|
// ADMIN_PERMISSIONS would otherwise brick the whole stack. Drop what it can't use, report it, seed
|
||||||
// leftover variable bricked the whole stack on upgrade. Bootstrap must never refuse to start over
|
// the rest.
|
||||||
// operator env: drop what it can't use, report it, seed the rest.
|
|
||||||
test("seedPermissions drops an ADMIN_PERMISSIONS name that isn't <resource>:<action>, and never throws", () => {
|
test("seedPermissions drops an ADMIN_PERMISSIONS name that isn't <resource>:<action>, and never throws", () => {
|
||||||
const legacy = seedPermissions("admin", ["users:read"]);
|
const legacy = seedPermissions("admin", ["users:read"]);
|
||||||
assert.deepEqual(legacy, { ignored: ["admin"], permissions: ["users:read"] });
|
assert.deepEqual(legacy, { ignored: ["admin"], permissions: ["users:read"] });
|
||||||
|
|||||||
+7
-12
@@ -29,19 +29,14 @@ export function permissionTuple(userId: string, permission: string) {
|
|||||||
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` };
|
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, empty by default)
|
// ADMIN_PERMISSIONS (empty by default) unioned with every discovered plugin's declared names, so
|
||||||
// unioned with every discovered plugin's declared permission names (a route/nav `permission` is a
|
// the host names no plugin yet a dropped-in one is seeded out of the box.
|
||||||
// coarse permission — granted as a Keto `Permission:<name>#granted` tuple). So the host names no plugin, yet a
|
//
|
||||||
// dropped-in plugin's permissions are seeded out of the box. Deduped, order-stable, blanks dropped.
|
|
||||||
// The base is empty because permissions are `<resource>:<action>` and every one of them is owned by
|
|
||||||
// the plugin that gates on it — a host-invented default would gate nothing.
|
|
||||||
// ADMIN_PERMISSIONS is the one place an operator names a permission by hand, so it is held to the
|
// ADMIN_PERMISSIONS is the one place an operator names a permission by hand, so it is held to the
|
||||||
// same `<resource>:<action>` rule discovery applies to a manifest — but *dropped with a warning*,
|
// same `<resource>:<action>` rule as a manifest — but *dropped with a warning*, never fatal:
|
||||||
// never fatal. Fail-loud belongs at the manifest boundary, where a developer authored the mistake
|
// fail-loud belongs at the manifest boundary where a developer authored the mistake, whereas this
|
||||||
// and can fix it; this is operator env, bootstrap gates `web`, and the whole stack must not refuse
|
// is operator env and bootstrap gates `web`, so the whole stack must not refuse to start over a
|
||||||
// to start over a stale variable. `admin` was this setting's own default before 2026-08-05, so a
|
// stale variable. The name it would have written gates nothing anyway.
|
||||||
// value that bricks the boot is the *expected* leftover on any upgrade. The name it would have
|
|
||||||
// written gates nothing anyway. Declared names already passed the check at discovery.
|
|
||||||
export function seedPermissions(adminPermissionsEnv: string | undefined, declaredNames: string[]): { ignored: string[]; permissions: string[] } {
|
export function seedPermissions(adminPermissionsEnv: string | undefined, declaredNames: string[]): { ignored: string[]; permissions: string[] } {
|
||||||
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
|
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
|
||||||
const configured = clean((adminPermissionsEnv ?? "").split(","));
|
const configured = clean((adminPermissionsEnv ?? "").split(","));
|
||||||
|
|||||||
+8
-16
@@ -1,20 +1,12 @@
|
|||||||
// Optional revocation denylist: instant permission/session revoke without putting Keto
|
// Optional revocation denylist: instant permission/session revoke without putting Keto back on the
|
||||||
// back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true.
|
// hot path. Off by default — enable with REVOCATION_DENYLIST=true. An admin action records the
|
||||||
|
// subject as revoked-now; the hot path then rejects that subject's pre-revoke tokens at once,
|
||||||
|
// forcing a re-mint (which re-reads permissions from Keto, or clears a now-dead session).
|
||||||
//
|
//
|
||||||
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked permission or a
|
// An in-memory, auto-evicting Map — no database, so it stays inside the stateless model. Entries
|
||||||
// killed session only takes effect when the token is next minted (re-login / TTL refresh) —
|
// self-evict after one token TTL, by which point any pre-revoke token has expired anyway.
|
||||||
// up to one token TTL of lag. For security-critical revoke (offboarding, a compromised
|
// Single-process: instant on the instance that handled the revoke, elsewhere the guarantee falls
|
||||||
// account) that lag is too long. An admin action records the subject as revoked-now and the
|
// back to the token TTL. Back it with a shared store for hard multi-instance instant-revoke.
|
||||||
// hot path then rejects that subject's pre-revoke tokens at once, forcing a re-mint (which
|
|
||||||
// re-reads permissions from Keto, or clears a now-dead session).
|
|
||||||
//
|
|
||||||
// Cost & scope: an in-memory, auto-evicting Map — no database, like the JWKS cache, so it
|
|
||||||
// stays inside the stateless model. A token carries `iat`, so a *fresh* re-login (iat after
|
|
||||||
// the revoke) passes while every token minted before the revoke is rejected. Entries self-evict
|
|
||||||
// after one token TTL, by which point any pre-revoke token has expired anyway. Single-process:
|
|
||||||
// instant on the instance that handled the revoke; across replicas/restarts the guarantee
|
|
||||||
// falls back to the token TTL (the gap is just no longer closed early). Back it with a shared
|
|
||||||
// store for hard multi-instance instant-revoke.
|
|
||||||
|
|
||||||
export interface Denylist {
|
export interface Denylist {
|
||||||
// Hot-path check: is a token for `sub`, issued at `iat` (unix sec), revoked? A token minted
|
// Hot-path check: is a token for `sub`, issued at `iat` (unix sec), revoked? A token minted
|
||||||
|
|||||||
+3
-5
@@ -1,8 +1,6 @@
|
|||||||
// Auth guards: in-handler authorization, the imperative counterpart to the
|
// In-handler authorization, the imperative counterpart to the declarative route `permission` gate.
|
||||||
// declarative route `permission` gate. The middleware already verified the session JWT and put
|
// `requireSession` asserts (throws GuardError, which app.ts maps to a response); `can`/`check` are
|
||||||
// the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps
|
// predicates a handler branches on. `check` is the one live Keto call, for relationship rules.
|
||||||
// to a response); `can`/`check` are predicates a handler branches on. `check` is the one live
|
|
||||||
// Keto call — the fine-grained "may I?" tier (README), reserved for relationship rules.
|
|
||||||
import type { RequestContext, User } from "../http/context.ts";
|
import type { RequestContext, User } from "../http/context.ts";
|
||||||
import type { KetoClient } from "./keto-client.ts";
|
import type { KetoClient } from "./keto-client.ts";
|
||||||
import { localPath } from "../http/safe-url.ts";
|
import { localPath } from "../http/safe-url.ts";
|
||||||
|
|||||||
+2
-4
@@ -231,10 +231,8 @@ function logout(kratos: KratosPublic, secureCookies: boolean): BuiltinRoute["han
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Kratos' self-service error sink (kratos.yml flows.error.ui_url → /error). A flow that fails a
|
// Kratos' self-service error sink (kratos.yml flows.error.ui_url → /error). A flow that fails a
|
||||||
// security/expiry check redirects the browser here with ?id=<uuid>. Render a themed page with a
|
// security/expiry check redirects the browser here with ?id=<uuid>; render a themed page with a
|
||||||
// path back into sign-in instead of the catch-all 404 ("Page not found") it used to hit. The
|
// path back into sign-in rather than the catch-all 404. The id is shown for support reference only.
|
||||||
// canonical-host redirect prevents the common cause (a lost cross-host CSRF cookie); this is the
|
|
||||||
// honest fallback for any genuine flow error. The id is shown only for support reference.
|
|
||||||
const errorSink = (ctx: RequestContext): RouteResult =>
|
const errorSink = (ctx: RequestContext): RouteResult =>
|
||||||
({ data: { id: ctx.url.searchParams.get("id") }, view: "error" });
|
({ data: { id: ctx.url.searchParams.get("id") }, view: "error" });
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -113,7 +113,7 @@ test("the E2E runner writes its artifacts as the invoking user, never as root",
|
|||||||
// filter would otherwise leave that command silently unguarded.
|
// filter would otherwise leave that command silently unguarded.
|
||||||
const documented = [read("README.md"), ...composeFiles("e2e-tests/").map(read)]
|
const documented = [read("README.md"), ...composeFiles("e2e-tests/").map(read)]
|
||||||
.join("\n").split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l));
|
.join("\n").split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l));
|
||||||
assert.equal(documented.length, 10, "5 compose headers + 5 README blocks");
|
assert.equal(documented.length, 6, "5 compose headers + 1 README block");
|
||||||
for (const l of documented)
|
for (const l of documented)
|
||||||
assert.match(l, /--user "\$\(id -u\):\$\(id -g\)"/, `passes the uid: ${l.trim()}`);
|
assert.match(l, /--user "\$\(id -u\):\$\(id -g\)"/, `passes the uid: ${l.trim()}`);
|
||||||
// An absent mount source is daemon-created as root, and then that uid can't write it at all.
|
// An absent mount source is daemon-created as root, and then that uid can't write it at all.
|
||||||
|
|||||||
+3
-9
@@ -1,12 +1,6 @@
|
|||||||
// Config loaded once from the environment at boot: Ory endpoints, cookie/CSRF
|
// Config loaded once from the environment at boot. Fail-loud — a bad value, a missing enforced
|
||||||
// secrets, JWKS location, listen port, behaviour toggles. Fail-loud — a bad value, a
|
// secret, a bad URL or an out-of-range port throws here, never at request time. Every value has a
|
||||||
// missing enforced secret, a bad URL, or an out-of-range port throws here, never at
|
// working dev default, so `docker compose up` runs with zero config.
|
||||||
// request time.
|
|
||||||
//
|
|
||||||
// Environment-agnostic (AGENTS.md): the app never asks "which environment am I?". Every
|
|
||||||
// behaviour that used to ride on NODE_ENV is its own explicit toggle — `CACHE_TEMPLATES`,
|
|
||||||
// `REQUIRE_SECURE_SECRETS`. Clean-clone (README): every value has a working dev default,
|
|
||||||
// so `docker compose up` runs with zero config; a hardened deploy sets the toggles it wants.
|
|
||||||
|
|
||||||
// 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;
|
||||||
|
|||||||
+11
-17
@@ -27,15 +27,12 @@ import type { MenuConfig } from "../ui/menu-config.ts";
|
|||||||
import { loadI18n } from "../i18n/load.ts";
|
import { loadI18n } from "../i18n/load.ts";
|
||||||
|
|
||||||
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
|
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
|
||||||
// The admin screens ship as a drop-in example plugin; the HTTP-level admin tests mount it via
|
// The HTTP-level admin tests mount the example plugin via createApp — stub Ory clients on
|
||||||
// createApp (with stub Ory clients on ctx.system + views from examples/plugins) exactly as an
|
// ctx.system, views from examples/plugins — exactly as an operator would after copying it in.
|
||||||
// operator would after copying it into plugins/.
|
|
||||||
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: "admin" };
|
||||||
|
|
||||||
// A session JWT signed with a throwaway test key — the verify path. Wired into the shared
|
// A session JWT signed with a throwaway test key; `staticJwks([ecJwk])` is the matching verify side.
|
||||||
// `server` (and the per-test apps) so a request can present a valid session; the dashboard and the
|
|
||||||
// gated routes need one. `staticJwks([ecJwk])` is the matching verify side.
|
|
||||||
const ec = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
const ec = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
||||||
const ecJwk: JsonWebKey = { ...(ec.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "test-kid" };
|
const ecJwk: JsonWebKey = { ...(ec.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "test-kid" };
|
||||||
const b64url = (i: Buffer | string): string => Buffer.from(i).toString("base64url");
|
const b64url = (i: Buffer | string): string => Buffer.from(i).toString("base64url");
|
||||||
@@ -101,8 +98,7 @@ test("plugins replace either landing: `home` owns the public /, `dashboard` owns
|
|||||||
const dir = mkdtempSync(join(tmpdir(), "pp-home-"));
|
const dir = mkdtempSync(join(tmpdir(), "pp-home-"));
|
||||||
mkdirSync(join(dir, "portal", "views"), { recursive: true });
|
mkdirSync(join(dir, "portal", "views"), { recursive: true });
|
||||||
writeFileSync(join(dir, "portal", "views", "welcome.ejs"), `<h1>Welcome to <%= brand %></h1><a href="/login">Sign in</a>`);
|
writeFileSync(join(dir, "portal", "views", "welcome.ejs"), `<h1>Welcome to <%= brand %></h1><a href="/login">Sign in</a>`);
|
||||||
// The dashboard view renders the native app shell from ctx.chrome — the blessed plugin ergonomics:
|
// The dashboard view renders the native app shell from ctx.chrome.
|
||||||
// its own title/body, the global menu (chrome.nav), the signed-in user, the Sign-out CSRF token.
|
|
||||||
writeFileSync(join(dir, "portal", "views", "board.ejs"),
|
writeFileSync(join(dir, "portal", "views", "board.ejs"),
|
||||||
`<%- include("partials/shell", { body: "<p>Hi " + user.email + "</p>", brand: chrome.brand, csrfToken: chrome.csrfToken, nav: include("partials/nav-tree", { nodes: chrome.nav }), theme: chrome.theme, title: "My Portal", user: chrome.user }) %>`);
|
`<%- include("partials/shell", { body: "<p>Hi " + user.email + "</p>", brand: chrome.brand, csrfToken: chrome.csrfToken, nav: include("partials/nav-tree", { nodes: chrome.nav }), theme: chrome.theme, title: "My Portal", user: chrome.user }) %>`);
|
||||||
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||||
@@ -326,9 +322,8 @@ function rawGet(port: number, path: string, host: string, method = "GET"): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
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) => {
|
||||||
// The fix for the localhost-vs-127.0.0.1 / multi-domain trap: reach the app on any host and it
|
// Reach the app on any host and it sends you to APP_URL's, so the browser, the themed form and the
|
||||||
// sends you to APP_URL's host, so the browser, the themed form, and the cross-origin Kratos POST
|
// cross-origin Kratos POST share ONE cookie host. Same-host requests pass straight through.
|
||||||
// all share ONE cookie host. Off-canonical only — same-host requests pass straight through.
|
|
||||||
const app = createApp({ jwks: staticJwks([ecJwk]), appUrl: "http://canonical.example:3000" });
|
const app = createApp({ jwks: staticJwks([ecJwk]), appUrl: "http://canonical.example:3000" });
|
||||||
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());
|
||||||
@@ -359,8 +354,8 @@ test("no APP_URL configured ⇒ no canonical redirect (unit-test apps and host-a
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("/error renders a themed sign-in error page (Kratos' flow error sink), not the 404", async () => {
|
test("/error renders a themed sign-in error page (Kratos' flow error sink), not the 404", async () => {
|
||||||
// Kratos' flows.error.ui_url points here; a flow error redirects to /error?id=<uuid>. Without a
|
// Kratos' flows.error.ui_url points here; a flow error redirects to /error?id=<uuid>, which must
|
||||||
// handler it 404'd as "Page not found" (confusing). It must be a real, themed page now.
|
// land on a real themed page rather than the catch-all 404.
|
||||||
const res = await fetch(base + `/error?id=${randomUUID()}`, { redirect: "manual" });
|
const res = await fetch(base + `/error?id=${randomUUID()}`, { redirect: "manual" });
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
|
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
|
||||||
@@ -1257,9 +1252,8 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
|
|||||||
assert.equal((await get("/admin/groups/%ZZ")).status, 404);
|
assert.equal((await get("/admin/groups/%ZZ")).status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Granting permissions over HTTP, on the two screens that replaced the deleted Permissions screen.
|
// Granting permissions over HTTP. The offered set is the host's catalog (ctx.declaredPermissions),
|
||||||
// The offered set is the host's catalog (ctx.declaredPermissions, from what the installed plugins
|
// so the checkboxes are a fixed list and the POST is the desired state.
|
||||||
// declare), so the checkboxes are a fixed list and the POST is the desired state.
|
|
||||||
test("admin permission grants: the picker offers the declared catalog, and a save is the desired set", async (t) => {
|
test("admin permission grants: the picker offers the declared catalog, and a save is the desired set", 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" } }];
|
||||||
@@ -1348,7 +1342,7 @@ test("admin screens render no write affordance for a read-only holder", async (t
|
|||||||
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 (it was the one this test was written to catch).
|
// The OAuth2-clients screen is held to the same rule.
|
||||||
const clientsRes = await get("/admin/clients", ["oauth2-clients:read"]);
|
const clientsRes = await get("/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();
|
||||||
|
|||||||
+43
-96
@@ -39,15 +39,11 @@ const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|||||||
export interface AppOptions {
|
export interface AppOptions {
|
||||||
appUrl?: string; // canonical public URL (config.appUrl); off-host GET/HEAD visitors are 308'd here. Omitted ⇒ no redirect
|
appUrl?: string; // canonical public URL (config.appUrl); off-host GET/HEAD visitors are 308'd here. Omitted ⇒ no redirect
|
||||||
auth?: VerifyOptions; // expected JWT issuer/audience + clock skew (config); used with jwks
|
auth?: VerifyOptions; // expected JWT issuer/audience + clock skew (config); used with jwks
|
||||||
// Cache compiled templates; caller decides (server passes config.cacheTemplates).
|
cache?: boolean; // cache compiled EJS templates (config.cacheTemplates); off ⇒ edits show live
|
||||||
// Off by default so edits show live; the app itself never inspects the environment.
|
|
||||||
cache?: boolean;
|
|
||||||
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
|
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
|
||||||
denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes
|
denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes
|
||||||
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
|
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
|
||||||
// Loaded translation catalogs (server.ts passes the discovered ones). Omitted ⇒ the built-in
|
i18n?: I18n; // discovered catalogs; omitted ⇒ the built-in en-US only, so an unwired app still renders English
|
||||||
// en-US catalog only, so an unwired app still renders real English.
|
|
||||||
i18n?: I18n;
|
|
||||||
jwks?: JwksProvider; // verify the session JWT → ctx.user/permissions; absent ⇒ always anonymous
|
jwks?: JwksProvider; // verify the session JWT → ctx.user/permissions; absent ⇒ always anonymous
|
||||||
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
|
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
|
||||||
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
|
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
|
||||||
@@ -62,15 +58,12 @@ export interface AppOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createApp(options: AppOptions = {}): Server {
|
export function createApp(options: AppOptions = {}): Server {
|
||||||
// The denylist (when enabled) rides in the verify options so resolveSession rejects a revoked
|
// The denylist rides in the verify options so resolveSession rejects a revoked subject on the hot
|
||||||
// subject on the hot path; the bound `revoke` is handed to the admin handlers that should
|
// path; the bound `revoke` goes to the admin handlers. Both absent ⇒ the feature is fully off.
|
||||||
// revoke instantly. Both absent ⇒ the feature is fully off (no cost, no behaviour change).
|
|
||||||
const denylist = options.denylist;
|
const denylist = options.denylist;
|
||||||
const authOptions: VerifyOptions = denylist ? { ...(options.auth ?? {}), denylist } : (options.auth ?? {});
|
const authOptions: VerifyOptions = denylist ? { ...(options.auth ?? {}), denylist } : (options.auth ?? {});
|
||||||
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;
|
||||||
// Canonical public host (APP_URL): when set, an off-host GET/HEAD visitor is redirected here so
|
|
||||||
// every cookie (esp. Kratos' cross-origin CSRF cookie) shares one host. Omitted ⇒ feature off.
|
|
||||||
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 canonicalHost = canonical?.host; // host[:port], default ports omitted — matches the Host header
|
||||||
const canonicalOrigin = canonical?.origin; // scheme + host[:port], no trailing slash
|
const canonicalOrigin = canonical?.origin; // scheme + host[:port], no trailing slash
|
||||||
@@ -82,9 +75,7 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
const keto = options.keto;
|
const keto = options.keto;
|
||||||
const kratos = options.kratos;
|
const kratos = options.kratos;
|
||||||
const kratosAdmin = options.kratosAdmin;
|
const kratosAdmin = options.kratosAdmin;
|
||||||
// Privileged host services handed to a system plugin via ctx.system — the Ory admin clients and
|
// Only the wired capabilities are present; with none wired ctx.system stays undefined.
|
||||||
// the instant-revoke hook. Only the wired capabilities are present; with none wired ctx.system
|
|
||||||
// stays undefined, so an ordinary deployment (no Ory, hence no system plugin) pays nothing.
|
|
||||||
const system: SystemCapabilities | undefined = kratosAdmin || keto || hydra || revoke
|
const system: SystemCapabilities | undefined = kratosAdmin || keto || hydra || revoke
|
||||||
? { ...(hydra ? { hydra } : {}), ...(keto ? { keto } : {}), ...(kratosAdmin ? { kratosAdmin } : {}), ...(revoke ? { revoke } : {}) }
|
? { ...(hydra ? { hydra } : {}), ...(keto ? { keto } : {}), ...(kratosAdmin ? { kratosAdmin } : {}), ...(revoke ? { revoke } : {}) }
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -93,15 +84,11 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
const menu = options.menu ?? DEFAULT_MENU;
|
const menu = options.menu ?? DEFAULT_MENU;
|
||||||
const plugins = options.plugins ?? [];
|
const plugins = options.plugins ?? [];
|
||||||
const pluginIds = new Set(plugins.map((p) => p.id));
|
const pluginIds = new Set(plugins.map((p) => p.id));
|
||||||
// A plugin may fully replace the public landing "/" (`home`) or the gated dashboard "/dashboard"
|
// `find` is unambiguous: findConflicts guarantees at most one owner of each landing slot.
|
||||||
// (`dashboard`) — Discovery's findConflicts guarantees at most one of each, so `find` is
|
|
||||||
// unambiguous; the predicates narrow the slot to defined.
|
|
||||||
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function");
|
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function");
|
||||||
const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function");
|
const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function");
|
||||||
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
|
|
||||||
// The permission catalog is a property of the installed plugin set, so it is computed once at
|
|
||||||
// wiring rather than per request.
|
|
||||||
const permissionCatalog = declaredPermissions(plugins);
|
const permissionCatalog = declaredPermissions(plugins);
|
||||||
|
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
|
||||||
const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
|
const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
|
||||||
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
|
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
|
||||||
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
||||||
@@ -115,19 +102,11 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
const render = (view: string, data: Record<string, unknown>): Promise<string> =>
|
const render = (view: string, data: Record<string, unknown>): Promise<string> =>
|
||||||
ejs.renderFile(join(viewsDir, `${view}.ejs`), data, { cache, views: [viewsDir] });
|
ejs.renderFile(join(viewsDir, `${view}.ejs`), data, { cache, views: [viewsDir] });
|
||||||
|
|
||||||
// A `view` RouteResult renders plugins/<id>/views/<view>.ejs; such views may include() the core
|
|
||||||
// building-block partials (resolved from viewsDir) and their own partials/subfolders.
|
|
||||||
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
|
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
|
||||||
|
|
||||||
// Every view renders with its context's i18n locals (t/locale/dir/localeSwitch/localeParam) merged
|
// Where the language picker points. Normally the page itself; after a POST that URL may answer no
|
||||||
// in, so a view — core or plugin, at any include depth — calls `t(...)` without its handler passing
|
// GET (POST /admin/users/:id/delete has no GET sibling), so fall back to the page the form was
|
||||||
// it. A plugin's context carries that plugin's translator, so its own catalog wins in its own views.
|
// submitted from, then to the front page — the picker is on every page, so every link must land.
|
||||||
// They are merged LAST: these names are reserved (README → Building plugins), and a handler that
|
|
||||||
// happens to use one loses that key rather than breaking the shell that renders around it.
|
|
||||||
// Where the language picker on this page should point. 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 submitted from, then to the front page — the picker is on every page, so
|
|
||||||
// every one of its links has to land somewhere real.
|
|
||||||
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();
|
||||||
if (method === "GET" || method === "HEAD") return `${url.pathname}${url.search}`;
|
if (method === "GET" || method === "HEAD") return `${url.pathname}${url.search}`;
|
||||||
@@ -147,6 +126,8 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
t: ctx.t,
|
t: ctx.t,
|
||||||
url: ctx.url,
|
url: ctx.url,
|
||||||
});
|
});
|
||||||
|
// i18n locals go last: their names are reserved, so a handler's colliding key loses instead of
|
||||||
|
// breaking the shell around it.
|
||||||
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)) });
|
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)) });
|
||||||
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)) });
|
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)) });
|
||||||
|
|
||||||
@@ -155,10 +136,7 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
res.end(html);
|
res.end(html);
|
||||||
};
|
};
|
||||||
|
|
||||||
// The public landing "/": ungated — anyone may see it. A plugin may fully own it via `home`
|
// The public landing "/", ungated. A plugin may own it via `home`; else the built-in intro page.
|
||||||
// (rendered against its own views, native shell via ctx.chrome, with a fresh CSRF cookie for
|
|
||||||
// any form it ships). Else the built-in intro page with prominent sign-in / register links
|
|
||||||
// (`user` picks "go to dashboard" vs sign-in; the shell's Sign-out form needs the CSRF cookie).
|
|
||||||
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();
|
||||||
if (homePlugin) {
|
if (homePlugin) {
|
||||||
@@ -172,10 +150,8 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
|
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
|
||||||
};
|
};
|
||||||
|
|
||||||
// The post-login app home "/dashboard", gated to a signed-in user: anonymous bounces to sign
|
// "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in
|
||||||
// in, remembering /dashboard as return_to. A plugin may fully own it via `dashboard` — its
|
// starter page.
|
||||||
// handler renders against its own views, same path as a plugin route. Else the built-in
|
|
||||||
// mock-data People list with the one global menu (ctx.chrome.nav) + branding from config/menu.ts.
|
|
||||||
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
|
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
|
||||||
if (!ctx.user) return { redirect: loginRedirect(ctx), status: 303 };
|
if (!ctx.user) return { redirect: loginRedirect(ctx), status: 303 };
|
||||||
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
|
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
|
||||||
@@ -214,26 +190,21 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
// (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);
|
||||||
|
|
||||||
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
|
|
||||||
// /public/<id>/… serves a plugin's public/; everything else the core public/.
|
|
||||||
// Before auth: assets don't need a verified user, and the JWT cookie rides every request.
|
// Before auth: assets don't need a verified user, and the JWT cookie rides every request.
|
||||||
|
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
|
||||||
const { dir, subPath } = routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds);
|
const { dir, subPath } = routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds);
|
||||||
await serveStatic(dir, subPath, res, method === "HEAD", (err) => reqLog.error("static stream error", { error: String(err) }));
|
await serveStatic(dir, subPath, res, method === "HEAD", (err) => reqLog.error("static stream error", { error: String(err) }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rendered pages content-negotiate on Accept-Language, so a cache in front of us must key on
|
// A cache in front of us must key on the language. Set after the static branch: an asset is
|
||||||
// it — otherwise the first visitor's language is served to everyone. Set after the static
|
// the same bytes in every language, and a Vary there fragments its entry per raw header.
|
||||||
// branch above: an asset is the same bytes in every language, and a Vary there would fragment
|
|
||||||
// its cache entry per raw header string.
|
|
||||||
res.setHeader("vary", "accept-language");
|
res.setHeader("vary", "accept-language");
|
||||||
|
|
||||||
// Canonical host (APP_URL): a visitor who reached us on a different host (localhost vs
|
// Canonical host (APP_URL): send an off-host visitor to the configured origin so the browser,
|
||||||
// 127.0.0.1, a secondary domain) is sent to the configured origin, path + query preserved, so
|
// the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the
|
||||||
// the browser, the themed forms, and the cross-origin Kratos POST all share one cookie host —
|
// host-scoped Kratos CSRF cookie is lost and login dumps onto /error. GET/HEAD only: a 308
|
||||||
// otherwise the host-scoped Kratos CSRF cookie is lost and login dumps onto /error. Static
|
// must not replay a cross-host POST.
|
||||||
// assets above are served on any host (health checks). GET/HEAD only — a 308 must not replay a
|
|
||||||
// cross-host POST; first-party forms are always served from a canonical page anyway.
|
|
||||||
if (canonicalHost && (method === "GET" || method === "HEAD")) {
|
if (canonicalHost && (method === "GET" || method === "HEAD")) {
|
||||||
const host = req.headers.host;
|
const host = req.headers.host;
|
||||||
if (host !== undefined && host !== canonicalHost) {
|
if (host !== undefined && host !== canonicalHost) {
|
||||||
@@ -242,18 +213,14 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Which language this request is served in: ?locale wins, else Accept-Language, else en-US.
|
// `explicit` (the URL asked for a locale) is what makes the choice travel: the chrome, this
|
||||||
// `explicit` (the URL asked) is what makes the choice travel: the chrome, this request's
|
// request's redirects and ctx.localeHref then carry ?locale onto the links they emit.
|
||||||
// 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 { 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 carryLocale = (href: string): string => localeHref(href, explicit ? locale : null);
|
||||||
const t = i18n.translator(locale);
|
const t = i18n.translator(locale);
|
||||||
|
|
||||||
// Verify the session JWT once (cached JWKS) → ctx.user/permissions; none/invalid ⇒ anonymous.
|
// A lapsed token still backed by a live Kratos session is silently re-minted — "stay signed
|
||||||
// If the token has lapsed but a live Kratos session still backs it (and we have the Ory
|
// in". The only place the hot path touches Ory.
|
||||||
// clients), silently re-mint it — "stay signed in": re-read permissions from Keto, re-tokenize,
|
|
||||||
// and set the fresh cookie via setHeader so it rides whatever response this request produces
|
|
||||||
// (a dead session clears the stale cookie). This is the only place the hot path touches Ory.
|
|
||||||
let user: User | null = null;
|
let user: User | null = null;
|
||||||
if (jwks) {
|
if (jwks) {
|
||||||
const auth = await resolveSession(req.headers.cookie, jwks, authOptions);
|
const auth = await resolveSession(req.headers.cookie, jwks, authOptions);
|
||||||
@@ -264,32 +231,25 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
user = reminted.user;
|
user = reminted.user;
|
||||||
res.appendHeader("set-cookie", reminted.setCookie);
|
res.appendHeader("set-cookie", reminted.setCookie);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Ory unreachable (Kratos/Keto 5xx, refused, timeout) — degrade to anonymous instead of
|
// Ory unreachable — degrade to anonymous instead of 500ing every lapsed request. Leave
|
||||||
// 500ing every lapsed request. Leave the cookie alone: it can re-mint once Ory recovers.
|
// the cookie alone: it can re-mint once Ory recovers.
|
||||||
reqLog.warn("session re-mint failed (Ory unreachable?)", { error: String(err) });
|
reqLog.warn("session re-mint failed (Ory unreachable?)", { error: String(err) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// CSRF token for this request's first-party forms: reuse a genuine cookie token, else mint
|
|
||||||
// one (a page-emitting handler Set-Cookies it via csrfMint). Verified on our own
|
|
||||||
// state-changing routes.
|
|
||||||
const csrf = ensureCsrfToken(req.headers.cookie, csrfSecret);
|
const csrf = ensureCsrfToken(req.headers.cookie, csrfSecret);
|
||||||
const csrfMint: RequestCsrf = {
|
const csrfMint: RequestCsrf = {
|
||||||
setCookie: (): void => { if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies })); },
|
setCookie: (): void => { if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies })); },
|
||||||
token: csrf.token,
|
token: csrf.token,
|
||||||
};
|
};
|
||||||
// Bound CSRF verifier handed to plugins via ctx.verifyCsrf (the host owns the secret).
|
|
||||||
const verifyCsrf = (submitted: string | null | undefined): boolean =>
|
const verifyCsrf = (submitted: string | null | undefined): boolean =>
|
||||||
verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted });
|
verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted });
|
||||||
// Chrome (brand/global-nav/user/theme/csrf) composes the whole menu, so it's resolved lazily and
|
// Chrome composes the whole menu, so it is memoized and resolved lazily — a json/redirect
|
||||||
// at most once per request: this app-level memo shares it across the contexts below, and each
|
// handler, or the public "/" with a standalone home, never pays for it.
|
||||||
// ctx.chrome getter only triggers it when a handler actually reads it (a json/redirect handler,
|
|
||||||
// or the public "/" with a standalone home, never composes the menu).
|
|
||||||
let chromeMemo: PageChrome | undefined;
|
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 }));
|
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, localeHref: carryLocale, menu, plugins, t, translatorFor: (id) => i18n.translator(locale, id), user }));
|
||||||
|
|
||||||
// The i18n half of every context: the locale, its translator, and the link carrier. A plugin
|
// A plugin's context gets the plugin's own translator — its catalog first, then core.
|
||||||
// route swaps in the plugin's own translator (its catalog first, then core).
|
|
||||||
const i18nFor = (pluginId?: string) => ({
|
const i18nFor = (pluginId?: string) => ({
|
||||||
locale,
|
locale,
|
||||||
localeHref: carryLocale,
|
localeHref: carryLocale,
|
||||||
@@ -297,9 +257,8 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
t: pluginId === undefined ? t : i18n.translator(locale, pluginId),
|
t: pluginId === undefined ? t : i18n.translator(locale, pluginId),
|
||||||
});
|
});
|
||||||
|
|
||||||
// base context (no route params yet); reused for the built-in routes. A plugin-owned render
|
// Base context (no route params), for the built-in routes. Every plugin-owned render — a
|
||||||
// (a landing slot, a hook short-circuit, a plugin route) gets `contextFor(id)` instead, so its
|
// landing slot, a hook short-circuit, a plugin route — gets `contextFor(id)` instead.
|
||||||
// own catalog is what `ctx.t` reads.
|
|
||||||
const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
||||||
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
|
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
|
||||||
buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
|
buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
|
||||||
@@ -309,23 +268,19 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
if (anyRequestHooks) {
|
if (anyRequestHooks) {
|
||||||
const short = await runRequestHooks(plugins, contextFor);
|
const short = await runRequestHooks(plugins, contextFor);
|
||||||
if (short) {
|
if (short) {
|
||||||
// Set the fresh CSRF cookie like every other page-emitting path, so a form the hook
|
// Like every other page-emitting path, so a form the hook renders has its matching cookie.
|
||||||
// renders (its token is in ctx.chrome.csrfToken) has the matching double-submit cookie.
|
|
||||||
csrfMint.setCookie();
|
csrfMint.setCookie();
|
||||||
await sendResult(res, short.result, pluginViewsFor(short.ctx, short.plugin.id), carryLocale);
|
await sendResult(res, short.result, pluginViewsFor(short.ctx, short.plugin.id), carryLocale);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plugin routes (any method): gate on the route's permission, then run the handler. The
|
|
||||||
// handler gets ctx.chrome (native app shell) + ctx.verifyCsrf (guard its own forms); a fresh
|
|
||||||
// CSRF cookie is set so those forms have a valid double-submit token.
|
|
||||||
const match = matchRoute(plugins, method, pathname);
|
const match = matchRoute(plugins, method, pathname);
|
||||||
if (match) {
|
if (match) {
|
||||||
const routeCtx = contextFor(match.plugin.id, match.params);
|
const routeCtx = contextFor(match.plugin.id, match.params);
|
||||||
if (!isAuthorized(match.route, routeCtx.permissions)) {
|
if (!isAuthorized(match.route, routeCtx.permissions)) {
|
||||||
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
|
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply
|
||||||
// return_to; a signed-in user who simply lacks the permission gets the 403 page.
|
// lacks the permission gets the 403 page.
|
||||||
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
|
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
|
||||||
reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
|
reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
|
||||||
sendHtml(res, 403, await renderPage("403", {}));
|
sendHtml(res, 403, await renderPage("403", {}));
|
||||||
@@ -340,9 +295,6 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Built-in endpoints (the auth/OAuth2 group, the landing slots, /error) from the internal
|
|
||||||
// route table — same handler shape as plugin routes; a `view` result renders the core views,
|
|
||||||
// null means the handler wrote to ctx.res itself.
|
|
||||||
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
|
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
|
||||||
if (builtin) {
|
if (builtin) {
|
||||||
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
|
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
|
||||||
@@ -385,20 +337,16 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return createServer((req, res) => {
|
return createServer((req, res) => {
|
||||||
// Per-request log + trace span: a "request" span, continuing an upstream W3C traceparent
|
// "close" (not "finish") fires on both a completed response and a premature disconnect, so an
|
||||||
// when present (distributed tracing across a proxy). "close" (not "finish") fires on both a
|
// aborted request is still logged and its span flushed.
|
||||||
// completed response and a premature disconnect/abort, so an aborted/truncated request is still
|
|
||||||
// logged and its span flushed.
|
|
||||||
const startMs = Date.now();
|
const startMs = Date.now();
|
||||||
const reqLog = requestLogger(log, {
|
const reqLog = requestLogger(log, {
|
||||||
requestId: randomUUID(),
|
requestId: randomUUID(),
|
||||||
...(typeof req.headers.traceparent === "string" ? { traceparent: req.headers.traceparent } : {}),
|
...(typeof req.headers.traceparent === "string" ? { traceparent: req.headers.traceparent } : {}),
|
||||||
});
|
});
|
||||||
// end() must run exactly once, after BOTH the handler has fully unwound (settled) AND the
|
// end() must run exactly once, after BOTH the handler has unwound AND the response has closed.
|
||||||
// response has closed (the access line is then emitted with the final status). Ending earlier
|
// Earlier would throw "already ended" from a still-running handler's ctx.log on a client abort,
|
||||||
// would throw "already ended" from a still-running handler's ctx.log/tracedFetch on a client
|
// or drop the access line on the happy path (the handler settles before close).
|
||||||
// abort, or drop the access line on the happy path (handler settles before close). Coordinating
|
|
||||||
// the two signals avoids both. Logging must never crash a served request, so it's all guarded.
|
|
||||||
let settled = false;
|
let settled = false;
|
||||||
let closed = false;
|
let closed = false;
|
||||||
const finalize = (): void => { if (settled && closed) void reqLog.end().catch(() => {}); };
|
const finalize = (): void => { if (settled && closed) void reqLog.end().catch(() => {}); };
|
||||||
@@ -410,9 +358,8 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
} catch { /* never let logging crash a served request */ }
|
} catch { /* never let logging crash a served request */ }
|
||||||
finalize();
|
finalize();
|
||||||
});
|
});
|
||||||
// Make reqLog ambient for the whole handler (sync body + every await) so all outbound fetch is
|
// Make reqLog ambient for the whole handler so all outbound fetch is traced. The .catch logs a
|
||||||
// traced. handleRequest owns its own try/catch; the .catch logs a pathological escape via the
|
// pathological escape via the app logger — not reqLog, which may be the thing that broke.
|
||||||
// app logger (not reqLog, which may be the thing that broke), never crashing the request.
|
|
||||||
void runWithLog(reqLog, () => handleRequest(req, res, reqLog))
|
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) }))
|
.catch((err) => log.error("request handler escaped its try/catch", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) }))
|
||||||
.finally(() => { settled = true; finalize(); });
|
.finally(() => { settled = true; finalize(); });
|
||||||
|
|||||||
+4
-11
@@ -1,15 +1,8 @@
|
|||||||
// URL safety helpers. Two pure, dependency-free guards:
|
// safeUrl(value) — a URL field is emitted verbatim into an href/src, so a `javascript:`/`data:`
|
||||||
//
|
// URL from untrusted data would be live XSS. Relative or http(s) passes,
|
||||||
// safeUrl(value) — sanitise an untrusted URL before rendering it in an href/src attribute.
|
|
||||||
// Partials escape *text*, but a URL field is emitted verbatim, so a
|
|
||||||
// `javascript:`/`data:` URL from upstream/user data would be live XSS. The
|
|
||||||
// contract (README.md → Routes & handlers) is: a relative or http(s) URL is allowed,
|
|
||||||
// anything else collapses to "#". Exported to plugins via plugin-api.ts.
|
// anything else collapses to "#". Exported to plugins via plugin-api.ts.
|
||||||
//
|
// localPath(value) — the redirect-URI allowlist for `return_to`: host-relative passes, absolute
|
||||||
// localPath(value) — validate a redirect target is a *same-origin* path (the redirect-URI
|
// or protocol-relative is rejected, so a crafted value can't open-redirect.
|
||||||
// allowlist). Used for `return_to`: a host-relative "/a/b?x=1" passes, an
|
|
||||||
// absolute or protocol-relative ("//evil.com", "https://evil.com") is rejected
|
|
||||||
// so a crafted ?return_to= can't turn login completion into an open redirect.
|
|
||||||
|
|
||||||
// ASCII control chars + space that browsers strip/ignore when resolving a URL — strip them before
|
// ASCII control chars + space that browsers strip/ignore when resolving a URL — strip them before
|
||||||
// the scheme check so "java\tscript:" / a leading space can't masquerade as relative.
|
// the scheme check so "java\tscript:" / a leading space can't masquerade as relative.
|
||||||
|
|||||||
@@ -1,15 +1,11 @@
|
|||||||
// Response security headers: set once per request in app.ts so every response — page,
|
// Set once per request in app.ts, so every response carries them (writeHead merges with setHeader).
|
||||||
// JSON, redirect, static, or error — carries them (writeHead merges with setHeader). A plugin route
|
// A plugin route may override any per-response via RouteResult.headers.
|
||||||
// may override any of them per-response via RouteResult.headers (e.g. relax the CSP to ship its own JS).
|
|
||||||
|
|
||||||
// Strict default CSP for the zero-JS, server-rendered core:
|
// The non-obvious parts of the CSP:
|
||||||
// - script-src 'self' : the core ships no JS; a plugin may still serve its own /public/<id>/*.js for
|
// - script-src 'self' with no 'unsafe-inline' ⇒ an injected <script> can't run. A plugin may still
|
||||||
// opt-in progressive enhancement. No 'unsafe-inline' ⇒ an injected <script>
|
// serve its own /public/<id>/*.js for opt-in progressive enhancement.
|
||||||
// can't run (the main XSS sink).
|
|
||||||
// - style-src adds 'unsafe-inline': a few partials carry inline style= attributes.
|
// - style-src adds 'unsafe-inline': a few partials carry inline style= attributes.
|
||||||
// - img-src adds data: : favicon + inline data URIs.
|
|
||||||
// - no form-action: the themed login form posts to Kratos' (often cross-origin) action URL.
|
// - no form-action: the themed login form posts to Kratos' (often cross-origin) action URL.
|
||||||
// - frame-ancestors 'none' : clickjacking guard (the modern X-Frame-Options).
|
|
||||||
const CSP = [
|
const CSP = [
|
||||||
"base-uri 'self'",
|
"base-uri 'self'",
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
|
|||||||
+6
-8
@@ -1,12 +1,10 @@
|
|||||||
// Catalog discovery: import src/i18n/locales/<tag>.ts and plugins/<id>/i18n/<tag>.ts, then
|
// Catalog discovery: import src/i18n/locales/<tag>.ts and plugins/<id>/i18n/<tag>.ts, then check
|
||||||
// check every one against its set's en-US baseline. The imperative shell over catalog.ts's pure
|
// every one against its set's en-US baseline. The imperative shell over catalog.ts's pure rules,
|
||||||
// rules — the same contract as plugin discovery: one boot-stopping Error listing every problem,
|
// with plugin discovery's contract: one boot-stopping Error listing every problem, so a
|
||||||
// so a half-translated deploy is caught at startup rather than as a stray English word in production.
|
// half-translated deploy is caught at startup rather than as a stray English word in production.
|
||||||
//
|
//
|
||||||
// Installed locales are whatever the core folder holds; a plugin may translate fewer of them (its
|
// A plugin may translate fewer locales than the core holds (its strings then render in en-US) but
|
||||||
// strings then render in en-US on that page) but never one the host does not have. The operator's
|
// never one the host lacks. The operator's `locales/` mount extends both sides.
|
||||||
// `locales/` mount extends both sides — `locales/<tag>.ts` for the core, `locales/plugins/<id>/<tag>.ts`
|
|
||||||
// for a plugin — so adding a language never means forking the image or a vendored plugin.
|
|
||||||
|
|
||||||
import { existsSync, readdirSync } from "node:fs";
|
import { existsSync, readdirSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
// The translator: a key + vars → the string to render. Pure and synchronous — views call it
|
// The translator: a key + vars → the string to render. Two rules the rest of the app leans on:
|
||||||
// as `t("shell.signOut")` and handlers as `ctx.t(...)`.
|
// · the lookup walks a catalog chain (plugin locale → plugin en-US → core locale → core en-US)
|
||||||
//
|
// and returns the key itself when nothing has it — so a plain nav label like "Shifts" is its
|
||||||
// Two rules the rest of the app leans on:
|
// own fallback and a manifest needs no catalog to keep working.
|
||||||
// · the lookup walks a catalog chain (plugin locale → plugin en-US → core locale → core en-US) and,
|
// · the result is raw text, escaped by the view with <%= %> like any other value, so a
|
||||||
// when nothing has the key, returns the key itself. That is what makes a plain nav label like
|
// translation is never double-escaped and one carrying markup is rendered with <%- %>.
|
||||||
// "Shifts" its own fallback — a manifest needs no catalog to keep working.
|
|
||||||
// · the result is raw text. Views escape with <%= %> exactly as they do for any other value, so a
|
|
||||||
// translation is never double-escaped, and a message that carries markup is rendered with <%- %>.
|
|
||||||
|
|
||||||
import { isPluralMessage, type Catalog, type PluralMessage } from "./catalog.ts";
|
import { isPluralMessage, type Catalog, type PluralMessage } from "./catalog.ts";
|
||||||
|
|
||||||
|
|||||||
+10
-17
@@ -21,11 +21,9 @@ export interface LoggerOptions {
|
|||||||
stdout?: (msg: string) => void;
|
stdout?: (msg: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The app-level logger: a Log tagged service.name so every console line, OTLP log record and span is
|
// The app-level logger, tagged service.name. With otlpEndpoint set, logs + spans also export to that
|
||||||
// attributed to the service. Level + format + name are explicit toggles (LOG_LEVEL/LOG_FORMAT/
|
// OTLP/HTTP collector; unset ⇒ console only, at zero export cost. The conditional spreads keep
|
||||||
// SERVICE_NAME — environment-agnostic, AGENTS.md §4). With otlpEndpoint set, logs + spans also export
|
// exactOptionalPropertyTypes happy (no `key: undefined`).
|
||||||
// to that OTLP/HTTP collector (e.g. an OpenTelemetry Collector fronting Tempo/Loki); unset ⇒ console
|
|
||||||
// only, at zero export cost. Conditional spreads keep exactOptionalPropertyTypes happy (no `key: undefined`).
|
|
||||||
export function createLogger(opts: LoggerOptions = {}): Log {
|
export function createLogger(opts: LoggerOptions = {}): Log {
|
||||||
return new Log({
|
return new Log({
|
||||||
context: { "service.name": opts.serviceName || SERVICE_NAME },
|
context: { "service.name": opts.serviceName || SERVICE_NAME },
|
||||||
@@ -49,13 +47,10 @@ export function currentLog(): Log | undefined {
|
|||||||
return requestStore.getStore();
|
return requestStore.getStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
// A drop-in `fetch` that traces through the active request log — a client span nested under the
|
// A drop-in `fetch` that traces through the active request log — a client span under the request
|
||||||
// request span, with a W3C `traceparent` injected so the downstream service continues the same
|
// span, with a W3C `traceparent` injected so the downstream service continues the same trace.
|
||||||
// trace. Outside a request (no ambient log) or for a non-string/URL input it's a plain `fetch`.
|
// Outside a request, or for a non-string/URL input, it is a plain `fetch`. Note log.fetch throws
|
||||||
// server.ts wires this (under the Ory timeout) into every Kratos/Keto/Hydra/JWKS call; a plugin
|
// synchronously once the request log has ended; app.ts ends it only after the handler unwinds.
|
||||||
// uses it for its upstream calls (exported via plugin-api.ts). The trace-setup adds no throw of its
|
|
||||||
// own, but log.fetch throws synchronously if the request log has already ended (app.ts ends it only
|
|
||||||
// after the handler unwinds, so a live handler never hits that).
|
|
||||||
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);
|
||||||
@@ -63,11 +58,9 @@ export const tracedFetch: typeof fetch = (input, init) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// A per-request child logger holding a "request" trace span. `clone` (not parentLog) gives the
|
// A per-request child logger holding a "request" trace span. `clone` (not parentLog) gives the
|
||||||
// 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 W3C `traceparent` is adopted
|
// inheriting the parent's level/format/streams/OTLP. A valid upstream `traceparent` is adopted;
|
||||||
// (the span continues that distributed trace across a reverse proxy/gateway; malformed ⇒ ignored, a
|
// malformed ⇒ ignored, a fresh trace starts. `end()` on response finish exports the span.
|
||||||
// fresh trace starts). `requestId` tags every line + the span for log↔trace correlation. Flush with
|
|
||||||
// `end()` on response finish to export the span — a no-op when OTLP is off.
|
|
||||||
export function requestLogger(appLog: Log, opts: { requestId: string; traceparent?: string | undefined }): Log {
|
export function requestLogger(appLog: Log, opts: { requestId: string; traceparent?: string | undefined }): Log {
|
||||||
return appLog.clone({
|
return appLog.clone({
|
||||||
context: { ...appLog.context, requestId: opts.requestId },
|
context: { ...appLog.context, requestId: opts.requestId },
|
||||||
|
|||||||
+27
-43
@@ -1,6 +1,5 @@
|
|||||||
// The plugin contract — the product's main API surface: the machine-readable types +
|
// The plugin contract — the product's main API surface: the machine-readable types + pure rules.
|
||||||
// pure rules; README.md (Building plugins) is the prose reference, discovery/router wire it to FS+HTTP.
|
// README → Building plugins is the prose reference; discovery/router wire this to FS + HTTP.
|
||||||
// Powerful, predictable, fails loud at boot/discovery rather than sandboxing at runtime.
|
|
||||||
//
|
//
|
||||||
// A plugin's identity is its folder under plugins/: folder name = `id` (isValidPluginId), mount =
|
// A plugin's identity is its folder under plugins/: folder name = `id` (isValidPluginId), mount =
|
||||||
// `/<id>`. Neither is in the manifest — the host derives them, so they can't drift or be claimed twice.
|
// `/<id>`. Neither is in the manifest — the host derives them, so they can't drift or be claimed twice.
|
||||||
@@ -8,9 +7,7 @@
|
|||||||
import type { RequestContext } from "../http/context.ts";
|
import type { RequestContext } from "../http/context.ts";
|
||||||
import type { NavNode } from "../ui/nav.ts";
|
import type { NavNode } from "../ui/nav.ts";
|
||||||
|
|
||||||
// Host contract version (semver). Bump major on a breaking manifest/handler change, minor on an
|
// Bump major on a breaking manifest/handler change, minor on an additive one.
|
||||||
// additive one. A plugin pins the version it targets via `apiVersion`; the host applies
|
|
||||||
// provider/consumer semver semantics in checkApiVersion (refuse/warn on mismatch).
|
|
||||||
export const HOST_API_VERSION = "1.0.0";
|
export const HOST_API_VERSION = "1.0.0";
|
||||||
|
|
||||||
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
|
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
|
||||||
@@ -30,24 +27,21 @@ export interface Route {
|
|||||||
method: HttpMethod;
|
method: HttpMethod;
|
||||||
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
|
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
|
||||||
permission?: string; // coarse gate — the Keto Permission the caller must hold; checked before the handler runs
|
permission?: string; // coarse gate — the Keto Permission the caller must hold; checked before the handler runs
|
||||||
// Mark the page reachable by anyone, signed in or not. The same as omitting `permission`
|
// Same as omitting `permission`, but stated outright so public is a deliberate choice rather than
|
||||||
// — an ungated route is already open — but stated outright, so "public" is a deliberate
|
// a forgotten gate. Mutually exclusive with `permission` (discovery refuses both).
|
||||||
// choice, not an accident. Mutually exclusive with `permission` (discovery refuses both).
|
|
||||||
public?: boolean;
|
public?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A Keto Permission this plugin gates on — declared for docs/seeding. Permission names are a shared
|
// A Keto Permission this plugin gates on — declared for docs/seeding. Names are a shared global
|
||||||
// global namespace (so an operator grants them once in Keto) and are always `<resource>:<action>` —
|
// namespace, so an operator grants them once in Keto. See README → Users, groups & permissions.
|
||||||
// `scheduling:read`, `users:write`. A bare word names who someone is rather than what they may do,
|
|
||||||
// which is a role, and roles are groups here (README → Users, groups & permissions).
|
|
||||||
export interface PermissionDecl {
|
export interface PermissionDecl {
|
||||||
description?: string;
|
description?: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `<resource>:<action>`, each half lowercase alphanumeric with dashes/underscores inside. The 64-char
|
// `<resource>:<action>`. The 64-char cap keeps a name usable as a Keto object and a URL path
|
||||||
// cap keeps a name usable as a Keto object and a URL path segment. Enforced at discovery like every
|
// segment. Enforced at discovery like every other manifest rule, so the convention holds for
|
||||||
// other manifest rule, so the convention holds for plugins the admin GUI never touches.
|
// plugins the admin GUI never touches.
|
||||||
const PERMISSION_NAME = /^[a-z0-9][a-z0-9_-]*:[a-z0-9][a-z0-9_-]*$/;
|
const PERMISSION_NAME = /^[a-z0-9][a-z0-9_-]*:[a-z0-9][a-z0-9_-]*$/;
|
||||||
|
|
||||||
export function isValidPermissionName(name: string): boolean {
|
export function isValidPermissionName(name: string): boolean {
|
||||||
@@ -77,12 +71,10 @@ export interface PluginHooks {
|
|||||||
// host derives them from the folder name at discovery (see Plugin).
|
// host derives them from the folder name at discovery (see Plugin).
|
||||||
export interface PluginManifest {
|
export interface PluginManifest {
|
||||||
apiVersion: string; // semver of the host contract this targets — write a literal, NOT HOST_API_VERSION (see docs)
|
apiVersion: string; // semver of the host contract this targets — write a literal, NOT HOST_API_VERSION (see docs)
|
||||||
// Take over the gated dashboard "/dashboard" — the post-login app home. A handler like any
|
// Take over "/dashboard"; the host gates it to a signed-in session first. At most one plugin may
|
||||||
// route's; the host gates it to a signed-in session (anonymous → /login), then renders its own view
|
// declare it (findConflicts → error, never last-write-wins).
|
||||||
// via ctx.chrome. At most one plugin may declare it (findConflicts → error, never last-write-wins).
|
|
||||||
dashboard?: RouteHandler;
|
dashboard?: RouteHandler;
|
||||||
// Take over the public landing "/" — the ungated front page. A handler like any route's,
|
// Take over the ungated public landing "/". At most one plugin may declare it.
|
||||||
// anyone may reach it. At most one plugin may declare it (findConflicts → error).
|
|
||||||
home?: RouteHandler;
|
home?: RouteHandler;
|
||||||
hooks?: PluginHooks;
|
hooks?: PluginHooks;
|
||||||
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique
|
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
|
||||||
@@ -96,27 +88,23 @@ export interface Plugin extends PluginManifest {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Identity helper: types the manifest, returns it unchanged. Validation happens at discovery
|
// Types the manifest and returns it unchanged; validation happens at discovery, so a plugin may
|
||||||
//, so a plugin may equally be a plain typed object. Mirrors Vite's `defineConfig`.
|
// equally be a plain typed object.
|
||||||
export function definePlugin(manifest: PluginManifest): PluginManifest {
|
export function definePlugin(manifest: PluginManifest): PluginManifest {
|
||||||
return manifest;
|
return manifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A plugin id (its folder name) — lowercase a–z, digits, and dashes, dashes allowed anywhere.
|
// The id forms the mount path `/<id>`, the view/static namespace and the central-override target,
|
||||||
// Rejects uppercase, underscores, dots, slashes, spaces: the id forms the mount path `/<id>`,
|
// so it must stay URL/path-safe: no uppercase, underscores, dots, slashes or spaces.
|
||||||
// the view/static namespace, and the central-override target, so it must stay URL/path-safe.
|
|
||||||
const PLUGIN_ID = /^[a-z0-9-]+$/;
|
const PLUGIN_ID = /^[a-z0-9-]+$/;
|
||||||
|
|
||||||
export function isValidPluginId(id: string): boolean {
|
export function isValidPluginId(id: string): boolean {
|
||||||
return PLUGIN_ID.test(id);
|
return PLUGIN_ID.test(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ids the host reserves for its own first-party mount segments (the gated /dashboard, the auth flows,
|
// Plugin routes resolve before the built-ins, so a folder named one of these would silently shadow
|
||||||
// /auth/complete, /logout, the /oauth2 provider routes, the /public/ static). Plugin routes resolve
|
// one — discovery refuses it. "/" is owned by the `home` field, not a route, so it needs no
|
||||||
// before these, so a folder named one of them would silently shadow a built-in route — discovery
|
// reservation; `admin` is deliberately absent, the admin screens being a drop-in plugin.
|
||||||
// refuses it, loud like any conflict. ("/" is owned by the `home` field, not a route, so it can't be
|
|
||||||
// shadowed and needs no reservation.) Note `admin` is NOT reserved: the admin screens ship as a
|
|
||||||
// drop-in plugin (examples/plugins/admin, mounted at /admin), not a built-in route.
|
|
||||||
export const RESERVED_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
export const RESERVED_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
||||||
"auth", "dashboard", "login", "logout", "oauth2", "public", "recovery", "registration", "settings", "verification",
|
"auth", "dashboard", "login", "logout", "oauth2", "public", "recovery", "registration", "settings", "verification",
|
||||||
]);
|
]);
|
||||||
@@ -127,14 +115,12 @@ export interface Semver {
|
|||||||
patch: number;
|
patch: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The official semver.org 2.0.0 core regex (major.minor.patch, optional prerelease/build) — a
|
// The official semver.org 2.0.0 core regex. Only major/minor drive compatibility, so the
|
||||||
// standardized parse with no dependency. We compare only major/minor for compatibility, so the
|
// prerelease/build groups are matched to accept valid input but otherwise ignored.
|
||||||
// prerelease/build groups are matched (to accept valid input) but otherwise ignored.
|
|
||||||
const SEMVER =
|
const SEMVER =
|
||||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$/;
|
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$/;
|
||||||
|
|
||||||
// Parse a strict semver string → {major, minor, patch}, or null. Rejects ranges/prefixes
|
// Rejects ranges/prefixes (`^1.2.3`, `v1`), leading zeros and missing parts — fail loud over coerce.
|
||||||
// (`^1.2.3`, `v1`), leading zeros, whitespace and missing parts — fail loud over coerce.
|
|
||||||
export function parseSemver(version: unknown): Semver | null {
|
export function parseSemver(version: unknown): Semver | null {
|
||||||
if (typeof version !== "string") return null;
|
if (typeof version !== "string") return null;
|
||||||
const m = SEMVER.exec(version);
|
const m = SEMVER.exec(version);
|
||||||
@@ -147,9 +133,8 @@ export interface VersionCheck {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider/consumer semver check (full table in README.md → Contract versioning): same major+minor → ok,
|
// Provider/consumer semver check (full table in README → Contract versioning). Discovery maps
|
||||||
// plugin minor < host → warn, else (newer minor, major mismatch, malformed) → refuse. Patch is
|
// refuse→throw, warn→log.
|
||||||
// ignored. Discovery maps refuse→throw, warn→log.
|
|
||||||
export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HOST_API_VERSION): VersionCheck {
|
export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HOST_API_VERSION): VersionCheck {
|
||||||
const plugin = parseSemver(pluginVersion);
|
const plugin = parseSemver(pluginVersion);
|
||||||
const host = parseSemver(hostVersion);
|
const host = parseSemver(hostVersion);
|
||||||
@@ -176,9 +161,8 @@ export interface PluginConflict {
|
|||||||
plugins: string[]; // unique ids involved
|
plugins: string[]; // unique ids involved
|
||||||
}
|
}
|
||||||
|
|
||||||
// The conflict rules: defined, loud resolution — never last-write-wins. Pure over the discovered
|
// Loud resolution, never last-write-wins: discovery throws on any "error" and logs every "warn".
|
||||||
// plugins; discovery throws on any "error" and logs every "warn". Mount-path (`/<id>`) uniqueness
|
// Mount-path uniqueness needs no rule of its own — it follows from the id check. Shared permission
|
||||||
// is structural — it follows from the id check, so it needs no rule of its own. Shared permission
|
|
||||||
// names are the one intentional overlap, so they warn rather than error.
|
// names are the one intentional overlap, so they warn rather than error.
|
||||||
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
||||||
const out: PluginConflict[] = [];
|
const out: PluginConflict[] = [];
|
||||||
|
|||||||
@@ -10,11 +10,10 @@ import type { HydraAdmin } from "../auth/hydra-admin.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";
|
||||||
|
|
||||||
// Grouping criterion (keep this cohesive — it's a contract, so the "no catch-all bucket" rule that
|
// Keep this cohesive — it is a contract, so the "no catch-all bucket" rule applies: every field is a
|
||||||
// governs folders governs this bag too): every field is a *privileged, host-owned, wire-dependent*
|
// *privileged, host-owned, wire-dependent* capability for administering Plainpages' own
|
||||||
// capability for administering Plainpages' own identity/permission stack. Add a field only when it
|
// identity/permission stack. Add one only when it meets all three; sub-group rather than pile in
|
||||||
// meets all three; if unrelated privileged concerns accrete (mailer, metrics, flags), sub-group
|
// unrelated privileged concerns (mailer, metrics, flags).
|
||||||
// rather than pile them in flat.
|
|
||||||
export interface SystemCapabilities {
|
export interface SystemCapabilities {
|
||||||
hydra?: HydraAdmin; // OAuth2 client admin (Hydra); present when the Hydra admin client is wired
|
hydra?: HydraAdmin; // OAuth2 client admin (Hydra); present when the Hydra admin client is wired
|
||||||
keto?: KetoClient; // relationship read/write (Keto); present when Keto is wired
|
keto?: KetoClient; // relationship read/write (Keto); present when Keto is wired
|
||||||
|
|||||||
+6
-14
@@ -1,9 +1,6 @@
|
|||||||
// Page chrome for plugin pages: the brand / global-nav / user / theme / csrf block a
|
// The brand / global-nav / user / theme / csrf block a view hands to partials/shell, exposed on
|
||||||
// plugin view hands to partials/shell so its page looks native — the same shell the dashboard and
|
// ctx.chrome. `nav` is the global menu — Dashboard plus every plugin's fragment — run through
|
||||||
// every plugin renders. Pure; the host builds it per plugin request and exposes it on ctx.chrome.
|
// composeNav (override + per-user filter) and current-marked for the request path.
|
||||||
// nav is the global menu — Dashboard + every plugin's fragment (admin screens included, when the
|
|
||||||
// admin plugin is installed) — run through composeNav (override + per-user filter) and
|
|
||||||
// current-marked for the request path.
|
|
||||||
|
|
||||||
import type { User } from "../http/context.ts";
|
import type { User } from "../http/context.ts";
|
||||||
import { ENGLISH } from "../i18n/english.ts";
|
import { ENGLISH } from "../i18n/english.ts";
|
||||||
@@ -13,9 +10,6 @@ import { composeNav, type NavNode } from "./nav.ts";
|
|||||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||||
import { branding, shellUser, type ShellUser } from "./shell-context.ts";
|
import { branding, shellUser, type ShellUser } from "./shell-context.ts";
|
||||||
|
|
||||||
// The "Dashboard" link to the gated app home (/dashboard). It targets a gated route, so it's shown
|
|
||||||
// only to a signed-in user (an anonymous click would only dead-end at /login). Its label is a
|
|
||||||
// catalog key — composeNav translates every label, and an unknown one renders as written.
|
|
||||||
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard" };
|
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard" };
|
||||||
|
|
||||||
export interface PageChrome {
|
export interface PageChrome {
|
||||||
@@ -41,13 +35,11 @@ export interface ChromeOptions {
|
|||||||
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||||
const t = opts.t ?? ENGLISH;
|
const t = opts.t ?? ENGLISH;
|
||||||
const carryLocale = opts.localeHref ?? ((href: string) => href);
|
const carryLocale = opts.localeHref ?? ((href: string) => href);
|
||||||
// The Dashboard link targets the gated /dashboard, so show it only to a signed-in user — to an
|
// Dashboard is gated, so an anonymous click would only dead-end at /login.
|
||||||
// anonymous visitor (a public page in the shell) it would only dead-end at /login. The admin
|
|
||||||
// section, when present, is just another plugin's nav fragment (examples/plugins/admin).
|
|
||||||
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
|
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
|
||||||
// A plugin's nav labels are keys in *its* catalog, so translate each fragment with that plugin's
|
// A plugin's nav labels are keys in *its* catalog, so translate each fragment with that plugin's
|
||||||
// translator before they are merged. composeNav then runs the core one over the result for the
|
// translator before merging. composeNav then runs the core one over the result; already-translated
|
||||||
// built-in nodes and the central override's labels; already-translated text passes through it.
|
// text passes through it.
|
||||||
for (const p of opts.plugins ?? []) {
|
for (const p of opts.plugins ?? []) {
|
||||||
if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t));
|
if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t));
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-7
@@ -1,10 +1,7 @@
|
|||||||
// composeNav: merge each plugin's nav fragment into one tree, apply the central
|
// composeNav: merge each plugin's nav fragment into one tree, apply the central override, then
|
||||||
// override, then permission-filter per user. Pure and I/O-free — menu gating reads the JWT
|
// permission-filter per user. Pure and I/O-free — menu gating reads the JWT `permissions` claim,
|
||||||
// `permissions` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or
|
// never Keto. A node is visible iff it is `public`, declares no `permission`, or the user holds that
|
||||||
// declares no `permission`, or `permissions` includes that permission name; a gated header hides its whole
|
// name; a gated header hides its whole subtree, and a pure header left with no children is dropped.
|
||||||
// subtree, and a pure header left with no children is dropped. The config/menu.ts supplies
|
|
||||||
// the override (+ branding); this helper only transforms data, so its result is per-deployment
|
|
||||||
// up to the final permission filter and emits clean nodes ready for nav-tree.ejs (no id/permission).
|
|
||||||
|
|
||||||
import { ENGLISH } from "../i18n/english.ts";
|
import { ENGLISH } from "../i18n/english.ts";
|
||||||
import type { Translate } from "../i18n/translate.ts";
|
import type { Translate } from "../i18n/translate.ts";
|
||||||
|
|||||||
@@ -4,71 +4,66 @@
|
|||||||
|
|
||||||
- [ ] Add a way to configure plugins directly when installing. Most reasonable is an .env file in the plugin folder, I think, but I am open to suggestions.
|
- [ ] Add a way to configure plugins directly when installing. Most reasonable is an .env file in the plugin folder, I think, but I am open to suggestions.
|
||||||
- [ ] Rename the plugin "admin" to something less generic, like "auth-admin" or "users-groups-admin".
|
- [ ] Rename the plugin "admin" to something less generic, like "auth-admin" or "users-groups-admin".
|
||||||
- [ ] Guard the group paths to self-lockout, or accept them explicitly. The self-revoke guard covers only your own *direct* grants on the Users screen; unticking a permission on a group you belong to, removing yourself from that group, or deleting it can all still strip your own effective access with no warning. Same scope the deleted Permissions screen had, and recorded in AGENTS.md as a known gap — the robust fix is a "last effective holder" check, which needs a reverse Keto query. Raised by the stability review 2026-08-05.
|
- [ ] Guard the group paths against self-lockout, or accept them explicitly. The self-revoke guard covers only your own *direct* grants on the Users screen; unticking a permission on a group you belong to, removing yourself from that group, or deleting it can all still strip your own effective access with no warning. Recorded in AGENTS.md as a known gap — the robust fix is a "last effective holder" check, which needs a reverse Keto query.
|
||||||
- [ ] The permission picker has no concurrency baseline, so two operators editing the same user/group silently discard each other's change (standard lost-update on a set-based form — and the natural "two of us are onboarding the new hire" workflow produces exactly it). Sketch: post the rendered set as a hidden baseline; if it no longer matches Keto, re-render with "this changed while you had the page open" rather than applying. Fits the existing "the form is the whole truth" model instead of fighting it. Raised by the product review 2026-08-05.
|
- [ ] The permission picker has no concurrency baseline, so two operators editing the same user/group silently discard each other's change. Sketch: post the rendered set as a hidden baseline; if it no longer matches Keto, re-render with "this changed while you had the page open" rather than applying.
|
||||||
- [ ] A grant whose plugin was uninstalled is invisible and unremovable in the GUI. `grantDiff` deliberately never revokes an undeclared name (so an unrelated save can't drop it), but nothing *shows* it either — so it can't be audited or cleaned, and reinstalling that plugin silently reactivates access nobody remembers granting. Sketch: a read-only "held, but no installed plugin offers this" list with a remove action. Raised by the product review 2026-08-05.
|
- [ ] A grant whose plugin was uninstalled is invisible and unremovable in the GUI. `grantDiff` deliberately never revokes an undeclared name, but nothing *shows* it either — so it can't be audited or cleaned, and reinstalling that plugin silently reactivates access nobody remembers granting. Sketch: a read-only "held, but no installed plugin offers this" list with a remove action.
|
||||||
- [ ] A plugin may gate a route on a permission it never declares — declaring stays optional on purpose (mandatory declaration would warn on the legitimate cross-plugin sharing case). The cost is a dead end: the picker is built from declarations only, so that route is ungrantable from the GUI with no boot error, no warning, and a permanent 403 as the operator's only clue. Sketch: a discovery *warning* (not an error) naming the gated-but-undeclared permission. Raised by the product review 2026-08-05.
|
- [ ] A plugin may gate a route on a permission it never declares — declaring stays optional on purpose. The cost is a dead end: the picker is built from declarations only, so that route is ungrantable from the GUI with no boot error and a permanent 403 as the operator's only clue. Sketch: a discovery *warning* (not an error) naming the gated-but-undeclared permission.
|
||||||
- [ ] Saving permissions gives no confirmation, and a partial failure is silent. `applyGrants` loops writes then deletes with no transaction, so a Keto error midway leaves a half-applied set behind the generic error page; and a *successful* save is indistinguishable from "nothing changed" (PRG back to the same page, checkboxes as the only feedback). The `alert alert-pos` pattern the recovery-code banner uses is already available. Raised by the product review 2026-08-05.
|
- [ ] Saving permissions gives no confirmation, and a partial failure is silent. `applyGrants` loops writes then deletes with no transaction, so a Keto error midway leaves a half-applied set behind the generic error page; and a successful save is indistinguishable from "nothing changed". The `alert alert-pos` pattern the recovery-code banner uses is already available.
|
||||||
- [ ] Add the read-only operator to README → Overview's personas. `users:read` now makes a support/helpdesk account possible for the first time, and it is a distinct persona from the three listed (end user, non-technical user, plugin author) — the one whose screens must render without write affordances. Writing it down makes read-only rendering a stated requirement rather than something the next reviewer rediscovers. Raised by the product review 2026-08-05.
|
- [ ] The seeded admin@plainpages.local is assigned twice to the same permission; should only be once.
|
||||||
- [ ] The seeded admin@plainpages.local are assigned twice to the permission "admin", should only be one, right? (the "admin" permission name can be switched after previous todos have been done)
|
|
||||||
- [ ] In Playwright tests, try different resolutions and sizes, from BIG desktop down to tiny phone.
|
- [ ] In Playwright tests, try different resolutions and sizes, from BIG desktop down to tiny phone.
|
||||||
- [ ] Decide whether `e2e-tests/` should be typechecked. It is outside `tsconfig.include`, so the gate never checks the most logic-bearing file in it (`console-guard.ts`) — Playwright strips its types without checking them. Including it needs the DOM lib and `@playwright/test` present wherever `npm run typecheck` runs, which today is the `web` image that installs neither. Raised by review 2026-08-05.
|
- [ ] Decide whether `e2e-tests/` should be typechecked. It is outside `tsconfig.include`, so the gate never checks its most logic-bearing file (`console-guard.ts`). Including it needs the DOM lib and `@playwright/test` present wherever `npm run typecheck` runs, which today is the `web` image that installs neither.
|
||||||
- [ ] Decide whether Playwright's `workers` should be pinned. It is unset, so Playwright sizes the pool from `os.cpus()`, which reports the host's cores regardless of a container CPU quota — and with `retries: 0` a starved runner turns a slow test straight into a red gate rather than a retry. Fine on the current act_runner; revisit if CI ever runs constrained. Raised by review 2026-08-05.
|
- [ ] Decide whether Playwright's `workers` should be pinned. Unset, it sizes the pool from `os.cpus()`, which reports the host's cores regardless of a container CPU quota — and with `retries: 0` a starved runner turns a slow test straight into a red gate. Fine on the current act_runner; revisit if CI ever runs constrained.
|
||||||
- [ ] Record the browser floor Plainpages actually requires, and whether the fallback is the contract or a courtesy. The stylesheet already needs `:has()` (Dec 2023); the menus now need the popover API (Safari 17, Sep 2023) and CSS anchor positioning for placement (newer still, and unguarded — the `@supports` test covers popover only). An iPadOS 16 tablet — capped at Safari 16, and exactly the "tablet on a factory floor, old thin client at a reception desk" README → Overview sells the zero-JS stance on — therefore gets panels flowing inline rather than working menus. Either state a supported floor in the README or accept the fallback as the answer for those devices; nobody has rendered that path on real hardware. Raised by the architecture review 2026-08-05.
|
- [ ] Record the browser floor Plainpages requires, and whether the fallback is the contract or a courtesy. The stylesheet needs `:has()` (Dec 2023); the menus need the popover API (Safari 17) and CSS anchor positioning for placement (newer still, and unguarded — the `@supports` test covers popover only). An iPadOS 16 tablet, capped at Safari 16, therefore gets panels flowing inline rather than working menus. Either state a supported floor or accept the fallback for those devices; nobody has rendered that path on real hardware.
|
||||||
- [ ] Decide whether the profile dropdown still earns a dropdown. With the dead Profile link gone it holds one item, Sign out, behind a click — and its "Signed in as X" head only repeats the name and email the trigger already shows. Either put Sign out in the footer directly, or give the menu a second reason to exist. Overlaps the outside-click item above. Raised by review 2026-08-05.
|
- [ ] Decide whether the profile dropdown still earns a dropdown. It holds one item, Sign out, behind a click, and its "Signed in as X" head repeats what the trigger already shows.
|
||||||
- [ ] When copy+paste the verification code from the email, it doesn't work because it does not trim whitechars around the code in the form. It should trim automatically.
|
- [ ] Trim whitespace around the verification code in the form — a copy+pasted code from the email currently fails.
|
||||||
- [ ] Guard against the double-clicked submit, without client-side JavaScript. The README's non-technical persona double-clicks a button that doesn't respond instantly, so a second identical POST is an expected event, not misuse — today it creates two users, mints two recovery codes, or registers two OAuth2 clients. Constraints: HTML/CSS only (no client JS — priority: zero-JS spine), and it must not break an action that is *legitimately* repeatable (an increase-by-one button is not a duplicate, it is two increments). Sketch to evaluate: a CSS-only affordance so the second click has nothing to hit (`:active`/`:focus` state, or the submit visually and semantically settling), paired with the host recognising a duplicate on the server — same session, same route, same payload, within a short window — and then logging it and dropping the second rather than replaying it. Open questions: what identifies "the same submission" (a one-time token minted into each rendered form is stronger than hashing the payload, and the CSRF plumbing already mints per-request tokens), how long the window is, where the record lives given the app is stateless (in-memory like the revoke denylist, or push it to the upstream the plugin already writes to), and how a plugin declares a route as repeatable — an opt-out on the route, or opt-in per form. Raised 2026-08-04 with the personas.
|
- [ ] Guard against the double-clicked submit, without client-side JavaScript. A non-technical user clicks a button twice when nothing happens fast enough, so a second identical POST is an expected event, not misuse — today it creates two users, mints two recovery codes, or registers two OAuth2 clients. Constraints: HTML/CSS only, and it must not break an action that is *legitimately* repeatable. Sketch: a CSS-only affordance so the second click has nothing to hit, paired with the host recognising a duplicate on the server — same session, route and payload within a short window — then logging and dropping it. Open questions: what identifies "the same submission" (a one-time token minted into each rendered form beats hashing the payload, and the CSRF plumbing already mints per-request tokens), the window length, where the record lives given the app is stateless, and how a plugin declares a route repeatable.
|
||||||
- [ ] Decide the caching contract for rendered pages. Responses now carry `Vary: Accept-Language` (they content-negotiate), but nothing sets `Cache-Control` — so a shared cache in front of the app has no instruction, and a signed-in page is not marked `private`. Pre-existing, surfaced by the i18n review 2026-08-03: either set the headers deliberately (public pages cacheable, gated pages `private, no-store`) or record in AGENTS.md that the reverse proxy owns this.
|
- [ ] Decide the caching contract for rendered pages. Responses carry `Vary: Accept-Language` but nothing sets `Cache-Control`, so a shared cache has no instruction and a signed-in page is not marked `private`. Either set the headers deliberately (public cacheable, gated `private, no-store`) or record in AGENTS.md that the reverse proxy owns this.
|
||||||
- [ ] Decide whether the single generic Keto `Resource` namespace should become per-domain namespaces (`Shift`, `Document`, …), as Ory's own examples model it. One global `Resource` bucket is the project's own "no catch-all names" rule (`utils`, `helpers`, `misc`) applied to namespaces. Raised 2026-08-03; a design question, not a naming one.
|
- [ ] Decide whether the single generic Keto `Resource` namespace should become per-domain namespaces (`Shift`, `Document`, …), as Ory's own examples model it. One global `Resource` bucket is the project's own "no catch-all names" rule applied to namespaces. A design question, not a naming one.
|
||||||
- [ ] Decide what `ICON_NAMES` (`src/ui/icons.ts`) actually is. Its comment says "the icons the UI actually references", but `i-chart`, `i-copy`, `i-download` and `i-sliders` have no caller anywhere — so either they go the way `i-gear` just did, or the comment should say the palette is curated and may carry an id ahead of its first use. Not cosmetic: the sprite is inlined into every page, and the rule decides whether a future removal is routine cleanup or a plugin-facing regression (see AGENTS.md → the `ICON_NAMES` deviation). Pre-existing, surfaced by the review 2026-08-05.
|
- [ ] Decide what `ICON_NAMES` (`src/ui/icons.ts`) actually is. `i-chart`, `i-copy`, `i-download` and `i-sliders` have no caller anywhere — so either they go, or the comment should say the palette is curated and may carry an id ahead of its first use. Not cosmetic: the sprite is inlined into every page, and the rule decides whether a future removal is routine cleanup or a plugin-facing regression.
|
||||||
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin (a sibling subdomain, or a plaintext hop with `SECURE_COOKIES=false`) can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md → "Deliberate architectural deviations" and in README → Security model under "Not guaranteed"; not accepted ⇒ bind the nonce to `sub` (small change). Raised by review 2026-08-02; left undecided because it is a maintainer call, and an undocumented exception reads as a bug to the next reviewer.
|
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md and README → Security model; not accepted ⇒ bind the nonce to `sub`.
|
||||||
- [ ] Verify the documented Docker commands on macOS and fix whatever misbehaves — **macOS is a supported dev host** (maintainer, 2026-08-05), but nothing here has been run on one. Two known suspects, both from the `--user "$(id -u):$(id -g)"` idiom the E2E runner and the lockfile edit share: a macOS `id -g` is `20`, which is `dialout` inside the noble image rather than a user group, and Docker Desktop remaps bind-mount ownership in its own VM layer, so "the file belongs to you afterwards" may hold for a different reason or not at all. The same question covers rootless Docker, where README already says to *drop* the flag. Raised by the stability review 2026-08-05.
|
- [ ] Verify the documented Docker commands on macOS and fix whatever misbehaves — **macOS is a supported dev host**, but nothing here has been run on one. Two suspects, both from the `--user "$(id -u):$(id -g)"` idiom: a macOS `id -g` is `20`, which is `dialout` inside the noble image rather than a user group, and Docker Desktop remaps bind-mount ownership in its own VM layer. The same question covers rootless Docker, where README already says to *drop* the flag.
|
||||||
|
|
||||||
### Architectural review findings (2026-07-02)
|
### Architectural review findings (2026-07-02)
|
||||||
|
|
||||||
Prioritized. Overall verdict: architecture is sound (contract-first plugin API, functional core/imperative shell, strong test seams); these are refinements.
|
Prioritized. Overall verdict: architecture is sound; these are refinements.
|
||||||
|
|
||||||
- [ ] **MEDIUM — Add complexity/method-size static analysis to the CI gate.** Only `tsc --strict` today; a size/complexity rule would have caught the `app.ts` growth. Also when wiring CI/CD: keep the merge gate fast (typecheck + units + Ory-free `visual` suite; heavy e2e suites required-but-separate) and make the pipeline the only path to a published image (build once at tag, promote).
|
- [ ] **MEDIUM — Add complexity/method-size static analysis to the CI gate.** Only `tsc --strict` today; a size/complexity rule would have caught the `app.ts` growth.
|
||||||
- [ ] **LOW — The users list offers a pencil "Edit" row action to a `users:read` holder.** The link is harmless (it opens the read-only detail page), but the label contradicts what the reader can do. Needs `canWrite` threaded into `listTable` plus a `common.view` core catalog key and an `i-eye` entry in `ICON_NAMES` — a core registry change for a cosmetic fix, so it was left out of the permission-naming branch. Raised by the stability review 2026-08-05.
|
- [ ] **LOW — The users list offers a pencil "Edit" row action to a `users:read` holder.** The link is harmless (it opens the read-only detail page), but the label contradicts what the reader can do. Needs `canWrite` threaded into `listTable` plus a `common.view` core catalog key and an `i-eye` entry in `ICON_NAMES`.
|
||||||
- [ ] **MEDIUM→LOW — Add a list-page view-model helper in `src/ui/`.** Every list screen (users, groups, clients, shifts) hand-rewrites the same ~40 lines bridging `parseListQuery`/`paginate` to the EJS partials; at minimum a `buildPaginationModel(page, hrefFor)` block.
|
- [ ] **MEDIUM→LOW — Add a list-page view-model helper in `src/ui/`.** Every list screen hand-rewrites the same ~40 lines bridging `parseListQuery`/`paginate` to the EJS partials; at minimum a `buildPaginationModel(page, hrefFor)` block.
|
||||||
- [ ] **LOW→MEDIUM — Retire `src/ui/shell-context.ts`.** `ShellModel`/`buildShellContext` has one consumer left (dashboard) and duplicates `PageChrome` on almost every field, incl. identical brand-assembly in `chrome.ts` and `shell-context.ts`. Fold the dashboard onto `ctx.chrome` + title/breadcrumbs; keep `shellUser` as the shared primitive.
|
- [ ] **LOW→MEDIUM — Retire `src/ui/shell-context.ts`.** `ShellModel`/`buildShellContext` has one consumer left (dashboard) and duplicates `PageChrome` on almost every field. Fold the dashboard onto `ctx.chrome` + title/breadcrumbs; keep `shellUser` as the shared primitive.
|
||||||
- [ ] **LOW — Fix stale doc references to removed `docs/plugin-contract.md`** in `views/index.ejs` (user-visible dashboard text; also links /scheduling as if pre-installed) and `examples/plugins/scheduling/views/shifts.ejs`.
|
- [ ] **LOW — Fix stale doc references to removed `docs/plugin-contract.md`** in `views/index.ejs` (user-visible dashboard text; also links /scheduling as if pre-installed) and `examples/plugins/scheduling/views/shifts.ejs`.
|
||||||
- [ ] **LOW — Decide (once) on a `ctx.system` facade.** `#plugin-api` exposes raw Ory client shapes, so an Ory client refactor is a major `apiVersion` bump. AGENTS.md accepts this; revisit only if external plugin authors appear. Record the decision.
|
- [ ] **LOW — Decide (once) on a `ctx.system` facade.** `#plugin-api` exposes raw Ory client shapes, so an Ory client refactor is a major `apiVersion` bump. AGENTS.md accepts this; revisit only if external plugin authors appear.
|
||||||
- [ ] **LOW — README/AGENTS.md gaps:** state the intended lifetime/horizon explicitly, add a short domain glossary (host, manifest, chrome, nav fragment, permission token, system plugin, denylist…), and note the expected plugin-author population (first-party vs external) to justify the versioning machinery.
|
- [ ] **LOW — README/AGENTS.md gaps:** state the intended lifetime/horizon explicitly, add a short domain glossary (host, manifest, chrome, nav fragment, permission token, system plugin, denylist…), and note the expected plugin-author population.
|
||||||
|
|
||||||
## Finnished work
|
## Finnished work
|
||||||
|
|
||||||
- [x] `e2e-tests/artifacts/` is written `root:root` into the checkout and needs `sudo` to delete — the Playwright container runs as root. Unlike the node_modules mountpoint, a container `user:` would fix it, but compose has no `$UID` of its own (needs an `.env` or `id -u` via `ci.sh`) and CI's artifact upload reads that dir. Found 2026-08-05. (Fixed with the idiom README already uses for a lockfile edit — every documented invocation passes `--user "$(id -u):$(id -g)"`: the five compose headers, the five README blocks and `ci.sh`'s two runs — so the runner writes as whoever started it, on a dev box and on a CI runner alike, and compose never needs a `$UID` of its own. Two things had to come with it. `e2e-tests/artifacts/` is now *tracked* (`.gitkeep`), because an absent bind-mount source is created by the daemon as root and an unprivileged runner then cannot write into it at all — the same trap the node_modules mountpoint hit, one layer up. And the runner image sets `HOME=/tmp` + `npm_config_cache=/tmp/.npm`, since an arbitrary uid has no home in the Playwright image. Baking a `USER` into the image was tried first and dropped: `pwuser` is **1001** in the noble image (uid 1000 is `ubuntu`), so it `EACCES`'d on a 1000-owned checkout, and no fixed uid can match every host. One premise turned out stale — no workflow uploads artifacts, so nothing in CI reads that dir. Verified by running the visual suite as uid 1000: 36 tests green across Chromium, Firefox and WebKit, every file written `lilleman:lilleman` and deletable without `sudo`, which this box does not even have. `src/compose.test.ts` guards every documented command plus the tracked mount point; `src/ci-gate.test.ts` guards the gate's own two.)
|
- [x] Run the E2E runner as the invoking user so its artifacts aren't root-owned.
|
||||||
- [x] node_modules gets installed straight to the root folder with root permissions, it should at the very least be same owner as the one running the docker process, or built inside the docker image. (It *was* built in the image; the checkout got an empty root-owned dir — the mountpoint for `compose.override.yml`'s `- /app/node_modules` volume. Re-owning it is impossible (the daemon creates mount destinations as root whatever `--user` says), so deps moved to `/node_modules` above `WORKDIR /app` and the volume is gone. See AGENTS.md.)
|
- [x] Install node_modules above `WORKDIR /app` so no mount leaves a root-owned dir in the checkout.
|
||||||
- [x] Document permissions format so it is folled going forward: <resource>:<action>, for example scheduling:read. Permission "admin" does not match this, and needs to be users:read, users:write, groups:read, groups:write. (README → [Naming a permission](README.md#naming-a-permission) is the one home for the rule, and the host *enforces* it at discovery — `isValidPermissionName` in `src/plugin-host/plugin.ts`, checked by `shapeError` over every route/nav `permission` and every declared name — so a badly-named permission stops the boot like any other bad manifest, for every plugin rather than only ones the admin GUI touches. `admin` is gone, split per screen into `users:`, `groups:` and `oauth2-clients:` × `read`/`write`. The read/write split is real, not cosmetic: `users:read` opens the list and is refused on every POST, and the Admin nav header lost its own gate so each screen is filtered by its own `:read` — hold none of the three and `composeNav` drops the emptied header (which needs the header to carry no `href`, now asserted). Two things had to be fixed to get here. The permission path validator was the *group* regex with no colon, so `/admin/permissions/scheduling:read` already 404'd. And `ADMIN_PERMISSIONS` defaulting to empty exposed that `bootstrap` never bind-mounted `plugins/` at all — it discovered only the image's empty copy, so a dropped-in plugin's permissions were never seeded; the mount lives in `compose.override.yml` (dev-only, mirroring `web`'s `.:/app`) because the base file gives both services the same baked copy and a base-file mount would collide with the e2e stacks that bind plugins *inside* that path. Quick start now says `docker compose up -d`, which re-runs the one-shot. Verified end to end on a live stack.)
|
- [x] Enforce `<resource>:<action>` permission names at discovery; split `admin` per screen.
|
||||||
- [x] Permissions should be a list in code. Since no permissions exists in the database out of the box, but there are a fixed number of permissions in the plugins that the end consumer and user of plain pages can use, these permissions must surface to the UI somehow. The effects is that the permissions page should be deleted completely, and the users and groups pages should gain the functionality to add permissions to their things instead, provided the user have the right permissiosn to do so, of course. Run the product reviewer agent on this todo also. (The host collects every installed plugin's declarations into one catalog — `declaredPermissions()` → `ctx.declaredPermissions`, deduped and sorted, computed once at wiring — and that catalog *is* the fixed list. The Permissions screen is deleted outright: its module, tests, three views, two partials and 29 catalog keys per locale. Users and Groups each gained a checkbox list of the catalog, ticked where held; the whole set posts back, so what is submitted is the desired state and `grantDiff` turns it into grants + revokes. Two properties earn their tests: a crafted POST cannot grant a name no plugin declares, and a held-but-undeclared name — left over from an uninstalled plugin — is never silently revoked by an unrelated save, since the picker only speaks for what it showed. A user's own change revokes their live tokens; a group's reaches members at their next re-mint, the documented transitive lag. Keto stays optional on the Users screen: without it the page still lists and edits, minus the picker. Maintainer's call 2026-08-05 to keep the OAuth2-clients screen and gate it `oauth2-clients:read/write` — permissions and OAuth2 are orthogonal, scopes say what an *app* may see and permissions what a *user* may do, so the screen only ever needed *a* gate.)
|
- [x] Make the declared-permission catalog the fixed list; delete the Permissions screen, move granting onto Users and Groups.
|
||||||
- [x] In Playwright tests, check for warnings and errors in all browsers on all the steps. If they exist, that is a failure we need to fix. (Every spec takes its `test` from `e2e-tests/console-guard.ts`, which watches every page a test opens — `console.error`, `console.warning`, and uncaught page errors — and fails the test that provoked one, at whatever step. The bar is zero rather than a curated tolerance list: the app ships no client JavaScript, so a message means a broken sub-resource, a rejected attribute, or an engine refusing a feature. Two narrow escapes, both explicit: the COOP header Chromium drops because the e2e stacks serve plain http over container hostnames (a deployment serves https, where it applies), and per-test `allowConsole(/…/)` — used once, by the 404 spec, whose own navigation Chromium and WebKit log. **All browsers** is now literal for the Ory-free suites: `visual.spec.ts` + `language.spec.ts` run in Chromium, Firefox *and* WebKit — the per-test `@engines` tag is gone, and screenshots are written per project so the three don't fight over one file — which is what makes an engine-specific message visible at all. The Ory-backed suites write users, groups and sessions to one shared backend, so they stay on Chromium; widening them needs a stack per engine. Nothing in the app had to be fixed: the sweep found only the two above. Verified by negative control — an injected `console.warn` failed the test in all three engines and an injected `console.error` failed on full-flow's shared serial page — which also caught the guard registering that page twice. `src/e2e-console-guard.test.ts` locks the wiring in the *unit* gate, since a spec importing `test` straight from Playwright would run unwatched and green.)
|
- [x] Fail a Playwright test on any browser console warning, error or uncaught exception, in every engine.
|
||||||
- [x] Don't run tests when only markdown files in the root have changed. (Already shipped for *any* `*.md`, anywhere in the tree — `ci.sh`'s `docs_only()` no-ops the gate when every path changed since `main` ends in `.md`, and the workflow still pushes the commit-hash image so a merged docs commit stays releasable. Kept wider than "in the root" deliberately: no test reads a markdown file, so a nested `examples/plugins/admin/README.md` edit is as safe to skip as `README.md`, and narrowing it would spend the full gate on one. What was actually broken was rename detection — `git mv src/app.ts notes.md` names only the destination under `git diff --name-only`, and collapses to a single `R src/app.ts -> notes.md` line under `git status --porcelain`, so **moving code onto a `.md` path skipped the gate over a source file that was gone**. Both channels now pass `--no-renames`; verified against a scratch repo across ten scenarios — docs-only, mixed, empty diff, dirty tree, untracked code, deleted doc, and the rename staged *and* committed — the last two failing before the fix and passing after. `src/ci-gate.test.ts` locks both flags; it stays a text guard because the test image is `node:alpine` with neither `git` nor `bash`.)
|
- [x] Skip the CI gate when only markdown changed.
|
||||||
- [x] The little menues, like when choosing language or clicking my username, they do not dissapear when clicking outside them, I must click the original trigger or choose something. See if there are more modern ways of handling this with HTML and CSS. I think there is a modal-thing or something? (The modern thing is the **Popover API**. All three popup menus — language picker, profile, row kebab — are now a `<button popovertarget>` plus a `[popover]` panel instead of `<details>`/`<summary>`, so the browser owns open/close: clicking anywhere outside dismisses one, `Esc` dismisses it and returns focus to the trigger, opening one closes the others, and the panel sits in the top layer where `.table-wrap`'s `overflow` can no longer clip a row kebab. Placement is CSS anchor positioning; the panel needs `position-anchor: auto` to bind to the button that opened it — a bare `anchor()` resolves to nothing in Chromium, Firefox and WebKit alike, measured in all three before picking the approach. `data-table.ejs` stopped hand-rolling its kebab and calls the `menu` partial, so the pattern lives in one file. Each panel is named by its caller (`locale-menu`, `profile-menu`, `row-actions-1`) and the partial fails loud without an `id`, since `popovertarget` is an idref — generated ids were tried first and dropped for being unreadable and nondeterministic. `<details>` stays in the nav tree, where it means disclosure rather than popup. A browser older than the popover API flows each panel inline under its trigger, so Sign out is never stranded behind an inert button. `e2e-tests/visual.spec.ts` drives the whole behaviour — opens, anchored to its trigger, outside-click, Esc — and runs in Firefox and WebKit as well as Chromium, because CSS anchor positioning is the newest thing in the app and every popup rests on it. Decisions recorded in AGENTS.md.)
|
- [x] Replace the `<details>` popup menus with the Popover API so an outside click dismisses them.
|
||||||
- [x] Organize the files in src in to folders so it is easier to understand the structure of the code.
|
- [x] Organize the files in src into folders.
|
||||||
- [x] Move docs/plugin-contract.md into README.md and remove the docs folder.
|
- [x] Move docs/plugin-contract.md into README.md and remove the docs folder.
|
||||||
- [x] The plugins/scheduling is an example and shouldn't be committed to the plugins directory since that should be empty to be able to be mounted in via docker or other means for the users/develoeprs using this application/framework. Put it in the examples folder instead.
|
- [x] Move the scheduling example out of `plugins/` into `examples/`.
|
||||||
- [x] The config folder should be empty and the current settings in the menu.ts should be the fallback default. IF a menu.ts where to appear in that folder, it should override the default settings with whatever is in it. The idea is the folder should be empty by default and you mount it in your docker container with your config.
|
- [x] Make `config/` an empty drop-in mount with the defaults as fallback.
|
||||||
- [x] Make the internal admin pages for users groups etc into a plugin instead in the examples folder and remove them from the internal source. Add a part in the quick start about copying this plugin into the plugins folder to enable GUI user- and group admining.
|
- [x] Turn the built-in admin pages into a drop-in example plugin.
|
||||||
- [x] CI/CD - Test on push to any branch except main. (`.gitea/workflows/ci.yml` runs `bash ci.sh`; the one-time act_runner setup it needs is documented in README → CI/CD.)
|
- [x] CI/CD — test on push to any branch except main.
|
||||||
- [x] CI/CD - Require PR to main and don't allow merge if tests does not pass. Only allow linear history and history that leaves the last commit hash on main the exact same as on the branch we just merged in. (Gitea branch protection on main + fast-forward-only merge style, set via API; documented in README → CI/CD.)
|
- [x] CI/CD — require a PR to main, gated on a green build, fast-forward-only.
|
||||||
- [x] CI/CD - Sync up to github after every successful merge to main, URL: git@github.com:larvit/plainpages.git - also note the true home top of the README. Force push to github, it should only ever be a mirror of the gitea.larvit.se repository. (`.gitea/workflows/mirror.yml` force-pushes main + tags over HTTPS with a dedicated account's PAT in the `MIRROR_GITHUB_TOKEN` secret; setup documented in README → CI/CD.)
|
- [x] CI/CD — force-push mirror to GitHub after every merge to main.
|
||||||
- [x] CI/CD - Build docker images as part of the requirements to be able to merge to main. Push them with the git commit hash as docker tag. Push to container registry at Gitea. (`ci.yml` builds + pushes `gitea.larvit.se/larvit/plainpages:<commit hash>` after a green gate — with ff-only merges that is the main commit's image; auth via the `DOCKER_REGISTRY_USER` variable + `DOCKER_REGISTRY_TOKEN` secret, retention via an org cleanup rule; documented in README → CI/CD.)
|
- [x] CI/CD — build and push the app image, tagged with the commit hash, as part of the gate.
|
||||||
- [x] CI/CD - Re-tag docker images from git hash to semver when a semver git tag is pushed. (`release.yml` on a `vX.Y.Z` tag pulls the commit-hash image and re-tags it `X.Y.Z`/`X.Y`/`X`/`latest`, failing loud if the gated image is missing; tag pushes also trigger the GitHub mirror; documented in README → CI/CD.)
|
- [x] CI/CD — re-tag the hash image to semver on a `vX.Y.Z` tag.
|
||||||
- [x] CI/CD - Sync docker images to docker hub after each re-tag to git tags. (`release.yml` pushes the same `X.Y.Z`/`X.Y`/`X`/`latest` tags to `docker.io/larvit/plainpages` after the Gitea re-tag — releases only, no hash tags; auth via the `DOCKERHUB_USER` variable + `DOCKERHUB_TOKEN` secret; documented in README → CI/CD.)
|
- [x] CI/CD — sync released tags to Docker Hub.
|
||||||
- [x] Write a short text on how to use this docker image to publish on docker hub and save it to README-dockerhub.md (tagline, tags, clone-free quick start — the image ships the Ory config, extracted via `docker run … tar` + a self-contained compose.yml — env table, first plugin; pasted into the Docker Hub overview by hand — noted in README → CI/CD.)
|
- [x] Write README-dockerhub.md for the Docker Hub overview.
|
||||||
- [x] CI/CD - Setup renovate bot. Check how other repos on this Gitea is setup you can get access to, there should be a number of renovate bot activated ones. (`renovate.yml` runs the self-hosted `renovate/renovate` image nightly against `renovate.json` — this repo only, via the shared `renovate@larvit.se` bot + `RENOVATE_TOKEN` secret, mirroring the `pwrpln/core` pattern; standard managers cover npm/Dockerfiles/compose/gitea-action pins, two custom regex managers cover the image tags embedded in workflow `run:` steps, the Ory + Playwright lockstep sets are grouped, every bump stays an exact pin, and each PR automerges once the gate is green; documented in README → CI/CD.)
|
- [x] CI/CD — set up the Renovate bot.
|
||||||
- [x] CI/CD - Renovate: set a read-only `GITHUB_COM_TOKEN` env in `renovate.yml` so Renovate stops hitting github.com rate limits when resolving github-hosted deps (Playwright, lucide, `actions/checkout`) and can fetch changelogs. Non-blocking refinement; needs a read-only GitHub PAT stored as an Actions secret. (The renovate job forwards the `RENOVATE_GITHUB_TOKEN` secret — a scopeless read-only github.com PAT; Gitea rejects `GITHUB_`-prefixed secret names — into the container as `GITHUB_COM_TOKEN`; documented in README → CI/CD.)
|
- [x] CI/CD — give Renovate a read-only `GITHUB_COM_TOKEN` so github.com lookups aren't rate-limited.
|
||||||
- [x] CI/CD - When renovate updates a dependency - also release a new version of plainpages based on what got updated with Renovate. Major typescript? New apiVersion + new major. A tiny patch to ejs? Only patch release etc. Before implementing, explain in detail how you will solve this. (`renovate.yml` gains an `auto-release` job (`needs: renovate`) that cuts one `vX.Y.Z` tag per run for what Renovate merged; level = highest `Release-Bump:` trailer Renovate stamps via `commitBody`, any dep's major/minor/patch mapped straight through (default patch). Decoupled from `apiVersion` (tag-only, `HOST_API_VERSION` untouched — a "major" is just a bigger image tag, never a plugin break); pre-1.0 shifts down so nothing auto-crosses into 1.0.0. Pure `auto-release/next-version.ts` + unit tests; tag pushed with renovate-bot's PAT so `release.yml` fires; documented in README → CI/CD.)
|
- [x] CI/CD — auto-release on Renovate updates, versioned from the `Release-Bump:` trailer.
|
||||||
- [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser. (compose.full.yml now includes Hydra (`serve all --dev`) and full-flow.spec.ts drives /admin/clients register → one-time secret → list → detail → delete in the browser; documented in README → Testing.)
|
- [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen.
|
||||||
- [x] Build and publish docker image as CI/CD. (Duplicate of the CI/CD items above: `ci.yml` builds and pushes `gitea.larvit.se/larvit/plainpages:<commit hash>` behind the green gate, `release.yml` re-tags it to semver and syncs those tags to Docker Hub.)
|
- [x] Document the auth security model in the README.
|
||||||
- [x] The human developer understands the security model in the auth in this project. (Two README sections. [Users, groups & permissions](README.md#users-groups--permissions) carries the weight: the entity model, a worked graph, a per-route can/cannot walkthrough, and the trap that a per-row grant never widens a coarse gate — placed before Building plugins because a manifest's `permission:` gate is unreadable without it. [Security model](README.md#security-model) is deliberately short, only the facts a deployment gets wrong without them: the private network as the *only* guard on the Ory APIs, signed-not-encrypted claims, the 30-day Kratos session behind the ~10m JWT, and non-instant offboarding. The first attempt answered the *threat* model instead — a 12-row attack/defense table — which was the wrong question and mostly restated code readable at its source; cut. Also corrected the hardening checklist: `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres/demo-admin secrets are now listed in "What you must supply". The mandatory-`exp` guard gained a test in `src/auth/jwt-middleware.test.ts`.)
|
- [x] Add i18n support.
|
||||||
- [x] Add i18n support. (Catalogs are TS modules per locale — `src/i18n/locales/<tag>.ts` for the host, `plugins/<id>/i18n/<tag>.ts` for a plugin, looked up plugin-first then core; en-US + sv-SE ship. A request is served by `?locale=sv-SE` → `Accept-Language` → `en-US`, exact on a full tag but a lone language takes the first regional catalog; no cookie — when the URL asked, the host carries `?locale` onto the links it renders and `ctx.localeHref()` does it for a plugin's. `ctx.t(key, vars)` plus `t`/`locale`/`locales`/`localeHref`/`dir` merged into every view (any include depth); `{{var}}` interpolation, plurals via `Intl.PluralRules`, an unknown key renders as itself — which is what makes a nav label either a key or plain text. Every catalog is checked against its set's en-US at boot (keys, kind, plural categories) and a mismatch stops startup. Kratos' own flow text is mapped by its numeric id (only ids verified against the live stack; its generic trait-label id is deliberately unmapped, field labels key on the input name instead). Zero-JS language picker in the shell + the auth/consent pages, `<html lang dir>` from the locale. Core, both example plugins and their views translated; unit tests + `e2e-tests/language.spec.ts` in the visual gate; documented in README → Languages, decisions in AGENTS.md.)
|
- [x] Settle the identity-vs-user vocabulary.
|
||||||
- [x] Settle the identity-vs-user vocabulary. (Plainpages says **user** everywhere — Keto namespace `User`, subjects `user:<kratos-id>`, `ctx.user`. Ory calls the record an "identity", but its own docs say it uses that term interchangeably with "users"/"accounts", so this is house style rather than a renamed concept, and "user" is the word readers know (Nielsen heuristic #2). README → Auth carries one note recording the mapping; the only place Ory's spelling survives is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors the Kratos wire shape. Recorded in AGENTS.md.)
|
- [x] Use one uniform verb per action in the English UI (sign in / sign out / create account).
|
||||||
- [x] On the first page there is a button saying "Log in" and in the bottom left corner another button says "Sign in". Use a uniform language. (English now says **sign in / sign out / create account** everywhere; Swedish was already uniform. Three outliers went: the landing's `landing.signIn` "Log in", the registration submit `kratos.1040001` "Sign up" — under a "Create account" heading, and sv-SE already said "Skapa konto" — and `oauth.logoutExpired`'s "This logout request", whose sign-in twin said "sign-in request". The first-run banner says "sign in at" too, and the admin example's email hint says "the sign-in identifier". The rule is recorded in AGENTS.md → Rules and held by the author: a unit test asserting the verb shipped first and was dropped on the maintainer's call, since a build that fails on a word removes the judgment a growing UI needs. The two e2e specs that clicked "Log in" now scope to `#main-content`, since the anonymous sidebar carries a "Sign in" link of its own.)
|
- [x] Remove the dead "Profile" link from the sidebar profile menu.
|
||||||
- [x] When logged in, there is a "profile" link in the little box when I've clicked my username in the bottom left corner. There is no profile, so the link is dead. Remove it. (The `<button type="button">` in the sidebar's profile menu had no handler and — zero-JS spine — could never get one; gone from `views/partials/shell.ejs` along with the `shell.profile` key in both locales. Sign out is now the menu's only item; the profile block itself (avatar, name, email) is the summary and stays. `src/ui/shell.test.ts` asserts the menu holds no dead `type="button"`, and `e2e-tests/full-flow.spec.ts` asserts Sign out is the only item once the dropdown is open.)
|
- [x] Remove the unspecified "Settings"/"Preferences" cog from the sidebar footer.
|
||||||
- [x] There is a "Settings" in the bottom left (a little cog) showing a "Preferences" in a little menu when clicked. That is not in any spec, it exists when not even logged in and erh. Just remove. (Dropped from the sidebar footer in `views/partials/shell.ejs`, which now carries the profile menu — or Sign in when anonymous — plus the language picker. The `shell.settings`/`shell.preferences` catalog keys went with it in both locales, as did the then-unreferenced `i-gear` icon: `ICON_NAMES` is by definition the icons the UI references, so `views/partials/icons.ejs` was regenerated from it. Kratos' own `/settings` account flow is a different thing and is untouched. Covered by `src/ui/shell.test.ts` signed-in and anonymous, plus the public-landing case in `e2e-tests/visual.spec.ts`.)
|
- [x] **HIGH — Split `handleRequest` in `src/http/app.ts`** — extract the built-in endpoints into named handlers on an internal route table.
|
||||||
|
|
||||||
### Architectural review findings (2026-07-02)
|
|
||||||
|
|
||||||
- [x] **HIGH — Split `handleRequest` in `src/http/app.ts` (~380 lines).** It mixes the request pipeline with inline implementations of ~10 built-in endpoints (Kratos flows, /oauth2/*, /auth/complete, /logout, /, /dashboard, 404/405). Extract each endpoint into a named handler (auth/OAuth2 group → `src/auth/` route module) with the same `(req, res, ctx)` shape plugin routes use; reduce `handleRequest` to pipeline → internal route table → `sendResult`.
|
|
||||||
|
|||||||
Reference in New Issue
Block a user