§9 trace all fetch + ENV service name + leveled logging (todo §9 follow-up); route every outbound fetch through the request logger, make the OTLP service name implementer-configurable, and add proper leveled logging throughout. An AsyncLocalStorage<Log> makes the per-request logger ambient (runWithLog/currentLog), so all outbound fetch traces with no signature churn: tracedFetch (a typeof fetch) routes through the active request log (client span + propagated W3C traceparent) for string/URL inputs, else plain fetch; server.ts wires it under the Ory timeout into every Kratos/Keto/Hydra + JWKS call (timeout still honoured — log.fetch spreads {...init,headers}). RequestContext gained ctx.log (request logger; additive/contract-stable, silent default) so a handler/plugin logs in-trace and ctx.log.fetch(url) traces upstream calls; the reference plugin's createUpstream defaults to tracedFetch and its handlers log via ctx.log; plugin-api.ts exports tracedFetch + the Log class. SERVICE_NAME (config + createLogger({serviceName})) brands the OTLP service.name. Leveled logging: who-did-what audit info lines on every admin write (user/group/role/client create·delete·assign — actor/target, no secrets), info on login (session mint) + logout, warn on missing-role 403 + CSRF rejections + Ory-unreachable, debug on a JWKS kid-miss reload. app.ts's handler body was extracted to handleRequest run inside runWithLog; end() now fires exactly once after BOTH the handler unwinds AND the response closes, so a client abort mid-handler can't end the log out from under a still-running ctx.log/tracedFetch (regression-tested) and the happy-path access line is never dropped. bootstrap.ts wraps main in runWithLog + traces the seed calls. Tests extended (logger: serviceName/runWithLog/currentLog/tracedFetch-continues-trace; config: SERVICE_NAME; context: ctx.log default+passthrough; app: ctx.log in-trace + ctx.log.fetch propagation + the abort race; plugin-api: tracedFetch+Log). Stability-reviewer: APPROVE, no Critical/High (fixed the abort-race end(); green nits addressed). docs/plugin-contract.md (ctx.log/ctx.log.fetch/tracedFetch) + README (config, Observability tracing/serviceName, plugin note, Layout) updated. typecheck + 333 units + the full scripts/ci.sh E2E gate green (326 → 333).

This commit is contained in:
2026-06-20 15:46:48 +02:00
parent a9e3dedbb4
commit bea9a71d6f
23 changed files with 341 additions and 81 deletions

View File

@@ -312,6 +312,7 @@ export async function handleAdminClients(ctx: RequestContext, csrfToken: string,
throw err;
}
// Show the one-time secret now (Hydra never returns it again) — render the detail directly.
ctx.log.info("admin: oauth2 client registered", { actor: user.id, client: created.client_id ?? "" });
return renderDetail(created, { created: true, ...(created.client_secret ? { secret: created.client_secret } : {}) });
}
return null;
@@ -339,6 +340,7 @@ export async function handleAdminClients(ctx: RequestContext, csrfToken: string,
}
if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
await hydra.deleteClient(id);
ctx.log.info("admin: oauth2 client deleted", { actor: user.id, client: id });
return { redirect: ADMIN_CLIENTS_BASE };
}
return null;

View File

@@ -362,6 +362,7 @@ export async function handleAdminGroups(ctx: RequestContext, csrfToken: string,
if (!tuple) return reject("Pick a member to add as the group's first member.");
if (await groupExists(keto, name)) return reject("A group with that name already exists.");
await keto.writeTuple(tuple);
ctx.log.info("admin: group created", { actor: user.id, group: name });
return { redirect: detailHref(name) };
}
return null;
@@ -392,6 +393,7 @@ export async function handleAdminGroups(ctx: RequestContext, csrfToken: string,
}
if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS }); // removes every member tuple
ctx.log.info("admin: group deleted", { actor: user.id, group: name });
return { redirect: ADMIN_GROUPS_BASE };
}
if (seg.length === 3 && seg[1] === "members" && seg[2] === "delete" && method === "POST") {

View File

@@ -339,6 +339,7 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
if (await roleExists(keto, name)) return reject("A role with that name already exists.");
await keto.writeTuple(tuple);
revokeUserMember(deps, member);
ctx.log.info("admin: role created + first member assigned", { actor: user.id, member, role: name });
return { redirect: detailHref(name) };
}
return null;
@@ -357,7 +358,7 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
if (seg.length === 2 && seg[1] === "members" && method === "POST") {
const member = (form!.get("member") ?? "").trim();
const tuple = roleMemberTuple(name, member);
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(deps, member); } // the picker only offers real users/groups
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(deps, member); ctx.log.info("admin: role assigned", { actor: user.id, member, role: name }); } // the picker only offers real users/groups
return { redirect: base };
}
if (seg.length === 2 && seg[1] === "delete" && method === "GET") {
@@ -374,6 +375,7 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS }); // removes every member tuple
// §9: a whole-role delete drops many members at once — left to lag like a group change; the
// per-member unassign above is the instant-revoke path.
ctx.log.info("admin: role deleted", { actor: user.id, role: name });
return { redirect: ADMIN_ROLES_BASE };
}
if (seg.length === 3 && seg[1] === "members" && seg[2] === "delete" && method === "POST") {
@@ -382,7 +384,7 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
// Admin held only via a group isn't covered here — the robust "last effective admin" check is §9.
if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return renderDetail(name, "You can't revoke your own admin access.");
const tuple = roleMemberTuple(name, member);
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(deps, member); }
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(deps, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); }
return { redirect: base };
}
return null;

View File

