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
@@ -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
@@ -4,7 +4,7 @@ import { test } from "node:test";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
const pagination = join(dirname(fileURLToPath(import.meta.url)), "..", "views", "partials", "pagination.ejs");
const pagination = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "pagination.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(pagination, data);
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();
@@ -5,7 +5,7 @@
// the profile shows the email's local part as the name with the full email beneath, initials from
// the local part; anonymous ⇒ "Guest".
import type { User } from "./context.ts";
import type { User } from "../http/context.ts";
import { type MenuConfig } from "./menu-config.ts";
export interface ShellUser {
+1 -1
View File
@@ -4,7 +4,7 @@ import { test } from "node:test";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
const shell = join(dirname(fileURLToPath(import.meta.url)), "..", "views", "partials", "shell.ejs");
const shell = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "shell.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(shell, data);
test("app shell renders sidebar, topbar and the content slot", async () => {
@@ -4,7 +4,7 @@ import { test } from "node:test";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
const themeSwitch = join(dirname(fileURLToPath(import.meta.url)), "..", "views", "partials", "theme-switch.ejs");
const themeSwitch = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views", "partials", "theme-switch.ejs");
const render = (data: Record<string, unknown> = {}): Promise<string> => ejs.renderFile(themeSwitch, data);
const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, " ").trim();