Bump the contract for the new gate, and close the ways it could read as open
CI / full-gate (push) Successful in 2m45s
CI / full-gate (push) Successful in 2m45s
This commit is contained in:
+5
-9
@@ -1,16 +1,13 @@
|
||||
// The coarse gate a route or nav node declares. One home for the rule, so the router and the menu
|
||||
// can never disagree about what a visitor may reach.
|
||||
// One home for the gate rule, so the router and the menu can never disagree about what a visitor
|
||||
// may reach. README → Public pages & menu items.
|
||||
import type { User } from "../http/context.ts";
|
||||
|
||||
// Widest first: whoever passes an earlier gate passes it without holding anything.
|
||||
const GATES = ["public", "session", "permission"] as const;
|
||||
|
||||
// A route or nav node names exactly one of these; discovery refuses two. Omitting all three is the
|
||||
// same as `public`, which is why stating it outright makes an open gate a choice, not an oversight.
|
||||
export interface Gate {
|
||||
permission?: string | undefined; // the Keto Permission the caller must hold, `<resource>:<action>`
|
||||
public?: boolean | undefined; // anyone, signed in or not
|
||||
session?: boolean | undefined; // any signed-in user, no grant to hold; anonymous is sent to /login
|
||||
permission?: string; // the Keto Permission the caller must hold, `<resource>:<action>`
|
||||
public?: boolean; // anyone, signed in or not
|
||||
session?: boolean; // any signed-in user, no grant to hold; anonymous is sent to /login
|
||||
}
|
||||
|
||||
export function allows(gate: Gate, user: User | null): boolean {
|
||||
@@ -19,7 +16,6 @@ export function allows(gate: Gate, user: User | null): boolean {
|
||||
return gate.permission == null || (user?.permissions.includes(gate.permission) ?? false);
|
||||
}
|
||||
|
||||
// Which gates a declaration sets — discovery refuses more than one, since they contradict.
|
||||
export function gatesSet(gate: Gate | null | undefined): string[] {
|
||||
if (gate == null) return [];
|
||||
return GATES.filter((name) => (name === "permission" ? gate.permission != null : gate[name] === true));
|
||||
|
||||
+4
-2
@@ -86,8 +86,10 @@ export interface Reminted {
|
||||
// anonymous instead of re-hitting Ory on every one.
|
||||
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
|
||||
const completed = await completeLogin(deps, cookie);
|
||||
if (!completed) return { setCookie: clearSessionCookie(options), user: null };
|
||||
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.userId, permissions: completed.permissions } };
|
||||
// No email is no session, exactly as `claimsToUser` reads a token carrying none: a User with an
|
||||
// empty email reads as anonymous in the shell, and is a blank key to whatever scopes on it.
|
||||
if (!completed?.email) return { setCookie: clearSessionCookie(options), user: null };
|
||||
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email, id: completed.userId, permissions: completed.permissions } };
|
||||
}
|
||||
|
||||
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
|
||||
|
||||
@@ -609,6 +609,7 @@ test("guards map to responses: requireSession → /login, a failed can/check →
|
||||
{ handler: (ctx) => { if (!can(ctx, "admin")) throw new GuardError(403, "no"); return { html: "ok" }; }, method: "GET", path: "/admin-only" },
|
||||
{ handler: async (ctx) => { if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id ?? "", relation: "view" }))) throw new GuardError(403, "no"); return { html: "seen" }; }, method: "GET", path: "/doc/:id" },
|
||||
{ handler: () => ({ html: "gated" }), method: "GET", path: "/gated", permission: "secret:read" }, // declarative route gate
|
||||
{ handler: () => ({ html: "mine" }), method: "GET", path: "/mine", session: true }, // declarative session gate
|
||||
],
|
||||
};
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [guarded] });
|
||||
@@ -642,6 +643,12 @@ test("guards map to responses: requireSession → /login, a failed can/check →
|
||||
assert.equal(gDenied.status, 403);
|
||||
assert.match(await gDenied.text(), /403/); // the rendered 403.ejs over HTTP
|
||||
assert.equal((await fetch(url + "/guarded/gated", auth(["secret:read"]))).status, 200);
|
||||
|
||||
// declarative `session` gate: anonymous → sign in, and any signed-in user through, grant or none.
|
||||
const sAnon = await fetch(url + "/guarded/mine", { redirect: "manual" });
|
||||
assert.equal(sAnon.status, 303);
|
||||
assert.equal(sAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fmine");
|
||||
assert.equal((await fetch(url + "/guarded/mine", auth([]))).status, 200);
|
||||
});
|
||||
|
||||
test("plugin hooks: onRequest can short-circuit a request and onResponse observes the handler result", async (t) => {
|
||||
|
||||
@@ -64,6 +64,8 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
|
||||
{ name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: true, permission: "x:read" }] };` }, match: /contranav.*public.*permission/s },
|
||||
{ name: "a route marked session AND permission is contradictory", files: { "contrasess/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", session: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contrasess.*session.*permission/s },
|
||||
{ name: "a route marked public AND session is contradictory", files: { "contrapub/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, session: true, handler: () => ({ html: "x" }) }] };` }, match: /contrapub.*public.*session/s },
|
||||
{ name: "a route whose session flag is a truthy non-boolean is refused, not read as ungated", files: { "truthy/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", session: "yes", handler: () => ({ html: "x" }) }] };` }, match: /truthy.*session.*true/s },
|
||||
{ name: "a nav node whose public flag is a truthy non-boolean is refused too", files: { "truthynav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: 1 }] };` }, match: /truthynav.*public.*true/s },
|
||||
{ name: "a nav node marked session AND permission is contradictory", files: { "contrasessnav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", session: true, permission: "x:read" }] };` }, match: /contrasessnav.*session.*permission/s },
|
||||
// A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not
|
||||
// only in the admin GUI, so it holds for a plugin installed without that GUI.
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { gatesSet } from "../auth/gate.ts";
|
||||
import { type Gate, gatesSet } from "../auth/gate.ts";
|
||||
import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
|
||||
import { settingsDeclError } from "./settings.ts";
|
||||
import { isValidStoragePluginId, MAX_STORAGE_PLUGIN_ID_LENGTH } from "./storage.ts";
|
||||
@@ -147,9 +147,9 @@ function shapeError(manifest: PluginManifest): string | null {
|
||||
const settings = settingsDeclError(manifest.settings);
|
||||
if (settings) return settings;
|
||||
}
|
||||
// Two gates on one route or nav node contradict each other — "open to all" vs "needs a session"
|
||||
// vs "needs this permission". Refuse rather than silently pick one, so intent stays unambiguous.
|
||||
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
||||
const flag = gateFlagError(`route "${route?.method} ${route?.path}"`, route);
|
||||
if (flag) return flag;
|
||||
const gates = gatesSet(route);
|
||||
if (gates.length > 1) return `route "${route?.method} ${route?.path}" sets ${gates.join(" and ")}; a route names exactly one gate — public, session or permission`;
|
||||
}
|
||||
@@ -172,9 +172,19 @@ function shapeError(manifest: PluginManifest): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Recurse the nav fragment: a node naming more than one gate is contradictory, same as a route.
|
||||
// A truthy non-boolean sets no gate at all, so `session: "yes"` would read as an open page.
|
||||
function gateFlagError(what: string, gate: Gate | null | undefined): string | null {
|
||||
for (const flag of ["public", "session"] as const) {
|
||||
const value = gate?.[flag];
|
||||
if (value !== undefined && typeof value !== "boolean") return `${what} sets ${flag} to ${JSON.stringify(value)}; a gate is declared with \`true\``;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findNavGateContradiction(nodes: PluginManifest["nav"]): string | null {
|
||||
for (const node of Array.isArray(nodes) ? nodes : []) {
|
||||
const flag = gateFlagError(`nav node "${node?.label ?? node?.id ?? "?"}"`, node);
|
||||
if (flag) return flag;
|
||||
const gates = gatesSet(node);
|
||||
if (gates.length > 1) return `nav node "${node?.label ?? node?.id ?? "?"}" sets ${gates.join(" and ")}; a node names exactly one gate — public, session or permission`;
|
||||
const inChild = findNavGateContradiction(node?.children);
|
||||
|
||||
@@ -39,7 +39,6 @@ export { CSRF_FIELD } from "../auth/csrf.ts";
|
||||
// reference consumer. The Ory client types + their error classes are re-exported so a system
|
||||
// plugin can type against them and `instanceof`-match their errors. See README → System capabilities.
|
||||
export type { SystemCapabilities } from "./system.ts";
|
||||
export type { Gate } from "../auth/gate.ts";
|
||||
export type { Identity, KratosAdmin, RecoveryCode } from "../auth/kratos-admin.ts";
|
||||
export type { ExpandTree, KetoClient, RelationQuery, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
|
||||
export type { HydraAdmin, OAuth2Client } from "../auth/hydra-admin.ts";
|
||||
|
||||
@@ -11,7 +11,7 @@ import { envName, type SettingDecl, type SettingsOf } from "./settings.ts";
|
||||
import type { StorageCredentials } from "./storage.ts";
|
||||
|
||||
// The Plainpages release this contract ships in — see README → Contract versioning.
|
||||
export const HOST_API_VERSION = "0.3.0";
|
||||
export const HOST_API_VERSION = "0.4.0";
|
||||
|
||||
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
|
||||
|
||||
|
||||
+2
-3
@@ -10,7 +10,7 @@ import { composeNav, type NavNode } from "./nav.ts";
|
||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
import { branding, shellUser, type ShellUser } from "./shell-context.ts";
|
||||
|
||||
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard" };
|
||||
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard", session: true };
|
||||
|
||||
export interface PageChrome {
|
||||
brand: { logo?: string; name: string; sub?: string };
|
||||
@@ -35,8 +35,7 @@ export interface ChromeOptions {
|
||||
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||
const t = opts.t ?? ENGLISH;
|
||||
const carryLocale = opts.localeHref ?? ((href: string) => href);
|
||||
// Dashboard is gated, so an anonymous click would only dead-end at /login.
|
||||
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
|
||||
const fragments: NavNode[][] = [[DASHBOARD_NAV]];
|
||||
// A plugin's nav labels are keys in *its* catalog, so translate each fragment with that plugin's
|
||||
// translator before merging. composeNav then runs the core one over the result; already-translated
|
||||
// text passes through it.
|
||||
|
||||
Reference in New Issue
Block a user