From 03d1cae0fd65bf9b8d9defd61e313c82ea8826e9 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 12 Nov 2026 23:13:59 +0100 Subject: [PATCH 1/3] feat(auth): inherited and site-default page gating, gated media (#157) A page's roles: now applies to everything beneath it, and site.auth.default_roles gates every page that neither declares nor inherits a rule. Precedence: own roles, nearest ancestor, site default. roles: public opts a page or section out. - Resolve the effective rule once over the page index (applyGatingInheritance); PageIndex.ownRoles keeps the declaration so the pass re-runs cleanly. loadPage exposes the effective rule as frontmatter.roles, so every existing gate picks it up. - Translations take the section rule from the same language, else another; a gate beats public when languages disagree. - A malformed roles value inherits instead of opening the page. - roles: any now means any logged-in user (was read as a role "any"). - /content-media/* applies the owning page's rule (404, private no-store); it checked nothing before. Fixes #157 Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 23 +++ src/auth/gating.ts | 84 ++++++++++- src/config/site-config.ts | 16 ++ src/content/index-builder.ts | 90 +++++++++++- src/content/page-loader.ts | 6 + src/content/types.ts | 12 +- src/core/engine.ts | 2 + src/runtime/register-static.ts | 27 +++- tests/auth/gating_test.ts | 51 +++++++ tests/content/gating_inheritance_test.ts | 178 +++++++++++++++++++++++ 10 files changed, 481 insertions(+), 8 deletions(-) create mode 100644 tests/content/gating_inheritance_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ff42081..e9646ee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,29 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what ## [Unreleased] +### Added + +- **Page gating is inherited, and a site can gate everything by default (#157).** A page's `roles:` now + applies to everything beneath it, so gating a section is one line on its top page, not one per page and + language. `site.auth.default_roles` (same values as `roles:`, e.g. `any`) gates every page that neither + declares nor inherits a rule. The first match wins: the page's own `roles:`, then the nearest ancestor's, + then the site default. `roles: public` opts a page or a whole section out of an inherited or default gate. + `/auth/*` and theme assets are never gated. A translated page takes the rule of its section from the same + language, else from another language, and where the languages disagree a gate beats `public`. A malformed + `roles:` value now counts as "not declared" (it inherits) instead of opening the page. `PageIndex.roles` + and `gated` hold the effective rule, and `frontmatter.roles` on a loaded page does too; the page's own + declaration is kept in the new `PageIndex.ownRoles`. + +### Fixed + +- **`roles: any` did nothing of the kind.** The docs describe it as "any logged-in user", but it was read as + a role literally named `any`, so a signed-in visitor without that role got a 403. It now means any + logged-in user (the same as `roles: []`). +- **Files in a gated page's folder were served to anyone on `/content-media/*`.** The route-based media path + (`//file.pdf`) already checked the owning page; the legacy `/content-media//` route did + not. It now applies the owning page's rule (404 for a visitor who may not see the page, `private, no-store` + for gated files), including audio and images. A file with no owning page stays public. (#157) + ## [0.36.1] — 2026-11-12 ### Fixed diff --git a/src/auth/gating.ts b/src/auth/gating.ts index 27e92a19..320eeaf9 100644 --- a/src/auth/gating.ts +++ b/src/auth/gating.ts @@ -9,7 +9,10 @@ * roles: # AND list — user must have all * all: [member, verified] * roles: [] # authenticated-only (any logged-in user) - * # absent # public access — no check performed + * roles: any # same as `[]` + * roles: public # open, even inside a gated section / gated site + * # absent # inherit: nearest ancestor's rule, else site.auth.default_roles, + * # else public (see applyGatingInheritance) * * When a polizy AuthSystem is wired via `setGatingAuthz()`, role checks go * through `authz.check()` for full hierarchy and group membership support. @@ -116,10 +119,15 @@ export function parseRolesSpec(raw: unknown): RolesSpec | null { // Absent or explicitly null → public if (raw === null || raw === undefined) return null; - // String → single role + // String → single role, or one of the two reserved words: + // "any" → any logged-in user (same as `[]`) + // "public" → explicitly open (never a gate; see parseRolesDirective) if (typeof raw === "string") { const trimmed = raw.trim(); - return trimmed.length > 0 ? trimmed : null; + if (trimmed.length === 0) return null; + if (trimmed === ANY_ROLE) return []; + if (trimmed === PUBLIC_ROLE) return null; + return trimmed; } // Array → OR list (possibly empty = authenticated-only) @@ -151,6 +159,41 @@ export function parseRolesSpec(raw: unknown): RolesSpec | null { return null; } +/** Reserved `roles:` value: any logged-in user, whatever their roles. */ +export const ANY_ROLE = "any"; + +/** Reserved `roles:` value: open to everyone, overriding an inherited gate. */ +export const PUBLIC_ROLE = "public"; + +/** + * What a page's own `roles:` value says about gating, once inheritance is in + * play: + * + * - `inherit` — nothing usable declared; take the nearest ancestor's rule, + * then the site default. + * - `public` — explicitly open (`roles: public`); ignores anything above. + * - `gate` — restricted by `spec`. + */ +export type RolesDirective = + | { kind: "inherit" } + | { kind: "public" } + | { kind: "gate"; spec: RolesSpec }; + +/** + * Classify a raw `roles` value. Unlike {@link parseRolesSpec} this tells + * "absent" (inherit) apart from "explicitly public", and treats a malformed + * value as `inherit` rather than public so that a typo cannot open a page + * inside a gated section or a gated site. + */ +export function parseRolesDirective(raw: unknown): RolesDirective { + if (raw === null || raw === undefined) return { kind: "inherit" }; + if (typeof raw === "string" && raw.trim() === PUBLIC_ROLE) { + return { kind: "public" }; + } + const spec = parseRolesSpec(raw); + return spec === null ? { kind: "inherit" } : { kind: "gate", spec }; +} + // ── Checking ────────────────────────────────────────────────────────────────── /** @@ -392,6 +435,41 @@ export async function navigationForRequest< return { nav: visible.filter((p) => p.depth === 0), navAll: visible }; } +/** + * Pages that own a co-located file addressed by its content-relative path + * (the form `/content-media/` uses, e.g. `05.caux/audio.mp3`). + * + * Returns every index entry (one per language) of the folder the file lives + * in; when that folder has no page of its own, the nearest ancestor folder + * that does. Empty when nothing owns the file, which then stays public, the + * same as for a media URL with no owner on the route-based path. + */ +export function findPagesForContentMediaPath< + T extends GatedIndexEntry & { sourcePath: string }, +>(pages: T[], mediaPath: string): T[] { + let rel = mediaPath; + try { + rel = decodeURIComponent(mediaPath); + } catch { + // Malformed escapes: look up the raw string; serveMedia will 404 anyway. + } + const byDir = new Map(); + for (const page of pages) { + const i = page.sourcePath.lastIndexOf("/"); + const dir = i < 0 ? "" : page.sourcePath.slice(0, i); + const list = byDir.get(dir); + if (list) list.push(page); + else byDir.set(dir, [page]); + } + let dir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : ""; + while (dir) { + const owners = byDir.get(dir); + if (owners) return owners; + dir = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : ""; + } + return []; +} + export function findPageForMediaUrl( pages: T[], pathname: string, diff --git a/src/config/site-config.ts b/src/config/site-config.ts index 40cfe04d..59ef5fa8 100644 --- a/src/config/site-config.ts +++ b/src/config/site-config.ts @@ -241,6 +241,22 @@ export interface SiteConfig { * "external-jwt" — validate Bearer JWT tokens from an external provider. */ mode?: "dune" | "external-jwt"; + /** + * Site-wide default for page gating: the `roles:` value applied to every + * page that neither declares `roles:` nor inherits one from an ancestor. + * Same values as page frontmatter (`any` = any logged-in user, a role + * name, a list). A page or section opts out with `roles: public`. + * `/auth/*` and theme assets are never gated. Unset = pages are public + * unless they say otherwise. + * + * @example Whole site behind login, one public page + * ```yaml + * site: + * auth: + * default_roles: any + * ``` + */ + default_roles?: unknown; /** OAuth provider credentials. */ providers?: { github?: { client_id: string; client_secret: string }; diff --git a/src/content/index-builder.ts b/src/content/index-builder.ts index 09d14022..027a0a85 100644 --- a/src/content/index-builder.ts +++ b/src/content/index-builder.ts @@ -12,6 +12,7 @@ * - .tsx → Fast path: read .frontmatter.yaml sidecar. Fallback: AST extract. */ +import { dirname } from "@std/path"; import { isWithinPublishWindow, parseScheduleDate } from "./schedule.ts"; import { ContentError } from "../core/errors.ts"; import type { StorageAdapter, StorageEntry } from "../storage/types.ts"; @@ -19,7 +20,8 @@ import type { ContentFormat, PageFrontmatter, PageIndex } from "./types.ts"; import type { FormatRegistry } from "./formats/registry.ts"; import type { BlueprintMap } from "../blueprints/types.ts"; import { validateFrontmatter } from "../blueprints/validator.ts"; -import { parseRolesSpec } from "../auth/gating.ts"; +import { parseRolesDirective, parseRolesSpec } from "../auth/gating.ts"; +import type { RolesDirective } from "../auth/gating.ts"; import { calculateDepth, dirPathToRoute, @@ -81,6 +83,12 @@ export interface IndexBuilderOptions { * `blog/my-post/post.md` → `/blog/my-post`. */ templateNames?: Set; + /** + * Site-wide default `roles:` value (`site.auth.default_roles`), applied to + * every page that neither declares `roles:` nor inherits one. A page opts + * out with `roles: public`. + */ + defaultRoles?: unknown; } /** Return type of {@link buildIndex} — the page index, taxonomy map, and build statistics. */ @@ -218,6 +226,7 @@ export async function buildIndex( // Deduplicate routes: directory-based pages win over flat files. const finalPages = deduplicateRoutes(pages); + applyGatingInheritance(finalPages, options.defaultRoles); const duration = performance.now() - start; const homeSlug = siteHome ?? detectHomeSlug(finalPages); @@ -365,6 +374,7 @@ export async function updateIndex( // Deduplicate routes: directory-based pages win over flat files. const finalPages = deduplicateRoutes(pages); + applyGatingInheritance(finalPages, options.defaultRoles); const duration = performance.now() - start; const homeSlug = siteHome ?? detectHomeSlug(finalPages); @@ -398,6 +408,81 @@ function isFlatFilePath(sp: string): boolean { return /^\d+\./.test(stem); } +/** + * Resolve each page's effective access rule and write it to `gated` / `roles`. + * + * Precedence, first match wins: + * 1. the page's own `roles:` + * 2. the nearest ancestor directory's `roles:` + * 3. the site default (`site.auth.default_roles`) + * + * `roles: public` is an explicit opt-out at any level; it ends the search + * with "open". A value that is absent or malformed means "keep looking". + * + * A directory's rule is the one declared by its page in the same language, + * else by its page in another language (a section translated into DE/FR is + * not left open because only the EN `default.md` carries `roles:`). Where + * languages disagree, a gate beats `public`, so ambiguity fails closed. + * + * Idempotent: always starts from `ownRoles`, so it can be re-run whenever any + * page or the site default changes. + */ +export function applyGatingInheritance( + pages: PageIndex[], + defaultRoles: unknown, +): void { + const fallback = parseRolesDirective(defaultRoles); + + // Directory → the pages that represent it (folder pages, not flat siblings). + const byDir = new Map(); + for (const page of pages) { + if (isFlatFilePath(page.sourcePath)) continue; + const dir = dirname(page.sourcePath); + const list = byDir.get(dir); + if (list) list.push(page); + else byDir.set(dir, [page]); + } + + const dirDirective = (dir: string, language: string): RolesDirective => { + const group = byDir.get(dir); + if (!group) return { kind: "inherit" }; + const same = group.find((p) => p.language === language); + if (same) { + const own = parseRolesDirective(same.ownRoles); + if (own.kind !== "inherit") return own; + } + let sawPublic = false; + for (const p of group) { + if (p === same) continue; + const d = parseRolesDirective(p.ownRoles); + if (d.kind === "gate") return d; + if (d.kind === "public") sawPublic = true; + } + return sawPublic ? { kind: "public" } : { kind: "inherit" }; + }; + + for (const page of pages) { + let directive = parseRolesDirective(page.ownRoles); + + // Walk up from the page's parent folder. Stop at the first decision. + let dir = page.parentPath; + while (directive.kind === "inherit" && dir) { + directive = dirDirective(dir, page.language); + const up = dirname(dir); + dir = up === "." || up === "" || up === dir ? null : up; + } + if (directive.kind === "inherit") directive = fallback; + + if (directive.kind === "gate") { + page.gated = true; + page.roles = directive.spec; + } else { + page.gated = false; + delete page.roles; + } + } +} + /** * Deduplicate routable pages by route. * Directory-based pages (non-flat) win over flat-file pages when routes collide. @@ -528,8 +613,11 @@ function buildPageIndex( ? (sourcePath.split("/").slice(0, -1).join("/") || null) : getParentPath(sourcePath), taxonomy: frontmatter.taxonomy ?? {}, + // Own declaration only; `applyGatingInheritance` overwrites `gated` and + // `roles` with the effective values once every page is indexed. gated: parseRolesSpec(frontmatter.roles) !== null, roles: frontmatter.roles, + ownRoles: frontmatter.roles, mtime, hash, coverImage: buildCoverImageUrl(sourcePath, frontmatter.image as string | undefined), diff --git a/src/content/page-loader.ts b/src/content/page-loader.ts index 60b12fc5..39546b8f 100644 --- a/src/content/page-loader.ts +++ b/src/content/page-loader.ts @@ -76,6 +76,12 @@ export async function loadPage( const frontmatter = await handler.extractFrontmatter(raw, contentFilePath); const rawContent = handler.extractBody(raw, contentFilePath); + // `frontmatter.roles` is what every request-time gate reads. Make it the + // *effective* rule (own, inherited or site default) that the index resolved, + // not just what this file declares. + if (index.gated) frontmatter.roles = index.roles as typeof frontmatter.roles; + else delete frontmatter.roles; + // Compute the URL prefix for co-located media (based on content directory, not page route) const contentDirPath = dirname(index.sourcePath); // e.g. "04.blog/01.post" const contentDirRoute = dirPathToRoute(contentDirPath); // e.g. "/blog/post" diff --git a/src/content/types.ts b/src/content/types.ts index 5c081151..bf15f317 100644 --- a/src/content/types.ts +++ b/src/content/types.ts @@ -294,11 +294,19 @@ export interface PageIndex { */ gated?: boolean; /** - * Raw `frontmatter.roles` value when the page is gated. Stored at index - * time so list/search/nav/media can run the same `parseRolesSpec` + + * The *effective* `roles` value when the page is gated: the page's own + * `roles:`, else the nearest ancestor's, else `site.auth.default_roles` + * (see `applyGatingInheritance`). Stored at index time so + * list/search/nav/media can run the same `parseRolesSpec` + * `enforceRolesFromRequest` check without loading the page body. */ roles?: unknown; + /** + * Raw `frontmatter.roles` exactly as the page declares it (absent when it + * declares none). Kept so the inheritance pass can be re-run from scratch + * whenever any page or the site default changes. + */ + ownRoles?: unknown; /** File modification time — for incremental cache invalidation */ mtime: number; /** Hash of frontmatter — for change detection */ diff --git a/src/core/engine.ts b/src/core/engine.ts index d3cd81f5..116b5f85 100644 --- a/src/core/engine.ts +++ b/src/core/engine.ts @@ -464,6 +464,7 @@ export async function createDuneEngine( blueprints, facetFields: config.system.search?.facets?.map((f) => f.field), templateNames: await discoverThemeTemplateNames(), + defaultRoles: config.site.auth?.default_roles, }); pages = result.pages; taxonomyMap = result.taxonomyMap; @@ -679,6 +680,7 @@ export async function createDuneEngine( blueprints, facetFields: config.system.search?.facets?.map((f) => f.field), templateNames: await discoverThemeTemplateNames(), + defaultRoles: config.site.auth?.default_roles, }); pages = result.pages; taxonomyMap = result.taxonomyMap; diff --git a/src/runtime/register-static.ts b/src/runtime/register-static.ts index 634f04fa..d44a26aa 100644 --- a/src/runtime/register-static.ts +++ b/src/runtime/register-static.ts @@ -7,6 +7,10 @@ import { } from "../cli/serve-utils.ts"; import type { buildPluginClientBundles } from "../cli/client-bundles.ts"; import { serveClientBundle } from "../cli/client-bundles.ts"; +import { + canAccessIndexEntry, + findPagesForContentMediaPath, +} from "../auth/gating.ts"; type ClientBundles = Awaited>; @@ -93,8 +97,27 @@ export function registerStaticRoutes( }); // Legacy co-located content media path. + // A file inherits the access rule of the page folder it lives in, exactly as + // on the route-based media path (register-middleware.ts): a visitor who may + // not see the page may not fetch its audio, images or downloads either. app.get("/content-media/*", async (fc) => { - const imageResult = await imageHandler(fc.req); - return imageResult ?? await routes.mediaHandler(fc.req); + const owners = findPagesForContentMediaPath( + ctx.engine.pages, + fc.url.pathname.replace(/^\/content-media\//, ""), + ); + const gated = owners.some((o) => o.gated === true); + if (gated) { + for (const owner of owners) { + if (!(await canAccessIndexEntry(fc.req, owner))) { + return new Response("Not found", { status: 404 }); + } + } + } + const res = (await imageHandler(fc.req)) ?? + await routes.mediaHandler(fc.req); + if (!gated) return res; + const headers = new Headers(res.headers); + headers.set("Cache-Control", "private, no-store"); + return new Response(res.body, { status: res.status, headers }); }); } diff --git a/tests/auth/gating_test.ts b/tests/auth/gating_test.ts index 2c60c55a..2d85d9e4 100644 --- a/tests/auth/gating_test.ts +++ b/tests/auth/gating_test.ts @@ -319,3 +319,54 @@ Deno.test("navigationForRequest: no req keeps only public entries", async () => assertEquals(nav.map((p) => p.route), ["/"]); assertEquals(navAll.map((p) => p.route), ["/", "/blog/a"]); }); + +// ── Reserved words, directives and media ownership (#157) ───────────────────── + +import { + findPagesForContentMediaPath, + parseRolesDirective, +} from "../../src/auth/gating.ts"; + +Deno.test("parseRolesSpec: `any` means any logged-in user", () => { + assertEquals(parseRolesSpec("any"), []); + assertEquals(parseRolesSpec(" any "), []); + assertStrictEquals(checkRoles(makeUser([]), parseRolesSpec("any")!), true); + assertStrictEquals(checkRoles(null, parseRolesSpec("any")!), false); +}); + +Deno.test("parseRolesSpec: `public` is never a gate", () => { + assertStrictEquals(parseRolesSpec("public"), null); +}); + +Deno.test("parseRolesDirective: absent, public, gate, malformed", () => { + assertEquals(parseRolesDirective(undefined), { kind: "inherit" }); + assertEquals(parseRolesDirective(null), { kind: "inherit" }); + assertEquals(parseRolesDirective("public"), { kind: "public" }); + assertEquals(parseRolesDirective("member"), { kind: "gate", spec: "member" }); + assertEquals(parseRolesDirective("any"), { kind: "gate", spec: [] }); + assertEquals(parseRolesDirective([]), { kind: "gate", spec: [] }); + assertEquals(parseRolesDirective(5), { kind: "inherit" }); + assertEquals(parseRolesDirective(""), { kind: "inherit" }); +}); + +Deno.test("findPagesForContentMediaPath: owner is the file's folder, else the nearest ancestor", () => { + const pages = [ + { sourcePath: "05.caux/default.md", gated: true }, + { sourcePath: "05.caux/default.de.md", gated: true }, + { sourcePath: "06.history/default.md", gated: false }, + ]; + assertEquals( + findPagesForContentMediaPath(pages, "05.caux/audio.mp3").length, + 2, + ); + assertEquals( + findPagesForContentMediaPath(pages, "05.caux/files/deep/doc.pdf").length, + 2, + ); + assertEquals( + findPagesForContentMediaPath(pages, "06.history/a%20b.png")[0].sourcePath, + "06.history/default.md", + ); + assertEquals(findPagesForContentMediaPath(pages, "logo.png"), []); + assertEquals(findPagesForContentMediaPath(pages, "99.none/x.png"), []); +}); diff --git a/tests/content/gating_inheritance_test.ts b/tests/content/gating_inheritance_test.ts new file mode 100644 index 00000000..3ce3ea9d --- /dev/null +++ b/tests/content/gating_inheritance_test.ts @@ -0,0 +1,178 @@ +/** + * Inherited and site-default page gating (#157): own `roles:` → nearest + * ancestor's → `site.auth.default_roles`, with `roles: public` as an opt-out. + */ +import { + assertEquals, + assertStrictEquals, +} from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { applyGatingInheritance } from "../../src/content/index-builder.ts"; +import type { PageIndex } from "../../src/content/types.ts"; + +/** Minimal index entry: only the fields the inheritance pass reads. */ +function page( + sourcePath: string, + ownRoles?: unknown, + language = "en", +): PageIndex { + const parts = sourcePath.split("/"); + const dirs = parts.slice(0, -1); + const stem = parts[parts.length - 1]; + const flat = /^\d+\./.test(stem); + // Mirrors getParentPath: a flat file's parent is its own folder. + const parentPath = flat + ? (dirs.join("/") || null) + : (dirs.length > 1 ? dirs.slice(0, -1).join("/") : null); + return { + sourcePath, + language, + parentPath, + ownRoles, + roles: ownRoles, + gated: ownRoles !== undefined, + } as unknown as PageIndex; +} + +function state(p: PageIndex) { + return { gated: p.gated, roles: p.roles }; +} + +Deno.test("gating: nothing declared anywhere → public", () => { + const p = page("01.home/default.md"); + applyGatingInheritance([p], undefined); + assertEquals(state(p), { gated: false, roles: undefined }); +}); + +Deno.test("gating: a page's own roles apply", () => { + const p = page("01.members/default.md", "member"); + applyGatingInheritance([p], undefined); + assertEquals(state(p), { gated: true, roles: "member" }); +}); + +Deno.test("gating: children and grandchildren inherit the parent's roles", () => { + const section = page("02.about/default.md", "member"); + const child = page("02.about/01.team/default.md"); + const grand = page("02.about/01.team/01.bios/default.md"); + const other = page("03.contact/default.md"); + applyGatingInheritance([section, child, grand, other], undefined); + assertEquals(state(child), { gated: true, roles: "member" }); + assertEquals(state(grand), { gated: true, roles: "member" }); + assertEquals(state(other), { gated: false, roles: undefined }); +}); + +Deno.test("gating: the nearest ancestor wins over a further one", () => { + const top = page("02.about/default.md", "member"); + const mid = page("02.about/01.team/default.md", ["staff"]); + const leaf = page("02.about/01.team/01.bios/default.md"); + applyGatingInheritance([top, mid, leaf], undefined); + assertEquals(state(leaf), { gated: true, roles: ["staff"] }); +}); + +Deno.test("gating: a child's own roles beat the parent's", () => { + const top = page("02.about/default.md", "member"); + const child = page("02.about/01.team/default.md", "admin"); + applyGatingInheritance([top, child], undefined); + assertEquals(state(child), { gated: true, roles: "admin" }); +}); + +Deno.test("gating: `roles: public` opts a page out of an ancestor's gate", () => { + const top = page("02.about/default.md", "member"); + const open = page("02.about/01.press/default.md", "public"); + const sub = page("02.about/01.press/01.kit/default.md"); + applyGatingInheritance([top, open, sub], undefined); + assertEquals(state(open), { gated: false, roles: undefined }); + assertEquals(state(sub), { gated: false, roles: undefined }); // section opt-out +}); + +Deno.test("gating: site default gates every page that says nothing", () => { + const a = page("01.home/default.md"); + const b = page("02.about/01.team/default.md"); + applyGatingInheritance([a, b], "any"); + assertEquals(state(a), { gated: true, roles: [] }); + assertEquals(state(b), { gated: true, roles: [] }); +}); + +Deno.test("gating: site default is overridden by an ancestor and by `public`", () => { + const home = page("01.home/default.md", "public"); + const members = page("02.members/default.md", "member"); + const memberChild = page("02.members/01.docs/default.md"); + applyGatingInheritance([home, members, memberChild], "any"); + assertEquals(state(home), { gated: false, roles: undefined }); + assertEquals(state(memberChild), { gated: true, roles: "member" }); +}); + +Deno.test("gating: a list default keeps its roles", () => { + const p = page("01.home/default.md"); + applyGatingInheritance([p], ["a", "b"]); + assertEquals(state(p), { gated: true, roles: ["a", "b"] }); +}); + +Deno.test("gating: translations inherit from the section's page in another language", () => { + const sectionEn = page("02.about/default.md", "member", "en"); + const sectionDe = page("02.about/default.de.md", undefined, "de"); + const childDe = page("02.about/01.team/default.de.md", undefined, "de"); + applyGatingInheritance([sectionEn, sectionDe, childDe], undefined); + assertEquals(state(childDe), { gated: true, roles: "member" }); +}); + +Deno.test("gating: the same-language section rule beats another language's", () => { + const sectionEn = page("02.about/default.md", "member", "en"); + const sectionDe = page("02.about/default.de.md", "admin", "de"); + const childDe = page("02.about/01.team/default.de.md", undefined, "de"); + const childEn = page("02.about/01.team/default.md", undefined, "en"); + applyGatingInheritance([sectionEn, sectionDe, childDe, childEn], undefined); + assertEquals(state(childDe), { gated: true, roles: "admin" }); + assertEquals(state(childEn), { gated: true, roles: "member" }); +}); + +Deno.test("gating: when languages disagree, a gate beats `public` (fails closed)", () => { + const sectionEn = page("02.about/default.md", "public", "en"); + const sectionDe = page("02.about/default.de.md", "member", "de"); + const childFr = page("02.about/01.team/default.fr.md", undefined, "fr"); + applyGatingInheritance([sectionEn, sectionDe, childFr], undefined); + assertEquals(state(childFr), { gated: true, roles: "member" }); +}); + +Deno.test("gating: a malformed value inherits instead of opening the page", () => { + const top = page("02.about/default.md", "member"); + const typo = page("02.about/01.team/default.md", 5); + const loose = page("03.contact/default.md", 5); + applyGatingInheritance([top, typo, loose], undefined); + assertEquals(state(typo), { gated: true, roles: "member" }); + assertEquals(state(loose), { gated: false, roles: undefined }); +}); + +Deno.test("gating: flat sibling files do not set their folder's rule", () => { + const section = page("03.blog/default.md"); + const gatedPost = page("03.blog/01.secret.md", "member"); + const otherPost = page("03.blog/02.hello.md"); + applyGatingInheritance([section, gatedPost, otherPost], undefined); + assertEquals(state(gatedPost), { gated: true, roles: "member" }); + assertEquals(state(otherPost), { gated: false, roles: undefined }); +}); + +Deno.test("gating: re-running after a parent changes updates its children", () => { + const top = page("02.about/default.md", "member"); + const child = page("02.about/01.team/default.md"); + const pages = [top, child]; + applyGatingInheritance(pages, undefined); + assertEquals(state(child), { gated: true, roles: "member" }); + + top.ownRoles = undefined; // the section is opened up + applyGatingInheritance(pages, undefined); + assertEquals(state(child), { gated: false, roles: undefined }); + + applyGatingInheritance(pages, "any"); // …and now a site default arrives + assertEquals(state(child), { gated: true, roles: [] }); +}); + +Deno.test("gating: applyGatingInheritance is idempotent", () => { + const top = page("02.about/default.md", { all: ["a", "b"] }); + const child = page("02.about/01.team/default.md"); + const pages = [top, child]; + applyGatingInheritance(pages, "any"); + const first = pages.map(state); + applyGatingInheritance(pages, "any"); + assertEquals(pages.map(state), first); + assertStrictEquals(child.gated, true); +}); From 28cc2d38ff6c672b0e9fb402cd26a4ce0992e992 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 12 Nov 2026 23:20:36 +0100 Subject: [PATCH 2/3] fix(gating): cache media owner lookup, gate ownerless media under the site default findPagesForContentMediaPath rebuilt a directory map over every page on each /content-media request; it is now built once per page array. A file with no owning page falls under site.auth.default_roles when set. Changelog notes the roles: any breaking edge. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 5 +++-- src/auth/gating.ts | 41 ++++++++++++++++++++++++++++------ src/runtime/register-static.ts | 8 ++++++- tests/auth/gating_test.ts | 20 +++++++++++++++++ 4 files changed, 64 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9646ee3..ea5deda3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,11 +28,12 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what - **`roles: any` did nothing of the kind.** The docs describe it as "any logged-in user", but it was read as a role literally named `any`, so a signed-in visitor without that role got a 403. It now means any - logged-in user (the same as `roles: []`). + logged-in user (the same as `roles: []`). Only the string form changes: a site that has a role literally named + `any` must rename it, and `roles: [any]` still means that literal role. - **Files in a gated page's folder were served to anyone on `/content-media/*`.** The route-based media path (`//file.pdf`) already checked the owning page; the legacy `/content-media//` route did not. It now applies the owning page's rule (404 for a visitor who may not see the page, `private, no-store` - for gated files), including audio and images. A file with no owning page stays public. (#157) + for gated files), including audio and images. A file with no owning page stays public, unless the site sets `auth.default_roles`, which then gates it too. (#157) ## [0.36.1] — 2026-11-12 diff --git a/src/auth/gating.ts b/src/auth/gating.ts index 320eeaf9..dee5698f 100644 --- a/src/auth/gating.ts +++ b/src/auth/gating.ts @@ -453,6 +453,27 @@ export function findPagesForContentMediaPath< } catch { // Malformed escapes: look up the raw string; serveMedia will 404 anyway. } + const byDir = pagesByDir(pages); + let dir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : ""; + while (dir) { + const owners = byDir.get(dir); + if (owners) return owners; + dir = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : ""; + } + return []; +} + +// Directory → index entries, built once per page array. The engine replaces +// `engine.pages` with a fresh array on every (re)index, so keying on the array +// keeps this current without invalidation, and a media request does not walk +// the whole index. +const pagesByDirCache = new WeakMap>(); + +function pagesByDir( + pages: T[], +): Map { + const cached = pagesByDirCache.get(pages); + if (cached) return cached as Map; const byDir = new Map(); for (const page of pages) { const i = page.sourcePath.lastIndexOf("/"); @@ -461,13 +482,19 @@ export function findPagesForContentMediaPath< if (list) list.push(page); else byDir.set(dir, [page]); } - let dir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : ""; - while (dir) { - const owners = byDir.get(dir); - if (owners) return owners; - dir = dir.includes("/") ? dir.slice(0, dir.lastIndexOf("/")) : ""; - } - return []; + pagesByDirCache.set(pages, byDir); + return byDir; +} + +/** + * The rule a file with no owning page falls under: the site default + * (`site.auth.default_roles`) as a gated entry, or null when the site has no + * default gate. Such a file has no page and no ancestor page, so nothing can + * have opted it out. + */ +export function siteDefaultGate(defaultRoles: unknown): GatedIndexEntry | null { + const d = parseRolesDirective(defaultRoles); + return d.kind === "gate" ? { gated: true, roles: d.spec } : null; } export function findPageForMediaUrl( diff --git a/src/runtime/register-static.ts b/src/runtime/register-static.ts index d44a26aa..4c2cc3b2 100644 --- a/src/runtime/register-static.ts +++ b/src/runtime/register-static.ts @@ -10,6 +10,7 @@ import { serveClientBundle } from "../cli/client-bundles.ts"; import { canAccessIndexEntry, findPagesForContentMediaPath, + siteDefaultGate, } from "../auth/gating.ts"; type ClientBundles = Awaited>; @@ -101,10 +102,15 @@ export function registerStaticRoutes( // on the route-based media path (register-middleware.ts): a visitor who may // not see the page may not fetch its audio, images or downloads either. app.get("/content-media/*", async (fc) => { - const owners = findPagesForContentMediaPath( + const found = findPagesForContentMediaPath( ctx.engine.pages, fc.url.pathname.replace(/^\/content-media\//, ""), ); + // A file no page owns falls under the site default gate, if there is one. + const fallback = found.length === 0 + ? siteDefaultGate(ctx.engine.site.auth?.default_roles) + : null; + const owners = fallback ? [fallback] : found; const gated = owners.some((o) => o.gated === true); if (gated) { for (const owner of owners) { diff --git a/tests/auth/gating_test.ts b/tests/auth/gating_test.ts index 2d85d9e4..904cdca4 100644 --- a/tests/auth/gating_test.ts +++ b/tests/auth/gating_test.ts @@ -325,6 +325,7 @@ Deno.test("navigationForRequest: no req keeps only public entries", async () => import { findPagesForContentMediaPath, parseRolesDirective, + siteDefaultGate, } from "../../src/auth/gating.ts"; Deno.test("parseRolesSpec: `any` means any logged-in user", () => { @@ -370,3 +371,22 @@ Deno.test("findPagesForContentMediaPath: owner is the file's folder, else the ne assertEquals(findPagesForContentMediaPath(pages, "logo.png"), []); assertEquals(findPagesForContentMediaPath(pages, "99.none/x.png"), []); }); + +Deno.test("findPagesForContentMediaPath: a replaced page array is looked up afresh", () => { + const first = [{ sourcePath: "05.a/default.md", gated: true }]; + assertEquals(findPagesForContentMediaPath(first, "05.a/x.png").length, 1); + // Same array again (cached map) gives the same answer + assertEquals(findPagesForContentMediaPath(first, "05.a/x.png").length, 1); + // The engine swaps in a new array on re-index; the old cache must not leak + const second = [{ sourcePath: "06.b/default.md", gated: false }]; + assertEquals(findPagesForContentMediaPath(second, "05.a/x.png"), []); + assertEquals(findPagesForContentMediaPath(second, "06.b/x.png").length, 1); +}); + +Deno.test("siteDefaultGate: a gate for gating defaults, null otherwise", () => { + assertEquals(siteDefaultGate("any"), { gated: true, roles: [] }); + assertEquals(siteDefaultGate("member"), { gated: true, roles: "member" }); + assertStrictEquals(siteDefaultGate(undefined), null); + assertStrictEquals(siteDefaultGate("public"), null); + assertStrictEquals(siteDefaultGate(5), null); +}); From 32c86c760ae264a645ea09f98c08bf7bbaa60ea5 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 12 Nov 2026 23:26:52 +0100 Subject: [PATCH 3/3] feat(gating): send a logged-out document navigation to a gated file to login Subresources and every other denial stay 404. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 ++-- src/auth/gating.ts | 35 ++++++++++++++++++++++++++++++++++ src/runtime/register-static.ts | 8 ++++---- tests/auth/gating_test.ts | 27 ++++++++++++++++++++++++++ 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ea5deda3..f85e8b48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,8 +32,8 @@ split, begin at v1.0.0 — see [VERSIONING.md](VERSIONING.md) for exactly what `any` must rename it, and `roles: [any]` still means that literal role. - **Files in a gated page's folder were served to anyone on `/content-media/*`.** The route-based media path (`//file.pdf`) already checked the owning page; the legacy `/content-media//` route did - not. It now applies the owning page's rule (404 for a visitor who may not see the page, `private, no-store` - for gated files), including audio and images. A file with no owning page stays public, unless the site sets `auth.default_roles`, which then gates it too. (#157) + not. It now applies the owning page's rule (404 for a visitor who may not see the page, except that a logged-out visitor who opens the file directly in the + browser is sent to the login page; `private, no-store` for gated files), including audio and images. A file with no owning page stays public, unless the site sets `auth.default_roles`, which then gates it too. (#157) ## [0.36.1] — 2026-11-12 diff --git a/src/auth/gating.ts b/src/auth/gating.ts index dee5698f..057a87a7 100644 --- a/src/auth/gating.ts +++ b/src/auth/gating.ts @@ -401,6 +401,41 @@ export async function canAccessIndexEntry( return denied === null; } +/** + * The denial `req` gets for a gated index entry, or null when it may have it. + * The Response is the page-level one from {@link enforceRoles} (302 to login + * for an anonymous visitor, 403 otherwise). + */ +export async function denialForIndexEntry( + req: Request, + page: GatedIndexEntry, + authzOverride?: DuneAuthSystem | null, +): Promise { + if (page.gated !== true) return null; + const spec = parseRolesSpec(page.roles); + if (spec === null) return null; + return await enforceRolesFromRequest(req, spec, authzOverride); +} + +/** + * What a *file* request is told when {@link denialForIndexEntry} denied it. + * + * A logged-out visitor who navigates straight to a gated file (a clicked PDF + * link, `Sec-Fetch-Dest: document`) is sent to the login page, as for a gated + * page. Every other denial is a 404, which also does not confirm that the file + * exists: subresources (``, `