From a9f25a76925b3254c81d17dc8567e142756e9885 Mon Sep 17 00:00:00 2001 From: lilleman Date: Tue, 23 Jun 2026 22:49:28 +0200 Subject: [PATCH] =?UTF-8?q?Remove=20completed=20todo.md=20+=20html-css-fou?= =?UTF-8?q?ndation=20mockups;=20strip=20dead=20=C2=A7N=20phase=20refs=20fr?= =?UTF-8?q?om=20comments/docs=20(simplify=20visual=20E2E=20to=20drop=20the?= =?UTF-8?q?=20mockup-comparison=20oracle)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 1 - AGENTS.md | 2 +- README.md | 118 ++-- compose.e2e-auth.yml | 2 +- compose.e2e-full.yml | 2 +- compose.e2e-oauth.yml | 2 +- compose.e2e.yml | 9 +- compose.yml | 8 +- config/menu.ts | 2 +- docs/plugin-contract.md | 18 +- e2e/auth-refresh.spec.ts | 6 +- e2e/devstack-login.spec.ts | 2 +- e2e/full-flow.spec.ts | 6 +- e2e/mock-oidc.mjs | 2 +- e2e/oauth-login.spec.ts | 6 +- e2e/playwright.config.ts | 7 +- e2e/proxy.mjs | 2 +- e2e/visual.spec.ts | 59 +- html-css-foundation/App Shell.html | 733 ------------------------ html-css-foundation/Auth.html | 216 ------- ory/hydra/hydra.yml | 4 +- ory/keto/namespaces.keto.ts | 2 +- ory/kratos/kratos.yml | 12 +- ory/kratos/tokenizer/plainpages.jsonnet | 2 +- plugins/scheduling/plugin.ts | 4 +- plugins/scheduling/shifts.test.ts | 2 +- plugins/scheduling/shifts.ts | 10 +- plugins/scheduling/views/overview.ejs | 2 +- public/css/auth.css | 2 +- public/css/styles.css | 6 +- scripts/ci.sh | 2 +- src/admin-clients.test.ts | 2 +- src/admin-clients.ts | 6 +- src/admin-groups.test.ts | 2 +- src/admin-groups.ts | 2 +- src/admin-nav.test.ts | 4 +- src/admin-nav.ts | 4 +- src/admin-roles.test.ts | 2 +- src/admin-roles.ts | 10 +- src/admin-users.test.ts | 2 +- src/admin-users.ts | 8 +- src/app.test.ts | 66 +-- src/app.ts | 52 +- src/body.ts | 4 +- src/bootstrap.test.ts | 2 +- src/bootstrap.ts | 4 +- src/chrome.ts | 4 +- src/compose.test.ts | 10 +- src/config.test.ts | 10 +- src/config.ts | 26 +- src/context.test.ts | 2 +- src/context.ts | 6 +- src/cookie.ts | 2 +- src/csrf.ts | 2 +- src/dashboard.test.ts | 2 +- src/dashboard.ts | 2 +- src/denylist.ts | 2 +- src/discovery.test.ts | 8 +- src/discovery.ts | 8 +- src/fetch-timeout.ts | 2 +- src/flow-view.ts | 2 +- src/gen-jwks.test.ts | 6 +- src/gen-jwks.ts | 2 +- src/guards.ts | 4 +- src/hooks.ts | 2 +- src/hydra-admin.test.ts | 4 +- src/hydra-admin.ts | 8 +- src/hydra.test.ts | 2 +- src/icons.test.ts | 2 +- src/jwks.ts | 4 +- src/jwt-middleware.test.ts | 4 +- src/jwt-middleware.ts | 12 +- src/jwt.ts | 8 +- src/keto-client.test.ts | 4 +- src/keto-client.ts | 4 +- src/keto.test.ts | 2 +- src/kratos-admin.test.ts | 4 +- src/kratos-admin.ts | 4 +- src/kratos-public.test.ts | 4 +- src/kratos-public.ts | 2 +- src/kratos.test.ts | 10 +- src/list-query.ts | 2 +- src/logger.ts | 2 +- src/login.test.ts | 4 +- src/login.ts | 6 +- src/menu-config.ts | 2 +- src/nav.test.ts | 2 +- src/nav.ts | 8 +- src/oauth-consent.test.ts | 2 +- src/oauth-consent.ts | 2 +- src/oauth-login.test.ts | 2 +- src/oauth-login.ts | 2 +- src/paginate.ts | 2 +- src/plugin-api.test.ts | 2 +- src/plugin-api.ts | 4 +- src/plugin.test.ts | 4 +- src/plugin.ts | 14 +- src/postgres.test.ts | 2 +- src/router.test.ts | 2 +- src/router.ts | 6 +- src/safe-url.ts | 2 +- src/security-headers.ts | 2 +- src/server.ts | 10 +- src/shell-context.ts | 4 +- src/shell.test.ts | 10 +- src/static.ts | 2 +- src/view-resolver.ts | 4 +- todo.md | 150 ----- views/admin/client-detail.ejs | 2 +- views/admin/client-form.ejs | 2 +- views/admin/clients.ejs | 2 +- views/admin/confirm.ejs | 2 +- views/admin/group-detail.ejs | 2 +- views/admin/group-form.ejs | 2 +- views/admin/groups.ejs | 2 +- views/admin/role-detail.ejs | 2 +- views/admin/role-form.ejs | 2 +- views/admin/roles.ejs | 2 +- views/admin/user-form.ejs | 2 +- views/admin/users.ejs | 2 +- views/auth.ejs | 2 +- views/home.ejs | 2 +- views/index.ejs | 2 +- views/oauth-consent.ejs | 2 +- views/partials/auth-card.ejs | 2 +- views/partials/client-detail-body.ejs | 2 +- views/partials/client-form-body.ejs | 2 +- views/partials/confirm-body.ejs | 2 +- views/partials/consent-body.ejs | 2 +- views/partials/data-table.ejs | 2 +- views/partials/field.ejs | 2 +- views/partials/filter-bar.ejs | 3 +- views/partials/flow-body.ejs | 2 +- views/partials/group-detail-body.ejs | 2 +- views/partials/group-form-body.ejs | 2 +- views/partials/landing-body.ejs | 2 +- views/partials/menu.ejs | 2 +- views/partials/nav-tree.ejs | 4 +- views/partials/pagination.ejs | 2 +- views/partials/role-detail-body.ejs | 2 +- views/partials/role-form-body.ejs | 2 +- views/partials/shell.ejs | 6 +- views/partials/user-form-body.ejs | 2 +- 143 files changed, 394 insertions(+), 1538 deletions(-) delete mode 100644 html-css-foundation/App Shell.html delete mode 100644 html-css-foundation/Auth.html delete mode 100644 todo.md diff --git a/.dockerignore b/.dockerignore index b8dec35..4d9cd33 100644 --- a/.dockerignore +++ b/.dockerignore @@ -3,6 +3,5 @@ node_modules npm-debug.log *.log .DS_Store -html-css-foundation e2e/artifacts diff --git a/AGENTS.md b/AGENTS.md index 1184a78..a0e1cd7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. \ No newline at end of file diff --git a/README.md b/README.md index 9a611cb..fd85a09 100644 --- a/README.md +++ b/README.md @@ -46,16 +46,15 @@ makes **semantic, accessible markup** a priority: real landmarks, one `

` 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// → 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//views/.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//views/.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 diff --git a/compose.e2e-auth.yml b/compose.e2e-auth.yml index 2f792ea..79c86b6 100644 --- a/compose.e2e-auth.yml +++ b/compose.e2e-auth.yml @@ -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. diff --git a/compose.e2e-full.yml b/compose.e2e-full.yml index 3d8770a..67edbb2 100644 --- a/compose.e2e-full.yml +++ b/compose.e2e-full.yml @@ -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. diff --git a/compose.e2e-oauth.yml b/compose.e2e-oauth.yml index 382d74e..89bdc27 100644 --- a/compose.e2e-oauth.yml +++ b/compose.e2e-oauth.yml @@ -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 diff --git a/compose.e2e.yml b/compose.e2e.yml index 1f676c0..d11f3fa 100644 --- a/compose.e2e.yml +++ b/compose.e2e.yml @@ -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 diff --git a/compose.yml b/compose.yml index b43612b..75cbf35 100644 --- a/compose.yml +++ b/compose.yml @@ -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 diff --git a/config/menu.ts b/config/menu.ts index 9f8d5e4..232c471 100644 --- a/config/menu.ts +++ b/config/menu.ts @@ -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. diff --git a/docs/plugin-contract.md b/docs/plugin-contract.md index ba3ad67..ceb738f 100644 --- a/docs/plugin-contract.md +++ b/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; // 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 `:` 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/` — 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. diff --git a/e2e/auth-refresh.spec.ts b/e2e/auth-refresh.spec.ts index 66479a5..0044ed1 100644 --- a/e2e/auth-refresh.spec.ts +++ b/e2e/auth-refresh.spec.ts @@ -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 => new Promise((r) => setTimeout(r, ms)); diff --git a/e2e/devstack-login.spec.ts b/e2e/devstack-login.spec.ts index 79eb5cb..c949058 100644 --- a/e2e/devstack-login.spec.ts +++ b/e2e/devstack-login.spec.ts @@ -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 { diff --git a/e2e/full-flow.spec.ts b/e2e/full-flow.spec.ts index b00d2a6..e7160f6 100644 --- a/e2e/full-flow.spec.ts +++ b/e2e/full-flow.spec.ts @@ -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). diff --git a/e2e/mock-oidc.mjs b/e2e/mock-oidc.mjs index 9a8f32a..8fe76e2 100644 --- a/e2e/mock-oidc.mjs +++ b/e2e/mock-oidc.mjs @@ -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). diff --git a/e2e/oauth-login.spec.ts b/e2e/oauth-login.spec.ts index 70b95ed..3c1973f 100644 --- a/e2e/oauth-login.spec.ts +++ b/e2e/oauth-login.spec.ts @@ -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 { diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 7ddec22..df10b4c 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -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", diff --git a/e2e/proxy.mjs b/e2e/proxy.mjs index 40fdbbb..0522435 100644 --- a/e2e/proxy.mjs +++ b/e2e/proxy.mjs @@ -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 diff --git a/e2e/visual.spec.ts b/e2e/visual.spec.ts index b1b41a2..8e96931 100644 --- a/e2e/visual.spec.ts +++ b/e2e/visual.spec.ts @@ -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 => 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> => - 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)[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 resolves to a defined (no broken graphics)", async ({ page }) => { @@ -93,7 +60,7 @@ test("every icon resolves to a defined (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 }); diff --git a/html-css-foundation/App Shell.html b/html-css-foundation/App Shell.html deleted file mode 100644 index 804623e..0000000 --- a/html-css-foundation/App Shell.html +++ /dev/null @@ -1,733 +0,0 @@ - - - - - -App Shell — Template - - - - - - - - - - -
- - - - - - - - -
- - -
- -

