Bump the contract for the new gate, and close the ways it could read as open
CI / full-gate (push) Successful in 2m45s

This commit is contained in:
2026-09-02 08:36:16 +02:00
parent a17ed96b54
commit bf146c07e7
19 changed files with 57 additions and 44 deletions
+3
View File
@@ -188,6 +188,9 @@ Revisit only if the stated reason stops holding.
the visitor's own — their upstream account, their own tokens — has no distinction a permission could the visitor's own — their upstream account, their own tokens — has no distinction a permission could
name; the alternative, granting every newly registered user a permission, couples the identity name; the alternative, granting every newly registered user a permission, couples the identity
lifecycle to a Keto write that nothing retries when it fails. lifecycle to a Keto write that nothing retries when it fails.
- **The reference plugin's two shift pages duplicate a view model and markup on purpose.** An example
is read far more often than it is changed, and each page reads top to bottom on its own. **Valid
while `examples/plugins/scheduling` stays a teaching artifact rather than a maintained product.**
- **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry - **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry
`canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders, `canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders,
disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create
+7 -8
View File
@@ -47,7 +47,7 @@ folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.
import { definePlugin } from "@plainpages/plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({ export default definePlugin({
apiVersion: "0.3.0", apiVersion: "0.4.0",
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
routes: [ routes: [
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) }, { method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
@@ -349,7 +349,7 @@ import { definePlugin } from "@plainpages/plugin-api";
import { listThings, createThings } from "./handlers.ts"; import { listThings, createThings } from "./handlers.ts";
export default definePlugin({ export default definePlugin({
apiVersion: "0.3.0", // semver string of the host contract this plugin was built against (see Versioning) apiVersion: "0.4.0", // semver string of the host contract this plugin was built against (see Versioning)
// Nav fragment, merged into the global menu and permission-filtered per user. // Nav fragment, merged into the global menu and permission-filtered per user.
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts). // `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
@@ -471,7 +471,7 @@ import { definePlugin } from "@plainpages/plugin-api";
import { landing, board } from "./pages.ts"; import { landing, board } from "./pages.ts";
export default definePlugin({ export default definePlugin({
apiVersion: "0.3.0", apiVersion: "0.4.0",
home: landing, // owns "/" — the public front page home: landing, // owns "/" — the public front page
dashboard: board, // owns "/dashboard" — the post-login app home dashboard: board, // owns "/dashboard" — the post-login app home
}); });
@@ -570,9 +570,8 @@ system plugins you author or vendor. An ordinary domain plugin ignores it.
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
applies the central override and then **filters per user** by the permissions in the session JWT: a applies the central override and then **filters per user** by the permissions in the session JWT: a
node shows iff it is `public`, is `session` and someone is signed in, declares no `permission`, or the node shows iff it is `public`, is `session` and someone is signed in, declares no `permission`, or
user holds that name. A node's `icon` the user holds that name. A node's `icon` is a **Lucide icon** by sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are
is a **Lucide icon** by sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are
`ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there. `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there.
**Gating a section header.** A `permission` on the header takes the whole subtree with it. When the **Gating a section header.** A `permission` on the header takes the whole subtree with it. When the
@@ -757,7 +756,7 @@ camel humps both becoming underscores — so `upstream` on the `scheduling` plug
```ts ```ts
export default definePlugin({ export default definePlugin({
apiVersion: "0.3.0", apiVersion: "0.4.0",
settings: [ settings: [
{ key: "upstream", type: "url", required: true, description: "Base URL of the backend" }, { key: "upstream", type: "url", required: true, description: "Base URL of the backend" },
{ key: "pageSize", type: "number", default: 25 }, { key: "pageSize", type: "number", default: 25 },
@@ -811,7 +810,7 @@ import { definePlugin } from "@plainpages/plugin-api";
let sql: ReturnType<typeof postgres>; let sql: ReturnType<typeof postgres>;
export default definePlugin({ export default definePlugin({
apiVersion: "0.3.0", apiVersion: "0.4.0",
storage: true, storage: true,
hooks: { hooks: {
onBoot: async (boot) => { onBoot: async (boot) => {
+4 -2
View File
@@ -180,8 +180,10 @@ test("the reference plugin: public Overview is open to all, My shifts takes any
await expect(page.locator('.sidebar a[href="/scheduling/mine"]')).toHaveCount(1); // session gate: a session is enough await expect(page.locator('.sidebar a[href="/scheduling/mine"]')).toHaveCount(1); // session gate: a session is enough
// And the page itself renders for that same member, holding no permission at all. This stack runs // And the page itself renders for that same member, holding no permission at all. This stack runs
// no shifts upstream, so the list degrades to its empty state — which still names whose page it is. // no shifts upstream, so it also pins the degraded page: the reason, never a 500 and never a claim
// about what is assigned. The working page is asserted against a real upstream in full-flow.spec.
await page.goto("/scheduling/mine"); await page.goto("/scheduling/mine");
await expect(page.getByRole("heading", { name: "My shifts" })).toBeVisible(); await expect(page.getByRole("heading", { name: "My shifts" })).toBeVisible();
await expect(page.getByText("No shifts are assigned to demo@plainpages.local.")).toBeVisible(); await expect(page.getByText("Couldn't reach the scheduling service")).toBeVisible();
await expect(page.getByText("No shifts are assigned to")).toHaveCount(0);
}); });
+1 -1
View File
@@ -28,7 +28,7 @@ const clients = on("oauth2-clients");
const pluginSettings = on("plugin-settings"); const pluginSettings = on("plugin-settings");
export default definePlugin({ export default definePlugin({
apiVersion: "0.3.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION
nav: [ADMIN_NAV], nav: [ADMIN_NAV],
-4
View File
@@ -26,10 +26,6 @@ What it demonstrates:
The plugin holds **no state** — data lives upstream (README → *Stateless*). Handlers are thin and The plugin holds **no state** — data lives upstream (README → *Stateless*). Handlers are thin and
`fetch` is injectable, so they unit-test as pure functions (`shifts.test.ts`). `fetch` is injectable, so they unit-test as pure functions (`shifts.test.ts`).
The shifts list and "My shifts" repeat a little view-model and markup rather than sharing a
parameterised one: an example is read far more often than it is changed, and each page is meant to be
followed top to bottom on its own.
## Upstream ## Upstream
Set `PLUGIN_SETTING_SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory Set `PLUGIN_SETTING_SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory
+1 -3
View File
@@ -12,7 +12,7 @@ let upstreamUrl = "";
const upstream = createUpstream(() => upstreamUrl); const upstream = createUpstream(() => upstreamUrl);
export default definePlugin({ export default definePlugin({
apiVersion: "0.3.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION
// onBoot runs after discovery, before the server listens — where a plugin receives its resolved // onBoot runs after discovery, before the server listens — where a plugin receives its resolved
// settings. A malformed URL already failed the boot by then; the host validated the declared type. // settings. A malformed URL already failed the boot by then; the host validated the declared type.
@@ -39,8 +39,6 @@ export default definePlugin({
{ description: "Create and edit shifts", name: WRITE }, { description: "Create and edit shifts", name: WRITE },
], ],
// Mounted under /scheduling; `permission` gates before the handler runs. The overview is `public`
// (anyone may reach /scheduling, signed in or not); the rest need a permission.
routes: [ routes: [
{ handler: overview(), method: "GET", path: "/", public: true }, { handler: overview(), method: "GET", path: "/", public: true },
{ handler: myShifts(upstream), method: "GET", path: "/mine", session: true }, { handler: myShifts(upstream), method: "GET", path: "/mine", session: true },
+2 -1
View File
@@ -218,7 +218,8 @@ export function buildMineModel(opts: { chrome: PageChrome; email: string; error?
table: { table: {
caption: t("scheduling.mine.title"), caption: t("scheduling.mine.title"),
columns: [{ label: t("scheduling.table.shift") }, { label: t("scheduling.table.start") }, { label: t("scheduling.table.end") }], columns: [{ label: t("scheduling.table.shift") }, { label: t("scheduling.table.start") }, { label: t("scheduling.table.end") }],
emptyText: t("scheduling.mine.empty", { email: opts.email }), // Only when the upstream answered: a failed read knows nothing about what is assigned.
...(opts.error === undefined ? { emptyText: t("scheduling.mine.empty", { email: opts.email }) } : {}),
rows: opts.shifts.map((s) => ({ cells: [{ rowHeader: { text: s.title } }, s.start, s.end], name: s.title })), rows: opts.shifts.map((s) => ({ cells: [{ rowHeader: { text: s.title } }, s.start, s.end], name: s.title })),
}, },
title: t("scheduling.mine.title"), title: t("scheduling.mine.title"),
+1 -2
View File
@@ -1,6 +1,5 @@
<%# <%#
Scheduling · the visitor's own shifts (reference plugin). Reached behind `session: true`, so Scheduling · the visitor's own shifts (reference plugin).
ctx.user is always set by the time this renders.
Data: chrome, title, breadcrumbs, count, table, error? Data: chrome, title, breadcrumbs, count, table, error?
%><% %><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav }); const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
+1 -1
View File
@@ -12,7 +12,7 @@ test("readHostApiVersion pulls the constant out of the real source, and returns
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => { test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
// Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that // Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
// makes an accidental edit fail here rather than at release time. // makes an accidental edit fail here rather than at release time.
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.3.0"); assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.4.0");
}); });
test("every author-facing apiVersion sample matches the shipped contract", () => { test("every author-facing apiVersion sample matches the shipped contract", () => {
+1 -1
View File
@@ -182,7 +182,7 @@ into the app. Create `plugins/hello/plugin.ts`:
import { definePlugin } from "@plainpages/plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({ export default definePlugin({
apiVersion: "0.3.0", apiVersion: "0.4.0",
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
routes: [ routes: [
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) }, { method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
+5 -9
View File
@@ -1,16 +1,13 @@
// The coarse gate a route or nav node declares. One home for the rule, so the router and the menu // One home for the gate rule, so the router and the menu can never disagree about what a visitor
// can never disagree about what a visitor may reach. // may reach. README → Public pages & menu items.
import type { User } from "../http/context.ts"; 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; 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 { export interface Gate {
permission?: string | undefined; // the Keto Permission the caller must hold, `<resource>:<action>` permission?: string; // the Keto Permission the caller must hold, `<resource>:<action>`
public?: boolean | undefined; // anyone, signed in or not public?: boolean; // anyone, signed in or not
session?: boolean | undefined; // any signed-in user, no grant to hold; anonymous is sent to /login session?: boolean; // any signed-in user, no grant to hold; anonymous is sent to /login
} }
export function allows(gate: Gate, user: User | null): boolean { 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); 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[] { export function gatesSet(gate: Gate | null | undefined): string[] {
if (gate == null) return []; if (gate == null) return [];
return GATES.filter((name) => (name === "permission" ? gate.permission != null : gate[name] === true)); return GATES.filter((name) => (name === "permission" ? gate.permission != null : gate[name] === true));
+4 -2
View File
@@ -86,8 +86,10 @@ export interface Reminted {
// anonymous instead of re-hitting Ory on every one. // anonymous instead of re-hitting Ory on every one.
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> { export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
const completed = await completeLogin(deps, cookie); const completed = await completeLogin(deps, cookie);
if (!completed) return { setCookie: clearSessionCookie(options), user: null }; // No email is no session, exactly as `claimsToUser` reads a token carrying none: a User with an
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.userId, permissions: completed.permissions } }; // 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 // Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
+7
View File
@@ -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: (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: 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: "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] }); 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.equal(gDenied.status, 403);
assert.match(await gDenied.text(), /403/); // the rendered 403.ejs over HTTP assert.match(await gDenied.text(), /403/); // the rendered 403.ejs over HTTP
assert.equal((await fetch(url + "/guarded/gated", auth(["secret:read"]))).status, 200); 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) => { test("plugin hooks: onRequest can short-circuit a request and onResponse observes the handler result", async (t) => {
+2
View File
@@ -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 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 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 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 }, { 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 // 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. // only in the admin GUI, so it holds for a plugin installed without that GUI.
+14 -4
View File
@@ -7,7 +7,7 @@
import { existsSync, readdirSync, readFileSync } from "node:fs"; import { existsSync, readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; 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 { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
import { settingsDeclError } from "./settings.ts"; import { settingsDeclError } from "./settings.ts";
import { isValidStoragePluginId, MAX_STORAGE_PLUGIN_ID_LENGTH } from "./storage.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); const settings = settingsDeclError(manifest.settings);
if (settings) return 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 : []) { 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); 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`; 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; 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 { function findNavGateContradiction(nodes: PluginManifest["nav"]): string | null {
for (const node of Array.isArray(nodes) ? nodes : []) { 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); 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`; 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); const inChild = findNavGateContradiction(node?.children);
-1
View File
@@ -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 // 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. // plugin can type against them and `instanceof`-match their errors. See README → System capabilities.
export type { SystemCapabilities } from "./system.ts"; 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 { Identity, KratosAdmin, RecoveryCode } from "../auth/kratos-admin.ts";
export type { ExpandTree, KetoClient, RelationQuery, RelationTuple, SubjectSet } from "../auth/keto-client.ts"; export type { ExpandTree, KetoClient, RelationQuery, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
export type { HydraAdmin, OAuth2Client } from "../auth/hydra-admin.ts"; export type { HydraAdmin, OAuth2Client } from "../auth/hydra-admin.ts";
+1 -1
View File
@@ -11,7 +11,7 @@ import { envName, type SettingDecl, type SettingsOf } from "./settings.ts";
import type { StorageCredentials } from "./storage.ts"; import type { StorageCredentials } from "./storage.ts";
// The Plainpages release this contract ships in — see README → Contract versioning. // 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"; export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
+2 -3
View File
@@ -10,7 +10,7 @@ import { composeNav, type NavNode } from "./nav.ts";
import type { Plugin } from "../plugin-host/plugin.ts"; import type { Plugin } from "../plugin-host/plugin.ts";
import { branding, shellUser, type ShellUser } from "./shell-context.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 { export interface PageChrome {
brand: { logo?: string; name: string; sub?: string }; brand: { logo?: string; name: string; sub?: string };
@@ -35,8 +35,7 @@ export interface ChromeOptions {
export function buildPluginChrome(opts: ChromeOptions): PageChrome { export function buildPluginChrome(opts: ChromeOptions): PageChrome {
const t = opts.t ?? ENGLISH; const t = opts.t ?? ENGLISH;
const carryLocale = opts.localeHref ?? ((href: string) => href); const carryLocale = opts.localeHref ?? ((href: string) => href);
// Dashboard is gated, so an anonymous click would only dead-end at /login. const fragments: NavNode[][] = [[DASHBOARD_NAV]];
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 // 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 // translator before merging. composeNav then runs the core one over the result; already-translated
// text passes through it. // text passes through it.
+1 -1
View File
@@ -14,7 +14,7 @@
<p>${t("dashboard.starter.intro")}</p> <p>${t("dashboard.starter.intro")}</p>
<p>${t("dashboard.starter.replace")}</p> <p>${t("dashboard.starter.replace")}</p>
<pre class="code-block"><code>export default definePlugin({ <pre class="code-block"><code>export default definePlugin({
apiVersion: "0.3.0", apiVersion: "0.4.0",
// view names plugins/&lt;id&gt;/views/&lt;view&gt;.ejs, rendered in this same shell // view names plugins/&lt;id&gt;/views/&lt;view&gt;.ejs, rendered in this same shell
dashboard: (ctx) =&gt; ({ view: "dashboard", data: { /* … */ } }), dashboard: (ctx) =&gt; ({ view: "dashboard", data: { /* … */ } }),
});</code></pre> });</code></pre>