Move admin screens (users/groups/roles/oauth2-clients) into a drop-in example plugin; add the ctx.system capability surface
This commit is contained in:
@@ -40,7 +40,6 @@ test("discovers each folder's manifest, sorted, id derived from the folder name"
|
||||
const badCases: Array<{ name: string; files: Record<string, string>; match: RegExp }> = [
|
||||
{ name: "invalid folder name", files: { "Bad_Name/plugin.ts": full("x") }, match: /Bad_Name/ },
|
||||
{ name: "reserved id shadows a host route", files: { "login/plugin.ts": full("login") }, match: /login.*reserved/s },
|
||||
{ name: "reserved admin id shadows the admin screens", files: { "admin/plugin.ts": full("admin") }, match: /admin.*reserved/s },
|
||||
{ name: "reserved oauth2 id shadows the provider routes", files: { "oauth2/plugin.ts": full("oauth2") }, match: /oauth2.*reserved/s },
|
||||
{ name: "missing plugin.ts", files: { "broken/readme.txt": "x" }, match: /broken.*plugin\.ts/s },
|
||||
{ name: "no default export", files: { "named-only/plugin.ts": "export const x = 1;" }, match: /named-only.*default/s },
|
||||
@@ -71,6 +70,13 @@ test("a route + nav node may be marked public and load fine", async (t) => {
|
||||
assert.equal(plugins[0]?.nav?.[0]?.public, true);
|
||||
});
|
||||
|
||||
test("`admin` is not reserved — the admin screens ship as a drop-in plugin mounted at /admin", async (t) => {
|
||||
const dir = scaffold(t, { "admin/plugin.ts": full("admin") });
|
||||
const plugins = await discoverPlugins({ dir });
|
||||
assert.equal(plugins.length, 1);
|
||||
assert.equal(plugins[0]?.id, "admin");
|
||||
});
|
||||
|
||||
test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => {
|
||||
const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ view: "home" }), dashboard: () => ({ view: "dash" }) };` });
|
||||
const plugins = await discoverPlugins({ dir });
|
||||
|
||||
@@ -11,8 +11,20 @@ export type { PageChrome } from "../ui/chrome.ts";
|
||||
export type { NavNode } from "../ui/nav.ts";
|
||||
export { can, check, GuardError, requireSession } from "../auth/guards.ts";
|
||||
export { parseListQuery } from "../ui/list-query.ts";
|
||||
export { paginate } from "../ui/paginate.ts";
|
||||
export type { PageModel } from "../ui/paginate.ts";
|
||||
export { readFormBody } from "../http/body.ts";
|
||||
export { CSRF_FIELD } from "../auth/csrf.ts";
|
||||
// System capabilities for a privileged/system plugin (ctx.system) — the Ory admin clients + the
|
||||
// instant-revoke hook. Undefined unless the host wired them; the built-in admin plugin is the
|
||||
// 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 { 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";
|
||||
export { KratosError } from "../auth/kratos-public.ts";
|
||||
export { HydraError } from "../auth/hydra-admin.ts";
|
||||
// Sanitise an untrusted URL (upstream/user data) before rendering it in an href/src — partials
|
||||
// escape text but not URL schemes, so a `javascript:`/`data:` URL would be live XSS (see docs).
|
||||
export { safeUrl } from "../http/safe-url.ts";
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type Plugin,
|
||||
type PluginManifest,
|
||||
} from "./plugin.ts";
|
||||
import { ADMIN_CLIENTS_BASE, ADMIN_GROUPS_BASE, ADMIN_ROLES_BASE, ADMIN_USERS_BASE } from "../admin/admin-nav.ts";
|
||||
import { AUTH_FLOWS } from "../auth/flow-view.ts";
|
||||
|
||||
// A representative manifest exercising every field — its existence type-checks the contract.
|
||||
@@ -115,12 +114,11 @@ test("findConflicts: each single slot (`home`/`dashboard`) may have one owner
|
||||
|
||||
// Drift guard: RESERVED_PLUGIN_IDS is a hand-maintained mirror of the host's own top-level mounts —
|
||||
// a folder claiming one would silently shadow a built-in route. Derive the segments from the real
|
||||
// route constants so adding a new auth flow or admin screen without reserving its id fails here.
|
||||
test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / field) is NOT reserved", () => {
|
||||
// route constants so adding a new auth flow or provider route without reserving its id fails here.
|
||||
test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / field) and `admin` (a plugin) are NOT reserved", () => {
|
||||
const seg = (path: string): string => path.split("/")[1] ?? ""; // first segment of "/x/y"
|
||||
const builtins = new Set<string>([
|
||||
...Object.keys(AUTH_FLOWS).map(seg), // /login, /recovery, /registration, /settings, /verification
|
||||
seg(ADMIN_USERS_BASE), seg(ADMIN_GROUPS_BASE), seg(ADMIN_ROLES_BASE), seg(ADMIN_CLIENTS_BASE), // → admin
|
||||
"auth", // /auth/complete (login completion)
|
||||
"logout", // POST /logout
|
||||
"oauth2", // /oauth2/login · /consent · /logout (Hydra provider)
|
||||
@@ -129,6 +127,8 @@ test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / f
|
||||
]);
|
||||
for (const id of builtins) assert.ok(RESERVED_PLUGIN_IDS.has(id), `built-in mount "${id}" must be a reserved plugin id`);
|
||||
// "/" is owned by the `home` manifest field (not a /<id> route), so it cannot be shadowed and is
|
||||
// deliberately not reserved — a plugin folder named "home" is legal.
|
||||
// deliberately not reserved — a plugin folder named "home" is legal. `admin` is likewise free: the
|
||||
// admin screens ship as a drop-in plugin mounted at /admin, not a built-in route.
|
||||
assert.equal(RESERVED_PLUGIN_IDS.has("home"), false);
|
||||
assert.equal(RESERVED_PLUGIN_IDS.has("admin"), false);
|
||||
});
|
||||
|
||||
@@ -89,12 +89,13 @@ export function isValidPluginId(id: string): boolean {
|
||||
}
|
||||
|
||||
// Ids the host reserves for its own first-party mount segments (the gated /dashboard, the auth flows,
|
||||
// /auth/complete, /logout, the /admin screens, 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.)
|
||||
// /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.
|
||||
export const RESERVED_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
||||
"admin", "auth", "dashboard", "login", "logout", "oauth2", "public", "recovery", "registration", "settings", "verification",
|
||||
"auth", "dashboard", "login", "logout", "oauth2", "public", "recovery", "registration", "settings", "verification",
|
||||
]);
|
||||
|
||||
export interface Semver {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// System capabilities: privileged host services a first-party/system plugin (the built-in admin
|
||||
// screens are the reference consumer) needs but an ordinary domain plugin does not — the Ory admin
|
||||
// clients and the instant-revoke hook. Exposed on ctx.system and re-exported via #plugin-api.
|
||||
//
|
||||
// Every field is optional: it is present only when the host wired that dependency (Ory configured,
|
||||
// denylist enabled), and ctx.system itself is undefined when the host wired none. A plugin must
|
||||
// treat each as optional and degrade when absent — the host does not fail a request over it.
|
||||
|
||||
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.
|
||||
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
|
||||
kratosAdmin?: KratosAdmin; // identity admin (Kratos); present when the Kratos admin client is wired
|
||||
revoke?: (sub: string) => void; // instant-revoke a subject's live tokens; present when the denylist is on
|
||||
}
|
||||
Reference in New Issue
Block a user