People

- -
- - -
- - -
- -
- - -
- Status -
- - - -
-
- - - - - - - - -
- - - - - -
- - -
-
- Tags - -
- - - - - -
-
- -
- -
- Joined - -
- - - - - - -
-
-
- - -
-
- Applied - Team: Engineering - Tag: On-call - Joined: 2026 - Clear all -
- -
- -
- - -
-
-
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
People in the directory
- - - - - - - - TeamStatus - - Actions
Mara Delgado[email protected]AdminEngineeringActive2 min ago - -
Soren Vance[email protected]MemberDesignIdle3 hours ago - -
Priya Nair[email protected]AdminOperationsActivejust now - -
Eli Brandt[email protected]ViewerSalesSuspended6 days ago - -
Tomas Lindqvist[email protected]MemberEngineeringInvited - -
Hana Osei[email protected]MemberDesignActive21 min ago - -
Rafael Costa[email protected]AdminOperationsIdle1 hour ago - -
Wen Li[email protected]ViewerSalesActive44 min ago - -
Nadia Farouk[email protected]MemberEngineeringSuspended12 days ago - -
Otto Berg[email protected]MemberDesignInvited - -
Greta Holm[email protected]AdminOperationsActive8 min ago - -
Yusuf Demir[email protected]ViewerSalesIdle5 hours ago - -
-
- - -
- 1–12 of 1,284 -
- -
- -
-
-
- -
- -
-
- - - diff --git a/html-css-foundation/Auth.html b/html-css-foundation/Auth.html deleted file mode 100644 index 8ae40c5..0000000 --- a/html-css-foundation/Auth.html +++ /dev/null @@ -1,216 +0,0 @@ - - - - - -Sign in — Console - - - - - - - - -
-
- -
- - Console -
- - -
-
-
-

