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
them. Revisit only if the stated reason stops holding.
- **`src/` is flat on purpose.** The functional-core / imperative-shell split is
conceptual, not a directory layout; splitting into `core/`/`shell/` dirs was judged
premature for a scaffold this size. Revisit only if the flat tree stops being readable.
- **`src/` is grouped by concern**, not flat — `http/` (request pipeline), `auth/`
(session-JWT hot path, guards, and the Ory REST clients), `admin/` (built-in screens),
`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
base request context. It protects the I/O-free hot path on the public, bot-hit landing
(`/`). (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`:
```ts
import { definePlugin } from "../../src/plugin-api.ts";
import { definePlugin } from "../../src/plugin-host/plugin-api.ts";
export default definePlugin({
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,
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
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.
@@ -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:
```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";
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
// 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: [
{
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.
> Discovery — scanning `plugins/`, importing each `plugin.ts` default export, and
> validating it (id, `apiVersion`, conflicts) — runs at boot (`src/discovery.ts`); a bad
> plugin stops startup with a precise message. The router (`src/router.ts`) then mounts
> 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/plugin-host/router.ts`) then mounts
> each route at `/<id>`, resolves `:name` params, runs the permission gate, and turns the
> 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>/`
> (`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.
## 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:
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
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.
@@ -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
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
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
@@ -350,7 +350,7 @@ set of reusable EJS partials + TS helpers, fully styled and zero-JS:
pagination, form fields, badges, menus, auth cards.
- **Helpers:** `composeNav` (menu from config), `parseListQuery`
(`?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
JWT-claim check, zero I/O), `check(relation, object)` (the one live Keto call, for
relationship rules).
@@ -541,7 +541,7 @@ users** on modest hardware. In return:
### 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
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
@@ -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
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
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
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
@@ -600,7 +600,7 @@ resumes to Hydra's post-logout redirect — the first-party `POST /logout` still
the Kratos session + our JWT cookie.
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
(PKCE) and the first-party auto-consent flag are set at registration; writes go only to
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
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
scripts, so an injected `<script>` can't run), `X-Content-Type-Options: nosniff`,
`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 —
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
unreachable on the sign-in path itself, the user gets an honest **503** ("sign-in is
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:
```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:
@@ -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:
```bash
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
```
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:
```bash
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
```
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`):
```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
```
@@ -879,52 +879,65 @@ unnecessary here — the signature itself is already invalid.
## Project layout
```
src/server.ts Entry point — starts the HTTP server (reads PORT, default 3000)
src/app.ts Request routing + EJS rendering (incl. the themed Kratos self-service routes)
src/static.ts Static file serving (path-traversal protection) + routePublic(): /public/<id>/ → a plugin's public/
src/jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK)
src/jwt-middleware.ts resolveSession()/authenticate(): per-request session-JWT verify — key by kid → signature → exp/nbf/iss/aud (clock skew) → ctx.user/roles; flags a lapsed token for re-mint
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
src/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection)
src/keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API)
src/hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD
src/fetch-timeout.ts withTimeout(): bound every outbound Ory call — wrap the injected fetch so each request aborts after a deadline unless the caller passed its own signal; server.ts wires it into the Kratos/Keto/Hydra clients
src/oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login
src/oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes
src/flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs
src/login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie
src/gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS; see JWT signing key & rotation
src/bootstrap.ts One-command bootstrap: idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto
src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies)
src/csrf.ts CSRF for our own POST forms: signed double-submit token — issue/verify, cookie, request gate
src/denylist.ts Optional instant-revoke denylist: in-memory, auto-evicting; hot path rejects a revoked subject's pre-revoke tokens (REVOCATION_DENYLIST)
src/security-headers.ts Response security headers set on every reply: strict CSP (zero-JS), nosniff, X-Frame-Options/frame-ancestors, Referrer-Policy, HSTS over https
src/safe-url.ts safeUrl() (sanitise an untrusted href/src to relative-or-http(s), exposed to plugins) + localPath() (host-relative redirect-allowlist guard for return_to)
src/logger.ts createLogger()/requestLogger() + the ambient request log (runWithLog/currentLog) and tracedFetch: structured logger (service.name) + per-request trace span on @larvit/log; every outbound fetch joins the trace; OTLP export when OTLP_ENDPOINT set
src/body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + forms)
src/context.ts RequestContext handed to handlers + buildContext()
src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot
src/dashboard.ts buildDashboardModel(): the gated "/dashboard" app home — a short instructional starter (replace it with a plugin `dashboard` handler); "/" is the public landing (a plugin `home` handler). Both render the one unified menu (ctx.chrome)
src/admin-users.ts Built-in Users admin screen: list Kratos identities (filter/sort/paginate) + create/edit/deactivate/delete/recovery; gated + CSRF-guarded
src/admin-groups.ts Built-in Groups admin screen: list Keto subject sets + create/delete + membership (add/remove users & nested groups); writes only to Keto, gated + CSRF-guarded
src/admin-roles.ts Built-in Roles admin screen: list/create/delete Keto roles + assign to users/groups + "effective access" (Keto expand → transitive members); reuses the Groups membership helpers, writes only to Keto, gated + CSRF-guarded
src/admin-clients.ts Built-in OAuth2 clients admin screen: list/register/delete Hydra OAuth2 clients (apps that log in through us); register shows the one-time client_secret; writes only to Hydra, gated + CSRF-guarded
src/admin-nav.ts adminSection(): the permission-gated "Admin" menu section (Users · Groups · Roles · OAuth2 clients), wired into the global dashboard menu + the in-screen admin nav (adminNav) so they can't drift
src/shell-context.ts buildShellContext(): brand/theme/user view-model shared by the dashboard + admin screens (real signed-in user, no demo profile)
src/chrome.ts buildPluginChrome(): the one global menu + brand/user/theme/csrf every page renders the shell from (unified across all pages) — exposed on ctx.chrome
src/icons.ts Used-icon registry + sprite builder from lucide-static (regenerates partials/icons.ejs)
src/list-query.ts parseListQuery(): read a list URL → { q, filters, sort, page, pageSize }
src/nav.ts composeNav(): merge plugin nav fragments + central override, role-filter → nav-tree model
src/paginate.ts paginate(total,page,pageSize): page model (counts, row window, ellipsis sequence) for pagination.ejs
src/plugin.ts Plugin contract: manifest types, definePlugin(), version + conflict rules + fullPath()
src/plugin-api.ts Stable plugin author barrel — the one module a plugin imports (definePlugin, ctx/result types, guards, body/CSRF/list-query helpers)
src/discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud at boot
src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate
src/guards.ts requireSession()/can()/check(): in-handler authorization — the imperative counterpart to the route permission gate; GuardError → 303 /login or 403; check() is the one live Keto "may I?" call
src/hooks.ts runBootHooks()/runRequestHooks()/runResponseHooks(): invoke a plugin's optional lifecycle hooks in discovery order; no sandbox (a throwing hook fails loud), skipped when no plugin declares one
src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials
src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot
src/ Node 24 + TypeScript app — strict tsc, no build step. *.test.ts sit beside their module.
server.ts Entry point — starts the HTTP server (reads PORT, default 3000)
config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot
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
*.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)
http/ Request pipeline + HTTP primitives
app.ts Request routing + EJS rendering (incl. the themed Kratos self-service routes)
context.ts RequestContext handed to handlers + buildContext()
body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + forms)
cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies)
static.ts Static file serving (path-traversal protection) + routePublic(): /public/<id>/ → a plugin's public/
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)
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
auth/ Identity, the session-JWT hot path, guards, and the Ory REST clients
jwt.ts JWS signature verify via node:crypto, no jose (decode + verify a compact JWS against one JWK)
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
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)
gen-jwks.ts generateJwks()/rotateJwks() + CLI (mint · --prepend · --prune): the ES256 session-tokenizer signing JWKS; see JWT signing key & rotation
login.ts completeLogin()/remintSession(): login completion + TTL re-mint — roles from Keto → metadata_public projection → tokenize → session JWT cookie
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
csrf.ts CSRF for our own POST forms: signed double-submit token — issue/verify, cookie, request gate
denylist.ts Optional instant-revoke denylist: in-memory, auto-evicting; hot path rejects a revoked subject's pre-revoke tokens (REVOCATION_DENYLIST)
flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs
oauth-login.ts resolveLoginChallenge(): authenticate a Hydra login challenge via the Kratos session → accept, or bounce to /login
oauth-consent.ts resolveConsentChallenge()/acceptConsent()/rejectConsent(): auto-accept first-party, else show the consent screen → grant scopes
bootstrap.ts One-command bootstrap: idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto
kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, browser logout, whoami, session→JWT tokenize
kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_public update (login role projection)
keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API)
hydra-admin.ts createHydraAdmin(): Hydra admin-API fetch client — OAuth2 login + consent challenge get/accept/reject + OAuth2 client CRUD
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
admin/ Built-in admin screens — the only domain screens plainpages ships
admin-users.ts Users: list Kratos identities (filter/sort/paginate) + create/edit/deactivate/delete/recovery; gated + CSRF-guarded
admin-groups.ts Groups: list Keto subject sets + create/delete + membership (add/remove users & nested groups); writes only to Keto, gated + CSRF-guarded
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
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
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
plugin-host/ Plugin discovery, routing, hooks, view resolution + the stable author barrel
plugin.ts Plugin contract: manifest types, definePlugin(), version + conflict rules + fullPath()
plugin-api.ts Stable plugin author barrel — the one module a plugin imports (definePlugin, ctx/result types, guards, body/CSRF/list-query helpers)
discovery.ts discoverPlugins(): scan plugins/, import + validate each plugin.ts default export, fail loud 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)
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)
+2 -2
View File
@@ -117,7 +117,7 @@ services:
retries: 20
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
# read-write (the only writer) so the absent-JWKS safety net can land the key.
bootstrap:
@@ -139,7 +139,7 @@ services:
KRATOS_ADMIN_URL: http://kratos:4434
volumes:
- ./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
# error must give up, not loop forever and hang `web` (gates on completion).
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
// nodes (by their `id`) across all plugins — the override always wins, applied before the
// per-user permission filter. Every field is optional; delete one to fall back to the default.
// See src/menu-config.ts (types), src/nav.ts (NavOverride), docs/plugin-contract.md.
// 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({
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
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
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.
> **Status.** This is the contract the host implements. The types and pure rules
> (`checkApiVersion`, `findConflicts`, `isValidPluginId`) live in `src/plugin.ts`; **discovery**
> (`src/discovery.ts`), the **router** (`src/router.ts` — method+path match, `:name` params,
> (`checkApiVersion`, `findConflicts`, `isValidPluginId`) live in `src/plugin-host/plugin.ts`; **discovery**
> (`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**
> (`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
> `public/`, `routePublic` in `src/static.ts`), and the **central menu override + branding**
> (`config/menu.ts`, loaded by `src/menu-config.ts`, with branding — name, logo, default theme —
> `public/`, `routePublic` in `src/http/static.ts`), and the **central menu override + branding**
> (`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.
> 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.
@@ -56,20 +56,20 @@ Nothing else references it; the operator stays in control through the central me
## 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
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.
```ts
import { definePlugin } from "../../src/plugin-api.ts";
import { definePlugin } from "../../src/plugin-host/plugin-api.ts";
import { listShifts, createShift } from "./shifts.ts";
export default definePlugin({
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.
// `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: [{
icon: "i-cal", id: "scheduling:root", label: "Scheduling",
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). |
| `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). |
| `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)). |
| `routes` | no | See [Routes & handlers](#routes--handlers). |
| `hooks` | no | See [Hooks](#hooks). |
@@ -136,7 +136,7 @@ type RouteResult =
```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) {
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()`
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
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`.
- **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
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
@@ -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
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
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 `"#"`:
```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) })) } };
```
@@ -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. |
```ts
import { definePlugin } from "../../src/plugin-api.ts";
import { definePlugin } from "../../src/plugin-host/plugin-api.ts";
import { landing, board } from "./pages.ts";
export default definePlugin({
@@ -220,7 +220,7 @@ route at all (route paths always carry the `/<id>` prefix).
## 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:
```ts
@@ -275,12 +275,12 @@ from the JWT middleware and are `null`/`[]` until a session exists.
## 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
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
**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
@@ -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,
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
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 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> =>
page.screenshot({ fullPage: true, path: `${SHOTS}/${name}.png` });
+1 -1
View File
@@ -9,7 +9,7 @@
"scripts": {
"start": "node 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",
"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
// 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";
// 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 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.
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 {
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
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).
// 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 SHIFTS_PATH = "/scheduling/shifts";
@@ -8,14 +8,14 @@
import { ADMIN_CLIENTS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
import { safeDecode } from "./admin-groups.ts";
import type { FieldConfig } from "./admin-users.ts";
import type { RequestContext, User } from "./context.ts";
import { HydraError, type HydraAdmin, type OAuth2Client } from "./hydra-admin.ts";
import { parseListQuery } from "./list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts";
import { paginate } from "./paginate.ts";
import type { RouteResult } from "./plugin.ts";
import { buildShellContext } from "./shell-context.ts";
import type { RequestContext, User } from "../http/context.ts";
import { HydraError, type HydraAdmin, type OAuth2Client } from "../auth/hydra-admin.ts";
import { parseListQuery } from "../ui/list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import type { NavNode } from "../ui/nav.ts";
import { paginate } from "../ui/paginate.ts";
import type { RouteResult } from "../plugin-host/plugin.ts";
import { buildShellContext } from "../ui/shell-context.ts";
const DEFAULT_PAGE_SIZE = 25;
const PAGE_SIZES = [25, 50, 100];
@@ -14,7 +14,7 @@ import {
memberView,
parseSubject,
} 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 userTuple = (group: string, n: number): RelationTuple =>
@@ -8,15 +8,15 @@
import { ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
import type { FieldConfig } from "./admin-users.ts";
import type { RequestContext, User } from "./context.ts";
import type { KetoClient, RelationQuery, RelationTuple, SubjectSet } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts";
import { parseListQuery } from "./list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts";
import { paginate } from "./paginate.ts";
import type { RouteResult } from "./plugin.ts";
import { buildShellContext } from "./shell-context.ts";
import type { RequestContext, User } from "../http/context.ts";
import type { KetoClient, RelationQuery, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
import type { KratosAdmin } from "../auth/kratos-admin.ts";
import { parseListQuery } from "../ui/list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import type { NavNode } from "../ui/nav.ts";
import { paginate } from "../ui/paginate.ts";
import type { RouteResult } from "../plugin-host/plugin.ts";
import { buildShellContext } from "../ui/shell-context.ts";
const GROUP_NS = "Group";
const MEMBERS = "members";
@@ -8,10 +8,10 @@ import { test } from "node:test";
import {
ADMIN_PERMISSION, ADMIN_USERS_BASE, adminSection, buildConfirmModel, guardedForm, requireAdmin,
} from "./admin-nav.ts";
import { buildContext, type RequestContext, type User } from "./context.ts";
import { CSRF_COOKIE, CSRF_FIELD, issueCsrfToken } from "./csrf.ts";
import { GuardError } from "./guards.ts";
import { DEFAULT_MENU } from "./menu-config.ts";
import { buildContext, type RequestContext, type User } from "../http/context.ts";
import { CSRF_COOKIE, CSRF_FIELD, issueCsrfToken } from "../auth/csrf.ts";
import { GuardError } from "../auth/guards.ts";
import { DEFAULT_MENU } from "../ui/menu-config.ts";
const admin: User = { email: "ada@x.io", id: "u1", roles: ["admin"] };
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
// separate admin sidebar to drift.
import { readFormBody } from "./body.ts";
import type { RequestContext, User } from "./context.ts";
import { CSRF_FIELD, verifyCsrfRequest } from "./csrf.ts";
import { GuardError, loginRedirect } from "./guards.ts";
import { type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts";
import { buildShellContext } from "./shell-context.ts";
import { readFormBody } from "../http/body.ts";
import type { RequestContext, User } from "../http/context.ts";
import { CSRF_FIELD, verifyCsrfRequest } from "../auth/csrf.ts";
import { GuardError, loginRedirect } from "../auth/guards.ts";
import { type MenuConfig } from "../ui/menu-config.ts";
import type { NavNode } from "../ui/nav.ts";
import { buildShellContext } from "../ui/shell-context.ts";
export const ADMIN_PERMISSION = "admin"; // role token gating the admin section
export const ADMIN_USERS_BASE = "/admin/users";
@@ -14,7 +14,7 @@ import {
isValidRoleName,
roleMemberTuple,
} 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 userTuple = (role: string, n: number): RelationTuple =>
@@ -22,15 +22,15 @@ import {
safeDecode,
} from "./admin-groups.ts";
import type { FieldConfig } from "./admin-users.ts";
import type { RequestContext, User } from "./context.ts";
import type { ExpandTree, KetoClient, RelationTuple } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts";
import { parseListQuery } from "./list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts";
import { paginate } from "./paginate.ts";
import type { RouteResult } from "./plugin.ts";
import { buildShellContext } from "./shell-context.ts";
import type { RequestContext, User } from "../http/context.ts";
import type { ExpandTree, KetoClient, RelationTuple } from "../auth/keto-client.ts";
import type { KratosAdmin } from "../auth/kratos-admin.ts";
import { parseListQuery } from "../ui/list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import type { NavNode } from "../ui/nav.ts";
import { paginate } from "../ui/paginate.ts";
import type { RouteResult } from "../plugin-host/plugin.ts";
import { buildShellContext } from "../ui/shell-context.ts";
const ROLE_NS = "Role";
const MEMBERS = "members";
@@ -10,7 +10,7 @@ import {
toUserView,
updateIdentityPayload,
} 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 identity = (n: number, over: Partial<Identity> = {}): Identity => ({
@@ -6,15 +6,15 @@
import { safeDecode } from "./admin-groups.ts";
import { ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-nav.ts";
import type { RequestContext, User } from "./context.ts";
import type { Identity, KratosAdmin, RecoveryCode } from "./kratos-admin.ts";
import { KratosError } from "./kratos-public.ts";
import { parseListQuery } from "./list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts";
import { paginate } from "./paginate.ts";
import type { RouteResult } from "./plugin.ts";
import { buildShellContext } from "./shell-context.ts";
import type { RequestContext, User } from "../http/context.ts";
import type { Identity, KratosAdmin, RecoveryCode } from "../auth/kratos-admin.ts";
import { KratosError } from "../auth/kratos-public.ts";
import { parseListQuery } from "../ui/list-query.ts";
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import type { NavNode } from "../ui/nav.ts";
import { paginate } from "../ui/paginate.ts";
import type { RouteResult } from "../plugin-host/plugin.ts";
import { buildShellContext } from "../ui/shell-context.ts";
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
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.
import { existsSync, writeFileSync } from "node:fs";
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 { createLogger, runWithLog, tracedFetch } from "./logger.ts";
import { createLogger, runWithLog, tracedFetch } from "../logger.ts";
// --- 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
// carry Kratos' CSRF token — this guards only the routes we handle.
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_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
// (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
// 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.
@@ -1,6 +1,6 @@
// Guards the session-tokenizer signing key: generateJwks() emits a fresh ES256
// EC private signing key, the committed dev JWKS is a valid such key, and a token signed
// with it verifies through our own verifier (src/jwt.ts) — so what Kratos signs, reads.
// with it verifies through our own verifier (src/auth/jwt.ts) — so what Kratos signs, reads.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
@@ -9,7 +9,7 @@ import { generateJwks, rotateJwks } from "./gen-jwks.ts";
import { verifyJws } from "./jwt.ts";
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", () => {
const a = generateJwks();
+1 -1
View File
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
// 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):
// 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)
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { IncomingMessage, ServerResponse } from "node:http";
import { Socket } from "node:net";
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 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
// 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.
import type { RequestContext, User } from "./context.ts";
import type { RequestContext, User } from "../http/context.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
// `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";
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 () => {
const [a, b] = [jwk("k1"), jwk("k2")];
+1 -1
View File
@@ -1,7 +1,7 @@
import type { JsonWebKey } from "node:crypto";
import { readFileSync } from "node:fs";
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
// `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 —
// 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
// (anonymous), so the route renders signed-out and the permission gate denies.
import type { User } from "./context.ts";
import { parseCookies } from "./cookie.ts";
import type { User } from "../http/context.ts";
import { parseCookies } from "../http/cookie.ts";
import type { Denylist } from "./denylist.ts";
import { decodeJws, verifyJws } from "./jwt.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
// Order matters: the projection is written before tokenizing, because the claims mapper
// reads only the identity, never Keto.
import type { User } from "./context.ts";
import { serializeCookie, type CookieOptions } from "./cookie.ts";
import { currentLog } from "./logger.ts";
import type { User } from "../http/context.ts";
import { serializeCookie, type CookieOptions } from "../http/cookie.ts";
import { currentLog } from "../logger.ts";
import type { KetoClient } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.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 +
// JWKS, then exits; web waits for it to complete. Live seeding is boot-verified.
const boot = compose.slice(compose.indexOf("\n bootstrap:"));
assert.match(boot, /node src\/bootstrap\.ts/, "bootstrap runs the seed script");
assert.match(boot, /node src\/auth\/bootstrap\.ts/, "bootstrap runs the seed script");
for (const svc of ["kratos", "keto"])
assert.match(boot, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_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 { createApp, type AppOptions } from "./app.ts";
import { readFormBody } from "./body.ts";
import { createLogger } from "./logger.ts";
import { createDenylist } from "./denylist.ts";
import { CSRF_COOKIE, issueCsrfToken } from "./csrf.ts";
import { can, check, GuardError, requireSession } from "./guards.ts";
import { HydraError, type HydraAdmin, type OAuth2Client } from "./hydra-admin.ts";
import { staticJwks } from "./jwks.ts";
import type { ExpandTree, KetoClient, RelationTuple, SubjectSet } from "./keto-client.ts";
import type { Identity, KratosAdmin } from "./kratos-admin.ts";
import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "./kratos-public.ts";
import { SESSION_COOKIE } from "./login.ts";
import type { Plugin } from "./plugin.ts";
import { createLogger } from "../logger.ts";
import { createDenylist } from "../auth/denylist.ts";
import { CSRF_COOKIE, issueCsrfToken } from "../auth/csrf.ts";
import { can, check, GuardError, requireSession } from "../auth/guards.ts";
import { HydraError, type HydraAdmin, type OAuth2Client } from "../auth/hydra-admin.ts";
import { staticJwks } from "../auth/jwks.ts";
import type { ExpandTree, KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
import type { Identity, KratosAdmin } from "../auth/kratos-admin.ts";
import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts";
import { SESSION_COOKIE } from "../auth/login.ts";
import type { Plugin } from "../plugin-host/plugin.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
// `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 { fileURLToPath } from "node:url";
import * as ejs from "ejs";
import { ADMIN_CLIENTS_BASE, ADMIN_GROUPS_BASE, ADMIN_ROLES_BASE, ADMIN_USERS_BASE } from "./admin-nav.ts";
import { type AdminClientsDeps, handleAdminClients } from "./admin-clients.ts";
import { type AdminGroupsDeps, handleAdminGroups } from "./admin-groups.ts";
import { type AdminRolesDeps, handleAdminRoles } from "./admin-roles.ts";
import { type AdminUsersDeps, handleAdminUsers } from "./admin-users.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/admin-clients.ts";
import { type AdminGroupsDeps, handleAdminGroups } from "../admin/admin-groups.ts";
import { type AdminRolesDeps, handleAdminRoles } from "../admin/admin-roles.ts";
import { type AdminUsersDeps, handleAdminUsers } from "../admin/admin-users.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 { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "./csrf.ts";
import type { Denylist } from "./denylist.ts";
import { buildDashboardModel } from "./dashboard.ts";
import { PLUGINS_DIR } from "./discovery.ts";
import { GuardError, loginRedirect } from "./guards.ts";
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
import { runRequestHooks, runResponseHooks } from "./hooks.ts";
import { HydraError, type HydraAdmin } from "./hydra-admin.ts";
import type { JwksProvider } from "./jwks.ts";
import { resolveSession, type VerifyOptions } from "./jwt-middleware.ts";
import type { KetoClient } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts";
import { type Flow, KratosError, type KratosPublic } from "./kratos-public.ts";
import { createLogger, type Log, requestLogger, runWithLog } from "./logger.ts";
import { clearSessionCookie, completeLogin, remintSession, sessionCookie } from "./login.ts";
import { resolveLoginChallenge } from "./oauth-login.ts";
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "./oauth-consent.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { Plugin, RouteHandler, RouteResult } from "./plugin.ts";
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
import { CSRF_FIELD, csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
import type { Denylist } from "../auth/denylist.ts";
import { buildDashboardModel } from "../ui/dashboard.ts";
import { PLUGINS_DIR } from "../plugin-host/discovery.ts";
import { GuardError, loginRedirect } from "../auth/guards.ts";
import { AUTH_FLOWS, buildFlowView } from "../auth/flow-view.ts";
import { runRequestHooks, runResponseHooks } from "../plugin-host/hooks.ts";
import { HydraError, type HydraAdmin } from "../auth/hydra-admin.ts";
import type { JwksProvider } from "../auth/jwks.ts";
import { resolveSession, type VerifyOptions } from "../auth/jwt-middleware.ts";
import type { KetoClient } from "../auth/keto-client.ts";
import type { KratosAdmin } from "../auth/kratos-admin.ts";
import { type Flow, KratosError, type KratosPublic } from "../auth/kratos-public.ts";
import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts";
import { clearSessionCookie, completeLogin, remintSession, sessionCookie } from "../auth/login.ts";
import { resolveLoginChallenge } from "../auth/oauth-login.ts";
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "../auth/oauth-consent.ts";
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import type { Plugin, RouteHandler, RouteResult } from "../plugin-host/plugin.ts";
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
import { securityHeaders } from "./security-headers.ts";
import { localPath } from "./safe-url.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 {
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 { test } from "node:test";
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.
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 { PageChrome } from "./chrome.ts"; // type-only: no runtime import, so no cycle
import { createLogger, type Log } from "./logger.ts";
import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle
import { createLogger, type Log } from "../logger.ts";
// The request context threaded to every route handler (plugin + built-in), built once
// per request by `buildContext`: the router supplies matched path `params`, the JWT
@@ -9,7 +9,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
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).
export const PLUGINS_DIR = join(rootDir, "plugins");
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
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 { 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
// 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";
// After discovery, before the server listens. A throw aborts boot.
@@ -6,17 +6,17 @@
export { definePlugin } 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 { PageChrome } from "./chrome.ts";
export type { NavNode } from "./nav.ts";
export { can, check, GuardError, requireSession } from "./guards.ts";
export { parseListQuery } from "./list-query.ts";
export { readFormBody } from "./body.ts";
export { CSRF_FIELD } from "./csrf.ts";
export type { RequestContext, User } from "../http/context.ts";
export type { PageChrome } from "../ui/chrome.ts";
export type { NavNode } from "../ui/nav.ts";
export { can, check, GuardError, requireSession } from "../auth/guards.ts";
export { parseListQuery } from "../ui/list-query.ts";
export { readFormBody } from "../http/body.ts";
export { CSRF_FIELD } from "../auth/csrf.ts";
// Sanitise an untrusted URL (upstream/user data) before rendering it in an href/src — partials
// escape text but not URL schemes, so a `javascript:`/`data:` URL would be live XSS (see docs).
export { safeUrl } from "./safe-url.ts";
export { safeUrl } from "../http/safe-url.ts";
// Observability: `ctx.log` (RequestContext) is the request logger; `tracedFetch` is a drop-in
// `fetch` a plugin uses for upstream calls so they join the request's trace (client span + traceparent).
// The `Log` class is exported so a plugin can type/construct one (e.g. `new Log("none")` in a test).
export { Log, tracedFetch } from "./logger.ts";
export { Log, tracedFetch } from "../logger.ts";
@@ -11,8 +11,8 @@ import {
type Plugin,
type PluginManifest,
} from "./plugin.ts";
import { ADMIN_CLIENTS_BASE, ADMIN_GROUPS_BASE, ADMIN_ROLES_BASE, ADMIN_USERS_BASE } from "./admin-nav.ts";
import { AUTH_FLOWS } from "./flow-view.ts";
import { ADMIN_CLIENTS_BASE, ADMIN_GROUPS_BASE, ADMIN_ROLES_BASE, ADMIN_USERS_BASE } from "../admin/admin-nav.ts";
import { AUTH_FLOWS } from "../auth/flow-view.ts";
// 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
+3 -3
View File
@@ -5,8 +5,8 @@
// 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.
import type { RequestContext } from "./context.ts";
import type { NavNode } from "./nav.ts";
import type { RequestContext } from "../http/context.ts";
import type { NavNode } from "../ui/nav.ts";
// 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
@@ -62,7 +62,7 @@ export interface PluginManifest {
// anyone may reach it. At most one plugin may declare it (findConflicts → error).
home?: RouteHandler;
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[];
routes?: Route[];
}
@@ -6,7 +6,7 @@ import { test, type TestContext } from "node:test";
import { fileURLToPath } from "node:url";
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", () => {
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 { createDenylist } from "./denylist.ts";
import { discoverPlugins } from "./discovery.ts";
import { withTimeout } from "./fetch-timeout.ts";
import { runBootHooks } from "./hooks.ts";
import { createHydraAdmin } from "./hydra-admin.ts";
import { createJwksProvider } from "./jwks.ts";
import { createKetoClient } from "./keto-client.ts";
import { createKratosAdmin } from "./kratos-admin.ts";
import { createKratosPublic } from "./kratos-public.ts";
import { createDenylist } from "./auth/denylist.ts";
import { discoverPlugins } from "./plugin-host/discovery.ts";
import { withTimeout } from "./auth/fetch-timeout.ts";
import { runBootHooks } from "./plugin-host/hooks.ts";
import { createHydraAdmin } from "./auth/hydra-admin.ts";
import { createJwksProvider } from "./auth/jwks.ts";
import { createKetoClient } from "./auth/keto-client.ts";
import { createKratosAdmin } from "./auth/kratos-admin.ts";
import { createKratosPublic } from "./auth/kratos-public.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
// 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 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
@@ -4,7 +4,7 @@ import { test } from "node:test";
import { fileURLToPath } from "node:url";
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 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 { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts";
import type { Plugin } from "./plugin.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
const scheduling: Plugin = {
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
// through composeNav (override + per-user filter) and current-marked for the request path.
import { adminSection, DASHBOARD_NAV } from "./admin-nav.ts";
import type { User } from "./context.ts";
import { adminSection, DASHBOARD_NAV } from "../admin/admin-nav.ts";
import type { User } from "../http/context.ts";
import { type MenuConfig } from "./menu-config.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";
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
// 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 type { NavNode } from "./nav.ts";
import { buildShellContext } from "./shell-context.ts";
@@ -4,7 +4,7 @@ import { test } from "node:test";
import { fileURLToPath } from "node:url";
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 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 * 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 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 * 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 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 { 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 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>`,
);
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">',
...symbols,
"</svg>",
+1 -1
View File
@@ -32,7 +32,7 @@ export interface MenuConfigInput {
export const DEFAULT_BRANDING: Branding = { name: "Plainpages", sub: "Console" };
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");
// 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 * 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 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 * 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 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