Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,30 @@ 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: []`). 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
(`/<page>/file.pdf`) already checked the owning page; the legacy `/content-media/<folder>/<file>` route did
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

### Fixed
Expand Down
146 changes: 143 additions & 3 deletions src/auth/gating.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 ──────────────────────────────────────────────────────────────────

/**
Expand Down Expand Up @@ -358,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<Response | null> {
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 (`<img>`, `<audio>`, …) cannot use a login redirect
* anyway, and a signed-in visitor without the role learns nothing.
*/
export function mediaDenialResponse(req: Request, denial: Response): Response {
if (
denial.status === 302 &&
req.headers.get("sec-fetch-dest") === "document"
) {
return denial;
}
return new Response("Not found", { status: 404 });
}

/** Keep only the entries `req` is allowed to see, preserving order. */
export async function filterAccessibleEntries<T extends GatedIndexEntry>(
req: Request,
Expand Down Expand Up @@ -392,6 +470,68 @@ 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/<path>` 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 = 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<object, Map<string, unknown[]>>();

function pagesByDir<T extends { sourcePath: string }>(
pages: T[],
): Map<string, T[]> {
const cached = pagesByDirCache.get(pages);
if (cached) return cached as Map<string, T[]>;
const byDir = new Map<string, T[]>();
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]);
}
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<T extends GatedIndexEntry & { route: string }>(
pages: T[],
pathname: string,
Expand Down
16 changes: 16 additions & 0 deletions src/config/site-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
90 changes: 89 additions & 1 deletion src/content/index-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,16 @@
* - .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";
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,
Expand Down Expand Up @@ -81,6 +83,12 @@ export interface IndexBuilderOptions {
* `blog/my-post/post.md` → `/blog/my-post`.
*/
templateNames?: Set<string>;
/**
* 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. */
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<string, PageIndex[]>();
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.
Expand Down Expand Up @@ -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),
Expand Down
6 changes: 6 additions & 0 deletions src/content/page-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading