Remove completed todo.md + html-css-foundation mockups; strip dead §N phase refs from comments/docs (simplify visual E2E to drop the mockup-comparison oracle)

This commit is contained in:
2026-06-23 22:49:28 +02:00
parent e22d24aa8a
commit a9f25a7692
143 changed files with 394 additions and 1538 deletions
-1
View File
@@ -3,6 +3,5 @@ node_modules
npm-debug.log npm-debug.log
*.log *.log
.DS_Store .DS_Store
html-css-foundation
e2e/artifacts e2e/artifacts
+1 -1
View File
@@ -73,5 +73,5 @@ docker compose -f compose.yml up --build -d # production
exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g. exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g.
`node:24.16.0-alpine3.24`). `node:24.16.0-alpine3.24`).
- Run the stability reviewer agent after every implementation of something that can be like - Run the stability reviewer agent after every implementation of something that can be like
a PR. That includes an implementation from the todo file that is pushed directly to master. a PR. That includes any change pushed directly to master.
Skip this if the changes are purely documentation and/or comments. Skip this if the changes are purely documentation and/or comments.
+56 -62
View File
@@ -46,16 +46,15 @@ makes **semantic, accessible markup** a priority: real landmarks, one `<h1>` per
lists and tables with proper headers, a skip link, and ARIA (`aria-current`/`aria-sort`) lists and tables with proper headers, a skip link, and ARIA (`aria-current`/`aria-sort`)
only where the platform leaves a gap (see [AGENTS.md](AGENTS.md)). only where the platform leaves a gap (see [AGENTS.md](AGENTS.md)).
> **Status.** Nearly all of the architecture this README describes is built today (see `todo.md`): > **Status.** The full architecture this README describes is built and exercised end-to-end by the
> the Node 24 + EJS server, the zero-JS **design system** (app shell, nav tree, data table, filters, > Playwright suites: the Node 24 + EJS server, the zero-JS **design system** (app shell, nav tree, data
> pagination, forms), the **plugin host** (discovery, router, per-plugin views + static, the > table, filters, pagination, forms), the **plugin host** (discovery, router, per-plugin views + static,
> `config/menu.ts` override + branding), the **Ory stack** (Postgres, Kratos + the session→JWT > the `config/menu.ts` override + branding), the **Ory stack** (Postgres, Kratos + the session→JWT
> tokenizer, Keto, Hydra), the **auth** wiring that consumes it (themed sign-in / register / reset / > tokenizer, Keto, Hydra), the **auth** wiring that consumes it (themed sign-in / register / reset /
> SSO, the session→JWT hot path, the users/groups/roles admin screens) and **Hydra's login / consent > SSO, the session→JWT hot path, the users/groups/roles admin screens), **Hydra's login / consent /
> / logout handlers** — all driven end-to-end by the Playwright suites, plus **production & ops > logout handlers**, and **production & ops hardening** (the prod compose profile, response security
> hardening** (the prod compose profile, response security headers, **structured logging + OTLP > headers, **structured logging + OTLP observability**, the
> observability**, the **[JWT key-rotation runbook](#jwt-signing-key--rotation)**). Remaining > **[JWT key-rotation runbook](#jwt-signing-key--rotation)**).
> polish is tracked in `todo.md` (§9–§10).
## The MVP — "clone, one command, hack on a plugin" ## The MVP — "clone, one command, hack on a plugin"
@@ -160,8 +159,8 @@ auto-merged by `docker compose up`) turns them back off for live editing.
| `OTLP_PROTOCOL` | `http/json` | OTLP wire format: `http/json` or `http/protobuf` | | `OTLP_PROTOCOL` | `http/json` | OTLP wire format: `http/json` or `http/protobuf` |
| `KRATOS_PUBLIC_URL` / `KRATOS_ADMIN_URL` | `http://kratos:4433` / `:4434` | identity (self-service / admin) | | `KRATOS_PUBLIC_URL` / `KRATOS_ADMIN_URL` | `http://kratos:4433` / `:4434` | identity (self-service / admin) |
| `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | permission check / write | | `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | permission check / write |
| `HYDRA_ADMIN_URL` | `http://hydra:4445` | OAuth2 provider admin API (§6 login/consent handshake) | | `HYDRA_ADMIN_URL` | `http://hydra:4445` | OAuth2 provider admin API (login/consent handshake) |
| `JWKS_URL` | `file://…/tokenizer/jwks.json` | the Kratos tokenizer signing key; verifies the session JWT (§4) | | `JWKS_URL` | `file://…/tokenizer/jwks.json` | the Kratos tokenizer signing key; verifies the session JWT |
| `JWT_ISSUER` / `JWT_AUDIENCE` | _unset_ | optional: when set, the session JWT's `iss` / `aud` must match (the dev tokenizer sets neither) | | `JWT_ISSUER` / `JWT_AUDIENCE` | _unset_ | optional: when set, the session JWT's `iss` / `aud` must match (the dev tokenizer sets neither) |
| `JWT_CLOCK_SKEW_SEC` | `60` | exp/nbf leeway (s) for Kratos↔web clock drift (the auth E2E sets `0`) | | `JWT_CLOCK_SKEW_SEC` | `60` | exp/nbf leeway (s) for Kratos↔web clock drift (the auth E2E sets `0`) |
| `ORY_TIMEOUT_SEC` | `5` | per-call timeout for outbound Kratos/Keto/Hydra (and http JWKS) fetches, so a hung Ory can't park a request | | `ORY_TIMEOUT_SEC` | `5` | per-call timeout for outbound Kratos/Keto/Hydra (and http JWKS) fetches, so a hung Ory can't park a request |
@@ -237,7 +236,7 @@ same way.
### JWT signing key & rotation ### JWT signing key & rotation
The session tokenizer (§3) signs each session→JWT with an **ES256** key at The session tokenizer signs each session→JWT with an **ES256** key at
`ory/kratos/tokenizer/jwks.json`. The committed one is a **dev throwaway** (like the `ory/kratos/tokenizer/jwks.json`. The committed one is a **dev throwaway** (like the
cookie/cipher secrets in `kratos.yml`) — a clean clone works; **never run it in cookie/cipher secrets in `kratos.yml`) — a clean clone works; **never run it in
production**. Mint a fresh key with the bundled generator: production**. Mint a fresh key with the bundled generator:
@@ -256,7 +255,7 @@ docker compose run --rm -T --no-deps web node src/gen-jwks.ts > ory/kratos/token
`file://` on the web side** so it picks up new keys without a restart. `file://` on the web side** so it picks up new keys without a restart.
**Why rotation is zero-downtime.** Kratos signs with the **first** key in the set and **Why rotation is zero-downtime.** Kratos signs with the **first** key in the set and
stamps its `kid` in each JWT header; web selects the verify key by that `kid` (§4). So a stamps its `kid` in each JWT header; web selects the verify key by that `kid`. So a
set can hold the new key *and* the old one at once — tokens minted before and after the set can hold the new key *and* the old one at once — tokens minted before and after the
swap both verify. swap both verify.
@@ -302,9 +301,9 @@ docker compose restart kratos
``` ```
Every existing JWT now fails signature verification → its bearer falls back to anonymous Every existing JWT now fails signature verification → its bearer falls back to anonymous
and must re-authenticate (the §4 re-mint only covers *expired* tokens, not bad signatures, and must re-authenticate (the re-mint only covers *expired* tokens, not bad signatures,
so a forged/leaked-key token can't be silently refreshed). The instant-revoke denylist so a forged/leaked-key token can't be silently refreshed). The instant-revoke denylist
(§9) is unnecessary here — the signature itself is already invalid. is unnecessary here — the signature itself is already invalid.
## Type check & tests ## Type check & tests
@@ -321,10 +320,9 @@ otherwise drags up its `depends_on` services.
E2E runs in the official Playwright image (browsers preinstalled) against the live `web` E2E runs in the official Playwright image (browsers preinstalled) against the live `web`
service — no Node/browsers on the host. There are four suites: service — no Node/browsers on the host. There are four suites:
**Visual + design system** (`visual.spec.ts`) — Ory-free (mock-data dashboard), so it stays fast. **Visual + design system** (`visual.spec.ts`) — Ory-free, so it stays fast. It screenshots the live
It screenshots the live pages **and** the `html-css-foundation` mockups, then asserts the live DOM pages and asserts the rendered design system — the app shell, theme switch, mobile off-canvas layout,
computes the **same design-system styles** as the reference (so a styling regression fails the icon sprite, CSRF-guarded sign-out, the public landing, the 404 page, and plugin permission-gating.
build, independent of the row data).
```bash ```bash
docker compose -f compose.yml -f compose.e2e.yml run --build --rm e2e # run the suite docker compose -f compose.yml -f compose.e2e.yml run --build --rm e2e # run the suite
@@ -334,7 +332,7 @@ docker compose -f compose.yml -f compose.e2e.yml down -v # tear
**Auth — token timeout + refresh** (`auth-refresh.spec.ts`) — the full-stack counterpart: it **Auth — token timeout + refresh** (`auth-refresh.spec.ts`) — the full-stack counterpart: it
boots the real Ory stack (Postgres + Kratos + Keto + bootstrap), shortens the session→JWT TTL to boots the real Ory stack (Postgres + Kratos + Keto + bootstrap), shortens the session→JWT TTL to
8s (`ory/kratos/e2e.yml`) and sets `JWT_CLOCK_SKEW_SEC=0`, then logs in the seeded admin and proves 8s (`ory/kratos/e2e.yml`) and sets `JWT_CLOCK_SKEW_SEC=0`, then logs in the seeded admin and proves
the §4 "stay signed in" hot path: the lapsed JWT is silently **re-minted** from the live Kratos the "stay signed in" hot path: the lapsed JWT is silently **re-minted** from the live Kratos
session (roles re-read from Keto), and once that session is revoked the stale cookie is **cleared**. session (roles re-read from Keto), and once that session is revoked the stale cookie is **cleared**.
```bash ```bash
@@ -344,7 +342,7 @@ docker compose -f compose.yml -f compose.e2e-auth.yml down -v #
**OAuth2 login + consent** (`oauth-login.spec.ts`) — another app logs in *through* us: it boots the **OAuth2 login + consent** (`oauth-login.spec.ts`) — another app logs in *through* us: it boots the
real stack (incl. Hydra), registers an OAuth2 client, starts an authorization flow, and drives the real stack (incl. Hydra), registers an OAuth2 client, starts an authorization flow, and drives the
§6 handlers end-to-end — `/oauth2/login` bounces an unauthenticated user to the themed login and handlers end-to-end — `/oauth2/login` bounces an unauthenticated user to the themed login and
**accepts** the challenge once a Kratos session exists; `/oauth2/consent` then shows the consent **accepts** the challenge once a Kratos session exists; `/oauth2/consent` then shows the consent
screen for the third-party client and **Allow** drives Hydra to issue the authorization code. screen for the third-party client and **Allow** drives Hydra to issue the authorization code.
@@ -567,8 +565,8 @@ docs for the full template-type list and the data each template receives.
## Building blocks ## Building blocks
Plainpages is a **component library, not a page generator** — you assemble pages from partials Plainpages is a **component library, not a page generator** — you assemble pages from partials
and helpers rather than declaring a schema and getting magic. The vocabulary is extracted from and helpers rather than declaring a schema and getting magic. The vocabulary is a set of reusable
`html-css-foundation/` into reusable EJS partials + TS helpers, fully styled and zero-JS: EJS partials + TS helpers, fully styled and zero-JS:
- **Partials:** app shell, nav tree, filter bar, data table (sort / select / row - **Partials:** app shell, nav tree, filter bar, data table (sort / select / row
actions), pagination, form fields, badges, menus, auth cards. actions), pagination, form fields, badges, menus, auth cards.
@@ -808,66 +806,62 @@ retried per request, not queued), so run a local collector/agent you trust.
``` ```
src/server.ts Entry point — starts the HTTP server (reads PORT, default 3000) src/server.ts Entry point — starts the HTTP server (reads PORT, default 3000)
src/app.ts Request routing + EJS rendering (incl. the themed Kratos self-service routes, §4) src/app.ts Request routing + EJS rendering (incl. the themed Kratos self-service routes)
src/static.ts Static file serving (path-traversal protection) + routePublic(): /public/<id>/ → a plugin's public/ src/static.ts Static file serving (path-traversal protection) + routePublic(): /public/<id>/ → a plugin's public/
src/jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK) src/jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK)
src/jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.user/roles; flags a lapsed token for re-mint (§4) src/jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.user/roles; flags a lapsed token for re-mint
src/jwks.ts JwksProvider — resolve the verify key by kid; createJwksProvider() picks by scheme: staticJwks (base64) or cachingJwks (file/http: TTL cache + rotation-on-miss reload) src/jwks.ts JwksProvider — resolve the verify key by kid; createJwksProvider() picks by scheme: staticJwks (base64) or cachingJwks (file/http: TTL cache + rotation-on-miss reload)
src/kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize (§4) src/kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize
src/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection, §4) src/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection)
src/keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API) (§4) src/keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API)
src/hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD (§6) src/hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD
src/fetch-timeout.ts withTimeout(): bound every outbound Ory call (§8) — wrap the injected fetch so each request aborts after a deadline unless the caller passed its own signal; server.ts wires it into the Kratos/Keto/Hydra clients src/fetch-timeout.ts withTimeout(): bound every outbound Ory call — wrap the injected fetch so each request aborts after a deadline unless the caller passed its own signal; server.ts wires it into the Kratos/Keto/Hydra clients
src/oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login (§6) src/oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login
src/oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes (§6) src/oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes
src/flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs (§4) src/flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs
src/login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie (§4) src/login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie
src/gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS (§3); see JWT signing key & rotation src/gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS; see JWT signing key & rotation
src/bootstrap.ts One-command bootstrap (§3): idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto src/bootstrap.ts One-command bootstrap: idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto
src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4) src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies)
src/csrf.ts CSRF for our own POST forms (§4): signed double-submit token — issue/verify, cookie, request gate src/csrf.ts CSRF for our own POST forms: signed double-submit token — issue/verify, cookie, request gate
src/denylist.ts Optional instant-revoke denylist (§9): in-memory, auto-evicting; hot path rejects a revoked subject's pre-revoke tokens (REVOCATION_DENYLIST) src/denylist.ts Optional instant-revoke denylist: in-memory, auto-evicting; hot path rejects a revoked subject's pre-revoke tokens (REVOCATION_DENYLIST)
src/security-headers.ts Response security headers set on every reply (§9): strict CSP (zero-JS), nosniff, X-Frame-Options/frame-ancestors, Referrer-Policy, HSTS over https src/security-headers.ts Response security headers set on every reply: strict CSP (zero-JS), nosniff, X-Frame-Options/frame-ancestors, Referrer-Policy, HSTS over https
src/safe-url.ts safeUrl() (sanitise an untrusted href/src to relative-or-http(s), exposed to plugins) + localPath() (host-relative redirect-allowlist guard for return_to) (§9) src/safe-url.ts safeUrl() (sanitise an untrusted href/src to relative-or-http(s), exposed to plugins) + localPath() (host-relative redirect-allowlist guard for return_to)
src/logger.ts createLogger()/requestLogger() + the ambient request log (runWithLog/currentLog) and tracedFetch: structured logger (service.name) + per-request trace span on @larvit/log; every outbound fetch joins the trace; OTLP export when OTLP_ENDPOINT set (§9) src/logger.ts createLogger()/requestLogger() + the ambient request log (runWithLog/currentLog) and tracedFetch: structured logger (service.name) + per-request trace span on @larvit/log; every outbound fetch joins the trace; OTLP export when OTLP_ENDPOINT set
src/body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + §5 forms) src/body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + forms)
src/context.ts RequestContext handed to handlers + buildContext() src/context.ts RequestContext handed to handlers + buildContext()
src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot
src/dashboard.ts buildDashboardModel(): the gated "/dashboard" app home — a short instructional starter (replace it with a plugin `dashboard` handler); "/" is the public landing (a plugin `home` handler) (§10). Both render the one unified menu (ctx.chrome) src/dashboard.ts buildDashboardModel(): the gated "/dashboard" app home — a short instructional starter (replace it with a plugin `dashboard` handler); "/" is the public landing (a plugin `home` handler). Both render the one unified menu (ctx.chrome)
src/admin-users.ts Built-in Users admin screen (§5): list Kratos identities (filter/sort/paginate) + create/edit/deactivate/delete/recovery; gated + CSRF-guarded src/admin-users.ts Built-in Users admin screen: list Kratos identities (filter/sort/paginate) + create/edit/deactivate/delete/recovery; gated + CSRF-guarded
src/admin-groups.ts Built-in Groups admin screen (§5): list Keto subject sets + create/delete + membership (add/remove users & nested groups); writes only to Keto, gated + CSRF-guarded src/admin-groups.ts Built-in Groups admin screen: list Keto subject sets + create/delete + membership (add/remove users & nested groups); writes only to Keto, gated + CSRF-guarded
src/admin-roles.ts Built-in Roles admin screen (§5): list/create/delete Keto roles + assign to users/groups + "effective access" (Keto expand → transitive members); reuses the Groups membership helpers, writes only to Keto, gated + CSRF-guarded src/admin-roles.ts Built-in Roles admin screen: list/create/delete Keto roles + assign to users/groups + "effective access" (Keto expand → transitive members); reuses the Groups membership helpers, writes only to Keto, gated + CSRF-guarded
src/admin-clients.ts Built-in OAuth2 clients admin screen (§6): list/register/delete Hydra OAuth2 clients (apps that log in through us); register shows the one-time client_secret; writes only to Hydra, gated + CSRF-guarded src/admin-clients.ts Built-in OAuth2 clients admin screen: list/register/delete Hydra OAuth2 clients (apps that log in through us); register shows the one-time client_secret; writes only to Hydra, gated + CSRF-guarded
src/admin-nav.ts adminSection(): the permission-gated "Admin" menu section (Users · Groups · Roles · OAuth2 clients), wired into the global dashboard menu + the in-screen admin nav (adminNav) so they can't drift src/admin-nav.ts adminSection(): the permission-gated "Admin" menu section (Users · Groups · Roles · OAuth2 clients), wired into the global dashboard menu + the in-screen admin nav (adminNav) so they can't drift
src/shell-context.ts buildShellContext(): brand/theme/user view-model shared by the dashboard + admin screens (real signed-in user, no demo profile) src/shell-context.ts buildShellContext(): brand/theme/user view-model shared by the dashboard + admin screens (real signed-in user, no demo profile)
src/chrome.ts buildPluginChrome(): the one global menu + brand/user/theme/csrf every page renders the shell from (§7, unified across all pages in §10) — exposed on ctx.chrome src/chrome.ts buildPluginChrome(): the one global menu + brand/user/theme/csrf every page renders the shell from (unified across all pages) — exposed on ctx.chrome
src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs) src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs)
src/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize } src/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize }
src/nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model src/nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model
src/paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs src/paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
src/plugin.ts Plugin contract: manifest types, definePlugin(), version + conflict rules + fullPath() src/plugin.ts Plugin contract: manifest types, definePlugin(), version + conflict rules + fullPath()
src/plugin-api.ts Stable plugin author barrel — the one module a plugin imports (definePlugin, ctx/result types, guards, body/CSRF/list-query helpers) src/plugin-api.ts Stable plugin author barrel — the one module a plugin imports (definePlugin, ctx/result types, guards, body/CSRF/list-query helpers)
src/discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot (§2) src/discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot
src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2) src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate
src/guards.ts requireSession()/can()/check(): in-handler authorization (§4) — the imperative counterpart to the route permission gate; GuardError → 303 /login or 403; check() is the one live Keto "may I?" call src/guards.ts requireSession()/can()/check(): in-handler authorization — the imperative counterpart to the route permission gate; GuardError → 303 /login or 403; check() is the one live Keto "may I?" call
src/hooks.ts runBootHooks()/runRequestHooks()/runResponseHooks(): invoke a plugin's optional lifecycle hooks in discovery order (§2); no sandbox (a throwing hook fails loud), skipped when no plugin declares one src/hooks.ts runBootHooks()/runRequestHooks()/runResponseHooks(): invoke a plugin's optional lifecycle hooks in discovery order; no sandbox (a throwing hook fails loud), skipped when no plugin declares one
src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials (§2) src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials
src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot (§2) src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot
views/ Core EJS templates, all in the one app shell (§10): home (public "/" landing), index (instructional /dashboard), admin/ (Users/Groups/Roles/Clients lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent/admin bodies, menu/popover, theme switch, icon sprite) views/ Core EJS templates, all in the one app shell: home (public "/" landing), index (instructional /dashboard), admin/ (Users/Groups/Roles/Clients lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent), error (flow-error sink → /error), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, landing/flow/consent/admin bodies, menu/popover, theme switch, icon sprite)
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt) public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
config/menu.ts Central menu override + branding (optional; defaults apply if absent) config/menu.ts Central menu override + branding (optional; defaults apply if absent)
ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service) ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service)
plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships scheduling/ — the §7 reference plugin (list/form over an upstream + permission-gated nav) you copy plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships scheduling/ — the reference plugin (list/form over an upstream + permission-gated nav) you copy
examples/ Non-app helpers; shifts-upstream/ is the dev mock backend the reference plugin reads/writes (stand-in for your real service) examples/ Non-app helpers; shifts-upstream/ is the dev mock backend the reference plugin reads/writes (stand-in for your real service)
docs/ Reference docs (plugin-contract.md — the authoritative plugin API) docs/ Reference docs (plugin-contract.md — the authoritative plugin API)
e2e/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-role, admin CRUD, plugin page, logout) + devstack-login.spec (regression: login works from the banner's localhost URL and 127.0.0.1 is canonicalised, on the plain `docker compose up` topology); proxy.mjs (same-origin gateway) + mock-oidc.mjs (mock SSO provider) back full-flow. Dockerfile.e2e + compose.e2e[-auth|-oauth|-full|-devstack].yml run them e2e/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-role, admin CRUD, plugin page, logout) + devstack-login.spec (regression: login works from the banner's localhost URL and 127.0.0.1 is canonicalised, on the plain `docker compose up` topology); proxy.mjs (same-origin gateway) + mock-oidc.mjs (mock SSO provider) back full-flow. Dockerfile.e2e + compose.e2e[-auth|-oauth|-full|-devstack].yml run them
html-css-foundation/ HTML design mockups — the source for the building-block scripts/ci.sh The full CI gate: typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash scripts/ci.sh`)
partials; reference the stylesheets in public/css/.
scripts/ci.sh The full CI gate (§8): typecheck → unit tests → every E2E suite, each on a fresh, always-torn-down stack (`bash scripts/ci.sh`)
``` ```
Comments and docs cite roadmap phases as `§N` — the sections in `todo.md`.
## Extending the core ## Extending the core
- **New page in a plugin:** add a route + handler to the plugin manifest and a - **New page in a plugin:** add a route + handler to the plugin manifest and a
+1 -1
View File
@@ -1,4 +1,4 @@
# Full-stack auth E2E — token timeout + silent re-mint ("stay signed in", §4). The Ory-free # Full-stack auth E2E — token timeout + silent re-mint ("stay signed in"). The Ory-free
# visual suite (compose.e2e.yml) covers the design system; this is its full-stack counterpart: # visual suite (compose.e2e.yml) covers the design system; this is its full-stack counterpart:
# real Postgres + Kratos + Keto + bootstrap + web, with a SHORT tokenizer TTL (ory/kratos/e2e.yml) # real Postgres + Kratos + Keto + bootstrap + web, with a SHORT tokenizer TTL (ory/kratos/e2e.yml)
# and zero clock skew, so the JWT lapses and re-mints within seconds instead of ~10m. # and zero clock skew, so the JWT lapses and re-mints within seconds instead of ~10m.
+1 -1
View File
@@ -1,4 +1,4 @@
# Full browser E2E (todo §8) — the real Playwright UI flow against the live stack: password + # Full browser E2E — the real Playwright UI flow against the live stack: password +
# mocked-SSO login, menu filtering by role, users/groups/roles CRUD, a plugin page, logout. A tiny # mocked-SSO login, menu filtering by role, users/groups/roles CRUD, a plugin page, logout. A tiny
# same-origin gateway (proxy, e2e/proxy.mjs) fronts web + Kratos on one host so the browser's cookies # same-origin gateway (proxy, e2e/proxy.mjs) fronts web + Kratos on one host so the browser's cookies
# round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test. # round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test.
+1 -1
View File
@@ -1,4 +1,4 @@
# Full-stack OAuth2 E2E — the §6 login-challenge handler. Another app logs in *through* us: # Full-stack OAuth2 E2E — the login-challenge handler. Another app logs in *through* us:
# Hydra starts an authorization flow and hands the browser to web's /oauth2/login; web resolves # Hydra starts an authorization flow and hands the browser to web's /oauth2/login; web resolves
# it via the Kratos session and accepts. Runs against the real stack (Postgres + Kratos + Keto + # it via the Kratos session and accepts. Runs against the real stack (Postgres + Kratos + Keto +
# Hydra + bootstrap + web). The runner drives the flow over HTTP (fetch, manual cookies), so it # Hydra + bootstrap + web). The runner drives the flow over HTTP (fetch, manual cookies), so it
+3 -6
View File
@@ -1,5 +1,5 @@
# Playwright E2E. Brings up the app + a Playwright runner, screenshots the live pages and the # Playwright E2E. Brings up the app + a Playwright runner and exercises the live pages (design
# html-css-foundation mockups, and asserts the live DOM computes the same design styles. # system, theme switch, mobile layout, CSRF, landing, 404, plugin gating) — Ory-free, so it's fast.
# docker compose -f compose.yml -f compose.e2e.yml run --build --rm e2e # docker compose -f compose.yml -f compose.e2e.yml run --build --rm e2e
# docker compose -f compose.yml -f compose.e2e.yml down -v # tear down after # docker compose -f compose.yml -f compose.e2e.yml down -v # tear down after
# --build rebuilds the runner (the image bakes in e2e/) so spec edits are picked up. # --build rebuilds the runner (the image bakes in e2e/) so spec edits are picked up.
@@ -33,10 +33,7 @@ services:
environment: environment:
BASE_URL: http://web:3000 BASE_URL: http://web:3000
volumes: volumes:
# The mockups + their stylesheet, kept as siblings so file:// ../public/css resolves.
- ./html-css-foundation:/repo/html-css-foundation:ro
- ./public:/repo/public:ro
# The committed dev tokenizer key — the spec signs a session JWT with it so the gated # The committed dev tokenizer key — the spec signs a session JWT with it so the gated
# dashboard (§10) renders; web verifies it with the same key (the file it mounts read-only). # dashboard renders; web verifies it with the same key (the file it mounts read-only).
- ./ory/kratos/tokenizer/jwks.json:/repo/jwks.json:ro - ./ory/kratos/tokenizer/jwks.json:/repo/jwks.json:ro
- ./e2e/artifacts:/e2e/artifacts - ./e2e/artifacts:/e2e/artifacts
+4 -4
View File
@@ -19,7 +19,7 @@ services:
LOG_FORMAT: "json" # structured logs for prod pipelines; set OTLP_ENDPOINT to also export to a collector LOG_FORMAT: "json" # structured logs for prod pipelines; set OTLP_ENDPOINT to also export to a collector
REQUIRE_SECURE_SECRETS: "true" REQUIRE_SECURE_SECRETS: "true"
SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure
# Wait for the services the app talks to (kratos + keto + hydra for the §6 OAuth2 login/ # Wait for the services the app talks to (kratos + keto + hydra for the OAuth2 login/
# consent handler) + the one-shot bootstrap (admin + JWKS seed). # consent handler) + the one-shot bootstrap (admin + JWKS seed).
depends_on: depends_on:
bootstrap: bootstrap:
@@ -30,7 +30,7 @@ services:
condition: service_healthy condition: service_healthy
hydra: hydra:
condition: service_healthy condition: service_healthy
# §4 verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL). # verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL).
# Read-only — bootstrap is the only writer. # Read-only — bootstrap is the only writer.
volumes: volumes:
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro - ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro
@@ -117,7 +117,7 @@ services:
retries: 20 retries: 20
restart: unless-stopped restart: unless-stopped
# One-shot first-boot seed (§3, the MVP bar); see src/bootstrap.ts. Idempotent, re-runs # One-shot first-boot seed (the MVP bar); see src/bootstrap.ts. Idempotent, re-runs
# cleanly. Runs once kratos+keto are healthy; web waits for it. Tokenizer dir mounted # cleanly. Runs once kratos+keto are healthy; web waits for it. Tokenizer dir mounted
# read-write (the only writer) so the absent-JWKS safety net can land the key. # read-write (the only writer) so the absent-JWKS safety net can land the key.
bootstrap: bootstrap:
@@ -146,7 +146,7 @@ services:
# Ory Hydra — OAuth2/OIDC provider (other apps log in *through* plainpages; README). # Ory Hydra — OAuth2/OIDC provider (other apps log in *through* plainpages; README).
# DSN is its own `hydra` DB (init.sql); config in ory/hydra/hydra.yml. web implements the # DSN is its own `hydra` DB (init.sql); config in ory/hydra/hydra.yml. web implements the
# login challenge at /oauth2/login (§6, consent next). Dev permits the http issuer via --dev # login challenge at /oauth2/login (consent next). Dev permits the http issuer via --dev
# (compose.override.yml); prod sets an https issuer via env (URLS_SELF_ISSUER). # (compose.override.yml); prod sets an https issuer via env (URLS_SELF_ISSUER).
hydra-migrate: hydra-migrate:
image: oryd/hydra:v26.2.0 image: oryd/hydra:v26.2.0
+1 -1
View File
@@ -1,4 +1,4 @@
// Central menu override + branding (todo §2). Brand the app and reorder/rename/group/hide nav // Central menu override + branding. Brand the app and reorder/rename/group/hide nav
// nodes (by their `id`) across all plugins — the override always wins, applied before the // nodes (by their `id`) across all plugins — the override always wins, applied before the
// per-user permission filter. Every field is optional; delete one to fall back to the default. // per-user permission filter. Every field is optional; delete one to fall back to the default.
// See src/menu-config.ts (types), src/nav.ts (NavOverride), docs/plugin-contract.md. // See src/menu-config.ts (types), src/nav.ts (NavOverride), docs/plugin-contract.md.
+9 -9
View File
@@ -13,7 +13,7 @@ manifest, a version mismatch, or a conflict stops startup with a clear message.
crash-isolation (one bad plugin can't take the host down) is a *non-goal* — diagnose at deploy crash-isolation (one bad plugin can't take the host down) is a *non-goal* — diagnose at deploy
time, not in production. time, not in production.
> **Status.** This is the contract the §2 host implements. The types and pure rules > **Status.** This is the contract the host implements. The types and pure rules
> (`checkApiVersion`, `findConflicts`, `isValidPluginId`) live in `src/plugin.ts`; **discovery** > (`checkApiVersion`, `findConflicts`, `isValidPluginId`) live in `src/plugin.ts`; **discovery**
> (`src/discovery.ts`), the **router** (`src/router.ts` — method+path match, `:name` params, > (`src/discovery.ts`), the **router** (`src/router.ts` — method+path match, `:name` params,
> permission gate, `RouteResult` → response), and the **per-plugin view resolver** > permission gate, `RouteResult` → response), and the **per-plugin view resolver**
@@ -23,7 +23,7 @@ time, not in production.
> (`config/menu.ts`, loaded by `src/menu-config.ts`, with branding — name, logo, default theme — > (`config/menu.ts`, loaded by `src/menu-config.ts`, with branding — name, logo, default theme —
> rendered in the app shell) are wired and in use by the built-in screens and the reference plugin. > rendered in the app shell) are wired and in use by the built-in screens and the reference plugin.
> Later phases extended this contract: the replaceable [landing pages](#the-landing-pages-home--dashboard) > Later phases extended this contract: the replaceable [landing pages](#the-landing-pages-home--dashboard)
> and [public pages & menu items](#public-pages--menu-items) (§10), both documented below. > and [public pages & menu items](#public-pages--menu-items), both documented below.
## Anatomy of a plugin ## Anatomy of a plugin
@@ -226,7 +226,7 @@ request:
```ts ```ts
interface RequestContext { interface RequestContext {
chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell chrome: PageChrome; // brand/global-nav/user/theme/csrf for the native app shell
log: Log; // request-scoped logger, in this request's trace (§9) log: Log; // request-scoped logger, in this request's trace
params: Record<string, string>; // path params from the route match, e.g. /shifts/:id → { id } params: Record<string, string>; // path params from the route match, e.g. /shifts/:id → { id }
query: URLSearchParams; // alias of url.searchParams query: URLSearchParams; // alias of url.searchParams
req: IncomingMessage; req: IncomingMessage;
@@ -257,8 +257,8 @@ login/registration/front pages), so the menu looks identical signed in or out
A page that wants a focused, chrome-free layout passes **`menu: false`** to `partials/shell` (drops the A page that wants a focused, chrome-free layout passes **`menu: false`** to `partials/shell` (drops the
sidebar, single column); everything else still renders. sidebar, single column); everything else still renders.
**`ctx.log`** is a structured, request-scoped logger ([`@larvit/log`](https://www.npmjs.com/package/@larvit/log), **`ctx.log`** is a structured, request-scoped logger ([`@larvit/log`](https://www.npmjs.com/package/@larvit/log))
§9) already in this request's trace: `ctx.log.info("…", { key: "value" })` (also `warn`/`error`/`debug`, already in this request's trace: `ctx.log.info("…", { key: "value" })` (also `warn`/`error`/`debug`,
metadata values are string/number/boolean), and **`ctx.log.fetch(url, init?)`** — a drop-in `fetch` metadata values are string/number/boolean), and **`ctx.log.fetch(url, init?)`** — a drop-in `fetch`
for upstream calls that adds a client span and propagates the trace (W3C `traceparent`) downstream. for upstream calls that adds a client span and propagates the trace (W3C `traceparent`) downstream.
The barrel also exports a standalone **`tracedFetch`** (same behaviour, reads the ambient request log) The barrel also exports a standalone **`tracedFetch`** (same behaviour, reads the ambient request log)
@@ -271,7 +271,7 @@ OpenTelemetry Collector when `OTLP_ENDPOINT` is set).
across a major `apiVersion`. New fields may be **added** within a major version (additive, never across a major `apiVersion`. New fields may be **added** within a major version (additive, never
breaking). `req`/`res` are the raw Node objects and the full escape hatch; reading them is fine, breaking). `req`/`res` are the raw Node objects and the full escape hatch; reading them is fine,
but prefer the typed fields so a handler keeps working as the host evolves. `user`/`roles` come but prefer the typed fields so a handler keeps working as the host evolves. `user`/`roles` come
from the §4 JWT middleware and are `null`/`[]` until a session exists. from the JWT middleware and are `null`/`[]` until a session exists.
## Nav & permissions ## Nav & permissions
@@ -307,7 +307,7 @@ Permission tokens are a **shared global namespace** — that's deliberate, so an
`scheduling:read` once in Keto and every plugin referencing it is gated consistently. Namespace `scheduling:read` once in Keto and every plugin referencing it is gated consistently. Namespace
your tokens as `<id>:<action>` to avoid accidental clashes. Declaring them in `permissions` is your tokens as `<id>:<action>` to avoid accidental clashes. Declaring them in `permissions` is
optional but recommended: it documents them, feeds conflict detection, and lets the one-command optional but recommended: it documents them, feeds conflict detection, and lets the one-command
bootstrap seed them — the demo admin is granted every discovered plugin's declared tokens (§3), so bootstrap seed them — the demo admin is granted every discovered plugin's declared tokens, so
a dropped-in plugin works out of the box without editing host config. a dropped-in plugin works out of the box without editing host config.
## Contract versioning ## Contract versioning
@@ -394,14 +394,14 @@ worked example: thin handlers bound to an injectable upstream client, unit-teste
2. **Run one plugin against the host.** Get the folder into the container's `/app/plugins/<id>` 2. **Run one plugin against the host.** Get the folder into the container's `/app/plugins/<id>`
— either in your clone (the dev compose bind-mounts the tree) or by bind-mounting an external — either in your clone (the dev compose bind-mounts the tree) or by bind-mounting an external
folder (README → *Where plugins live*) — and `docker compose up`; the host discovers it. For folder (README → *Where plugins live*) — and `docker compose up`; the host discovers it. For
an isolated harness, the §2 host exposes plugin injection (`createApp({ plugins: [myPlugin] })`) an isolated harness, the host exposes plugin injection (`createApp({ plugins: [myPlugin] })`)
so a test can mount a single manifest and assert its routes, nav, and gating without the rest so a test can mount a single manifest and assert its routes, nav, and gating without the rest
of the stack. of the stack.
3. **E2E the user-facing flow.** Per AGENTS.md §6, ship a side-effect-free Playwright test in 3. **E2E the user-facing flow.** Per AGENTS.md §6, ship a side-effect-free Playwright test in
`e2e/` for each plugin page/form so the suite stays `fullyParallel`, run against the live `web` `e2e/` for each plugin page/form so the suite stays `fullyParallel`, run against the live `web`
service with the plugin mounted. The reference's permission-gating is covered in `visual.spec.ts`; service with the plugin mounted. The reference's permission-gating is covered in `visual.spec.ts`;
its authenticated list/form happy-path is the §8 full-E2E item (needs cross-host login infra). its authenticated list/form happy-path is the full-E2E item (needs cross-host login infra).
The validation an author hits is the same the host runs: bad `apiVersion` or a conflict The validation an author hits is the same the host runs: bad `apiVersion` or a conflict
([above](#conflict-rules)) stops boot with a precise message naming the plugin(s) involved. ([above](#conflict-rules)) stops boot with a precise message naming the plugin(s) involved.
+3 -3
View File
@@ -1,15 +1,15 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
// Full-stack auth E2E: token timeout + silent re-mint ("stay signed in", §4). Runs against the // Full-stack auth E2E: token timeout + silent re-mint ("stay signed in"). Runs against the
// real Ory stack via compose.e2e-auth.yml, where the session→JWT TTL is shortened to 8s and the // real Ory stack via compose.e2e-auth.yml, where the session→JWT TTL is shortened to 8s and the
// web clock skew is 0 — so the ~10m token lapses in seconds and the hot path re-mints it from the // web clock skew is 0 — so the ~10m token lapses in seconds and the hot path re-mints it from the
// still-live Kratos session. We drive the flow over HTTP (fetch, manual cookies) because Kratos // still-live Kratos session. We drive the flow over HTTP (fetch, manual cookies) because Kratos
// and web sit on different hosts here; web's own server-side cookie relay is what we exercise. // and web sit on different hosts here; web's own server-side cookie relay is what we exercise.
// The browser-UI login is owned by §8; this proves the timeout/refresh server behaviour end-to-end. // The browser-UI login is owned by the full-flow E2E; this proves the timeout/refresh server behaviour end-to-end.
const WEB = process.env.BASE_URL ?? "http://web:3000"; const WEB = process.env.BASE_URL ?? "http://web:3000";
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433"; const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434"; const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap (§3); admin role granted in Keto const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap; admin role granted in Keto
const ADMIN_PASSWORD = "admin"; const ADMIN_PASSWORD = "admin";
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms)); const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
+1 -1
View File
@@ -15,7 +15,7 @@ import { expect, test } from "@playwright/test";
// (compose.e2e-devstack.yml) against the plain `docker compose up` topology, so it sees // (compose.e2e-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 // 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. // 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 (§3) const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap
const ADMIN_PASSWORD = "admin"; const ADMIN_PASSWORD = "admin";
async function signIn(page: import("@playwright/test").Page): Promise<void> { async function signIn(page: import("@playwright/test").Page): Promise<void> {
+3 -3
View File
@@ -1,7 +1,7 @@
import { type Browser, type Page, expect, test } from "@playwright/test"; import { type Browser, type Page, expect, test } from "@playwright/test";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
// Full browser E2E (todo §8): the real Playwright UI against the live stack via the same-origin // Full browser E2E: the real Playwright UI against the live stack via the same-origin
// gateway (compose.e2e-full.yml) — the browser-UI login the earlier full-stack suites deferred here. // gateway (compose.e2e-full.yml) — the browser-UI login the earlier full-stack suites deferred here.
// Coverage is the test titles below, plus the standalone SSO test. // Coverage is the test titles below, plus the standalone SSO test.
// //
@@ -9,7 +9,7 @@ import { randomUUID } from "node:crypto";
// journey and the standalone SSO test run in parallel (fullyParallel) but stay independent: each // journey and the standalone SSO test run in parallel (fullyParallel) but stay independent: each
// uses its own browser context, and only the SSO test writes the mock-OIDC identity — keep it so // uses its own browser context, and only the SSO test writes the mock-OIDC identity — keep it so
// (no cross-group shared backend writes) or serialise the file if that ever changes. // (no cross-group shared backend writes) or serialise the file if that ever changes.
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap (§3), holds the admin role in Keto const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the admin role in Keto
const ADMIN_PASSWORD = "admin"; const ADMIN_PASSWORD = "admin";
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login
const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names
@@ -103,7 +103,7 @@ test.describe.serial("authenticated admin journey", () => {
}); });
}); });
test("return_to: a deep link while logged out returns to that page after login (§9)", async ({ page }) => { test("return_to: a deep link while logged out returns to that page after login", async ({ page }) => {
test.setTimeout(90_000); test.setTimeout(90_000);
// A gated deep link, logged out → bounced to the themed login (return_to is baked into the Kratos // A gated deep link, logged out → bounced to the themed login (return_to is baked into the Kratos
// flow server-side, so it's consumed, not shown in the settled URL). // flow server-side, so it's consumed, not shown in the settled URL).
+1 -1
View File
@@ -1,4 +1,4 @@
// Mock OIDC provider for the SSO browser E2E (todo §8) — a stand-in for Google/etc. so the test // Mock OIDC provider for the SSO browser E2E — a stand-in for Google/etc. so the test
// never leaves the compose network. Auto-approves /authorize (no provider login UI), then signs an // never leaves the compose network. Auto-approves /authorize (no provider login UI), then signs an
// RS256 id_token Kratos verifies against /jwks. stdlib only, in-memory, NOT app code. The single // RS256 id_token Kratos verifies against /jwks. stdlib only, in-memory, NOT app code. The single
// host (mock-oidc:9000) is reachable by both the browser (/authorize) and Kratos (token/jwks). // host (mock-oidc:9000) is reachable by both the browser (/authorize) and Kratos (token/jwks).
+3 -3
View File
@@ -1,16 +1,16 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
// Full-stack OAuth2 login + consent E2E (§6): another app logs in *through* plainpages. Hydra // Full-stack OAuth2 login + consent E2E: another app logs in *through* plainpages. Hydra
// starts an authorization flow and hands the browser to web's /oauth2/login; web resolves it via // starts an authorization flow and hands the browser to web's /oauth2/login; web resolves it via
// the Kratos session and accepts, Hydra continues to web's /oauth2/consent, web shows the themed // the Kratos session and accepts, Hydra continues to web's /oauth2/consent, web shows the themed
// consent screen, and Allow drives Hydra to issue the authorization code. We drive the flow over // consent screen, and Allow drives Hydra to issue the authorization code. We drive the flow over
// HTTP (fetch, per-host cookie jars) because the browser hosts differ on the compose network; this // HTTP (fetch, per-host cookie jars) because the browser hosts differ on the compose network; this
// exercises web's server-side challenge handling. The browser-UI login is owned by §8. // exercises web's server-side challenge handling. The browser-UI login is owned by the full-flow E2E (full-flow.spec.ts).
const WEB = process.env.BASE_URL ?? "http://web:3000"; const WEB = process.env.BASE_URL ?? "http://web:3000";
const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433"; const KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
const HYDRA_PUBLIC = process.env.HYDRA_PUBLIC_URL ?? "http://hydra:4444"; const HYDRA_PUBLIC = process.env.HYDRA_PUBLIC_URL ?? "http://hydra:4444";
const HYDRA_ADMIN = process.env.HYDRA_ADMIN_URL ?? "http://hydra:4445"; const HYDRA_ADMIN = process.env.HYDRA_ADMIN_URL ?? "http://hydra:4445";
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap (§3) const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap
const ADMIN_PASSWORD = "admin"; const ADMIN_PASSWORD = "admin";
function setCookieLine(res: Response, name: string): string | undefined { function setCookieLine(res: Response, name: string): string | undefined {
+3 -4
View File
@@ -1,9 +1,8 @@
import { defineConfig, devices } from "@playwright/test"; import { defineConfig, devices } from "@playwright/test";
// Visual + functional checks against the live app (the `web` compose service, BASE_URL) and the // Visual + functional checks against the live app (the `web` compose service, BASE_URL). Run via
// static html-css-foundation mockups (bind-mounted at /repo). Run via compose.e2e.yml. Parallel // compose.e2e.yml. Parallel per the project's E2E principle; deterministic colorScheme/viewport
// per the project's E2E principle (todo §1.1); deterministic colorScheme/viewport so the // so the rendered design is stable across runs.
// computed-style parity vs the reference design is stable.
export default defineConfig({ export default defineConfig({
testDir: ".", testDir: ".",
outputDir: "artifacts/test-output", outputDir: "artifacts/test-output",
+1 -1
View File
@@ -1,4 +1,4 @@
// Same-origin gateway for the browser E2E (todo §8). The themed login form posts straight to // Same-origin gateway for the browser E2E. The themed login form posts straight to
// Kratos' flow action and Kratos sets the session cookie for its own base_url host — so for a real // Kratos' flow action and Kratos sets the session cookie for its own base_url host — so for a real
// browser, web and Kratos must look like ONE origin (cookies are host-scoped). This tiny stdlib // browser, web and Kratos must look like ONE origin (cookies are host-scoped). This tiny stdlib
// reverse proxy fronts both on a single host (the browser's only origin), exactly as a production // reverse proxy fronts both on a single host (the browser's only origin), exactly as a production
+13 -46
View File
@@ -3,10 +3,6 @@ import { readFileSync } from "node:fs";
import { mkdir } from "node:fs/promises"; import { mkdir } from "node:fs/promises";
import { expect, test, type Page } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
// The mockups are bind-mounted at /repo (sibling to /repo/public so their ../public/css/ resolves).
const MOCKUP = "file:///repo/html-css-foundation";
const APP_SHELL = `${MOCKUP}/App%20Shell.html`;
const AUTH = `${MOCKUP}/Auth.html`;
const SHOTS = "artifacts/screenshots"; const SHOTS = "artifacts/screenshots";
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000"; const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
const SESSION_COOKIE = "plainpages_jwt"; // src/login.ts — web verifies it against the committed dev JWKS const SESSION_COOKIE = "plainpages_jwt"; // src/login.ts — web verifies it against the committed dev JWKS
@@ -15,7 +11,7 @@ const shot = (page: Page, name: string): Promise<Buffer> =>
page.screenshot({ fullPage: true, path: `${SHOTS}/${name}.png` }); page.screenshot({ fullPage: true, path: `${SHOTS}/${name}.png` });
// Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the // Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the
// gated dashboard (§10) renders for a "signed-in" user without standing up Ory — web verifies it // gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it
// with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT. // with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT.
function devSession(roles: string[] = []): string { function devSession(roles: string[] = []): string {
const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0]; const jwk = JSON.parse(readFileSync("/repo/jwks.json", "utf8")).keys[0];
@@ -28,16 +24,16 @@ function devSession(roles: string[] = []): string {
test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); }); test.beforeAll(async () => { await mkdir(SHOTS, { recursive: true }); });
// The dashboard is gated (§10): a page navigation needs a session. Plant one per test — a plain // The dashboard is gated: a page navigation needs a session. Plant one per test — a plain
// member (no roles) so the gated scheduling/admin nav stays filtered out, matching the mockup. // member (no roles) so the gated scheduling/admin nav stays filtered out.
test.beforeEach(async ({ context }) => { test.beforeEach(async ({ context }) => {
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]); await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
}); });
test("captures live pages + reference mockups for side-by-side review", async ({ page }) => { test("captures the live pages for review", async ({ page }) => {
await page.goto("/dashboard"); await page.goto("/dashboard");
await expect(page.locator(".sidebar")).toBeVisible(); await expect(page.locator(".sidebar")).toBeVisible();
// §10: the default /dashboard is the instructional starter, not a mock-data list. // the default /dashboard is the instructional starter, not a mock-data list.
await expect(page.getByRole("heading", { name: "Starter dashboard" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Starter dashboard" })).toBeVisible();
await shot(page, "live-01-dashboard"); await shot(page, "live-01-dashboard");
@@ -49,35 +45,6 @@ test("captures live pages + reference mockups for side-by-side review", async ({
await page.goto("/dashboard"); await page.goto("/dashboard");
await shot(page, "live-04-mobile"); await shot(page, "live-04-mobile");
await page.setViewportSize({ width: 1280, height: 800 }); await page.setViewportSize({ width: 1280, height: 800 });
await page.goto(APP_SHELL);
await shot(page, "mockup-01-app-shell");
await page.goto(AUTH);
await shot(page, "mockup-02-auth");
});
// The live DOM reuses the foundation's classes, so the same styles.css must compute identically
// on both — proof we render the intended graphics, independent of the (different) row data.
const PROPS = ["backgroundColor", "borderRadius", "borderTopColor", "color", "fontSize", "fontWeight"] as const;
const styleOf = (page: Page, selector: string): Promise<Record<string, string>> =>
page.locator(selector).first().evaluate((el, props) => {
const cs = getComputedStyle(el as Element);
return Object.fromEntries(props.map((p) => [p, cs.getPropertyValue(p) || (cs as unknown as Record<string, string>)[p]]));
}, PROPS as unknown as string[]);
test("live components compute the same design-system styles as the reference mockup", async ({ page, context }) => {
await page.goto("/dashboard");
const ref = await context.newPage();
await ref.goto(APP_SHELL);
// Shell components appear on every page, incl. the instructional /dashboard. The data components
// (.filters/.table/.pager) used to live on the mock dashboard (removed in §10); they're now covered
// by the mockup screenshots + their unit tests, and rendered live with real data by the full-flow
// E2E (the admin Users list) which the Ory-free visual suite can't stand up.
for (const selector of [".sidebar", ".topbar", ".brand", ".btn.btn-primary", ".theme-switch"]) { // .btn-primary = the dashboard's "Browse the example plugin" CTA
expect(await styleOf(page, selector), `computed style mismatch for ${selector}`).toEqual(await styleOf(ref, selector));
}
await ref.close();
}); });
test("every icon <use> resolves to a defined <symbol> (no broken graphics)", async ({ page }) => { test("every icon <use> resolves to a defined <symbol> (no broken graphics)", async ({ page }) => {
@@ -93,7 +60,7 @@ test("every icon <use> resolves to a defined <symbol> (no broken graphics)", asy
// (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
// (list-query/data-table/filter-bar) and exercised live with real data by the full-flow E2E's admin // (list-query/data-table/filter-bar) and exercised live with real data by the full-flow E2E's admin
// Users list. The mock-data dashboard that used to host it in this Ory-free suite is gone (§10).) // 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");
@@ -128,11 +95,11 @@ test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a t
expect(res.status()).toBe(403); expect(res.status()).toBe(403);
}); });
test("the public landing at / is ungated and links to sign in + register (§10)", async ({ page, context }) => { test("the public landing at / is ungated and links to sign in + register", async ({ page, context }) => {
await context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session) await context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session)
await page.goto("/"); await page.goto("/");
await expect(page.locator(".landing")).toBeVisible(); await expect(page.locator(".landing")).toBeVisible();
// §10: the same app shell every page renders — the menu shows even signed out (role-filtered). // the same app shell every page renders — the menu shows even signed out (role-filtered).
await expect(page.locator(".sidebar")).toBeVisible(); await expect(page.locator(".sidebar")).toBeVisible();
await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login"); await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login");
await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration"); await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
@@ -148,9 +115,9 @@ test("unknown routes serve the 404 page (a real user-facing flow, covered end-to
// The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is // The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is
// reachable by anyone and its menu header shows for everyone; the shifts list stays permission-gated, // reachable by anyone and its menu header shows for everyone; the shifts list stays permission-gated,
// so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the §8 full // so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the full
// E2E (full-flow.spec). Side-effect-free. // E2E (full-flow.spec). Side-effect-free.
test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login (§10)", async ({ page, request }) => { test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login", async ({ page, request }) => {
// `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so these // `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so these
// probes are genuinely anonymous. // probes are genuinely anonymous.
// The public overview is reachable with no session (200), not bounced to sign in. // The public overview is reachable with no session (200), not bounced to sign in.
@@ -158,13 +125,13 @@ test("the reference plugin: public Overview is open to all, the gated Shifts red
expect(pub.status()).toBe(200); expect(pub.status()).toBe(200);
const body = await pub.text(); const body = await pub.text();
expect(body).toContain("Scheduling"); expect(body).toContain("Scheduling");
// Anonymous in the native shell (§10): the gated Dashboard link is hidden (it would only dead-end at // Anonymous in the native shell: the gated Dashboard link is hidden (it would only dead-end at
// /login), and the shell's Sign-in link carries the current page as return_to. // /login), and the shell's Sign-in link carries the current page as return_to.
expect(body).not.toContain('href="/dashboard"'); expect(body).not.toContain('href="/dashboard"');
expect(body).toContain('href="/login?return_to=%2Fscheduling"'); expect(body).toContain('href="/login?return_to=%2Fscheduling"');
// The gated shifts list still bounces (don't follow — this Ory-free suite has no /login handler); // The gated shifts list still bounces (don't follow — this Ory-free suite has no /login handler);
// assert the gate's 303 with the requested page preserved as return_to (§9). // assert the gate's 303 with the requested page preserved as return_to.
const res = await request.get("/scheduling/shifts", { maxRedirects: 0 }); const res = await request.get("/scheduling/shifts", { maxRedirects: 0 });
expect(res.status()).toBe(303); expect(res.status()).toBe(303);
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts"); expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
@@ -172,7 +139,7 @@ test("the reference plugin: public Overview is open to all, the gated Shifts red
// The signed-in member (no scheduling role) sees the public Scheduling → Overview leaf in the nav, // The signed-in member (no scheduling role) sees the public Scheduling → Overview leaf in the nav,
// but the gated Shifts leaf is filtered out. // but the gated Shifts leaf is filtered out.
await page.goto("/dashboard"); await page.goto("/dashboard");
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders (§10) await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out
}); });
-733
View File
@@ -1,733 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>App Shell — Template</title>
<link rel="stylesheet" href="../public/css/styles.css">
</head>
<body>
<!-- ============ ICON SPRITE — Lucide (https://lucide.dev, ISC license) ============
Official Lucide paths, inlined as <symbol> so usage stays zero-JS:
<svg class="ico"><use href="#i-name"/></svg>. Stroke + currentColor are
applied via the .ico class, matching Lucide's 24-grid / round-cap style. -->
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
<symbol id="i-chev" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></symbol>
<symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol>
<symbol id="i-menu" viewBox="0 0 24 24"><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="18" y2="18"/></symbol>
<symbol id="i-kebab" viewBox="0 0 24 24"><circle cx="12" cy="12" r="1"/><circle cx="12" cy="5" r="1"/><circle cx="12" cy="19" r="1"/></symbol>
<symbol id="i-sort" viewBox="0 0 24 24"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></symbol>
<symbol id="i-up" viewBox="0 0 24 24"><path d="m18 15-6-6-6 6"/></symbol>
<symbol id="i-cal" viewBox="0 0 24 24"><path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/></symbol>
<symbol id="i-sliders" viewBox="0 0 24 24"><line x1="21" x2="14" y1="4" y2="4"/><line x1="10" x2="3" y1="4" y2="4"/><line x1="21" x2="12" y1="12" y2="12"/><line x1="8" x2="3" y1="12" y2="12"/><line x1="21" x2="16" y1="20" y2="20"/><line x1="12" x2="3" y1="20" y2="20"/><line x1="14" x2="14" y1="2" y2="6"/><line x1="8" x2="8" y1="10" y2="14"/><line x1="16" x2="16" y1="18" y2="22"/></symbol>
<symbol id="i-cols" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="M15 3v18"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol>
<symbol id="i-download" viewBox="0 0 24 24"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></symbol>
<symbol id="i-grid" viewBox="0 0 24 24"><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></symbol>
<symbol id="i-box" viewBox="0 0 24 24"><path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></symbol>
<symbol id="i-layers" viewBox="0 0 24 24"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></symbol>
<symbol id="i-chart" viewBox="0 0 24 24"><path d="M3 3v18h18"/><path d="M18 17V9"/><path d="M13 17V5"/><path d="M8 17v-3"/></symbol>
<symbol id="i-users" viewBox="0 0 24 24"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></symbol>
<symbol id="i-gear" viewBox="0 0 24 24"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></symbol>
<symbol id="i-user" viewBox="0 0 24 24"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
<symbol id="i-bell" viewBox="0 0 24 24"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></symbol>
<symbol id="i-edit" viewBox="0 0 24 24"><path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"/></symbol>
<symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></symbol>
<symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></symbol>
<symbol id="i-logout" viewBox="0 0 24 24"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/></symbol>
<symbol id="i-globe" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></symbol>
</svg>
<!-- nav-toggle drives the mobile overlay (pure CSS) -->
<input type="checkbox" id="nav-toggle" aria-hidden="true" tabindex="-1">
<div class="app">
<!-- =================== SIDEBAR =================== -->
<aside class="sidebar" aria-label="Primary">
<div class="brand">
<span class="brand-mark"><svg class="ico ico-sm"><use href="#i-box"/></svg></span>
<span class="brand-name">Console</span>
<span class="brand-sub">v0.1</span>
</div>
<!-- ====================================================
UNIFIED NAV TREE — every item uses the SAME node markup.
HEADER → node has a <details class="nav-disc"> toggle + <ul class="nav-children">
LEAF → node has a <span class="nav-spacer"> instead of a toggle
CLICKABLE → label is <a class="nav-self" href>
STATIC → label is <span class="nav-self">
Mix freely: a node can be header+clickable, header+static,
leaf+clickable, or leaf+static. Workspace / Insights below are
simply header + static nodes — nothing special about them.
==================================================== -->
<nav class="nav" aria-label="Main navigation">
<ul class="nav-tree">
<!-- leaf + clickable -->
<li class="nav-node">
<div class="nav-row">
<span class="nav-spacer" aria-hidden="true"></span>
<a class="nav-self" href="#"><svg class="ico"><use href="#i-grid"/></svg><span class="nav-label">Overview</span></a>
</div>
</li>
<!-- header + STATIC (was the "Workspace" group label) -->
<li class="nav-node">
<div class="nav-row">
<details class="nav-disc" open>
<summary class="nav-tog" aria-label="Toggle Workspace"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
</details>
<span class="nav-self"><span class="nav-label">Workspace</span></span>
</div>
<ul class="nav-children">
<!-- header + CLICKABLE -->
<li class="nav-node">
<div class="nav-row">
<details class="nav-disc" open>
<summary class="nav-tog" aria-label="Toggle Directory"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
</details>
<a class="nav-self" href="#"><svg class="ico"><use href="#i-users"/></svg><span class="nav-label">Directory</span><span class="nav-count">4</span></a>
</div>
<ul class="nav-children">
<!-- leaf + clickable (active) -->
<li class="nav-node">
<div class="nav-row">
<span class="nav-spacer" aria-hidden="true"></span>
<a class="nav-self" href="#" aria-current="page"><span class="nav-label">People</span><span class="nav-count">1,284</span></a>
</div>
</li>
<li class="nav-node">
<div class="nav-row">
<span class="nav-spacer" aria-hidden="true"></span>
<a class="nav-self" href="#"><span class="nav-label">Teams</span></a>
</div>
</li>
<!-- header + CLICKABLE -->
<li class="nav-node">
<div class="nav-row">
<details class="nav-disc" open>
<summary class="nav-tog" aria-label="Toggle Roles &amp; Access"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
</details>
<a class="nav-self" href="#"><span class="nav-label">Roles &amp; Access</span></a>
</div>
<ul class="nav-children">
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Roles</span></a></div></li>
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Permission sets</span></a></div></li>
<!-- header + CLICKABLE (level 4) -->
<li class="nav-node">
<div class="nav-row">
<details class="nav-disc" open>
<summary class="nav-tog" aria-label="Toggle Policies"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
</details>
<a class="nav-self" href="#"><span class="nav-label">Policies</span></a>
</div>
<ul class="nav-children">
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Password policy</span></a></div></li>
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Session limits</span></a></div></li>
</ul>
</li>
<!-- header + STATIC (level 4) -->
<li class="nav-node">
<div class="nav-row">
<details class="nav-disc">
<summary class="nav-tog" aria-label="Toggle Scopes"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
</details>
<span class="nav-self"><span class="nav-label">Scopes</span></span>
</div>
<ul class="nav-children">
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Read scopes</span></a></div></li>
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Write scopes</span></a></div></li>
</ul>
</li>
</ul>
</li>
<!-- header + STATIC -->
<li class="nav-node">
<div class="nav-row">
<details class="nav-disc">
<summary class="nav-tog" aria-label="Toggle Segments"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
</details>
<span class="nav-self"><span class="nav-label">Segments</span></span>
</div>
<ul class="nav-children">
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Active users</span></a></div></li>
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Invited</span></a></div></li>
</ul>
</li>
</ul>
</li>
<!-- header + STATIC -->
<li class="nav-node">
<div class="nav-row">
<details class="nav-disc" open>
<summary class="nav-tog" aria-label="Toggle Resources"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
</details>
<span class="nav-self"><svg class="ico"><use href="#i-box"/></svg><span class="nav-label">Resources</span></span>
</div>
<ul class="nav-children">
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Projects</span></a></div></li>
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Environments</span></a></div></li>
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">API keys</span></a></div></li>
<!-- leaf + STATIC (no link → not a navigation target) -->
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><span class="nav-self"><span class="nav-label">Webhooks (soon)</span></span></div></li>
</ul>
</li>
</ul>
</li>
<!-- header + STATIC (was the "Insights" group label) -->
<li class="nav-node">
<div class="nav-row">
<details class="nav-disc" open>
<summary class="nav-tog" aria-label="Toggle Insights"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
</details>
<span class="nav-self"><span class="nav-label">Insights</span></span>
</div>
<ul class="nav-children">
<li class="nav-node">
<div class="nav-row">
<span class="nav-spacer" aria-hidden="true"></span>
<a class="nav-self" href="#"><svg class="ico"><use href="#i-chart"/></svg><span class="nav-label">Reports</span></a>
</div>
</li>
<!-- header + CLICKABLE -->
<li class="nav-node">
<div class="nav-row">
<details class="nav-disc">
<summary class="nav-tog" aria-label="Toggle Activity"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
</details>
<a class="nav-self" href="#"><svg class="ico"><use href="#i-bell"/></svg><span class="nav-label">Activity</span></a>
</div>
<ul class="nav-children">
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Audit log</span></a></div></li>
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Notifications</span></a></div></li>
</ul>
</li>
<!-- header + STATIC -->
<li class="nav-node">
<div class="nav-row">
<details class="nav-disc">
<summary class="nav-tog" aria-label="Toggle Catalog"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
</details>
<span class="nav-self"><svg class="ico"><use href="#i-layers"/></svg><span class="nav-label">Catalog</span></span>
</div>
<ul class="nav-children">
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Items</span></a></div></li>
<li class="nav-node"><div class="nav-row"><span class="nav-spacer" aria-hidden="true"></span><a class="nav-self" href="#"><span class="nav-label">Categories</span></a></div></li>
</ul>
</li>
</ul>
</li>
</ul>
</nav>
<!-- ---- sidebar footer: theme + profile + settings ---- -->
<div class="side-footer">
<!-- theme switcher: Light / Auto / Dark (Auto = follow system) -->
<div class="theme-switch" role="radiogroup" aria-label="Color theme">
<label>
<input type="radio" name="theme" id="theme-light">
<span>Light</span>
</label>
<label>
<input type="radio" name="theme" id="theme-auto" checked>
<span>Auto</span>
</label>
<label>
<input type="radio" name="theme" id="theme-dark">
<span>Dark</span>
</label>
</div>
<div class="footer-actions">
<!-- profile (opens a menu) -->
<details class="menu" style="flex:1 1 auto">
<summary class="profile">
<span class="avatar" aria-hidden="true">AK</span>
<span class="profile-meta">
<span class="profile-name">Avery Kline</span>
<span class="profile-mail"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="1f7e697a6d665f7e7c727a317670">[email&#160;protected]</a></span>
</span>
</summary>
<div class="menu-pop left" style="bottom:calc(100% + 6px); top:auto; min-width:220px">
<div class="menu-head">Signed in as Avery</div>
<button class="menu-item"><svg class="ico"><use href="#i-user"/></svg>Profile</button>
<button class="menu-item"><svg class="ico"><use href="#i-globe"/></svg>Language — English</button>
<button class="menu-item"><svg class="ico"><use href="#i-bell"/></svg>Notifications</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-logout"/></svg>Sign out</button>
</div>
</details>
<!-- settings -->
<details class="menu">
<summary class="btn icon-btn" aria-label="Settings">
<svg class="ico"><use href="#i-gear"/></svg>
</summary>
<div class="menu-pop" style="bottom:calc(100% + 6px); top:auto">
<div class="menu-head">Settings</div>
<button class="menu-item"><svg class="ico"><use href="#i-gear"/></svg>Preferences</button>
<button class="menu-item"><svg class="ico"><use href="#i-users"/></svg>Members</button>
<button class="menu-item"><svg class="ico"><use href="#i-globe"/></svg>Region &amp; language</button>
</div>
</details>
</div>
</div>
</aside>
<!-- scrim closes the mobile menu (label toggles the checkbox) -->
<label class="scrim" for="nav-toggle" aria-label="Close menu"></label>
<!-- =================== CONTENT =================== -->
<main class="content">
<!-- topbar -->
<header class="topbar">
<label class="btn icon-btn hamburger" for="nav-toggle" aria-label="Open menu">
<svg class="ico"><use href="#i-menu"/></svg>
</label>
<h1 class="page-title">People</h1>
<nav class="crumbs" aria-label="Breadcrumb">
<a href="#">Directory</a><span class="sep">/</span><span>People</span>
</nav>
<div class="topbar-spacer"></div>
<button class="btn"><svg class="ico ico-sm"><use href="#i-download"/></svg>Export</button>
<button class="btn btn-primary"><svg class="ico ico-sm"><use href="#i-plus"/></svg>Add person</button>
</header>
<!-- ============ FILTER BAR ============
A real GET form: selections submit as query params (?q=…&status=…)
so filtering is server-side and works with zero JavaScript.
Every control has a name + associated label; related controls are
grouped in <fieldset>/<legend>; Apply submits, Reset clears. -->
<form class="filters" method="get" aria-label="Filter people">
<!-- row 1: search + status + team + column/extra menus -->
<div class="filter-row">
<label class="search">
<span class="sr-only">Search people</span>
<svg class="ico ico-sm" aria-hidden="true"><use href="#i-search"/></svg>
<input type="search" name="q" placeholder="Search people…">
</label>
<fieldset class="filter-field">
<legend class="sr-only">Status</legend>
<div class="segmented">
<label><input type="radio" name="status" value="all" checked><span>All</span><span class="seg-count">1,284</span></label>
<label><input type="radio" name="status" value="active"><span>Active</span></label>
<label><input type="radio" name="status" value="archived"><span>Archived</span></label>
</div>
</fieldset>
<span class="filter">
<label class="sr-only" for="f-team">Team</label>
<span class="select">
<select id="f-team" name="team">
<option value="">All teams</option>
<option value="engineering">Engineering</option>
<option value="design">Design</option>
<option value="operations">Operations</option>
<option value="sales">Sales</option>
</select>
</span>
</span>
<div class="spacer"></div>
<!-- column visibility (display preference, also persisted via the form) -->
<details class="menu">
<summary class="btn"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cols"/></svg>Columns</summary>
<div class="menu-pop">
<fieldset class="menu-field">
<legend class="menu-head">Visible columns</legend>
<label class="menu-check"><input type="checkbox" name="col" value="name" checked>Name</label>
<label class="menu-check"><input type="checkbox" name="col" value="email" checked>Email</label>
<label class="menu-check"><input type="checkbox" name="col" value="role" checked>Role</label>
<label class="menu-check"><input type="checkbox" name="col" value="team" checked>Team</label>
<label class="menu-check"><input type="checkbox" name="col" value="status" checked>Status</label>
<label class="menu-check"><input type="checkbox" name="col" value="last_active" checked>Last active</label>
<label class="menu-check"><input type="checkbox" name="col" value="created">Created</label>
</fieldset>
</div>
</details>
<details class="menu">
<summary class="btn"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-sliders"/></svg>More filters</summary>
<div class="menu-pop" style="min-width:240px">
<fieldset class="menu-field">
<legend class="menu-head">Role</legend>
<label class="menu-check"><input type="radio" name="role" value="" checked>Any role</label>
<label class="menu-check"><input type="radio" name="role" value="admin">Admin</label>
<label class="menu-check"><input type="radio" name="role" value="member">Member</label>
<label class="menu-check"><input type="radio" name="role" value="viewer">Viewer</label>
</fieldset>
<div class="menu-sep"></div>
<fieldset class="menu-field">
<legend class="menu-head">Flags</legend>
<label class="menu-check"><input type="checkbox" name="flag" value="2fa">2FA enabled</label>
<label class="menu-check"><input type="checkbox" name="flag" value="pending">Pending invite</label>
</fieldset>
</div>
</details>
</div>
<!-- row 2: tags + joined date range -->
<div class="filter-row">
<fieldset class="filter-field">
<legend class="sr-only">Tags</legend>
<span class="filter-legend" aria-hidden="true">Tags</span>
<div class="chips">
<label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="tag" value="engineering" checked>Engineering</label>
<label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="tag" value="design">Design</label>
<label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="tag" value="oncall" checked>On-call</label>
<label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="tag" value="contractor">Contractor</label>
<label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="tag" value="remote">Remote</label>
</div>
</fieldset>
<div class="spacer"></div>
<fieldset class="filter-field">
<legend class="sr-only">Joined</legend>
<span class="filter-legend" aria-hidden="true">Joined</span>
<div class="daterange">
<svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"/></svg>
<label class="sr-only" for="f-from">Joined from</label>
<input type="date" id="f-from" name="joined_from" value="2026-01-01">
<span class="to" aria-hidden="true">to</span>
<label class="sr-only" for="f-to">Joined to</label>
<input type="date" id="f-to" name="joined_to" value="2026-06-14">
</div>
</fieldset>
</div>
<!-- row 3: applied filters (server-rendered) + form actions -->
<div class="filter-row filter-foot">
<div class="active-pills" aria-label="Applied filters">
<span class="filter-legend">Applied</span>
<span class="pill"><b>Team:</b> Engineering <a class="pill-x" href="?tag=oncall&amp;joined_from=2026-01-01" aria-label="Remove Team filter"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg></a></span>
<span class="pill"><b>Tag:</b> On-call <a class="pill-x" href="?team=engineering&amp;joined_from=2026-01-01" aria-label="Remove On-call filter"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg></a></span>
<span class="pill"><b>Joined:</b> 2026 <a class="pill-x" href="?team=engineering&amp;tag=oncall" aria-label="Remove Joined filter"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg></a></span>
<a class="pill-clear" href="?">Clear all</a>
</div>
<div class="spacer"></div>
<div class="filter-actions">
<button type="reset" class="btn">Reset</button>
<button type="submit" class="btn btn-primary"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-search"/></svg>Apply filters</button>
</div>
</div>
</form>
<!-- ============ TABLE ============ -->
<div class="table-wrap">
<table class="table">
<caption class="sr-only">People in the directory</caption>
<thead>
<tr>
<th class="col-check" scope="col">
<input type="checkbox" aria-label="Select all rows">
</th>
<th scope="col" aria-sort="ascending">
<button class="th-sort">Name <svg class="ico ico-sm sort-ico"><use href="#i-up"/></svg></button>
</th>
<th scope="col">
<button class="th-sort">Email <svg class="ico ico-sm sort-ico"><use href="#i-sort"/></svg></button>
</th>
<th scope="col">
<button class="th-sort">Role <svg class="ico ico-sm sort-ico"><use href="#i-sort"/></svg></button>
</th>
<th scope="col">Team</th>
<th scope="col">Status</th>
<th scope="col">
<button class="th-sort">Last active <svg class="ico ico-sm sort-ico"><use href="#i-sort"/></svg></button>
</th>
<th class="col-actions" scope="col"><span class="sr-only">Actions</span></th>
</tr>
</thead>
<tbody>
<!-- row template, repeated -->
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Mara Delgado"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">MD</span><span class="cell-strong">Mara Delgado</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="80ede1f2e1aee4e5ece7e1e4efc0e1e3ede5aee9ef">[email&#160;protected]</a></td>
<td>Admin</td>
<td class="cell-muted">Engineering</td>
<td><span class="badge pos"><span class="dot"></span>Active</span></td>
<td class="cell-muted">2 min ago</td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Mara Delgado"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Soren Vance"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">SV</span><span class="cell-strong">Soren Vance</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="4b3824392e25653d2a25282e0b2a28262e652224">[email&#160;protected]</a></td>
<td>Member</td>
<td class="cell-muted">Design</td>
<td><span class="badge warn"><span class="dot"></span>Idle</span></td>
<td class="cell-muted">3 hours ago</td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Soren Vance"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Priya Nair"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">PN</span><span class="cell-strong">Priya Nair</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="5f2f2d36263e71313e362d1f3e3c323a713630">[email&#160;protected]</a></td>
<td>Admin</td>
<td class="cell-muted">Operations</td>
<td><span class="badge pos"><span class="dot"></span>Active</span></td>
<td class="cell-muted">just now</td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Priya Nair"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Eli Brandt"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">EB</span><span class="cell-strong">Eli Brandt</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="b0d5dcd99ed2c2d1ded4c4f0d1d3ddd59ed9df">[email&#160;protected]</a></td>
<td>Viewer</td>
<td class="cell-muted">Sales</td>
<td><span class="badge neg"><span class="dot"></span>Suspended</span></td>
<td class="cell-muted">6 days ago</td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Eli Brandt"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Tomas Lindqvist"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">TL</span><span class="cell-strong">Tomas Lindqvist</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="d9adb6b4b8aaf7b599b8bab4bcf7b0b6">[email&#160;protected]</a></td>
<td>Member</td>
<td class="cell-muted">Engineering</td>
<td><span class="badge info"><span class="dot"></span>Invited</span></td>
<td class="cell-muted"></td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Tomas Lindqvist"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Hana Osei"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">HO</span><span class="cell-strong">Hana Osei</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="e28a838c83cc8d91878ba283818f87cc8b8d">[email&#160;protected]</a></td>
<td>Member</td>
<td class="cell-muted">Design</td>
<td><span class="badge pos"><span class="dot"></span>Active</span></td>
<td class="cell-muted">21 min ago</td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Hana Osei"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Rafael Costa"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">RC</span><span class="cell-strong">Rafael Costa</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="fd8f9c9b9c9891d39e928e899cbd9c9e9098d39492">[email&#160;protected]</a></td>
<td>Admin</td>
<td class="cell-muted">Operations</td>
<td><span class="badge warn"><span class="dot"></span>Idle</span></td>
<td class="cell-muted">1 hour ago</td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Rafael Costa"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Wen Li"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">WL</span><span class="cell-strong">Wen Li</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="dfa8bab1f1b3b69fbebcb2baf1b6b0">[email&#160;protected]</a></td>
<td>Viewer</td>
<td class="cell-muted">Sales</td>
<td><span class="badge pos"><span class="dot"></span>Active</span></td>
<td class="cell-muted">44 min ago</td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Wen Li"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Nadia Farouk"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">NF</span><span class="cell-strong">Nadia Farouk</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="4826292c2129662e293a273d2308292b252d662127">[email&#160;protected]</a></td>
<td>Member</td>
<td class="cell-muted">Engineering</td>
<td><span class="badge neg"><span class="dot"></span>Suspended</span></td>
<td class="cell-muted">12 days ago</td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Nadia Farouk"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Otto Berg"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">OB</span><span class="cell-strong">Otto Berg</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="4a253e3e2564282f382d0a2b29272f642325">[email&#160;protected]</a></td>
<td>Member</td>
<td class="cell-muted">Design</td>
<td><span class="badge info"><span class="dot"></span>Invited</span></td>
<td class="cell-muted"></td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Otto Berg"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Greta Holm"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">GH</span><span class="cell-strong">Greta Holm</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="2047524554410e484f4c4d6041434d450e494f">[email&#160;protected]</a></td>
<td>Admin</td>
<td class="cell-muted">Operations</td>
<td><span class="badge pos"><span class="dot"></span>Active</span></td>
<td class="cell-muted">8 min ago</td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Greta Holm"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
<tr>
<td class="col-check"><input type="checkbox" class="row-select" aria-label="Select Yusuf Demir"></td>
<td><span class="cell-user"><span class="avatar" aria-hidden="true">YD</span><span class="cell-strong">Yusuf Demir</span></span></td>
<td class="cell-muted cell-mono"><a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="2059555355460e44454d49526041434d450e494f">[email&#160;protected]</a></td>
<td>Viewer</td>
<td class="cell-muted">Sales</td>
<td><span class="badge warn"><span class="dot"></span>Idle</span></td>
<td class="cell-muted">5 hours ago</td>
<td class="col-actions">
<details class="menu kebab">
<summary aria-label="Row actions for Yusuf Demir"><svg class="ico ico-sm"><use href="#i-kebab"/></svg></summary>
<div class="menu-pop">
<button class="menu-item"><svg class="ico"><use href="#i-edit"/></svg>Edit</button>
<button class="menu-item"><svg class="ico"><use href="#i-copy"/></svg>Duplicate</button>
<div class="menu-sep"></div>
<button class="menu-item danger"><svg class="ico"><use href="#i-trash"/></svg>Delete</button>
</div>
</details>
</td>
</tr>
</tbody>
</table>
</div>
<!-- ============ PAGINATION ============ -->
<footer class="pager">
<span>112 of <b>1,284</b></span>
<div class="pager-rows">
<label for="rows">Rows</label>
<div class="select">
<select id="rows" aria-label="Rows per page">
<option>12</option><option>25</option><option>50</option><option>100</option>
</select>
</div>
</div>
<div class="spacer"></div>
<nav class="page-nums" aria-label="Pagination">
<button class="page-btn" disabled aria-label="Previous page"><svg class="ico ico-sm" style="transform:rotate(180deg)"><use href="#i-chev"/></svg></button>
<button class="page-btn" aria-current="page">1</button>
<button class="page-btn">2</button>
<button class="page-btn">3</button>
<button class="page-btn"></button>
<button class="page-btn">107</button>
<button class="page-btn" aria-label="Next page"><svg class="ico ico-sm"><use href="#i-chev"/></svg></button>
</nav>
</footer>
</main>
</div>
<script data-cfasync="false" src="/cdn-cgi/scripts/5c5dd728/cloudflare-static/email-decode.min.js"></script></body>
</html>
-216
View File
@@ -1,216 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sign in — Console</title>
<link rel="stylesheet" href="../public/css/styles.css">
<link rel="stylesheet" href="../public/css/auth.css">
</head>
<body>
<!-- ============ ICON SPRITE — Lucide (ISC) ============ -->
<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">
<symbol id="i-box" viewBox="0 0 24 24"><path d="m7.5 4.27 9 5.15"/><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/></symbol>
<symbol id="i-mail" viewBox="0 0 24 24"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></symbol>
<symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol>
<symbol id="i-user" viewBox="0 0 24 24"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
<symbol id="i-arrow-left" viewBox="0 0 24 24"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/></symbol>
<symbol id="i-check-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></symbol>
<symbol id="i-alert" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></symbol>
</svg>
<main class="auth-stage">
<div class="auth">
<div class="auth-brand">
<span class="brand-mark"><svg class="ico ico-sm"><use href="#i-box"/></svg></span>
<span class="brand-name">Console</span>
</div>
<!-- =================== LOGIN =================== -->
<section id="login" class="auth-view" aria-labelledby="login-title">
<form class="auth-card" method="post" action="#">
<div class="auth-head">
<h1 id="login-title">Sign in</h1>
<p class="auth-sub">Welcome back. Enter your details to continue.</p>
</div>
<!-- SSO section (toggle on/off) -->
<div class="sso" aria-label="Single sign-on options">
<ul class="sso-list">
<!-- add a provider: copy one <li> and change the logo + label -->
<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true">G</span><span class="sso-label">Continue with Google</span></button></li>
<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true">M</span><span class="sso-label">Continue with Microsoft</span></button></li>
<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true"><svg class="ico ico-sm"><use href="#i-shield"/></svg></span><span class="sso-label">Continue with SAML SSO</span></button></li>
</ul>
<div class="auth-divider">or</div>
</div>
<div class="auth-form">
<div class="field">
<label for="login-email">Email</label>
<div class="input-wrap">
<svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#i-mail"/></svg>
<input class="input has-ico" id="login-email" name="email" type="email" autocomplete="email" placeholder="you@company.com" required>
</div>
</div>
<div class="field">
<div class="field-top">
<label for="login-password">Password</label>
<a class="field-link" href="#forgot">Forgot password?</a>
</div>
<div class="input-wrap">
<svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#i-lock"/></svg>
<input class="input has-ico" id="login-password" name="password" type="password" autocomplete="current-password" placeholder="••••••••" required>
</div>
</div>
<label class="check remember"><input type="checkbox" name="remember" value="1"> Keep me signed in</label>
<button type="submit" class="btn btn-primary btn-block">Sign in</button>
</div>
<p class="auth-alt">Don't have an account? <a href="#register">Create one</a></p>
</form>
</section>
<!-- =================== REGISTER =================== -->
<section id="register" class="auth-view" aria-labelledby="register-title">
<form class="auth-card" method="post" action="#">
<div class="auth-head">
<h1 id="register-title">Create account</h1>
<p class="auth-sub">Get started — it only takes a minute.</p>
</div>
<div class="sso" aria-label="Single sign-on options">
<ul class="sso-list">
<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true">G</span><span class="sso-label">Sign up with Google</span></button></li>
<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true">M</span><span class="sso-label">Sign up with Microsoft</span></button></li>
<li><button type="button" class="sso-btn"><span class="sso-logo" aria-hidden="true"><svg class="ico ico-sm"><use href="#i-shield"/></svg></span><span class="sso-label">Sign up with SAML SSO</span></button></li>
</ul>
<div class="auth-divider">or</div>
</div>
<div class="auth-form">
<div class="register-alert alert alert-neg" role="alert">
<svg class="ico ico-sm" aria-hidden="true"><use href="#i-alert"/></svg>
<div class="alert-body"><strong>Please fix the highlighted fields</strong><span>A couple of details need your attention before we can create your account.</span></div>
</div>
<div class="field">
<div class="field-top">
<label for="reg-name">Name</label>
<span class="optional">Optional</span>
</div>
<div class="input-wrap">
<svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#i-user"/></svg>
<input class="input has-ico" id="reg-name" name="name" type="text" autocomplete="name" placeholder="Avery Kline">
</div>
</div>
<div class="field">
<label for="reg-email">Email</label>
<div class="input-wrap">
<svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#i-mail"/></svg>
<input class="input has-ico" id="reg-email" name="email" type="email" autocomplete="email" placeholder="you@company.com" aria-describedby="reg-email-err" required>
</div>
<p class="field-error err-email" id="reg-email-err" role="alert">
<svg class="ico ico-sm" aria-hidden="true"><use href="#i-alert"/></svg>
<span>This email is already used by another account. <a href="#login">Sign in instead</a>.</span>
</p>
</div>
<div class="field">
<label for="reg-password">Password</label>
<div class="input-wrap">
<svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#i-lock"/></svg>
<input class="input has-ico" id="reg-password" name="password" type="password" autocomplete="new-password" placeholder="At least 8 characters" minlength="8" aria-describedby="reg-password-err" required>
</div>
<span class="field-hint">Use 8 or more characters.</span>
<p class="field-error err-password" id="reg-password-err" role="alert">
<svg class="ico ico-sm" aria-hidden="true"><use href="#i-alert"/></svg>
<span>Password must be at least 8 characters.</span>
</p>
</div>
<button type="submit" class="btn btn-primary btn-block">Create account</button>
</div>
<p class="auth-alt">Already have an account? <a href="#login">Sign in</a></p>
</form>
</section>
<!-- =================== FORGOT PASSWORD =================== -->
<section id="forgot" class="auth-view" aria-labelledby="forgot-title">
<form class="auth-card" method="post">
<div class="auth-head">
<a class="auth-back" href="#login"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-arrow-left"/></svg>Back to sign in</a>
<h1 id="forgot-title">Reset password</h1>
<p class="auth-sub">Enter your email and we'll send you a reset link.</p>
</div>
<!-- feedback (shown by server via .state-sent / .state-error on #forgot) -->
<div class="forgot-alert is-sent alert alert-pos" role="status">
<svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg>
<div class="alert-body"><strong>Check your email</strong><span>If an account exists for that address, a reset link is on its way.</span></div>
</div>
<div class="forgot-alert is-error alert alert-neg" role="alert">
<svg class="ico ico-sm" aria-hidden="true"><use href="#i-alert"/></svg>
<div class="alert-body"><strong>Couldn't send the link</strong><span>Something went wrong on our end. Please try again.</span></div>
</div>
<div class="auth-form">
<div class="field">
<label for="forgot-email">Email</label>
<div class="input-wrap">
<svg class="ico ico-sm input-ico" aria-hidden="true"><use href="#i-mail"/></svg>
<input class="input has-ico" id="forgot-email" name="email" type="email" autocomplete="email" placeholder="you@company.com" required>
</div>
</div>
<button type="submit" class="btn btn-primary btn-block">Send reset link</button>
</div>
<p class="auth-alt">Remembered it? <a href="#login">Sign in</a></p>
</form>
</section>
</div>
</main>
<!-- ============ TEMPLATE PREVIEW CONTROLS (remove for production) ============ -->
<div class="tpl-controls" role="group" aria-label="Template preview controls">
<span class="tpl-label">Preview</span>
<div class="theme-switch" role="radiogroup" aria-label="Color theme">
<label><input type="radio" name="theme" id="theme-light"><span>Light</span></label>
<label><input type="radio" name="theme" id="theme-auto" checked><span>Auto</span></label>
<label><input type="radio" name="theme" id="theme-dark"><span>Dark</span></label>
</div>
<span class="tpl-sep" aria-hidden="true"></span>
<label class="tpl-toggle">
<input type="checkbox" id="sso-toggle" checked>
<span class="tpl-track" aria-hidden="true"></span>
SSO
</label>
<span class="tpl-sep tpl-forgot" aria-hidden="true"></span>
<div class="tpl-forgot">
<div class="segmented" role="radiogroup" aria-label="Forgot-password state (preview)">
<label><input type="radio" name="fstate" id="fstate-default" checked><span>Default</span></label>
<label><input type="radio" name="fstate" id="fstate-sent"><span>Sent</span></label>
<label><input type="radio" name="fstate" id="fstate-error"><span>Error</span></label>
</div>
</div>
<span class="tpl-sep tpl-register" aria-hidden="true"></span>
<div class="tpl-register">
<div class="segmented" role="radiogroup" aria-label="Register state (preview)">
<label><input type="radio" name="rstate" id="rstate-default" checked><span>Default</span></label>
<label><input type="radio" name="rstate" id="rstate-taken"><span>Email taken</span></label>
<label><input type="radio" name="rstate" id="rstate-combined"><span>Multiple</span></label>
</div>
</div>
</div>
</body>
</html>
+2 -2
View File
@@ -2,7 +2,7 @@
# plainpages (README: "OAuth2 provider"). The web app implements Hydra's login & # plainpages (README: "OAuth2 provider"). The web app implements Hydra's login &
# consent steps at the URLs below, authenticating the user via their Kratos session; # consent steps at the URLs below, authenticating the user via their Kratos session;
# Hydra mints the tokens. DSN comes from the env (the per-service hydra DB). Only # Hydra mints the tokens. DSN comes from the env (the per-service hydra DB). Only
# relevant when external apps log in through us — nothing first-party needs it (§6). # relevant when external apps log in through us — nothing first-party needs it.
serve: serve:
public: public:
port: 4444 port: 4444
@@ -10,7 +10,7 @@ serve:
port: 4445 port: 4445
# issuer = the public OAuth2 URL clients use; login/consent/logout hand the browser to # issuer = the public OAuth2 URL clients use; login/consent/logout hand the browser to
# our themed handlers (§6). Dev defaults (http) — prod overrides issuer via env (https). # our themed handlers. Dev defaults (http) — prod overrides issuer via env (https).
urls: urls:
self: self:
issuer: http://127.0.0.1:4444/ issuer: http://127.0.0.1:4444/
+1 -1
View File
@@ -8,7 +8,7 @@ import { Context, Namespace, SubjectSet } from "@ory/keto-namespace-types"
class User implements Namespace {} class User implements Namespace {}
// A subject set: a named collection of users (and nested groups), resolved transitively. // A subject set: a named collection of users (and nested groups), resolved transitively.
// The admin "Groups" screen (§5) manages membership; checks expand it automatically. // The admin "Groups" screen manages membership; checks expand it automatically.
class Group implements Namespace { class Group implements Namespace {
related: { related: {
members: (User | SubjectSet<Group, "members">)[] members: (User | SubjectSet<Group, "members">)[]
+6 -6
View File
@@ -1,6 +1,6 @@
# Ory Kratos — identity & self-service auth. Identity schema (email, name) + # Ory Kratos — identity & self-service auth. Identity schema (email, name) +
# password login; recovery & verification run on email codes. Every self-service # password login; recovery & verification run on email codes. Every self-service
# flow returns to our own themed routes (§4 renders the fields). DSN + prod # flow returns to our own themed routes (renders the fields). DSN + prod
# courier/secrets come from the env. Session→JWT tokenizer wired below (signing # courier/secrets come from the env. Session→JWT tokenizer wired below (signing
# key in tokenizer/jwks.json). # key in tokenizer/jwks.json).
serve: serve:
@@ -24,7 +24,7 @@ selfservice:
code: # email one-time code — powers recovery + verification (not login) code: # email one-time code — powers recovery + verification (not login)
enabled: true enabled: true
# Social sign-in, OFF by default → clean clone is password-only. Activate via env only # Social sign-in, OFF by default → clean clone is password-only. Activate via env only
# (no code; the whole-array form is the only env-settable one Kratos offers); §4 derives # (no code; the whole-array form is the only env-settable one Kratos offers); derives
# the buttons from this list. SAML isn't in OSS Kratos — bridge it as OIDC (README). # the buttons from this list. SAML isn't in OSS Kratos — bridge it as OIDC (README).
# SELFSERVICE_METHODS_OIDC_ENABLED=true # SELFSERVICE_METHODS_OIDC_ENABLED=true
# SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS=[{"id":"google","provider":"google", # SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS=[{"id":"google","provider":"google",
@@ -41,7 +41,7 @@ selfservice:
ui_url: http://localhost:3000/login ui_url: http://localhost:3000/login
after: after:
# After authenticating, land on our completion route — it mints the session JWT # After authenticating, land on our completion route — it mints the session JWT
# (roles from Keto → metadata_public projection → tokenize) and sets our cookie (§4). # (roles from Keto → metadata_public projection → tokenize) and sets our cookie.
default_browser_return_url: http://localhost:3000/auth/complete default_browser_return_url: http://localhost:3000/auth/complete
registration: registration:
ui_url: http://localhost:3000/registration ui_url: http://localhost:3000/registration
@@ -82,7 +82,7 @@ identity:
url: file:///etc/config/kratos/identity.schema.json url: file:///etc/config/kratos/identity.schema.json
# "Stay signed in" backbone: a long-lived Kratos session that the app re-mints the # "Stay signed in" backbone: a long-lived Kratos session that the app re-mints the
# short-lived (~10m) JWT off (§4). Sliding refresh — an active session is extended # short-lived (~10m) JWT off. Sliding refresh — an active session is extended
# back to full lifespan only once it's within earliest_possible_extend of expiry, # back to full lifespan only once it's within earliest_possible_extend of expiry,
# so frequent users never lapse without a DB write per request. # so frequent users never lapse without a DB write per request.
session: session:
@@ -92,7 +92,7 @@ session:
name: plainpages_session name: plainpages_session
persistent: true # survive browser restarts persistent: true # survive browser restarts
same_site: Lax same_site: Lax
# Session→JWT tokenizer (§4): whoami(tokenize_as: plainpages) mints a short-lived, # Session→JWT tokenizer: whoami(tokenize_as: plainpages) mints a short-lived,
# locally-verifiable JWT so the hot path never calls Ory. Claims come from the # locally-verifiable JWT so the hot path never calls Ory. Claims come from the
# committed Jsonnet mapper (sub = identity id, email from traits, roles from the # committed Jsonnet mapper (sub = identity id, email from traits, roles from the
# metadata_public projection); signed with tokenizer/jwks.json. # metadata_public projection); signed with tokenizer/jwks.json.
@@ -105,7 +105,7 @@ session:
claims_mapper_url: file:///etc/config/kratos/tokenizer/plainpages.jsonnet claims_mapper_url: file:///etc/config/kratos/tokenizer/plainpages.jsonnet
jwks_url: file:///etc/config/kratos/tokenizer/jwks.json jwks_url: file:///etc/config/kratos/tokenizer/jwks.json
# Dev throwaways — production supplies real secrets via env (§3). cipher = 32 chars. # Dev throwaways — production supplies real secrets via env. cipher = 32 chars.
secrets: secrets:
cookie: cookie:
- PLEASE-CHANGE-ME-dev-kratos-cookie-secret - PLEASE-CHANGE-ME-dev-kratos-cookie-secret
+1 -1
View File
@@ -1,4 +1,4 @@
// Session→JWT claims mapper for the `plainpages` tokenizer (§4). Kratos exposes the // Session→JWT claims mapper for the `plainpages` tokenizer. Kratos exposes the
// session as `session`; `sub` is set from the identity id (subject_source: id) and // session as `session`; `sub` is set from the identity id (subject_source: id) and
// can't be overridden here. roles come from metadata_public — the per-login projection // can't be overridden here. roles come from metadata_public — the per-login projection
// of Keto roles the app refreshes at login (metadata_admin is NOT carried in the session // of Keto roles the app refreshes at login (metadata_admin is NOT carried in the session
+2 -2
View File
@@ -1,4 +1,4 @@
// Reference plugin (todo §7): a worked example of the contract — a list page that fetches upstream // Reference plugin: a worked example of the contract — a list page that fetches upstream
// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this // data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this
// folder, rename it, point it at your own backend. Full contract: docs/plugin-contract.md. // folder, rename it, point it at your own backend. Full contract: docs/plugin-contract.md.
@@ -19,7 +19,7 @@ export default definePlugin({
// Merged into the global menu + filtered per user. "Overview" is `public`, so the "Scheduling" // Merged into the global menu + filtered per user. "Overview" is `public`, so the "Scheduling"
// header shows for everyone (even signed out); "Shifts" needs `scheduling:read`, so the gated data // header shows for everyone (even signed out); "Shifts" needs `scheduling:read`, so the gated data
// stays hidden until a reader signs in (§10 — a plugin may make a page + its menu option public). // stays hidden until a reader signs in (a plugin may make a page + its menu option public).
nav: [{ nav: [{
children: [ children: [
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true }, { href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true },
+1 -1
View File
@@ -112,7 +112,7 @@ test("listShifts degrades to a recoverable error page when the upstream is down
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []); assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []);
}); });
// ---- public overview handler (§10: a page anyone can reach, gated data stays behind the role) ---- // ---- public overview handler (a page anyone can reach, gated data stays behind the role) ----
test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => { test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
const anon = asView(await overview()(fakeCtx())); // user null, no roles const anon = asView(await overview()(fakeCtx())); // user null, no roles
+5 -5
View File
@@ -1,4 +1,4 @@
// Reference plugin (todo §7) — Scheduling/Shifts handlers + the upstream client. Shows the blessed // Reference plugin — Scheduling/Shifts handlers + the upstream client. Shows the blessed
// shape: a thin handler parses ctx, calls an upstream REST service, and returns a RouteResult the // shape: a thin handler parses ctx, calls an upstream REST service, and returns a RouteResult the
// host renders. The plugin holds no state of its own (README "Stateless") — data lives upstream. // host renders. The plugin holds no state of its own (README "Stateless") — data lives upstream.
// //
@@ -8,7 +8,7 @@
// One import from the host's plugin-api barrel — the stable author surface (see docs/plugin-contract.md). // One import from the host's plugin-api barrel — the stable author surface (see docs/plugin-contract.md).
import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, tracedFetch } from "../../src/plugin-api.ts"; import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, tracedFetch } from "../../src/plugin-api.ts";
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page (§10) export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
export const SHIFTS_PATH = "/scheduling/shifts"; export const SHIFTS_PATH = "/scheduling/shifts";
export const READ = "scheduling:read"; // permission token gating the list + nav export const READ = "scheduling:read"; // permission token gating the list + nav
export const WRITE = "scheduling:write"; // permission token gating create export const WRITE = "scheduling:write"; // permission token gating create
@@ -56,7 +56,7 @@ export function assertHttpUrl(value: string, name: string): void {
} }
// REST client over the upstream service (a stand-in for the customer's real backend). `fetch` // REST client over the upstream service (a stand-in for the customer's real backend). `fetch`
// defaults to the host's tracedFetch (§9), so each upstream call joins the request's trace (a client // defaults to the host's tracedFetch, so each upstream call joins the request's trace (a client
// span + a propagated traceparent); it's injectable so handlers unit-test against a mock, no network. // span + a propagated traceparent); it's injectable so handlers unit-test against a mock, no network.
export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream { export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream {
const base = baseUrl.replace(/\/+$/, ""); const base = baseUrl.replace(/\/+$/, "");
@@ -172,7 +172,7 @@ export function listShifts(upstream: ShiftsUpstream): RouteHandler {
try { try {
shifts = await upstream.list(); shifts = await upstream.list();
} catch (err) { } catch (err) {
ctx.log.warn("scheduling upstream unreachable", { error: String(err) }); // plugin logging via ctx.log (§9) ctx.log.warn("scheduling upstream unreachable", { error: String(err) }); // plugin logging via ctx.log
error = "Couldn't reach the scheduling service — try again shortly."; error = "Couldn't reach the scheduling service — try again shortly.";
} }
const needle = q.toLowerCase(); const needle = q.toLowerCase();
@@ -185,7 +185,7 @@ export function newShiftForm(): RouteHandler {
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome }), view: "shift-new" }); return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome }), view: "shift-new" });
} }
// Public overview (§10): a page anyone may reach — its route + nav node are marked `public`, so the // Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data // gate lets an anonymous visitor through and the menu option shows for everyone. The real data
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone // (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
// else a prompt to sign in. ctx.user may be null here, so read the role via can() (zero I/O). // else a prompt to sign in. ctx.user may be null here, so read the role via can() (zero I/O).
+1 -1
View File
@@ -1,5 +1,5 @@
<%# <%#
Scheduling · public overview (reference plugin, §10). A page ANYONE may reach — the route and its Scheduling · public overview (reference plugin). A page ANYONE may reach — the route and its
nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for
everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to
it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome. it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome.
+1 -1
View File
@@ -23,7 +23,7 @@
} }
.auth-brand .brand-name { font-size: 16px; } .auth-brand .brand-name { font-size: 16px; }
/* public landing — the ungated "/" (§10): centered intro + prominent sign-in/register actions */ /* public landing — the ungated "/": centered intro + prominent sign-in/register actions */
.landing { .landing {
width: 100%; max-width: 560px; width: 100%; max-width: 560px;
display: flex; flex-direction: column; align-items: center; gap: 18px; display: flex; flex-direction: column; align-items: center; gap: 18px;
+3 -3
View File
@@ -666,7 +666,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
/* the nav-toggle checkbox itself is visually hidden but focusable */ /* the nav-toggle checkbox itself is visually hidden but focusable */
#nav-toggle { position: absolute; opacity: 0; pointer-events: none; } #nav-toggle { position: absolute; opacity: 0; pointer-events: none; }
/* admin forms (§5): create/edit user, account actions */ /* admin forms: create/edit user, account actions */
.form-page { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 560px; } .form-page { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
.form-card { .form-card {
display: flex; flex-direction: column; gap: 14px; display: flex; flex-direction: column; gap: 14px;
@@ -686,8 +686,8 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
.btn-danger:hover { background: var(--neg-bg); } .btn-danger:hover { background: var(--neg-bg); }
.recovery-code code { font-size: 1.15rem; font-weight: 600; letter-spacing: 0.04em; } .recovery-code code { font-size: 1.15rem; font-weight: 600; letter-spacing: 0.04em; }
.code-block { margin: 0; padding: 12px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); overflow-x: auto; font-size: var(--fz-sm); } .code-block { margin: 0; padding: 12px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); overflow-x: auto; font-size: var(--fz-sm); }
/* Chromeless shell (§10): a page may drop the sidebar for a focused single column. */ /* Chromeless shell: a page may drop the sidebar for a focused single column. */
.app-bare { grid-template-columns: minmax(0, 1fr); } .app-bare { grid-template-columns: minmax(0, 1fr); }
.app-bare .content { grid-column: 1; } .app-bare .content { grid-column: 1; }
/* Auth/landing rendered inside the app shell (§10): a roomy, centered column in the content area. */ /* Auth/landing rendered inside the app shell: a roomy, centered column in the content area. */
.shell-auth { flex: 1 1 auto; overflow-y: auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; } .shell-auth { flex: 1 1 auto; overflow-y: auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; }
+1 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# The full CI gate (todo §8): typecheck → unit tests → every E2E suite, each against a FRESH stack # The full CI gate: typecheck → unit tests → every E2E suite, each against a FRESH stack
# that is always torn down. One reproducible command — run it locally or wire it into your CI # that is always torn down. One reproducible command — run it locally or wire it into your CI
# service. Docker-only (it drives `docker compose`; node/npm/tsc run inside containers, never the host). # service. Docker-only (it drives `docker compose`; node/npm/tsc run inside containers, never the host).
# #
+1 -1
View File
@@ -1,4 +1,4 @@
// Built-in OAuth2 clients admin screen (§6): the pure view-model + Hydra-payload builders. A client // Built-in OAuth2 clients admin screen: the pure view-model + Hydra-payload builders. A client
// is an Ory Hydra OAuth2 client (apps that log in *through* us); writes go only to Hydra. The // is an Ory Hydra OAuth2 client (apps that log in *through* us); writes go only to Hydra. The
// HTTP routing/gate/CSRF + live Hydra calls (incl. the one-time secret) are exercised in app.test.ts. // HTTP routing/gate/CSRF + live Hydra calls (incl. the one-time secret) are exercised in app.test.ts.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
+3 -3
View File
@@ -1,5 +1,5 @@
// Built-in OAuth2 clients admin screen (todo §6): register / list / delete the OAuth2 clients other // Built-in OAuth2 clients admin screen: register / list / delete the OAuth2 clients other
// apps log in *through* us with (Ory Hydra, the §6 login+consent handlers). A client is an Ory Hydra // apps log in *through* us with (Ory Hydra, the login+consent handlers). A client is an Ory Hydra
// OAuth2 client; writes go only to Hydra. Hydra returns the client_secret once, on create — so the // OAuth2 client; writes go only to Hydra. Hydra returns the client_secret once, on create — so the
// register POST renders the new client's detail page (with the one-time secret) directly instead of a // register POST renders the new client's detail page (with the one-time secret) directly instead of a
// PRG redirect (mirrors the Users "trigger recovery" one-time code). `handleAdminClients` is the // PRG redirect (mirrors the Users "trigger recovery" one-time code). `handleAdminClients` is the
@@ -310,7 +310,7 @@ export async function handleAdminClients(ctx: RequestContext, csrfToken: string,
created = await hydra.createClient(clientPayload(input)); created = await hydra.createClient(clientPayload(input));
} catch (err) { } catch (err) {
// A Hydra 4xx (bad redirect/scope it rejects) is the operator's input — re-render the form; // A Hydra 4xx (bad redirect/scope it rejects) is the operator's input — re-render the form;
// a 5xx (Hydra down) rethrows → 500. Mirrors the §6 challenge-handler degrade. // a 5xx (Hydra down) rethrows → 500. Mirrors the challenge-handler degrade.
if (err instanceof HydraError && err.status < 500) { if (err instanceof HydraError && err.status < 500) {
return { ...(await renderForm({ error: "Hydra rejected the client — check the redirect URIs and scopes.", values: input })), status: 400 }; return { ...(await renderForm({ error: "Hydra rejected the client — check the redirect URIs and scopes.", values: input })), status: 400 };
} }
+1 -1
View File
@@ -1,4 +1,4 @@
// Built-in Groups admin screen (§5): the pure view-model + Keto-tuple builders. A group is a // Built-in Groups admin screen: the pure view-model + Keto-tuple builders. A group is a
// Keto subject set (Group:<name>#members); membership tuples carry users (subject_id) or nested // Keto subject set (Group:<name>#members); membership tuples carry users (subject_id) or nested
// groups (subject_set). The HTTP routing/gate/CSRF + live Keto/Kratos calls are exercised over // groups (subject_set). The HTTP routing/gate/CSRF + live Keto/Kratos calls are exercised over
// HTTP in app.test.ts. // HTTP in app.test.ts.
+1 -1
View File
@@ -1,4 +1,4 @@
// Built-in Groups admin screen (todo §5): list / create / delete Keto groups and manage membership. // Built-in Groups admin screen: list / create / delete Keto groups and manage membership.
// A group is a Keto subject set `Group:<name>#members`; a member is a user or a nested group (see // A group is a Keto subject set `Group:<name>#members`; a member is a user or a nested group (see
// parseSubject). Writes go only to Keto (README "stateless"). Keto has no "create object" — a group // parseSubject). Writes go only to Keto (README "stateless"). Keto has no "create object" — a group
// exists exactly while it has ≥1 member, so create writes its first-member tuple and delete removes // exists exactly while it has ≥1 member, so create writes its first-member tuple and delete removes
+2 -2
View File
@@ -1,4 +1,4 @@
// Direct units for the admin section's pure nav + auth helpers (todo §8). They're security-critical // Direct units for the admin section's pure nav + auth helpers. They're security-critical
// (requireAdmin/guardedForm gate every admin write) and reused across all four admin screens, so pin // (requireAdmin/guardedForm gate every admin write) and reused across all four admin screens, so pin
// the contract here in isolation — the admin-*.test.ts HTTP tests exercise them only end-to-end. // the contract here in isolation — the admin-*.test.ts HTTP tests exercise them only end-to-end.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
@@ -45,7 +45,7 @@ test("adminSection: gated Admin header over the four screens; current marks the
assert.equal(onRoles.children?.find((c) => c.id === "users")?.current, undefined); assert.equal(onRoles.children?.find((c) => c.id === "users")?.current, undefined);
}); });
// (The in-screen admin sidebar is gone in §10 — every page renders the one global menu, built by // (The in-screen admin sidebar is gone in — every page renders the one global menu, built by
// buildPluginChrome; see chrome.test.ts. adminSection above is that menu's gated Admin fragment.) // buildPluginChrome; see chrome.test.ts. adminSection above is that menu's gated Admin fragment.)
// ---- auth gates ---- // ---- auth gates ----
+2 -2
View File
@@ -1,6 +1,6 @@
// The built-in admin section of the menu (todo §5). `adminSection()` is the one definition of the // The built-in admin section of the menu. `adminSection()` is the one definition of the
// permission-gated "Admin" header (Users/Groups/Roles/clients) + its gate, composed into the single // permission-gated "Admin" header (Users/Groups/Roles/clients) + its gate, composed into the single
// global menu (`buildPluginChrome`, §10) — composeNav drops the whole header + subtree for a // global menu (`buildPluginChrome`) — composeNav drops the whole header + subtree for a
// non-admin. Every page (dashboard, admin, plugin, auth) renders that one menu, so there's no // non-admin. Every page (dashboard, admin, plugin, auth) renders that one menu, so there's no
// separate admin sidebar to drift. // separate admin sidebar to drift.
+1 -1
View File
@@ -1,4 +1,4 @@
// Built-in Roles & permissions admin screen (§5): the pure view-model + Keto builders. A role is a // Built-in Roles & permissions admin screen: the pure view-model + Keto builders. A role is a
// Keto subject set (Role:<name>#members); members are users (subject_id) or groups (subject_set) — // Keto subject set (Role:<name>#members); members are users (subject_id) or groups (subject_set) —
// "assign roles to users/groups". The "effective access" view flattens a Keto `expand` tree into the // "assign roles to users/groups". The "effective access" view flattens a Keto `expand` tree into the
// distinct set of users who hold the role directly or transitively via a group. The HTTP // distinct set of users who hold the role directly or transitively via a group. The HTTP
+5 -5
View File
@@ -1,4 +1,4 @@
// Built-in Roles & permissions admin screen (todo §5): list / create / delete Keto roles and assign // Built-in Roles & permissions admin screen: list / create / delete Keto roles and assign
// them to users and groups. A role is a Keto subject set `Role:<name>#members` (OPL: members are users // them to users and groups. A role is a Keto subject set `Role:<name>#members` (OPL: members are users
// or groups, resolved transitively) — the source of truth for the JWT `roles` claim. It shares the // or groups, resolved transitively) — the source of truth for the JWT `roles` claim. It shares the
// Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging) // Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging)
@@ -276,10 +276,10 @@ export interface AdminRolesDeps {
kratosAdmin: KratosAdmin; kratosAdmin: KratosAdmin;
menu: MenuConfig; menu: MenuConfig;
render: (view: string, data: Record<string, unknown>) => Promise<string>; render: (view: string, data: Record<string, unknown>) => Promise<string>;
revoke?: (sub: string) => void; // optional instant-revoke (§9): assigning/unassigning a *user* kills their live tokens revoke?: (sub: string) => void; // optional instant-revoke: assigning/unassigning a *user* kills their live tokens
} }
// §9 instant-revoke: a role change for a `user:<id>` member must take effect now, so revoke that // instant-revoke: a role change for a `user:<id>` member must take effect now, so revoke that
// user's live tokens (a re-mint then re-reads roles from Keto). A `group:<name>` change is // user's live tokens (a re-mint then re-reads roles from Keto). A `group:<name>` change is
// transitive across many users — left to lag (documented), so only direct user members revoke. // transitive across many users — left to lag (documented), so only direct user members revoke.
function revokeUserMember(deps: AdminRolesDeps, member: string): void { function revokeUserMember(deps: AdminRolesDeps, member: string): void {
@@ -378,7 +378,7 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
if (seg.length === 2 && seg[1] === "delete" && method === "POST") { if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
if (name === ADMIN_PERMISSION) return renderDetail(name, "The admin role can't be deleted — it would remove all admin access."); if (name === ADMIN_PERMISSION) return renderDetail(name, "The admin role can't be deleted — it would remove all admin access.");
await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS }); // removes every member tuple await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS }); // removes every member tuple
// §9: a whole-role delete drops many members at once — left to lag like a group change; the // a whole-role delete drops many members at once — left to lag like a group change; the
// per-member unassign above is the instant-revoke path. // per-member unassign above is the instant-revoke path.
ctx.log.info("admin: role deleted", { actor: user.id, role: name }); ctx.log.info("admin: role deleted", { actor: user.id, role: name });
return { redirect: ADMIN_ROLES_BASE }; return { redirect: ADMIN_ROLES_BASE };
@@ -386,7 +386,7 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
if (seg.length === 3 && seg[1] === "members" && seg[2] === "delete" && method === "POST") { if (seg.length === 3 && seg[1] === "members" && seg[2] === "delete" && method === "POST") {
const member = (form!.get("member") ?? "").trim(); const member = (form!.get("member") ?? "").trim();
// Self-protection: don't let an admin revoke their own *direct* admin grant (would lock them out). // Self-protection: don't let an admin revoke their own *direct* admin grant (would lock them out).
// Admin held only via a group isn't covered here — the robust "last effective admin" check is §9. // Admin held only via a group isn't covered here — the robust "last effective admin" check is deferred.
if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return renderDetail(name, "You can't revoke your own admin access."); if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return renderDetail(name, "You can't revoke your own admin access.");
const tuple = roleMemberTuple(name, member); const tuple = roleMemberTuple(name, member);
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(deps, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); } if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(deps, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); }
+1 -1
View File
@@ -1,4 +1,4 @@
// Built-in Users admin screen (§5): the pure view-model + Kratos-payload builders. The HTTP // Built-in Users admin screen: the pure view-model + Kratos-payload builders. The HTTP
// routing/gate/CSRF + live Kratos calls are exercised over HTTP in app.test.ts. // routing/gate/CSRF + live Kratos calls are exercised over HTTP in app.test.ts.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
+4 -4
View File
@@ -1,4 +1,4 @@
// Built-in Users admin screen (todo §5): list Kratos identities (filter/sort/paginate) + // Built-in 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. Writes go only to Kratos via the admin client
// (README "stateless"). Pure builders turn identities + the request URL into building-block view // (README "stateless"). Pure builders turn identities + the request URL into building-block view
// models; `handleAdminUsers` is the imperative shell app.ts dispatches to — gated admin-only, // models; `handleAdminUsers` is the imperative shell app.ts dispatches to — gated admin-only,
@@ -289,7 +289,7 @@ export interface AdminUsersDeps {
kratosAdmin: KratosAdmin; kratosAdmin: KratosAdmin;
menu: MenuConfig; menu: MenuConfig;
render: (view: string, data: Record<string, unknown>) => Promise<string>; render: (view: string, data: Record<string, unknown>) => Promise<string>;
revoke?: (sub: string) => void; // optional instant-revoke (§9): kill the target's live tokens on deactivate/delete revoke?: (sub: string) => void; // optional instant-revoke: kill the target's live tokens on deactivate/delete
} }
function readUserInput(form: URLSearchParams): UserInput { function readUserInput(form: URLSearchParams): UserInput {
@@ -378,14 +378,14 @@ export async function handleAdminUsers(ctx: RequestContext, csrfToken: string, d
if (isSelf) return { ...(await renderForm({ error: "You can't deactivate your own account.", identity })), status: 400 }; if (isSelf) return { ...(await renderForm({ error: "You can't deactivate your own account.", identity })), status: 400 };
const nextState = identity.state === "inactive" ? "active" : "inactive"; const nextState = identity.state === "inactive" ? "active" : "inactive";
await kratosAdmin.updateIdentity(targetId, setStatePayload(identity, nextState)); await kratosAdmin.updateIdentity(targetId, setStatePayload(identity, nextState));
if (nextState === "inactive") deps.revoke?.(targetId); // §9: a deactivation takes effect now, not after the JWT TTL if (nextState === "inactive") deps.revoke?.(targetId); // a deactivation takes effect now, not after the JWT TTL
ctx.log.info("admin: user state changed", { actor: user.id, state: nextState, target: targetId }); ctx.log.info("admin: user state changed", { actor: user.id, state: nextState, target: targetId });
return { redirect: back }; return { redirect: back };
} }
if (seg[1] === "delete") { if (seg[1] === "delete") {
if (isSelf) return { ...(await renderForm({ error: "You can't delete your own account.", identity })), status: 400 }; if (isSelf) return { ...(await renderForm({ error: "You can't delete your own account.", identity })), status: 400 };
await kratosAdmin.deleteIdentity(targetId); await kratosAdmin.deleteIdentity(targetId);
deps.revoke?.(targetId); // §9: the account is gone — reject its live tokens immediately deps.revoke?.(targetId); // the account is gone — reject its live tokens immediately
ctx.log.info("admin: user deleted", { actor: user.id, target: targetId }); ctx.log.info("admin: user deleted", { actor: user.id, target: targetId });
return { redirect: ADMIN_USERS_BASE }; return { redirect: ADMIN_USERS_BASE };
} }
+33 -33
View File
@@ -24,9 +24,9 @@ import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "views"); const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "views");
// A session JWT signed with a throwaway test key — the §4 verify path. Wired into the shared // A session JWT signed with a throwaway test key — the verify path. Wired into the shared
// `server` (and the per-test apps) so a request can present a valid session; the dashboard and the // `server` (and the per-test apps) so a request can present a valid session; the dashboard and the
// gated routes need one (§10). `staticJwks([ecJwk])` is the matching verify side. // 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");
@@ -49,12 +49,12 @@ before(async () => {
after(() => server.close()); after(() => server.close());
test("the dashboard at /dashboard: the instructional starter in the unified shell, gated to a session", async () => { test("the dashboard at /dashboard: the instructional starter in the unified shell, gated to a session", async () => {
// The dashboard is gated to a signed-in user (§10), so present a session. // The dashboard is gated to a signed-in user, so present a session.
const res = await fetch(base + "/dashboard", { headers: { cookie: session() } }); const res = await fetch(base + "/dashboard", { headers: { cookie: session() } });
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/);
const html = await res.text(); const html = await res.text();
// The unified app shell (§10): the same sidebar/menu every page renders. // The unified app shell: the same sidebar/menu every page renders.
assert.match(html, /Plainpages/); // sidebar brand assert.match(html, /Plainpages/); // sidebar brand
assert.match(html, /<aside class="sidebar"/); assert.match(html, /<aside class="sidebar"/);
// The default is a short instructional starter, not a mock-data list. // The default is a short instructional starter, not a mock-data list.
@@ -63,7 +63,7 @@ test("the dashboard at /dashboard: the instructional starter in the unified shel
assert.doesNotMatch(html, /<form class="filters"/); // the old mock People list is gone assert.doesNotMatch(html, /<form class="filters"/); // the old mock People list is gone
assert.doesNotMatch(html, /Avery Kline/); assert.doesNotMatch(html, /Avery Kline/);
// The Sign-out POST form carries a CSRF token matching the Set-Cookie issued for the page (§4). // The Sign-out POST form carries a CSRF token matching the Set-Cookie issued for the page.
const csrfCookie = (res.headers.get("set-cookie") ?? "").match(/plainpages_csrf=([^;]+)/)?.[1]; const csrfCookie = (res.headers.get("set-cookie") ?? "").match(/plainpages_csrf=([^;]+)/)?.[1];
assert.ok(csrfCookie, "GET /dashboard issues a CSRF cookie"); assert.ok(csrfCookie, "GET /dashboard issues a CSRF cookie");
assert.match(res.headers.get("set-cookie") ?? "", /plainpages_csrf=[^;]+;.*HttpOnly/); assert.match(res.headers.get("set-cookie") ?? "", /plainpages_csrf=[^;]+;.*HttpOnly/);
@@ -71,24 +71,24 @@ test("the dashboard at /dashboard: the instructional starter in the unified shel
assert.match(html, new RegExp(`name="_csrf" value="${csrfCookie!.replace(/[.]/g, "\\.")}"`)); assert.match(html, new RegExp(`name="_csrf" value="${csrfCookie!.replace(/[.]/g, "\\.")}"`));
}); });
test("/ is the public landing (§10): anonymous → 200 with intro + sign-in/register links, in the unified shell", async () => { test("/ is the public landing: anonymous → 200 with intro + sign-in/register links, in the unified shell", async () => {
const res = await fetch(base + "/", { redirect: "manual" }); const res = await fetch(base + "/", { redirect: "manual" });
assert.equal(res.status, 200); // public — no redirect to sign in assert.equal(res.status, 200); // public — no redirect to sign in
const html = await res.text(); const html = await res.text();
assert.match(html, /href="\/login"/); // a prominent path to sign in assert.match(html, /href="\/login"/); // a prominent path to sign in
assert.match(html, /href="\/registration"/); // and to register assert.match(html, /href="\/registration"/); // and to register
// §10: the same app shell every page renders — the menu shows even when signed out (role-filtered). // the same app shell every page renders — the menu shows even when signed out (role-filtered).
assert.match(html, /<aside class="sidebar"/); assert.match(html, /<aside class="sidebar"/);
assert.match(html, /class="landing-title"/); // the landing hero owns the page's single <h1> assert.match(html, /class="landing-title"/); // the landing hero owns the page's single <h1>
}); });
test("/dashboard is gated (§10): an anonymous visitor is bounced to sign in (return_to kept)", async () => { test("/dashboard is gated: an anonymous visitor is bounced to sign in (return_to kept)", async () => {
const res = await fetch(base + "/dashboard", { redirect: "manual" }); const res = await fetch(base + "/dashboard", { redirect: "manual" });
assert.equal(res.status, 303); assert.equal(res.status, 303);
assert.equal(res.headers.get("location"), "/login?return_to=%2Fdashboard"); assert.equal(res.headers.get("location"), "/login?return_to=%2Fdashboard");
}); });
test("plugins replace either landing (§10): `home` owns the public /, `dashboard` owns the gated /dashboard", async (t) => { test("plugins replace either landing: `home` owns the public /, `dashboard` owns the gated /dashboard", async (t) => {
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>`);
@@ -134,7 +134,7 @@ test("renders branding from the menu config into the shell: logo + default theme
assert.match(html, /id="theme-dark"\s+checked/); // config default theme reaches the switch assert.match(html, /id="theme-dark"\s+checked/); // config default theme reaches the switch
}); });
test("emits a structured access-log line per request (the injected §9 logger)", async (t) => { test("emits a structured access-log line per request (the injected logger)", async (t) => {
const lines: string[] = []; const lines: string[] = [];
const app = createApp({ log: createLogger({ format: "json", level: "info", stderr: () => {}, stdout: (m) => lines.push(m) }) }); const app = createApp({ log: createLogger({ format: "json", level: "info", stderr: () => {}, stdout: (m) => lines.push(m) }) });
await new Promise<void>((r) => app.listen(0, r)); await new Promise<void>((r) => app.listen(0, r));
@@ -159,7 +159,7 @@ test("emits a structured access-log line per request (the injected §9 logger)",
assert.ok(rec.requestId, "carries a requestId for log↔trace correlation"); assert.ok(rec.requestId, "carries a requestId for log↔trace correlation");
}); });
test("ctx.log: a handler logs in the request trace, and ctx.log.fetch continues the inbound trace (§9)", async (t) => { test("ctx.log: a handler logs in the request trace, and ctx.log.fetch continues the inbound trace", async (t) => {
const lines: string[] = []; const lines: string[] = [];
const upstream: { traceparent: string | undefined; url: string }[] = []; const upstream: { traceparent: string | undefined; url: string }[] = [];
const realFetch = globalThis.fetch; const realFetch = globalThis.fetch;
@@ -207,7 +207,7 @@ test("ctx.log: a handler logs in the request trace, and ctx.log.fetch continues
assert.equal(up!.traceparent!.split("-")[1], inbound, "the upstream call continues the inbound trace"); assert.equal(up!.traceparent!.split("-")[1], inbound, "the upstream call continues the inbound trace");
}); });
test("ctx.log after a client abort doesn't throw: the request log is ended only once the handler unwinds (§9)", async (t) => { test("ctx.log after a client abort doesn't throw: the request log is ended only once the handler unwinds", async (t) => {
// The request span is ended on response "close", which also fires on a premature client abort. // The request span is ended on response "close", which also fires on a premature client abort.
// The handler keeps running after that — its ctx.log must not throw "already ended", so end() is // The handler keeps running after that — its ctx.log must not throw "already ended", so end() is
// deferred until the handler settles (regression for the abort race). // deferred until the handler settles (regression for the abort race).
@@ -260,7 +260,7 @@ test("static serving: GET sends body + content-type, HEAD headers only, unsafe p
assert.equal((await fetch(base + "/public/%00")).status, 403); assert.equal((await fetch(base + "/public/%00")).status, 403);
}); });
test("every response carries the security headers; HSTS follows SECURE_COOKIES (§9)", async (t) => { test("every response carries the security headers; HSTS follows SECURE_COOKIES", async (t) => {
// Default app (secureCookies off): a page (the public "/") and a static asset both carry the // Default app (secureCookies off): a page (the public "/") and a static asset both carry the
// hardening headers, proving they're set once up front and survive each writeHead (paths merge). // hardening headers, proving they're set once up front and survive each writeHead (paths merge).
for (const path of ["/", "/public/css/styles.css"]) { for (const path of ["/", "/public/css/styles.css"]) {
@@ -388,7 +388,7 @@ const demoPlugin: Plugin = {
{ handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" }, { handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" },
{ handler: () => ({ redirect: "/demo/hello/world" }), method: "POST", path: "/go" }, { handler: () => ({ redirect: "/demo/hello/world" }), method: "POST", path: "/go" },
{ handler: () => ({ html: "secret" }), method: "GET", path: "/secret", permission: "demo:read" }, { handler: () => ({ html: "secret" }), method: "GET", path: "/secret", permission: "demo:read" },
{ handler: () => ({ html: "open to all" }), method: "GET", path: "/public-page", public: true }, // §10 blessed public { handler: () => ({ html: "open to all" }), method: "GET", path: "/public-page", public: true }, // blessed public
{ handler: () => ({ data: { who: "Plainpages" }, view: "page" }), method: "GET", path: "/page" }, { handler: () => ({ data: { who: "Plainpages" }, view: "page" }), method: "GET", path: "/page" },
], ],
}; };
@@ -443,7 +443,7 @@ test("mounts plugin routes: params, html/json/redirect/view results, and the per
assert.equal(denied.status, 303); assert.equal(denied.status, 303);
assert.equal(denied.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret"); assert.equal(denied.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
// a route marked public (§10) is reachable anonymously — no gate, no redirect. // a route marked public is reachable anonymously — no gate, no redirect.
const open = await fetch(url + "/demo/public-page", { redirect: "manual" }); const open = await fetch(url + "/demo/public-page", { redirect: "manual" });
assert.equal(open.status, 200); assert.equal(open.status, 200);
assert.match(await open.text(), /open to all/); assert.match(await open.text(), /open to all/);
@@ -455,7 +455,7 @@ test("mounts plugin routes: params, html/json/redirect/view results, and the per
assert.equal((await fetch(url + "/demo/nope")).status, 404); assert.equal((await fetch(url + "/demo/nope")).status, 404);
}); });
test("a plugin view renders the native chrome; its forms are CSRF-guarded via ctx.verifyCsrf (§7)", async (t) => { test("a plugin view renders the native chrome; its forms are CSRF-guarded via ctx.verifyCsrf", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-")); const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
mkdirSync(join(dir, "panelkit", "views"), { recursive: true }); mkdirSync(join(dir, "panelkit", "views"), { recursive: true });
// The view composes the core shell from ctx.chrome — branding, the global nav — and its own // The view composes the core shell from ctx.chrome — branding, the global nav — and its own
@@ -510,7 +510,7 @@ test("a plugin view renders the native chrome; its forms are CSRF-guarded via ct
assert.equal(ok.status, 303); assert.equal(ok.status, 303);
}); });
// JWT middleware (§4): a verified session cookie populates ctx.user/roles, which the gate reads. // JWT middleware: a verified session cookie populates ctx.user/roles, which the gate reads.
// The key + mintJwt + session() helper are hoisted above the shared `server` (top of file). // The key + mintJwt + session() helper are hoisted above the shared `server` (top of file).
test("a verified session JWT authorizes a role-gated route; no cookie / expired token → sign in", async (t) => { test("a verified session JWT authorizes a role-gated route; no cookie / expired token → sign in", async (t) => {
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] }); const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
@@ -533,7 +533,7 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}`)).status, 303); assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}`)).status, 303);
// The dashboard wires in the permission-gated Admin section: an admin's roles surface the links; // The dashboard wires in the permission-gated Admin section: an admin's roles surface the links;
// anonymous is bounced to sign in before any page renders (§10 gate on /dashboard). // anonymous is bounced to sign in before any page renders (gate on /dashboard).
const admin = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["admin"], sub: "u1" })}` } }); const admin = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["admin"], sub: "u1" })}` } });
assert.match(await admin.text(), /href="\/admin\/users"/); assert.match(await admin.text(), /href="\/admin\/users"/);
const anonDash = await fetch(url + "/dashboard", { redirect: "manual" }); const anonDash = await fetch(url + "/dashboard", { redirect: "manual" });
@@ -541,7 +541,7 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
assert.equal(anonDash.headers.get("location"), "/login?return_to=%2Fdashboard"); assert.equal(anonDash.headers.get("location"), "/login?return_to=%2Fdashboard");
}); });
test("revocation denylist (§9): a revoked subject's token stops authorizing on the hot path; a fresh re-login passes", async (t) => { test("revocation denylist: a revoked subject's token stops authorizing on the hot path; a fresh re-login passes", async (t) => {
const denylist = createDenylist(); // no Ory clients ⇒ a revoked token drops straight to anonymous (no re-mint) const denylist = createDenylist(); // no Ory clients ⇒ a revoked token drops straight to anonymous (no re-mint)
const app = createApp({ denylist, jwks: staticJwks([ecJwk]), plugins: [demoPlugin] }); const app = createApp({ denylist, jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
await new Promise<void>((r) => app.listen(0, r)); await new Promise<void>((r) => app.listen(0, r));
@@ -745,7 +745,7 @@ test("themed auth GET: an existing Kratos session (no app JWT yet) recovers via
assert.equal((await fetch(url2 + "/login", { redirect: "manual" })).status, 500); assert.equal((await fetch(url2 + "/login", { redirect: "manual" })).status, 500);
}); });
// return_to (§9): a deep-link login lands back on the requested page. The gate redirects to // return_to: a deep-link login lands back on the requested page. The gate redirects to
// /login?return_to=<host-relative path>; /login bakes that into the Kratos flow so completion // /login?return_to=<host-relative path>; /login bakes that into the Kratos flow so completion
// returns there — but a first-party path must route via /auth/complete first (to mint the JWT). // returns there — but a first-party path must route via /auth/complete first (to mint the JWT).
test("login return_to: a first-party deep link is wrapped through /auth/complete; an absolute target passes through as-is", async (t) => { test("login return_to: a first-party deep link is wrapped through /auth/complete; an absolute target passes through as-is", async (t) => {
@@ -764,7 +764,7 @@ test("login return_to: a first-party deep link is wrapped through /auth/complete
await fetch(url + "/login?return_to=" + encodeURIComponent("/admin/users?q=1"), { redirect: "manual" }); await fetch(url + "/login?return_to=" + encodeURIComponent("/admin/users?q=1"), { redirect: "manual" });
assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?return_to=%2Fadmin%2Fusers%3Fq%3D1$/); assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?return_to=%2Fadmin%2Fusers%3Fq%3D1$/);
// An absolute target (the §6 OAuth2 login challenge) is passed to Kratos unchanged — Kratos // An absolute target (the OAuth2 login challenge) is passed to Kratos unchanged — Kratos
// allow-lists it. A protocol-relative "//evil.com" is likewise not wrapped (Kratos rejects it). // allow-lists it. A protocol-relative "//evil.com" is likewise not wrapped (Kratos rejects it).
const abs = "http://localhost/oauth2/login?login_challenge=abc"; const abs = "http://localhost/oauth2/login?login_challenge=abc";
await fetch(url + "/login?return_to=" + encodeURIComponent(abs), { redirect: "manual" }); await fetch(url + "/login?return_to=" + encodeURIComponent(abs), { redirect: "manual" });
@@ -818,7 +818,7 @@ test("renders a fetched flow as the themed auth page: fields post straight to Kr
assert.match(html, /The provided credentials are invalid\./); assert.match(html, /The provided credentials are invalid\./);
}); });
// Login completion (§4): /auth/complete is where Kratos lands the browser after login. // Login completion: /auth/complete is where Kratos lands the browser after login.
const stubAdmin = (over: Partial<KratosAdmin>): KratosAdmin => ({ const stubAdmin = (over: Partial<KratosAdmin>): KratosAdmin => ({
createIdentity: async () => { throw new Error("unused"); }, createIdentity: async () => { throw new Error("unused"); },
createRecoveryCode: async () => ({ code: "000000", link: "http://kratos/recover" }), createRecoveryCode: async () => ({ code: "000000", link: "http://kratos/recover" }),
@@ -848,7 +848,7 @@ const fakeKeto = (tuples: RelationTuple[] = [], over: Partial<KetoClient> = {}):
}); });
const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami }); const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami });
// Shared harness for the §5 admin-screen HTTP tests: an app on a random port with an admin JWT + // Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT +
// CSRF cookie. get(path, roles)/post(path, body) carry them; `token` is the matching CSRF field. // CSRF cookie. get(path, roles)/post(path, body) carry them; `token` is the matching CSRF field.
const ADMIN_CSRF = "admin-secret"; const ADMIN_CSRF = "admin-secret";
async function adminHarness(t: TestContext, opts: AppOptions = {}) { async function adminHarness(t: TestContext, opts: AppOptions = {}) {
@@ -892,7 +892,7 @@ test("login completion (/auth/complete): a live session mints the JWT cookie; no
assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=h\.p\.s;.*HttpOnly/); assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=h\.p\.s;.*HttpOnly/);
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles projected onto the identity for the tokenizer assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles projected onto the identity for the tokenizer
// return_to (§9): a safe host-relative target lands the user back where they were headed; an // return_to: a safe host-relative target lands the user back where they were headed; an
// off-origin one is ignored (open-redirect guard) and falls back to the dashboard. // off-origin one is ignored (open-redirect guard) and falls back to the dashboard.
assert.equal((await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s", "/admin/users?q=1")).headers.get("location"), "/admin/users?q=1"); assert.equal((await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s", "/admin/users?q=1")).headers.get("location"), "/admin/users?q=1");
assert.equal((await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s", "//evil.com")).headers.get("location"), "/dashboard"); assert.equal((await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s", "//evil.com")).headers.get("location"), "/dashboard");
@@ -935,7 +935,7 @@ test("logout (CSRF-guarded POST): valid token revokes the Kratos session + clear
assert.equal((await post("", `_csrf=${token}`)).status, 403); // no cookie to match assert.equal((await post("", `_csrf=${token}`)).status, 403); // no cookie to match
}); });
// OAuth2 login challenge (§6): another app logs in *through* us; Hydra hands the browser here. // OAuth2 login challenge: another app logs in *through* us; Hydra hands the browser here.
const stubHydra = (over: Partial<HydraAdmin> = {}): HydraAdmin => ({ const stubHydra = (over: Partial<HydraAdmin> = {}): HydraAdmin => ({
acceptConsentRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?consent_verifier=v" }), acceptConsentRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?consent_verifier=v" }),
acceptLoginRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?login_verifier=v" }), acceptLoginRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?login_verifier=v" }),
@@ -1074,7 +1074,7 @@ test("OAuth2 challenge endpoints degrade identically: stale Hydra 4xx → 400, o
} }
}); });
// Built-in Users admin screen (§5): gate + every CRUD action over HTTP against a mock Kratos admin. // Built-in Users admin screen: gate + every CRUD action over HTTP against a mock Kratos admin.
test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, recovery (CSRF-guarded)", async (t) => { test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, recovery (CSRF-guarded)", async (t) => {
const mk = (email: string, over: Partial<Identity> = {}): Identity => const mk = (email: string, over: Partial<Identity> = {}): Identity =>
({ id: randomUUID(), schema_id: "default", state: "active", traits: { email, name: { first: "Ada", last: "Lovelace" } }, ...over }); ({ id: randomUUID(), schema_id: "default", state: "active", traits: { email, name: { first: "Ada", last: "Lovelace" } }, ...over });
@@ -1088,7 +1088,7 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
listIdentities: async () => ({ identities: store, nextPageToken: null }), listIdentities: async () => ({ identities: store, nextPageToken: null }),
updateIdentity: async (id, payload) => { const it = store.find((x) => x.id === id)!; Object.assign(it, payload); return it; }, updateIdentity: async (id, payload) => { const it = store.find((x) => x.id === id)!; Object.assign(it, payload); return it; },
}); });
const denylist = createDenylist(); // §9: a deactivate/delete should revoke the target's live tokens instantly const denylist = createDenylist(); // a deactivate/delete should revoke the target's live tokens instantly
const { get, post, token, url } = await adminHarness(t, { denylist, kratosAdmin }); const { get, post, token, url } = await adminHarness(t, { denylist, kratosAdmin });
await assertAdminGate(url, get, "/admin/users"); await assertAdminGate(url, get, "/admin/users");
@@ -1120,7 +1120,7 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
assert.equal(updated.status, 303); assert.equal(updated.status, 303);
assert.deepEqual((target.traits as { name: unknown }).name, { first: "Ada", last: "King" }); assert.deepEqual((target.traits as { name: unknown }).name, { first: "Ada", last: "King" });
// Deactivate (state toggle): active → inactive, and the target's live tokens are revoked at once (§9). // Deactivate (state toggle): active → inactive, and the target's live tokens are revoked at once.
await post(`/admin/users/${target.id}/state`, `_csrf=${token}`); await post(`/admin/users/${target.id}/state`, `_csrf=${token}`);
assert.equal(target.state, "inactive"); assert.equal(target.state, "inactive");
assert.equal(denylist.isRevoked(target.id, 0), true); assert.equal(denylist.isRevoked(target.id, 0), true);
@@ -1152,7 +1152,7 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
assert.equal((await get("/admin/users/%ZZ")).status, 404); assert.equal((await get("/admin/users/%ZZ")).status, 404);
}); });
// Built-in Groups admin screen (§5): gate + list/create/membership/delete over HTTP against a // Built-in Groups admin screen: gate + list/create/membership/delete over HTTP against a
// fakeKeto (tuples are the only state) and a stub Kratos admin (resolves member emails). // fakeKeto (tuples are the only state) and a stub Kratos admin (resolves member emails).
test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-guarded)", async (t) => { test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-guarded)", async (t) => {
const ada = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b01"; const ada = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b01";
@@ -1208,7 +1208,7 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
assert.equal((await get("/admin/groups/%ZZ")).status, 404); assert.equal((await get("/admin/groups/%ZZ")).status, 404);
}); });
// Built-in Roles & permissions admin screen (§5): gate + list/create/assign/revoke/delete over HTTP // Built-in Roles & permissions admin screen: gate + list/create/assign/revoke/delete over HTTP
// against a fake in-memory Keto whose `expand` mirrors Keto's transitive resolution, so the // against a fake in-memory Keto whose `expand` mirrors Keto's transitive resolution, so the
// effective-access view surfaces a user reachable only through a group. // effective-access view surfaces a user reachable only through a group.
test("admin Roles screen: gate, list, create, assign user/group, effective access (expand), revoke, delete", async (t) => { test("admin Roles screen: gate, list, create, assign user/group, effective access (expand), revoke, delete", async (t) => {
@@ -1233,7 +1233,7 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
}); });
const keto = fakeKeto(tuples, { expand: async (set) => expandSet(set) }); const keto = fakeKeto(tuples, { expand: async (set) => expandSet(set) });
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) }); const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
const denylist = createDenylist(); // §9: granting/revoking a *user's* role revokes their live tokens (a group change is transitive → left to lag) const denylist = createDenylist(); // granting/revoking a *user's* role revokes their live tokens (a group change is transitive → left to lag)
const { get, post, token, url } = await adminHarness(t, { denylist, keto, kratosAdmin }); const { get, post, token, url } = await adminHarness(t, { denylist, keto, kratosAdmin });
await assertAdminGate(url, get, "/admin/roles"); await assertAdminGate(url, get, "/admin/roles");
@@ -1275,7 +1275,7 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=group:eng`); await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=group:eng`);
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng")); assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
// Unassigning a *user* membership likewise revokes that user's live token (§9), so the loss of access is immediate. // Unassigning a *user* membership likewise revokes that user's live token, so the loss of access is immediate.
await post("/admin/roles/editor/members", `_csrf=${token}&member=user:${grace}`); await post("/admin/roles/editor/members", `_csrf=${token}&member=user:${grace}`);
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=user:${grace}`); await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=user:${grace}`);
assert.equal(denylist.isRevoked(grace, 0), true); assert.equal(denylist.isRevoked(grace, 0), true);
@@ -1299,7 +1299,7 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
assert.equal((await get("/admin/roles/%ZZ")).status, 404); assert.equal((await get("/admin/roles/%ZZ")).status, 404);
}); });
// Built-in OAuth2 clients admin screen (§6): gate + list/register/detail/delete over HTTP against an // Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an
// in-memory Hydra. Registration shows the one-time client_secret on the post-create page (no PRG). // in-memory Hydra. Registration shows the one-time client_secret on the post-create page (no PRG).
test("admin OAuth2 clients screen: gate, list, register (one-time secret), detail, delete (CSRF-guarded)", async (t) => { test("admin OAuth2 clients screen: gate, list, register (one-time secret), detail, delete (CSRF-guarded)", async (t) => {
const store: OAuth2Client[] = [ const store: OAuth2Client[] = [
+26 -26
View File
@@ -45,15 +45,15 @@ export interface AppOptions {
// Off by default so edits show live; the app itself never inspects the environment. // Off by default so edits show live; the app itself never inspects the environment.
cache?: boolean; 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 (§9); 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 (§6) hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
jwks?: JwksProvider; // verify the session JWT → ctx.user/roles (§4); absent ⇒ always anonymous jwks?: JwksProvider; // verify the session JWT → ctx.user/roles; absent ⇒ always anonymous
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion (§4) keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes (§4) kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion (§4) kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion
log?: Log; // app-level logger (§9); per-request access log + trace span. Default: silent (tests) log?: Log; // app-level logger; per-request access log + trace span. Default: silent (tests)
menu?: MenuConfig; // central override + branding (config/menu.ts); defaults to DEFAULT_MENU menu?: MenuConfig; // central override + branding (config/menu.ts); defaults to DEFAULT_MENU
plugins?: Plugin[]; // discovered manifests to mount (router); empty until §2 discovery runs plugins?: Plugin[]; // discovered manifests to mount (router); empty until discovery runs
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/ pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
publicDir?: string; publicDir?: string;
secureCookies?: boolean; // set Secure on our session/CSRF cookies (config.secureCookies; off in dev http) secureCookies?: boolean; // set Secure on our session/CSRF cookies (config.secureCookies; off in dev http)
@@ -86,7 +86,7 @@ export function createApp(options: AppOptions = {}): Server {
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" // A plugin may fully replace the public landing "/" (`home`) or the gated dashboard "/dashboard"
// (`dashboard`) — §10. Discovery's findConflicts guarantees at most one of each, so `find` is // (`dashboard`) — Discovery's findConflicts guarantees at most one of each, so `find` is
// unambiguous; the predicates narrow the slot to defined. // 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");
@@ -108,13 +108,13 @@ export function createApp(options: AppOptions = {}): Server {
// building-block partials (resolved from viewsDir) and their own partials/subfolders. // 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 });
// Built-in admin screens (§5) — wired only when their Ory clients are present (the writes go // Built-in admin screens — wired only when their Ory clients are present (the writes go
// there). They render core views via `render` and are gated/CSRF-guarded inside the handler. // there). They render core views via `render` and are gated/CSRF-guarded inside the handler.
// Users writes to Kratos; Groups writes to Keto and reads users from Kratos for the pickers. // Users writes to Kratos; Groups writes to Keto and reads users from Kratos for the pickers.
const adminDeps: AdminUsersDeps | null = kratosAdmin ? { csrfSecret, kratosAdmin, menu, render, ...(revoke ? { revoke } : {}) } : null; const adminDeps: AdminUsersDeps | null = kratosAdmin ? { csrfSecret, kratosAdmin, menu, render, ...(revoke ? { revoke } : {}) } : null;
const adminGroupsDeps: AdminGroupsDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render } : null; const adminGroupsDeps: AdminGroupsDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render } : null;
const adminRolesDeps: AdminRolesDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render, ...(revoke ? { revoke } : {}) } : null; const adminRolesDeps: AdminRolesDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render, ...(revoke ? { revoke } : {}) } : null;
// OAuth2 clients (§6) write to Hydra; wired only when the Hydra admin client is present. // OAuth2 clients write to Hydra; wired only when the Hydra admin client is present.
const adminClientsDeps: AdminClientsDeps | null = hydra ? { csrfSecret, hydra, menu, render } : null; const adminClientsDeps: AdminClientsDeps | null = hydra ? { csrfSecret, hydra, menu, render } : null;
const sendHtml = (res: ServerResponse, status: number, html: string): void => { const sendHtml = (res: ServerResponse, status: number, html: string): void => {
@@ -158,7 +158,7 @@ export function createApp(options: AppOptions = {}): Server {
// Verify the session JWT once (cached JWKS) → ctx.user/roles; none/invalid ⇒ anonymous. // Verify the session JWT once (cached JWKS) → ctx.user/roles; none/invalid ⇒ anonymous.
// If the token has lapsed but a live Kratos session still backs it (and we have the Ory // If the token has lapsed but a live Kratos session still backs it (and we have the Ory
// clients), silently re-mint it — "stay signed in" (§4): re-read roles from Keto, re-tokenize, // clients), silently re-mint it — "stay signed in": re-read roles from Keto, re-tokenize,
// and set the fresh cookie via setHeader so it rides whatever response this request produces // and set the fresh cookie via setHeader so it rides whatever response this request produces
// (a dead session clears the stale cookie). This is the only place the hot path touches Ory. // (a dead session clears the stale cookie). This is the only place the hot path touches Ory.
let user: User | null = null; let user: User | null = null;
@@ -178,7 +178,7 @@ export function createApp(options: AppOptions = {}): Server {
} }
} }
// CSRF token for this request's first-party forms: reuse a genuine cookie token, else mint // CSRF token for this request's first-party forms: reuse a genuine cookie token, else mint
// one (the form page below Set-Cookies it). Verified on our own state-changing routes (§4). // one (the form page below Set-Cookies it). Verified on our own state-changing routes.
const csrf = ensureCsrfToken(req.headers.cookie, csrfSecret); const csrf = ensureCsrfToken(req.headers.cookie, csrfSecret);
// Bound CSRF verifier handed to plugins via ctx.verifyCsrf (the host owns the secret). // 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 =>
@@ -226,7 +226,7 @@ export function createApp(options: AppOptions = {}): Server {
return; return;
} }
// Built-in admin screens (§5). Each handler gates (admin only; throws GuardError the catch // Built-in admin screens. Each handler gates (admin only; throws GuardError the catch
// maps), CSRF-guards mutations, and returns html/redirect. Set the page's CSRF cookie when // maps), CSRF-guards mutations, and returns html/redirect. Set the page's CSRF cookie when
// freshly minted (its forms carry the matching token); null ⇒ unknown subpath → 404. // freshly minted (its forms carry the matching token); null ⇒ unknown subpath → 404.
if (adminDeps && pathname.startsWith(ADMIN_USERS_BASE)) { if (adminDeps && pathname.startsWith(ADMIN_USERS_BASE)) {
@@ -282,7 +282,7 @@ export function createApp(options: AppOptions = {}): Server {
// A `return_to` is baked into the flow so Kratos lands there after login instead of the // A `return_to` is baked into the flow so Kratos lands there after login instead of the
// default completion route. A first-party deep link (host-relative, from the gate's // default completion route. A first-party deep link (host-relative, from the gate's
// return_to) is wrapped through /auth/complete so the session JWT is minted before the // return_to) is wrapped through /auth/complete so the session JWT is minted before the
// user reaches the page; an absolute target (the §6 OAuth2 login challenge) is passed // user reaches the page; an absolute target (the OAuth2 login challenge) is passed
// as-is — Kratos allow-lists it. localPath rejects an off-origin "//evil.com". // as-is — Kratos allow-lists it. localPath rejects an off-origin "//evil.com".
const raw = ctx.url.searchParams.get("return_to"); const raw = ctx.url.searchParams.get("return_to");
const local = localPath(raw); const local = localPath(raw);
@@ -324,14 +324,14 @@ export function createApp(options: AppOptions = {}): Server {
} }
throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected) throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected)
} }
// Rendered inside the unified app shell (§10), so set a fresh CSRF cookie when minted — the // Rendered inside the unified app shell, so set a fresh CSRF cookie when minted — the
// shell's Sign-out form (shown on /settings, where the user is signed in) needs the token. // shell's Sign-out form (shown on /settings, where the user is signed in) needs the token.
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies })); if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
sendHtml(res, 200, await render("auth", { chrome: ctx.chrome, flow: buildFlowView(flow, flowType) })); sendHtml(res, 200, await render("auth", { chrome: ctx.chrome, flow: buildFlowView(flow, flowType) }));
return; return;
} }
// OAuth2 login challenge (§6): Hydra hands the browser here when another app logs in // OAuth2 login challenge: Hydra hands the browser here when another app logs in
// *through* us. Resolve it via the Kratos session and accept; an unauthenticated user // *through* us. Resolve it via the Kratos session and accept; an unauthenticated user
// bounces to our themed login and returns here once signed in. Provider-only. // bounces to our themed login and returns here once signed in. Provider-only.
if (hydra && kratos && pathname === "/oauth2/login" && (method === "GET" || method === "HEAD")) { if (hydra && kratos && pathname === "/oauth2/login" && (method === "GET" || method === "HEAD")) {
@@ -358,7 +358,7 @@ export function createApp(options: AppOptions = {}): Server {
return; return;
} }
// OAuth2 consent challenge (§6): after login Hydra hands the browser here. A first-party // OAuth2 consent challenge: after login Hydra hands the browser here. A first-party
// (or Hydra-skipped) client is auto-granted its scopes; a third-party client gets the themed // (or Hydra-skipped) client is auto-granted its scopes; a third-party client gets the themed
// consent screen, whose CSRF-guarded POST accepts (Allow) or rejects (Deny). Provider-only. // consent screen, whose CSRF-guarded POST accepts (Allow) or rejects (Deny). Provider-only.
if (hydra && kratos && pathname === "/oauth2/consent") { if (hydra && kratos && pathname === "/oauth2/consent") {
@@ -408,7 +408,7 @@ export function createApp(options: AppOptions = {}): Server {
} }
} }
// OAuth2 RP-initiated logout (§6): Hydra hands the browser here to end the OAuth2 session // OAuth2 RP-initiated logout: Hydra hands the browser here to end the OAuth2 session
// (hydra.yml urls.logout). Accept the challenge and resume to Hydra's post-logout redirect; // (hydra.yml urls.logout). Accept the challenge and resume to Hydra's post-logout redirect;
// the first-party POST /logout (below) owns the Kratos session + our JWT cookie. Provider-only. // the first-party POST /logout (below) owns the Kratos session + our JWT cookie. Provider-only.
// GET-accept is safe (like the login/consent handlers): the challenge is Hydra-minted + // GET-accept is safe (like the login/consent handlers): the challenge is Hydra-minted +
@@ -433,7 +433,7 @@ export function createApp(options: AppOptions = {}): Server {
// Login completion: where Kratos lands the browser after authenticating (kratos.yml). // Login completion: where Kratos lands the browser after authenticating (kratos.yml).
// Mint our session JWT — read roles from Keto, project onto the identity, tokenize — // Mint our session JWT — read roles from Keto, project onto the identity, tokenize —
// and store it as the cookie; no active session bounces back to sign in (§4). // and store it as the cookie; no active session bounces back to sign in.
if (pathname === "/auth/complete" && method === "GET" && kratos && kratosAdmin && keto) { if (pathname === "/auth/complete" && method === "GET" && kratos && kratosAdmin && keto) {
const completed = await completeLogin({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie); const completed = await completeLogin({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie);
if (!completed) { if (!completed) {
@@ -442,7 +442,7 @@ export function createApp(options: AppOptions = {}): Server {
} }
res.appendHeader("set-cookie", sessionCookie(completed.jwt, { secure: secureCookies })); res.appendHeader("set-cookie", sessionCookie(completed.jwt, { secure: secureCookies }));
// Land on the deep link the user was headed to (return_to, validated host-relative so a // Land on the deep link the user was headed to (return_to, validated host-relative so a
// crafted ?return_to= can't make this an open redirect), else the gated dashboard (§9/§10). // crafted ?return_to= can't make this an open redirect), else the gated dashboard.
res.writeHead(303, { location: localPath(ctx.url.searchParams.get("return_to")) ?? "/dashboard" }).end(); res.writeHead(303, { location: localPath(ctx.url.searchParams.get("return_to")) ?? "/dashboard" }).end();
return; return;
} }
@@ -476,7 +476,7 @@ export function createApp(options: AppOptions = {}): Server {
} }
if (pathname === "/" && (method === "GET" || method === "HEAD")) { if (pathname === "/" && (method === "GET" || method === "HEAD")) {
// The public landing (§10): ungated — anyone may see it. A plugin may fully own it via `home` // The public landing: ungated — anyone may see it. A plugin may fully own it via `home`
// (rendered against its own views, native shell via ctx.chrome, with a fresh CSRF cookie for // (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. // any form it ships). Else the built-in intro page with prominent sign-in / register links.
if (homePlugin) { if (homePlugin) {
@@ -487,7 +487,7 @@ export function createApp(options: AppOptions = {}): Server {
await sendResult(res, result, (view, data) => renderView(homePlugin.id, view, data)); await sendResult(res, result, (view, data) => renderView(homePlugin.id, view, data));
return; return;
} }
// Default landing in the unified app shell (§10): `user` picks "go to dashboard" vs sign-in, // Default landing in the unified app shell: `user` picks "go to dashboard" vs sign-in,
// and the shell's Sign-out form (when signed in) needs a fresh CSRF cookie. // and the shell's Sign-out form (when signed in) needs a fresh CSRF cookie.
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies })); if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
sendHtml(res, 200, await render("home", { chrome: ctx.chrome, user })); sendHtml(res, 200, await render("home", { chrome: ctx.chrome, user }));
@@ -495,12 +495,12 @@ export function createApp(options: AppOptions = {}): Server {
} }
if (pathname === "/dashboard" && (method === "GET" || method === "HEAD")) { if (pathname === "/dashboard" && (method === "GET" || method === "HEAD")) {
// The post-login app home, gated to a signed-in user (§10): anonymous bounces to sign in, // The post-login app home, gated to a signed-in user: anonymous bounces to sign in,
// remembering /dashboard as return_to. // remembering /dashboard as return_to.
if (!user) { res.writeHead(303, { location: loginRedirect(ctx) }).end(); return; } if (!user) { res.writeHead(303, { location: loginRedirect(ctx) }).end(); return; }
// 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.
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies })); if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
// A plugin may fully own the dashboard (§10): render its handler against its own views, native // A plugin may fully own the dashboard: render its handler against its own views, native
// shell via ctx.chrome — same path as a plugin route. Else the built-in mock-data People list. // shell via ctx.chrome — same path as a plugin route. Else the built-in mock-data People list.
if (dashboardPlugin) { if (dashboardPlugin) {
const dashCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf }); const dashCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf });
@@ -543,7 +543,7 @@ export function createApp(options: AppOptions = {}): Server {
}; };
return createServer((req, res) => { return createServer((req, res) => {
// Per-request log + trace span (§9): a "request" span, continuing an upstream W3C traceparent // Per-request log + trace span: a "request" span, continuing an upstream W3C traceparent
// when present (distributed tracing across a proxy). "close" (not "finish") fires on both a // when present (distributed tracing across a proxy). "close" (not "finish") fires on both a
// completed response and a premature disconnect/abort, so an aborted/truncated request is still // completed response and a premature disconnect/abort, so an aborted/truncated request is still
// logged and its span flushed. // logged and its span flushed.
+2 -2
View File
@@ -1,6 +1,6 @@
// Read an application/x-www-form-urlencoded request body (todo §4). Our own POST forms are // Read an application/x-www-form-urlencoded request body. Our own POST forms are
// tiny, so cap the size and reject anything larger rather than buffer unbounded. Consumes the // tiny, so cap the size and reject anything larger rather than buffer unbounded. Consumes the
// stream once; never throws on an empty body. The CSRF gate + §5 admin forms read fields here. // stream once; never throws on an empty body. The CSRF gate + admin forms read fields here.
import type { IncomingMessage } from "node:http"; import type { IncomingMessage } from "node:http";
const DEFAULT_LIMIT = 1024 * 1024; // 1 MiB const DEFAULT_LIMIT = 1024 * 1024; // 1 MiB
+1 -1
View File
@@ -1,4 +1,4 @@
// One-command bootstrap (§3): idempotent first-boot seeding. Guards the pure payload // One-command bootstrap: idempotent first-boot seeding. Guards the pure payload
// builders (Kratos create-identity body + Keto role tuple), the idempotent seedAdmin // builders (Kratos create-identity body + Keto role tuple), the idempotent seedAdmin
// orchestration (fresh 201 vs existing 409 → reuse id), and the JWKS generate-if-absent // orchestration (fresh 201 vs existing 409 → reuse id), and the JWKS generate-if-absent
// safety net. Live boot is verified by running the stack; these catch contract drift. // safety net. Live boot is verified by running the stack; these catch contract drift.
+2 -2
View File
@@ -1,4 +1,4 @@
// One-command bootstrap (todo §3, the MVP bar). One-shot compose service: runs after // One-command bootstrap (the MVP bar). One-shot compose service: runs after
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`: // kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`:
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net); // 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos; // 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
@@ -134,7 +134,7 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password:
async function main() { async function main() {
const env = process.env; const env = process.env;
// Structured like the web app (§9) so prod logs stay uniform; honour LOG_FORMAT/SERVICE_NAME. // Structured like the web app so prod logs stay uniform; honour LOG_FORMAT/SERVICE_NAME.
const log = createLogger({ const log = createLogger({
format: env["LOG_FORMAT"] === "json" ? "json" : "text", format: env["LOG_FORMAT"] === "json" ? "json" : "text",
...(env["SERVICE_NAME"] ? { serviceName: env["SERVICE_NAME"] } : {}), ...(env["SERVICE_NAME"] ? { serviceName: env["SERVICE_NAME"] } : {}),
+2 -2
View File
@@ -1,4 +1,4 @@
// Page chrome for plugin pages (todo §7): the brand / global-nav / user / theme / csrf block a // Page chrome for plugin pages: the brand / global-nav / user / theme / csrf block a
// plugin view hands to partials/shell so its page looks native — the same shell the dashboard and // plugin view hands to partials/shell so its page looks native — the same shell the dashboard and
// admin screens render. Pure; the host builds it per plugin request and exposes it on ctx.chrome. // admin screens render. Pure; the host builds it per plugin request and exposes it on ctx.chrome.
// nav is the global menu — Dashboard + every plugin's fragment + the gated admin section — run // nav is the global menu — Dashboard + every plugin's fragment + the gated admin section — run
@@ -30,7 +30,7 @@ export interface ChromeOptions {
export function buildPluginChrome(opts: ChromeOptions): PageChrome { export function buildPluginChrome(opts: ChromeOptions): PageChrome {
// The Dashboard link targets the gated /dashboard, so show it only to a signed-in user — to an // The Dashboard link targets the gated /dashboard, so show it only to a signed-in user — to an
// anonymous visitor (a public page in the shell, §10) it would only dead-end at /login. // anonymous visitor (a public page in the shell) it would only dead-end at /login.
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : []; const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav); for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
fragments.push([adminSection()]); fragments.push([adminSection()]);
+5 -5
View File
@@ -1,4 +1,4 @@
// Guards the dev/prod compose split + stack ordering (§3): every image is pinned to an // Guards the dev/prod compose split + stack ordering: every image is pinned to an
// exact version (AGENTS.md), long-running Ory services carry readiness healthchecks so // exact version (AGENTS.md), long-running Ory services carry readiness healthchecks so
// `depends_on: service_healthy` works, the web app waits for the services it talks to // `depends_on: service_healthy` works, the web app waits for the services it talks to
// (kratos + keto + hydra), prod publishes no internal Ory ports while dev exposes // (kratos + keto + hydra), prod publishes no internal Ory ports while dev exposes
@@ -42,7 +42,7 @@ test("long-running Ory services declare readiness healthchecks", () => {
test("web waits for kratos, keto and hydra to be healthy before starting", () => { test("web waits for kratos, keto and hydra to be healthy before starting", () => {
assert.match(webBlock, /depends_on:/, "web declares dependencies"); assert.match(webBlock, /depends_on:/, "web declares dependencies");
// hydra: the §6 OAuth2 login/consent handler talks to its admin API. // hydra: the OAuth2 login/consent handler talks to its admin API.
for (const svc of ["kratos", "keto", "hydra"]) for (const svc of ["kratos", "keto", "hydra"])
assert.match(webBlock, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_healthy`), assert.match(webBlock, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_healthy`),
`web waits for ${svc} healthy`); `web waits for ${svc} healthy`);
@@ -58,7 +58,7 @@ test("prod base publishes no internal Ory ports; dev exposes the host-facing one
}); });
test("prod base supplies the app secret via env and mounts no source; dev override flips it", () => { test("prod base supplies the app secret via env and mounts no source; dev override flips it", () => {
// §9 prod compose: CSRF_SECRET comes from the environment (dev-throwaway fallback that // prod compose: CSRF_SECRET comes from the environment (dev-throwaway fallback that
// REQUIRE_SECURE_SECRETS rejects in prod — see config.ts); the base never bind-mounts the // REQUIRE_SECURE_SECRETS rejects in prod — see config.ts); the base never bind-mounts the
// source tree (runs the built image), while the dev override does for live editing. // source tree (runs the built image), while the dev override does for live editing.
assert.match(webBlock, /CSRF_SECRET:\s*\$\{CSRF_SECRET\b/, "base wires CSRF_SECRET from env"); assert.match(webBlock, /CSRF_SECRET:\s*\$\{CSRF_SECRET\b/, "base wires CSRF_SECRET from env");
@@ -67,7 +67,7 @@ test("prod base supplies the app secret via env and mounts no source; dev overri
// Secret/cookie hardening: enforced in prod, off in dev so the throwaway + http cookies pass. // Secret/cookie hardening: enforced in prod, off in dev so the throwaway + http cookies pass.
assert.match(webBlock, /REQUIRE_SECURE_SECRETS:\s*"true"/, "base enforces real secrets"); assert.match(webBlock, /REQUIRE_SECURE_SECRETS:\s*"true"/, "base enforces real secrets");
assert.match(override, /REQUIRE_SECURE_SECRETS:\s*"false"/, "dev allows the throwaway"); assert.match(override, /REQUIRE_SECURE_SECRETS:\s*"false"/, "dev allows the throwaway");
// §9 observability: prod emits structured JSON logs; dev flips it to human-readable text. // observability: prod emits structured JSON logs; dev flips it to human-readable text.
assert.match(webBlock, /LOG_FORMAT:\s*"json"/, "prod logs structured JSON"); assert.match(webBlock, /LOG_FORMAT:\s*"json"/, "prod logs structured JSON");
assert.match(override, /LOG_FORMAT:\s*"text"/, "dev logs human-readable text"); assert.match(override, /LOG_FORMAT:\s*"text"/, "dev logs human-readable text");
// Postgres credentials are env-supplied (dev default), never a baked-in literal. // Postgres credentials are env-supplied (dev default), never a baked-in literal.
@@ -75,7 +75,7 @@ test("prod base supplies the app secret via env and mounts no source; dev overri
}); });
test("a one-shot bootstrap seeds the stack before web starts", () => { test("a one-shot bootstrap seeds the stack before web starts", () => {
// §3 MVP bar: `bootstrap` runs after kratos+keto are healthy, seeds the admin + // MVP bar: `bootstrap` runs after kratos+keto are healthy, seeds the admin +
// JWKS, then exits; web waits for it to complete. Live seeding is boot-verified. // JWKS, then exits; web waits for it to complete. Live seeding is boot-verified.
const boot = compose.slice(compose.indexOf("\n bootstrap:")); const boot = compose.slice(compose.indexOf("\n bootstrap:"));
assert.match(boot, /node src\/bootstrap\.ts/, "bootstrap runs the seed script"); assert.match(boot, /node src\/bootstrap\.ts/, "bootstrap runs the seed script");
+5 -5
View File
@@ -21,9 +21,9 @@ test("loads dev defaults when the environment is empty", () => {
assert.equal(c.hydraAdminUrl, "http://hydra:4445"); assert.equal(c.hydraAdminUrl, "http://hydra:4445");
assert.match(c.csrfSecret, /dev-insecure/); assert.match(c.csrfSecret, /dev-insecure/);
assert.equal(c.jwtClockSkewSec, 60); // default exp/nbf leeway for Kratos↔web clock drift assert.equal(c.jwtClockSkewSec, 60); // default exp/nbf leeway for Kratos↔web clock drift
assert.equal(c.revocationDenylist, false); // instant-revoke is opt-in (§9) assert.equal(c.revocationDenylist, false); // instant-revoke is opt-in
assert.equal(c.revocationTtlSec, 900); // ≥ tokenizer TTL (10m) + skew assert.equal(c.revocationTtlSec, 900); // ≥ tokenizer TTL (10m) + skew
assert.equal(c.logLevel, "info"); // §9 observability defaults assert.equal(c.logLevel, "info"); // observability defaults
assert.equal(c.logFormat, "text"); // human-readable in dev; prod compose sets json assert.equal(c.logFormat, "text"); // human-readable in dev; prod compose sets json
assert.equal(c.otlpEndpoint, undefined); // OTLP export opt-in; console-only by default assert.equal(c.otlpEndpoint, undefined); // OTLP export opt-in; console-only by default
assert.equal(c.otlpProtocol, "http/json"); assert.equal(c.otlpProtocol, "http/json");
@@ -38,12 +38,12 @@ test("APP_URL is the canonical public URL: opt-in (unset ⇒ no redirect), honou
assert.throws(() => loadConfig({ APP_URL: "not a url" }), /APP_URL/); assert.throws(() => loadConfig({ APP_URL: "not a url" }), /APP_URL/);
}); });
test("SERVICE_NAME is overridable so an implementer brands their own logs/traces (§9)", () => { test("SERVICE_NAME is overridable so an implementer brands their own logs/traces", () => {
assert.equal(loadConfig({ SERVICE_NAME: "acme-ops" }).serviceName, "acme-ops"); assert.equal(loadConfig({ SERVICE_NAME: "acme-ops" }).serviceName, "acme-ops");
assert.equal(loadConfig({ SERVICE_NAME: "" }).serviceName, "plainpages"); // empty ⇒ default assert.equal(loadConfig({ SERVICE_NAME: "" }).serviceName, "plainpages"); // empty ⇒ default
}); });
test("LOG_LEVEL/LOG_FORMAT/OTLP_PROTOCOL are validated enums; OTLP_ENDPOINT an optional URL (§9)", () => { test("LOG_LEVEL/LOG_FORMAT/OTLP_PROTOCOL are validated enums; OTLP_ENDPOINT an optional URL", () => {
assert.equal(loadConfig({ LOG_LEVEL: "debug" }).logLevel, "debug"); assert.equal(loadConfig({ LOG_LEVEL: "debug" }).logLevel, "debug");
assert.equal(loadConfig({ LOG_LEVEL: "none" }).logLevel, "none"); assert.equal(loadConfig({ LOG_LEVEL: "none" }).logLevel, "none");
assert.throws(() => loadConfig({ LOG_LEVEL: "trace" }), /LOG_LEVEL/); assert.throws(() => loadConfig({ LOG_LEVEL: "trace" }), /LOG_LEVEL/);
@@ -64,7 +64,7 @@ test("REVOCATION_DENYLIST: opt-in toggle (off by default) + REVOCATION_TTL_SEC m
test("JWKS_URL defaults to the committed Kratos tokenizer signing key, not an http endpoint", () => { test("JWKS_URL defaults to the committed Kratos tokenizer signing key, not an http endpoint", () => {
// The session JWT is signed by the tokenizer key (kratos.yml jwks_url); Kratos does NOT // The session JWT is signed by the tokenizer key (kratos.yml jwks_url); Kratos does NOT
// republish it at /.well-known/jwks.json, so the §4 verifier reads that same file://. // republish it at /.well-known/jwks.json, so the verifier reads that same file://.
// gen-jwks.test.ts owns that the file is a valid ES256 signing key with a kid. // gen-jwks.test.ts owns that the file is a valid ES256 signing key with a kid.
const url = new URL(loadConfig({}).jwksUrl); const url = new URL(loadConfig({}).jwksUrl);
assert.equal(url.protocol, "file:"); assert.equal(url.protocol, "file:");
+13 -13
View File
@@ -1,4 +1,4 @@
// Config loaded once from the environment at boot (todo §0): Ory endpoints, cookie/CSRF // Config loaded once from the environment at boot: Ory endpoints, cookie/CSRF
// secrets, JWKS location, listen port, behaviour toggles. Fail-loud — a bad value, a // secrets, JWKS location, listen port, behaviour toggles. Fail-loud — a bad value, a
// missing enforced secret, a bad URL, or an out-of-range port throws here, never at // missing enforced secret, a bad URL, or an out-of-range port throws here, never at
// request time. // request time.
@@ -25,16 +25,16 @@ export interface Config {
ketoWriteUrl: string; ketoWriteUrl: string;
kratosAdminUrl: string; kratosAdminUrl: string;
kratosPublicUrl: string; kratosPublicUrl: string;
logFormat: "json" | "text"; // §9: console/OTLP entry format (json for structured prod logs) logFormat: "json" | "text"; // console/OTLP entry format (json for structured prod logs)
logLevel: LogLevel; // §9: minimum severity emitted logLevel: LogLevel; // minimum severity emitted
oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory) oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory)
otlpEndpoint: string | undefined; // §9: OTLP/HTTP collector base URI; unset ⇒ console-only (no export) otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export)
otlpProtocol: "http/json" | "http/protobuf"; // §9: OTLP wire format (protobuf for json-averse collectors) otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors)
port: number; port: number;
revocationDenylist: boolean; // §9: enable the optional instant role/session revoke denylist revocationDenylist: boolean; // enable the optional instant role/session revoke denylist
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
secureCookies: boolean; secureCookies: boolean;
serviceName: string; // §9: OTLP service.name — an implementer brands their own logs/traces serviceName: string; // OTLP service.name — an implementer brands their own logs/traces
} }
type Env = Record<string, string | undefined>; type Env = Record<string, string | undefined>;
@@ -59,7 +59,7 @@ function readBool(env: Env, key: string, devDefault: boolean): boolean {
} }
// An optional pinned value: present only when set non-empty. Unset ⇒ the matching claim // An optional pinned value: present only when set non-empty. Unset ⇒ the matching claim
// check is skipped (clean clone — the dev tokenizer sets no iss/aud; §4 verifier). // check is skipped (clean clone — the dev tokenizer sets no iss/aud; verifier).
function readOptional(env: Env, key: string): string | undefined { function readOptional(env: Env, key: string): string | undefined {
return env[key] || undefined; return env[key] || undefined;
} }
@@ -134,9 +134,9 @@ export function loadConfig(env: Env = process.env): Config {
appUrl: readOptionalUrl(env, "APP_URL"), appUrl: readOptionalUrl(env, "APP_URL"),
cacheTemplates: readBool(env, "CACHE_TEMPLATES", false), cacheTemplates: readBool(env, "CACHE_TEMPLATES", false),
csrfSecret: readSecret(env, "CSRF_SECRET", "dev-insecure-csrf-secret", requireSecure), csrfSecret: readSecret(env, "CSRF_SECRET", "dev-insecure-csrf-secret", requireSecure),
// Hydra admin API — the OAuth2 login/consent challenge handshake (§6); not on the first-party path. // Hydra admin API — the OAuth2 login/consent challenge handshake; not on the first-party path.
hydraAdminUrl: readUrl(env, "HYDRA_ADMIN_URL", "http://hydra:4445"), hydraAdminUrl: readUrl(env, "HYDRA_ADMIN_URL", "http://hydra:4445"),
// §4 verifier reads the same key the Kratos tokenizer signs with (kratos.yml jwks_url). // verifier reads the same key the Kratos tokenizer signs with (kratos.yml jwks_url).
// Kratos doesn't republish it over HTTP, so default to a file:// of the tokenizer JWKS // Kratos doesn't republish it over HTTP, so default to a file:// of the tokenizer JWKS
// mounted into web (compose.yml). Prod overrides with a real key (README: rotation). // mounted into web (compose.yml). Prod overrides with a real key (README: rotation).
jwksUrl: readUrl(env, "JWKS_URL", "file:///etc/config/kratos/tokenizer/jwks.json"), jwksUrl: readUrl(env, "JWKS_URL", "file:///etc/config/kratos/tokenizer/jwks.json"),
@@ -149,7 +149,7 @@ export function loadConfig(env: Env = process.env): Config {
ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"), ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"),
kratosAdminUrl: readUrl(env, "KRATOS_ADMIN_URL", "http://kratos:4434"), kratosAdminUrl: readUrl(env, "KRATOS_ADMIN_URL", "http://kratos:4434"),
kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"), kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"),
// §9 observability. Console-only by default (clean clone). Setting OTLP_ENDPOINT to an // observability. Console-only by default (clean clone). Setting OTLP_ENDPOINT to an
// OpenTelemetry Collector exports structured logs + per-request spans there (Loki/Tempo). // OpenTelemetry Collector exports structured logs + per-request spans there (Loki/Tempo).
logFormat: readEnum(env, "LOG_FORMAT", ["json", "text"] as const, "text"), logFormat: readEnum(env, "LOG_FORMAT", ["json", "text"] as const, "text"),
logLevel: readEnum(env, "LOG_LEVEL", LOG_LEVELS, "info"), logLevel: readEnum(env, "LOG_LEVEL", LOG_LEVELS, "info"),
@@ -157,13 +157,13 @@ export function loadConfig(env: Env = process.env): Config {
otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"), otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"), otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
port: readPort(env), port: readPort(env),
// Optional instant-revoke (§9), off by default. When on, an admin deactivate/delete or role // Optional instant-revoke, off by default. When on, an admin deactivate/delete or role
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m // change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
// tokenizer TTL + skew, so it outlasts any pre-revoke token). // tokenizer TTL + skew, so it outlasts any pre-revoke token).
revocationDenylist: readBool(env, "REVOCATION_DENYLIST", false), revocationDenylist: readBool(env, "REVOCATION_DENYLIST", false),
revocationTtlSec: readPosInt(env, "REVOCATION_TTL_SEC", 900), revocationTtlSec: readPosInt(env, "REVOCATION_TTL_SEC", 900),
// Set Secure on our session/CSRF cookies. Off by default (dev runs http); prod (https) sets it. // Set Secure on our session/CSRF cookies. Off by default (dev runs http); prod (https) sets it.
secureCookies: readBool(env, "SECURE_COOKIES", false), secureCookies: readBool(env, "SECURE_COOKIES", false),
serviceName: env["SERVICE_NAME"] || "plainpages", // §9 OTLP service.name; empty ⇒ default serviceName: env["SERVICE_NAME"] || "plainpages", // OTLP service.name; empty ⇒ default
}; };
} }
+1 -1
View File
@@ -46,7 +46,7 @@ test("buildContext defaults a missing request URL to /", () => {
assert.equal(buildContext(req, res).url.pathname, "/"); assert.equal(buildContext(req, res).url.pathname, "/");
}); });
test("buildContext provides a logger: a silent default, or the host's request logger (§9)", () => { test("buildContext provides a logger: a silent default, or the host's request logger", () => {
const { req, res } = reqRes("/"); const { req, res } = reqRes("/");
assert.equal(typeof buildContext(req, res).log.info, "function"); // always present (silent default) assert.equal(typeof buildContext(req, res).log.info, "function"); // always present (silent default)
const log = createLogger({ level: "none" }); const log = createLogger({ level: "none" });
+3 -3
View File
@@ -3,10 +3,10 @@ import type { PageChrome } from "./chrome.ts"; // type-only: no runtime import,
import { createLogger, type Log } from "./logger.ts"; import { createLogger, type Log } from "./logger.ts";
// The request context threaded to every route handler (plugin + built-in), built once // The request context threaded to every route handler (plugin + built-in), built once
// per request by `buildContext`: the router supplies matched path `params`, the §4 JWT // per request by `buildContext`: the router supplies matched path `params`, the JWT
// middleware supplies `user` (null until then). The host's single handler argument. // middleware supplies `user` (null until then). The host's single handler argument.
// The authenticated user, projected from verified session JWT claims (§4): // The authenticated user, projected from verified session JWT claims:
// `id` = `sub`, plus `email` and the coarse `roles` carried in the token. // `id` = `sub`, plus `email` and the coarse `roles` carried in the token.
export interface User { export interface User {
email: string; email: string;
@@ -18,7 +18,7 @@ export interface RequestContext {
// Page chrome (brand/global-nav/user/theme/csrf) a plugin view hands to partials/shell so its // Page chrome (brand/global-nav/user/theme/csrf) a plugin view hands to partials/shell so its
// page renders the native app shell; the host builds it per request (anonymous default otherwise). // page renders the native app shell; the host builds it per request (anonymous default otherwise).
chrome: PageChrome; chrome: PageChrome;
// Request-scoped logger (§9): structured, in the request's trace. `log.info/warn/error(...)` to // Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to
// log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by // log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by
// requestId. Additive, stable per the contract; defaults to a silent logger off the request path. // requestId. Additive, stable per the contract; defaults to a silent logger off the request path.
log: Log; log: Log;
+1 -1
View File
@@ -1,5 +1,5 @@
// Cookie helpers — parse the request `Cookie` header, build secure-by-default // Cookie helpers — parse the request `Cookie` header, build secure-by-default
// `Set-Cookie` headers. Stdlib only (no `cookie` dep); §4 stores/clears the session // `Set-Cookie` headers. Stdlib only (no `cookie` dep); stores/clears the session
// JWT + CSRF token here. Values round-trip via percent-encoding; JWT `-_.` chars are // JWT + CSRF token here. Values round-trip via percent-encoding; JWT `-_.` chars are
// URI-unreserved, so JWTs stay readable. // URI-unreserved, so JWTs stay readable.
+1 -1
View File
@@ -1,4 +1,4 @@
// CSRF protection for our own POST forms (todo §4). Stateless signed double-submit token: // CSRF protection for our own POST forms. Stateless signed double-submit token:
// the token is `<nonce>.<HMAC(secret, nonce)>`, set as a cookie *and* echoed in a hidden form // the token is `<nonce>.<HMAC(secret, nonce)>`, set as a cookie *and* echoed in a hidden form
// field. A request passes iff the cookie is a genuine signature (can't be forged without the // field. A request passes iff the cookie is a genuine signature (can't be forged without the
// secret) and the submitted field equals it. SameSite=Lax already blocks the cross-site POST // secret) and the submitted field equals it. SameSite=Lax already blocks the cross-site POST
+1 -1
View File
@@ -3,7 +3,7 @@ import { test } from "node:test";
import { buildDashboardModel } from "./dashboard.ts"; import { buildDashboardModel } from "./dashboard.ts";
import type { NavNode } from "./nav.ts"; import type { NavNode } from "./nav.ts";
// The default /dashboard is an instructional starter (todo §10): no mock data, just the unified // The default /dashboard is an instructional starter: no mock data, just the unified
// menu + shell. The host passes the one global menu (ctx.chrome.nav); the model passes it through. // menu + shell. The host passes the one global menu (ctx.chrome.nav); the model passes it through.
const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }]; const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }];
+1 -1
View File
@@ -1,4 +1,4 @@
// Dashboard view model (todo §10): the gated "/dashboard" app home. By default a short instructional // Dashboard view model: the gated "/dashboard" app home. By default a short instructional
// starter — what the dashboard is and how to replace it by exporting a `dashboard` handler from a // starter — what the dashboard is and how to replace it by exporting a `dashboard` handler from a
// plugin (views/index.ejs holds the prose). A plugin owns the real one (PluginManifest.dashboard); // plugin (views/index.ejs holds the prose). A plugin owns the real one (PluginManifest.dashboard);
// this placeholder renders until then. Pure: `nav` is the one global menu (ctx.chrome.nav), built // this placeholder renders until then. Pure: `nav` is the one global menu (ctx.chrome.nav), built
+1 -1
View File
@@ -1,4 +1,4 @@
// Optional revocation denylist (todo §9): instant role/session revoke without putting Keto // Optional revocation denylist: instant role/session revoke without putting Keto
// back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true. // back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true.
// //
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked role or a // The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked role or a
+4 -4
View File
@@ -51,8 +51,8 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
{ name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s }, { name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s },
{ name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s }, { name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s },
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ }, { name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
{ name: "a route marked public AND permission is contradictory (§10)", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, permission: "x", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s }, { name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, permission: "x", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s },
{ name: "a nav node marked public AND permission is contradictory (§10)", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, permission: "x" }] };` }, match: /contranav.*public.*permission/s }, { name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, permission: "x" }] };` }, match: /contranav.*public.*permission/s },
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ }, { name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ },
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ }, { name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ },
]; ];
@@ -63,7 +63,7 @@ for (const c of badCases) {
}); });
} }
test("a route + nav node may be marked public (§10) and load fine", async (t) => { test("a route + nav node may be marked public and load fine", async (t) => {
const dir = scaffold(t, { "pub/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };` }); const dir = scaffold(t, { "pub/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };` });
const plugins = await discoverPlugins({ dir }); const plugins = await discoverPlugins({ dir });
assert.equal(plugins.length, 1); assert.equal(plugins.length, 1);
@@ -71,7 +71,7 @@ test("a route + nav node may be marked public (§10) and load fine", async (t) =
assert.equal(plugins[0]?.nav?.[0]?.public, true); assert.equal(plugins[0]?.nav?.[0]?.public, true);
}); });
test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers (§10)", async (t) => { test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => {
const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ view: "home" }), dashboard: () => ({ view: "dash" }) };` }); const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ view: "home" }), dashboard: () => ({ view: "dash" }) };` });
const plugins = await discoverPlugins({ dir }); const plugins = await discoverPlugins({ dir });
assert.equal(plugins.length, 1); assert.equal(plugins.length, 1);
+4 -4
View File
@@ -1,4 +1,4 @@
// Plugin discovery (todo §2): scan plugins/, import each folder's plugin.ts default export, // Plugin discovery: scan plugins/, import each folder's plugin.ts default export,
// validate it, assemble the loaded Plugin[]. The imperative shell over plugin.ts's pure rules // validate it, assemble the loaded Plugin[]. The imperative shell over plugin.ts's pure rules
// (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin problem and // (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin problem and
// error-level conflict is collected into one boot-stopping Error; warn-level diagnostics // error-level conflict is collected into one boot-stopping Error; warn-level diagnostics
@@ -88,12 +88,12 @@ function shapeError(manifest: PluginManifest): string | null {
for (const field of ["nav", "permissions", "routes"] as const) { for (const field of ["nav", "permissions", "routes"] as const) {
if (manifest[field] !== undefined && !Array.isArray(manifest[field])) return `"${field}" must be an array`; if (manifest[field] !== undefined && !Array.isArray(manifest[field])) return `"${field}" must be an array`;
} }
// `home` / `dashboard` (the §10 landing-page overrides) are route handlers; the host calls them, so // `home` / `dashboard` (the landing-page overrides) are route handlers; the host calls them, so
// a non-function fails loud. // a non-function fails loud.
for (const slot of ["home", "dashboard"] as const) { for (const slot of ["home", "dashboard"] as const) {
if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`; if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`;
} }
// `public` and `permission` are contradictory on the same route/nav node (§10) — "open to all" vs // `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
// "needs this role". Refuse rather than silently pick one, so the author's intent is unambiguous. // "needs this role". Refuse rather than silently pick one, so the author's intent is unambiguous.
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) { for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
if (route?.public === true && route.permission != null) return `route "${route.method} ${route.path}" sets both public and permission — they are mutually exclusive`; if (route?.public === true && route.permission != null) return `route "${route.method} ${route.path}" sets both public and permission — they are mutually exclusive`;
@@ -103,7 +103,7 @@ function shapeError(manifest: PluginManifest): string | null {
return null; return null;
} }
// Recurse the nav fragment: a node that is both `public` and `permission`-gated is contradictory (§10). // Recurse the nav fragment: a node that is both `public` and `permission`-gated is contradictory.
function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null { function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null {
for (const node of Array.isArray(nodes) ? nodes : []) { for (const node of Array.isArray(nodes) ? nodes : []) {
if (node?.public === true && node.permission != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and permission — they are mutually exclusive`; if (node?.public === true && node.permission != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and permission — they are mutually exclusive`;
+1 -1
View File
@@ -1,4 +1,4 @@
// Bound every outbound Ory call (todo §8 review): a reachable-but-silent host — a hung container, a // Bound every outbound Ory call: a reachable-but-silent host — a hung container, a
// black-holed socket, an LB holding the connection — would otherwise park a request handler forever // black-holed socket, an LB holding the connection — would otherwise park a request handler forever
// (and exhaust the pool under load). Wrap the injected `fetch` so each call aborts after `ms` unless // (and exhaust the pool under load). Wrap the injected `fetch` so each call aborts after `ms` unless
// the caller already passed its own signal. server.ts wires this into the Kratos/Keto/Hydra clients. // the caller already passed its own signal. server.ts wires this into the Kratos/Keto/Hydra clients.
+1 -1
View File
@@ -1,4 +1,4 @@
// Kratos flow → themed view model (todo §4). Pure: turns a fetched self-service Flow // Kratos flow → themed view model. Pure: turns a fetched self-service Flow
// (src/kratos-public.ts) into the data views/auth.ejs renders — hidden inputs (incl. the // (src/kratos-public.ts) into the data views/auth.ejs renders — hidden inputs (incl. the
// CSRF token), themed fields, submit buttons, tone-mapped messages, and one SSO button per // CSRF token), themed fields, submit buttons, tone-mapped messages, and one SSO button per
// configured `oidc` provider. The form posts straight back to `flow.ui.action`, so Kratos // configured `oidc` provider. The form posts straight back to `flow.ui.action`, so Kratos
+3 -3
View File
@@ -1,6 +1,6 @@
// Guards the session-tokenizer signing key (§3): generateJwks() emits a fresh ES256 // Guards the session-tokenizer signing key: generateJwks() emits a fresh ES256
// EC private signing key, the committed dev JWKS is a valid such key, and a token signed // EC private signing key, the committed dev JWKS is a valid such key, and a token signed
// with it verifies through our own verifier (src/jwt.ts) — so what Kratos signs, §4 reads. // with it verifies through our own verifier (src/jwt.ts) — so what Kratos signs, reads.
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
@@ -45,7 +45,7 @@ test("rotateJwks --prune keeps only the newest (first) key, dropping superseded
assert.deepEqual(pruned.keys, [twoKeys.keys[0]], "only the active signing key remains"); assert.deepEqual(pruned.keys, [twoKeys.keys[0]], "only the active signing key remains");
}); });
test("a JWS signed with a generated key verifies via our own verifier (§4 reads what Kratos signs)", () => { test("a JWS signed with a generated key verifies via our own verifier (reads what Kratos signs)", () => {
const key = generateJwks().keys[0]!; const key = generateJwks().keys[0]!;
const head = b64url(JSON.stringify({ alg: "ES256", kid: key.kid })); const head = b64url(JSON.stringify({ alg: "ES256", kid: key.kid }));
const body = b64url(JSON.stringify({ email: "a@b.c", roles: [], sub: key.kid })); const body = b64url(JSON.stringify({ email: "a@b.c", roles: [], sub: key.kid }));
+1 -1
View File
@@ -2,7 +2,7 @@ import { generateKeyPairSync, randomUUID } from "node:crypto";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
// ES256 signing JWKS for the Kratos session tokenizer (§3) — Ory-recommended and the // ES256 signing JWKS for the Kratos session tokenizer — Ory-recommended and the
// verifier's preferred alg (src/jwt.ts). Rotation runbook: README, JWT signing key. // verifier's preferred alg (src/jwt.ts). Rotation runbook: README, JWT signing key.
// CLI (prod supplies its own key; the committed one is a dev throwaway): // CLI (prod supplies its own key; the committed one is a dev throwaway):
// gen-jwks.ts → a fresh one-key set (mint/replace; emergency rotation) // gen-jwks.ts → a fresh one-key set (mint/replace; emergency rotation)
+2 -2
View File
@@ -1,4 +1,4 @@
// Auth guards (todo §4): in-handler authorization, the imperative counterpart to the // Auth guards: in-handler authorization, the imperative counterpart to the
// declarative route `permission` gate. The middleware already verified the session JWT and put // declarative route `permission` gate. The middleware already verified the session JWT and put
// the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps // the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps
// to a response); `can`/`check` are predicates a handler branches on. `check` is the one live // to a response); `can`/`check` are predicates a handler branches on. `check` is the one live
@@ -8,7 +8,7 @@ import type { KetoClient } from "./keto-client.ts";
import { localPath } from "./safe-url.ts"; import { localPath } from "./safe-url.ts";
// Build the sign-in redirect for a gated request, preserving where the user was headed as // Build the sign-in redirect for a gated request, preserving where the user was headed as
// `return_to` so login can land them back there (§9). Only a safe GET/HEAD navigation to a // `return_to` so login can land them back there. Only a safe GET/HEAD navigation to a
// non-home, host-relative path is remembered (a POST or "/" ⇒ a bare /login); the target is // non-home, host-relative path is remembered (a POST or "/" ⇒ a bare /login); the target is
// validated host-relative (localPath) so it can't become an open redirect. // validated host-relative (localPath) so it can't become an open redirect.
export function loginRedirect(ctx: RequestContext): string { export function loginRedirect(ctx: RequestContext): string {
+1 -1
View File
@@ -1,4 +1,4 @@
// Plugin lifecycle hooks (todo §2): the host invokes the optional PluginHooks a plugin may declare // Plugin lifecycle hooks: the host invokes the optional PluginHooks a plugin may declare
// (docs/plugin-contract.md → Hooks). No sandbox — a throwing hook fails loud (boot for onBoot, the // (docs/plugin-contract.md → Hooks). No sandbox — a throwing hook fails loud (boot for onBoot, the
// request for the others). Hooks run in discovery order (plugins sorted by id). app.ts skips these // request for the others). Hooks run in discovery order (plugins sorted by id). app.ts skips these
// entirely when no plugin declares the hook, so the no-hooks hot path stays free. // entirely when no plugin declares the hook, so the no-hooks hot path stays free.
+2 -2
View File
@@ -1,4 +1,4 @@
// Hydra admin-API client (§6): typed fetch wrappers over Ory Hydra's OAuth2 login/consent // Hydra admin-API client: typed fetch wrappers over Ory Hydra's OAuth2 login/consent
// challenge handshake. Guards the request contracts (URLs, method, login_challenge query, // challenge handshake. Guards the request contracts (URLs, method, login_challenge query,
// JSON body) and the result mapping (200 → request/redirect, non-2xx → HydraError). Live // JSON body) and the result mapping (200 → request/redirect, non-2xx → HydraError). Live
// wiring is verified by the OAuth login E2E. // wiring is verified by the OAuth login E2E.
@@ -93,7 +93,7 @@ test("a non-2xx response throws a HydraError carrying the status", async () => {
); );
}); });
// OAuth2 client registration (§6): create/list/get/delete clients over Hydra's admin API. // OAuth2 client registration: create/list/get/delete clients over Hydra's admin API.
test("createClient POSTs the client and returns it (incl. the one-time client_secret)", async () => { test("createClient POSTs the client and returns it (incl. the one-time client_secret)", async () => {
const created = { client_id: "c1", client_name: "Acme", client_secret: "s3cr3t", redirect_uris: ["https://acme/cb"] }; const created = { client_id: "c1", client_name: "Acme", client_secret: "s3cr3t", redirect_uris: ["https://acme/cb"] };
const { calls, fetchImpl } = recorder(() => res(201, created)); const { calls, fetchImpl } = recorder(() => res(201, created));
+4 -4
View File
@@ -1,4 +1,4 @@
// Hydra admin-API client (todo §6): typed `fetch` wrappers over Ory Hydra's OAuth2 admin // Hydra admin-API client: typed `fetch` wrappers over Ory Hydra's OAuth2 admin
// endpoints (internal admin port) — the login/consent challenge handshake other apps log in // endpoints (internal admin port) — the login/consent challenge handshake other apps log in
// *through* us with. Built-in `fetch` only, no SDK dep (AGENTS.md); `fetchImpl`-injectable // *through* us with. Built-in `fetch` only, no SDK dep (AGENTS.md); `fetchImpl`-injectable
// like the kratos/keto clients. We authenticate the user (login) and grant scopes (consent); // like the kratos/keto clients. We authenticate the user (login) and grant scopes (consent);
@@ -9,7 +9,7 @@ export interface OAuth2Client {
client_name?: string; client_name?: string;
client_secret?: string; // write-only: Hydra returns it once, on create, for a confidential client client_secret?: string; // write-only: Hydra returns it once, on create, for a confidential client
grant_types?: string[]; grant_types?: string[];
metadata?: Record<string, unknown>; // arbitrary client metadata; `first_party: true` ⇒ auto-consent (§6) metadata?: Record<string, unknown>; // arbitrary client metadata; `first_party: true` ⇒ auto-consent
redirect_uris?: string[]; redirect_uris?: string[];
response_types?: string[]; response_types?: string[];
scope?: string; // space-separated scope?: string; // space-separated
@@ -90,7 +90,7 @@ export class HydraError extends Error {
export interface HydraAdmin { export interface HydraAdmin {
acceptConsentRequest(challenge: string, body: AcceptConsent): Promise<Completed>; acceptConsentRequest(challenge: string, body: AcceptConsent): Promise<Completed>;
acceptLoginRequest(challenge: string, body: AcceptLogin): Promise<Completed>; acceptLoginRequest(challenge: string, body: AcceptLogin): Promise<Completed>;
acceptLogoutRequest(challenge: string): Promise<Completed>; // RP-initiated logout (§6): confirm + resume acceptLogoutRequest(challenge: string): Promise<Completed>; // RP-initiated logout: confirm + resume
createClient(client: OAuth2Client): Promise<OAuth2Client>; createClient(client: OAuth2Client): Promise<OAuth2Client>;
deleteClient(id: string): Promise<void>; deleteClient(id: string): Promise<void>;
getClient(id: string): Promise<OAuth2Client | null>; getClient(id: string): Promise<OAuth2Client | null>;
@@ -145,7 +145,7 @@ export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof f
return put("accept logout", reqUrl("logout", challenge, "/accept"), {}); return put("accept logout", reqUrl("logout", challenge, "/accept"), {});
}, },
// OAuth2 client registration (§6, admin screen). Hydra generates the client_id/secret when // OAuth2 client registration (admin screen). Hydra generates the client_id/secret when
// omitted; the secret rides the 201 body and is never retrievable afterwards. // omitted; the secret rides the 201 body and is never retrievable afterwards.
async createClient(client) { async createClient(client) {
const res = await http(clientsUrl, { body: JSON.stringify(client), headers: json, method: "POST" }); const res = await http(clientsUrl, { body: JSON.stringify(client), headers: json, method: "POST" });
+1 -1
View File
@@ -1,4 +1,4 @@
// Guards the Ory Hydra config (§3): migrations run before the server (hydra-migrate → // Guards the Ory Hydra config: migrations run before the server (hydra-migrate →
// hydra), the DSN targets the hydra database, the server listens on the public/admin // hydra), the DSN targets the hydra database, the server listens on the public/admin
// ports, and the issuer + login/consent/logout URLs point at our app. Version pinning is // ports, and the issuer + login/consent/logout URLs point at our app. Version pinning is
// in compose.test.ts. Real boot is verified by running the stack; this catches edits. // in compose.test.ts. Real boot is verified by running the stack; this catches edits.
+1 -1
View File
@@ -29,5 +29,5 @@ test("icons partial inlines exactly the used lucide-static icons", async () => {
assert.match(symbolInner(built, "i-x"), /M18 6 6 18/); assert.match(symbolInner(built, "i-x"), /M18 6 6 18/);
assert.match(symbolInner(built, "i-search"), /circle cx="11" cy="11" r="8"/); assert.match(symbolInner(built, "i-search"), /circle cx="11" cy="11" r="8"/);
assert.match(symbolInner(built, "i-kebab"), /circle cx="12" cy="12" r="1"/); assert.match(symbolInner(built, "i-kebab"), /circle cx="12" cy="12" r="1"/);
assert.match(symbolInner(built, "i-bell"), /M10\.268 21/); // lucide v1.18 path, not the mockup's older one assert.match(symbolInner(built, "i-bell"), /M10\.268 21/); // lucide v1.18 path, not an older one
}); });
+2 -2
View File
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { currentLog } from "./logger.ts"; import { currentLog } from "./logger.ts";
// JWKS provider: resolve the JWT verify key by the JWS `kid` (todo §4). The middleware calls // JWKS provider: resolve the JWT verify key by the JWS `kid`. The middleware calls
// `getKey` per request. `staticJwks` holds a fixed set; `cachingJwks` fetches over the network // `getKey` per request. `staticJwks` holds a fixed set; `cachingJwks` fetches over the network
// (or re-reads a mounted file), caches for a TTL, and reloads once on a `kid` miss so a rotated- // (or re-reads a mounted file), caches for a TTL, and reloads once on a `kid` miss so a rotated-
// in key is picked up without a restart (README: zero-downtime rotation). `createJwksProvider` // in key is picked up without a restart (README: zero-downtime rotation). `createJwksProvider`
@@ -98,7 +98,7 @@ export function cachingJwks(load: () => Promise<JsonWebKey[]>, opts: JwksCacheOp
// Build the verify-key provider from the configured JWKS URL and prime it at boot (fail loud): // Build the verify-key provider from the configured JWKS URL and prime it at boot (fail loud):
// `base64://` → immutable inline set; `file://` → re-readable cache (rotation by remount/edit); // `base64://` → immutable inline set; `file://` → re-readable cache (rotation by remount/edit);
// `http(s)://` → fetched, cached, rotation-on-miss. The §4 middleware sees only `getKey`. // `http(s)://` → fetched, cached, rotation-on-miss. The middleware sees only `getKey`.
export async function createJwksProvider(jwksUrl: string, opts: JwksCacheOptions = {}): Promise<JwksProvider> { export async function createJwksProvider(jwksUrl: string, opts: JwksCacheOptions = {}): Promise<JwksProvider> {
if (jwksUrl.startsWith("base64://")) return staticJwks(loadJwks(jwksUrl)); if (jwksUrl.startsWith("base64://")) return staticJwks(loadJwks(jwksUrl));
const { protocol } = new URL(jwksUrl); const { protocol } = new URL(jwksUrl);
+2 -2
View File
@@ -72,7 +72,7 @@ test("resolveSession classifies the cookie; authenticate is its fail-closed user
// A valid token → the user, not expired. // A valid token → the user, not expired.
assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, user }); assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, user });
// Present but past exp → the §4 re-mint trigger (expired flagged, no user). // Present but past exp → the re-mint trigger (expired flagged, no user).
assert.deepEqual(await resolveSession(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), { expired: true, user: null }); assert.deepEqual(await resolveSession(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), { expired: true, user: null });
// No cookie / non-ours / garbage / bad-signature are NOT re-mint candidates (no Ory round-trip). // No cookie / non-ours / garbage / bad-signature are NOT re-mint candidates (no Ory round-trip).
assert.deepEqual(await resolveSession(undefined, jwks, { now: NOW }), { expired: false, user: null }); assert.deepEqual(await resolveSession(undefined, jwks, { now: NOW }), { expired: false, user: null });
@@ -90,7 +90,7 @@ test("verifyToken honours an optional denylist: a revoked subject's token reject
// Deny u1's tokens minted at/before NOW; a token minted after passes (a fresh re-login). // Deny u1's tokens minted at/before NOW; a token minted after passes (a fresh re-login).
const denylist = { isRevoked: (sub: string, iat: number | undefined) => sub === "u1" && (iat === undefined || iat <= NOW) }; const denylist = { isRevoked: (sub: string, iat: number | undefined) => sub === "u1" && (iat === undefined || iat <= NOW) };
// Revoked: thrown as *expired* so resolveSession flags it for the §4 re-mint (re-read Keto / clear). // Revoked: thrown as *expired* so resolveSession flags it for the re-mint (re-read Keto / clear).
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 }), jwks, { denylist, now: NOW }), /revoked/); await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 }), jwks, { denylist, now: NOW }), /revoked/);
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 })}`, jwks, { denylist, now: NOW }), { expired: true, user: null }); assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 })}`, jwks, { denylist, now: NOW }), { expired: true, user: null });
// A token minted after the revoke (fresh login) is accepted; a different subject is untouched. // A token minted after the revoke (fresh login) is accepted; a different subject is untouched.
+6 -6
View File
@@ -1,4 +1,4 @@
// JWT session middleware (todo §4): verify our session cookie in-process on every request — // JWT session middleware: verify our session cookie in-process on every request —
// the hot path that never calls Ory. Select the verify key by `kid` from the cached JWKS, // the hot path that never calls Ory. Select the verify key by `kid` from the cached JWKS,
// check the signature (src/jwt.ts), validate the time/issuer/audience claims, project the // check the signature (src/jwt.ts), validate the time/issuer/audience claims, project the
// User onto the request context. `authenticate` fails closed: any bad/expired token ⇒ null // User onto the request context. `authenticate` fails closed: any bad/expired token ⇒ null
@@ -16,14 +16,14 @@ const DEFAULT_CLOCK_SKEW_SEC = 60;
export interface VerifyOptions { export interface VerifyOptions {
audience?: string | undefined; // if set, the token `aud` must include it (else skipped) audience?: string | undefined; // if set, the token `aud` must include it (else skipped)
clockSkewSec?: number | undefined; clockSkewSec?: number | undefined;
denylist?: Pick<Denylist, "isRevoked"> | undefined; // optional instant-revoke (§9); a revoked sub is rejected like an expiry denylist?: Pick<Denylist, "isRevoked"> | undefined; // optional instant-revoke; a revoked sub is rejected like an expiry
issuer?: string | undefined; // if set, the token `iss` must equal it (else skipped) issuer?: string | undefined; // if set, the token `iss` must equal it (else skipped)
now?: number | undefined; // unix seconds; injectable for tests now?: number | undefined; // unix seconds; injectable for tests
} }
// A rejected token (bad signature, expired, wrong iss/aud, malformed claims). `authenticate` // A rejected token (bad signature, expired, wrong iss/aud, malformed claims). `authenticate`
// swallows it to anonymous; a caller wanting the reason can catch it. `expired` is set only for // swallows it to anonymous; a caller wanting the reason can catch it. `expired` is set only for
// a lapsed-but-otherwise-intact token — the §4 re-mint trigger (see resolveSession). // a lapsed-but-otherwise-intact token — the re-mint trigger (see resolveSession).
export class TokenError extends Error { export class TokenError extends Error {
expired: boolean; expired: boolean;
constructor(message: string, expired = false) { constructor(message: string, expired = false) {
@@ -79,14 +79,14 @@ export async function verifyToken(token: string, jwks: JwksProvider, options: Ve
const verified = verifyJws(token, jwk); // throws on a bad signature / disallowed alg const verified = verifyJws(token, jwk); // throws on a bad signature / disallowed alg
validateClaims(verified.payload, options); validateClaims(verified.payload, options);
const user = claimsToUser(verified.payload); const user = claimsToUser(verified.payload);
// Instant revoke (§9): a denylisted subject's pre-revoke token is rejected as *expired* so // Instant revoke: a denylisted subject's pre-revoke token is rejected as *expired* so
// resolveSession routes it through the §4 re-mint (fresh roles from Keto, or a cleared session). // resolveSession routes it through the re-mint (fresh roles from Keto, or a cleared session).
if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true); if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true);
return user; return user;
} }
export interface SessionAuth { export interface SessionAuth {
expired: boolean; // a token was present but rejected as *expired* → a re-mint candidate (§4) expired: boolean; // a token was present but rejected as *expired* → a re-mint candidate
user: User | null; user: User | null;
} }
+4 -4
View File
@@ -1,9 +1,9 @@
import { createPublicKey, verify } from "node:crypto"; import { createPublicKey, verify } from "node:crypto";
import type { JsonWebKey, KeyObject } from "node:crypto"; import type { JsonWebKey, KeyObject } from "node:crypto";
// JWS signature verification with the Node stdlib — no `jose`/JWT dep (todo §0): // JWS signature verification with the Node stdlib — no `jose`/JWT dep:
// `createPublicKey({format:"jwk"})` imports a JWK and verifies the RS*/ES* signatures // `createPublicKey({format:"jwk"})` imports a JWK and verifies the RS*/ES* signatures
// the Kratos tokenizer produces (see AGENTS.md). Signature only — §4 adds claim checks // the Kratos tokenizer produces (see AGENTS.md). Signature only — adds claim checks
// (exp/iss/aud, clock skew), JWKS-by-`kid` fetch/cache/rotation, and `token` bounds. // (exp/iss/aud, clock skew), JWKS-by-`kid` fetch/cache/rotation, and `token` bounds.
// JOSE `alg` → Node verify parameters. ES* signatures are raw r‖s (IEEE P1363), not DER. // JOSE `alg` → Node verify parameters. ES* signatures are raw r‖s (IEEE P1363), not DER.
@@ -27,7 +27,7 @@ export interface DecodedJws {
} }
// Unpadded base64url alphabet — `Buffer.from(_,"base64url")` is lax (drops junk, tolerates // Unpadded base64url alphabet — `Buffer.from(_,"base64url")` is lax (drops junk, tolerates
// bad padding), so reject non-canonical segments up front. §4 reads `kid` from the still- // bad padding), so reject non-canonical segments up front. reads `kid` from the still-
// unverified header, so this stops laundered bytes reaching key selection. // unverified header, so this stops laundered bytes reaching key selection.
const base64url = /^[A-Za-z0-9_-]+$/; const base64url = /^[A-Za-z0-9_-]+$/;
@@ -65,7 +65,7 @@ export function decodeJws(token: string): DecodedJws {
} }
// Verify a compact JWS against one JWK public key; returns the decoded JWS or throws. // Verify a compact JWS against one JWK public key; returns the decoded JWS or throws.
// Signature only — caller validates claims. Returned header is post-verification, so §4 // Signature only — caller validates claims. Returned header is post-verification, so the caller
// can trust its `alg`/`kid` when logging. // can trust its `alg`/`kid` when logging.
export function verifyJws(token: string, jwk: JsonWebKey): DecodedJws { export function verifyJws(token: string, jwk: JsonWebKey): DecodedJws {
const decoded = decodeJws(token); const decoded = decodeJws(token);
+2 -2
View File
@@ -1,7 +1,7 @@
// Keto client (§4): typed fetch wrappers over Ory Keto's read (check/list/expand) and // Keto client: typed fetch wrappers over Ory Keto's read (check/list/expand) and
// write (write/delete tuple) APIs. Guards the request contracts (URLs, ports, method, // write (write/delete tuple) APIs. Guards the request contracts (URLs, ports, method,
// query/body shape, subject_id vs subject_set) and the result mapping (allowed bool, the // query/body shape, subject_id vs subject_set) and the result mapping (allowed bool, the
// next_page_token, 2xx/204/error). Live wiring is verified by login completion + guards (§4). // next_page_token, 2xx/204/error). Live wiring is verified by login completion + guards.
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { createKetoClient, KetoError } from "./keto-client.ts"; import { createKetoClient, KetoError } from "./keto-client.ts";
+2 -2
View File
@@ -1,4 +1,4 @@
// Keto client (todo §4): typed `fetch` wrappers over Ory Keto's relation-tuple APIs — // Keto client: typed `fetch` wrappers over Ory Keto's relation-tuple APIs —
// `check` a permission, `listRelations`/`expand` to inspect them (read API), `writeTuple`/ // `check` a permission, `listRelations`/`expand` to inspect them (read API), `writeTuple`/
// `deleteTuple` to grant/revoke them (write API). Built-in `fetch` only, no SDK dep (AGENTS.md); // `deleteTuple` to grant/revoke them (write API). Built-in `fetch` only, no SDK dep (AGENTS.md);
// `fetchImpl`-injectable like the kratos clients. Read/write split onto the two ports config.ts // `fetchImpl`-injectable like the kratos clients. Read/write split onto the two ports config.ts
@@ -32,7 +32,7 @@ export interface RelationList {
// Keto's expand tree: a node is a set operation (union/…) or a leaf. The resolved subject // Keto's expand tree: a node is a set operation (union/…) or a leaf. The resolved subject
// (subject_id xor subject_set) rides on `tuple`, not the node itself — verified against Keto // (subject_id xor subject_set) rides on `tuple`, not the node itself — verified against Keto
// v26.2.0. A `subject_set` node carries its members as `children` (§5 "effective access" view). // v26.2.0. A `subject_set` node carries its members as `children` ("effective access" view).
export interface ExpandTree { export interface ExpandTree {
children?: ExpandTree[]; children?: ExpandTree[];
tuple?: RelationTuple; tuple?: RelationTuple;
+1 -1
View File
@@ -1,4 +1,4 @@
// Guards the Ory Keto config (§3): migrations run before the server (keto-migrate → // Guards the Ory Keto config: migrations run before the server (keto-migrate →
// keto), the DSN targets the keto database, read/write APIs serve on the ports config.ts // keto), the DSN targets the keto database, read/write APIs serve on the ports config.ts
// points at, and the OPL declares the role/group/resource namespaces. Version pinning is // points at, and the OPL declares the role/group/resource namespaces. Version pinning is
// in compose.test.ts. Real boot is verified by running the stack; this catches edits. // in compose.test.ts. Real boot is verified by running the stack; this catches edits.
+2 -2
View File
@@ -1,7 +1,7 @@
// Kratos admin-API client (§4): typed fetch wrappers over Ory Kratos' admin endpoints — // Kratos admin-API client: typed fetch wrappers over Ory Kratos' admin endpoints —
// identity CRUD + the surgical metadata_public update the login flow projects roles into. // identity CRUD + the surgical metadata_public update the login flow projects roles into.
// Guards the request contracts (URLs, method, JSON-Patch body, query/pagination) and the // Guards the request contracts (URLs, method, JSON-Patch body, query/pagination) and the
// result mapping (201/200/404/4xx). Live wiring is verified by login completion (§4). // result mapping (201/200/404/4xx). Live wiring is verified by login completion.
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { createKratosAdmin } from "./kratos-admin.ts"; import { createKratosAdmin } from "./kratos-admin.ts";
+2 -2
View File
@@ -1,4 +1,4 @@
// Kratos admin-API client (todo §4): typed `fetch` wrappers over Ory Kratos' admin endpoints // Kratos admin-API client: typed `fetch` wrappers over Ory Kratos' admin endpoints
// (internal-only admin port) — identity CRUD + the surgical `metadata_public` update login // (internal-only admin port) — identity CRUD + the surgical `metadata_public` update login
// completion projects Keto roles into (README). Built-in `fetch` only, no SDK dep (AGENTS.md); // completion projects Keto roles into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
// `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`). // `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`).
@@ -25,7 +25,7 @@ export interface ListOptions {
pageToken?: string; pageToken?: string;
} }
// A one-time recovery code + the self-service link wrapping it (admin "trigger recovery", §5). // A one-time recovery code + the self-service link wrapping it (admin "trigger recovery").
export interface RecoveryCode { export interface RecoveryCode {
code: string; code: string;
link: string; link: string;
+2 -2
View File
@@ -1,7 +1,7 @@
// Kratos public-API client (§4): typed fetch wrappers over Ory Kratos' public endpoints. // Kratos public-API client: typed fetch wrappers over Ory Kratos' public endpoints.
// Guards the request contracts (URLs, JSON-accept, cookie relay) and the result mapping // Guards the request contracts (URLs, JSON-accept, cookie relay) and the result mapping
// (200/401/4xx, validation-flow vs success, tokenized JWT). Live wiring is verified by the // (200/401/4xx, validation-flow vs success, tokenized JWT). Live wiring is verified by the
// flow pages (§4); these catch contract drift with a mock fetch. // flow pages; these catch contract drift with a mock fetch.
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { createKratosPublic, KratosError } from "./kratos-public.ts"; import { createKratosPublic, KratosError } from "./kratos-public.ts";
+1 -1
View File
@@ -1,4 +1,4 @@
// Kratos public-API client (todo §4): typed `fetch` wrappers over Ory Kratos' public // Kratos public-API client: typed `fetch` wrappers over Ory Kratos' public
// endpoints — self-service flow init/get/submit, browser logout, session `whoami`, and the // endpoints — self-service flow init/get/submit, browser logout, session `whoami`, and the
// session→JWT tokenizer (`whoami?tokenize_as`). Built-in `fetch` only, no SDK dep (AGENTS.md). // session→JWT tokenizer (`whoami?tokenize_as`). Built-in `fetch` only, no SDK dep (AGENTS.md).
// Flow `ui.nodes` types stay loose — rendering + field-error mapping is flow-view.ts's job. // Flow `ui.nodes` types stay loose — rendering + field-error mapping is flow-view.ts's job.
+5 -5
View File
@@ -1,4 +1,4 @@
// Guards the Ory Kratos config (§3): migrations run before the server (kratos-migrate → // Guards the Ory Kratos config: migrations run before the server (kratos-migrate →
// kratos), the DSN targets the kratos database, and the identity schema carries email // kratos), the DSN targets the kratos database, and the identity schema carries email
// (password identifier) + name traits. Version pinning is in compose.test.ts. Real boot // (password identifier) + name traits. Version pinning is in compose.test.ts. Real boot
// is verified by running the stack; this catches edits. // is verified by running the stack; this catches edits.
@@ -37,7 +37,7 @@ test("kratos config wires the identity schema", () => {
assert.match(kratosYml, /identity\.schema\.json/); assert.match(kratosYml, /identity\.schema\.json/);
}); });
// The five self-service flows return the browser to our own themed routes (§4 renders them). The // The five self-service flows return the browser to our own themed routes (renders them). The
// host is `localhost` — the dev/clean-clone host the stack sets APP_URL to, so the web app's canonical // host is `localhost` — the dev/clean-clone host the stack sets APP_URL to, so the web app's canonical
// host matches the host the login form POSTs to (cookies share one host). Compose overrides these // host matches the host the login form POSTs to (cookies share one host). Compose overrides these
// from ${APP_URL} for a custom host. // from ${APP_URL} for a custom host.
@@ -52,7 +52,7 @@ test("self-service flows return to our themed pages (on the localhost dev host)"
test("after a successful login Kratos returns to our /auth/complete route to mint the JWT", () => { test("after a successful login Kratos returns to our /auth/complete route to mint the JWT", () => {
assert.match(kratosYml, /default_browser_return_url:\s*http:\/\/localhost:3000\/auth\/complete/, assert.match(kratosYml, /default_browser_return_url:\s*http:\/\/localhost:3000\/auth\/complete/,
"login completion (read roles → project → tokenize → set cookie) runs at /auth/complete (§4)"); "login completion (read roles → project → tokenize → set cookie) runs at /auth/complete");
}); });
test("recovery + verification run on email code, delivered by a courier", () => { test("recovery + verification run on email code, delivered by a courier", () => {
@@ -70,7 +70,7 @@ test("session settings: branded cookie, bounded lifespan, sliding refresh", () =
test("session tokenizer template 'plainpages' mints a short-lived signed JWT", () => { test("session tokenizer template 'plainpages' mints a short-lived signed JWT", () => {
// whoami(tokenize_as: plainpages) → a locally-verifiable JWT, so the hot path never // whoami(tokenize_as: plainpages) → a locally-verifiable JWT, so the hot path never
// calls Ory (§4). Signed with the committed tokenizer/jwks.json (gen-jwks.ts). // calls Ory. Signed with the committed tokenizer/jwks.json (gen-jwks.ts).
assert.match(kratosYml, /tokenizer:\s*\n\s*templates:\s*\n\s*plainpages:/, "plainpages template defined"); assert.match(kratosYml, /tokenizer:\s*\n\s*templates:\s*\n\s*plainpages:/, "plainpages template defined");
assert.match(kratosYml, /ttl:\s*10m/, "~10m TTL — re-minted on refresh"); assert.match(kratosYml, /ttl:\s*10m/, "~10m TTL — re-minted on refresh");
assert.match(kratosYml, /subject_source:\s*id/, "sub = the Kratos identity id"); assert.match(kratosYml, /subject_source:\s*id/, "sub = the Kratos identity id");
@@ -84,7 +84,7 @@ test("the tokenizer claims mapper emits email + roles from the metadata_public p
// metadata (admin metadata is stripped), so the roles projection must live in metadata_public. // metadata (admin metadata is stripped), so the roles projection must live in metadata_public.
const mapper = read("ory/kratos/tokenizer/plainpages.jsonnet"); const mapper = read("ory/kratos/tokenizer/plainpages.jsonnet");
assert.match(mapper, /email:\s*session\.identity\.traits\.email/, "email ← identity trait"); assert.match(mapper, /email:\s*session\.identity\.traits\.email/, "email ← identity trait");
assert.match(mapper, /metadata_public/, "roles ← metadata_public (the per-login Keto projection, §4)"); assert.match(mapper, /metadata_public/, "roles ← metadata_public (the per-login Keto projection)");
}); });
test("social sign-in is off by default — a clean clone stays password-only", () => { test("social sign-in is off by default — a clean clone stays password-only", () => {
+1 -1
View File
@@ -1,4 +1,4 @@
// parseListQuery (todo §1): read a list-page URL into the state the building blocks render // parseListQuery: read a list-page URL into the state the building blocks render
// from — search, filters, sort, pagination. The URL is the only list state (README // from — search, filters, sort, pagination. The URL is the only list state (README
// "Interactivity"), so this is the inverse of the filter-bar GET form, the sort links and the // "Interactivity"), so this is the inverse of the filter-bar GET form, the sort links and the
// pagination links: bookmarkable, shareable, reproducible. Pure; never throws. // pagination links: bookmarkable, shareable, reproducible. Pure; never throws.
+1 -1
View File
@@ -1,4 +1,4 @@
// Structured logging + basic observability (todo §9), on @larvit/log (zero-dependency, OTLP-native). // Structured logging + basic observability, on @larvit/log (zero-dependency, OTLP-native).
// One app-level Log holds the config (level/format/OTLP) and tags every line with service.name; // One app-level Log holds the config (level/format/OTLP) and tags every line with service.name;
// each request clones it into a short-lived trace span. Console always; OTLP only when configured. // each request clones it into a short-lived trace span. Console always; OTLP only when configured.
// An AsyncLocalStorage makes that per-request Log ambiently available, so every outbound `fetch` // An AsyncLocalStorage makes that per-request Log ambiently available, so every outbound `fetch`
+2 -2
View File
@@ -1,6 +1,6 @@
// Login completion (§4): turn a Kratos session into our session JWT — read roles from Keto, // Login completion: turn a Kratos session into our session JWT — read roles from Keto,
// project them onto the identity, tokenize, build the cookie. Fakes the three Ory clients; // project them onto the identity, tokenize, build the cookie. Fakes the three Ory clients;
// the live, full-stack login is verified by the §8 Playwright E2E. // the live, full-stack login is verified by the Playwright E2E.
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import type { KetoClient, RelationTuple } from "./keto-client.ts"; import type { KetoClient, RelationTuple } from "./keto-client.ts";
+3 -3
View File
@@ -1,4 +1,4 @@
// Login completion (todo §4): turn a fresh Kratos session into our locally-verifiable // Login completion: turn a fresh Kratos session into our locally-verifiable
// session JWT — the one moment Ory is on the path (README: Login → session JWT): // session JWT — the one moment Ory is on the path (README: Login → session JWT):
// 1. whoami(cookie) → the identity (id, email); no active session ⇒ null // 1. whoami(cookie) → the identity (id, email); no active session ⇒ null
// 2. read roles from Keto → the source of truth for the `roles` claim // 2. read roles from Keto → the source of truth for the `roles` claim
@@ -18,7 +18,7 @@ import type { KratosPublic } from "./kratos-public.ts";
export const SESSION_COOKIE = "plainpages_jwt"; export const SESSION_COOKIE = "plainpages_jwt";
// Mirrors kratos.yml session.lifespan (30d) so the cookie survives browser restarts; the // Mirrors kratos.yml session.lifespan (30d) so the cookie survives browser restarts; the
// JWT inside is short-lived (~10m) and re-minted on expiry by the §4 hot path (remintSession). // JWT inside is short-lived (~10m) and re-minted on expiry by the hot path (remintSession).
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30; const COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
// The tokenizer template (kratos.yml session.whoami.tokenizer.templates.plainpages). // The tokenizer template (kratos.yml session.whoami.tokenizer.templates.plainpages).
@@ -91,7 +91,7 @@ export async function remintSession(deps: LoginDeps, cookie: string | undefined,
} }
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is // Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
// supplied by the caller (off in dev http; the §9 cookie hardening toggles it on for prod). // supplied by the caller (off in dev http; the cookie hardening toggles it on for prod).
export function sessionCookie(jwt: string, options: { secure?: boolean } = {}): string { export function sessionCookie(jwt: string, options: { secure?: boolean } = {}): string {
const opts: CookieOptions = { httpOnly: true, maxAge: COOKIE_MAX_AGE, path: "/", sameSite: "Lax", ...(options.secure ? { secure: true } : {}) }; const opts: CookieOptions = { httpOnly: true, maxAge: COOKIE_MAX_AGE, path: "/", sameSite: "Lax", ...(options.secure ? { secure: true } : {}) };
return serializeCookie(SESSION_COOKIE, jwt, opts); return serializeCookie(SESSION_COOKIE, jwt, opts);
+1 -1
View File
@@ -1,4 +1,4 @@
// Central menu config (todo §2): config/menu.ts lets an operator set branding (app name, logo, // Central menu config: config/menu.ts lets an operator set branding (app name, logo,
// default theme) and reorder/rename/group/hide nav nodes across all plugins. The reorder/rename/ // default theme) and reorder/rename/group/hide nav nodes across all plugins. The reorder/rename/
// group/hide part is the NavOverride composeNav already applies (the override always wins, before // group/hide part is the NavOverride composeNav already applies (the override always wins, before
// the per-user permission filter). Authored as TypeScript (defineMenu types it); loaded once at // the per-user permission filter). Authored as TypeScript (defineMenu types it); loaded once at
+1 -1
View File
@@ -45,7 +45,7 @@ test("composeNav drops gated subtrees, empty headers, and (with no roles) all ga
assert.deepEqual(composeNav(), []); assert.deepEqual(composeNav(), []);
}); });
test("composeNav keeps a node marked public for everyone — the blessed public alias (§10)", () => { test("composeNav keeps a node marked public for everyone — the blessed public alias", () => {
// A header with one public child + one gated child: with no roles, the public child keeps the // A header with one public child + one gated child: with no roles, the public child keeps the
// header alive (the gated child is filtered out) — so a plugin can show a public menu option to all. // header alive (the gated child is filtered out) — so a plugin can show a public menu option to all.
const frag: NavNode[][] = [[{ const frag: NavNode[][] = [[{
+4 -4
View File
@@ -1,8 +1,8 @@
// composeNav (todo §1): merge each plugin's nav fragment into one tree, apply the central // composeNav: merge each plugin's nav fragment into one tree, apply the central
// override, then permission-filter per user. Pure and I/O-free — menu gating reads the JWT // override, then permission-filter per user. Pure and I/O-free — menu gating reads the JWT
// `roles` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or // `roles` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or
// declares no `permission`, or `roles` includes that permission token; a gated header hides its whole // declares no `permission`, or `roles` includes that permission token; a gated header hides its whole
// subtree, and a pure header left with no children is dropped. The §2 config/menu.ts supplies // subtree, and a pure header left with no children is dropped. The config/menu.ts supplies
// the override (+ branding); this helper only transforms data, so its result is per-deployment // the override (+ branding); this helper only transforms data, so its result is per-deployment
// up to the final role filter and emits clean nodes ready for nav-tree.ejs (no id/permission). // up to the final role filter and emits clean nodes ready for nav-tree.ejs (no id/permission).
@@ -16,10 +16,10 @@ export interface NavNode {
label: string; label: string;
open?: boolean; open?: boolean;
permission?: string; // required role token; consumed by the filter, never rendered permission?: string; // required role token; consumed by the filter, never rendered
public?: boolean; // §10: show to everyone, signed in or not — the blessed alias for "no permission", stated outright; consumed by the filter, never rendered. Mutually exclusive with permission (discovery refuses both). public?: boolean; // show to everyone, signed in or not — the blessed alias for "no permission", stated outright; consumed by the filter, never rendered. Mutually exclusive with permission (discovery refuses both).
} }
// Central override (config/menu.ts, §2). Targets nodes by `id`; applied rename → group → // Central override (config/menu.ts). Targets nodes by `id`; applied rename → group →
// order → hide, then the per-user permission filter runs last. // order → hide, then the per-user permission filter runs last.
export interface NavOverride { export interface NavOverride {
groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header
+1 -1
View File
@@ -1,4 +1,4 @@
// OAuth2 consent-challenge resolution (§6): given a Hydra consent challenge, auto-accept a // OAuth2 consent-challenge resolution: given a Hydra consent challenge, auto-accept a
// first-party (or Hydra-skipped) client granting the requested scopes, else show a consent // first-party (or Hydra-skipped) client granting the requested scopes, else show a consent
// screen; on submit accept (allow) or reject (deny). id_token claims come from the Kratos identity. // screen; on submit accept (allow) or reject (deny). id_token claims come from the Kratos identity.
import { test } from "node:test"; import { test } from "node:test";
+1 -1
View File
@@ -1,4 +1,4 @@
// OAuth2 consent-challenge handler (todo §6): after login, Hydra hands the browser to // OAuth2 consent-challenge handler: after login, Hydra hands the browser to
// /oauth2/consent?consent_challenge=… (hydra.yml urls.consent). A first-party client (or one // /oauth2/consent?consent_challenge=… (hydra.yml urls.consent). A first-party client (or one
// Hydra already skipped) is auto-granted the requested scopes; a third-party client shows the // Hydra already skipped) is auto-granted the requested scopes; a third-party client shows the
// themed consent screen, then accept (allow) / reject (deny). id_token claims (email/name) come // themed consent screen, then accept (allow) / reject (deny). id_token claims (email/name) come
+1 -1
View File
@@ -1,4 +1,4 @@
// OAuth2 login-challenge resolution (§6): given a Hydra login challenge, authenticate the user // OAuth2 login-challenge resolution: given a Hydra login challenge, authenticate the user
// via their Kratos session and accept — or bounce an unauthenticated user to the Kratos login UI. // via their Kratos session and accept — or bounce an unauthenticated user to the Kratos login UI.
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
+1 -1
View File
@@ -1,4 +1,4 @@
// OAuth2 login-challenge handler (todo §6): when another app logs in *through* plainpages, // OAuth2 login-challenge handler: when another app logs in *through* plainpages,
// Hydra hands the browser to /oauth2/login?login_challenge=… (hydra.yml urls.login). We // Hydra hands the browser to /oauth2/login?login_challenge=… (hydra.yml urls.login). We
// authenticate the user with their existing Kratos session and accept the request; Hydra then // authenticate the user with their existing Kratos session and accept the request; Hydra then
// proceeds to consent and mints the tokens. No first-party page needs this — it's the OAuth2 // proceeds to consent and mints the tokens. No first-party page needs this — it's the OAuth2
+1 -1
View File
@@ -1,4 +1,4 @@
// paginate (todo §1): pagination math → the model pagination.ejs renders. Pure and // paginate: pagination math → the model pagination.ejs renders. Pure and
// URL-free (README signature `paginate(total, page, pageSize)`); the caller maps each // URL-free (README signature `paginate(total, page, pageSize)`); the caller maps each
// page number to an href. Inputs are clamped/guarded so it never produces a broken model: // page number to an href. Inputs are clamped/guarded so it never produces a broken model:
// page is pinned to [1, pageCount], total/pageSize coerced to sane integers. // page is pinned to [1, pageCount], total/pageSize coerced to sane integers.
+1 -1
View File
@@ -1,4 +1,4 @@
// The plugin author barrel (§7): the stable surface a plugin imports. Guards that the value exports // The plugin author barrel: the stable surface a plugin imports. Guards that the value exports
// stay present — removing one is a breaking contract change. The types resolve via typecheck (the // stay present — removing one is a breaking contract change. The types resolve via typecheck (the
// reference plugin imports them from here). // reference plugin imports them from here).
import assert from "node:assert/strict"; import assert from "node:assert/strict";
+2 -2
View File
@@ -1,4 +1,4 @@
// The plugin author surface (todo §7) — the ONE module a plugin imports. It re-exports exactly the // The plugin author surface — the ONE module a plugin imports. It re-exports exactly the
// stable contract: definePlugin + the manifest/handler types, the RequestContext, the auth guards, // stable contract: definePlugin + the manifest/handler types, the RequestContext, the auth guards,
// and the request-body/CSRF/list-query helpers the blessed pattern needs. This barrel *is* the // and the request-body/CSRF/list-query helpers the blessed pattern needs. This barrel *is* the
// contract boundary in code — the host may refactor any other src/* freely as long as it holds, so // contract boundary in code — the host may refactor any other src/* freely as long as it holds, so
@@ -16,7 +16,7 @@ export { CSRF_FIELD } from "./csrf.ts";
// Sanitise an untrusted URL (upstream/user data) before rendering it in an href/src — partials // Sanitise an untrusted URL (upstream/user data) before rendering it in an href/src — partials
// escape text but not URL schemes, so a `javascript:`/`data:` URL would be live XSS (see docs). // escape text but not URL schemes, so a `javascript:`/`data:` URL would be live XSS (see docs).
export { safeUrl } from "./safe-url.ts"; export { safeUrl } from "./safe-url.ts";
// Observability (§9): `ctx.log` (RequestContext) is the request logger; `tracedFetch` is a drop-in // Observability: `ctx.log` (RequestContext) is the request logger; `tracedFetch` is a drop-in
// `fetch` a plugin uses for upstream calls so they join the request's trace (client span + traceparent). // `fetch` a plugin uses for upstream calls so they join the request's trace (client span + traceparent).
// The `Log` class is exported so a plugin can type/construct one (e.g. `new Log("none")` in a test). // The `Log` class is exported so a plugin can type/construct one (e.g. `new Log("none")` in a test).
export { Log, tracedFetch } from "./logger.ts"; export { Log, tracedFetch } from "./logger.ts";
+2 -2
View File
@@ -103,7 +103,7 @@ test("findConflicts: duplicate nav id is an error, a shared permission token onl
assert.ok(permDup.some((c) => c.kind === "permission" && c.level === "warn")); assert.ok(permDup.some((c) => c.kind === "permission" && c.level === "warn"));
}); });
test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error (§10)", () => { test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => {
const handler = () => ({ html: "x" }); const handler = () => ({ html: "x" });
const homeDup = findConflicts([p({ id: "a", home: handler }), p({ id: "b", home: handler })]); const homeDup = findConflicts([p({ id: "a", home: handler }), p({ id: "b", home: handler })]);
assert.ok(homeDup.some((c) => c.kind === "home" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b"))); assert.ok(homeDup.some((c) => c.kind === "home" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
@@ -124,7 +124,7 @@ test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / f
"auth", // /auth/complete (login completion) "auth", // /auth/complete (login completion)
"logout", // POST /logout "logout", // POST /logout
"oauth2", // /oauth2/login · /consent · /logout (Hydra provider) "oauth2", // /oauth2/login · /consent · /logout (Hydra provider)
"dashboard", // the gated app home (§10) "dashboard", // the gated app home
"public", // static assets "public", // static assets
]); ]);
for (const id of builtins) assert.ok(RESERVED_PLUGIN_IDS.has(id), `built-in mount "${id}" must be a reserved plugin id`); for (const id of builtins) assert.ok(RESERVED_PLUGIN_IDS.has(id), `built-in mount "${id}" must be a reserved plugin id`);
+7 -7
View File
@@ -1,4 +1,4 @@
// The plugin contract (todo §2) — 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; `docs/plugin-contract.md` is the prose reference, discovery/router wire it to FS+HTTP. // pure rules; `docs/plugin-contract.md` is the prose reference, discovery/router wire it to FS+HTTP.
// Powerful, predictable, fails loud at boot/discovery rather than sandboxing at runtime. // Powerful, predictable, fails loud at boot/discovery rather than sandboxing at runtime.
// //
@@ -30,7 +30,7 @@ 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 (a role token); checked before the handler runs permission?: string; // coarse gate (a role token); checked before the handler runs
// Mark the page reachable by anyone, signed in or not (§10). The same as omitting `permission` // Mark the page reachable by anyone, signed in or not. The same as omitting `permission`
// — a no-permission route is already open — but stated outright, so "public" is a deliberate // — a no-permission route is already open — but stated outright, so "public" is a deliberate
// choice, not an accident. Mutually exclusive with `permission` (discovery refuses both). // choice, not an accident. Mutually exclusive with `permission` (discovery refuses both).
public?: boolean; public?: boolean;
@@ -54,11 +54,11 @@ 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 (§10). A handler like any // Take over the gated dashboard "/dashboard" — the post-login app home. A handler like any
// route's; the host gates it to a signed-in session (anonymous → /login), then renders its own view // route's; the host gates it to a signed-in session (anonymous → /login), then renders its own view
// via ctx.chrome. At most one plugin may 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 (§10). A handler like any route's, // Take over the public landing "/" — the ungated front page. A handler like any route's,
// anyone may reach it. At most one plugin may declare it (findConflicts → error). // anyone may reach it. At most one plugin may declare it (findConflicts → error).
home?: RouteHandler; home?: RouteHandler;
hooks?: PluginHooks; hooks?: PluginHooks;
@@ -74,7 +74,7 @@ export interface Plugin extends PluginManifest {
} }
// Identity helper: types the manifest, returns it unchanged. Validation happens at discovery // Identity helper: types the manifest, returns it unchanged. Validation happens at discovery
// (§2), so a plugin may equally be a plain typed object. Mirrors Vite's `defineConfig`. //, so a plugin may equally be a plain typed object. Mirrors Vite's `defineConfig`.
export function definePlugin(manifest: PluginManifest): PluginManifest { export function definePlugin(manifest: PluginManifest): PluginManifest {
return manifest; return manifest;
} }
@@ -165,7 +165,7 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
if (n > 1) out.push({ kind: "id", level: "error", message: `${n} plugins share id "${id}"; ids must be globally unique`, plugins: [id] }); if (n > 1) out.push({ kind: "id", level: "error", message: `${n} plugins share id "${id}"; ids must be globally unique`, plugins: [id] });
} }
// The landing pages are single slots (§10): "/" (home) and "/dashboard" (dashboard) take one owner // The landing pages are single slots: "/" (home) and "/dashboard" (dashboard) take one owner
// each — two plugins claiming either is a loud error, not a race. // each — two plugins claiming either is a loud error, not a race.
for (const slot of ["home", "dashboard"] as const) { for (const slot of ["home", "dashboard"] as const) {
const owners = plugins.filter((plugin) => plugin[slot]).map((plugin) => plugin.id); const owners = plugins.filter((plugin) => plugin[slot]).map((plugin) => plugin.id);
@@ -206,7 +206,7 @@ function collectNavIds(nodes: NavNode[] | undefined, push: (id: string) => void)
} }
// A route's full path = the plugin's mount path `/<id>` + the route path. The single source of // A route's full path = the plugin's mount path `/<id>` + the route path. The single source of
// truth for both conflict detection (here) and the §2 router, so they can't disagree. // truth for both conflict detection (here) and the router, so they can't disagree.
export function fullPath(id: string, path: string): string { export function fullPath(id: string, path: string): string {
return `/${id}${path.startsWith("/") ? path : `/${path}`}`; return `/${id}${path.startsWith("/") ? path : `/${path}`}`;
} }
+1 -1
View File
@@ -1,4 +1,4 @@
// Guards the Ory Postgres config (§3): each Ory service keeps its own database (the // Guards the Ory Postgres config: each Ory service keeps its own database (the
// image pin is covered by compose.test.ts's global scan). Real container behaviour is // image pin is covered by compose.test.ts's global scan). Real container behaviour is
// verified by booting postgres in CI/e2e; this catches edits. // verified by booting postgres in CI/e2e; this catches edits.
import { test } from "node:test"; import { test } from "node:test";
+1 -1
View File
@@ -58,7 +58,7 @@ test("allowedMethods lists methods at a path (GET implies HEAD); empty when the
test("isAuthorized: open routes pass; gated routes require the role token; public is explicitly open", () => { test("isAuthorized: open routes pass; gated routes require the role token; public is explicitly open", () => {
const open: Route = { handler: noop, method: "GET", path: "/" }; const open: Route = { handler: noop, method: "GET", path: "/" };
const gated: Route = { handler: noop, method: "GET", path: "/", permission: "x:read" }; const gated: Route = { handler: noop, method: "GET", path: "/", permission: "x:read" };
const pub: Route = { handler: noop, method: "GET", path: "/", public: true }; // §10 blessed public alias const pub: Route = { handler: noop, method: "GET", path: "/", public: true }; // blessed public alias
assert.equal(isAuthorized(open, []), true); assert.equal(isAuthorized(open, []), true);
assert.equal(isAuthorized(gated, []), false); assert.equal(isAuthorized(gated, []), false);
assert.equal(isAuthorized(gated, ["x:read"]), true); assert.equal(isAuthorized(gated, ["x:read"]), true);
+3 -3
View File
@@ -1,4 +1,4 @@
// Router (todo §2): pure core mapping method + pathname → a discovered plugin route. I/O-free; // Router: pure core mapping method + pathname → a discovered plugin route. I/O-free;
// app.ts is the shell (build context, gate, call handler, render RouteResult). A route mounts at // app.ts is the shell (build context, gate, call handler, render RouteResult). A route mounts at
// `/<id>` + its path (fullPath, shared with conflict detection); `:name` segments → path params. // `/<id>` + its path (fullPath, shared with conflict detection); `:name` segments → path params.
// Specificity: a literal segment beats a `:param` (/users/new wins /users/:id), order-independent. // Specificity: a literal segment beats a `:param` (/users/new wins /users/:id), order-independent.
@@ -75,8 +75,8 @@ export function allowedMethods(plugins: Plugin[], pathname: string): string[] {
} }
// Coarse permission gate: a route marked `public` (or one with no `permission`) is open; otherwise // Coarse permission gate: a route marked `public` (or one with no `permission`) is open; otherwise
// the user's roles (from the session JWT, §4) must include the token. The same rule composeNav uses // the user's roles (from the session JWT) must include the token. The same rule composeNav uses
// for the menu. `public` and `permission` are mutually exclusive (discovery refuses both, §10). // for the menu. `public` and `permission` are mutually exclusive (discovery refuses both).
export function isAuthorized(route: Route, roles: string[]): boolean { export function isAuthorized(route: Route, roles: string[]): boolean {
return route.public === true || route.permission == null || roles.includes(route.permission); return route.public === true || route.permission == null || roles.includes(route.permission);
} }

Some files were not shown because too many files have changed in this diff Show More