Say user throughout, noting Ory's identity naming in the docs
This commit is contained in:
+28
-28
@@ -105,7 +105,7 @@ test("plugins replace either landing: `home` owns the public /, `dashboard` owns
|
||||
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||
const portal: Plugin = {
|
||||
apiVersion: "1.0.0",
|
||||
dashboard: (ctx) => ({ data: { chrome: ctx.chrome, user: ctx.identity }, view: "board" }),
|
||||
dashboard: (ctx) => ({ data: { chrome: ctx.chrome, user: ctx.user }, view: "board" }),
|
||||
home: () => ({ data: { brand: "Acme" }, view: "welcome" }),
|
||||
id: "portal",
|
||||
};
|
||||
@@ -125,7 +125,7 @@ test("plugins replace either landing: `home` owns the public /, `dashboard` owns
|
||||
assert.equal(board.status, 200);
|
||||
const html = await board.text();
|
||||
assert.match(html, /<h1 class="page-title">My Portal<\/h1>/); // its own title in the native shell
|
||||
assert.match(html, /Hi a@b\.c/); // its handler rendered, with ctx.identity
|
||||
assert.match(html, /Hi a@b\.c/); // its handler rendered, with ctx.user
|
||||
assert.doesNotMatch(html, /Avery Kline/); // the built-in mock People list is gone — fully replaced
|
||||
});
|
||||
|
||||
@@ -516,7 +516,7 @@ test("a plugin view renders the native chrome; its forms are CSRF-guarded via ct
|
||||
assert.equal(ok.status, 303);
|
||||
});
|
||||
|
||||
// JWT middleware: a verified session cookie populates ctx.identity/permissions, which the gate reads.
|
||||
// JWT middleware: a verified session cookie populates ctx.user/permissions, which the gate reads.
|
||||
// The key + mintJwt + session() helper are hoisted above the shared `server` (top of file).
|
||||
test("a verified session JWT authorizes a permission-gated route; no cookie / expired token → sign in", async (t) => {
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
|
||||
@@ -569,7 +569,7 @@ test("session re-mint: an expired JWT backed by a live Kratos session is silentl
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["demo:read"], sub: "u1" });
|
||||
const live = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session);
|
||||
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "demo:read", relation: "granted", subject_id: "identity:u1" }] }) });
|
||||
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "demo:read", relation: "granted", subject_id: "user:u1" }] }) });
|
||||
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["demo:read"], sub: "u1" })}; plainpages_session=s`;
|
||||
|
||||
// Live Kratos session: the lapsed token is re-minted — the gated route runs AND a fresh cookie rides the response.
|
||||
@@ -727,7 +727,7 @@ test("themed auth GET: anonymous inits a flow (CSRF relay, stale→restart); a s
|
||||
});
|
||||
|
||||
test("themed auth GET: an existing Kratos session (no app JWT yet) recovers via /auth/complete, never 500", async (t) => {
|
||||
// After registration's `session` hook the user holds a Kratos session but no app JWT — so ctx.identity
|
||||
// After registration's `session` hook the user holds a Kratos session but no app JWT — so ctx.user
|
||||
// is null and the "already signed in" short-circuit can't fire. Initialising a login/registration
|
||||
// flow then returns Kratos 400 `session_already_available`; recover by completing login (mint the
|
||||
// JWT from the live session), preserving return_to — never fall through to the catch-all 500.
|
||||
@@ -884,7 +884,7 @@ test("login completion (/auth/complete): a live session mints the JWT cookie; no
|
||||
let projected: unknown;
|
||||
const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session);
|
||||
const kratosAdmin = stubAdmin({ updateMetadataPublic: async (_id, meta) => { projected = meta; return identity; } });
|
||||
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `identity:${identity.id}` }] }) });
|
||||
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${identity.id}` }] }) });
|
||||
const complete = async (app: ReturnType<typeof createApp>, cookie?: string, returnTo?: string) => {
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
@@ -1178,7 +1178,7 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
|
||||
{ id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } },
|
||||
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } },
|
||||
];
|
||||
const tuples: RelationTuple[] = [{ namespace: "Group", object: "eng", relation: "members", subject_id: `identity:${ada}` }];
|
||||
const tuples: RelationTuple[] = [{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }];
|
||||
const keto = fakeKeto(tuples);
|
||||
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const { get, post, token, url } = await adminHarness(t, { keto, kratosAdmin });
|
||||
@@ -1192,26 +1192,26 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
|
||||
|
||||
// Create: the form renders; a valid post writes the first-member tuple and redirects to the detail.
|
||||
assert.match(await (await get("/admin/groups/new")).text(), /Create group/);
|
||||
const created = await post("/admin/groups", `_csrf=${token}&name=design&member=identity:${grace}`);
|
||||
const created = await post("/admin/groups", `_csrf=${token}&name=design&member=user:${grace}`);
|
||||
assert.equal(created.status, 303);
|
||||
assert.equal(created.headers.get("location"), "/admin/groups/design");
|
||||
assert.ok(tuples.some((tp) => tp.object === "design" && tp.subject_id === `identity:${grace}`));
|
||||
assert.ok(tuples.some((tp) => tp.object === "design" && tp.subject_id === `user:${grace}`));
|
||||
|
||||
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
|
||||
const before = tuples.length;
|
||||
assert.equal((await post("/admin/groups", `_csrf=${token}&name=Bad Name&member=identity:${grace}`)).status, 400);
|
||||
assert.equal((await post("/admin/groups", `_csrf=${token}&name=eng&member=identity:${grace}`)).status, 400); // already exists
|
||||
assert.equal((await post("/admin/groups", `name=x&member=identity:${grace}`)).status, 403);
|
||||
assert.equal((await post("/admin/groups", `_csrf=${token}&name=Bad Name&member=user:${grace}`)).status, 400);
|
||||
assert.equal((await post("/admin/groups", `_csrf=${token}&name=eng&member=user:${grace}`)).status, 400); // already exists
|
||||
assert.equal((await post("/admin/groups", `name=x&member=user:${grace}`)).status, 403);
|
||||
assert.equal(tuples.length, before);
|
||||
|
||||
// Detail: lists the current member by email.
|
||||
assert.match(await (await get("/admin/groups/eng")).text(), /ada@example\.com/);
|
||||
|
||||
// Add a member, then remove it.
|
||||
await post("/admin/groups/eng/members", `_csrf=${token}&member=identity:${grace}`);
|
||||
assert.ok(tuples.some((tp) => tp.object === "eng" && tp.subject_id === `identity:${grace}`));
|
||||
await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=identity:${grace}`);
|
||||
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `identity:${grace}`));
|
||||
await post("/admin/groups/eng/members", `_csrf=${token}&member=user:${grace}`);
|
||||
assert.ok(tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
||||
await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`);
|
||||
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
||||
|
||||
// Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
||||
assert.match(await (await get("/admin/groups/eng/delete")).text(), /Cancel/);
|
||||
@@ -1237,8 +1237,8 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
|
||||
];
|
||||
// grace is in the `eng` group; `editor` is an existing permission whose only direct member is ada.
|
||||
const tuples: RelationTuple[] = [
|
||||
{ namespace: "Group", object: "eng", relation: "members", subject_id: `identity:${grace}` },
|
||||
{ namespace: "Permission", object: "editor", relation: "granted", subject_id: `identity:${ada}` },
|
||||
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${grace}` },
|
||||
{ namespace: "Permission", object: "editor", relation: "granted", subject_id: `user:${ada}` },
|
||||
];
|
||||
// Mirror Keto's expand shape: the subject rides on `tuple`, set nodes carry members as children.
|
||||
const expandSet = (set: SubjectSet): ExpandTree => ({
|
||||
@@ -1262,17 +1262,17 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
|
||||
|
||||
// Create: a valid post writes the first-member tuple and redirects to the detail.
|
||||
assert.match(await (await get("/admin/permissions/new")).text(), /Create permission/);
|
||||
const created = await post("/admin/permissions", `_csrf=${token}&name=viewer&member=identity:${ada}`);
|
||||
const created = await post("/admin/permissions", `_csrf=${token}&name=viewer&member=user:${ada}`);
|
||||
assert.equal(created.status, 303);
|
||||
assert.equal(created.headers.get("location"), "/admin/permissions/viewer");
|
||||
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "viewer" && tp.subject_id === `identity:${ada}`));
|
||||
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "viewer" && tp.subject_id === `user:${ada}`));
|
||||
assert.equal(denylist.isRevoked(ada, 0), true); // assigning a permission to a user revokes their stale token so the grant lands now
|
||||
|
||||
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
|
||||
const before = tuples.length;
|
||||
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=Bad Name&member=identity:${ada}`)).status, 400);
|
||||
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=editor&member=identity:${ada}`)).status, 400); // already exists
|
||||
assert.equal((await post("/admin/permissions", `name=x&member=identity:${ada}`)).status, 403);
|
||||
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=Bad Name&member=user:${ada}`)).status, 400);
|
||||
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=editor&member=user:${ada}`)).status, 400); // already exists
|
||||
assert.equal((await post("/admin/permissions", `name=x&member=user:${ada}`)).status, 403);
|
||||
assert.equal(tuples.length, before);
|
||||
|
||||
// Detail: ada (direct) is in the effective-access list; grace (only reachable via a group) is not
|
||||
@@ -1293,8 +1293,8 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
|
||||
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor" && tp.subject_set?.object === "eng"));
|
||||
|
||||
// Unassigning a *user* membership likewise revokes that user's live token, so the loss of access is immediate.
|
||||
await post("/admin/permissions/editor/members", `_csrf=${token}&member=identity:${grace}`);
|
||||
await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=identity:${grace}`);
|
||||
await post("/admin/permissions/editor/members", `_csrf=${token}&member=user:${grace}`);
|
||||
await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=user:${grace}`);
|
||||
assert.equal(denylist.isRevoked(grace, 0), true);
|
||||
|
||||
// Delete the permission: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
||||
@@ -1305,11 +1305,11 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
|
||||
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor"));
|
||||
|
||||
// Self-protection: the admin permission can't be deleted, nor can you revoke your own admin (sub admin1).
|
||||
tuples.push({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "identity:admin1" });
|
||||
tuples.push({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "user:admin1" });
|
||||
assert.equal((await post("/admin/permissions/admin/delete", `_csrf=${token}`)).status, 400);
|
||||
assert.ok(tuples.some((tp) => tp.object === "admin"));
|
||||
assert.equal((await post("/admin/permissions/admin/members/delete", `_csrf=${token}&member=identity:admin1`)).status, 400);
|
||||
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "identity:admin1"));
|
||||
assert.equal((await post("/admin/permissions/admin/members/delete", `_csrf=${token}&member=user:admin1`)).status, 400);
|
||||
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "user:admin1"));
|
||||
|
||||
// An invalid permission name in the path → 404; malformed %-encoding doesn't 500.
|
||||
assert.equal((await get("/admin/permissions/Bad%20Name")).status, 404);
|
||||
|
||||
+14
-14
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import { type BuiltinRoute, matchBuiltinRoute, type RequestCsrf } from "./builtin-routes.ts";
|
||||
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
|
||||
import { buildContext, type RequestContext, type SessionIdentity } from "./context.ts";
|
||||
import { buildContext, type RequestContext, type User } from "./context.ts";
|
||||
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
|
||||
import type { Denylist } from "../auth/denylist.ts";
|
||||
import { buildDashboardModel } from "../ui/dashboard.ts";
|
||||
@@ -40,7 +40,7 @@ export interface AppOptions {
|
||||
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
|
||||
jwks?: JwksProvider; // verify the session JWT → ctx.identity/permissions; absent ⇒ always anonymous
|
||||
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
|
||||
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion
|
||||
@@ -124,7 +124,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
await sendResult(ctx.res, result, (view, data) => renderView(homePlugin.id, view, data));
|
||||
return null;
|
||||
}
|
||||
return { data: { chrome: ctx.chrome, user: ctx.identity }, view: "home" };
|
||||
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
|
||||
@@ -132,7 +132,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// 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.
|
||||
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult | null> => {
|
||||
if (!ctx.identity) return { redirect: loginRedirect(ctx), status: 303 };
|
||||
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.
|
||||
csrf.setCookie();
|
||||
if (dashboardPlugin) {
|
||||
@@ -141,7 +141,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
await sendResult(ctx.res, result, (view, data) => renderView(dashboardPlugin.id, view, data));
|
||||
return null;
|
||||
}
|
||||
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, identity: ctx.identity, nav: ctx.chrome.nav }) }, view: "index" };
|
||||
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, user: ctx.user, nav: ctx.chrome.nav }) }, view: "index" };
|
||||
};
|
||||
|
||||
// The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/
|
||||
@@ -186,19 +186,19 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the session JWT once (cached JWKS) → ctx.identity/permissions; none/invalid ⇒ anonymous.
|
||||
// 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.
|
||||
let user: SessionIdentity | null = null;
|
||||
let user: User | null = null;
|
||||
if (jwks) {
|
||||
const auth = await resolveSession(req.headers.cookie, jwks, authOptions);
|
||||
user = auth.identity;
|
||||
user = auth.user;
|
||||
if (!user && auth.expired && keto && kratos && kratosAdmin) {
|
||||
try {
|
||||
const reminted = await remintSession({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie, { secure: secureCookies });
|
||||
user = reminted.identity;
|
||||
user = reminted.user;
|
||||
res.appendHeader("set-cookie", reminted.setCookie);
|
||||
} catch (err) {
|
||||
// Ory unreachable (Kratos/Keto 5xx, refused, timeout) — degrade to anonymous instead of
|
||||
@@ -223,10 +223,10 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// 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).
|
||||
let chromeMemo: PageChrome | undefined;
|
||||
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, identity: user }));
|
||||
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }));
|
||||
|
||||
// base context (no route params yet); reused for onRequest hooks and the landing routes.
|
||||
const ctx = buildContext(req, res, { chrome, identity: user, log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
||||
const ctx = buildContext(req, res, { chrome, user, log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
||||
|
||||
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
||||
if (anyRequestHooks) {
|
||||
@@ -245,12 +245,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, identity: user, log: reqLog, params: match.params, verifyCsrf, ...(system ? { system } : {}) });
|
||||
const routeCtx = buildContext(req, res, { chrome, user, log: reqLog, params: match.params, verifyCsrf, ...(system ? { system } : {}) });
|
||||
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.
|
||||
if (!routeCtx.identity) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
|
||||
reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.identity.id });
|
||||
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 render("403", { title: "Forbidden" }));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
|
||||
import { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Socket } from "node:net";
|
||||
import { test } from "node:test";
|
||||
import { buildContext, type SessionIdentity } from "./context.ts";
|
||||
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.
|
||||
@@ -22,7 +22,7 @@ test("buildContext parses the URL, exposes query, and defaults to an anonymous u
|
||||
assert.equal(ctx.query, ctx.url.searchParams); // same instance, not a copy
|
||||
assert.equal(ctx.query.get("q"), "ann");
|
||||
assert.equal(ctx.query.get("page"), "2");
|
||||
assert.equal(ctx.identity, null);
|
||||
assert.equal(ctx.user, null);
|
||||
assert.deepEqual(ctx.permissions, []);
|
||||
assert.deepEqual(ctx.params, {});
|
||||
});
|
||||
@@ -35,9 +35,9 @@ test("buildContext threads path params supplied by the router", () => {
|
||||
|
||||
test("buildContext threads the user and derives permissions from it", () => {
|
||||
const { req, res } = reqRes("/");
|
||||
const user: SessionIdentity = { email: "a@b.c", id: "u1", permissions: ["admin", "editor"] };
|
||||
const ctx = buildContext(req, res, { identity: user });
|
||||
assert.equal(ctx.identity, user);
|
||||
const user: User = { email: "a@b.c", id: "u1", permissions: ["admin", "editor"] };
|
||||
const ctx = buildContext(req, res, { user });
|
||||
assert.equal(ctx.user, user);
|
||||
assert.equal(ctx.permissions, user.permissions); // same reference, never a divergent copy — buildContext is the only writer
|
||||
});
|
||||
|
||||
|
||||
+10
-10
@@ -5,11 +5,12 @@ import { createLogger, type Log } from "../logger.ts";
|
||||
|
||||
// The request context threaded to every route handler (plugin + built-in), built once
|
||||
// per request by `buildContext`: the router supplies matched path `params`, the JWT
|
||||
// middleware supplies `identity` (null until then). The host's single handler argument.
|
||||
// middleware supplies `user` (null until then). The host's single handler argument.
|
||||
|
||||
// The authenticated Kratos identity, projected from verified session JWT claims:
|
||||
// The signed-in user, projected from verified session JWT claims. Ory calls this record an
|
||||
// "identity" (see README); Plainpages says user throughout.
|
||||
// `id` = `sub`, plus `email` and the coarse `permissions` carried in the token.
|
||||
export interface SessionIdentity {
|
||||
export interface User {
|
||||
email: string;
|
||||
id: string;
|
||||
permissions: string[];
|
||||
@@ -19,21 +20,20 @@ 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;
|
||||
// The signed-in Kratos identity, or null when anonymous.
|
||||
identity: SessionIdentity | null;
|
||||
// Request-scoped logger: 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 }
|
||||
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
|
||||
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
permissions: string[]; // identity?.permissions ?? [] — coarse gate without a null-check
|
||||
// Privileged host services (Ory admin clients + instant-revoke) for a system plugin. Undefined
|
||||
// unless the host wired them; every field optional. Ordinary domain plugins ignore it.
|
||||
system?: SystemCapabilities;
|
||||
url: URL;
|
||||
user: User | null; // the signed-in user, or null when anonymous
|
||||
// Gate a first-party form submission: true iff `submitted` matches this request's signed CSRF
|
||||
// cookie (double-submit). The host binds the secret; a plugin calls it after reading its body.
|
||||
verifyCsrf(submitted: string | null | undefined): boolean;
|
||||
@@ -44,7 +44,7 @@ export interface BuildContextOptions {
|
||||
// ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing).
|
||||
// The host's factory is memoised, so the menu composes at most once per request across contexts.
|
||||
chrome?: () => PageChrome;
|
||||
identity?: SessionIdentity | null;
|
||||
user?: User | null;
|
||||
log?: Log;
|
||||
params?: Record<string, string>;
|
||||
system?: SystemCapabilities;
|
||||
@@ -63,18 +63,18 @@ export function buildContext(
|
||||
options: BuildContextOptions = {},
|
||||
): RequestContext {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const identity = options.identity ?? null;
|
||||
const user = options.user ?? null;
|
||||
const buildChrome = options.chrome;
|
||||
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
|
||||
return {
|
||||
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
|
||||
identity,
|
||||
user,
|
||||
log: options.log ?? SILENT_LOG,
|
||||
params: options.params ?? {},
|
||||
query: url.searchParams,
|
||||
req,
|
||||
res,
|
||||
permissions: identity?.permissions ?? [],
|
||||
permissions: user?.permissions ?? [],
|
||||
...(options.system ? { system: options.system } : {}),
|
||||
url,
|
||||
verifyCsrf: options.verifyCsrf ?? (() => false), // fail-closed unless the host binds the secret
|
||||
|
||||
Reference in New Issue
Block a user