This commit is contained in:
@@ -69,8 +69,8 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
# Cleanup, and the runner's Docker config is shared (AGENTS.md) — a lost race here must not
|
# Cleanup, and the runner's Docker config is shared (docs/decisions.md) — a lost race here
|
||||||
# fail a release that published, nor skip the overview job that follows.
|
# must not fail a release that published, nor skip the overview job that follows.
|
||||||
docker logout gitea.larvit.se || true
|
docker logout gitea.larvit.se || true
|
||||||
docker logout docker.io || true
|
docker logout docker.io || true
|
||||||
|
|
||||||
|
|||||||
@@ -67,288 +67,58 @@ branch, create a PR and merge it when the CI/CD turns green.
|
|||||||
**fails loud at boot/discovery** rather than sandboxing at runtime. Runtime crash-isolation is a
|
**fails loud at boot/discovery** rather than sandboxing at runtime. Runtime crash-isolation is a
|
||||||
deliberate **non-goal**.
|
deliberate **non-goal**.
|
||||||
|
|
||||||
## Deliberate architectural deviations (don't re-flag)
|
## Decisions
|
||||||
|
|
||||||
Intentional, reasoned choices — an architecture review should honor them, not re-raise them.
|
In `docs/decisions.md`, each with its reasoning and the premise it rests on. A review honours them
|
||||||
Revisit only if the stated reason stops holding.
|
rather than re-raising them.
|
||||||
|
|
||||||
### Structure & contracts
|
- `src/` is grouped by concern, and the core ships no domain screens
|
||||||
|
- Plugins and config import the host only through the two barrels
|
||||||
- **`src/` is grouped by concern**, not flat — `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`,
|
- The Ory client types are contract-visible through the barrel
|
||||||
and `test-tooling/` (modules only tests import), with `server.ts`/`config.ts`/`logger.ts` and
|
- The barrel is a package, so a plugin folder may carry its own `package.json`
|
||||||
the topology-guard `*.test.ts` at the root; tests are co-located. Add a new module to the folder owning its concern. The core ships **no domain
|
- Plugin storage hands over credentials, not a client
|
||||||
screens** — even the admin GUI is a drop-in plugin (`examples/plugins/admin/`).
|
- Plugin settings are declared, not discovered
|
||||||
- **Plugins and config import the host only through a barrel** — `@plainpages/plugin-api` →
|
- The trust boundary is the `web` process, not the plugin
|
||||||
`plugin-api/index.ts` → `src/plugin-host/plugin-api.ts`, `#menu-config` → `src/ui/menu-config.ts`,
|
- `ory/postgres/init/init.sql` is the only home for the Ory databases' ACL
|
||||||
never a relative `../../src/*` path. These two barrels are the whole contract surface; don't "fix"
|
- `bootstrap.ts` stays under `src/auth/`
|
||||||
either back to a relative path. Three consequences:
|
- `BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook
|
||||||
- `@plainpages/plugin-api` re-exports the Ory client types (`KratosAdmin`/`KetoClient`/`HydraAdmin` + their
|
- `config/` is a plain dir with no `package.json` of its own
|
||||||
DTOs and error classes), so those shapes are **contract-visible** — changing them needs a major
|
- A plugin `package.json` without `"type": "module"` is refused, not warned
|
||||||
`apiVersion` bump, not a free refactor.
|
- `examples/` mirrors the drop-in mount dirs
|
||||||
- **The barrel is a package, not a `#`-import, so a plugin folder may carry its own
|
- `ctx.chrome` is lazily memoized, never built unconditionally
|
||||||
`package.json`** and depend on npm packages (README → Plugin dependencies). The Dockerfile links
|
- A plugin-owned render always runs on that plugin's context
|
||||||
it into `/node_modules`, above every plugin scope. Never let a copy reach a plugin's own
|
- Email is delegated to Kratos
|
||||||
`node_modules`: two instances of the barrel break `instanceof` across the boundary, which
|
- The vocabulary is `User` → `Group` → `Permission`, and there is no `Role`
|
||||||
`plugin-api.test.ts` guards by asserting both paths reach one module.
|
- A permission name is always `<resource>:<action>`, enforced at discovery
|
||||||
- **Plugin storage hands over credentials, not a client** (README → Plugin storage). The host takes
|
- Permission names are authored in plugin code; only grants live in Keto
|
||||||
`postgres` to run the provisioning DDL, and `storage-provisioning.ts` is the only module importing
|
- A permission's `<resource>` is global, not plugin-scoped
|
||||||
it — `storage.ts` beside it stays pure so `web` never loads a driver (`src/postgres.test.ts` guards
|
- Declaring a permission stays optional
|
||||||
both halves, because one value imported from the wrong module breaks it invisibly). It is never
|
- `ADMIN_PERMISSIONS` defaults to empty, and an unusable value is dropped with a warning
|
||||||
re-exported through the barrel, so no driver shape enters the contract. Three properties hold the design together, so
|
- `actionForMethod` is plugin-local and stays out of `@plainpages/plugin-api`
|
||||||
don't trade one away in isolation: passwords are `HMAC-SHA256(PLUGIN_DB_SECRET, id)` rather than
|
- A gate is one of `public`, `session` or `permission`, named exactly once
|
||||||
stored, which is what keeps the host stateless — whoever holds that secret holds every plugin
|
- The reference plugin's two shift pages duplicate a view model and markup
|
||||||
database, so it ranks with the DB password itself; the provisioning DSN reaches `bootstrap` only
|
- A `:read`-only holder is never shown a write affordance
|
||||||
(`src/compose.test.ts` guards the split); and provisioning never drops anything, so uninstalling a
|
- `users:write` and `groups:write` each amount to full administrative access
|
||||||
plugin cannot destroy data — boot logs the orphans instead. Because the host's copy sits in the
|
- The CSRF token is unbound to the session
|
||||||
ambient `/node_modules`, a plugin can `import "postgres"` without declaring it — incidental, not a
|
- Plainpages says "user" everywhere, where Ory says "identity"
|
||||||
packaging promise, and a plugin must still depend on its own driver.
|
- The locale lives in the URL, never in a cookie
|
||||||
- **Plugin settings are declared, not discovered** (README → Plugin settings). `settings.ts` is pure and
|
- The core building blocks carry the locale, so a plugin doesn't have to
|
||||||
takes the env as an argument, so the whole matrix unit-tests without a stack. Four rules carry the
|
- `locale` and the i18n view locals are reserved by the host
|
||||||
design: the prefix is `PLUGIN_SETTING_`, never bare `PLUGIN_`, because a plugin id `db` with key
|
- Catalogs are checked at boot, not at render
|
||||||
`url` would otherwise name the host's own `PLUGIN_DB_URL`; keys are camelCase so the
|
- `locales/` at the repo root is a drop-in mount
|
||||||
`camelCase → SNAKE_CASE` mapping is total and no two keys collide, with the residual cross-plugin
|
- The language picker is on every page, POST-rendered ones included
|
||||||
collision caught by `findConflicts` (`plugin-host/conflicts.ts`); `required` and `default` are mutually exclusive, which is what
|
- An unknown translation key renders as itself
|
||||||
lets `SettingsOf` type a declared key as present rather than `T | undefined`, so no plugin author
|
- `t()` returns raw text, and the view escapes it
|
||||||
casts; and a secret's value reaches the plugin but never a log, an error or `ctx.declaredSettings`
|
- RTL is out of scope until there is a real use case
|
||||||
— not even as a mask or a length. An author mistake is refused at discovery, a bad operator value
|
- A dropdown is a `<button popovertarget>` and a `[popover]`, never a `<details>`
|
||||||
refuses the boot, and a stray `PLUGIN_SETTING_` variable only warns (the orphan-database precedent).
|
- The document is the one scroller
|
||||||
- **The trust boundary is the `web` process, not the plugin.** Per-plugin databases and roles bound
|
- `ICON_NAMES` is a host-owned registry, not a plugin contract
|
||||||
*accidents*, not hostile plugins: `PLUGIN_DB_SECRET` is in `web`'s environment during `onBoot`, and
|
- Dependencies install to `/node_modules`, above `WORKDIR /app`
|
||||||
a plugin already holds `ctx.system`'s Ory admin clients — so cross-plugin DB isolation is
|
- A container whose output a human then edits or deletes runs as the invoking user
|
||||||
containment, and README says so rather than implying a sandbox. Consistent with priority #7
|
- Anything the browser logs fails the E2E test that provoked it
|
||||||
(crash-isolation is a non-goal). `server.ts` still deletes the secret from `process.env` right
|
- The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium
|
||||||
after `loadConfig`, which is before discovery imports any plugin module — the ordering is the whole
|
- The docs-only CI skip is `*.md` anywhere in the tree
|
||||||
point, so move it earlier if anything, **never later**. **Valid while plugins are
|
- CI docker logins share the runner host's Docker config
|
||||||
operator-installed code, not third-party uploads.**
|
|
||||||
- **`ory/postgres/init/init.sql` is the only home for the Ory databases' ACL** — don't re-assert the
|
|
||||||
`REVOKE CONNECT` from `bootstrap`. `REVOKE` only *warns* when the caller doesn't own the database,
|
|
||||||
so under the least-privilege provisioning account the README recommends it would report success
|
|
||||||
while changing nothing, and it hard-fails whenever `PLUGIN_DB_ADMIN_URL` names a server with no
|
|
||||||
`kratos`. It runs only on **first init**, so a revoke added to it later never reaches a volume that
|
|
||||||
already exists — `docker compose down -v` is the dev remedy, a deployed install needs a migration.
|
|
||||||
- **`bootstrap.ts` stays under `src/auth/`** even though it now provisions plugin databases as well
|
|
||||||
as seeding Ory. It is the one-shot service's entrypoint, not an auth module; moving it to
|
|
||||||
`src/bootstrap.ts` would edit `compose.yml`, five e2e compose files and `src/compose.test.ts` for a
|
|
||||||
rename. Reconsider when a third seeding concern lands.
|
|
||||||
- **`BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook.** Adding
|
|
||||||
to the context costs a minor bump and removing one a major, so the shape errs small elsewhere. Pools
|
|
||||||
handed to a plugin are reaped on process exit — revisit if a plugin ever needs an orderly drain.
|
|
||||||
- **`config/` is still a plain dir — no `package.json` of its own**, or `#menu-config` resolves
|
|
||||||
against that instead and boot fails loud. An operator's menu override has no use for
|
|
||||||
dependencies; if that changes, it needs the same package treatment.
|
|
||||||
- **A plugin `package.json` without `"type": "module"` is refused, not warned.** Allowing it costs a
|
|
||||||
warning and a re-parse per file, not a break — Node detects module syntax, so even a `.js` helper
|
|
||||||
loads — and an operator on a read-only third-party mount cannot apply the remedy. Refused anyway
|
|
||||||
because the direction is safe: refuse→warn relaxes freely, warn→refuse breaks installed plugins.
|
|
||||||
**Valid while nothing is installed in the wild.**
|
|
||||||
- **`examples/` mirrors the drop-in mount dirs** — `examples/plugins/<id>/` copies to
|
|
||||||
`plugins/<id>/`, `examples/config/menu.ts` to `config/menu.ts`. Both mirrors are in
|
|
||||||
`tsconfig.include` and resolve the host through the barrels, so each typechecks in place *and*
|
|
||||||
copies across unchanged. Never commit real plugins/config into the root mount dirs — they ship empty.
|
|
||||||
- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the base request
|
|
||||||
context. It protects the I/O-free hot path on the public, bot-hit landing (`/`).
|
|
||||||
- **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`,
|
|
||||||
`dashboard`) and an `onRequest` short-circuit build their context with `contextFor(pluginId)`
|
|
||||||
exactly as a plugin route does — otherwise `ctx.t` is the core translator and the plugin's own keys
|
|
||||||
render as bare keys on the pages it owns.
|
|
||||||
- **Email is delegated to Kratos** (it renders + sends recovery/verification mail); `web` never
|
|
||||||
touches SMTP. Customization is Kratos' `courier.template_override_path`, not app code.
|
|
||||||
|
|
||||||
### Authorization
|
|
||||||
|
|
||||||
- **Vocabulary: `User` → `Group` → `Permission`, and there is no `Role`.** Keto ships no namespaces —
|
|
||||||
all four in `ory/keto/namespaces.keto.ts` are ours. A permission is one operation ("read shifts");
|
|
||||||
a role is a *bundle*, which here is just a group with several grants (groups nest). Ory's own
|
|
||||||
"permission" (the `Resource` `permits`: view/edit/delete) is the separate per-row tier.
|
|
||||||
- **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare
|
|
||||||
word names *who someone is* (a role), and roles are groups here. **Enforced at discovery**
|
|
||||||
(`isValidPermissionName` in `plugin-host/plugin.ts`, checked by `shapeError` over every route, nav and profile-menu
|
|
||||||
`permission` and every declared name), fail-loud like any other manifest rule — not only in the
|
|
||||||
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
|
|
||||||
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
|
|
||||||
in a GUI invents a name, and holding one is a property of a user or group, edited as a checkbox
|
|
||||||
list there. A Keto tuple naming something no installed plugin declares gates nothing, is not
|
|
||||||
offered, and is never revoked by an unrelated save — the picker only speaks for what it showed.
|
|
||||||
- `<resource>` is **global, not plugin-scoped** (hence `oauth2-clients`, not `clients`): users are
|
|
||||||
the *host's*, and cross-plugin sharing is a goal. Cost: collision-freedom is a convention rather
|
|
||||||
than structural. Accepted — the alternative penalizes the sharing case.
|
|
||||||
- **Declaring a permission stays optional.** Mandatory declaration would let `findConflicts` see all
|
|
||||||
overlaps, but would then warn on exactly that legitimate sharing case. Shape is enforced;
|
|
||||||
declaration is not.
|
|
||||||
- `ADMIN_PERMISSIONS` **defaults to empty**, and **an unusable value is dropped with a warning,
|
|
||||||
never fatal** — fail-loud belongs at the manifest boundary where a developer authored the
|
|
||||||
mistake, whereas `bootstrap` gates `web`, so refusing operator env takes the whole stack down
|
|
||||||
(`e2e-tests/compose.auth.yml` seeds a bad value to prove the container survives one). The seed is
|
|
||||||
a function of what `bootstrap` discovers, so a plugin dropped in after first boot needs
|
|
||||||
`docker compose up -d`, not `restart web`. `bootstrap`'s matching `./plugins` mount belongs in
|
|
||||||
`compose.override.yml` and nowhere else: in the base file it would desynchronise prod and collide
|
|
||||||
with the e2e stacks, which bind individual plugins *inside* `/app/plugins` (a nested mount into a
|
|
||||||
read-only parent is EROFS and the container never starts). Valid while bootstrap is the only
|
|
||||||
writer of grants.
|
|
||||||
- **`actionForMethod` is plugin-local and must not migrate into `@plainpages/plugin-api`.** Inside the admin
|
|
||||||
example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
|
|
||||||
× 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
|
|
||||||
verb — a route table must answer "what does this need?" on its own.
|
|
||||||
- **A gate is one of three, named exactly once, and `session` is a first-class one.** A route or nav
|
|
||||||
node names exactly one of `public`, `session`, `permission` — discovery refuses none, two, and a
|
|
||||||
flag spelled anything but `true`, so a forgotten gate fails the boot rather than publishing a page.
|
|
||||||
`src/auth/gate.ts` is the one home of the rule the plugin router, the host's own route table and
|
|
||||||
the menu all read. Exactly-one-gate is a discovery-time rule on manifests, not a runtime
|
|
||||||
invariant: `allows({}, user)` stays open **by design**, because the central override's `groups`
|
|
||||||
builds header nodes that carry no gate. Making `allows` fail closed would hide every
|
|
||||||
operator-grouped section. `session` exists because a plugin whose data is
|
|
||||||
the visitor's own — their upstream account, their own tokens — has no distinction a permission could
|
|
||||||
name; the alternative, granting every newly registered user a permission, couples the identity
|
|
||||||
lifecycle to a Keto write that nothing retries when it fails. A page scoped to "mine" joins on
|
|
||||||
`ctx.user.id`, never the email — an address is user-changeable and can be reassigned to someone
|
|
||||||
who would then inherit the previous holder's rows.
|
|
||||||
- **The reference plugin's two shift pages duplicate a view model and markup on purpose.** An example
|
|
||||||
is read far more often than it is changed, and each page reads top to bottom on its own. **Valid
|
|
||||||
while `examples/plugins/scheduling` stays a teaching artifact rather than a maintained product.**
|
|
||||||
- **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry
|
|
||||||
`canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders,
|
|
||||||
disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create
|
|
||||||
form, a delete-confirm page) is the exception to `actionForMethod` and gates on `:write`. Two
|
|
||||||
grant-specific guards go with it: you cannot revoke your own **direct** grants (self-lockout would
|
|
||||||
need a `curl` against Keto to undo), and a permission held *through a group* renders
|
|
||||||
ticked-but-disabled, because unticked stated the opposite of the truth. The group paths are
|
|
||||||
guarded too: unticking a permission on a group you are in, removing yourself (or a nested group you
|
|
||||||
are in) from its members, or deleting the group is refused when you would lose a permission you
|
|
||||||
don't hold directly.
|
|
||||||
- **`users:write` and `groups:write` are equivalent to full administrative access**: `groups:write`
|
|
||||||
adds you to any group, including one holding every permission; `users:write` mints a recovery code
|
|
||||||
for any account. The containment the split buys is real on the **read** half only (`users:read` is
|
|
||||||
a safe helpdesk grant). Don't let the per-resource naming imply otherwise in docs.
|
|
||||||
- **The CSRF token is deliberately unbound to the session** (`src/auth/csrf.ts`; README → Security
|
|
||||||
model): the usual cost of a signed double-submit token, covered by `SameSite=Lax` and HSTS.
|
|
||||||
**Valid while the session cookie stays `SameSite=Lax` or stricter.**
|
|
||||||
- **Plainpages says "user" everywhere; Ory's word is "identity".** House style, not a renamed
|
|
||||||
concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors
|
|
||||||
Kratos' wire shape — don't rename it.
|
|
||||||
|
|
||||||
### i18n
|
|
||||||
|
|
||||||
- **The locale lives in the URL, never in a cookie.** `?locale=sv-SE` → `Accept-Language` → `en-US`,
|
|
||||||
and when the URL asked for one the host carries it onto the links it renders. A cookie would make a
|
|
||||||
page's language invisible in its address and unshareable; the cost is that a plugin wraps its own
|
|
||||||
hrefs. Matching is exact on a full tag (`sv-FI` ≠ `sv-SE`), except that a lone language from
|
|
||||||
`Accept-Language` takes the first regional catalog for it.
|
|
||||||
- **The core building blocks carry the locale; a plugin doesn't have to.** The shell, `pagination`,
|
|
||||||
`filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every href in
|
|
||||||
`localeHref`; nav and sign-in are wrapped in `chrome.ts`; the two GET forms carry it as a hidden
|
|
||||||
`locale` input, since a GET submit replaces the whole query string. **A form's `action` counts as a
|
|
||||||
link** — sign-out, consent and auth-card forms carry it too, or picking a language and then saving
|
|
||||||
anything drops back to `Accept-Language`. The obligation stays on the building block, never on each
|
|
||||||
call site. `ctx.localeHref` remains for hrefs a plugin's own markup emits. The one round-trip that
|
|
||||||
cannot carry it is the Kratos sign-in POST (absolute off-site URL).
|
|
||||||
- **`locale` is a host-owned query param** — in `parseListQuery`'s reserved set, so a localized list
|
|
||||||
page doesn't hand a plugin a phantom `locale` filter. The i18n view locals (`t`, `locale`, `locales`,
|
|
||||||
`localeHref`, `localeParam`, `localeSwitch`, `dir`) are likewise reserved, merged after a handler's
|
|
||||||
`data` so a collision loses the key instead of breaking the shell — as is `assetHref`.
|
|
||||||
- **Catalogs are checked at boot, not at render.** Every locale is compared against its set's `en-US`
|
|
||||||
— keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` requires — and a
|
|
||||||
mismatch stops startup. A plugin may ship fewer locales than the host (its strings fall back to
|
|
||||||
`en-US` per key), never one the host lacks.
|
|
||||||
- **`locales/` at the repo root is a drop-in mount**, like `plugins/` and `config/` — `locales/<tag>.ts`
|
|
||||||
for the core, `locales/plugins/<id>/<tag>.ts` for a plugin; a new tag adds a language, an existing
|
|
||||||
one replaces that catalog wholesale. Adding a language must never require forking the image. The
|
|
||||||
SHIPPED `en-US` stays the parity baseline even when the mount replaces it, so a mounted catalog is
|
|
||||||
checked rather than trusted (one compared only against itself would boot green with the whole UI
|
|
||||||
rendering keys).
|
|
||||||
- **The language picker is on every page, POST-rendered ones included.** A POST-rendered URL often
|
|
||||||
answers no GET (`POST /admin/users/:id/recovery`), so the host resolves the picker's target
|
|
||||||
(`app.ts` → `switchBase`): this path when it answers GET, else the same-origin Referer, else `/`.
|
|
||||||
Accepted cost: switching language there leaves that POST's own result behind. Valid while the picker
|
|
||||||
is expected on literally every page — if that softens, hiding it after a POST is simpler.
|
|
||||||
- **An unknown translation key renders as itself.** That single rule lets a nav label, branding, or a
|
|
||||||
menu `rename` be either a key or plain text without a second field or a migration. Don't "fix" it
|
|
||||||
into a loud failure: a manifest with plain labels must keep working.
|
|
||||||
- **`t()` returns raw text; the view escapes it.** Messages go through `<%= %>` like any other value;
|
|
||||||
one carrying markup uses `<%- %>`, and then its `{{vars}}` are escaped at the call site. Don't move
|
|
||||||
escaping into `t()` — every other value in a view would become the odd one out.
|
|
||||||
- **RTL is out of scope until there is a real use case.** `textDirection` sets `<html dir>` because
|
|
||||||
that is free and correct, but the stylesheet keeps physical `left`/`right` properties; a genuine RTL
|
|
||||||
locale needs those moved to logical ones first. Valid while no deployment needs an RTL language.
|
|
||||||
|
|
||||||
### UI
|
|
||||||
|
|
||||||
- **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
|
|
||||||
top layer, so a row kebab is not clipped by `.table-wrap`'s `overflow`. Four rules hold it
|
|
||||||
together: the panel carries **`position-anchor: auto`** (a bare `anchor()` resolves to nothing in
|
|
||||||
all three engines); it stays the trigger's **next sibling inside the `.menu` wrapper**, which the
|
|
||||||
open-state style and the old-browser fallback both read; the partial **requires a caller-named
|
|
||||||
`id`** and fails loud without one, since that is the `popovertarget` idref (never generate one —
|
|
||||||
nondeterministic HTML forecloses the caching decision); and **neither `aria-expanded` nor
|
|
||||||
`aria-haspopup` is written**: every engine maintains the first itself on a declarative
|
|
||||||
`popovertarget` invoker, so a hand-written one replaces a live state with a static lie, and the
|
|
||||||
second would promise `role="menu"` semantics these panels don't implement. **That guarantee is the
|
|
||||||
declarative attribute's alone** — open a panel from script and no engine applies it, so
|
|
||||||
"enhancing" one of these triggers is what would cost it its accessibility. `<details>` stays where
|
|
||||||
it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
|
|
||||||
profile menu (its trigger composes escaped user values and its Sign-out item is a CSRF POST form) — keep
|
|
||||||
the two in step.
|
|
||||||
- **One scroller, the document** (priority 1). `.app` is `min-height: 100dvh`. `.nav`'s
|
|
||||||
`overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto` are not leftovers of a bounded frame:
|
|
||||||
they are what makes the off-canvas panel usable with a long tree. `#nav-toggle` is `position: fixed` —
|
|
||||||
a label click focuses it, and a browser scrolls a focused element into view.
|
|
||||||
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it
|
|
||||||
is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference
|
|
||||||
to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an
|
|
||||||
unknown sprite id renders blank instead of failing loud (the `every icon <use> resolves` e2e test
|
|
||||||
catches anything reaching the nav).
|
|
||||||
|
|
||||||
### Build, test & release
|
|
||||||
|
|
||||||
- **Deps install to `/node_modules`, above `WORKDIR /app`** — Node resolves upward, so dev's `.:/app`
|
|
||||||
bind mount has nothing to shadow. Not a volume at `/app/node_modules`: the daemon creates a mount
|
|
||||||
destination as root whatever `--user` says, leaving a root-owned dir in the checkout. Nothing may
|
|
||||||
sit at that path now — it shadows `/node_modules` silently (`src/compose.test.ts` guards the compose
|
|
||||||
files, `.dockerignore` the image).
|
|
||||||
- **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
|
|
||||||
a dev box may not have. Not universal: `bootstrap` writes `jwks.json` as root when it is absent on
|
|
||||||
first boot; the committed dev key makes that rare, and when it happens the rotation runbook's
|
|
||||||
host-side `>` needs the file re-owned first (valid while the dev key ships committed). Three
|
|
||||||
consequences: `e2e-tests/artifacts/` is *tracked* (`.gitkeep`), since an absent bind-mount source is
|
|
||||||
daemon-created as root and that uid then cannot write it (README → Upgrading); the runner image sets
|
|
||||||
`HOME=/tmp`, since an arbitrary uid has no passwd entry and would land on an unwritable `/`; and
|
|
||||||
rootless Docker wants the flag *dropped*, container root already being the invoking user. Baking a
|
|
||||||
`USER` in instead does not work — the image's `pwuser` is 1001 and no fixed uid matches every host.
|
|
||||||
`src/compose.test.ts` guards every documented command, `src/ci-gate.test.ts` the gate's own.
|
|
||||||
- **Anything the browser logs fails the E2E test that provoked it.** Every spec takes its `test` from
|
|
||||||
`e2e-tests/console-guard.ts`, which fails a test on a console error/warning or uncaught exception on
|
|
||||||
any page it opened. A zero-JS app has nothing to say in the console, so the bar is *zero* rather than
|
|
||||||
a curated tolerance list; the two exceptions are narrow — a module-level allowance for the COOP header
|
|
||||||
Chromium drops (the e2e stacks serve plain http over container hostnames), and `allowConsole(re)` for
|
|
||||||
a test whose own page provokes a message on purpose. `src/e2e-console-guard.test.ts` locks the wiring
|
|
||||||
in the *unit* gate, since a spec importing `test` straight from Playwright — or minting a page with
|
|
||||||
a raw `newPage()` instead of `watchedPage()` — would run unwatched and green. Accepted cost: a page
|
|
||||||
outliving its test can log late and fail the next one.
|
|
||||||
- **The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium.**
|
|
||||||
`visual.spec.ts` + `language.spec.ts` are side-effect-free, so parallel runs don't collide, and a
|
|
||||||
console message only appears in the engine that renders the page (`ORY_FREE` in
|
|
||||||
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
|
|
||||||
so widening them means a stack per engine.
|
|
||||||
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** Both git channels in
|
|
||||||
`ci.sh`'s `docs_only()` pass `--no-renames`: rename detection names only the destination, so
|
|
||||||
`git mv src/app.ts notes.md` would otherwise read as docs and skip the gate over a source file that
|
|
||||||
was gone. `src/ci-gate.test.ts` locks the flags as a
|
|
||||||
*text* guard — the test image ships neither `git` nor `bash`. This is why the Docker Hub overview is
|
|
||||||
`release-tooling/dockerhub-overview.md.tmpl` and not a `.md`: a release reads it and a unit test
|
|
||||||
guards it, so giving it a `.md` name would let a broken `{{VERSION}}` merge with its own guard
|
|
||||||
skipped. `README.md` is the one markdown tests read, and a README-only change skips them. Accepted:
|
|
||||||
the `apiVersion` samples `release-tooling/contract-version.test.ts` checks are illustrative, and
|
|
||||||
the copies that matter (`examples/`, `views/`, the template) are gated. A Hub page command the
|
|
||||||
README dropped (`release-tooling/dockerhub-overview.test.ts`) fails the next gated change. **Valid while no markdown file is rendered or executed.**
|
|
||||||
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
|
|
||||||
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: concurrent jobs
|
|
||||||
can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that
|
|
||||||
file between login and logout. Same class: concurrent runs share the workspace dir, so ci.sh's
|
|
||||||
web-image build races another run's container creation on the `<project>-web` tag. Accepted for a
|
|
||||||
single-maintainer cadence; serialize with a workflow `concurrency` group if it ever bites.
|
|
||||||
|
|
||||||
## Docker only — no host tooling
|
## Docker only — no host tooling
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,515 @@
|
|||||||
|
# Decisions
|
||||||
|
|
||||||
|
"Goal N" is README → Goals; "code health" is the goal every project shares.
|
||||||
|
|
||||||
|
## `src/` is grouped by concern, and the core ships no domain screens
|
||||||
|
|
||||||
|
2026-06-24, the maintainer. Code health and goal 3. Valid while each concern folder stays one a
|
||||||
|
reader can name.
|
||||||
|
|
||||||
|
The folders are `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`, and `test-tooling/` (modules only
|
||||||
|
tests import), with `server.ts`, `config.ts`, `logger.ts` and the topology-guard `*.test.ts` at the
|
||||||
|
root. Tests are co-located. Add a new module to the folder owning its concern. Even the admin GUI
|
||||||
|
is a drop-in plugin (`examples/plugins/admin/`).
|
||||||
|
|
||||||
|
## Plugins and config import the host only through the two barrels
|
||||||
|
|
||||||
|
2026-08-17, the maintainer. Goal 7. Valid while the barrels are the contract surface.
|
||||||
|
|
||||||
|
`@plainpages/plugin-api` → `plugin-api/index.ts` → `src/plugin-host/plugin-api.ts`, and
|
||||||
|
`#menu-config` → `src/ui/menu-config.ts`; never a relative `../../src/*` path. These two barrels are
|
||||||
|
the whole contract surface, so don't "fix" either back to a relative path.
|
||||||
|
|
||||||
|
## The Ory client types are contract-visible through the barrel
|
||||||
|
|
||||||
|
2026-07-02, the maintainer. Goal 7. Valid while the barrel re-exports them.
|
||||||
|
|
||||||
|
`@plainpages/plugin-api` re-exports `KratosAdmin`, `KetoClient`, `HydraAdmin`, their DTOs and their
|
||||||
|
error classes. Changing those shapes needs a major `apiVersion` bump, not a free refactor.
|
||||||
|
|
||||||
|
## The barrel is a package, so a plugin folder may carry its own `package.json`
|
||||||
|
|
||||||
|
2026-08-17, the maintainer. Goal 7. Valid while plugins may depend on npm packages (README → Plugin
|
||||||
|
dependencies).
|
||||||
|
|
||||||
|
The Dockerfile links the barrel into `/node_modules`, above every plugin scope. Never let a copy
|
||||||
|
reach a plugin's own `node_modules`: two instances of the barrel break `instanceof` across the
|
||||||
|
boundary. `plugin-api.test.ts` guards this by asserting both paths reach one module.
|
||||||
|
|
||||||
|
## Plugin storage hands over credentials, not a client
|
||||||
|
|
||||||
|
2026-08-18, the maintainer. Goals 3 and 7. Valid while the host stays stateless (AGENTS.md →
|
||||||
|
priority 2).
|
||||||
|
|
||||||
|
README → Plugin storage describes the contract. The host takes `postgres` only to run the
|
||||||
|
provisioning DDL, and `storage-provisioning.ts` is the only module importing it. `storage.ts`
|
||||||
|
beside it stays pure, so `web` never loads a driver. `src/postgres.test.ts` guards both halves,
|
||||||
|
because one value imported from the wrong module breaks this invisibly. The driver is never
|
||||||
|
re-exported through the barrel, so no driver shape enters the contract.
|
||||||
|
|
||||||
|
Three properties hold the design together, so don't trade one away in isolation:
|
||||||
|
|
||||||
|
- Passwords are `HMAC-SHA256(PLUGIN_DB_SECRET, id)` rather than stored, which keeps the host
|
||||||
|
stateless. Whoever holds that secret holds every plugin database, so it ranks with the DB password
|
||||||
|
itself.
|
||||||
|
- The provisioning DSN reaches `bootstrap` only (`src/compose.test.ts` guards the split).
|
||||||
|
- Provisioning never drops anything, so uninstalling a plugin cannot destroy data; boot logs the
|
||||||
|
orphans instead.
|
||||||
|
|
||||||
|
Because the host's copy sits in the ambient `/node_modules`, a plugin can `import "postgres"`
|
||||||
|
without declaring it. That is incidental, not a packaging promise: a plugin must still depend on its
|
||||||
|
own driver.
|
||||||
|
|
||||||
|
## Plugin settings are declared, not discovered
|
||||||
|
|
||||||
|
2026-08-23, the maintainer. Goals 7 and 10. Valid while settings reach plugins through the
|
||||||
|
environment (README → Plugin settings).
|
||||||
|
|
||||||
|
`settings.ts` is pure and takes the env as an argument, so the whole matrix unit-tests without a
|
||||||
|
stack. Four rules carry the design:
|
||||||
|
|
||||||
|
- The prefix is `PLUGIN_SETTING_`, never bare `PLUGIN_`: a plugin id `db` with key `url` would
|
||||||
|
otherwise name the host's own `PLUGIN_DB_URL`.
|
||||||
|
- Keys are camelCase, so the `camelCase → SNAKE_CASE` mapping is total and no two keys collide.
|
||||||
|
`findConflicts` (`plugin-host/conflicts.ts`) catches the residual cross-plugin collision.
|
||||||
|
- `required` and `default` are mutually exclusive. That lets `SettingsOf` type a declared key as
|
||||||
|
present rather than `T | undefined`, so no plugin author casts.
|
||||||
|
- A secret's value reaches the plugin but never a log, an error or `ctx.declaredSettings`, not even
|
||||||
|
as a mask or a length.
|
||||||
|
|
||||||
|
An author mistake is refused at discovery, a bad operator value refuses the boot, and a stray
|
||||||
|
`PLUGIN_SETTING_` variable only warns, as an orphan database does.
|
||||||
|
|
||||||
|
## The trust boundary is the `web` process, not the plugin
|
||||||
|
|
||||||
|
2026-08-19, the maintainer. Goal 7. Valid while plugins are operator-installed code, not third-party
|
||||||
|
uploads.
|
||||||
|
|
||||||
|
Per-plugin databases and roles bound accidents, not hostile plugins: `PLUGIN_DB_SECRET` is in
|
||||||
|
`web`'s environment during `onBoot`, and a plugin already holds `ctx.system`'s Ory admin clients. So
|
||||||
|
cross-plugin DB isolation is containment, and README says so rather than implying a sandbox. This
|
||||||
|
follows AGENTS.md → priority 7: crash-isolation is a non-goal.
|
||||||
|
|
||||||
|
`server.ts` still deletes the secret from `process.env` right after `loadConfig`, before discovery
|
||||||
|
imports any plugin module. That ordering is the whole point: move the line earlier if anything,
|
||||||
|
never later.
|
||||||
|
|
||||||
|
## `ory/postgres/init/init.sql` is the only home for the Ory databases' ACL
|
||||||
|
|
||||||
|
2026-08-19, the maintainer. Code health. Valid while Ory's databases are created by Postgres' first
|
||||||
|
init.
|
||||||
|
|
||||||
|
Don't re-assert the `REVOKE CONNECT` from `bootstrap`. `REVOKE` only warns when the caller doesn't
|
||||||
|
own the database, so under the least-privilege provisioning account the README recommends it would
|
||||||
|
report success while changing nothing. It also hard-fails whenever `PLUGIN_DB_ADMIN_URL` names a
|
||||||
|
server with no `kratos`. The script runs only on first init, so a revoke added to it later never
|
||||||
|
reaches a volume that already exists: `docker compose down -v` is the dev remedy, and a deployed
|
||||||
|
install needs a migration.
|
||||||
|
|
||||||
|
## `bootstrap.ts` stays under `src/auth/`
|
||||||
|
|
||||||
|
2026-08-19, the maintainer. Code health. Valid until a third seeding concern lands.
|
||||||
|
|
||||||
|
It provisions plugin databases as well as seeding Ory, but it is the one-shot service's entrypoint,
|
||||||
|
not an auth module. Moving it to `src/bootstrap.ts` would edit `compose.yml`, five e2e compose files
|
||||||
|
and `src/compose.test.ts` for a rename.
|
||||||
|
|
||||||
|
## `BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook
|
||||||
|
|
||||||
|
2026-08-19, the maintainer. Goal 7. Valid while no plugin needs an orderly drain of its pools.
|
||||||
|
|
||||||
|
Adding to the context costs a minor bump and removing from it a major, so the shape errs small
|
||||||
|
elsewhere. Pools handed to a plugin are reaped on process exit.
|
||||||
|
|
||||||
|
## `config/` is a plain dir with no `package.json` of its own
|
||||||
|
|
||||||
|
2026-07-01, the maintainer. Goal 7. Valid while an operator's menu override needs no dependencies.
|
||||||
|
|
||||||
|
With a `package.json` there, `#menu-config` resolves against it instead and boot fails loud. If the
|
||||||
|
override ever needs dependencies, `config/` needs the package treatment `plugins/` has.
|
||||||
|
|
||||||
|
## A plugin `package.json` without `"type": "module"` is refused, not warned
|
||||||
|
|
||||||
|
2026-08-18, the maintainer. Goal 7. Valid while nothing is installed in the wild.
|
||||||
|
|
||||||
|
Allowing it would cost a warning and a re-parse per file, not a break: Node detects module syntax,
|
||||||
|
so even a `.js` helper loads. And an operator on a read-only third-party mount cannot apply the
|
||||||
|
remedy. Refused anyway because that direction is safe: refuse→warn relaxes freely, while warn→refuse
|
||||||
|
breaks installed plugins.
|
||||||
|
|
||||||
|
## `examples/` mirrors the drop-in mount dirs
|
||||||
|
|
||||||
|
2026-07-01, the maintainer. Goal 9. Valid while the examples are meant to be copied in unchanged.
|
||||||
|
|
||||||
|
`examples/plugins/<id>/` copies to `plugins/<id>/`, and `examples/config/menu.ts` to
|
||||||
|
`config/menu.ts`. Both mirrors are in `tsconfig.include` and resolve the host through the barrels,
|
||||||
|
so each typechecks in place and copies across unchanged. Never commit real plugins or config into
|
||||||
|
the root mount dirs: they ship empty.
|
||||||
|
|
||||||
|
## `ctx.chrome` is lazily memoized, never built unconditionally
|
||||||
|
|
||||||
|
2026-06-23, the maintainer. Goal 8. Valid while the public landing (`/`) renders without I/O.
|
||||||
|
|
||||||
|
Don't make it unconditional or move it into the base request context: it protects the I/O-free hot
|
||||||
|
path on the public, bot-hit landing.
|
||||||
|
|
||||||
|
## A plugin-owned render always runs on that plugin's context
|
||||||
|
|
||||||
|
2026-08-03, the maintainer. Goal 7. Valid while each plugin has its own translator.
|
||||||
|
|
||||||
|
The landing slots (`home`, `dashboard`) and an `onRequest` short-circuit build their context with
|
||||||
|
`contextFor(pluginId)`, exactly as a plugin route does. Otherwise `ctx.t` is the core translator and
|
||||||
|
the plugin's own keys render as bare keys on the pages it owns.
|
||||||
|
|
||||||
|
## Email is delegated to Kratos
|
||||||
|
|
||||||
|
2026-06-23, the maintainer. Goal 3. Valid while Kratos sends recovery and verification mail.
|
||||||
|
|
||||||
|
Kratos renders and sends recovery and verification mail; `web` never touches SMTP. Customization is
|
||||||
|
Kratos' `courier.template_override_path`, not app code.
|
||||||
|
|
||||||
|
## The vocabulary is `User` → `Group` → `Permission`, and there is no `Role`
|
||||||
|
|
||||||
|
2026-08-03, the maintainer. Code health. Valid while Keto holds no namespace of its own.
|
||||||
|
|
||||||
|
Keto ships no namespaces: all four in `ory/keto/namespaces.keto.ts` are ours. A permission is one
|
||||||
|
operation ("read shifts"). A role is a bundle, which here is just a group with several grants, and
|
||||||
|
groups nest. Ory's own "permission" (the `Resource` `permits`: view, edit, delete) is the separate
|
||||||
|
per-row tier.
|
||||||
|
|
||||||
|
## A permission name is always `<resource>:<action>`, enforced at discovery
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Goal 7. Valid while permissions are named by plugin authors.
|
||||||
|
|
||||||
|
Examples: `scheduling:read`, `users:write`. A bare word names who someone is (a role), and roles are
|
||||||
|
groups here. `isValidPermissionName` in `plugin-host/plugin.ts`, checked by `shapeError` over every
|
||||||
|
route, nav and profile-menu `permission` and every declared name, refuses the boot like any other
|
||||||
|
manifest rule. It is not left to the admin GUI, which an operator removes by not copying it in.
|
||||||
|
|
||||||
|
## Permission names are authored in plugin code; only grants live in Keto
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Goal 2. Valid while the admin screens offer only declared names.
|
||||||
|
|
||||||
|
The host collects every installed plugin's declarations into one catalog (`declaredPermissions` →
|
||||||
|
`ctx.declaredPermissions`), and that catalog is the list the admin screens offer. Hence there is no
|
||||||
|
Permissions admin screen: nothing in a GUI invents a name, and holding one is a property of a user
|
||||||
|
or group, edited as a checkbox list there. A Keto tuple naming something no installed plugin
|
||||||
|
declares gates nothing, is not offered, and is never revoked by an unrelated save: the picker only
|
||||||
|
speaks for what it showed.
|
||||||
|
|
||||||
|
## A permission's `<resource>` is global, not plugin-scoped
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Goal 7. Valid while cross-plugin sharing of permissions is wanted.
|
||||||
|
|
||||||
|
Hence `oauth2-clients`, not `clients`. Users are the host's, and plugins share them. The cost is
|
||||||
|
that collision-freedom is a convention rather than structural; the alternative penalizes the
|
||||||
|
sharing case.
|
||||||
|
|
||||||
|
## Declaring a permission stays optional
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Goal 7. Valid while plugins may share a permission.
|
||||||
|
|
||||||
|
Mandatory declaration would let `findConflicts` see every overlap, but it would then warn on exactly
|
||||||
|
the legitimate sharing case. Shape is enforced; declaration is not.
|
||||||
|
|
||||||
|
## `ADMIN_PERMISSIONS` defaults to empty, and an unusable value is dropped with a warning
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Goal 10. Valid while `bootstrap` is the only writer of grants.
|
||||||
|
|
||||||
|
Fail-loud belongs at the manifest boundary, where a developer authored the mistake. `bootstrap`
|
||||||
|
gates `web`, so refusing operator env would take the whole stack down;
|
||||||
|
`e2e-tests/compose.auth.yml` seeds a bad value to prove the container survives one.
|
||||||
|
|
||||||
|
The seed is a function of what `bootstrap` discovers, so a plugin dropped in after first boot needs
|
||||||
|
`docker compose up -d`, not `restart web`. `bootstrap`'s matching `./plugins` mount belongs in
|
||||||
|
`compose.override.yml` and nowhere else. In the base file it would desynchronise prod and collide
|
||||||
|
with the e2e stacks, which bind individual plugins inside `/app/plugins`: a nested mount into a
|
||||||
|
read-only parent is EROFS, and the container never starts.
|
||||||
|
|
||||||
|
## `actionForMethod` is plugin-local and stays out of `@plainpages/plugin-api`
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Code health. Valid while route tables name their permission per route.
|
||||||
|
|
||||||
|
Inside the admin example it keeps the route table and the in-handler guard deriving from one
|
||||||
|
function, so 29 routes × 2 gate sites cannot drift. Generalised, it would make authorization a
|
||||||
|
function of the transport verb, and a route table must answer "what does this need?" on its own.
|
||||||
|
|
||||||
|
## A gate is one of `public`, `session` or `permission`, named exactly once
|
||||||
|
|
||||||
|
2026-09-02, the maintainer. Goal 7. Valid while the central override's `groups` builds header nodes
|
||||||
|
without a gate.
|
||||||
|
|
||||||
|
A route or nav node names exactly one gate. Discovery refuses none, two, and a flag spelled anything
|
||||||
|
but `true`, so a forgotten gate fails the boot rather than publishing a page. `src/auth/gate.ts` is
|
||||||
|
the one home of the rule that the plugin router, the host's own route table and the menu all read.
|
||||||
|
|
||||||
|
Exactly-one-gate is a discovery-time rule on manifests, not a runtime invariant. `allows({}, user)`
|
||||||
|
stays open by design, because the central override's `groups` builds header nodes that carry no
|
||||||
|
gate; making `allows` fail closed would hide every operator-grouped section.
|
||||||
|
|
||||||
|
`session` exists because a plugin whose data is the visitor's own (their upstream account, their own
|
||||||
|
tokens) has no distinction a permission could name. The alternative, granting every newly
|
||||||
|
registered user a permission, couples the identity lifecycle to a Keto write that nothing retries
|
||||||
|
when it fails. A page scoped to "mine" joins on `ctx.user.id`, never the email: an address is
|
||||||
|
user-changeable and can be reassigned to someone who would then inherit the previous holder's rows.
|
||||||
|
|
||||||
|
## The reference plugin's two shift pages duplicate a view model and markup
|
||||||
|
|
||||||
|
2026-09-02, the maintainer. Goal 9. Valid while `examples/plugins/scheduling` stays a teaching
|
||||||
|
artifact rather than a maintained product.
|
||||||
|
|
||||||
|
An example is read far more often than it is changed, and each page reads top to bottom on its own.
|
||||||
|
|
||||||
|
## A `:read`-only holder is never shown a write affordance
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Goal 2. Valid while the admin GUI splits `:read` from `:write`.
|
||||||
|
|
||||||
|
The list and detail models carry `canWrite`, and the views drop create, save, delete, add and
|
||||||
|
remove. The permission picker still renders, disabled, because seeing who holds what is the point
|
||||||
|
of `:read`. A write-intent GET (a create form, a delete-confirm page) is the exception to
|
||||||
|
`actionForMethod` and gates on `:write`.
|
||||||
|
|
||||||
|
Guards against self-lockout go with it, since undoing one needs a `curl` against Keto:
|
||||||
|
|
||||||
|
- You cannot revoke your own direct grants.
|
||||||
|
- A permission held through a group renders ticked but disabled, because unticked would state the
|
||||||
|
opposite of the truth.
|
||||||
|
- Unticking a permission on a group you are in, removing yourself (or a nested group you are in)
|
||||||
|
from its members, or deleting the group is refused when you would lose a permission you don't hold
|
||||||
|
directly.
|
||||||
|
|
||||||
|
## `users:write` and `groups:write` each amount to full administrative access
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Goal 2. Valid while `groups:write` can add anyone to any group and
|
||||||
|
`users:write` can mint a recovery code.
|
||||||
|
|
||||||
|
`groups:write` adds you to any group, including one holding every permission; `users:write` mints a
|
||||||
|
recovery code for any account. The containment the split buys is real on the read half only:
|
||||||
|
`users:read` is a safe helpdesk grant. Don't let the per-resource naming imply otherwise in docs.
|
||||||
|
|
||||||
|
## The CSRF token is unbound to the session
|
||||||
|
|
||||||
|
2026-10-30, the maintainer. Goal 5. Valid while the session cookie stays `SameSite=Lax` or stricter.
|
||||||
|
|
||||||
|
`src/auth/csrf.ts`; README → Security model. It is the usual cost of a signed double-submit token,
|
||||||
|
covered by `SameSite=Lax` and HSTS.
|
||||||
|
|
||||||
|
## Plainpages says "user" everywhere, where Ory says "identity"
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Code health. Valid while Kratos' wire shape names the DTO `Identity`.
|
||||||
|
|
||||||
|
House style, not a renamed concept. The single exception is the `Identity` DTO in
|
||||||
|
`src/auth/kratos-admin.ts`, which mirrors Kratos' wire shape: don't rename it.
|
||||||
|
|
||||||
|
## The locale lives in the URL, never in a cookie
|
||||||
|
|
||||||
|
2026-08-03, the maintainer. Goal 5. Valid while a page's language should show in its address.
|
||||||
|
|
||||||
|
The order is `?locale=sv-SE` → `Accept-Language` → `en-US`, and when the URL asked for a locale the
|
||||||
|
host carries it onto the links it renders. A cookie would make a page's language invisible in its
|
||||||
|
address and unshareable; the cost is that a plugin wraps its own hrefs. Matching is exact on a full
|
||||||
|
tag (`sv-FI` ≠ `sv-SE`), except that a lone language from `Accept-Language` takes the first regional
|
||||||
|
catalog for it.
|
||||||
|
|
||||||
|
## The core building blocks carry the locale, so a plugin doesn't have to
|
||||||
|
|
||||||
|
2026-08-03, the maintainer. Goal 1. Valid while the locale lives in the URL.
|
||||||
|
|
||||||
|
The shell, `pagination`, `filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu`
|
||||||
|
wrap every href in `localeHref`. Nav and sign-in are wrapped in `chrome.ts`. The two GET forms carry
|
||||||
|
the locale as a hidden `locale` input, since a GET submit replaces the whole query string.
|
||||||
|
|
||||||
|
A form's `action` counts as a link: sign-out, consent and auth-card forms carry the locale too, or
|
||||||
|
picking a language and then saving anything drops back to `Accept-Language`. The obligation stays on
|
||||||
|
the building block, never on each call site. `ctx.localeHref` remains for hrefs a plugin's own
|
||||||
|
markup emits. The one round-trip that cannot carry the locale is the Kratos sign-in POST, to an
|
||||||
|
absolute off-site URL.
|
||||||
|
|
||||||
|
## `locale` and the i18n view locals are reserved by the host
|
||||||
|
|
||||||
|
2026-08-03, the maintainer. Goal 7. Valid while the locale lives in the URL.
|
||||||
|
|
||||||
|
`locale` is in `parseListQuery`'s reserved set, so a localized list page doesn't hand a plugin a
|
||||||
|
phantom `locale` filter. The view locals `t`, `locale`, `locales`, `localeHref`, `localeParam`,
|
||||||
|
`localeSwitch`, `dir` and `assetHref` are merged after a handler's `data`, so a collision loses the
|
||||||
|
key instead of breaking the shell.
|
||||||
|
|
||||||
|
## Catalogs are checked at boot, not at render
|
||||||
|
|
||||||
|
2026-08-03, the maintainer. Goal 7. Valid while catalogs are TypeScript modules loaded at boot.
|
||||||
|
|
||||||
|
Every locale is compared against its set's `en-US`: keys, string-vs-plural kind, and the plural
|
||||||
|
categories `Intl.PluralRules` requires. A mismatch stops startup. A plugin may ship fewer locales
|
||||||
|
than the host, with its strings falling back to `en-US` per key, but never one the host lacks.
|
||||||
|
|
||||||
|
## `locales/` at the repo root is a drop-in mount
|
||||||
|
|
||||||
|
2026-08-03, the maintainer. Goal 7. Valid while adding a language must not require forking the
|
||||||
|
image.
|
||||||
|
|
||||||
|
Like `plugins/` and `config/`: `locales/<tag>.ts` for the core, `locales/plugins/<id>/<tag>.ts` for
|
||||||
|
a plugin. A new tag adds a language; an existing one replaces that catalog wholesale. The shipped
|
||||||
|
`en-US` stays the parity baseline even when the mount replaces it, so a mounted catalog is checked
|
||||||
|
rather than trusted. One compared only against itself would boot green with the whole UI rendering
|
||||||
|
keys.
|
||||||
|
|
||||||
|
## The language picker is on every page, POST-rendered ones included
|
||||||
|
|
||||||
|
2026-08-04, the maintainer. Goal 2. Valid while the picker is expected on literally every page; if
|
||||||
|
that softens, hiding it after a POST is simpler.
|
||||||
|
|
||||||
|
A POST-rendered URL often answers no GET (`POST /admin/users/:id/recovery`), so the host resolves
|
||||||
|
the picker's target (`app.ts` → `switchBase`): this path when it answers GET, else the same-origin
|
||||||
|
Referer, else `/`. The accepted cost: switching language there leaves that POST's own result behind.
|
||||||
|
|
||||||
|
## An unknown translation key renders as itself
|
||||||
|
|
||||||
|
2026-08-03, the maintainer. Goal 7. Valid while labels may be either a key or plain text.
|
||||||
|
|
||||||
|
That single rule lets a nav label, branding, or a menu `rename` be either a key or plain text,
|
||||||
|
without a second field or a migration. Don't "fix" it into a loud failure: a manifest with plain
|
||||||
|
labels must keep working.
|
||||||
|
|
||||||
|
## `t()` returns raw text, and the view escapes it
|
||||||
|
|
||||||
|
2026-08-03, the maintainer. Code health. Valid while views escape with `<%= %>`.
|
||||||
|
|
||||||
|
Messages go through `<%= %>` like any other value. One carrying markup uses `<%- %>`, and then its
|
||||||
|
`{{vars}}` are escaped at the call site. Don't move escaping into `t()`: every other value in a view
|
||||||
|
would become the odd one out.
|
||||||
|
|
||||||
|
## RTL is out of scope until there is a real use case
|
||||||
|
|
||||||
|
2026-08-04, the maintainer. Goal 6. Valid while no deployment needs an RTL language.
|
||||||
|
|
||||||
|
`textDirection` sets `<html dir>` because that is free and correct, but the stylesheet keeps
|
||||||
|
physical `left` and `right` properties. A genuine RTL locale needs those moved to logical ones
|
||||||
|
first.
|
||||||
|
|
||||||
|
## A dropdown is a `<button popovertarget>` and a `[popover]`, never a `<details>`
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Goals 4 and 6. Valid while the popover API is the only zero-JS way to
|
||||||
|
dismiss on an outside click.
|
||||||
|
|
||||||
|
The browser owns open and close, and the panel sits in the top layer, so a row kebab is not clipped
|
||||||
|
by `.table-wrap`'s `overflow`. Four rules hold it together:
|
||||||
|
|
||||||
|
- The panel carries `position-anchor: auto`: a bare `anchor()` resolves to nothing in all three
|
||||||
|
engines.
|
||||||
|
- The panel stays the trigger's next sibling inside the `.menu` wrapper, which the open-state style
|
||||||
|
and the old-browser fallback both read.
|
||||||
|
- The partial requires a caller-named `id` and fails loud without one, since that is the
|
||||||
|
`popovertarget` idref. Never generate one: nondeterministic HTML forecloses caching.
|
||||||
|
- Neither `aria-expanded` nor `aria-haspopup` is written. Every engine maintains the first itself on
|
||||||
|
a declarative `popovertarget` invoker, so a hand-written one replaces a live state with a static
|
||||||
|
lie. The second would promise `role="menu"` semantics these panels don't implement.
|
||||||
|
|
||||||
|
That `aria-expanded` guarantee is the declarative attribute's alone: open a panel from script and no
|
||||||
|
engine applies it, so "enhancing" one of these triggers would cost it its accessibility. `<details>`
|
||||||
|
stays where it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same
|
||||||
|
block for the profile menu, because its trigger composes escaped user values and its Sign-out item
|
||||||
|
is a CSRF POST form. Keep the two in step.
|
||||||
|
|
||||||
|
## The document is the one scroller
|
||||||
|
|
||||||
|
2026-09-09, the maintainer. Goal 4. Valid while AGENTS.md → priority 1 holds that a page is a
|
||||||
|
document.
|
||||||
|
|
||||||
|
`.app` is `min-height: 100dvh`. `.nav`'s `overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto`
|
||||||
|
are not leftovers of a bounded frame: they make the off-canvas panel usable with a long tree.
|
||||||
|
`#nav-toggle` is `position: fixed` because a label click focuses it, and a browser scrolls a focused
|
||||||
|
element into view.
|
||||||
|
|
||||||
|
## `ICON_NAMES` is a host-owned registry, not a plugin contract
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Goal 7. Valid while the `every icon <use> resolves` e2e test guards the
|
||||||
|
nav.
|
||||||
|
|
||||||
|
`src/ui/icons.ts` is deliberately not re-exported from `@plainpages/plugin-api`. The palette may
|
||||||
|
narrow when the last reference to an id goes, and a plugin needing one gets it re-registered in the
|
||||||
|
same change. The accepted cost: an unknown sprite id renders blank instead of failing loud.
|
||||||
|
|
||||||
|
## Dependencies install to `/node_modules`, above `WORKDIR /app`
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Code health. Valid while dev bind-mounts the checkout at `/app`.
|
||||||
|
|
||||||
|
Node resolves upward, so dev's `.:/app` bind mount has nothing to shadow. A volume at
|
||||||
|
`/app/node_modules` would not do: the daemon creates a mount destination as root whatever `--user`
|
||||||
|
says, leaving a root-owned dir in the checkout. Nothing may sit at that path, since it silently
|
||||||
|
shadows `/node_modules`; `src/compose.test.ts` guards the compose files, and `.dockerignore` the
|
||||||
|
image.
|
||||||
|
|
||||||
|
## A container whose output a human then edits or deletes runs as the invoking user
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Code health. Valid while a dev box may lack `sudo`.
|
||||||
|
|
||||||
|
That means `--user "$(id -u):$(id -g)"` for the E2E runner (artifacts) and a lockfile edit;
|
||||||
|
otherwise the output is root-owned. It is not universal: `bootstrap` writes `jwks.json` as root when
|
||||||
|
it is absent on first boot. The committed dev key makes that rare, and when it happens the rotation
|
||||||
|
runbook's host-side `>` needs the file re-owned first. That exception is valid while the dev key
|
||||||
|
ships committed.
|
||||||
|
|
||||||
|
Three consequences:
|
||||||
|
|
||||||
|
- `e2e-tests/artifacts/` is tracked (`.gitkeep`), since an absent bind-mount source is
|
||||||
|
daemon-created as root and that uid then cannot write it (README → Upgrading).
|
||||||
|
- The runner image sets `HOME=/tmp`, since an arbitrary uid has no passwd entry and would land on an
|
||||||
|
unwritable `/`.
|
||||||
|
- Rootless Docker wants the flag dropped, container root already being the invoking user.
|
||||||
|
|
||||||
|
Baking a `USER` into the image does not work: the image's `pwuser` is 1001, and no fixed uid matches
|
||||||
|
every host. `src/compose.test.ts` guards every documented command, and `src/ci-gate.test.ts` the
|
||||||
|
gate's own.
|
||||||
|
|
||||||
|
## Anything the browser logs fails the E2E test that provoked it
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Goal 4. Valid while pages ship no JavaScript of their own.
|
||||||
|
|
||||||
|
Every spec takes its `test` from `e2e-tests/console-guard.ts`, which fails a test on a console error
|
||||||
|
or warning, or an uncaught exception, on any page it opened. A zero-JS app has nothing to say in the
|
||||||
|
console, so the bar is zero rather than a curated tolerance list. The two exceptions are narrow: a
|
||||||
|
module-level allowance for the COOP header Chromium drops (the e2e stacks serve plain http over
|
||||||
|
container hostnames), and `allowConsole(re)` for a test whose own page provokes a message on
|
||||||
|
purpose.
|
||||||
|
|
||||||
|
`src/e2e-console-guard.test.ts` locks the wiring in the unit gate, since a spec importing `test`
|
||||||
|
straight from Playwright, or minting a page with a raw `newPage()` instead of `watchedPage()`, would
|
||||||
|
run unwatched and green. The accepted cost: a page outliving its test can log late and fail the
|
||||||
|
next one.
|
||||||
|
|
||||||
|
## The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Goal 4. Valid while the Ory-backed specs share one backend.
|
||||||
|
|
||||||
|
`visual.spec.ts` and `language.spec.ts` are side-effect-free, so parallel runs don't collide, and a
|
||||||
|
console message only appears in the engine that renders the page (`ORY_FREE` in
|
||||||
|
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
|
||||||
|
so widening them means a stack per engine.
|
||||||
|
|
||||||
|
## The docs-only CI skip is `*.md` anywhere in the tree
|
||||||
|
|
||||||
|
2026-08-05, the maintainer. Code health. Valid while no markdown file is rendered or executed.
|
||||||
|
|
||||||
|
Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`: rename detection names only the
|
||||||
|
destination, so `git mv src/app.ts notes.md` would otherwise read as docs and skip the gate over a
|
||||||
|
source file that was gone. `src/ci-gate.test.ts` locks the flags as a text guard, since the test
|
||||||
|
image ships neither `git` nor `bash`.
|
||||||
|
|
||||||
|
This is why the Docker Hub overview is `release-tooling/dockerhub-overview.md.tmpl` and not a `.md`:
|
||||||
|
a release reads it and a unit test guards it, so a `.md` name would let a broken `{{VERSION}}` merge
|
||||||
|
with its own guard skipped. `README.md` is the one markdown tests read, and a README-only change
|
||||||
|
skips them. Accepted: the `apiVersion` samples `release-tooling/contract-version.test.ts` checks are
|
||||||
|
illustrative, and the copies that matter (`examples/`, `views/`, the template) are gated. A Hub page
|
||||||
|
command the README dropped (`release-tooling/dockerhub-overview.test.ts`) fails the next gated
|
||||||
|
change.
|
||||||
|
|
||||||
|
## CI docker logins share the runner host's Docker config
|
||||||
|
|
||||||
|
2026-07-04, the maintainer. Code health. Valid while releases keep a single-maintainer cadence;
|
||||||
|
serialize with a workflow `concurrency` group if it ever bites.
|
||||||
|
|
||||||
|
The act_runner is host-mode, so `docker login` and `logout` in the workflows mutate one shared
|
||||||
|
`~/.docker/config.json`. Concurrent jobs can race: one job's logout can 401 another's push, and the
|
||||||
|
recovery is a re-run. Tokens sit in that file between login and logout. In the same class,
|
||||||
|
concurrent runs share the workspace dir, so `ci.sh`'s web-image build races another run's container
|
||||||
|
creation on the `<project>-web` tag.
|
||||||
@@ -29,7 +29,8 @@ export function permissionName(resource: AdminResource, action: AdminAction): st
|
|||||||
|
|
||||||
// Every screen reads on GET/HEAD and mutates on POST. The route table and the in-handler guard both
|
// Every screen reads on GET/HEAD and mutates on POST. The route table and the in-handler guard both
|
||||||
// go through this rather than each spelling the permission out, so they cannot drift. Deliberately
|
// go through this rather than each spelling the permission out, so they cannot drift. Deliberately
|
||||||
// local: generalised, it would make authorization a function of the transport verb (AGENTS.md).
|
// local: generalised, it would make authorization a function of the transport verb
|
||||||
|
// (docs/decisions.md).
|
||||||
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";
|
||||||
|
|||||||
+5
-1
@@ -10,10 +10,14 @@ export interface Gate {
|
|||||||
session?: boolean; // any signed-in user, no grant to hold; anonymous is sent to /login
|
session?: boolean; // any signed-in user, no grant to hold; anonymous is sent to /login
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A gate naming none of the three allows everyone. Discovery refuses one on a route or a plugin's
|
||||||
|
// nav node, so only the header nodes `config/menu.ts`'s `groups` builds get here without a gate,
|
||||||
|
// and failing closed would hide every section an operator grouped.
|
||||||
export function allows(gate: Gate, user: User | null): boolean {
|
export function allows(gate: Gate, user: User | null): boolean {
|
||||||
if (gate.public === true) return true;
|
if (gate.public === true) return true;
|
||||||
if (gate.session === true) return user !== null;
|
if (gate.session === true) return user !== null;
|
||||||
return gate.permission == null || (user?.permissions.includes(gate.permission) ?? false);
|
if (gate.permission == null) return true;
|
||||||
|
return user?.permissions.includes(gate.permission) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function gatesSet(gate: Gate | null | undefined): string[] {
|
export function gatesSet(gate: Gate | null | undefined): string[] {
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ test("init SQL gives each Ory service its own database, and leaves plugin databa
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// AGENTS.md records that the driver runs the provisioning DDL in bootstrap and nothing else. A
|
// docs/decisions.md records that the driver runs the provisioning DDL in bootstrap and nothing
|
||||||
// single value imported from the wrong module puts it in web's graph without changing behaviour,
|
// else. A single value imported from the wrong module puts it in web's graph without changing
|
||||||
// so nothing but this would notice.
|
// behaviour, so nothing but this would notice.
|
||||||
test("the Postgres driver reaches bootstrap only, never web's import graph", () => {
|
test("the Postgres driver reaches bootstrap only, never web's import graph", () => {
|
||||||
const files = sourceFiles();
|
const files = sourceFiles();
|
||||||
assert.ok(files.length > 40, "walks the source tree");
|
assert.ok(files.length > 40, "walks the source tree");
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@ import { buildCredentials, storagePluginIds, type StorageCredentials } from "./p
|
|||||||
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
|
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
|
||||||
// The storage secret is in `config` now, so drop it from the environment before ANY plugin code
|
// The storage secret is in `config` now, so drop it from the environment before ANY plugin code
|
||||||
// runs: a plugin module's top level evaluates during discovery, long before onBoot. Defence in
|
// runs: a plugin module's top level evaluates during discovery, long before onBoot. Defence in
|
||||||
// depth, not a boundary (AGENTS.md) — and only ever move this line earlier, never later.
|
// depth, not a boundary (docs/decisions.md) — and only ever move this line earlier, never later.
|
||||||
delete process.env["PLUGIN_DB_SECRET"];
|
delete process.env["PLUGIN_DB_SECRET"];
|
||||||
// App-level logger: structured, OTLP-capable when OTLP_ENDPOINT is set. The hot path clones it
|
// App-level logger: structured, OTLP-capable when OTLP_ENDPOINT is set. The hot path clones it
|
||||||
// per request for access logging + a trace span (src/http/request-tracking.ts); console-only otherwise.
|
// per request for access logging + a trace span (src/http/request-tracking.ts); console-only otherwise.
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
|
|
||||||
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
| 60 | 0.5.0 | | | **Move the decisions out of AGENTS.md into `docs/decisions.md`, leaving only each decision's title in AGENTS.md.** | 3 | 2 | 2 | 5 | 6 | code health by 4 | 4.0 |
|
|
||||||
| 2 | 0.5.0 | | | **Rename the admin example plugin to a name that says what it administers.** | 3 | 1 | 2 | 4 | 6 | 9 by 3, code health by 2 | 2.6 |
|
| 2 | 0.5.0 | | | **Rename the admin example plugin to a name that says what it administers.** | 3 | 1 | 2 | 4 | 6 | 9 by 3, code health by 2 | 2.6 |
|
||||||
| 5 | 0.5.0 | | | **List grants that no installed plugin declares, with a remove action.** | 2 | 2 | 2 | 4 | 4 | 2 by 3 | 2.1 |
|
| 5 | 0.5.0 | | | **List grants that no installed plugin declares, with a remove action.** | 2 | 2 | 2 | 4 | 4 | 2 by 3 | 2.1 |
|
||||||
| 41 | 0.5.0 | | | **Apply a permission save as one Keto call, so it cannot stop part-way.** | 2 | 2 | 2 | 4 | 3 | 2 by 2 | 2.1 |
|
| 41 | 0.5.0 | | | **Apply a permission save as one Keto call, so it cannot stop part-way.** | 2 | 2 | 2 | 4 | 3 | 2 by 2 | 2.1 |
|
||||||
@@ -28,17 +27,6 @@
|
|||||||
|
|
||||||
## Details
|
## Details
|
||||||
|
|
||||||
### 60. Move the decisions out of AGENTS.md into `docs/decisions.md`, leaving only each decision's title in AGENTS.md.
|
|
||||||
|
|
||||||
AGENTS.md is loaded into every session, and about 400 of its 474 lines are the "Deliberate
|
|
||||||
architectural deviations". Each becomes a `docs/decisions.md` entry per `~/.claude/rules/decisions.md`:
|
|
||||||
a one-line title stating what holds, the reasoning and its "valid while", and the README goal or
|
|
||||||
principle it serves. AGENTS.md keeps the priorities, Docker commands, README structure, Rules, Comments
|
|
||||||
and Scoring run, plus the list of titles. Repoint the code comments that cite "(AGENTS.md)" for a
|
|
||||||
decision, and make `allows` in `src/auth/gate.ts` easier to read: a seat found its reason only in
|
|
||||||
AGENTS.md. No test reads AGENTS.md; `rank_todo.py` and larv-review start reading `docs/decisions.md`
|
|
||||||
once it exists, so run `rank_todo.py --check` on it.
|
|
||||||
|
|
||||||
### 2. Rename the admin example plugin to a name that says what it administers.
|
### 2. Rename the admin example plugin to a name that says what it administers.
|
||||||
|
|
||||||
The new name is `auth-admin`, chosen by the maintainer. The id is also the mount path, so the compose files,
|
The new name is `auth-admin`, chosen by the maintainer. The id is also the mount path, so the compose files,
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
<% if (user.email) { %>
|
<% if (user.email) { %>
|
||||||
<%# Signed in: the same popover block as the menu partial, hand-rolled because this one's
|
<%# Signed in: the same popover block as the menu partial, hand-rolled because this one's
|
||||||
trigger composes escaped user values and its Sign-out item is a CSRF POST form, neither of
|
trigger composes escaped user values and its Sign-out item is a CSRF POST form, neither of
|
||||||
which the partial's Item shapes cover (AGENTS.md). Keep the two in step. %>
|
which the partial's Item shapes cover (docs/decisions.md). Keep the two in step. %>
|
||||||
<div class="menu" style="flex:1 1 auto">
|
<div class="menu" style="flex:1 1 auto">
|
||||||
<button class="profile" type="button" popovertarget="profile-menu">
|
<button class="profile" type="button" popovertarget="profile-menu">
|
||||||
<span class="avatar" aria-hidden="true"><%= user.initials %></span>
|
<span class="avatar" aria-hidden="true"><%= user.initials %></span>
|
||||||
|
|||||||
Reference in New Issue
Block a user