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