§10 public pages + menu items, the blessed explicit alias (todo §10); a plugin may mark a page and its menu option public. A no-permission route/nav node is already anonymous-reachable, so per the human's pick this BLESSES that as a first-class, explicit choice (keep the default; add an explicit alias — not a secure-by-default flip). New optional public?: boolean on Route (src/plugin.ts) + NavNode (src/nav.ts) = "open to everyone, signed in or not", honored outright in isAuthorized (router.ts) + filterByRoles (nav.ts), and MUTUALLY EXCLUSIVE with permission — discovery shapeError recursively rejects a route/nav node setting both, failing the boot loud (never silently picks one). public is filter-only (toRenderNode never emits it). The shell (views/partials/shell.ejs) now renders a Sign in link instead of the profile/sign-out block for an anonymous visitor, so a public page in the native shell (ctx.chrome; ctx.user may be null) isn't a broken "Guest / Sign out". Reference plugin demos it: a public /scheduling Overview route + a public "Overview" nav child (the "Scheduling" header now shows for everyone), the shifts list still behind scheduling:read. Hardened the latent gap the shell newly leans on: claimsToUser rejects an empty email like it does an empty sub. Tests-first (348 → 354 units): router/nav/discovery (public open + reject-both + loads), shell (anon → Sign in, no logout form), app (public route anon-200), shifts (overview handler), jwt-middleware (empty email). Docs: plugin-contract.md ("Public pages & menu items" + route shape + shape-error note) + README (menu system + reference snippet). E2E: visual.spec asserts the public Overview is anon-200 + shown in the member's nav while the gated Shifts redirects/filters. stability-reviewer: APPROVE, no Critical/High/Medium (addressed its one Low — the empty-email hardening). typecheck + 354 units + full scripts/ci.sh gate (visual 10 · auth 1 · oauth 2 · full 7) green.
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
// folder, rename it, point it at your own backend. Full contract: docs/plugin-contract.md.
|
||||
|
||||
import { definePlugin } from "../../src/plugin-api.ts";
|
||||
import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, READ, SHIFTS_PATH, WRITE } from "./shifts.ts";
|
||||
import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
|
||||
|
||||
// The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is
|
||||
// stateless). Configure via env; the dev compose points it at a tiny mock (examples/shifts-upstream).
|
||||
@@ -17,10 +17,14 @@ export default definePlugin({
|
||||
// typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later.
|
||||
hooks: { onBoot: () => assertHttpUrl(upstreamUrl, "SCHEDULING_UPSTREAM") },
|
||||
|
||||
// Merged into the global menu + filtered per user: the "Shifts" leaf shows only for a user holding
|
||||
// `scheduling:read`, so the whole "Scheduling" header disappears for everyone else.
|
||||
// Merged into the global menu + filtered per user. "Overview" is `public`, so the "Scheduling"
|
||||
// header shows for everyone (even signed out); "Shifts" needs `scheduling:read`, so the gated data
|
||||
// stays hidden until a reader signs in (§10 — a plugin may make a page + its menu option public).
|
||||
nav: [{
|
||||
children: [{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", permission: READ }],
|
||||
children: [
|
||||
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true },
|
||||
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", permission: READ },
|
||||
],
|
||||
icon: "i-cal",
|
||||
id: "scheduling",
|
||||
label: "Scheduling",
|
||||
@@ -32,8 +36,10 @@ export default definePlugin({
|
||||
{ description: "Create and edit shifts", token: WRITE },
|
||||
],
|
||||
|
||||
// Mounted under /scheduling; `permission` gates before the handler runs.
|
||||
// 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 role.
|
||||
routes: [
|
||||
{ handler: overview(), method: "GET", path: "/", public: true },
|
||||
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
|
||||
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
|
||||
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },
|
||||
|
||||
@@ -6,7 +6,7 @@ import test from "node:test";
|
||||
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
|
||||
import { GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "../../src/plugin-api.ts";
|
||||
import {
|
||||
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, readInput,
|
||||
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
|
||||
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
|
||||
} from "./shifts.ts";
|
||||
|
||||
@@ -112,6 +112,18 @@ test("listShifts degrades to a recoverable error page when the upstream is down
|
||||
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []);
|
||||
});
|
||||
|
||||
// ---- public overview handler (§10: a page anyone can reach, gated data stays behind the role) ----
|
||||
|
||||
test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
|
||||
const anon = asView(await overview()(fakeCtx())); // user null, no roles
|
||||
assert.equal(anon.view, "overview");
|
||||
assert.equal(anon.data["chrome"], CHROME);
|
||||
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
|
||||
|
||||
const reader = asView(await overview()(fakeCtx({ roles: ["scheduling:read"] })));
|
||||
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
|
||||
});
|
||||
|
||||
// ---- create handler ----
|
||||
|
||||
test("newShiftForm renders the empty form", async () => {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// One import from the host's plugin-api barrel — the stable author surface (see docs/plugin-contract.md).
|
||||
import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, tracedFetch } from "../../src/plugin-api.ts";
|
||||
|
||||
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page (§10)
|
||||
export const SHIFTS_PATH = "/scheduling/shifts";
|
||||
export const READ = "scheduling:read"; // permission token gating the list + nav
|
||||
export const WRITE = "scheduling:write"; // permission token gating create
|
||||
@@ -184,6 +185,17 @@ export function newShiftForm(): RouteHandler {
|
||||
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome }), view: "shift-new" });
|
||||
}
|
||||
|
||||
// Public overview (§10): a page anyone may reach — its route + nav node are marked `public`, so the
|
||||
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data
|
||||
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
|
||||
// else a prompt to sign in. ctx.user may be null here, so read the role via can() (zero I/O).
|
||||
export function overview(): RouteHandler {
|
||||
return (ctx) => ({
|
||||
data: { breadcrumbs: [{ label: "Overview" }], canRead: can(ctx, READ), chrome: ctx.chrome, shiftsHref: SHIFTS_PATH, title: "Scheduling" },
|
||||
view: "overview",
|
||||
});
|
||||
}
|
||||
|
||||
export function createShift(upstream: ShiftsUpstream): RouteHandler {
|
||||
return async (ctx) => {
|
||||
const form = await readFormBody(ctx.req);
|
||||
|
||||
24
plugins/scheduling/views/overview.ejs
Normal file
24
plugins/scheduling/views/overview.ejs
Normal file
@@ -0,0 +1,24 @@
|
||||
<%#
|
||||
Scheduling · public overview (reference plugin, §10). A page ANYONE may reach — the route and its
|
||||
nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for
|
||||
everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to
|
||||
it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome.
|
||||
Data: chrome, title, breadcrumbs, canRead, shiftsHref
|
||||
%><%
|
||||
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
const cta = canRead
|
||||
? '<a class="btn btn-primary" href="' + shiftsHref + '">View shifts</a>'
|
||||
: '<a class="btn btn-primary" href="/login?return_to=' + encodeURIComponent(shiftsHref) + '">Sign in to view shifts</a>';
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
actions: "",
|
||||
body: '<div class="scheduling-page"><p>Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> role.</p>' + cta + '</div>',
|
||||
brand: chrome.brand,
|
||||
breadcrumbs,
|
||||
csrfToken: chrome.csrfToken,
|
||||
nav: navHtml,
|
||||
styles: ["/public/scheduling/scheduling.css"],
|
||||
theme: chrome.theme,
|
||||
title,
|
||||
user: chrome.user,
|
||||
}) %>
|
||||
Reference in New Issue
Block a user