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:
@@ -3,6 +3,5 @@ node_modules
|
||||
npm-debug.log
|
||||
*.log
|
||||
.DS_Store
|
||||
html-css-foundation
|
||||
|
||||
e2e/artifacts
|
||||
|
||||
@@ -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.
|
||||
`node:24.16.0-alpine3.24`).
|
||||
- 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.
|
||||
@@ -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`)
|
||||
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`):
|
||||
> the Node 24 + EJS server, the zero-JS **design system** (app shell, nav tree, data table, filters,
|
||||
> pagination, forms), the **plugin host** (discovery, router, per-plugin views + static, the
|
||||
> `config/menu.ts` override + branding), the **Ory stack** (Postgres, Kratos + the session→JWT
|
||||
> **Status.** The full architecture this README describes is built and exercised end-to-end by the
|
||||
> Playwright suites: the Node 24 + EJS server, the zero-JS **design system** (app shell, nav tree, data
|
||||
> table, filters, pagination, forms), the **plugin host** (discovery, router, per-plugin views + static,
|
||||
> 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 /
|
||||
> SSO, the session→JWT hot path, the users/groups/roles admin screens) and **Hydra's login / consent
|
||||
> / logout handlers** — all driven end-to-end by the Playwright suites, plus **production & ops
|
||||
> hardening** (the prod compose profile, response security headers, **structured logging + OTLP
|
||||
> observability**, the **[JWT key-rotation runbook](#jwt-signing-key--rotation)**). Remaining
|
||||
> polish is tracked in `todo.md` (§9–§10).
|
||||
> SSO, the session→JWT hot path, the users/groups/roles admin screens), **Hydra's login / consent /
|
||||
> logout handlers**, and **production & ops hardening** (the prod compose profile, response security
|
||||
> headers, **structured logging + OTLP observability**, the
|
||||
> **[JWT key-rotation runbook](#jwt-signing-key--rotation)**).
|
||||
|
||||
## 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` |
|
||||
| `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 |
|
||||
| `HYDRA_ADMIN_URL` | `http://hydra:4445` | OAuth2 provider admin API (§6 login/consent handshake) |
|
||||
| `JWKS_URL` | `file://…/tokenizer/jwks.json` | the Kratos tokenizer signing key; verifies the session JWT (§4) |
|
||||
| `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 |
|
||||
| `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`) |
|
||||
| `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
|
||||
|
||||
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
|
||||
cookie/cipher secrets in `kratos.yml`) — a clean clone works; **never run it in
|
||||
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.
|
||||
|
||||
**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
|
||||
swap both verify.
|
||||
|
||||
@@ -302,9 +301,9 @@ docker compose restart kratos
|
||||
```
|
||||
|
||||
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
|
||||
(§9) is unnecessary here — the signature itself is already invalid.
|
||||
is unnecessary here — the signature itself is already invalid.
|
||||
|
||||
## 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`
|
||||
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.
|
||||
It screenshots the live pages **and** the `html-css-foundation` mockups, then asserts the live DOM
|
||||
computes the **same design-system styles** as the reference (so a styling regression fails the
|
||||
build, independent of the row data).
|
||||
**Visual + design system** (`visual.spec.ts`) — Ory-free, so it stays fast. It screenshots the live
|
||||
pages and asserts the rendered design system — the app shell, theme switch, mobile off-canvas layout,
|
||||
icon sprite, CSRF-guarded sign-out, the public landing, the 404 page, and plugin permission-gating.
|
||||
|
||||
```bash
|
||||
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
|
||||
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
|
||||
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**.
|
||||
|
||||
```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
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
`html-css-foundation/` into reusable EJS partials + TS helpers, fully styled and zero-JS:
|
||||
and helpers rather than declaring a schema and getting magic. The vocabulary is a set of reusable
|
||||
EJS partials + TS helpers, fully styled and zero-JS:
|
||||
|
||||
- **Partials:** app shell, nav tree, filter bar, data table (sort / select / row
|
||||
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/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/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/kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize (§4)
|
||||
src/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection, §4)
|
||||
src/keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API) (§4)
|
||||
src/hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD (§6)
|
||||
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/oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login (§6)
|
||||
src/oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes (§6)
|
||||
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/login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie (§4)
|
||||
src/gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS (§3); 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/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4)
|
||||
src/csrf.ts CSRF for our own POST forms (§4): 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/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/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/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/body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + §5 forms)
|
||||
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)
|
||||
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
|
||||
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
|
||||
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
|
||||
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; see JWT signing key & rotation
|
||||
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)
|
||||
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: 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: 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)
|
||||
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 + forms)
|
||||
src/context.ts RequestContext handed to handlers + buildContext()
|
||||
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/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-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-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-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/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: list Kratos identities (filter/sort/paginate) + create/edit/deactivate/delete/recovery; 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: 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: 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/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/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/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-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/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2)
|
||||
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/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/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials (§2)
|
||||
src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot (§2)
|
||||
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)
|
||||
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
|
||||
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; 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
|
||||
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: 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)
|
||||
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)
|
||||
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)
|
||||
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
|
||||
html-css-foundation/ HTML design mockups — the source for the building-block
|
||||
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`)
|
||||
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`)
|
||||
```
|
||||
|
||||
Comments and docs cite roadmap phases as `§N` — the sections in `todo.md`.
|
||||
|
||||
## Extending the core
|
||||
|
||||
- **New page in a plugin:** add a route + handler to the plugin manifest and a
|
||||
|
||||
@@ -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:
|
||||
# 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.
|
||||
|
||||
@@ -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
|
||||
# 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.
|
||||
|
||||
@@ -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
|
||||
# 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
|
||||
|
||||
+3
-6
@@ -1,5 +1,5 @@
|
||||
# Playwright E2E. Brings up the app + a Playwright runner, screenshots the live pages and the
|
||||
# html-css-foundation mockups, and asserts the live DOM computes the same design styles.
|
||||
# Playwright E2E. Brings up the app + a Playwright runner and exercises the live pages (design
|
||||
# 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 down -v # tear down after
|
||||
# --build rebuilds the runner (the image bakes in e2e/) so spec edits are picked up.
|
||||
@@ -33,10 +33,7 @@ services:
|
||||
environment:
|
||||
BASE_URL: http://web:3000
|
||||
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
|
||||
# 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
|
||||
- ./e2e/artifacts:/e2e/artifacts
|
||||
|
||||
+4
-4
@@ -19,7 +19,7 @@ services:
|
||||
LOG_FORMAT: "json" # structured logs for prod pipelines; set OTLP_ENDPOINT to also export to a collector
|
||||
REQUIRE_SECURE_SECRETS: "true"
|
||||
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).
|
||||
depends_on:
|
||||
bootstrap:
|
||||
@@ -30,7 +30,7 @@ services:
|
||||
condition: service_healthy
|
||||
hydra:
|
||||
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.
|
||||
volumes:
|
||||
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
retries: 20
|
||||
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
|
||||
# read-write (the only writer) so the absent-JWKS safety net can land the key.
|
||||
bootstrap:
|
||||
@@ -146,7 +146,7 @@ services:
|
||||
|
||||
# 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
|
||||
# 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).
|
||||
hydra-migrate:
|
||||
image: oryd/hydra:v26.2.0
|
||||
|
||||
+1
-1
@@ -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
|
||||
// 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.
|
||||
|
||||
@@ -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
|
||||
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**
|
||||
> (`src/discovery.ts`), the **router** (`src/router.ts` — method+path match, `:name` params,
|
||||
> 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 —
|
||||
> 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)
|
||||
> 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
|
||||
|
||||
@@ -226,7 +226,7 @@ request:
|
||||
```ts
|
||||
interface RequestContext {
|
||||
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 }
|
||||
query: URLSearchParams; // alias of url.searchParams
|
||||
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
|
||||
sidebar, single column); everything else still renders.
|
||||
|
||||
**`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`,
|
||||
**`ctx.log`** is a structured, request-scoped logger ([`@larvit/log`](https://www.npmjs.com/package/@larvit/log))
|
||||
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`
|
||||
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)
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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.
|
||||
|
||||
## 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>`
|
||||
— 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
|
||||
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
|
||||
of the stack.
|
||||
|
||||
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`
|
||||
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
|
||||
([above](#conflict-rules)) stops boot with a precise message naming the plugin(s) involved.
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
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
|
||||
// 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
|
||||
// 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 KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
|
||||
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 sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
@@ -15,7 +15,7 @@ import { expect, test } from "@playwright/test";
|
||||
// (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
|
||||
// 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";
|
||||
|
||||
async function signIn(page: import("@playwright/test").Page): Promise<void> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type Browser, type Page, expect, test } from "@playwright/test";
|
||||
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.
|
||||
// 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
|
||||
// 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.
|
||||
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 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
|
||||
@@ -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);
|
||||
// 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).
|
||||
|
||||
+1
-1
@@ -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
|
||||
// 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).
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
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
|
||||
// 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
|
||||
// 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 KRATOS = process.env.KRATOS_PUBLIC_URL ?? "http://kratos:4433";
|
||||
const HYDRA_PUBLIC = process.env.HYDRA_PUBLIC_URL ?? "http://hydra:4444";
|
||||
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";
|
||||
|
||||
function setCookieLine(res: Response, name: string): string | undefined {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
// Visual + functional checks against the live app (the `web` compose service, BASE_URL) and the
|
||||
// static html-css-foundation mockups (bind-mounted at /repo). Run via compose.e2e.yml. Parallel
|
||||
// per the project's E2E principle (todo §1.1); deterministic colorScheme/viewport so the
|
||||
// computed-style parity vs the reference design is stable.
|
||||
// Visual + functional checks against the live app (the `web` compose service, BASE_URL). Run via
|
||||
// compose.e2e.yml. Parallel per the project's E2E principle; deterministic colorScheme/viewport
|
||||
// so the rendered design is stable across runs.
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
outputDir: "artifacts/test-output",
|
||||
|
||||
+1
-1
@@ -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
|
||||
// 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
|
||||
|
||||
+13
-46
@@ -3,10 +3,6 @@ import { readFileSync } from "node:fs";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
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 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
|
||||
@@ -15,7 +11,7 @@ const shot = (page: Page, name: string): Promise<Buffer> =>
|
||||
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
|
||||
// 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.
|
||||
function devSession(roles: string[] = []): string {
|
||||
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 }); });
|
||||
|
||||
// The dashboard is gated (§10): 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.
|
||||
// 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.
|
||||
test.beforeEach(async ({ context }) => {
|
||||
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 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 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 shot(page, "live-04-mobile");
|
||||
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 }) => {
|
||||
@@ -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
|
||||
// (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 }) => {
|
||||
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);
|
||||
});
|
||||
|
||||
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 page.goto("/");
|
||||
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.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login");
|
||||
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
|
||||
// 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.
|
||||
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
|
||||
// probes are genuinely anonymous.
|
||||
// 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);
|
||||
const body = await pub.text();
|
||||
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.
|
||||
expect(body).not.toContain('href="/dashboard"');
|
||||
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);
|
||||
// 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 });
|
||||
expect(res.status()).toBe(303);
|
||||
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,
|
||||
// but the gated Shifts leaf is filtered out.
|
||||
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/shifts"]')).toHaveCount(0); // gated leaf filtered out
|
||||
});
|
||||
|
||||
@@ -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 & Access"><svg class="ico chev"><use href="#i-chev"/></svg></summary>
|
||||
</details>
|
||||
<a class="nav-self" href="#"><span class="nav-label">Roles & 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 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 & 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&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&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&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 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 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 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 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 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 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 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 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 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 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 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 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>1–12 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>
|
||||
@@ -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
@@ -2,7 +2,7 @@
|
||||
# plainpages (README: "OAuth2 provider"). The web app implements Hydra's login &
|
||||
# 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
|
||||
# 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:
|
||||
public:
|
||||
port: 4444
|
||||
@@ -10,7 +10,7 @@ serve:
|
||||
port: 4445
|
||||
|
||||
# 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:
|
||||
self:
|
||||
issuer: http://127.0.0.1:4444/
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Context, Namespace, SubjectSet } from "@ory/keto-namespace-types"
|
||||
class User implements Namespace {}
|
||||
|
||||
// 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 {
|
||||
related: {
|
||||
members: (User | SubjectSet<Group, "members">)[]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Ory Kratos — identity & self-service auth. Identity schema (email, name) +
|
||||
# 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
|
||||
# key in tokenizer/jwks.json).
|
||||
serve:
|
||||
@@ -24,7 +24,7 @@ selfservice:
|
||||
code: # email one-time code — powers recovery + verification (not login)
|
||||
enabled: true
|
||||
# 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).
|
||||
# SELFSERVICE_METHODS_OIDC_ENABLED=true
|
||||
# SELFSERVICE_METHODS_OIDC_CONFIG_PROVIDERS=[{"id":"google","provider":"google",
|
||||
@@ -41,7 +41,7 @@ selfservice:
|
||||
ui_url: http://localhost:3000/login
|
||||
after:
|
||||
# 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
|
||||
registration:
|
||||
ui_url: http://localhost:3000/registration
|
||||
@@ -82,7 +82,7 @@ identity:
|
||||
url: file:///etc/config/kratos/identity.schema.json
|
||||
|
||||
# "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,
|
||||
# so frequent users never lapse without a DB write per request.
|
||||
session:
|
||||
@@ -92,7 +92,7 @@ session:
|
||||
name: plainpages_session
|
||||
persistent: true # survive browser restarts
|
||||
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
|
||||
# committed Jsonnet mapper (sub = identity id, email from traits, roles from the
|
||||
# metadata_public projection); signed with tokenizer/jwks.json.
|
||||
@@ -105,7 +105,7 @@ session:
|
||||
claims_mapper_url: file:///etc/config/kratos/tokenizer/plainpages.jsonnet
|
||||
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:
|
||||
cookie:
|
||||
- PLEASE-CHANGE-ME-dev-kratos-cookie-secret
|
||||
|
||||
@@ -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
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
// 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"
|
||||
// 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: [{
|
||||
children: [
|
||||
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true },
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
|
||||
// ---- 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 () => {
|
||||
const anon = asView(await overview()(fakeCtx())); // user null, no roles
|
||||
|
||||
@@ -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
|
||||
// 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).
|
||||
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 READ = "scheduling:read"; // permission token gating the list + nav
|
||||
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`
|
||||
// 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.
|
||||
export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream {
|
||||
const base = baseUrl.replace(/\/+$/, "");
|
||||
@@ -172,7 +172,7 @@ export function listShifts(upstream: ShiftsUpstream): RouteHandler {
|
||||
try {
|
||||
shifts = await upstream.list();
|
||||
} 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.";
|
||||
}
|
||||
const needle = q.toLowerCase();
|
||||
@@ -185,7 +185,7 @@ export function newShiftForm(): RouteHandler {
|
||||
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
|
||||
// (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).
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@
|
||||
}
|
||||
.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 {
|
||||
width: 100%; max-width: 560px;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 18px;
|
||||
|
||||
@@ -666,7 +666,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
||||
/* the nav-toggle checkbox itself is visually hidden but focusable */
|
||||
#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-card {
|
||||
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); }
|
||||
.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); }
|
||||
/* 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 .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; }
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
#!/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
|
||||
# service. Docker-only (it drives `docker compose`; node/npm/tsc run inside containers, never the host).
|
||||
#
|
||||
|
||||
@@ -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
|
||||
// HTTP routing/gate/CSRF + live Hydra calls (incl. the one-time secret) are exercised in app.test.ts.
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Built-in OAuth2 clients admin screen (todo §6): 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
|
||||
// Built-in OAuth2 clients admin screen: register / list / delete the OAuth2 clients other
|
||||
// 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
|
||||
// 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
|
||||
@@ -310,7 +310,7 @@ export async function handleAdminClients(ctx: RequestContext, csrfToken: string,
|
||||
created = await hydra.createClient(clientPayload(input));
|
||||
} catch (err) {
|
||||
// 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) {
|
||||
return { ...(await renderForm({ error: "Hydra rejected the client — check the redirect URIs and scopes.", values: input })), status: 400 };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// groups (subject_set). The HTTP routing/gate/CSRF + live Keto/Kratos calls are exercised over
|
||||
// HTTP in app.test.ts.
|
||||
|
||||
+1
-1
@@ -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
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
// the contract here in isolation — the admin-*.test.ts HTTP tests exercise them only end-to-end.
|
||||
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);
|
||||
});
|
||||
|
||||
// (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.)
|
||||
|
||||
// ---- auth gates ----
|
||||
|
||||
+2
-2
@@ -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
|
||||
// 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
|
||||
// separate admin sidebar to drift.
|
||||
|
||||
|
||||
@@ -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) —
|
||||
// "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
|
||||
|
||||
+5
-5
@@ -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
|
||||
// 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)
|
||||
@@ -276,10 +276,10 @@ export interface AdminRolesDeps {
|
||||
kratosAdmin: KratosAdmin;
|
||||
menu: MenuConfig;
|
||||
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
|
||||
// transitive across many users — left to lag (documented), so only direct user members revoke.
|
||||
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 (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
|
||||
// §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.
|
||||
ctx.log.info("admin: role deleted", { actor: user.id, role: name });
|
||||
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") {
|
||||
const member = (form!.get("member") ?? "").trim();
|
||||
// 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.");
|
||||
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 }); }
|
||||
|
||||
@@ -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.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
|
||||
+4
-4
@@ -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
|
||||
// (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,
|
||||
@@ -289,7 +289,7 @@ export interface AdminUsersDeps {
|
||||
kratosAdmin: KratosAdmin;
|
||||
menu: MenuConfig;
|
||||
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 {
|
||||
@@ -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 };
|
||||
const nextState = identity.state === "inactive" ? "active" : "inactive";
|
||||
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 });
|
||||
return { redirect: back };
|
||||
}
|
||||
if (seg[1] === "delete") {
|
||||
if (isSelf) return { ...(await renderForm({ error: "You can't delete your own account.", identity })), status: 400 };
|
||||
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 });
|
||||
return { redirect: ADMIN_USERS_BASE };
|
||||
}
|
||||
|
||||
+33
-33
@@ -24,9 +24,9 @@ import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
|
||||
|
||||
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
|
||||
// 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 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");
|
||||
@@ -49,12 +49,12 @@ before(async () => {
|
||||
after(() => server.close());
|
||||
|
||||
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() } });
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
|
||||
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, /<aside class="sidebar"/);
|
||||
// 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, /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];
|
||||
assert.ok(csrfCookie, "GET /dashboard issues a CSRF cookie");
|
||||
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, "\\.")}"`));
|
||||
});
|
||||
|
||||
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" });
|
||||
assert.equal(res.status, 200); // public — no redirect to sign in
|
||||
const html = await res.text();
|
||||
assert.match(html, /href="\/login"/); // a prominent path to sign in
|
||||
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, /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" });
|
||||
assert.equal(res.status, 303);
|
||||
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-"));
|
||||
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>`);
|
||||
@@ -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
|
||||
});
|
||||
|
||||
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 app = createApp({ log: createLogger({ format: "json", level: "info", stderr: () => {}, stdout: (m) => lines.push(m) }) });
|
||||
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");
|
||||
});
|
||||
|
||||
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 upstream: { traceparent: string | undefined; url: string }[] = [];
|
||||
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");
|
||||
});
|
||||
|
||||
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 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).
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
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
|
||||
// hardening headers, proving they're set once up front and survive each writeHead (paths merge).
|
||||
for (const path of ["/", "/public/css/styles.css"]) {
|
||||
@@ -388,7 +388,7 @@ const demoPlugin: Plugin = {
|
||||
{ handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" },
|
||||
{ handler: () => ({ redirect: "/demo/hello/world" }), method: "POST", path: "/go" },
|
||||
{ handler: () => ({ html: "secret" }), method: "GET", path: "/secret", permission: "demo:read" },
|
||||
{ handler: () => ({ html: "open to all" }), method: "GET", path: "/public-page", public: true }, // §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" },
|
||||
],
|
||||
};
|
||||
@@ -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.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" });
|
||||
assert.equal(open.status, 200);
|
||||
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);
|
||||
});
|
||||
|
||||
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-"));
|
||||
mkdirSync(join(dir, "panelkit", "views"), { recursive: true });
|
||||
// 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);
|
||||
});
|
||||
|
||||
// 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).
|
||||
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] });
|
||||
@@ -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);
|
||||
|
||||
// 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" })}` } });
|
||||
assert.match(await admin.text(), /href="\/admin\/users"/);
|
||||
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");
|
||||
});
|
||||
|
||||
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 app = createApp({ denylist, jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
|
||||
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);
|
||||
});
|
||||
|
||||
// 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
|
||||
// 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) => {
|
||||
@@ -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" });
|
||||
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).
|
||||
const abs = "http://localhost/oauth2/login?login_challenge=abc";
|
||||
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\./);
|
||||
});
|
||||
|
||||
// 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 => ({
|
||||
createIdentity: async () => { throw new Error("unused"); },
|
||||
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 });
|
||||
|
||||
// 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.
|
||||
const ADMIN_CSRF = "admin-secret";
|
||||
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.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.
|
||||
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");
|
||||
@@ -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
|
||||
});
|
||||
|
||||
// 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 => ({
|
||||
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" }),
|
||||
@@ -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) => {
|
||||
const mk = (email: string, over: Partial<Identity> = {}): Identity =>
|
||||
({ 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 }),
|
||||
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 });
|
||||
|
||||
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.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}`);
|
||||
assert.equal(target.state, "inactive");
|
||||
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);
|
||||
});
|
||||
|
||||
// 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).
|
||||
test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-guarded)", async (t) => {
|
||||
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);
|
||||
});
|
||||
|
||||
// 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
|
||||
// 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) => {
|
||||
@@ -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 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 });
|
||||
|
||||
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`);
|
||||
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/delete", `_csrf=${token}&member=user:${grace}`);
|
||||
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);
|
||||
});
|
||||
|
||||
// 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).
|
||||
test("admin OAuth2 clients screen: gate, list, register (one-time secret), detail, delete (CSRF-guarded)", async (t) => {
|
||||
const store: OAuth2Client[] = [
|
||||
|
||||
+26
-26
@@ -45,15 +45,15 @@ export interface AppOptions {
|
||||
// Off by default so edits show live; the app itself never inspects the environment.
|
||||
cache?: boolean;
|
||||
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
|
||||
denylist?: Denylist; // optional instant-revoke (§9); the hot path rejects revoked subjects, admin writes record revokes
|
||||
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge (§6)
|
||||
jwks?: JwksProvider; // verify the session JWT → ctx.user/roles (§4); absent ⇒ always anonymous
|
||||
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion (§4)
|
||||
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes (§4)
|
||||
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion (§4)
|
||||
log?: Log; // app-level logger (§9); per-request access log + trace span. Default: silent (tests)
|
||||
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
|
||||
jwks?: JwksProvider; // verify the session JWT → ctx.user/roles; absent ⇒ always anonymous
|
||||
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
|
||||
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
|
||||
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion
|
||||
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
|
||||
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/
|
||||
publicDir?: string;
|
||||
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 pluginIds = new Set(plugins.map((p) => p.id));
|
||||
// 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.
|
||||
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");
|
||||
@@ -108,13 +108,13 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// building-block partials (resolved from viewsDir) and their own partials/subfolders.
|
||||
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.
|
||||
// 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 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;
|
||||
// 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 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.
|
||||
// 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
|
||||
// (a dead session clears the stale cookie). This is the only place the hot path touches Ory.
|
||||
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
|
||||
// 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);
|
||||
// Bound CSRF verifier handed to plugins via ctx.verifyCsrf (the host owns the secret).
|
||||
const verifyCsrf = (submitted: string | null | undefined): boolean =>
|
||||
@@ -226,7 +226,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
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
|
||||
// freshly minted (its forms carry the matching token); null ⇒ unknown subpath → 404.
|
||||
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
|
||||
// default completion route. A first-party deep link (host-relative, from the gate's
|
||||
// return_to) is wrapped through /auth/complete so the session JWT is minted before the
|
||||
// user reaches the page; an absolute target (the §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".
|
||||
const raw = ctx.url.searchParams.get("return_to");
|
||||
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)
|
||||
}
|
||||
// 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.
|
||||
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) }));
|
||||
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
|
||||
// bounces to our themed login and returns here once signed in. Provider-only.
|
||||
if (hydra && kratos && pathname === "/oauth2/login" && (method === "GET" || method === "HEAD")) {
|
||||
@@ -358,7 +358,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
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
|
||||
// consent screen, whose CSRF-guarded POST accepts (Allow) or rejects (Deny). Provider-only.
|
||||
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;
|
||||
// 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 +
|
||||
@@ -433,7 +433,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
|
||||
// Login completion: where Kratos lands the browser after authenticating (kratos.yml).
|
||||
// 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) {
|
||||
const completed = await completeLogin({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie);
|
||||
if (!completed) {
|
||||
@@ -442,7 +442,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
}
|
||||
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
|
||||
// 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();
|
||||
return;
|
||||
}
|
||||
@@ -476,7 +476,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
}
|
||||
|
||||
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
|
||||
// any form it ships). Else the built-in intro page with prominent sign-in / register links.
|
||||
if (homePlugin) {
|
||||
@@ -487,7 +487,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
await sendResult(res, result, (view, data) => renderView(homePlugin.id, view, data));
|
||||
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.
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
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")) {
|
||||
// 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.
|
||||
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.
|
||||
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.
|
||||
if (dashboardPlugin) {
|
||||
const dashCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf });
|
||||
@@ -543,7 +543,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
};
|
||||
|
||||
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
|
||||
// completed response and a premature disconnect/abort, so an aborted/truncated request is still
|
||||
// logged and its span flushed.
|
||||
|
||||
+2
-2
@@ -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
|
||||
// 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";
|
||||
|
||||
const DEFAULT_LIMIT = 1024 * 1024; // 1 MiB
|
||||
|
||||
@@ -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
|
||||
// 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.
|
||||
|
||||
+2
-2
@@ -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`:
|
||||
// 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;
|
||||
@@ -134,7 +134,7 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password:
|
||||
|
||||
async function main() {
|
||||
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({
|
||||
format: env["LOG_FORMAT"] === "json" ? "json" : "text",
|
||||
...(env["SERVICE_NAME"] ? { serviceName: env["SERVICE_NAME"] } : {}),
|
||||
|
||||
+2
-2
@@ -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
|
||||
// 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
|
||||
@@ -30,7 +30,7 @@ export interface ChromeOptions {
|
||||
|
||||
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||
// 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]] : [];
|
||||
for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
|
||||
fragments.push([adminSection()]);
|
||||
|
||||
+5
-5
@@ -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
|
||||
// `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
|
||||
@@ -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", () => {
|
||||
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"])
|
||||
assert.match(webBlock, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_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", () => {
|
||||
// §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
|
||||
// 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");
|
||||
@@ -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.
|
||||
assert.match(webBlock, /REQUIRE_SECURE_SECRETS:\s*"true"/, "base enforces real secrets");
|
||||
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(override, /LOG_FORMAT:\s*"text"/, "dev logs human-readable text");
|
||||
// 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", () => {
|
||||
// §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.
|
||||
const boot = compose.slice(compose.indexOf("\n bootstrap:"));
|
||||
assert.match(boot, /node src\/bootstrap\.ts/, "bootstrap runs the seed script");
|
||||
|
||||
+5
-5
@@ -21,9 +21,9 @@ test("loads dev defaults when the environment is empty", () => {
|
||||
assert.equal(c.hydraAdminUrl, "http://hydra:4445");
|
||||
assert.match(c.csrfSecret, /dev-insecure/);
|
||||
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.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.otlpEndpoint, undefined); // OTLP export opt-in; console-only by default
|
||||
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/);
|
||||
});
|
||||
|
||||
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: "" }).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: "none" }).logLevel, "none");
|
||||
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", () => {
|
||||
// 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.
|
||||
const url = new URL(loadConfig({}).jwksUrl);
|
||||
assert.equal(url.protocol, "file:");
|
||||
|
||||
+13
-13
@@ -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
|
||||
// missing enforced secret, a bad URL, or an out-of-range port throws here, never at
|
||||
// request time.
|
||||
@@ -25,16 +25,16 @@ export interface Config {
|
||||
ketoWriteUrl: string;
|
||||
kratosAdminUrl: string;
|
||||
kratosPublicUrl: string;
|
||||
logFormat: "json" | "text"; // §9: console/OTLP entry format (json for structured prod logs)
|
||||
logLevel: LogLevel; // §9: minimum severity emitted
|
||||
logFormat: "json" | "text"; // console/OTLP entry format (json for structured prod logs)
|
||||
logLevel: LogLevel; // minimum severity emitted
|
||||
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)
|
||||
otlpProtocol: "http/json" | "http/protobuf"; // §9: OTLP wire format (protobuf for json-averse collectors)
|
||||
otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export)
|
||||
otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors)
|
||||
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
|
||||
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>;
|
||||
@@ -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
|
||||
// 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 {
|
||||
return env[key] || undefined;
|
||||
}
|
||||
@@ -134,9 +134,9 @@ export function loadConfig(env: Env = process.env): Config {
|
||||
appUrl: readOptionalUrl(env, "APP_URL"),
|
||||
cacheTemplates: readBool(env, "CACHE_TEMPLATES", false),
|
||||
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"),
|
||||
// §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
|
||||
// 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"),
|
||||
@@ -149,7 +149,7 @@ export function loadConfig(env: Env = process.env): Config {
|
||||
ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"),
|
||||
kratosAdminUrl: readUrl(env, "KRATOS_ADMIN_URL", "http://kratos:4434"),
|
||||
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).
|
||||
logFormat: readEnum(env, "LOG_FORMAT", ["json", "text"] as const, "text"),
|
||||
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"),
|
||||
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
|
||||
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
|
||||
// tokenizer TTL + skew, so it outlasts any pre-revoke token).
|
||||
revocationDenylist: readBool(env, "REVOCATION_DENYLIST", false),
|
||||
revocationTtlSec: readPosInt(env, "REVOCATION_TTL_SEC", 900),
|
||||
// Set Secure on our session/CSRF cookies. Off by default (dev runs http); prod (https) sets it.
|
||||
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
@@ -46,7 +46,7 @@ test("buildContext defaults a missing request URL to /", () => {
|
||||
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("/");
|
||||
assert.equal(typeof buildContext(req, res).log.info, "function"); // always present (silent default)
|
||||
const log = createLogger({ level: "none" });
|
||||
|
||||
+3
-3
@@ -3,10 +3,10 @@ import type { PageChrome } from "./chrome.ts"; // type-only: no runtime import,
|
||||
import { createLogger, type Log } from "./logger.ts";
|
||||
|
||||
// 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.
|
||||
|
||||
// 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.
|
||||
export interface User {
|
||||
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 renders the native app shell; the host builds it per request (anonymous default otherwise).
|
||||
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
|
||||
// requestId. Additive, stable per the contract; defaults to a silent logger off the request path.
|
||||
log: Log;
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
// 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
|
||||
// URI-unreserved, so JWTs stay readable.
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
// 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
|
||||
|
||||
@@ -3,7 +3,7 @@ import { test } from "node:test";
|
||||
import { buildDashboardModel } from "./dashboard.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.
|
||||
const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }];
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
// 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
|
||||
|
||||
+1
-1
@@ -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.
|
||||
//
|
||||
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked role or a
|
||||
|
||||
@@ -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: "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: "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 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 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", 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 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 plugins = await discoverPlugins({ dir });
|
||||
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);
|
||||
});
|
||||
|
||||
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 plugins = await discoverPlugins({ dir });
|
||||
assert.equal(plugins.length, 1);
|
||||
|
||||
+4
-4
@@ -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
|
||||
// (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin problem and
|
||||
// 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) {
|
||||
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.
|
||||
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)`;
|
||||
}
|
||||
// `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.
|
||||
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`;
|
||||
@@ -103,7 +103,7 @@ function shapeError(manifest: PluginManifest): string | 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 {
|
||||
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`;
|
||||
|
||||
@@ -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
|
||||
// (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.
|
||||
|
||||
+1
-1
@@ -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
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
// 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 assert from "node:assert/strict";
|
||||
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");
|
||||
});
|
||||
|
||||
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 head = b64url(JSON.stringify({ alg: "ES256", kid: key.kid }));
|
||||
const body = b64url(JSON.stringify({ email: "a@b.c", roles: [], sub: key.kid }));
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
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.
|
||||
// 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)
|
||||
|
||||
+2
-2
@@ -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
|
||||
// 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
|
||||
@@ -8,7 +8,7 @@ import type { KetoClient } from "./keto-client.ts";
|
||||
import { localPath } from "./safe-url.ts";
|
||||
|
||||
// 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
|
||||
// validated host-relative (localPath) so it can't become an open redirect.
|
||||
export function loginRedirect(ctx: RequestContext): string {
|
||||
|
||||
+1
-1
@@ -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
|
||||
// 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.
|
||||
|
||||
@@ -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,
|
||||
// JSON body) and the result mapping (200 → request/redirect, non-2xx → HydraError). Live
|
||||
// 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 () => {
|
||||
const created = { client_id: "c1", client_name: "Acme", client_secret: "s3cr3t", redirect_uris: ["https://acme/cb"] };
|
||||
const { calls, fetchImpl } = recorder(() => res(201, created));
|
||||
|
||||
+4
-4
@@ -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
|
||||
// *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);
|
||||
@@ -9,7 +9,7 @@ export interface OAuth2Client {
|
||||
client_name?: string;
|
||||
client_secret?: string; // write-only: Hydra returns it once, on create, for a confidential client
|
||||
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[];
|
||||
response_types?: string[];
|
||||
scope?: string; // space-separated
|
||||
@@ -90,7 +90,7 @@ export class HydraError extends Error {
|
||||
export interface HydraAdmin {
|
||||
acceptConsentRequest(challenge: string, body: AcceptConsent): 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>;
|
||||
deleteClient(id: string): Promise<void>;
|
||||
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"), {});
|
||||
},
|
||||
|
||||
// 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.
|
||||
async createClient(client) {
|
||||
const res = await http(clientsUrl, { body: JSON.stringify(client), headers: json, method: "POST" });
|
||||
|
||||
+1
-1
@@ -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
|
||||
// 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.
|
||||
|
||||
+1
-1
@@ -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-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-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
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
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
|
||||
// (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`
|
||||
@@ -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):
|
||||
// `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> {
|
||||
if (jwksUrl.startsWith("base64://")) return staticJwks(loadJwks(jwksUrl));
|
||||
const { protocol } = new URL(jwksUrl);
|
||||
|
||||
@@ -72,7 +72,7 @@ test("resolveSession classifies the cookie; authenticate is its fail-closed user
|
||||
|
||||
// A valid token → the user, not expired.
|
||||
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 });
|
||||
// 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 });
|
||||
@@ -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).
|
||||
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/);
|
||||
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.
|
||||
|
||||
@@ -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,
|
||||
// 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
|
||||
@@ -16,14 +16,14 @@ const DEFAULT_CLOCK_SKEW_SEC = 60;
|
||||
export interface VerifyOptions {
|
||||
audience?: string | undefined; // if set, the token `aud` must include it (else skipped)
|
||||
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)
|
||||
now?: number | undefined; // unix seconds; injectable for tests
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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 {
|
||||
expired: boolean;
|
||||
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
|
||||
validateClaims(verified.payload, options);
|
||||
const user = claimsToUser(verified.payload);
|
||||
// Instant revoke (§9): 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).
|
||||
// Instant revoke: a denylisted subject's pre-revoke token is rejected as *expired* so
|
||||
// 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);
|
||||
return user;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
import { createPublicKey, verify } 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
|
||||
// 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.
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
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.
|
||||
// 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.
|
||||
export function verifyJws(token: string, jwk: JsonWebKey): DecodedJws {
|
||||
const decoded = decodeJws(token);
|
||||
|
||||
@@ -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,
|
||||
// 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 assert from "node:assert/strict";
|
||||
import { createKetoClient, KetoError } from "./keto-client.ts";
|
||||
|
||||
+2
-2
@@ -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`/
|
||||
// `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
|
||||
@@ -32,7 +32,7 @@ export interface RelationList {
|
||||
|
||||
// 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
|
||||
// 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 {
|
||||
children?: ExpandTree[];
|
||||
tuple?: RelationTuple;
|
||||
|
||||
+1
-1
@@ -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
|
||||
// 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.
|
||||
|
||||
@@ -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.
|
||||
// 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 assert from "node:assert/strict";
|
||||
import { createKratosAdmin } from "./kratos-admin.ts";
|
||||
|
||||
+2
-2
@@ -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
|
||||
// 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`).
|
||||
@@ -25,7 +25,7 @@ export interface ListOptions {
|
||||
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 {
|
||||
code: string;
|
||||
link: string;
|
||||
|
||||
@@ -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
|
||||
// (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 assert from "node:assert/strict";
|
||||
import { createKratosPublic, KratosError } from "./kratos-public.ts";
|
||||
|
||||
@@ -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
|
||||
// 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.
|
||||
|
||||
+5
-5
@@ -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
|
||||
// (password identifier) + name traits. Version pinning is in compose.test.ts. Real boot
|
||||
// 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/);
|
||||
});
|
||||
|
||||
// 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 matches the host the login form POSTs to (cookies share one host). Compose overrides these
|
||||
// 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", () => {
|
||||
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", () => {
|
||||
@@ -70,7 +70,7 @@ test("session settings: branded cookie, bounded lifespan, sliding refresh", () =
|
||||
|
||||
test("session tokenizer template 'plainpages' mints a short-lived signed JWT", () => {
|
||||
// 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, /ttl:\s*10m/, "~10m TTL — re-minted on refresh");
|
||||
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.
|
||||
const mapper = read("ory/kratos/tokenizer/plainpages.jsonnet");
|
||||
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", () => {
|
||||
|
||||
+1
-1
@@ -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
|
||||
// "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.
|
||||
|
||||
+1
-1
@@ -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;
|
||||
// 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`
|
||||
|
||||
+2
-2
@@ -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;
|
||||
// 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 assert from "node:assert/strict";
|
||||
import type { KetoClient, RelationTuple } from "./keto-client.ts";
|
||||
|
||||
+3
-3
@@ -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):
|
||||
// 1. whoami(cookie) → the identity (id, email); no active session ⇒ null
|
||||
// 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";
|
||||
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
// 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 {
|
||||
const opts: CookieOptions = { httpOnly: true, maxAge: COOKIE_MAX_AGE, path: "/", sameSite: "Lax", ...(options.secure ? { secure: true } : {}) };
|
||||
return serializeCookie(SESSION_COOKIE, jwt, opts);
|
||||
|
||||
+1
-1
@@ -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/
|
||||
// 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
|
||||
|
||||
+1
-1
@@ -45,7 +45,7 @@ test("composeNav drops gated subtrees, empty headers, and (with no roles) all ga
|
||||
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
|
||||
// header alive (the gated child is filtered out) — so a plugin can show a public menu option to all.
|
||||
const frag: NavNode[][] = [[{
|
||||
|
||||
+4
-4
@@ -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
|
||||
// `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
|
||||
// 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
|
||||
// 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;
|
||||
open?: boolean;
|
||||
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.
|
||||
export interface NavOverride {
|
||||
groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header
|
||||
|
||||
@@ -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
|
||||
// screen; on submit accept (allow) or reject (deny). id_token claims come from the Kratos identity.
|
||||
import { test } from "node:test";
|
||||
|
||||
@@ -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
|
||||
// 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
|
||||
|
||||
@@ -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.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
+1
-1
@@ -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
|
||||
// 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
|
||||
|
||||
+1
-1
@@ -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
|
||||
// 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.
|
||||
|
||||
@@ -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
|
||||
// reference plugin imports them from here).
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
+2
-2
@@ -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,
|
||||
// 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
|
||||
@@ -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
|
||||
// escape text but not URL schemes, so a `javascript:`/`data:` URL would be live XSS (see docs).
|
||||
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).
|
||||
// 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";
|
||||
|
||||
+2
-2
@@ -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"));
|
||||
});
|
||||
|
||||
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 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")));
|
||||
@@ -124,7 +124,7 @@ test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / f
|
||||
"auth", // /auth/complete (login completion)
|
||||
"logout", // POST /logout
|
||||
"oauth2", // /oauth2/login · /consent · /logout (Hydra provider)
|
||||
"dashboard", // the gated app home (§10)
|
||||
"dashboard", // the gated app home
|
||||
"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`);
|
||||
|
||||
+7
-7
@@ -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.
|
||||
// Powerful, predictable, fails loud at boot/discovery rather than sandboxing at runtime.
|
||||
//
|
||||
@@ -30,7 +30,7 @@ export interface Route {
|
||||
method: HttpMethod;
|
||||
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
|
||||
// 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
|
||||
// choice, not an accident. Mutually exclusive with `permission` (discovery refuses both).
|
||||
public?: boolean;
|
||||
@@ -54,11 +54,11 @@ export interface PluginHooks {
|
||||
// host derives them from the folder name at discovery (see Plugin).
|
||||
export interface PluginManifest {
|
||||
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
|
||||
// via ctx.chrome. At most one plugin may declare it (findConflicts → error, never last-write-wins).
|
||||
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).
|
||||
home?: RouteHandler;
|
||||
hooks?: PluginHooks;
|
||||
@@ -74,7 +74,7 @@ export interface Plugin extends PluginManifest {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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] });
|
||||
}
|
||||
|
||||
// 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.
|
||||
for (const slot of ["home", "dashboard"] as const) {
|
||||
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
|
||||
// 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 {
|
||||
return `/${id}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// verified by booting postgres in CI/e2e; this catches edits.
|
||||
import { test } from "node:test";
|
||||
|
||||
+1
-1
@@ -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", () => {
|
||||
const open: Route = { handler: noop, method: "GET", path: "/" };
|
||||
const gated: Route = { handler: noop, method: "GET", path: "/", permission: "x:read" };
|
||||
const pub: Route = { handler: noop, method: "GET", path: "/", public: true }; // §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(gated, []), false);
|
||||
assert.equal(isAuthorized(gated, ["x:read"]), true);
|
||||
|
||||
+3
-3
@@ -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
|
||||
// `/<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.
|
||||
@@ -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
|
||||
// the user's roles (from the session JWT, §4) must include the token. The same rule composeNav uses
|
||||
// for the menu. `public` and `permission` are mutually exclusive (discovery refuses both, §10).
|
||||
// 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).
|
||||
export function isAuthorized(route: Route, roles: string[]): boolean {
|
||||
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
Reference in New Issue
Block a user