Organize src/ into concern folders (http, auth, admin, plugin-host, ui); co-locate tests, move plugin-api barrel into plugin-host, sync docs + AGENTS layout

This commit is contained in:
2026-06-24 00:23:55 +02:00
parent 6d316c4888
commit de22f51c12
113 changed files with 282 additions and 265 deletions
+7 -3
View File
@@ -37,9 +37,13 @@ commands and layout.
Intentional, reasoned choices — an architecture review should honor them, not re-raise Intentional, reasoned choices — an architecture review should honor them, not re-raise
them. Revisit only if the stated reason stops holding. them. Revisit only if the stated reason stops holding.
- **`src/` is flat on purpose.** The functional-core / imperative-shell split is - **`src/` is grouped by concern**, not flat — `http/` (request pipeline), `auth/`
conceptual, not a directory layout; splitting into `core/`/`shell/` dirs was judged (session-JWT hot path, guards, and the Ory REST clients), `admin/` (built-in screens),
premature for a scaffold this size. Revisit only if the flat tree stops being readable. `plugin-host/` (discovery/router/hooks/view-resolver + the `plugin-api.ts` author barrel),
and `ui/` (design-system view-models + menu/chrome); `server.ts`/`config.ts`/`logger.ts`
and the topology-guard `*.test.ts` stay at the root. Tests are co-located (`foo.test.ts`
beside `foo.ts`). Add a new module to the folder that owns its concern rather than to the
root; don't reintroduce a flat tree.
- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the - **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the
base request context. It protects the I/O-free hot path on the public, bot-hit landing base request context. It protects the I/O-free hot path on the public, bot-hit landing
(`/`). (Declined twice.) (`/`). (Declined twice.)
+80 -67
View File
@@ -32,7 +32,7 @@ landing; the gated app home is `/dashboard`.
folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.ts`: folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.ts`:
```ts ```ts
import { definePlugin } from "../../src/plugin-api.ts"; import { definePlugin } from "../../src/plugin-host/plugin-api.ts";
export default definePlugin({ export default definePlugin({
apiVersion: "1.0.0", apiVersion: "1.0.0",
@@ -196,7 +196,7 @@ via a central override (see [The menu system](#the-menu-system)).
The full, authoritative API surface — manifest shape, handler/`RequestContext` contract, The full, authoritative API surface — manifest shape, handler/`RequestContext` contract,
versioning, conflict rules, hooks, and the dev/test story — is versioning, conflict rules, hooks, and the dev/test story — is
**[docs/plugin-contract.md](docs/plugin-contract.md)** (`src/plugin.ts` holds the types). A **[docs/plugin-contract.md](docs/plugin-contract.md)** (`src/plugin-host/plugin.ts` holds the types). A
complete, runnable reference ships in **[`plugins/scheduling/`](plugins/scheduling/)** — a complete, runnable reference ships in **[`plugins/scheduling/`](plugins/scheduling/)** — a
public overview page, a permission-gated list page fetching upstream data, a CSRF-guarded public overview page, a permission-gated list page fetching upstream data, a CSRF-guarded
form forwarding writes upstream, and a mix of public + role-gated nav. Copy it and adapt. form forwarding writes upstream, and a mix of public + role-gated nav. Copy it and adapt.
@@ -216,7 +216,7 @@ The manifest is **TypeScript** — typed, commented, no separate schema to keep
`id` and mount path are **derived from the folder name**, not declared: `id` and mount path are **derived from the folder name**, not declared:
```ts ```ts
import { definePlugin } from "../../src/plugin-api.ts"; // the stable author barrel (see docs) import { definePlugin } from "../../src/plugin-host/plugin-api.ts"; // the stable author barrel (see docs)
import { listShifts, overview } from "./shifts.ts"; import { listShifts, overview } from "./shifts.ts";
export default definePlugin({ export default definePlugin({
@@ -224,7 +224,7 @@ export default definePlugin({
// Nav fragment, composed into the global menu. Permission-gated: items the current user can't // Nav fragment, composed into the global menu. Permission-gated: items the current user can't
// access are hidden. `public: true` shows an item to everyone (signed in or not). Arbitrary // access are hidden. `public: true` shows an item to everyone (signed in or not). Arbitrary
// depth. `icon` is a Lucide icon by its sprite id (src/icons.ts). // depth. `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
nav: [ nav: [
{ {
label: "Scheduling", icon: "i-cal", label: "Scheduling", icon: "i-cal",
@@ -301,13 +301,13 @@ the plugin in the build context and it's `COPY`'d in at build time — pinned an
reproducible; mount a volume only to add plugins to an already-built image. reproducible; mount a volume only to add plugins to an already-built image.
> Discovery — scanning `plugins/`, importing each `plugin.ts` default export, and > Discovery — scanning `plugins/`, importing each `plugin.ts` default export, and
> validating it (id, `apiVersion`, conflicts) — runs at boot (`src/discovery.ts`); a bad > validating it (id, `apiVersion`, conflicts) — runs at boot (`src/plugin-host/discovery.ts`); a bad
> plugin stops startup with a precise message. The router (`src/router.ts`) then mounts > plugin stops startup with a precise message. The router (`src/plugin-host/router.ts`) then mounts
> each route at `/<id>`, resolves `:name` params, runs the permission gate, and turns the > each route at `/<id>`, resolves `:name` params, runs the permission gate, and turns the
> handler's `RouteResult` into the response; a `view` result renders > handler's `RouteResult` into the response; a `view` result renders
> `plugins/<id>/views/<view>.ejs` (`src/view-resolver.ts`), which may `include()` the core > `plugins/<id>/views/<view>.ejs` (`src/plugin-host/view-resolver.ts`), which may `include()` the core
> building-block partials. A plugin's `public/` assets are served at `/public/<id>/` > building-block partials. A plugin's `public/` assets are served at `/public/<id>/`
> (`src/static.ts`). The mount mechanics above are how the files get into the container > (`src/http/static.ts`). The mount mechanics above are how the files get into the container
> either way. > either way.
## The menu system ## The menu system
@@ -315,7 +315,7 @@ reproducible; mount a volume only to add plugins to an already-built image.
The menu is **driven entirely by config** and assembled from two sources: The menu is **driven entirely by config** and assembled from two sources:
1. **Plugin fragments** — each plugin contributes its own `nav` (above). 1. **Plugin fragments** — each plugin contributes its own `nav` (above).
2. **A central override**`config/menu.ts` (loaded by `src/menu-config.ts`, validated at 2. **A central override**`config/menu.ts` (loaded by `src/ui/menu-config.ts`, validated at
boot) — where the operator reorders, renames, groups, or hides items (by node `id`), and boot) — where the operator reorders, renames, groups, or hides items (by node `id`), and
sets branding (app name, logo, default theme). The override always wins, applied before sets branding (app name, logo, default theme). The override always wins, applied before
the per-user filter. A clean clone needs no `config/menu.ts`; defaults apply. the per-user filter. A clean clone needs no `config/menu.ts`; defaults apply.
@@ -332,7 +332,7 @@ counts, arbitrary depth). Branding (name, logo, default theme) renders in the ap
the sidebar brand shows the configured logo (else a default mark), and the theme sets the the sidebar brand shows the configured logo (else a default mark), and the theme sets the
theme-switch default. theme-switch default.
**One menu, one shell, everywhere.** There is a single menu (`src/chrome.ts` **One menu, one shell, everywhere.** There is a single menu (`src/ui/chrome.ts`
`buildPluginChrome`), rendered by the same app shell on **every** page — the dashboard, the `buildPluginChrome`), rendered by the same app shell on **every** page — the dashboard, the
admin screens, plugin pages, and the login / registration / recovery / front (`/`) pages. admin screens, plugin pages, and the login / registration / recovery / front (`/`) pages.
So it looks identical signed in or out; it just shows fewer items to an anonymous visitor So it looks identical signed in or out; it just shows fewer items to an anonymous visitor
@@ -350,7 +350,7 @@ set of reusable EJS partials + TS helpers, fully styled and zero-JS:
pagination, form fields, badges, menus, auth cards. pagination, form fields, badges, menus, auth cards.
- **Helpers:** `composeNav` (menu from config), `parseListQuery` - **Helpers:** `composeNav` (menu from config), `parseListQuery`
(`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math), and the (`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math), and the
auth guards a handler calls to authorize (`src/guards.ts`): `requireSession` (assert a auth guards a handler calls to authorize (`src/auth/guards.ts`): `requireSession` (assert a
session — a `GuardError` the host turns into a redirect to sign in), `can(role)` (a coarse session — a `GuardError` the host turns into a redirect to sign in), `can(role)` (a coarse
JWT-claim check, zero I/O), `check(relation, object)` (the one live Keto call, for JWT-claim check, zero I/O), `check(relation, object)` (the one live Keto call, for
relationship rules). relationship rules).
@@ -541,7 +541,7 @@ users** on modest hardware. In return:
### Instant revoke: the optional denylist ### Instant revoke: the optional denylist
Off by default; turn it on with `REVOCATION_DENYLIST=true` (`src/denylist.ts`). For Off by default; turn it on with `REVOCATION_DENYLIST=true` (`src/auth/denylist.ts`). For
security-critical revoke (offboarding, a compromised account) the ~10m role/session lag security-critical revoke (offboarding, a compromised account) the ~10m role/session lag
above is too long. When enabled, an admin **deactivating** or **deleting** a user, or above is too long. When enabled, an admin **deactivating** or **deleting** a user, or
**granting/revoking** a role to a *user*, records that subject as revoked-now; the hot path **granting/revoking** a role to a *user*, records that subject as revoked-now; the hot path
@@ -587,10 +587,10 @@ Hydra's login & consent steps — authenticating the user via their Kratos sessi
issues the access / refresh / id tokens those apps use. Nothing in the menu or first-party issues the access / refresh / id tokens those apps use. Nothing in the menu or first-party
pages needs Hydra. pages needs Hydra.
The **login challenge** is wired (`src/oauth-login.ts` at `/oauth2/login`): Hydra hands the The **login challenge** is wired (`src/auth/oauth-login.ts` at `/oauth2/login`): Hydra hands the
browser here, the app resolves it against the Kratos session and accepts (or bounces an browser here, the app resolves it against the Kratos session and accepts (or bounces an
unauthenticated user to the themed login, returning here once signed in). The **consent unauthenticated user to the themed login, returning here once signed in). The **consent
challenge** is wired too (`src/oauth-consent.ts` at `/oauth2/consent`): a first-party client challenge** is wired too (`src/auth/oauth-consent.ts` at `/oauth2/consent`): a first-party client
(its Hydra `metadata.first_party: true`) — or one Hydra already skipped — is auto-granted the (its Hydra `metadata.first_party: true`) — or one Hydra already skipped — is auto-granted the
requested scopes; any other client gets a themed consent screen (naming the signed-in requested scopes; any other client gets a themed consent screen (naming the signed-in
account, with a sign-out escape) whose CSRF-guarded Allow/Deny accepts or rejects. id_token account, with a sign-out escape) whose CSRF-guarded Allow/Deny accepts or rejects. id_token
@@ -600,7 +600,7 @@ resumes to Hydra's post-logout redirect — the first-party `POST /logout` still
the Kratos session + our JWT cookie. the Kratos session + our JWT cookie.
Those clients are registered from the admin **OAuth2 clients** screen (`/admin/clients`, Those clients are registered from the admin **OAuth2 clients** screen (`/admin/clients`,
`src/admin-clients.ts`): register (Hydra shows the generated `client_secret` **once**, on `src/admin/admin-clients.ts`): register (Hydra shows the generated `client_secret` **once**, on
the confirmation page — confidential clients), list, and delete. Confidential vs public the confirmation page — confidential clients), list, and delete. Confidential vs public
(PKCE) and the first-party auto-consent flag are set at registration; writes go only to (PKCE) and the first-party auto-consent flag are set at registration; writes go only to
Hydra. Hydra.
@@ -744,7 +744,7 @@ Before going live, supply the production secrets and any SSO credentials — the
manual prep ([What you must supply](#what-you-must-supply-the-only-manual-prep)); the rest is manual prep ([What you must supply](#what-you-must-supply-the-only-manual-prep)); the rest is
auto-generated. auto-generated.
Every response carries security headers (`src/security-headers.ts`, set once per request): a Every response carries security headers (`src/http/security-headers.ts`, set once per request): a
strict `Content-Security-Policy` (the core is **zero-JS**`script-src 'self'`, no inline strict `Content-Security-Policy` (the core is **zero-JS**`script-src 'self'`, no inline
scripts, so an injected `<script>` can't run), `X-Content-Type-Options: nosniff`, scripts, so an injected `<script>` can't run), `X-Content-Type-Options: nosniff`,
`X-Frame-Options: DENY` + `frame-ancestors 'none'`, `Referrer-Policy`, and — when `X-Frame-Options: DENY` + `frame-ancestors 'none'`, `Referrer-Policy`, and — when
@@ -754,7 +754,7 @@ any header per-response via `RouteResult.headers` (e.g. to ship its own JS).
A deep link reached while signed out — or after the ~10m session JWT lapses mid-task — A deep link reached while signed out — or after the ~10m session JWT lapses mid-task —
bounces to the themed sign-in and, once authenticated, returns to the **page that was bounces to the themed sign-in and, once authenticated, returns to the **page that was
requested** (`return_to`, validated **host-relative** by `localPath` in `src/safe-url.ts`, so requested** (`return_to`, validated **host-relative** by `localPath` in `src/http/safe-url.ts`, so
a crafted `?return_to=` can't turn login completion into an open redirect). If Ory is a crafted `?return_to=` can't turn login completion into an open redirect). If Ory is
unreachable on the sign-in path itself, the user gets an honest **503** ("sign-in is unreachable on the sign-in path itself, the user gets an honest **503** ("sign-in is
temporarily unavailable"), distinct from the catch-all 500. temporarily unavailable"), distinct from the catch-all 500.
@@ -814,7 +814,7 @@ cookie/cipher secrets in `kratos.yml`) — a clean clone works; **never run it i
production**. Mint a fresh key with the bundled generator: production**. Mint a fresh key with the bundled generator:
```bash ```bash
docker compose run --rm -T --no-deps web node src/gen-jwks.ts > ory/kratos/tokenizer/jwks.json docker compose run --rm -T --no-deps web node src/auth/gen-jwks.ts > ory/kratos/tokenizer/jwks.json
``` ```
**Install in production.** Two endpoints must read the *same* key material: **Install in production.** Two endpoints must read the *same* key material:
@@ -840,7 +840,7 @@ container-relative; with the dev bind-mount they edit the real file).
shell's `>` can't truncate the input before it's read: shell's `>` can't truncate the input before it's read:
```bash ```bash
docker compose run --rm -T --no-deps web sh -c \ docker compose run --rm -T --no-deps web sh -c \
'node src/gen-jwks.ts --prepend ory/kratos/tokenizer/jwks.json' > /tmp/jwks.json \ 'node src/auth/gen-jwks.ts --prepend ory/kratos/tokenizer/jwks.json' > /tmp/jwks.json \
&& mv /tmp/jwks.json ory/kratos/tokenizer/jwks.json && mv /tmp/jwks.json ory/kratos/tokenizer/jwks.json
``` ```
2. **Restart Kratos** so it signs with the new first key: `docker compose restart kratos`. 2. **Restart Kratos** so it signs with the new first key: `docker compose restart kratos`.
@@ -852,7 +852,7 @@ container-relative; with the dev bind-mount they edit the real file).
4. **Wait ~12 min**, then **prune** the superseded key: 4. **Wait ~12 min**, then **prune** the superseded key:
```bash ```bash
docker compose run --rm -T --no-deps web sh -c \ docker compose run --rm -T --no-deps web sh -c \
'node src/gen-jwks.ts --prune ory/kratos/tokenizer/jwks.json' > /tmp/jwks.json \ 'node src/auth/gen-jwks.ts --prune ory/kratos/tokenizer/jwks.json' > /tmp/jwks.json \
&& mv /tmp/jwks.json ory/kratos/tokenizer/jwks.json && mv /tmp/jwks.json ory/kratos/tokenizer/jwks.json
``` ```
No Kratos restart needed — it already signs with that key; this only drops a now-unused No Kratos restart needed — it already signs with that key; this only drops a now-unused
@@ -867,7 +867,7 @@ Skip the overlap — you want every token signed with the leaked key to die now.
the set with a single fresh key (no `--prepend`): the set with a single fresh key (no `--prepend`):
```bash ```bash
docker compose run --rm -T --no-deps web node src/gen-jwks.ts > ory/kratos/tokenizer/jwks.json docker compose run --rm -T --no-deps web node src/auth/gen-jwks.ts > ory/kratos/tokenizer/jwks.json
docker compose restart kratos docker compose restart kratos
``` ```
@@ -879,52 +879,65 @@ unnecessary here — the signature itself is already invalid.
## Project layout ## Project layout
``` ```
src/server.ts Entry point — starts the HTTP server (reads PORT, default 3000) src/ Node 24 + TypeScript app — strict tsc, no build step. *.test.ts sit beside their module.
src/app.ts Request routing + EJS rendering (incl. the themed Kratos self-service routes) server.ts Entry point — starts the HTTP server (reads PORT, default 3000)
src/static.ts Static file serving (path-traversal protection) + routePublic(): /public/<id>/ → a plugin's public/ config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot
src/jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK) 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/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 *.test.ts (compose/kratos/keto/hydra/postgres) Topology guards with no source counterpart — assert the compose dev/prod split + ordering and each Ory service's config (they validate ory/ + the compose files)
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 http/ Request pipeline + HTTP primitives
src/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection) app.ts Request routing + EJS rendering (incl. the themed Kratos self-service routes)
src/keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API) context.ts RequestContext handed to handlers + buildContext()
src/hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + forms)
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 cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies)
src/oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login static.ts Static file serving (path-traversal protection) + routePublic(): /public/<id>/ → a plugin's public/
src/oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes 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/flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs 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/login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie
src/gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS; see JWT signing key & rotation auth/ Identity, the session-JWT hot path, guards, and the Ory REST clients
src/bootstrap.ts One-command bootstrap: idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK)
src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies) 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/csrf.ts CSRF for our own POST forms: signed double-submit token — issue/verify, cookie, request gate 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/denylist.ts Optional instant-revoke denylist: in-memory, auto-evicting; hot path rejects a revoked subject's pre-revoke tokens (REVOCATION_DENYLIST) gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS; see JWT signing key & rotation
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 login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie
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) 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/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 csrf.ts CSRF for our own POST forms: signed double-submit token — issue/verify, cookie, request gate
src/body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + forms) denylist.ts Optional instant-revoke denylist: in-memory, auto-evicting; hot path rejects a revoked subject's pre-revoke tokens (REVOCATION_DENYLIST)
src/context.ts RequestContext handed to handlers + buildContext() flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs
src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login
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) oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes
src/admin-users.ts Built-in Users admin screen: list Kratos identities (filter/sort/paginate) + create/edit/deactivate/delete/recovery; gated + CSRF-guarded bootstrap.ts One-command bootstrap: idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto
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 kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize
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 kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection)
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 keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API)
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 hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD
src/shell-context.ts buildShellContext(): brand/theme/user view-model shared by the dashboard + admin screens (real signed-in user, no demo profile) 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/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) admin/ Built-in admin screens — the only domain screens plainpages ships
src/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize } admin-users.ts Users: list Kratos identities (filter/sort/paginate) + create/edit/deactivate/delete/recovery; gated + CSRF-guarded
src/nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model admin-groups.ts Groups: list Keto subject sets + create/delete + membership (add/remove users & nested groups); writes only to Keto, gated + CSRF-guarded
src/paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs admin-roles.ts Roles: 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/plugin.ts Plugin contract: manifest types, definePlugin(), version + conflict rules + fullPath() admin-clients.ts OAuth2 clients: 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/plugin-api.ts Stable plugin author barrel — the one module a plugin imports (definePlugin, ctx/result types, guards, body/CSRF/list-query helpers) 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/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 plugin-host/ Plugin discovery, routing, hooks, view resolution + the stable author barrel
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 plugin.ts Plugin contract: manifest types, definePlugin(), version + conflict rules + fullPath()
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 plugin-api.ts Stable plugin author barrel — the one module a plugin imports (definePlugin, ctx/result types, guards, body/CSRF/list-query helpers)
src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot
src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate
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
view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials
ui/ Design-system view-models + menu/chrome — the building blocks pages render from
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
shell-context.ts buildShellContext(): brand/theme/user view-model shared by the dashboard + admin screens (real signed-in user, no demo profile)
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)
nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model
menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot
icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs)
list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize }
paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
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) 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)
+2 -2
View File
@@ -117,7 +117,7 @@ services:
retries: 20 retries: 20
restart: unless-stopped restart: unless-stopped
# One-shot first-boot seed (the MVP bar); see src/bootstrap.ts. Idempotent, re-runs # One-shot first-boot seed (the MVP bar); see src/auth/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:
@@ -139,7 +139,7 @@ services:
KRATOS_ADMIN_URL: http://kratos:4434 KRATOS_ADMIN_URL: http://kratos:4434
volumes: volumes:
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer - ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer
command: node src/bootstrap.ts command: node src/auth/bootstrap.ts
# Bounded retry: the seed is idempotent, so transient Ory blips recover — but a permanent # Bounded retry: the seed is idempotent, so transient Ory blips recover — but a permanent
# error must give up, not loop forever and hang `web` (gates on completion). # error must give up, not loop forever and hang `web` (gates on completion).
restart: "on-failure:5" restart: "on-failure:5"
+2 -2
View File
@@ -1,9 +1,9 @@
// Central menu override + branding. 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/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), docs/plugin-contract.md.
import { defineMenu } from "../src/menu-config.ts"; import { defineMenu } from "../src/ui/menu-config.ts";
export default defineMenu({ export default defineMenu({
branding: { branding: {
+20 -20
View File
@@ -2,7 +2,7 @@
The authoritative reference for the plugin API — the product's main surface. A plugin is a The authoritative reference for the plugin API — the product's main surface. A plugin is a
self-contained folder under `plugins/` that the host discovers at boot; there is no self-contained folder under `plugins/` that the host discovers at boot; there is no
registration step. The contract is **TypeScript** (`src/plugin.ts`), so the types here are the registration step. The contract is **TypeScript** (`src/plugin-host/plugin.ts`), so the types here are the
single source of truth — this document explains them, the guarantees around them, and the rules single source of truth — this document explains them, the guarantees around them, and the rules
the host enforces. the host enforces.
@@ -14,13 +14,13 @@ crash-isolation (one bad plugin can't take the host down) is a *non-goal* — di
time, not in production. time, not in production.
> **Status.** This is the contract the 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-host/plugin.ts`; **discovery**
> (`src/discovery.ts`), the **router** (`src/router.ts` — method+path match, `:name` params, > (`src/plugin-host/discovery.ts`), the **router** (`src/plugin-host/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**
> (`src/view-resolver.ts` — a `view` result renders `plugins/<id>/views/`, with the core partials > (`src/plugin-host/view-resolver.ts` — a `view` result renders `plugins/<id>/views/`, with the core partials
> reachable via `include()`), **per-plugin static serving** (`/public/<id>/` → the plugin's > reachable via `include()`), **per-plugin static serving** (`/public/<id>/` → the plugin's
> `public/`, `routePublic` in `src/static.ts`), and the **central menu override + branding** > `public/`, `routePublic` in `src/http/static.ts`), and the **central menu override + branding**
> (`config/menu.ts`, loaded by `src/menu-config.ts`, with branding — name, logo, default theme — > (`config/menu.ts`, loaded by `src/ui/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), both documented below. > and [public pages & menu items](#public-pages--menu-items), both documented below.
@@ -56,20 +56,20 @@ Nothing else references it; the operator stays in control through the central me
## The manifest ## The manifest
A plugin imports its host surface from one module — `src/plugin-api.ts`, the **stable author A plugin imports its host surface from one module — `src/plugin-host/plugin-api.ts`, the **stable author
barrel** (`definePlugin`, the manifest/handler types, `RequestContext`, the guards, and the barrel** (`definePlugin`, the manifest/handler types, `RequestContext`, the guards, and the
body/CSRF/list-query helpers). That barrel *is* the contract boundary; don't reach into deeper body/CSRF/list-query helpers). That barrel *is* the contract boundary; don't reach into deeper
`src/*` modules — the host may refactor those freely as long as the barrel holds. `src/*` modules — the host may refactor those freely as long as the barrel holds.
```ts ```ts
import { definePlugin } from "../../src/plugin-api.ts"; import { definePlugin } from "../../src/plugin-host/plugin-api.ts";
import { listShifts, createShift } from "./shifts.ts"; import { listShifts, createShift } from "./shifts.ts";
export default definePlugin({ export default definePlugin({
apiVersion: "1.0.0", // semver of the host contract this was built against (a literal — see Versioning) apiVersion: "1.0.0", // semver of the host contract this was built against (a literal — see Versioning)
// Nav fragment, merged into the global menu and permission-filtered per user. // Nav fragment, merged into the global menu and permission-filtered per user.
// `icon` is a Lucide icon by its sprite id (src/icons.ts). // `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
nav: [{ nav: [{
icon: "i-cal", id: "scheduling:root", label: "Scheduling", icon: "i-cal", id: "scheduling:root", label: "Scheduling",
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }], children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
@@ -101,7 +101,7 @@ there is **no `id` or `basePath`** in the manifest — both come from the folder
| `apiVersion` | yes | Semver the plugin was built against — a **literal**, not `HOST_API_VERSION`. See [Versioning](#contract-versioning). | | `apiVersion` | yes | Semver the plugin was built against — a **literal**, not `HOST_API_VERSION`. See [Versioning](#contract-versioning). |
| `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | | `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | | `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/icons.ts`); node `id`s must be globally unique. | | `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. |
| `permissions` | no | Tokens this plugin introduces; declared for docs, conflict detection, and bootstrap seeding (see [Nav & permissions](#nav--permissions)). | | `permissions` | no | Tokens this plugin introduces; declared for docs, conflict detection, and bootstrap seeding (see [Nav & permissions](#nav--permissions)). |
| `routes` | no | See [Routes & handlers](#routes--handlers). | | `routes` | no | See [Routes & handlers](#routes--handlers). |
| `hooks` | no | See [Hooks](#hooks). | | `hooks` | no | See [Hooks](#hooks). |
@@ -136,7 +136,7 @@ type RouteResult =
```ts ```ts
// shifts.ts // shifts.ts
import { parseListQuery, type RequestContext } from "../../src/plugin-api.ts"; import { parseListQuery, type RequestContext } from "../../src/plugin-host/plugin-api.ts";
export async function listShifts(ctx: RequestContext) { export async function listShifts(ctx: RequestContext) {
const q = parseListQuery(ctx.url); const q = parseListQuery(ctx.url);
@@ -145,13 +145,13 @@ export async function listShifts(ctx: RequestContext) {
} }
``` ```
- **`view`** resolves against the plugin's own `views/` (`src/view-resolver.ts`) — nested names - **`view`** resolves against the plugin's own `views/` (`src/plugin-host/view-resolver.ts`) — nested names
like `"shifts/edit"` work, and an out-of-bounds name is refused. The template may `include()` like `"shifts/edit"` work, and an out-of-bounds name is refused. The template may `include()`
the core building-block partials (app shell, nav tree, data table, …) and its own the core building-block partials (app shell, nav tree, data table, …) and its own
partials/subfolders to render a full page — exactly as the built-in screens do. To load the partials/subfolders to render a full page — exactly as the built-in screens do. To load the
plugin's own CSS, pass its `/public/<id>/x.css` href in the shell's `styles` slot (an array of plugin's own CSS, pass its `/public/<id>/x.css` href in the shell's `styles` slot (an array of
extra stylesheet hrefs) — see the reference's `views/shifts.ejs`. extra stylesheet hrefs) — see the reference's `views/shifts.ejs`.
- **Finer authorization than the route `permission`** uses the guards from `src/plugin-api.ts`: - **Finer authorization than the route `permission`** uses the guards from `src/plugin-host/plugin-api.ts`:
`requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect `requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect
to sign in), `can(ctx, role)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx, to sign in), `can(ctx, role)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx,
{namespace, object, relation})` (a live Keto check for relationship rules — the subject is the {namespace, object, relation})` (a live Keto check for relationship rules — the subject is the
@@ -173,10 +173,10 @@ safety of the data it renders**:
names), so those are injection-safe. But a URL field — nav `href`, a table cell link, a menu names), so those are injection-safe. But a URL field — nav `href`, a table cell link, a menu
item, a breadcrumb, `brand.logo` — is emitted as-is inside the attribute: a `javascript:` or item, a breadcrumb, `brand.logo` — is emitted as-is inside the attribute: a `javascript:` or
`data:` URL from upstream/user data becomes live XSS. When a URL comes from data you don't `data:` URL from upstream/user data becomes live XSS. When a URL comes from data you don't
control, pass it through **`safeUrl()`** from `src/plugin-api.ts` first — it returns the URL when control, pass it through **`safeUrl()`** from `src/plugin-host/plugin-api.ts` first — it returns the URL when
it's relative or `http(s):` and collapses anything else to `"#"`: it's relative or `http(s):` and collapses anything else to `"#"`:
```ts ```ts
import { safeUrl } from "../../src/plugin-api.ts"; import { safeUrl } from "../../src/plugin-host/plugin-api.ts";
return { view: "list", data: { rows: rows.map((r) => ({ ...r, href: safeUrl(r.href) })) } }; return { view: "list", data: { rows: rows.map((r) => ({ ...r, href: safeUrl(r.href) })) } };
``` ```
@@ -190,7 +190,7 @@ The host has two replaceable landing slots, and a plugin may own either or both:
| `dashboard` | `/dashboard` | **signed-in session** (anonymous → `/login`, with `/dashboard` as `return_to`) | The built-in mock-data People list. | | `dashboard` | `/dashboard` | **signed-in session** (anonymous → `/login`, with `/dashboard` as `return_to`) | The built-in mock-data People list. |
```ts ```ts
import { definePlugin } from "../../src/plugin-api.ts"; import { definePlugin } from "../../src/plugin-host/plugin-api.ts";
import { landing, board } from "./pages.ts"; import { landing, board } from "./pages.ts";
export default definePlugin({ export default definePlugin({
@@ -220,7 +220,7 @@ route at all (route paths always carry the `/<id>` prefix).
## RequestContext ## RequestContext
Every handler receives one argument, the `RequestContext` (`src/context.ts`), built once per Every handler receives one argument, the `RequestContext` (`src/http/context.ts`), built once per
request: request:
```ts ```ts
@@ -275,12 +275,12 @@ from the JWT middleware and are `null`/`[]` until a session exists.
## Nav & permissions ## Nav & permissions
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/nav.ts`), which A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
applies the central override and then **filters per user** by the roles in the session JWT — a applies the central override and then **filters per user** by the roles in the session JWT — a
node shows iff it is `public`, declares no `permission`, or the user's roles include that token. Use node shows iff it is `public`, declares no `permission`, or the user's roles include that token. Use
arbitrary depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` is a arbitrary depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` is a
**Lucide icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids **Lucide icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids
are `ICON_NAMES` in `src/icons.ts`, and adding one means registering its lucide name there. are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there.
### Public pages & menu items ### Public pages & menu items
@@ -385,7 +385,7 @@ worked example: thin handlers bound to an injectable upstream client, unit-teste
1. **Unit-test handlers as pure functions.** Keep a handler thin: parse `ctx`, fetch upstream, 1. **Unit-test handlers as pure functions.** Keep a handler thin: parse `ctx`, fetch upstream,
return a `RouteResult`. Test the data-shaping in isolation (mock `fetch`/upstream) with return a `RouteResult`. Test the data-shaping in isolation (mock `fetch`/upstream) with
`node --test`, exactly like `src/dashboard.test.ts` tests the dashboard model. No host needed. `node --test`, exactly like `src/ui/dashboard.test.ts` tests the dashboard model. No host needed.
```bash ```bash
docker compose run --rm web npm test docker compose run --rm web npm test
+1 -1
View File
@@ -5,7 +5,7 @@ import { expect, test, type Page } from "@playwright/test";
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/auth/login.ts — web verifies it against the committed dev JWKS
const shot = (page: Page, name: string): Promise<Buffer> => const shot = (page: Page, name: string): Promise<Buffer> =>
page.screenshot({ fullPage: true, path: `${SHOTS}/${name}.png` }); page.screenshot({ fullPage: true, path: `${SHOTS}/${name}.png` });
+1 -1
View File
@@ -9,7 +9,7 @@
"scripts": { "scripts": {
"start": "node src/server.ts", "start": "node src/server.ts",
"dev": "node --watch src/server.ts", "dev": "node --watch src/server.ts",
"gen-jwks": "node src/gen-jwks.ts", "gen-jwks": "node src/auth/gen-jwks.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\"" "test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\""
}, },
+1 -1
View File
@@ -2,7 +2,7 @@
// 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.
import { definePlugin } from "../../src/plugin-api.ts"; import { definePlugin } from "../../src/plugin-host/plugin-api.ts";
import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts"; import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
// The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is // The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is
+1 -1
View File
@@ -4,7 +4,7 @@ import { Readable } from "node:stream";
import test from "node:test"; import test from "node:test";
// Import only from the plugin-api barrel — the same contract boundary shifts.ts uses (the host may // Import only from the plugin-api barrel — the same contract boundary shifts.ts uses (the host may
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches. // refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
import { GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "../../src/plugin-api.ts"; import { GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "../../src/plugin-host/plugin-api.ts";
import { import {
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput, assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate, SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
+1 -1
View File
@@ -6,7 +6,7 @@
// pure functions against a mock upstream with no network (docs/plugin-contract.md → dev/test story). // pure functions against a mock upstream with no network (docs/plugin-contract.md → dev/test story).
// 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-host/plugin-api.ts";
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
export const SHIFTS_PATH = "/scheduling/shifts"; export const SHIFTS_PATH = "/scheduling/shifts";
@@ -8,14 +8,14 @@
import { ADMIN_CLIENTS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts"; import { ADMIN_CLIENTS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
import { safeDecode } from "./admin-groups.ts"; import { safeDecode } from "./admin-groups.ts";
import type { FieldConfig } from "./admin-users.ts"; import type { FieldConfig } from "./admin-users.ts";
import type { RequestContext, User } from "./context.ts"; import type { RequestContext, User } from "../http/context.ts";
import { HydraError, type HydraAdmin, type OAuth2Client } from "./hydra-admin.ts"; import { HydraError, type HydraAdmin, type OAuth2Client } from "../auth/hydra-admin.ts";
import { parseListQuery } from "./list-query.ts"; import { parseListQuery } from "../ui/list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import type { NavNode } from "./nav.ts"; import type { NavNode } from "../ui/nav.ts";
import { paginate } from "./paginate.ts"; import { paginate } from "../ui/paginate.ts";
import type { RouteResult } from "./plugin.ts"; import type { RouteResult } from "../plugin-host/plugin.ts";
import { buildShellContext } from "./shell-context.ts"; import { buildShellContext } from "../ui/shell-context.ts";
const DEFAULT_PAGE_SIZE = 25; const DEFAULT_PAGE_SIZE = 25;
const PAGE_SIZES = [25, 50, 100]; const PAGE_SIZES = [25, 50, 100];
@@ -14,7 +14,7 @@ import {
memberView, memberView,
parseSubject, parseSubject,
} from "./admin-groups.ts"; } from "./admin-groups.ts";
import type { RelationTuple } from "./keto-client.ts"; import type { RelationTuple } from "../auth/keto-client.ts";
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`; const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const userTuple = (group: string, n: number): RelationTuple => const userTuple = (group: string, n: number): RelationTuple =>
@@ -8,15 +8,15 @@
import { ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts"; import { ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
import type { FieldConfig } from "./admin-users.ts"; import type { FieldConfig } from "./admin-users.ts";
import type { RequestContext, User } from "./context.ts"; import type { RequestContext, User } from "../http/context.ts";
import type { KetoClient, RelationQuery, RelationTuple, SubjectSet } from "./keto-client.ts"; import type { KetoClient, RelationQuery, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts"; import type { KratosAdmin } from "../auth/kratos-admin.ts";
import { parseListQuery } from "./list-query.ts"; import { parseListQuery } from "../ui/list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import type { NavNode } from "./nav.ts"; import type { NavNode } from "../ui/nav.ts";
import { paginate } from "./paginate.ts"; import { paginate } from "../ui/paginate.ts";
import type { RouteResult } from "./plugin.ts"; import type { RouteResult } from "../plugin-host/plugin.ts";
import { buildShellContext } from "./shell-context.ts"; import { buildShellContext } from "../ui/shell-context.ts";
const GROUP_NS = "Group"; const GROUP_NS = "Group";
const MEMBERS = "members"; const MEMBERS = "members";
@@ -8,10 +8,10 @@ import { test } from "node:test";
import { import {
ADMIN_PERMISSION, ADMIN_USERS_BASE, adminSection, buildConfirmModel, guardedForm, requireAdmin, ADMIN_PERMISSION, ADMIN_USERS_BASE, adminSection, buildConfirmModel, guardedForm, requireAdmin,
} from "./admin-nav.ts"; } from "./admin-nav.ts";
import { buildContext, type RequestContext, type User } from "./context.ts"; import { buildContext, type RequestContext, type User } from "../http/context.ts";
import { CSRF_COOKIE, CSRF_FIELD, issueCsrfToken } from "./csrf.ts"; import { CSRF_COOKIE, CSRF_FIELD, issueCsrfToken } from "../auth/csrf.ts";
import { GuardError } from "./guards.ts"; import { GuardError } from "../auth/guards.ts";
import { DEFAULT_MENU } from "./menu-config.ts"; import { DEFAULT_MENU } from "../ui/menu-config.ts";
const admin: User = { email: "ada@x.io", id: "u1", roles: ["admin"] }; const admin: User = { email: "ada@x.io", id: "u1", roles: ["admin"] };
const member: User = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] }; const member: User = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] };
+7 -7
View File
@@ -4,13 +4,13 @@
// 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.
import { readFormBody } from "./body.ts"; import { readFormBody } from "../http/body.ts";
import type { RequestContext, User } from "./context.ts"; import type { RequestContext, User } from "../http/context.ts";
import { CSRF_FIELD, verifyCsrfRequest } from "./csrf.ts"; import { CSRF_FIELD, verifyCsrfRequest } from "../auth/csrf.ts";
import { GuardError, loginRedirect } from "./guards.ts"; import { GuardError, loginRedirect } from "../auth/guards.ts";
import { type MenuConfig } from "./menu-config.ts"; import { type MenuConfig } from "../ui/menu-config.ts";
import type { NavNode } from "./nav.ts"; import type { NavNode } from "../ui/nav.ts";
import { buildShellContext } from "./shell-context.ts"; import { buildShellContext } from "../ui/shell-context.ts";
export const ADMIN_PERMISSION = "admin"; // role token gating the admin section export const ADMIN_PERMISSION = "admin"; // role token gating the admin section
export const ADMIN_USERS_BASE = "/admin/users"; export const ADMIN_USERS_BASE = "/admin/users";
@@ -14,7 +14,7 @@ import {
isValidRoleName, isValidRoleName,
roleMemberTuple, roleMemberTuple,
} from "./admin-roles.ts"; } from "./admin-roles.ts";
import type { ExpandTree, RelationTuple } from "./keto-client.ts"; import type { ExpandTree, RelationTuple } from "../auth/keto-client.ts";
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`; const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const userTuple = (role: string, n: number): RelationTuple => const userTuple = (role: string, n: number): RelationTuple =>
@@ -22,15 +22,15 @@ import {
safeDecode, safeDecode,
} from "./admin-groups.ts"; } from "./admin-groups.ts";
import type { FieldConfig } from "./admin-users.ts"; import type { FieldConfig } from "./admin-users.ts";
import type { RequestContext, User } from "./context.ts"; import type { RequestContext, User } from "../http/context.ts";
import type { ExpandTree, KetoClient, RelationTuple } from "./keto-client.ts"; import type { ExpandTree, KetoClient, RelationTuple } from "../auth/keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts"; import type { KratosAdmin } from "../auth/kratos-admin.ts";
import { parseListQuery } from "./list-query.ts"; import { parseListQuery } from "../ui/list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import type { NavNode } from "./nav.ts"; import type { NavNode } from "../ui/nav.ts";
import { paginate } from "./paginate.ts"; import { paginate } from "../ui/paginate.ts";
import type { RouteResult } from "./plugin.ts"; import type { RouteResult } from "../plugin-host/plugin.ts";
import { buildShellContext } from "./shell-context.ts"; import { buildShellContext } from "../ui/shell-context.ts";
const ROLE_NS = "Role"; const ROLE_NS = "Role";
const MEMBERS = "members"; const MEMBERS = "members";
@@ -10,7 +10,7 @@ import {
toUserView, toUserView,
updateIdentityPayload, updateIdentityPayload,
} from "./admin-users.ts"; } from "./admin-users.ts";
import type { Identity } from "./kratos-admin.ts"; import type { Identity } from "../auth/kratos-admin.ts";
const id = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`; const id = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const identity = (n: number, over: Partial<Identity> = {}): Identity => ({ const identity = (n: number, over: Partial<Identity> = {}): Identity => ({
@@ -6,15 +6,15 @@
import { safeDecode } from "./admin-groups.ts"; import { safeDecode } from "./admin-groups.ts";
import { ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts"; import { ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
import type { RequestContext, User } from "./context.ts"; import type { RequestContext, User } from "../http/context.ts";
import type { Identity, KratosAdmin, RecoveryCode } from "./kratos-admin.ts"; import type { Identity, KratosAdmin, RecoveryCode } from "../auth/kratos-admin.ts";
import { KratosError } from "./kratos-public.ts"; import { KratosError } from "../auth/kratos-public.ts";
import { parseListQuery } from "./list-query.ts"; import { parseListQuery } from "../ui/list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import type { NavNode } from "./nav.ts"; import type { NavNode } from "../ui/nav.ts";
import { paginate } from "./paginate.ts"; import { paginate } from "../ui/paginate.ts";
import type { RouteResult } from "./plugin.ts"; import type { RouteResult } from "../plugin-host/plugin.ts";
import { buildShellContext } from "./shell-context.ts"; import { buildShellContext } from "../ui/shell-context.ts";
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
const DEFAULT_PAGE_SIZE = 25; const DEFAULT_PAGE_SIZE = 25;
+2 -2
View File
@@ -8,9 +8,9 @@
// Then prints a first-run banner; fails loud on any unexpected upstream error. // Then prints a first-run banner; fails loud on any unexpected upstream error.
import { existsSync, writeFileSync } from "node:fs"; import { existsSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { discoverPlugins } from "./discovery.ts"; import { discoverPlugins } from "../plugin-host/discovery.ts";
import { generateJwks, type JwkSet } from "./gen-jwks.ts"; import { generateJwks, type JwkSet } from "./gen-jwks.ts";
import { createLogger, runWithLog, tracedFetch } from "./logger.ts"; import { createLogger, runWithLog, tracedFetch } from "../logger.ts";
// --- Pure payload builders (the Kratos/Keto request contracts) ----------------------- // --- Pure payload builders (the Kratos/Keto request contracts) -----------------------
+1 -1
View File
@@ -5,7 +5,7 @@
// from sending the cookie; the signature + double-submit defend the rest. Kratos' own flows // from sending the cookie; the signature + double-submit defend the rest. Kratos' own flows
// carry Kratos' CSRF token — this guards only the routes we handle. // carry Kratos' CSRF token — this guards only the routes we handle.
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
import { parseCookies, serializeCookie } from "./cookie.ts"; import { parseCookies, serializeCookie } from "../http/cookie.ts";
export const CSRF_COOKIE = "plainpages_csrf"; export const CSRF_COOKIE = "plainpages_csrf";
export const CSRF_FIELD = "_csrf"; // hidden input name forms submit the token under export const CSRF_FIELD = "_csrf"; // hidden input name forms submit the token under
+1 -1
View File
@@ -1,5 +1,5 @@
// Kratos flow → themed view model. 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/auth/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
// owns its CSRF; we only render and map errors. No providers configured ⇒ no SSO buttons. // owns its CSRF; we only render and map errors. No providers configured ⇒ no SSO buttons.
@@ -1,6 +1,6 @@
// Guards the session-tokenizer signing key: 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, reads. // with it verifies through our own verifier (src/auth/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";
@@ -9,7 +9,7 @@ import { generateJwks, rotateJwks } from "./gen-jwks.ts";
import { verifyJws } from "./jwt.ts"; import { verifyJws } from "./jwt.ts";
const b64url = (s: string) => Buffer.from(s).toString("base64url"); const b64url = (s: string) => Buffer.from(s).toString("base64url");
const committed = JSON.parse(readFileSync(new URL("../ory/kratos/tokenizer/jwks.json", import.meta.url), "utf8")); const committed = JSON.parse(readFileSync(new URL("../../ory/kratos/tokenizer/jwks.json", import.meta.url), "utf8"));
test("generateJwks emits one ES256 EC private signing key with a fresh kid", () => { test("generateJwks emits one ES256 EC private signing key with a fresh kid", () => {
const a = generateJwks(); const a = generateJwks();
+1 -1
View File
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
// ES256 signing JWKS for the Kratos session tokenizer — 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/auth/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)
// gen-jwks.ts --prepend <jwks.json> → new key first + the old ones (zero-downtime rotation) // gen-jwks.ts --prepend <jwks.json> → new key first + the old ones (zero-downtime rotation)
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { IncomingMessage, ServerResponse } from "node:http"; import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net"; import { Socket } from "node:net";
import { test } from "node:test"; import { test } from "node:test";
import { buildContext, type RequestContext, type User } from "./context.ts"; import { buildContext, type RequestContext, type User } from "../http/context.ts";
import { can, check, GuardError, requireSession } from "./guards.ts"; import { can, check, GuardError, requireSession } from "./guards.ts";
import type { KetoClient, RelationTuple } from "./keto-client.ts"; import type { KetoClient, RelationTuple } from "./keto-client.ts";
+2 -2
View File
@@ -3,9 +3,9 @@
// 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
// Keto call — the fine-grained "may I?" tier (README), reserved for relationship rules. // Keto call — the fine-grained "may I?" tier (README), reserved for relationship rules.
import type { RequestContext, User } from "./context.ts"; import type { RequestContext, User } from "../http/context.ts";
import type { KetoClient } from "./keto-client.ts"; import type { KetoClient } from "./keto-client.ts";
import { localPath } from "./safe-url.ts"; import { localPath } from "../http/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. 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
+1 -1
View File
@@ -6,7 +6,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
import { cachingJwks, createJwksProvider, loadJwks, staticJwks } from "./jwks.ts"; import { cachingJwks, createJwksProvider, loadJwks, staticJwks } from "./jwks.ts";
const jwk = (kid: string): JsonWebKey => ({ ...(generateKeyPairSync("ec", { namedCurve: "P-256" }).publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid }); const jwk = (kid: string): JsonWebKey => ({ ...(generateKeyPairSync("ec", { namedCurve: "P-256" }).publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid });
const committed = join(dirname(fileURLToPath(import.meta.url)), "..", "ory/kratos/tokenizer/jwks.json"); const committed = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "ory/kratos/tokenizer/jwks.json");
test("staticJwks selects by kid, falls back to the sole key when none, misses cleanly", async () => { test("staticJwks selects by kid, falls back to the sole key when none, misses cleanly", async () => {
const [a, b] = [jwk("k1"), jwk("k2")]; const [a, b] = [jwk("k1"), jwk("k2")];
+1 -1
View File
@@ -1,7 +1,7 @@
import type { JsonWebKey } from "node:crypto"; import type { JsonWebKey } from "node:crypto";
import { readFileSync } from "node:fs"; 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`. 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
@@ -1,10 +1,10 @@
// JWT session middleware: 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/auth/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
// (anonymous), so the route renders signed-out and the permission gate denies. // (anonymous), so the route renders signed-out and the permission gate denies.
import type { User } from "./context.ts"; import type { User } from "../http/context.ts";
import { parseCookies } from "./cookie.ts"; import { parseCookies } from "../http/cookie.ts";
import type { Denylist } from "./denylist.ts"; import type { Denylist } from "./denylist.ts";
import { decodeJws, verifyJws } from "./jwt.ts"; import { decodeJws, verifyJws } from "./jwt.ts";
import type { JwksProvider } from "./jwks.ts"; import type { JwksProvider } from "./jwks.ts";
View File
+3 -3
View File
@@ -6,9 +6,9 @@
// 4. whoami(tokenize_as) → the signed JWT { sub, email, roles }, stored as our cookie // 4. whoami(tokenize_as) → the signed JWT { sub, email, roles }, stored as our cookie
// Order matters: the projection is written before tokenizing, because the claims mapper // Order matters: the projection is written before tokenizing, because the claims mapper
// reads only the identity, never Keto. // reads only the identity, never Keto.
import type { User } from "./context.ts"; import type { User } from "../http/context.ts";
import { serializeCookie, type CookieOptions } from "./cookie.ts"; import { serializeCookie, type CookieOptions } from "../http/cookie.ts";
import { currentLog } from "./logger.ts"; import { currentLog } from "../logger.ts";
import type { KetoClient } from "./keto-client.ts"; import type { KetoClient } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts"; import type { KratosAdmin } from "./kratos-admin.ts";
import type { KratosPublic } from "./kratos-public.ts"; import type { KratosPublic } from "./kratos-public.ts";
+1 -1
View File
@@ -78,7 +78,7 @@ test("a one-shot bootstrap seeds the stack before web starts", () => {
// 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\/auth\/bootstrap\.ts/, "bootstrap runs the seed script");
for (const svc of ["kratos", "keto"]) for (const svc of ["kratos", "keto"])
assert.match(boot, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_healthy`), assert.match(boot, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_healthy`),
`bootstrap waits for ${svc} healthy`); `bootstrap waits for ${svc} healthy`);
+12 -12
View File
@@ -9,20 +9,20 @@ import { after, before, test, type TestContext } from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { createApp, type AppOptions } from "./app.ts"; import { createApp, type AppOptions } from "./app.ts";
import { readFormBody } from "./body.ts"; import { readFormBody } from "./body.ts";
import { createLogger } from "./logger.ts"; import { createLogger } from "../logger.ts";
import { createDenylist } from "./denylist.ts"; import { createDenylist } from "../auth/denylist.ts";
import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts"; import { CSRF_COOKIE, issueCsrfToken } from "../auth/csrf.ts";
import { can, check, GuardError, requireSession } from "./guards.ts"; import { can, check, GuardError, requireSession } from "../auth/guards.ts";
import { HydraError, type HydraAdmin, type OAuth2Client } from "./hydra-admin.ts"; import { HydraError, type HydraAdmin, type OAuth2Client } from "../auth/hydra-admin.ts";
import { staticJwks } from "./jwks.ts"; import { staticJwks } from "../auth/jwks.ts";
import type { ExpandTree, KetoClient, RelationTuple, SubjectSet } from "./keto-client.ts"; import type { ExpandTree, KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
import type { Identity, KratosAdmin } from "./kratos-admin.ts"; import type { Identity, KratosAdmin } from "../auth/kratos-admin.ts";
import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "./kratos-public.ts"; import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts";
import { SESSION_COOKIE } from "./login.ts"; import { SESSION_COOKIE } from "../auth/login.ts";
import type { Plugin } from "./plugin.ts"; import type { Plugin } from "../plugin-host/plugin.ts";
import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts"; 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 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
+28 -28
View File
@@ -3,40 +3,40 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import * as ejs from "ejs"; import * as ejs from "ejs";
import { ADMIN_CLIENTS_BASE, ADMIN_GROUPS_BASE, ADMIN_ROLES_BASE, ADMIN_USERS_BASE } from "./admin-nav.ts"; import { ADMIN_CLIENTS_BASE, ADMIN_GROUPS_BASE, ADMIN_ROLES_BASE, ADMIN_USERS_BASE } from "../admin/admin-nav.ts";
import { type AdminClientsDeps, handleAdminClients } from "./admin-clients.ts"; import { type AdminClientsDeps, handleAdminClients } from "../admin/admin-clients.ts";
import { type AdminGroupsDeps, handleAdminGroups } from "./admin-groups.ts"; import { type AdminGroupsDeps, handleAdminGroups } from "../admin/admin-groups.ts";
import { type AdminRolesDeps, handleAdminRoles } from "./admin-roles.ts"; import { type AdminRolesDeps, handleAdminRoles } from "../admin/admin-roles.ts";
import { type AdminUsersDeps, handleAdminUsers } from "./admin-users.ts"; import { type AdminUsersDeps, handleAdminUsers } from "../admin/admin-users.ts";
import { readFormBody } from "./body.ts"; import { readFormBody } from "./body.ts";
import { buildPluginChrome, type PageChrome } from "./chrome.ts"; import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
import { buildContext, type User } from "./context.ts"; import { buildContext, type User } from "./context.ts";
import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "./csrf.ts"; import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
import type { Denylist } from "./denylist.ts"; import type { Denylist } from "../auth/denylist.ts";
import { buildDashboardModel } from "./dashboard.ts"; import { buildDashboardModel } from "../ui/dashboard.ts";
import { PLUGINS_DIR } from "./discovery.ts"; import { PLUGINS_DIR } from "../plugin-host/discovery.ts";
import { GuardError, loginRedirect } from "./guards.ts"; import { GuardError, loginRedirect } from "../auth/guards.ts";
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts"; import { AUTH_FLOWS, buildFlowView } from "../auth/flow-view.ts";
import { runRequestHooks, runResponseHooks } from "./hooks.ts"; import { runRequestHooks, runResponseHooks } from "../plugin-host/hooks.ts";
import { HydraError, type HydraAdmin } from "./hydra-admin.ts"; import { HydraError, type HydraAdmin } from "../auth/hydra-admin.ts";
import type { JwksProvider } from "./jwks.ts"; import type { JwksProvider } from "../auth/jwks.ts";
import { resolveSession, type VerifyOptions } from "./jwt-middleware.ts"; import { resolveSession, type VerifyOptions } from "../auth/jwt-middleware.ts";
import type { KetoClient } from "./keto-client.ts"; import type { KetoClient } from "../auth/keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts"; import type { KratosAdmin } from "../auth/kratos-admin.ts";
import { type Flow, KratosError, type KratosPublic } from "./kratos-public.ts"; import { type Flow, KratosError, type KratosPublic } from "../auth/kratos-public.ts";
import { createLogger, type Log, requestLogger, runWithLog } from "./logger.ts"; import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts";
import { clearSessionCookie, completeLogin, remintSession, sessionCookie } from "./login.ts"; import { clearSessionCookie, completeLogin, remintSession, sessionCookie } from "../auth/login.ts";
import { resolveLoginChallenge } from "./oauth-login.ts"; import { resolveLoginChallenge } from "../auth/oauth-login.ts";
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "./oauth-consent.ts"; import { acceptConsent, rejectConsent, resolveConsentChallenge } from "../auth/oauth-consent.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import type { Plugin, RouteHandler, RouteResult } from "./plugin.ts"; import type { Plugin, RouteHandler, RouteResult } from "../plugin-host/plugin.ts";
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts"; import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
import { securityHeaders } from "./security-headers.ts"; import { securityHeaders } from "./security-headers.ts";
import { localPath } from "./safe-url.ts"; import { localPath } from "./safe-url.ts";
import { routePublic, serveStatic } from "./static.ts"; import { routePublic, serveStatic } from "./static.ts";
import { renderPluginView } from "./view-resolver.ts"; import { renderPluginView } from "../plugin-host/view-resolver.ts";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), ".."); const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
export interface AppOptions { export interface AppOptions {
appUrl?: string; // canonical public URL (config.appUrl); off-host GET/HEAD visitors are 308'd here. Omitted ⇒ no redirect appUrl?: string; // canonical public URL (config.appUrl); off-host GET/HEAD visitors are 308'd here. Omitted ⇒ no redirect
View File
@@ -3,7 +3,7 @@ import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net"; import { Socket } from "node:net";
import { test } from "node:test"; import { test } from "node:test";
import { buildContext, type User } from "./context.ts"; import { buildContext, type User } from "./context.ts";
import { createLogger } from "./logger.ts"; import { createLogger } from "../logger.ts";
// A req/res pair without a live server — enough to build and inspect a context. // A req/res pair without a live server — enough to build and inspect a context.
function reqRes(url?: string): { req: IncomingMessage; res: ServerResponse } { function reqRes(url?: string): { req: IncomingMessage; res: ServerResponse } {
+2 -2
View File
@@ -1,6 +1,6 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import type { PageChrome } from "./chrome.ts"; // type-only: no runtime import, so no cycle import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle
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 JWT // per request by `buildContext`: the router supplies matched path `params`, the JWT
@@ -9,7 +9,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import { checkApiVersion, findConflicts, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts"; import { checkApiVersion, findConflicts, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), ".."); const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
// Default scan root — <repo>/plugins, i.e. the /app/plugins the container mounts (README). // Default scan root — <repo>/plugins, i.e. the /app/plugins the container mounts (README).
export const PLUGINS_DIR = join(rootDir, "plugins"); export const PLUGINS_DIR = join(rootDir, "plugins");
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import type { RequestContext } from "./context.ts"; import type { RequestContext } from "../http/context.ts";
import type { Plugin, PluginHooks } from "./plugin.ts"; import type { Plugin, PluginHooks } from "./plugin.ts";
import { runBootHooks, runRequestHooks, runResponseHooks } from "./hooks.ts"; import { runBootHooks, runRequestHooks, runResponseHooks } from "./hooks.ts";
+1 -1
View File
@@ -3,7 +3,7 @@
// 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.
import type { RequestContext } from "./context.ts"; import type { RequestContext } from "../http/context.ts";
import type { Plugin, RouteResult } from "./plugin.ts"; import type { Plugin, RouteResult } from "./plugin.ts";
// After discovery, before the server listens. A throw aborts boot. // After discovery, before the server listens. A throw aborts boot.
@@ -6,17 +6,17 @@
export { definePlugin } from "./plugin.ts"; export { definePlugin } from "./plugin.ts";
export type { HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts"; export type { HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
export type { RequestContext, User } from "./context.ts"; export type { RequestContext, User } from "../http/context.ts";
export type { PageChrome } from "./chrome.ts"; export type { PageChrome } from "../ui/chrome.ts";
export type { NavNode } from "./nav.ts"; export type { NavNode } from "../ui/nav.ts";
export { can, check, GuardError, requireSession } from "./guards.ts"; export { can, check, GuardError, requireSession } from "../auth/guards.ts";
export { parseListQuery } from "./list-query.ts"; export { parseListQuery } from "../ui/list-query.ts";
export { readFormBody } from "./body.ts"; export { readFormBody } from "../http/body.ts";
export { CSRF_FIELD } from "./csrf.ts"; export { CSRF_FIELD } from "../auth/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 "../http/safe-url.ts";
// Observability: `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";
@@ -11,8 +11,8 @@ import {
type Plugin, type Plugin,
type PluginManifest, type PluginManifest,
} from "./plugin.ts"; } from "./plugin.ts";
import { ADMIN_CLIENTS_BASE, ADMIN_GROUPS_BASE, ADMIN_ROLES_BASE, ADMIN_USERS_BASE } from "./admin-nav.ts"; import { ADMIN_CLIENTS_BASE, ADMIN_GROUPS_BASE, ADMIN_ROLES_BASE, ADMIN_USERS_BASE } from "../admin/admin-nav.ts";
import { AUTH_FLOWS } from "./flow-view.ts"; import { AUTH_FLOWS } from "../auth/flow-view.ts";
// A representative manifest exercising every field — its existence type-checks the contract. // A representative manifest exercising every field — its existence type-checks the contract.
// `apiVersion` is a literal: a plugin pins the version it was built against, so importing // `apiVersion` is a literal: a plugin pins the version it was built against, so importing
+3 -3
View File
@@ -5,8 +5,8 @@
// A plugin's identity is its folder under plugins/: folder name = `id` (isValidPluginId), mount = // A plugin's identity is its folder under plugins/: folder name = `id` (isValidPluginId), mount =
// `/<id>`. Neither is in the manifest — the host derives them, so they can't drift or be claimed twice. // `/<id>`. Neither is in the manifest — the host derives them, so they can't drift or be claimed twice.
import type { RequestContext } from "./context.ts"; import type { RequestContext } from "../http/context.ts";
import type { NavNode } from "./nav.ts"; import type { NavNode } from "../ui/nav.ts";
// Host contract version (semver). Bump major on a breaking manifest/handler change, minor on an // Host contract version (semver). Bump major on a breaking manifest/handler change, minor on an
// additive one. A plugin pins the version it targets via `apiVersion`; the host applies // additive one. A plugin pins the version it targets via `apiVersion`; the host applies
@@ -62,7 +62,7 @@ export interface PluginManifest {
// 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;
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/icons.ts), node ids must be globally unique nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique
permissions?: PermissionDecl[]; permissions?: PermissionDecl[];
routes?: Route[]; routes?: Route[];
} }
@@ -6,7 +6,7 @@ import { test, type TestContext } from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { renderPluginView, resolveViewPath } from "./view-resolver.ts"; import { renderPluginView, resolveViewPath } from "./view-resolver.ts";
const coreViewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "views"); const coreViewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
test("resolveViewPath resolves names/nested subfolders within the views dir, rejects traversal + control chars", () => { test("resolveViewPath resolves names/nested subfolders within the views dir, rejects traversal + control chars", () => {
const dir = "/srv/plugins"; const dir = "/srv/plugins";
+12 -12
View File
@@ -1,20 +1,20 @@
import { createApp } from "./app.ts"; import { createApp } from "./http/app.ts";
import { loadConfig } from "./config.ts"; import { loadConfig } from "./config.ts";
import { createDenylist } from "./denylist.ts"; import { createDenylist } from "./auth/denylist.ts";
import { discoverPlugins } from "./discovery.ts"; import { discoverPlugins } from "./plugin-host/discovery.ts";
import { withTimeout } from "./fetch-timeout.ts"; import { withTimeout } from "./auth/fetch-timeout.ts";
import { runBootHooks } from "./hooks.ts"; import { runBootHooks } from "./plugin-host/hooks.ts";
import { createHydraAdmin } from "./hydra-admin.ts"; import { createHydraAdmin } from "./auth/hydra-admin.ts";
import { createJwksProvider } from "./jwks.ts"; import { createJwksProvider } from "./auth/jwks.ts";
import { createKetoClient } from "./keto-client.ts"; import { createKetoClient } from "./auth/keto-client.ts";
import { createKratosAdmin } from "./kratos-admin.ts"; import { createKratosAdmin } from "./auth/kratos-admin.ts";
import { createKratosPublic } from "./kratos-public.ts"; import { createKratosPublic } from "./auth/kratos-public.ts";
import { createLogger, tracedFetch } from "./logger.ts"; import { createLogger, tracedFetch } from "./logger.ts";
import { loadMenuConfig } from "./menu-config.ts"; import { loadMenuConfig } from "./ui/menu-config.ts";
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
// App-level logger: structured, OTLP-capable when OTLP_ENDPOINT is set. The hot path clones it // App-level logger: structured, OTLP-capable when OTLP_ENDPOINT is set. The hot path clones it
// per request for access logging + a trace span (src/app.ts); console-only otherwise. // per request for access logging + a trace span (src/http/app.ts); console-only otherwise.
const log = createLogger({ format: config.logFormat, level: config.logLevel, otlpEndpoint: config.otlpEndpoint, otlpProtocol: config.otlpProtocol, serviceName: config.serviceName }); const log = createLogger({ format: config.logFormat, level: config.logLevel, otlpEndpoint: config.otlpEndpoint, otlpProtocol: config.otlpProtocol, serviceName: config.serviceName });
const menu = await loadMenuConfig(); // config/menu.ts override + branding — fails loud if malformed const menu = await loadMenuConfig(); // config/menu.ts override + branding — fails loud if malformed
// Every outbound Ory call is traced through the active request's logger (a client span continuing // Every outbound Ory call is traced through the active request's logger (a client span continuing
@@ -4,7 +4,7 @@ import { test } from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import * as ejs from "ejs"; import * as ejs from "ejs";
const authCard = join(dirname(fileURLToPath(import.meta.url)), "..", "views", "partials", "auth-card.ejs"); const authCard = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "auth-card.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(authCard, data); const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(authCard, data);
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim(); const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
+1 -1
View File
@@ -3,7 +3,7 @@ import test from "node:test";
import { buildPluginChrome } from "./chrome.ts"; import { buildPluginChrome } from "./chrome.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts"; import type { NavNode } from "./nav.ts";
import type { Plugin } from "./plugin.ts"; import type { Plugin } from "../plugin-host/plugin.ts";
const scheduling: Plugin = { const scheduling: Plugin = {
apiVersion: "1.0.0", apiVersion: "1.0.0",
+3 -3
View File
@@ -4,11 +4,11 @@
// 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
// through composeNav (override + per-user filter) and current-marked for the request path. // through composeNav (override + per-user filter) and current-marked for the request path.
import { adminSection, DASHBOARD_NAV } from "./admin-nav.ts"; import { adminSection, DASHBOARD_NAV } from "../admin/admin-nav.ts";
import type { User } from "./context.ts"; import type { User } from "../http/context.ts";
import { type MenuConfig } from "./menu-config.ts"; import { type MenuConfig } from "./menu-config.ts";
import { composeNav, type NavNode } from "./nav.ts"; import { composeNav, type NavNode } from "./nav.ts";
import type { Plugin } from "./plugin.ts"; import type { Plugin } from "../plugin-host/plugin.ts";
import { shellUser, type ShellUser } from "./shell-context.ts"; import { shellUser, type ShellUser } from "./shell-context.ts";
export interface PageChrome { export interface PageChrome {
+1 -1
View File
@@ -4,7 +4,7 @@
// 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
// once per request by the host, so the dashboard shows the exact same menu as every other page. // once per request by the host, so the dashboard shows the exact same menu as every other page.
import type { User } from "./context.ts"; import type { User } from "../http/context.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts"; import type { NavNode } from "./nav.ts";
import { buildShellContext } from "./shell-context.ts"; import { buildShellContext } from "./shell-context.ts";
@@ -4,7 +4,7 @@ import { test } from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import * as ejs from "ejs"; import * as ejs from "ejs";
const dataTable = join(dirname(fileURLToPath(import.meta.url)), "..", "views", "partials", "data-table.ejs"); const dataTable = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "data-table.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(dataTable, data); const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(dataTable, data);
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim(); const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
+1 -1
View File
@@ -4,7 +4,7 @@ import { test } from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import * as ejs from "ejs"; import * as ejs from "ejs";
const field = join(dirname(fileURLToPath(import.meta.url)), "..", "views", "partials", "field.ejs"); const field = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "field.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(field, data); const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(field, data);
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim(); const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
@@ -4,7 +4,7 @@ import { test } from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import * as ejs from "ejs"; import * as ejs from "ejs";
const filterBar = join(dirname(fileURLToPath(import.meta.url)), "..", "views", "partials", "filter-bar.ejs"); const filterBar = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "filter-bar.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(filterBar, data); const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(filterBar, data);
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim(); const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
+1 -1
View File
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
import * as ejs from "ejs"; import * as ejs from "ejs";
import { ICON_NAMES, buildIconSprite } from "./icons.ts"; import { ICON_NAMES, buildIconSprite } from "./icons.ts";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), ".."); const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const lucideDir = join(rootDir, "node_modules", "lucide-static", "icons"); const lucideDir = join(rootDir, "node_modules", "lucide-static", "icons");
const partial = join(rootDir, "views", "partials", "icons.ejs"); const partial = join(rootDir, "views", "partials", "icons.ejs");
+1 -1
View File
@@ -50,7 +50,7 @@ export function buildIconSprite(iconsDir: string): string {
([id, name]) => ` <symbol id="${id}" viewBox="0 0 24 24">${inner(readFileSync(join(iconsDir, `${name}.svg`), "utf8"))}</symbol>`, ([id, name]) => ` <symbol id="${id}" viewBox="0 0 24 24">${inner(readFileSync(join(iconsDir, `${name}.svg`), "utf8"))}</symbol>`,
); );
return [ return [
"<%# Generated from lucide-static by src/icons.ts — regenerate on dep bump (guarded by icons.test.ts). %>", "<%# Generated from lucide-static by src/ui/icons.ts — regenerate on dep bump (guarded by icons.test.ts). %>",
'<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">', '<svg width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">',
...symbols, ...symbols,
"</svg>", "</svg>",
+1 -1
View File
@@ -32,7 +32,7 @@ export interface MenuConfigInput {
export const DEFAULT_BRANDING: Branding = { name: "Plainpages", sub: "Console" }; export const DEFAULT_BRANDING: Branding = { name: "Plainpages", sub: "Console" };
export const DEFAULT_MENU: MenuConfig = { branding: DEFAULT_BRANDING, override: {} }; export const DEFAULT_MENU: MenuConfig = { branding: DEFAULT_BRANDING, override: {} };
const rootDir = join(dirname(fileURLToPath(import.meta.url)), ".."); const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
export const MENU_CONFIG_FILE = join(rootDir, "config", "menu.ts"); export const MENU_CONFIG_FILE = join(rootDir, "config", "menu.ts");
// Identity helper: types the authored config, returns it unchanged (mirrors definePlugin). // Identity helper: types the authored config, returns it unchanged (mirrors definePlugin).
+1 -1
View File
@@ -4,7 +4,7 @@ import { test } from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import * as ejs from "ejs"; import * as ejs from "ejs";
const menu = join(dirname(fileURLToPath(import.meta.url)), "..", "views", "partials", "menu.ejs"); const menu = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "menu.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(menu, data); const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(menu, data);
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim(); const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
@@ -4,7 +4,7 @@ import { test } from "node:test";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import * as ejs from "ejs"; import * as ejs from "ejs";
const navTree = join(dirname(fileURLToPath(import.meta.url)), "..", "views", "partials", "nav-tree.ejs"); const navTree = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "nav-tree.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(navTree, data); const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(navTree, data);
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim(); const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
View File

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