@@ -328,6 +328,7 @@ export async function handleAdminUsers(ctx: RequestContext, csrfToken: string, d
if (err instanceof KratosError) return { ...(await renderForm({ error: createError(err), values: input })), status: 400 };
throw err;
}
ctx.log.info("admin: user created", { actor: user.id, email: input.email });
return { redirect: ADMIN_USERS_BASE };
}
return null;
@@ -374,12 +375,14 @@ export async function handleAdminUsers(ctx: RequestContext, csrfToken: string, d
const nextState = identity.state === "inactive" ? "active" : "inactive";
await kratosAdmin.updateIdentity(targetId, setStatePayload(identity, nextState));
if (nextState === "inactive") deps.revoke?.(targetId); // §9: a deactivation takes effect now, not after the JWT TTL
ctx.log.info("admin: user state changed", { actor: user.id, state: nextState, target: targetId });
return { redirect: back };
}
if (seg[1] === "delete") {
if (isSelf) return { ...(await renderForm({ error: "You can't delete your own account.", identity })), status: 400 };
await kratosAdmin.deleteIdentity(targetId);
deps.revoke?.(targetId); // §9: the account is gone — reject its live tokens immediately
ctx.log.info("admin: user deleted", { actor: user.id, target: targetId });
return { redirect: ADMIN_USERS_BASE };
}
if (seg[1] === "recovery") {

View File

@@ -94,6 +94,92 @@ test("emits a structured access-log line per request (the injected §9 logger)",
assert.ok(rec.requestId, "carries a requestId for log↔trace correlation");
});
test("ctx.log: a handler logs in the request trace, and ctx.log.fetch continues the inbound trace (§9)", async (t) => {
const lines: string[] = [];
const upstream: { traceparent: string | undefined; url: string }[] = [];
const realFetch = globalThis.fetch;
// Intercept only the upstream call; everything else (the test's own request to the server) passes through.
globalThis.fetch = async (input, init) => {
const url = String(input);
if (!url.startsWith("http://upstream.test")) return realFetch(input, init);
upstream.push({ traceparent: new Headers(init?.headers).get("traceparent") ?? undefined, url });
return new Response("[]", { headers: { "content-type": "application/json" }, status: 200 });
};
t.after(() => { globalThis.fetch = realFetch; });
const plugin: Plugin = {
apiVersion: "1.0.0",
id: "obs",
routes: [{
handler: async (ctx) => {
ctx.log.info("ping handled", { who: "obs" }); // plugin logging via ctx.log
await ctx.log.fetch("http://upstream.test/data"); // an upstream call, traced + propagated
return { json: { ok: true } };
},
method: "GET",
path: "/ping",
}],
};
const app = createApp({ log: createLogger({ format: "json", level: "info", stderr: () => {}, stdout: (m) => lines.push(m) }), plugins: [plugin] });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const base = `http://localhost:${(app.address() as AddressInfo).port}`;
const inbound = "0af7651916cd43dd8448eb211c80319c";
await (await fetch(base + "/obs/ping", { headers: { traceparent: `00-${inbound}-b7ad6b7169203331-01` } })).text();
// ctx.log emitted a line tagged with the request's id (handler ran inside the request trace).
let pl: string | undefined;
for (let i = 0; i < 50 && !pl; i++) { pl = lines.find((l) => l.includes('"msg":"ping handled"')); if (!pl) await new Promise((r) => setTimeout(r, 10)); }
assert.ok(pl, "ctx.log line is emitted");
const rec = JSON.parse(pl!);
assert.equal(rec.who, "obs");
assert.ok(rec.requestId, "the plugin line shares the request id");
// ctx.log.fetch propagated a W3C traceparent continuing the inbound distributed trace.
const up = upstream.find((r) => r.url === "http://upstream.test/data");
assert.ok(up?.traceparent, "ctx.log.fetch injects a traceparent");
assert.equal(up!.traceparent!.split("-")[1], inbound, "the upstream call continues the inbound trace");
});
test("ctx.log after a client abort doesn't throw: the request log is ended only once the handler unwinds (§9)", async (t) => {
// The request span is ended on response "close", which also fires on a premature client abort.
// The handler keeps running after that — its ctx.log must not throw "already ended", so end() is
// deferred until the handler settles (regression for the abort race).
let afterCloseOk = false;
let afterCloseErr: string | undefined;
const plugin: Plugin = {
apiVersion: "1.0.0",
id: "slow",
routes: [{
handler: async (ctx) => {
await new Promise((r) => setTimeout(r, 120)); // outlasts the client abort below
try { ctx.log.info("after abort", {}); afterCloseOk = true; } // would throw if end() already ran
catch (e) { afterCloseErr = String(e); }
return { json: { ok: true } };
},
method: "GET",
path: "/go", // route mounts at /<id>/<path> → /slow/go
}],
};
const app = createApp({ log: createLogger({ level: "none" }), plugins: [plugin] });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const base = `http://localhost:${(app.address() as AddressInfo).port}`;
// Abort the request mid-handler (well before the 120ms), forcing res "close" while it still runs.
const ac = new AbortController();
setTimeout(() => ac.abort(), 20);
await assert.rejects(fetch(base + "/slow/go", { signal: ac.signal })); // the client sees the abort
await new Promise((r) => setTimeout(r, 200)); // let the handler finish post-abort
assert.equal(afterCloseErr, undefined, "ctx.log did not throw after the client disconnected");
assert.ok(afterCloseOk, "the handler logged successfully after close");
// The server is unharmed — a fresh request still succeeds.
assert.equal((await fetch(base + "/slow/go")).status, 200);
});
test("static serving: GET sends body + content-type, HEAD headers only, unsafe paths → 403", async () => {
const get = await fetch(base + "/public/css/styles.css");
assert.equal(get.status, 200);

View File

@@ -1,5 +1,5 @@
import { randomBytes, randomUUID } from "node:crypto";
import { createServer, type Server, type ServerResponse } from "node:http";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import * as ejs from "ejs";
@@ -24,7 +24,7 @@ import { resolveSession, type VerifyOptions } from "./jwt-middleware.ts";
import type { KetoClient } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts";
import { KratosError, type KratosPublic } from "./kratos-public.ts";
import { createLogger, type Log, requestLogger } from "./logger.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";
@@ -110,24 +110,10 @@ export function createApp(options: AppOptions = {}): Server {
res.end(html);
};
return createServer(async (req, res) => {
// Per-request log + trace span (§9): 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 or truncated request is
// still logged and its span flushed; it fires once. Logging must never crash a served request,
// so the access line is guarded too — then end() exports the span (a no-op when OTLP is off).
const startMs = Date.now();
const reqLog = requestLogger(log, {
requestId: randomUUID(),
...(typeof req.headers.traceparent === "string" ? { traceparent: req.headers.traceparent } : {}),
});
res.on("close", () => {
try {
// path only (no query — it may carry tokens); method/status are header-safe here.
reqLog.info("request", { method: req.method ?? "GET", ms: Date.now() - startMs, path: (req.url ?? "/").split("?", 1)[0] ?? "/", status: res.statusCode });
} catch { /* never let logging crash a served request */ }
void reqLog.end().catch(() => {}); // never let a flaky OTLP collector crash a served request
});
// The request handler. Run inside runWithLog (below) so the per-request logger is ambient: every
// outbound fetch (the Ory clients via tracedFetch) and any deep module joins this request's trace
// and correlation with no logger threaded through their signatures.
const handleRequest = async (req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<void> => {
try {
const method = req.method ?? "GET";
const pathname = new URL(req.url ?? "/", "http://localhost").pathname;
@@ -179,7 +165,7 @@ export function createApp(options: AppOptions = {}): Server {
// base context (no route params yet); reused for onRequest.
const ctx = buildContext(req, res, {
user, verifyCsrf,
log: reqLog, user, verifyCsrf,
...(anyRequestHooks ? { chrome: chrome() } : {}),
});
@@ -200,11 +186,12 @@ export function createApp(options: AppOptions = {}): Server {
// CSRF cookie is set so those forms have a valid double-submit token.
const match = matchRoute(plugins, method, pathname);
if (match) {
const routeCtx = buildContext(req, res, { chrome: chrome(), params: match.params, user, verifyCsrf });
const routeCtx = buildContext(req, res, { chrome: chrome(), log: reqLog, params: match.params, user, verifyCsrf });
if (!isAuthorized(match.route, routeCtx.roles)) {
// Anonymous → sign in (like the built-in screens' requireSession); a signed-in user who
// simply lacks the role gets the 403 page.
if (!routeCtx.user) { res.writeHead(303, { location: "/login" }).end(); return; }
reqLog.warn("forbidden: missing role", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
return;
}
@@ -336,6 +323,7 @@ export function createApp(options: AppOptions = {}): Server {
if (method === "POST") {
const form = await readFormBody(req);
if (!verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted: form.get(CSRF_FIELD) })) {
reqLog.warn("csrf rejected", { path: pathname });
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
return;
}
@@ -404,11 +392,13 @@ export function createApp(options: AppOptions = {}): Server {
if (pathname === "/logout" && method === "POST" && kratos) {
const form = await readFormBody(req);
if (!verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted: form.get(CSRF_FIELD) })) {
reqLog.warn("csrf rejected", { path: pathname });
sendHtml(res, 403, await render("403", { title: "Forbidden" }));
return;
}
const flow = await kratos.createLogoutFlow(req.headers.cookie ? { cookie: req.headers.cookie } : {});
res.appendHeader("set-cookie", clearSessionCookie({ secure: secureCookies }));
reqLog.info("logout", { sub: user?.id ?? "" });
res.writeHead(303, { location: flow?.logoutUrl ?? "/login" }).end();
return;
}
@@ -447,6 +437,40 @@ export function createApp(options: AppOptions = {}): Server {
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end("Internal Server Error");
}
}
};
return createServer((req, res) => {
// Per-request log + trace span (§9): 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.
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.
let settled = false;
let closed = false;
const finalize = (): void => { if (settled && closed) void reqLog.end().catch(() => {}); };
res.on("close", () => {
closed = true;
try {
// path only (no query — it may carry tokens); method/status are header-safe here.
reqLog.info("request", { method: req.method ?? "GET", ms: Date.now() - startMs, path: (req.url ?? "/").split("?", 1)[0] ?? "/", status: res.statusCode });
} 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.
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(); });
});
}

View File

@@ -10,7 +10,7 @@ import { existsSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { discoverPlugins } from "./discovery.ts";
import { generateJwks, type JwkSet } from "./gen-jwks.ts";
import { createLogger } from "./logger.ts";
import { createLogger, runWithLog, tracedFetch } from "./logger.ts";
// --- Pure payload builders (the Kratos/Keto request contracts) -----------------------
@@ -134,26 +134,34 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password:
async function main() {
const env = process.env;
// Structured like the web app (§9) so prod logs stay uniform; honour LOG_FORMAT, default text.
const log = createLogger({ format: env["LOG_FORMAT"] === "json" ? "json" : "text" });
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
// Seed `admin` (or ADMIN_ROLES) + every discovered plugin's declared permission tokens, so the
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.token));
const roles = seedRoles(env["ADMIN_ROLES"], declared);
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
const password = env["ADMIN_PASSWORD"] ?? "admin";
const result = await seedAdmin({
email,
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
password,
roles,
// Structured like the web app (§9) so prod logs stay uniform; honour LOG_FORMAT/SERVICE_NAME.
const log = createLogger({
format: env["LOG_FORMAT"] === "json" ? "json" : "text",
...(env["SERVICE_NAME"] ? { serviceName: env["SERVICE_NAME"] } : {}),
});
log.info("admin seeded", { created: result.created, id: result.id, roles: result.roles.join(", ") });
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
// runWithLog makes `log` ambient so seedAdmin's tracedFetch traces the Kratos/Keto seed calls.
await runWithLog(log, async () => {
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
// Seed `admin` (or ADMIN_ROLES) + every discovered plugin's declared permission tokens, so the
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.token));
const roles = seedRoles(env["ADMIN_ROLES"], declared);
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
const password = env["ADMIN_PASSWORD"] ?? "admin";
const result = await seedAdmin({
email,
fetchImpl: tracedFetch,
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
password,
roles,
});
log.info("admin seeded", { created: result.created, id: result.id, roles: result.roles.join(", ") });
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
});
await log.end(); // flush any pending OTLP spans/logs before the one-shot exits
}
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();

View File

@@ -27,6 +27,12 @@ test("loads dev defaults when the environment is empty", () => {
assert.equal(c.logFormat, "text"); // human-readable in dev; prod compose sets json
assert.equal(c.otlpEndpoint, undefined); // OTLP export opt-in; console-only by default
assert.equal(c.otlpProtocol, "http/json");
assert.equal(c.serviceName, "plainpages"); // OTLP service.name default; implementer-overridable
});
test("SERVICE_NAME is overridable so an implementer brands their own logs/traces (§9)", () => {
assert.equal(loadConfig({ SERVICE_NAME: "acme-ops" }).serviceName, "acme-ops");
assert.equal(loadConfig({ SERVICE_NAME: "" }).serviceName, "plainpages"); // empty ⇒ default
});
test("LOG_LEVEL/LOG_FORMAT/OTLP_PROTOCOL are validated enums; OTLP_ENDPOINT an optional URL (§9)", () => {

View File

@@ -33,6 +33,7 @@ export interface Config {
revocationDenylist: boolean; // §9: enable the optional instant role/session revoke denylist
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
secureCookies: boolean;
serviceName: string; // §9: OTLP service.name — an implementer brands their own logs/traces
}
type Env = Record<string, string | undefined>;
@@ -155,5 +156,6 @@ export function loadConfig(env: Env = process.env): Config {
revocationTtlSec: readPosInt(env, "REVOCATION_TTL_SEC", 900),
// Set Secure on our session/CSRF cookies. Off by default (dev runs http); prod (https) sets it.
secureCookies: readBool(env, "SECURE_COOKIES", false),
serviceName: env["SERVICE_NAME"] || "plainpages", // §9 OTLP service.name; empty ⇒ default
};
}

View File

@@ -3,6 +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";
// A req/res pair without a live server — enough to build and inspect a context.
function reqRes(url?: string): { req: IncomingMessage; res: ServerResponse } {
@@ -44,3 +45,10 @@ test("buildContext defaults a missing request URL to /", () => {
const { req, res } = reqRes();
assert.equal(buildContext(req, res).url.pathname, "/");
});
test("buildContext provides a logger: a silent default, or the host's request logger (§9)", () => {
const { req, res } = reqRes("/");
assert.equal(typeof buildContext(req, res).log.info, "function"); // always present (silent default)
const log = createLogger({ level: "none" });
assert.equal(buildContext(req, res, { log }).log, log); // host's request logger threads through
});

View File

@@ -1,5 +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";
// The request context threaded to every route handler (plugin + built-in), built once
// per request by `buildContext`: the router supplies matched path `params`, the §4 JWT
@@ -17,6 +18,10 @@ export interface RequestContext {
// Page chrome (brand/global-nav/user/theme/csrf) a plugin view hands to partials/shell so its
// page renders the native app shell; the host builds it per request (anonymous default otherwise).
chrome: PageChrome;
// Request-scoped logger (§9): structured, in the request's trace. `log.info/warn/error(...)` to
// log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by
// requestId. Additive, stable per the contract; defaults to a silent logger off the request path.
log: Log;
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
req: IncomingMessage;
@@ -31,6 +36,7 @@ export interface RequestContext {
export interface BuildContextOptions {
chrome?: PageChrome;
log?: Log;
params?: Record<string, string>;
user?: User | null;
verifyCsrf?: (submitted: string | null | undefined) => boolean;
@@ -38,6 +44,9 @@ export interface BuildContextOptions {
// Anonymous default chrome — used until the host supplies a real one (built-in routes, tests).
const ANON_CHROME: PageChrome = { brand: { name: "Plainpages" }, csrfToken: "", nav: [], user: { email: "", initials: "G", name: "Guest" } };
// Silent default logger — used off the request path (built-in routes built ad hoc, tests) until the
// host supplies the real request logger. One instance, no output, negligible cost.
const SILENT_LOG = createLogger({ level: "none" });
export function buildContext(
req: IncomingMessage,
@@ -48,6 +57,7 @@ export function buildContext(
const user = options.user ?? null;
return {
chrome: options.chrome ?? ANON_CHROME,
log: options.log ?? SILENT_LOG,
params: options.params ?? {},
query: url.searchParams,
req,

View File

@@ -1,6 +1,7 @@
import type { JsonWebKey } from "node:crypto";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { currentLog } from "./logger.ts";
// JWKS provider: resolve the JWT verify key by the JWS `kid` (todo §4). The middleware calls
// `getKey` per request. `staticJwks` holds a fixed set; `cachingJwks` fetches over the network
@@ -87,6 +88,7 @@ export function cachingJwks(load: () => Promise<JsonWebKey[]>, opts: JwksCacheOp
const hit = pick(keys, kid);
if (hit || kid === undefined) return hit;
if (now() - loadedAt >= minRefetchMs) {
currentLog()?.debug("jwks reload on kid miss (rotation?)", { kid }); // rare — only an unknown kid
try { await refresh(); } catch { /* keep last-good */ }
}
return pick(keys, kid);

View File

@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createLogger, requestLogger, SERVICE_NAME } from "./logger.ts";
import { createLogger, currentLog, requestLogger, runWithLog, SERVICE_NAME, tracedFetch } from "./logger.ts";
// A capture pair so a test reads exactly what hit stdout/stderr without touching the console.
function capture() {
@@ -26,6 +26,11 @@ test("createLogger: tags service.name, routes by severity, gates on level, honou
assert.equal(rec.n, 1); // metadata kept native in JSON
});
test("createLogger: service.name is overridable (implementer sets their own)", () => {
assert.equal(createLogger({}).context["service.name"], SERVICE_NAME); // default
assert.equal(createLogger({ serviceName: "acme-ops" }).context["service.name"], "acme-ops");
});
test("createLogger: level none silences every severity", () => {
const c = capture();
const log = createLogger({ level: "none", stderr: c.stderr, stdout: c.stdout });
@@ -92,3 +97,36 @@ test("requestLogger: a malformed traceparent is ignored, not thrown (starts a fr
const tp = requestLogger(app, { requestId: "x", traceparent: "garbage" }).traceparent();
assert.match(tp, /^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/);
});
test("runWithLog/currentLog: the active request log is ambiently available within the scope", () => {
const app = createLogger({ stderr: () => {}, stdout: () => {} });
assert.equal(currentLog(), undefined); // none outside a request
const req = requestLogger(app, { requestId: "r1" });
const seen = runWithLog(req, () => currentLog());
assert.equal(seen, req);
assert.equal(currentLog(), undefined); // scope ended
});
test("tracedFetch: traces through the active request log (continuing its trace), plain otherwise", async () => {
const orig = globalThis.fetch;
const seen: { traceparent: string | undefined; url: string }[] = [];
globalThis.fetch = async (input, init) => {
seen.push({ traceparent: new Headers(init?.headers).get("traceparent") ?? undefined, url: String(input) });
return new Response("{}", { status: 200 });
};
try {
// Outside a request: no logger, so no traceparent is injected (plain fetch).
await tracedFetch("http://up.test/a");
assert.equal(seen.at(-1)!.traceparent, undefined);
// Inside runWithLog: the call is routed through req.fetch → a traceparent continuing req's trace.
const app = createLogger({ stderr: () => {}, stdout: () => {} });
const req = requestLogger(app, { requestId: "r2", traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01" });
await runWithLog(req, () => tracedFetch("http://up.test/b"));
const tp = seen.at(-1)!.traceparent;
assert.ok(tp, "injects a traceparent inside a request");
assert.equal(tp!.split("-")[1], "0af7651916cd43dd8448eb211c80319c", "continues the request's trace");
} finally {
globalThis.fetch = orig;
}
});

View File

@@ -1,30 +1,34 @@
// Structured logging + basic observability (todo §9), on @larvit/log (zero-dependency, OTLP-native).
// One app-level Log holds the config (level/format/OTLP) and tags every line with service.name;
// each request clones it into a short-lived trace span. Console always; OTLP only when configured.
// An AsyncLocalStorage makes that per-request Log ambiently available, so every outbound `fetch`
// (`tracedFetch`) and any deep module (`currentLog()`) joins the request's trace with no threading.
import { AsyncLocalStorage } from "node:async_hooks";
import { Log, type LogLevel } from "@larvit/log";
export { Log };
export type { LogLevel };
export const SERVICE_NAME = "plainpages"; // OTLP resource attribute — what Loki/Tempo group logs+traces by
export const SERVICE_NAME = "plainpages"; // default OTLP resource attribute — what Loki/Tempo group logs+traces by
export interface LoggerOptions {
format?: "json" | "text";
level?: LogLevel | "none"; // @larvit/log's LogLevel omits "none"; LogConf accepts it to silence all
otlpEndpoint?: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only
otlpProtocol?: "http/json" | "http/protobuf";
serviceName?: string; // OTLP service.name (SERVICE_NAME env); an implementer brands their own logs/traces
stderr?: (msg: string) => void; // injectable so tests read output without the console
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 "plainpages". Level + format are explicit toggles (LOG_LEVEL/LOG_FORMAT
// 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`).
// 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`).
export function createLogger(opts: LoggerOptions = {}): Log {
return new Log({
context: { "service.name": SERVICE_NAME },
context: { "service.name": opts.serviceName || SERVICE_NAME },
format: opts.format ?? "text",
logLevel: opts.level ?? "info",
...(opts.otlpEndpoint ? { otlpHttpBaseURI: opts.otlpEndpoint, otlpProtocol: opts.otlpProtocol ?? "http/json" } : {}),
@@ -33,6 +37,32 @@ export function createLogger(opts: LoggerOptions = {}): Log {
});
}
// The current request's Log, made ambient so deep modules (the Ory clients via tracedFetch, login,
// jwks) join its trace + correlation without threading a logger through every signature.
const requestStore = new AsyncLocalStorage<Log>();
// Run `fn` with `log` as the ambient request logger (app.ts wraps each request). currentLog() reads
// it back; returns undefined outside any request (boot, tests) so callers use `currentLog()?.info(…)`.
export function runWithLog<T>(log: Log, fn: () => T): T {
return requestStore.run(log, fn);
}
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).
export const tracedFetch: typeof fetch = (input, init) => {
const log = currentLog();
if (log && (typeof input === "string" || input instanceof URL)) return log.fetch(input, init);
return 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

View File

@@ -8,6 +8,7 @@
// 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 { KetoClient } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts";
import type { KratosPublic } from "./kratos-public.ts";
@@ -69,6 +70,7 @@ export async function completeLogin(deps: LoginDeps, cookie: string | undefined)
const jwt = tokenized?.tokenized;
if (!jwt) throw new Error("login completion: Kratos tokenizer returned no JWT");
currentLog()?.info("session minted", { roles: roles.join(","), sub: identityId }); // login or TTL re-mint
return { email, identityId, jwt, roles };
}

View File

@@ -6,9 +6,10 @@ import test from "node:test";
import * as api from "./plugin-api.ts";
test("plugin-api re-exports the stable author value surface", () => {
for (const name of ["definePlugin", "can", "check", "GuardError", "requireSession", "parseListQuery", "readFormBody", "CSRF_FIELD"]) {
for (const name of ["definePlugin", "can", "check", "GuardError", "requireSession", "parseListQuery", "readFormBody", "CSRF_FIELD", "tracedFetch", "Log"]) {
assert.ok(name in api && api[name as keyof typeof api] !== undefined, `missing export: ${name}`);
}
assert.equal(typeof api.definePlugin, "function");
assert.equal(typeof api.tracedFetch, "function"); // the request-trace-aware fetch a plugin uses for upstream calls
assert.equal(api.definePlugin({ apiVersion: "1.0.0" }).apiVersion, "1.0.0"); // identity helper works through the barrel
});

View File

@@ -13,3 +13,7 @@ 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";
// Observability (§9): `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";

View File

@@ -9,16 +9,18 @@ import { createJwksProvider } from "./jwks.ts";
import { createKetoClient } from "./keto-client.ts";
import { createKratosAdmin } from "./kratos-admin.ts";
import { createKratosPublic } from "./kratos-public.ts";
import { createLogger } from "./logger.ts";
import { createLogger, tracedFetch } from "./logger.ts";
import { loadMenuConfig } from "./menu-config.ts";
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
// App-level logger (§9): 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.
const log = createLogger({ format: config.logFormat, level: config.logLevel, otlpEndpoint: config.otlpEndpoint, otlpProtocol: config.otlpProtocol });
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 bounded so a hung/silent Ory can't park a request handler forever.
const oryFetch = withTimeout(fetch, config.oryTimeoutSec * 1000);
// Every outbound Ory call is traced through the active request's logger (a client span continuing
// the trace + a propagated traceparent — tracedFetch) and bounded by the Ory timeout, so a hung/
// silent Ory can't park a request handler forever. Off the request path it's a plain timed fetch.
const oryFetch = withTimeout(tracedFetch, config.oryTimeoutSec * 1000);
// Ory clients for the themed self-service routes + login completion (§4).
const kratos = createKratosPublic({ baseUrl: config.kratosPublicUrl, fetchImpl: oryFetch });
const kratosAdmin = createKratosAdmin({ baseUrl: config.kratosAdminUrl, fetchImpl: oryFetch });