Cut non-essential prose from docs and comments, and require the same of every future change
CI / full-gate (push) Successful in 2m38s
CI / full-gate (push) Successful in 2m38s
README loses the competitor comparison, the personas and the repeated philosophy; the
five near-identical E2E command blocks become a table plus one command, and the file
map a clause per entry. AGENTS.md keeps every decision but drops the narrative around
them. todo.md's completed items collapse to their task line — git holds the rest.
Comments lose restatement, README duplication and history ("used to", "originally",
dated notes). AGENTS.md gains a Prose discipline section making this a standing pass on
every change rather than a one-off cleanup.
src/compose.test.ts now expects 6 documented E2E run commands, not 10, since the README
states the command once instead of per suite.
This commit is contained in:
@@ -42,10 +42,9 @@ test("seedPermissions unions ADMIN_PERMISSIONS (empty by default) with the disco
|
||||
assert.deepEqual(names(",, ", [" scheduling:read ", ""]), ["scheduling:read"]); // blanks dropped, names trimmed (both sides)
|
||||
});
|
||||
|
||||
// The regression this pins: an earlier revision *threw* here, so `ADMIN_PERMISSIONS=admin` — this
|
||||
// setting's own default until 2026-08-05 — exited bootstrap 1, and bootstrap gates `web`, so a
|
||||
// leftover variable bricked the whole stack on upgrade. Bootstrap must never refuse to start over
|
||||
// operator env: drop what it can't use, report it, seed the rest.
|
||||
// Bootstrap gates `web`, so it must never refuse to start over operator env — a leftover
|
||||
// ADMIN_PERMISSIONS would otherwise brick the whole stack. Drop what it can't use, report it, seed
|
||||
// the rest.
|
||||
test("seedPermissions drops an ADMIN_PERMISSIONS name that isn't <resource>:<action>, and never throws", () => {
|
||||
const legacy = seedPermissions("admin", ["users:read"]);
|
||||
assert.deepEqual(legacy, { ignored: ["admin"], permissions: ["users:read"] });
|
||||
|
||||
+7
-12
@@ -29,19 +29,14 @@ export function permissionTuple(userId: string, permission: string) {
|
||||
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` };
|
||||
}
|
||||
|
||||
// The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, empty by default)
|
||||
// unioned with every discovered plugin's declared permission names (a route/nav `permission` is a
|
||||
// coarse permission — granted as a Keto `Permission:<name>#granted` tuple). So the host names no plugin, yet a
|
||||
// dropped-in plugin's permissions are seeded out of the box. Deduped, order-stable, blanks dropped.
|
||||
// The base is empty because permissions are `<resource>:<action>` and every one of them is owned by
|
||||
// the plugin that gates on it — a host-invented default would gate nothing.
|
||||
// ADMIN_PERMISSIONS (empty by default) unioned with every discovered plugin's declared names, so
|
||||
// the host names no plugin yet a dropped-in one is seeded out of the box.
|
||||
//
|
||||
// ADMIN_PERMISSIONS is the one place an operator names a permission by hand, so it is held to the
|
||||
// same `<resource>:<action>` rule discovery applies to a manifest — but *dropped with a warning*,
|
||||
// never fatal. Fail-loud belongs at the manifest boundary, where a developer authored the mistake
|
||||
// and can fix it; this is operator env, bootstrap gates `web`, and the whole stack must not refuse
|
||||
// to start over a stale variable. `admin` was this setting's own default before 2026-08-05, so a
|
||||
// value that bricks the boot is the *expected* leftover on any upgrade. The name it would have
|
||||
// written gates nothing anyway. Declared names already passed the check at discovery.
|
||||
// same `<resource>:<action>` rule as a manifest — but *dropped with a warning*, never fatal:
|
||||
// fail-loud belongs at the manifest boundary where a developer authored the mistake, whereas this
|
||||
// is operator env and bootstrap gates `web`, so the whole stack must not refuse to start over a
|
||||
// stale variable. The name it would have written gates nothing anyway.
|
||||
export function seedPermissions(adminPermissionsEnv: string | undefined, declaredNames: string[]): { ignored: string[]; permissions: string[] } {
|
||||
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
|
||||
const configured = clean((adminPermissionsEnv ?? "").split(","));
|
||||
|
||||
+8
-16
@@ -1,20 +1,12 @@
|
||||
// Optional revocation denylist: instant permission/session revoke without putting Keto
|
||||
// back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true.
|
||||
// Optional revocation denylist: instant permission/session revoke without putting Keto back on the
|
||||
// hot path. Off by default — enable with REVOCATION_DENYLIST=true. An admin action records the
|
||||
// subject as revoked-now; the hot path then rejects that subject's pre-revoke tokens at once,
|
||||
// forcing a re-mint (which re-reads permissions from Keto, or clears a now-dead session).
|
||||
//
|
||||
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked permission or a
|
||||
// killed session only takes effect when the token is next minted (re-login / TTL refresh) —
|
||||
// up to one token TTL of lag. For security-critical revoke (offboarding, a compromised
|
||||
// account) that lag is too long. An admin action records the subject as revoked-now and the
|
||||
// hot path then rejects that subject's pre-revoke tokens at once, forcing a re-mint (which
|
||||
// re-reads permissions from Keto, or clears a now-dead session).
|
||||
//
|
||||
// Cost & scope: an in-memory, auto-evicting Map — no database, like the JWKS cache, so it
|
||||
// stays inside the stateless model. A token carries `iat`, so a *fresh* re-login (iat after
|
||||
// the revoke) passes while every token minted before the revoke is rejected. Entries self-evict
|
||||
// after one token TTL, by which point any pre-revoke token has expired anyway. Single-process:
|
||||
// instant on the instance that handled the revoke; across replicas/restarts the guarantee
|
||||
// falls back to the token TTL (the gap is just no longer closed early). Back it with a shared
|
||||
// store for hard multi-instance instant-revoke.
|
||||
// An in-memory, auto-evicting Map — no database, so it stays inside the stateless model. Entries
|
||||
// self-evict after one token TTL, by which point any pre-revoke token has expired anyway.
|
||||
// Single-process: instant on the instance that handled the revoke, elsewhere the guarantee falls
|
||||
// back to the token TTL. Back it with a shared store for hard multi-instance instant-revoke.
|
||||
|
||||
export interface Denylist {
|
||||
// Hot-path check: is a token for `sub`, issued at `iat` (unix sec), revoked? A token minted
|
||||
|
||||
+3
-5
@@ -1,8 +1,6 @@
|
||||
// Auth guards: in-handler authorization, the imperative counterpart to the
|
||||
// declarative route `permission` gate. The middleware already verified the session JWT and put
|
||||
// the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps
|
||||
// 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.
|
||||
// In-handler authorization, the imperative counterpart to the declarative route `permission` gate.
|
||||
// `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, for relationship rules.
|
||||
import type { RequestContext, User } from "../http/context.ts";
|
||||
import type { KetoClient } from "./keto-client.ts";
|
||||
import { localPath } from "../http/safe-url.ts";
|
||||
|
||||
+2
-4
@@ -231,10 +231,8 @@ function logout(kratos: KratosPublic, secureCookies: boolean): BuiltinRoute["han
|
||||
}
|
||||
|
||||
// Kratos' self-service error sink (kratos.yml flows.error.ui_url → /error). A flow that fails a
|
||||
// security/expiry check redirects the browser here with ?id=<uuid>. Render a themed page with a
|
||||
// path back into sign-in instead of the catch-all 404 ("Page not found") it used to hit. The
|
||||
// canonical-host redirect prevents the common cause (a lost cross-host CSRF cookie); this is the
|
||||
// honest fallback for any genuine flow error. The id is shown only for support reference.
|
||||
// security/expiry check redirects the browser here with ?id=<uuid>; render a themed page with a
|
||||
// path back into sign-in rather than the catch-all 404. The id is shown for support reference only.
|
||||
const errorSink = (ctx: RequestContext): RouteResult =>
|
||||
({ data: { id: ctx.url.searchParams.get("id") }, view: "error" });
|
||||
|
||||
|
||||
+1
-1
@@ -113,7 +113,7 @@ test("the E2E runner writes its artifacts as the invoking user, never as root",
|
||||
// filter would otherwise leave that command silently unguarded.
|
||||
const documented = [read("README.md"), ...composeFiles("e2e-tests/").map(read)]
|
||||
.join("\n").split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l));
|
||||
assert.equal(documented.length, 10, "5 compose headers + 5 README blocks");
|
||||
assert.equal(documented.length, 6, "5 compose headers + 1 README block");
|
||||
for (const l of documented)
|
||||
assert.match(l, /--user "\$\(id -u\):\$\(id -g\)"/, `passes the uid: ${l.trim()}`);
|
||||
// An absent mount source is daemon-created as root, and then that uid can't write it at all.
|
||||
|
||||
+3
-9
@@ -1,12 +1,6 @@
|
||||
// Config loaded once from the environment at boot: Ory endpoints, cookie/CSRF
|
||||
// secrets, JWKS location, listen port, behaviour toggles. Fail-loud — a bad value, a
|
||||
// missing enforced secret, a bad URL, or an out-of-range port throws here, never at
|
||||
// request time.
|
||||
//
|
||||
// Environment-agnostic (AGENTS.md): the app never asks "which environment am I?". Every
|
||||
// behaviour that used to ride on NODE_ENV is its own explicit toggle — `CACHE_TEMPLATES`,
|
||||
// `REQUIRE_SECURE_SECRETS`. Clean-clone (README): every value has a working dev default,
|
||||
// so `docker compose up` runs with zero config; a hardened deploy sets the toggles it wants.
|
||||
// Config loaded once from the environment at boot. Fail-loud — a bad value, a missing enforced
|
||||
// secret, a bad URL or an out-of-range port throws here, never at request time. Every value has a
|
||||
// working dev default, so `docker compose up` runs with zero config.
|
||||
|
||||
// Log verbosity, most→least severe; "none" silences everything (matches @larvit/log's levels).
|
||||
export const LOG_LEVELS = ["error", "warn", "info", "verbose", "debug", "silly", "none"] as const;
|
||||
|
||||
+11
-17
@@ -27,15 +27,12 @@ import type { MenuConfig } from "../ui/menu-config.ts";
|
||||
import { loadI18n } from "../i18n/load.ts";
|
||||
|
||||
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
|
||||
// The admin screens ship as a drop-in example plugin; the HTTP-level admin tests mount it via
|
||||
// createApp (with stub Ory clients on ctx.system + views from examples/plugins) exactly as an
|
||||
// operator would after copying it into plugins/.
|
||||
// The HTTP-level admin tests mount the example plugin via createApp — stub Ory clients on
|
||||
// ctx.system, views from examples/plugins — exactly as an operator would after copying it in.
|
||||
const examplesPluginsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "examples", "plugins");
|
||||
const adminPlugin: Plugin = { ...adminManifest, id: "admin" };
|
||||
|
||||
// A session JWT signed with a throwaway test key — the verify path. Wired into the shared
|
||||
// `server` (and the per-test apps) so a request can present a valid session; the dashboard and the
|
||||
// gated routes need one. `staticJwks([ecJwk])` is the matching verify side.
|
||||
// A session JWT signed with a throwaway test key; `staticJwks([ecJwk])` is the matching verify side.
|
||||
const ec = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
||||
const ecJwk: JsonWebKey = { ...(ec.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "test-kid" };
|
||||
const b64url = (i: Buffer | string): string => Buffer.from(i).toString("base64url");
|
||||
@@ -101,8 +98,7 @@ test("plugins replace either landing: `home` owns the public /, `dashboard` owns
|
||||
const dir = mkdtempSync(join(tmpdir(), "pp-home-"));
|
||||
mkdirSync(join(dir, "portal", "views"), { recursive: true });
|
||||
writeFileSync(join(dir, "portal", "views", "welcome.ejs"), `<h1>Welcome to <%= brand %></h1><a href="/login">Sign in</a>`);
|
||||
// The dashboard view renders the native app shell from ctx.chrome — the blessed plugin ergonomics:
|
||||
// its own title/body, the global menu (chrome.nav), the signed-in user, the Sign-out CSRF token.
|
||||
// The dashboard view renders the native app shell from ctx.chrome.
|
||||
writeFileSync(join(dir, "portal", "views", "board.ejs"),
|
||||
`<%- include("partials/shell", { body: "<p>Hi " + user.email + "</p>", brand: chrome.brand, csrfToken: chrome.csrfToken, nav: include("partials/nav-tree", { nodes: chrome.nav }), theme: chrome.theme, title: "My Portal", user: chrome.user }) %>`);
|
||||
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||
@@ -326,9 +322,8 @@ function rawGet(port: number, path: string, host: string, method = "GET"): Promi
|
||||
}
|
||||
|
||||
test("APP_URL canonical-host redirect: an off-host visitor is 308'd to the configured origin (path+query kept)", async (t) => {
|
||||
// The fix for the localhost-vs-127.0.0.1 / multi-domain trap: reach the app on any host and it
|
||||
// sends you to APP_URL's host, so the browser, the themed form, and the cross-origin Kratos POST
|
||||
// all share ONE cookie host. Off-canonical only — same-host requests pass straight through.
|
||||
// Reach the app on any host and it sends you to APP_URL's, so the browser, the themed form and the
|
||||
// cross-origin Kratos POST share ONE cookie host. Same-host requests pass straight through.
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), appUrl: "http://canonical.example:3000" });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
@@ -359,8 +354,8 @@ test("no APP_URL configured ⇒ no canonical redirect (unit-test apps and host-a
|
||||
});
|
||||
|
||||
test("/error renders a themed sign-in error page (Kratos' flow error sink), not the 404", async () => {
|
||||
// Kratos' flows.error.ui_url points here; a flow error redirects to /error?id=<uuid>. Without a
|
||||
// handler it 404'd as "Page not found" (confusing). It must be a real, themed page now.
|
||||
// Kratos' flows.error.ui_url points here; a flow error redirects to /error?id=<uuid>, which must
|
||||
// land on a real themed page rather than the catch-all 404.
|
||||
const res = await fetch(base + `/error?id=${randomUUID()}`, { redirect: "manual" });
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
|
||||
@@ -1257,9 +1252,8 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
|
||||
assert.equal((await get("/admin/groups/%ZZ")).status, 404);
|
||||
});
|
||||
|
||||
// Granting permissions over HTTP, on the two screens that replaced the deleted Permissions screen.
|
||||
// The offered set is the host's catalog (ctx.declaredPermissions, from what the installed plugins
|
||||
// declare), so the checkboxes are a fixed list and the POST is the desired state.
|
||||
// Granting permissions over HTTP. The offered set is the host's catalog (ctx.declaredPermissions),
|
||||
// so the checkboxes are a fixed list and the POST is the desired state.
|
||||
test("admin permission grants: the picker offers the declared catalog, and a save is the desired set", async (t) => {
|
||||
const ada = randomUUID();
|
||||
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||
@@ -1348,7 +1342,7 @@ test("admin screens render no write affordance for a read-only holder", async (t
|
||||
assert.doesNotMatch(group, /Delete group/);
|
||||
assert.doesNotMatch(group, /Save permissions/);
|
||||
|
||||
// The OAuth2-clients screen is held to the same rule (it was the one this test was written to catch).
|
||||
// The OAuth2-clients screen is held to the same rule.
|
||||
const clientsRes = await get("/admin/clients", ["oauth2-clients:read"]);
|
||||
assert.equal(clientsRes.status, 200); // a real render, not the capability-missing 503
|
||||
const clients = await clientsRes.text();
|
||||
|
||||
+43
-96
@@ -39,15 +39,11 @@ 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
|
||||
auth?: VerifyOptions; // expected JWT issuer/audience + clock skew (config); used with jwks
|
||||
// Cache compiled templates; caller decides (server passes config.cacheTemplates).
|
||||
// Off by default so edits show live; the app itself never inspects the environment.
|
||||
cache?: boolean;
|
||||
cache?: boolean; // cache compiled EJS templates (config.cacheTemplates); off ⇒ edits show live
|
||||
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
|
||||
denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes
|
||||
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
|
||||
// Loaded translation catalogs (server.ts passes the discovered ones). Omitted ⇒ the built-in
|
||||
// en-US catalog only, so an unwired app still renders real English.
|
||||
i18n?: I18n;
|
||||
i18n?: I18n; // discovered catalogs; omitted ⇒ the built-in en-US only, so an unwired app still renders English
|
||||
jwks?: JwksProvider; // verify the session JWT → ctx.user/permissions; absent ⇒ always anonymous
|
||||
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
|
||||
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
|
||||
@@ -62,15 +58,12 @@ export interface AppOptions {
|
||||
}
|
||||
|
||||
export function createApp(options: AppOptions = {}): Server {
|
||||
// The denylist (when enabled) rides in the verify options so resolveSession rejects a revoked
|
||||
// subject on the hot path; the bound `revoke` is handed to the admin handlers that should
|
||||
// revoke instantly. Both absent ⇒ the feature is fully off (no cost, no behaviour change).
|
||||
// The denylist rides in the verify options so resolveSession rejects a revoked subject on the hot
|
||||
// path; the bound `revoke` goes to the admin handlers. Both absent ⇒ the feature is fully off.
|
||||
const denylist = options.denylist;
|
||||
const authOptions: VerifyOptions = denylist ? { ...(options.auth ?? {}), denylist } : (options.auth ?? {});
|
||||
const revoke = denylist ? (sub: string): void => denylist.revoke(sub) : undefined;
|
||||
const cache = options.cache ?? false;
|
||||
// Canonical public host (APP_URL): when set, an off-host GET/HEAD visitor is redirected here so
|
||||
// every cookie (esp. Kratos' cross-origin CSRF cookie) shares one host. Omitted ⇒ feature off.
|
||||
const canonical = options.appUrl ? new URL(options.appUrl) : undefined;
|
||||
const canonicalHost = canonical?.host; // host[:port], default ports omitted — matches the Host header
|
||||
const canonicalOrigin = canonical?.origin; // scheme + host[:port], no trailing slash
|
||||
@@ -82,9 +75,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
const keto = options.keto;
|
||||
const kratos = options.kratos;
|
||||
const kratosAdmin = options.kratosAdmin;
|
||||
// Privileged host services handed to a system plugin via ctx.system — the Ory admin clients and
|
||||
// the instant-revoke hook. Only the wired capabilities are present; with none wired ctx.system
|
||||
// stays undefined, so an ordinary deployment (no Ory, hence no system plugin) pays nothing.
|
||||
// Only the wired capabilities are present; with none wired ctx.system stays undefined.
|
||||
const system: SystemCapabilities | undefined = kratosAdmin || keto || hydra || revoke
|
||||
? { ...(hydra ? { hydra } : {}), ...(keto ? { keto } : {}), ...(kratosAdmin ? { kratosAdmin } : {}), ...(revoke ? { revoke } : {}) }
|
||||
: undefined;
|
||||
@@ -93,15 +84,11 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
const menu = options.menu ?? DEFAULT_MENU;
|
||||
const plugins = options.plugins ?? [];
|
||||
const pluginIds = new Set(plugins.map((p) => p.id));
|
||||
// A plugin may fully replace the public landing "/" (`home`) or the gated dashboard "/dashboard"
|
||||
// (`dashboard`) — Discovery's findConflicts guarantees at most one of each, so `find` is
|
||||
// unambiguous; the predicates narrow the slot to defined.
|
||||
// `find` is unambiguous: findConflicts guarantees at most one owner of each landing slot.
|
||||
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function");
|
||||
const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function");
|
||||
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
|
||||
// The permission catalog is a property of the installed plugin set, so it is computed once at
|
||||
// wiring rather than per request.
|
||||
const permissionCatalog = declaredPermissions(plugins);
|
||||
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
|
||||
const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
|
||||
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
|
||||
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
||||
@@ -115,19 +102,11 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
const render = (view: string, data: Record<string, unknown>): Promise<string> =>
|
||||
ejs.renderFile(join(viewsDir, `${view}.ejs`), data, { cache, views: [viewsDir] });
|
||||
|
||||
// A `view` RouteResult renders plugins/<id>/views/<view>.ejs; such views may include() the core
|
||||
// building-block partials (resolved from viewsDir) and their own partials/subfolders.
|
||||
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
|
||||
|
||||
// Every view renders with its context's i18n locals (t/locale/dir/localeSwitch/localeParam) merged
|
||||
// in, so a view — core or plugin, at any include depth — calls `t(...)` without its handler passing
|
||||
// it. A plugin's context carries that plugin's translator, so its own catalog wins in its own views.
|
||||
// They are merged LAST: these names are reserved (README → Building plugins), and a handler that
|
||||
// happens to use one loses that key rather than breaking the shell that renders around it.
|
||||
// Where the language picker on this page should point. Normally the page itself; after a POST
|
||||
// that URL may answer no GET (POST /admin/users/:id/delete has no GET sibling), so fall back to
|
||||
// the page the form was submitted from, then to the front page — the picker is on every page, so
|
||||
// every one of its links has to land somewhere real.
|
||||
// Where the language picker points. Normally the page itself; after a POST that URL may answer no
|
||||
// GET (POST /admin/users/:id/delete has no GET sibling), so fall back to the page the form was
|
||||
// submitted from, then to the front page — the picker is on every page, so every link must land.
|
||||
const switchBase = (req: IncomingMessage, url: URL): string => {
|
||||
const method = (req.method ?? "GET").toUpperCase();
|
||||
if (method === "GET" || method === "HEAD") return `${url.pathname}${url.search}`;
|
||||
@@ -147,6 +126,8 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
t: ctx.t,
|
||||
url: ctx.url,
|
||||
});
|
||||
// i18n locals go last: their names are reserved, so a handler's colliding key loses instead of
|
||||
// breaking the shell around it.
|
||||
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)) });
|
||||
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)) });
|
||||
|
||||
@@ -155,10 +136,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
res.end(html);
|
||||
};
|
||||
|
||||
// The public landing "/": ungated — anyone may see it. A plugin may fully own it via `home`
|
||||
// (rendered against its own views, native shell via ctx.chrome, with a fresh CSRF cookie for
|
||||
// any form it ships). Else the built-in intro page with prominent sign-in / register links
|
||||
// (`user` picks "go to dashboard" vs sign-in; the shell's Sign-out form needs the CSRF cookie).
|
||||
// The public landing "/", ungated. A plugin may own it via `home`; else the built-in intro page.
|
||||
const serveHome = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
|
||||
csrf.setCookie();
|
||||
if (homePlugin) {
|
||||
@@ -172,10 +150,8 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
|
||||
};
|
||||
|
||||
// The post-login app home "/dashboard", gated to a signed-in user: anonymous bounces to sign
|
||||
// in, remembering /dashboard as return_to. A plugin may fully own it via `dashboard` — its
|
||||
// handler renders against its own views, same path as a plugin route. Else the built-in
|
||||
// mock-data People list with the one global menu (ctx.chrome.nav) + branding from config/menu.ts.
|
||||
// "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in
|
||||
// starter page.
|
||||
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
|
||||
if (!ctx.user) return { redirect: loginRedirect(ctx), status: 303 };
|
||||
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
|
||||
@@ -214,26 +190,21 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// (writeHead merges these with its own headers; a plugin's RouteResult.headers can override).
|
||||
for (const [name, value] of secHeaderEntries) res.setHeader(name, value);
|
||||
|
||||
// Before auth: assets don't need a verified user, and the JWT cookie rides every request.
|
||||
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
|
||||
// /public/<id>/… serves a plugin's public/; everything else the core public/.
|
||||
// Before auth: assets don't need a verified user, and the JWT cookie rides every request.
|
||||
const { dir, subPath } = routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds);
|
||||
await serveStatic(dir, subPath, res, method === "HEAD", (err) => reqLog.error("static stream error", { error: String(err) }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Rendered pages content-negotiate on Accept-Language, so a cache in front of us must key on
|
||||
// it — otherwise the first visitor's language is served to everyone. Set after the static
|
||||
// branch above: an asset is the same bytes in every language, and a Vary there would fragment
|
||||
// its cache entry per raw header string.
|
||||
// A cache in front of us must key on the language. Set after the static branch: an asset is
|
||||
// the same bytes in every language, and a Vary there fragments its entry per raw header.
|
||||
res.setHeader("vary", "accept-language");
|
||||
|
||||
// Canonical host (APP_URL): a visitor who reached us on a different host (localhost vs
|
||||
// 127.0.0.1, a secondary domain) is sent to the configured origin, path + query preserved, so
|
||||
// the browser, the themed forms, and the cross-origin Kratos POST all share one cookie host —
|
||||
// otherwise the host-scoped Kratos CSRF cookie is lost and login dumps onto /error. Static
|
||||
// assets above are served on any host (health checks). GET/HEAD only — a 308 must not replay a
|
||||
// cross-host POST; first-party forms are always served from a canonical page anyway.
|
||||
// Canonical host (APP_URL): send an off-host visitor to the configured origin so the browser,
|
||||
// the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the
|
||||
// host-scoped Kratos CSRF cookie is lost and login dumps onto /error. GET/HEAD only: a 308
|
||||
// must not replay a cross-host POST.
|
||||
if (canonicalHost && (method === "GET" || method === "HEAD")) {
|
||||
const host = req.headers.host;
|
||||
if (host !== undefined && host !== canonicalHost) {
|
||||
@@ -242,18 +213,14 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
}
|
||||
}
|
||||
|
||||
// Which language this request is served in: ?locale wins, else Accept-Language, else en-US.
|
||||
// `explicit` (the URL asked) is what makes the choice travel: the chrome, this request's
|
||||
// redirects and ctx.localeHref then carry ?locale onto the links they emit.
|
||||
// `explicit` (the URL asked for a locale) is what makes the choice travel: the chrome, this
|
||||
// request's redirects and ctx.localeHref then carry ?locale onto the links they emit.
|
||||
const { explicit, locale } = i18n.resolve({ acceptLanguage: req.headers["accept-language"], param: url.searchParams.get("locale") });
|
||||
const carryLocale = (href: string): string => localeHref(href, explicit ? locale : null);
|
||||
const t = i18n.translator(locale);
|
||||
|
||||
// Verify the session JWT once (cached JWKS) → ctx.user/permissions; none/invalid ⇒ anonymous.
|
||||
// If the token has lapsed but a live Kratos session still backs it (and we have the Ory
|
||||
// clients), silently re-mint it — "stay signed in": re-read permissions from Keto, re-tokenize,
|
||||
// and set the fresh cookie via setHeader so it rides whatever response this request produces
|
||||
// (a dead session clears the stale cookie). This is the only place the hot path touches Ory.
|
||||
// A lapsed token still backed by a live Kratos session is silently re-minted — "stay signed
|
||||
// in". The only place the hot path touches Ory.
|
||||
let user: User | null = null;
|
||||
if (jwks) {
|
||||
const auth = await resolveSession(req.headers.cookie, jwks, authOptions);
|
||||
@@ -264,32 +231,25 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
user = reminted.user;
|
||||
res.appendHeader("set-cookie", reminted.setCookie);
|
||||
} catch (err) {
|
||||
// Ory unreachable (Kratos/Keto 5xx, refused, timeout) — degrade to anonymous instead of
|
||||
// 500ing every lapsed request. Leave the cookie alone: it can re-mint once Ory recovers.
|
||||
// Ory unreachable — degrade to anonymous instead of 500ing every lapsed request. Leave
|
||||
// the cookie alone: it can re-mint once Ory recovers.
|
||||
reqLog.warn("session re-mint failed (Ory unreachable?)", { error: String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
// CSRF token for this request's first-party forms: reuse a genuine cookie token, else mint
|
||||
// one (a page-emitting handler Set-Cookies it via csrfMint). Verified on our own
|
||||
// state-changing routes.
|
||||
const csrf = ensureCsrfToken(req.headers.cookie, csrfSecret);
|
||||
const csrfMint: RequestCsrf = {
|
||||
setCookie: (): void => { if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies })); },
|
||||
token: csrf.token,
|
||||
};
|
||||
// Bound CSRF verifier handed to plugins via ctx.verifyCsrf (the host owns the secret).
|
||||
const verifyCsrf = (submitted: string | null | undefined): boolean =>
|
||||
verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted });
|
||||
// Chrome (brand/global-nav/user/theme/csrf) composes the whole menu, so it's resolved lazily and
|
||||
// at most once per request: this app-level memo shares it across the contexts below, and each
|
||||
// ctx.chrome getter only triggers it when a handler actually reads it (a json/redirect handler,
|
||||
// or the public "/" with a standalone home, never composes the menu).
|
||||
// Chrome composes the whole menu, so it is memoized and resolved lazily — a json/redirect
|
||||
// handler, or the public "/" with a standalone home, never pays for it.
|
||||
let chromeMemo: PageChrome | undefined;
|
||||
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, localeHref: carryLocale, menu, plugins, t, translatorFor: (id) => i18n.translator(locale, id), user }));
|
||||
|
||||
// The i18n half of every context: the locale, its translator, and the link carrier. A plugin
|
||||
// route swaps in the plugin's own translator (its catalog first, then core).
|
||||
// A plugin's context gets the plugin's own translator — its catalog first, then core.
|
||||
const i18nFor = (pluginId?: string) => ({
|
||||
locale,
|
||||
localeHref: carryLocale,
|
||||
@@ -297,9 +257,8 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
t: pluginId === undefined ? t : i18n.translator(locale, pluginId),
|
||||
});
|
||||
|
||||
// base context (no route params yet); reused for the built-in routes. A plugin-owned render
|
||||
// (a landing slot, a hook short-circuit, a plugin route) gets `contextFor(id)` instead, so its
|
||||
// own catalog is what `ctx.t` reads.
|
||||
// Base context (no route params), for the built-in routes. Every plugin-owned render — a
|
||||
// landing slot, a hook short-circuit, a plugin route — gets `contextFor(id)` instead.
|
||||
const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
||||
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
|
||||
buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
|
||||
@@ -309,23 +268,19 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
if (anyRequestHooks) {
|
||||
const short = await runRequestHooks(plugins, contextFor);
|
||||
if (short) {
|
||||
// Set the fresh CSRF cookie like every other page-emitting path, so a form the hook
|
||||
// renders (its token is in ctx.chrome.csrfToken) has the matching double-submit cookie.
|
||||
// Like every other page-emitting path, so a form the hook renders has its matching cookie.
|
||||
csrfMint.setCookie();
|
||||
await sendResult(res, short.result, pluginViewsFor(short.ctx, short.plugin.id), carryLocale);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin routes (any method): gate on the route's permission, then run the handler. The
|
||||
// handler gets ctx.chrome (native app shell) + ctx.verifyCsrf (guard its own forms); a fresh
|
||||
// CSRF cookie is set so those forms have a valid double-submit token.
|
||||
const match = matchRoute(plugins, method, pathname);
|
||||
if (match) {
|
||||
const routeCtx = contextFor(match.plugin.id, match.params);
|
||||
if (!isAuthorized(match.route, routeCtx.permissions)) {
|
||||
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
|
||||
// return_to; a signed-in user who simply lacks the permission gets the 403 page.
|
||||
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply
|
||||
// lacks the permission gets the 403 page.
|
||||
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
|
||||
reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
|
||||
sendHtml(res, 403, await renderPage("403", {}));
|
||||
@@ -340,9 +295,6 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
return;
|
||||
}
|
||||
|
||||
// Built-in endpoints (the auth/OAuth2 group, the landing slots, /error) from the internal
|
||||
// route table — same handler shape as plugin routes; a `view` result renders the core views,
|
||||
// null means the handler wrote to ctx.res itself.
|
||||
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
|
||||
if (builtin) {
|
||||
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
|
||||
@@ -385,20 +337,16 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
};
|
||||
|
||||
return createServer((req, res) => {
|
||||
// Per-request log + trace span: a "request" span, continuing an upstream W3C traceparent
|
||||
// when present (distributed tracing across a proxy). "close" (not "finish") fires on both a
|
||||
// completed response and a premature disconnect/abort, so an aborted/truncated request is still
|
||||
// logged and its span flushed.
|
||||
// "close" (not "finish") fires on both a completed response and a premature disconnect, so an
|
||||
// aborted request is still logged and its span flushed.
|
||||
const startMs = Date.now();
|
||||
const reqLog = requestLogger(log, {
|
||||
requestId: randomUUID(),
|
||||
...(typeof req.headers.traceparent === "string" ? { traceparent: req.headers.traceparent } : {}),
|
||||
});
|
||||
// end() must run exactly once, after BOTH the handler has fully unwound (settled) AND the
|
||||
// response has closed (the access line is then emitted with the final status). Ending earlier
|
||||
// would throw "already ended" from a still-running handler's ctx.log/tracedFetch on a client
|
||||
// abort, or drop the access line on the happy path (handler settles before close). Coordinating
|
||||
// the two signals avoids both. Logging must never crash a served request, so it's all guarded.
|
||||
// end() must run exactly once, after BOTH the handler has unwound AND the response has closed.
|
||||
// Earlier would throw "already ended" from a still-running handler's ctx.log on a client abort,
|
||||
// or drop the access line on the happy path (the handler settles before close).
|
||||
let settled = false;
|
||||
let closed = false;
|
||||
const finalize = (): void => { if (settled && closed) void reqLog.end().catch(() => {}); };
|
||||
@@ -410,9 +358,8 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
} catch { /* never let logging crash a served request */ }
|
||||
finalize();
|
||||
});
|
||||
// Make reqLog ambient for the whole handler (sync body + every await) so all outbound fetch is
|
||||
// traced. handleRequest owns its own try/catch; the .catch logs a pathological escape via the
|
||||
// app logger (not reqLog, which may be the thing that broke), never crashing the request.
|
||||
// Make reqLog ambient for the whole handler so all outbound fetch is traced. The .catch logs a
|
||||
// pathological escape via the app logger — not reqLog, which may be the thing that broke.
|
||||
void runWithLog(reqLog, () => handleRequest(req, res, reqLog))
|
||||
.catch((err) => log.error("request handler escaped its try/catch", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) }))
|
||||
.finally(() => { settled = true; finalize(); });
|
||||
|
||||
+5
-12
@@ -1,15 +1,8 @@
|
||||
// URL safety helpers. Two pure, dependency-free guards:
|
||||
//
|
||||
// safeUrl(value) — sanitise an untrusted URL before rendering it in an href/src attribute.
|
||||
// Partials escape *text*, but a URL field is emitted verbatim, so a
|
||||
// `javascript:`/`data:` URL from upstream/user data would be live XSS. The
|
||||
// contract (README.md → Routes & handlers) is: a relative or http(s) URL is allowed,
|
||||
// anything else collapses to "#". Exported to plugins via plugin-api.ts.
|
||||
//
|
||||
// localPath(value) — validate a redirect target is a *same-origin* path (the redirect-URI
|
||||
// allowlist). Used for `return_to`: a host-relative "/a/b?x=1" passes, an
|
||||
// absolute or protocol-relative ("//evil.com", "https://evil.com") is rejected
|
||||
// so a crafted ?return_to= can't turn login completion into an open redirect.
|
||||
// safeUrl(value) — a URL field is emitted verbatim into an href/src, so a `javascript:`/`data:`
|
||||
// URL from untrusted data would be live XSS. Relative or http(s) passes,
|
||||
// anything else collapses to "#". Exported to plugins via plugin-api.ts.
|
||||
// localPath(value) — the redirect-URI allowlist for `return_to`: host-relative passes, absolute
|
||||
// or protocol-relative is rejected, so a crafted value can't open-redirect.
|
||||
|
||||
// ASCII control chars + space that browsers strip/ignore when resolving a URL — strip them before
|
||||
// the scheme check so "java\tscript:" / a leading space can't masquerade as relative.
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
// Response security headers: set once per request in app.ts so every response — page,
|
||||
// JSON, redirect, static, or error — carries them (writeHead merges with setHeader). A plugin route
|
||||
// may override any of them per-response via RouteResult.headers (e.g. relax the CSP to ship its own JS).
|
||||
// Set once per request in app.ts, so every response carries them (writeHead merges with setHeader).
|
||||
// A plugin route may override any per-response via RouteResult.headers.
|
||||
|
||||
// Strict default CSP for the zero-JS, server-rendered core:
|
||||
// - script-src 'self' : the core ships no JS; a plugin may still serve its own /public/<id>/*.js for
|
||||
// opt-in progressive enhancement. No 'unsafe-inline' ⇒ an injected <script>
|
||||
// can't run (the main XSS sink).
|
||||
// - style-src adds 'unsafe-inline' : a few partials carry inline style= attributes.
|
||||
// - img-src adds data: : favicon + inline data URIs.
|
||||
// - no form-action : the themed login form posts to Kratos' (often cross-origin) action URL.
|
||||
// - frame-ancestors 'none' : clickjacking guard (the modern X-Frame-Options).
|
||||
// The non-obvious parts of the CSP:
|
||||
// - script-src 'self' with no 'unsafe-inline' ⇒ an injected <script> can't run. A plugin may still
|
||||
// serve its own /public/<id>/*.js for opt-in progressive enhancement.
|
||||
// - style-src adds 'unsafe-inline': a few partials carry inline style= attributes.
|
||||
// - no form-action: the themed login form posts to Kratos' (often cross-origin) action URL.
|
||||
const CSP = [
|
||||
"base-uri 'self'",
|
||||
"default-src 'self'",
|
||||
|
||||
+6
-8
@@ -1,12 +1,10 @@
|
||||
// Catalog discovery: import src/i18n/locales/<tag>.ts and plugins/<id>/i18n/<tag>.ts, then
|
||||
// check every one against its set's en-US baseline. The imperative shell over catalog.ts's pure
|
||||
// rules — the same contract as plugin discovery: one boot-stopping Error listing every problem,
|
||||
// so a half-translated deploy is caught at startup rather than as a stray English word in production.
|
||||
// Catalog discovery: import src/i18n/locales/<tag>.ts and plugins/<id>/i18n/<tag>.ts, then check
|
||||
// every one against its set's en-US baseline. The imperative shell over catalog.ts's pure rules,
|
||||
// with plugin discovery's contract: one boot-stopping Error listing every problem, so a
|
||||
// half-translated deploy is caught at startup rather than as a stray English word in production.
|
||||
//
|
||||
// Installed locales are whatever the core folder holds; a plugin may translate fewer of them (its
|
||||
// strings then render in en-US on that page) but never one the host does not have. The operator's
|
||||
// `locales/` mount extends both sides — `locales/<tag>.ts` for the core, `locales/plugins/<id>/<tag>.ts`
|
||||
// for a plugin — so adding a language never means forking the image or a vendored plugin.
|
||||
// A plugin may translate fewer locales than the core holds (its strings then render in en-US) but
|
||||
// never one the host lacks. The operator's `locales/` mount extends both sides.
|
||||
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
// The translator: a key + vars → the string to render. Pure and synchronous — views call it
|
||||
// as `t("shell.signOut")` and handlers as `ctx.t(...)`.
|
||||
//
|
||||
// Two rules the rest of the app leans on:
|
||||
// · the lookup walks a catalog chain (plugin locale → plugin en-US → core locale → core en-US) and,
|
||||
// when nothing has the key, returns the key itself. That is what makes a plain nav label like
|
||||
// "Shifts" its own fallback — a manifest needs no catalog to keep working.
|
||||
// · the result is raw text. Views escape with <%= %> exactly as they do for any other value, so a
|
||||
// translation is never double-escaped, and a message that carries markup is rendered with <%- %>.
|
||||
// The translator: a key + vars → the string to render. Two rules the rest of the app leans on:
|
||||
// · the lookup walks a catalog chain (plugin locale → plugin en-US → core locale → core en-US)
|
||||
// and returns the key itself when nothing has it — so a plain nav label like "Shifts" is its
|
||||
// own fallback and a manifest needs no catalog to keep working.
|
||||
// · the result is raw text, escaped by the view with <%= %> like any other value, so a
|
||||
// translation is never double-escaped and one carrying markup is rendered with <%- %>.
|
||||
|
||||
import { isPluralMessage, type Catalog, type PluralMessage } from "./catalog.ts";
|
||||
|
||||
|
||||
+10
-17
@@ -21,11 +21,9 @@ export interface LoggerOptions {
|
||||
stdout?: (msg: string) => void;
|
||||
}
|
||||
|
||||
// The app-level logger: a Log tagged service.name so every console line, OTLP log record and span is
|
||||
// attributed to the service. Level + format + name are explicit toggles (LOG_LEVEL/LOG_FORMAT/
|
||||
// SERVICE_NAME — environment-agnostic, AGENTS.md §4). With otlpEndpoint set, logs + spans also export
|
||||
// to that OTLP/HTTP collector (e.g. an OpenTelemetry Collector fronting Tempo/Loki); unset ⇒ console
|
||||
// only, at zero export cost. Conditional spreads keep exactOptionalPropertyTypes happy (no `key: undefined`).
|
||||
// The app-level logger, tagged service.name. With otlpEndpoint set, logs + spans also export to that
|
||||
// OTLP/HTTP collector; unset ⇒ console only, at zero export cost. The conditional spreads keep
|
||||
// exactOptionalPropertyTypes happy (no `key: undefined`).
|
||||
export function createLogger(opts: LoggerOptions = {}): Log {
|
||||
return new Log({
|
||||
context: { "service.name": opts.serviceName || SERVICE_NAME },
|
||||
@@ -49,13 +47,10 @@ export function currentLog(): Log | undefined {
|
||||
return requestStore.getStore();
|
||||
}
|
||||
|
||||
// A drop-in `fetch` that traces through the active request log — a client span nested under the
|
||||
// request span, with a W3C `traceparent` injected so the downstream service continues the same
|
||||
// trace. Outside a request (no ambient log) or for a non-string/URL input it's a plain `fetch`.
|
||||
// server.ts wires this (under the Ory timeout) into every Kratos/Keto/Hydra/JWKS call; a plugin
|
||||
// uses it for its upstream calls (exported via plugin-api.ts). The trace-setup adds no throw of its
|
||||
// own, but log.fetch throws synchronously if the request log has already ended (app.ts ends it only
|
||||
// after the handler unwinds, so a live handler never hits that).
|
||||
// A drop-in `fetch` that traces through the active request log — a client span under the request
|
||||
// span, with a W3C `traceparent` injected so the downstream service continues the same trace.
|
||||
// Outside a request, or for a non-string/URL input, it is a plain `fetch`. Note log.fetch throws
|
||||
// synchronously once the request log has ended; app.ts ends it only after the handler unwinds.
|
||||
export const tracedFetch: typeof fetch = (input, init) => {
|
||||
const log = currentLog();
|
||||
if (log && (typeof input === "string" || input instanceof URL)) return log.fetch(input, init);
|
||||
@@ -63,11 +58,9 @@ export const tracedFetch: typeof fetch = (input, init) => {
|
||||
};
|
||||
|
||||
// A per-request child logger holding a "request" trace span. `clone` (not parentLog) gives the
|
||||
// request its own root trace — so requests aren't all nested under one app-lifetime span — while
|
||||
// inheriting the parent's level/format/streams/OTLP. A valid upstream W3C `traceparent` is adopted
|
||||
// (the span continues that distributed trace across a reverse proxy/gateway; malformed ⇒ ignored, a
|
||||
// fresh trace starts). `requestId` tags every line + the span for log↔trace correlation. Flush with
|
||||
// `end()` on response finish to export the span — a no-op when OTLP is off.
|
||||
// request its own root trace, so requests aren't all nested under one app-lifetime span, while
|
||||
// inheriting the parent's level/format/streams/OTLP. A valid upstream `traceparent` is adopted;
|
||||
// malformed ⇒ ignored, a fresh trace starts. `end()` on response finish exports the span.
|
||||
export function requestLogger(appLog: Log, opts: { requestId: string; traceparent?: string | undefined }): Log {
|
||||
return appLog.clone({
|
||||
context: { ...appLog.context, requestId: opts.requestId },
|
||||
|
||||
+27
-43
@@ -1,6 +1,5 @@
|
||||
// The plugin contract — the product's main API surface: the machine-readable types +
|
||||
// pure rules; README.md (Building plugins) is the prose reference, discovery/router wire it to FS+HTTP.
|
||||
// Powerful, predictable, fails loud at boot/discovery rather than sandboxing at runtime.
|
||||
// The plugin contract — the product's main API surface: the machine-readable types + pure rules.
|
||||
// README → Building plugins is the prose reference; discovery/router wire this to FS + HTTP.
|
||||
//
|
||||
// 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.
|
||||
@@ -8,9 +7,7 @@
|
||||
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
|
||||
// provider/consumer semver semantics in checkApiVersion (refuse/warn on mismatch).
|
||||
// Bump major on a breaking manifest/handler change, minor on an additive one.
|
||||
export const HOST_API_VERSION = "1.0.0";
|
||||
|
||||
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
|
||||
@@ -30,24 +27,21 @@ export interface Route {
|
||||
method: HttpMethod;
|
||||
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
|
||||
permission?: string; // coarse gate — the Keto Permission the caller must hold; checked before the handler runs
|
||||
// Mark the page reachable by anyone, signed in or not. The same as omitting `permission`
|
||||
// — an ungated route is already open — but stated outright, so "public" is a deliberate
|
||||
// choice, not an accident. Mutually exclusive with `permission` (discovery refuses both).
|
||||
// Same as omitting `permission`, but stated outright so public is a deliberate choice rather than
|
||||
// a forgotten gate. Mutually exclusive with `permission` (discovery refuses both).
|
||||
public?: boolean;
|
||||
}
|
||||
|
||||
// A Keto Permission this plugin gates on — declared for docs/seeding. Permission names are a shared
|
||||
// global namespace (so an operator grants them once in Keto) and are always `<resource>:<action>` —
|
||||
// `scheduling:read`, `users:write`. A bare word names who someone is rather than what they may do,
|
||||
// which is a role, and roles are groups here (README → Users, groups & permissions).
|
||||
// A Keto Permission this plugin gates on — declared for docs/seeding. Names are a shared global
|
||||
// namespace, so an operator grants them once in Keto. See README → Users, groups & permissions.
|
||||
export interface PermissionDecl {
|
||||
description?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// `<resource>:<action>`, each half lowercase alphanumeric with dashes/underscores inside. The 64-char
|
||||
// cap keeps a name usable as a Keto object and a URL path segment. Enforced at discovery like every
|
||||
// other manifest rule, so the convention holds for plugins the admin GUI never touches.
|
||||
// `<resource>:<action>`. The 64-char cap keeps a name usable as a Keto object and a URL path
|
||||
// segment. Enforced at discovery like every other manifest rule, so the convention holds for
|
||||
// plugins the admin GUI never touches.
|
||||
const PERMISSION_NAME = /^[a-z0-9][a-z0-9_-]*:[a-z0-9][a-z0-9_-]*$/;
|
||||
|
||||
export function isValidPermissionName(name: string): boolean {
|
||||
@@ -77,12 +71,10 @@ export interface PluginHooks {
|
||||
// host derives them from the folder name at discovery (see Plugin).
|
||||
export interface PluginManifest {
|
||||
apiVersion: string; // semver of the host contract this targets — write a literal, NOT HOST_API_VERSION (see docs)
|
||||
// Take over the gated dashboard "/dashboard" — the post-login app home. A handler like any
|
||||
// route's; the host gates it to a signed-in session (anonymous → /login), then renders its own view
|
||||
// via ctx.chrome. At most one plugin may declare it (findConflicts → error, never last-write-wins).
|
||||
// Take over "/dashboard"; the host gates it to a signed-in session first. At most one plugin may
|
||||
// declare it (findConflicts → error, never last-write-wins).
|
||||
dashboard?: RouteHandler;
|
||||
// Take over the public landing "/" — the ungated front page. A handler like any route's,
|
||||
// anyone may reach it. At most one plugin may declare it (findConflicts → error).
|
||||
// Take over the ungated public landing "/". At most one plugin may declare it.
|
||||
home?: RouteHandler;
|
||||
hooks?: PluginHooks;
|
||||
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
|
||||
@@ -96,27 +88,23 @@ export interface Plugin extends PluginManifest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// Identity helper: types the manifest, returns it unchanged. Validation happens at discovery
|
||||
//, so a plugin may equally be a plain typed object. Mirrors Vite's `defineConfig`.
|
||||
// Types the manifest and returns it unchanged; validation happens at discovery, so a plugin may
|
||||
// equally be a plain typed object.
|
||||
export function definePlugin(manifest: PluginManifest): PluginManifest {
|
||||
return manifest;
|
||||
}
|
||||
|
||||
// A plugin id (its folder name) — lowercase a–z, digits, and dashes, dashes allowed anywhere.
|
||||
// Rejects uppercase, underscores, dots, slashes, spaces: the id forms the mount path `/<id>`,
|
||||
// the view/static namespace, and the central-override target, so it must stay URL/path-safe.
|
||||
// The id forms the mount path `/<id>`, the view/static namespace and the central-override target,
|
||||
// so it must stay URL/path-safe: no uppercase, underscores, dots, slashes or spaces.
|
||||
const PLUGIN_ID = /^[a-z0-9-]+$/;
|
||||
|
||||
export function isValidPluginId(id: string): boolean {
|
||||
return PLUGIN_ID.test(id);
|
||||
}
|
||||
|
||||
// Ids the host reserves for its own first-party mount segments (the gated /dashboard, the auth flows,
|
||||
// /auth/complete, /logout, the /oauth2 provider routes, the /public/ static). Plugin routes resolve
|
||||
// before these, so a folder named one of them would silently shadow a built-in route — discovery
|
||||
// refuses it, loud like any conflict. ("/" is owned by the `home` field, not a route, so it can't be
|
||||
// shadowed and needs no reservation.) Note `admin` is NOT reserved: the admin screens ship as a
|
||||
// drop-in plugin (examples/plugins/admin, mounted at /admin), not a built-in route.
|
||||
// Plugin routes resolve before the built-ins, so a folder named one of these would silently shadow
|
||||
// one — discovery refuses it. "/" is owned by the `home` field, not a route, so it needs no
|
||||
// reservation; `admin` is deliberately absent, the admin screens being a drop-in plugin.
|
||||
export const RESERVED_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
||||
"auth", "dashboard", "login", "logout", "oauth2", "public", "recovery", "registration", "settings", "verification",
|
||||
]);
|
||||
@@ -127,14 +115,12 @@ export interface Semver {
|
||||
patch: number;
|
||||
}
|
||||
|
||||
// The official semver.org 2.0.0 core regex (major.minor.patch, optional prerelease/build) — a
|
||||
// standardized parse with no dependency. We compare only major/minor for compatibility, so the
|
||||
// prerelease/build groups are matched (to accept valid input) but otherwise ignored.
|
||||
// The official semver.org 2.0.0 core regex. Only major/minor drive compatibility, so the
|
||||
// prerelease/build groups are matched to accept valid input but otherwise ignored.
|
||||
const SEMVER =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$/;
|
||||
|
||||
// Parse a strict semver string → {major, minor, patch}, or null. Rejects ranges/prefixes
|
||||
// (`^1.2.3`, `v1`), leading zeros, whitespace and missing parts — fail loud over coerce.
|
||||
// Rejects ranges/prefixes (`^1.2.3`, `v1`), leading zeros and missing parts — fail loud over coerce.
|
||||
export function parseSemver(version: unknown): Semver | null {
|
||||
if (typeof version !== "string") return null;
|
||||
const m = SEMVER.exec(version);
|
||||
@@ -147,9 +133,8 @@ export interface VersionCheck {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Provider/consumer semver check (full table in README.md → Contract versioning): same major+minor → ok,
|
||||
// plugin minor < host → warn, else (newer minor, major mismatch, malformed) → refuse. Patch is
|
||||
// ignored. Discovery maps refuse→throw, warn→log.
|
||||
// Provider/consumer semver check (full table in README → Contract versioning). Discovery maps
|
||||
// refuse→throw, warn→log.
|
||||
export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HOST_API_VERSION): VersionCheck {
|
||||
const plugin = parseSemver(pluginVersion);
|
||||
const host = parseSemver(hostVersion);
|
||||
@@ -176,9 +161,8 @@ export interface PluginConflict {
|
||||
plugins: string[]; // unique ids involved
|
||||
}
|
||||
|
||||
// The conflict rules: defined, loud resolution — never last-write-wins. Pure over the discovered
|
||||
// plugins; discovery throws on any "error" and logs every "warn". Mount-path (`/<id>`) uniqueness
|
||||
// is structural — it follows from the id check, so it needs no rule of its own. Shared permission
|
||||
// Loud resolution, never last-write-wins: discovery throws on any "error" and logs every "warn".
|
||||
// Mount-path uniqueness needs no rule of its own — it follows from the id check. Shared permission
|
||||
// names are the one intentional overlap, so they warn rather than error.
|
||||
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
||||
const out: PluginConflict[] = [];
|
||||
|
||||
@@ -10,11 +10,10 @@ import type { HydraAdmin } from "../auth/hydra-admin.ts";
|
||||
import type { KetoClient } from "../auth/keto-client.ts";
|
||||
import type { KratosAdmin } from "../auth/kratos-admin.ts";
|
||||
|
||||
// Grouping criterion (keep this cohesive — it's a contract, so the "no catch-all bucket" rule that
|
||||
// governs folders governs this bag too): every field is a *privileged, host-owned, wire-dependent*
|
||||
// capability for administering Plainpages' own identity/permission stack. Add a field only when it
|
||||
// meets all three; if unrelated privileged concerns accrete (mailer, metrics, flags), sub-group
|
||||
// rather than pile them in flat.
|
||||
// Keep this cohesive — it is a contract, so the "no catch-all bucket" rule applies: every field is a
|
||||
// *privileged, host-owned, wire-dependent* capability for administering Plainpages' own
|
||||
// identity/permission stack. Add one only when it meets all three; sub-group rather than pile in
|
||||
// unrelated privileged concerns (mailer, metrics, flags).
|
||||
export interface SystemCapabilities {
|
||||
hydra?: HydraAdmin; // OAuth2 client admin (Hydra); present when the Hydra admin client is wired
|
||||
keto?: KetoClient; // relationship read/write (Keto); present when Keto is wired
|
||||
|
||||
+6
-14
@@ -1,9 +1,6 @@
|
||||
// Page chrome for plugin pages: the brand / global-nav / user / theme / csrf block a
|
||||
// plugin view hands to partials/shell so its page looks native — the same shell the dashboard and
|
||||
// every plugin renders. Pure; the host builds it per plugin request and exposes it on ctx.chrome.
|
||||
// nav is the global menu — Dashboard + every plugin's fragment (admin screens included, when the
|
||||
// admin plugin is installed) — run through composeNav (override + per-user filter) and
|
||||
// current-marked for the request path.
|
||||
// The brand / global-nav / user / theme / csrf block a view hands to partials/shell, exposed on
|
||||
// ctx.chrome. `nav` is the global menu — Dashboard plus every plugin's fragment — run through
|
||||
// composeNav (override + per-user filter) and current-marked for the request path.
|
||||
|
||||
import type { User } from "../http/context.ts";
|
||||
import { ENGLISH } from "../i18n/english.ts";
|
||||
@@ -13,9 +10,6 @@ import { composeNav, type NavNode } from "./nav.ts";
|
||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
import { branding, shellUser, type ShellUser } from "./shell-context.ts";
|
||||
|
||||
// The "Dashboard" link to the gated app home (/dashboard). It targets a gated route, so it's shown
|
||||
// only to a signed-in user (an anonymous click would only dead-end at /login). Its label is a
|
||||
// catalog key — composeNav translates every label, and an unknown one renders as written.
|
||||
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard" };
|
||||
|
||||
export interface PageChrome {
|
||||
@@ -41,13 +35,11 @@ export interface ChromeOptions {
|
||||
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||
const t = opts.t ?? ENGLISH;
|
||||
const carryLocale = opts.localeHref ?? ((href: string) => href);
|
||||
// The Dashboard link targets the gated /dashboard, so show it only to a signed-in user — to an
|
||||
// anonymous visitor (a public page in the shell) it would only dead-end at /login. The admin
|
||||
// section, when present, is just another plugin's nav fragment (examples/plugins/admin).
|
||||
// Dashboard is gated, so an anonymous click would only dead-end at /login.
|
||||
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
|
||||
// A plugin's nav labels are keys in *its* catalog, so translate each fragment with that plugin's
|
||||
// translator before they are merged. composeNav then runs the core one over the result for the
|
||||
// built-in nodes and the central override's labels; already-translated text passes through it.
|
||||
// translator before merging. composeNav then runs the core one over the result; already-translated
|
||||
// text passes through it.
|
||||
for (const p of opts.plugins ?? []) {
|
||||
if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t));
|
||||
}
|
||||
|
||||
+4
-7
@@ -1,10 +1,7 @@
|
||||
// composeNav: merge each plugin's nav fragment into one tree, apply the central
|
||||
// override, then permission-filter per user. Pure and I/O-free — menu gating reads the JWT
|
||||
// `permissions` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or
|
||||
// declares no `permission`, or `permissions` includes that permission name; a gated header hides its whole
|
||||
// subtree, and a pure header left with no children is dropped. The config/menu.ts supplies
|
||||
// the override (+ branding); this helper only transforms data, so its result is per-deployment
|
||||
// up to the final permission filter and emits clean nodes ready for nav-tree.ejs (no id/permission).
|
||||
// composeNav: merge each plugin's nav fragment into one tree, apply the central override, then
|
||||
// permission-filter per user. Pure and I/O-free — menu gating reads the JWT `permissions` claim,
|
||||
// never Keto. A node is visible iff it is `public`, declares no `permission`, or the user holds that
|
||||
// name; a gated header hides its whole subtree, and a pure header left with no children is dropped.
|
||||
|
||||
import { ENGLISH } from "../i18n/english.ts";
|
||||
import type { Translate } from "../i18n/translate.ts";
|
||||
|
||||
Reference in New Issue
Block a user