Sign in

-

Welcome back. Enter your details to continue.

-
- - -
-
    - -
  • -
  • -
  • -
-
or
-
- -
-
- -
- - -
-
- -
-
- - Forgot password? -
-
- - -
-
- - - - -
- -

Don't have an account? Create one

-
-
- - -
-
-
-

Create account

-

Get started — it only takes a minute.

-
- -
-
    -
  • -
  • -
  • -
-
or
-
- -
- -
-
- - Optional -
-
- - -
-
- -
- -
- - -
- -
- -
- -
- - -
- Use 8 or more characters. - -
- - -
- -

Already have an account? Sign in

-
-
- - -
-
-
- Back to sign in -

Reset password

-

Enter your email and we'll send you a reset link.

-
- - -
- -
Check your emailIf an account exists for that address, a reset link is on its way.
-
- - -
-
- -
- - -
-
- - -
- -

Remembered it? Sign in

-
-
- -
-
- - -
- Preview -
- - - -
- - - -
-
- - - -
-
- -
-
- - - -
-
-
- - - diff --git a/ory/hydra/hydra.yml b/ory/hydra/hydra.yml index 9cab190..ce0dc84 100644 --- a/ory/hydra/hydra.yml +++ b/ory/hydra/hydra.yml @@ -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/ diff --git a/ory/keto/namespaces.keto.ts b/ory/keto/namespaces.keto.ts index a4d5821..f5b0e63 100644 --- a/ory/keto/namespaces.keto.ts +++ b/ory/keto/namespaces.keto.ts @@ -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)[] diff --git a/ory/kratos/kratos.yml b/ory/kratos/kratos.yml index af94197..1c0cd86 100644 --- a/ory/kratos/kratos.yml +++ b/ory/kratos/kratos.yml @@ -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 diff --git a/ory/kratos/tokenizer/plainpages.jsonnet b/ory/kratos/tokenizer/plainpages.jsonnet index 3a96cd0..ddf5973 100644 --- a/ory/kratos/tokenizer/plainpages.jsonnet +++ b/ory/kratos/tokenizer/plainpages.jsonnet @@ -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 diff --git a/plugins/scheduling/plugin.ts b/plugins/scheduling/plugin.ts index bedb213..7327193 100644 --- a/plugins/scheduling/plugin.ts +++ b/plugins/scheduling/plugin.ts @@ -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 }, diff --git a/plugins/scheduling/shifts.test.ts b/plugins/scheduling/shifts.test.ts index 5888ce3..d5dd982 100644 --- a/plugins/scheduling/shifts.test.ts +++ b/plugins/scheduling/shifts.test.ts @@ -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 diff --git a/plugins/scheduling/shifts.ts b/plugins/scheduling/shifts.ts index c2e376e..90b72f2 100644 --- a/plugins/scheduling/shifts.ts +++ b/plugins/scheduling/shifts.ts @@ -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). diff --git a/plugins/scheduling/views/overview.ejs b/plugins/scheduling/views/overview.ejs index 1bbc73a..8858c17 100644 --- a/plugins/scheduling/views/overview.ejs +++ b/plugins/scheduling/views/overview.ejs @@ -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. diff --git a/public/css/auth.css b/public/css/auth.css index 931c1eb..dbee49f 100644 --- a/public/css/auth.css +++ b/public/css/auth.css @@ -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; diff --git a/public/css/styles.css b/public/css/styles.css index c6d3d72..a937229 100644 --- a/public/css/styles.css +++ b/public/css/styles.css @@ -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; } diff --git a/scripts/ci.sh b/scripts/ci.sh index 59073b1..b564015 100755 --- a/scripts/ci.sh +++ b/scripts/ci.sh @@ -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). # diff --git a/src/admin-clients.test.ts b/src/admin-clients.test.ts index 282e3ff..edda952 100644 --- a/src/admin-clients.test.ts +++ b/src/admin-clients.test.ts @@ -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"; diff --git a/src/admin-clients.ts b/src/admin-clients.ts index 4320619..f44959d 100644 --- a/src/admin-clients.ts +++ b/src/admin-clients.ts @@ -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 }; } diff --git a/src/admin-groups.test.ts b/src/admin-groups.test.ts index 9bdd84d..6fc841b 100644 --- a/src/admin-groups.test.ts +++ b/src/admin-groups.test.ts @@ -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:#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. diff --git a/src/admin-groups.ts b/src/admin-groups.ts index 4463692..d540c87 100644 --- a/src/admin-groups.ts +++ b/src/admin-groups.ts @@ -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:#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 diff --git a/src/admin-nav.test.ts b/src/admin-nav.test.ts index dfc8d40..89e461a 100644 --- a/src/admin-nav.test.ts +++ b/src/admin-nav.test.ts @@ -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 ---- diff --git a/src/admin-nav.ts b/src/admin-nav.ts index fc3f451..09e7d43 100644 --- a/src/admin-nav.ts +++ b/src/admin-nav.ts @@ -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. diff --git a/src/admin-roles.test.ts b/src/admin-roles.test.ts index 9a0cb01..e5cbe36 100644 --- a/src/admin-roles.test.ts +++ b/src/admin-roles.test.ts @@ -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:#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 diff --git a/src/admin-roles.ts b/src/admin-roles.ts index 699e787..9082071 100644 --- a/src/admin-roles.ts +++ b/src/admin-roles.ts @@ -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:#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) => Promise; - 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:` member must take effect now, so revoke that +// instant-revoke: a role change for a `user:` member must take effect now, so revoke that // user's live tokens (a re-mint then re-reads roles from Keto). A `group:` 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 }); } diff --git a/src/admin-users.test.ts b/src/admin-users.test.ts index d27e471..2458b77 100644 --- a/src/admin-users.test.ts +++ b/src/admin-users.test.ts @@ -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"; diff --git a/src/admin-users.ts b/src/admin-users.ts index 7973056..579e671 100644 --- a/src/admin-users.ts +++ b/src/admin-users.ts @@ -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) => Promise; - 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 }; } diff --git a/src/app.test.ts b/src/app.test.ts index 35a1ce6..ccb4d07 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -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, /