diff --git a/CHANGELOG.md b/CHANGELOG.md index fa807bd4..da83ff4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,18 +139,49 @@ where the setting lives now (see `dune migrate:config`, which reports these too) ### Changed — BREAKING +- **Core gates `/api/inline-edit/*`; the `editor.inline` provider serves it (#127).** Core resolves who + is editing (a site user, else the `editor.session` provider's user, both holding `pages.update` by + `authz.check()`), checks that writes are same-origin, and hands the request to the provider's new + `InlineEditManager.handleRequest(req, { editor, path, pageFrontmatter })`. The inline-edit + WebSocket and the bar use the same resolver (`createEditorResolver()`), so a signed-in site user who + may edit now gets the bar too, and any signed-in request bypasses the shared page cache. The + `source` / `commit` / `fields` routes that lived in the admin move to the provider + (`/api/inline-edit/content//...`). `InlineEditManager` loses `commit` and `patchFields` (core + never called them) and gains `handleRequest`; exports `EditorIdentity` and + `InlineEditRequestContext` (`@dune/core/inline-edit`). `runPluginResponseTransforms()` takes + `resolveEditor` instead of `auth`, `authz` and `sessionCookies`. `checkInlineEditPermission()` and + `checkInlineEditPermissionForSiteUser()` are gone. +- **Page events carry an optional `actor`.** `onPageCreate`, `onPageUpdate`, `onPageDelete` and + `onPageMove` payloads may include `actor?: string`, the user's name when a person did the write. + - **The contract keeps only what core consumes; each plugin declares its own extension points (#127).** `AdminPageRegistration`, `AdminRouteRegistration`, `ContentEditorPlugin`, `AdminBarAction` and `AdminBarActionContext` leave `@dune/core/plugins` and `@dune/plugin-api`; core never read them. The keys `admin.pages`, `admin.routes`, `editor.content` (now `admin.editor`) and `bar.actions` leave `Capabilities` / - `Contributions` and belong to the plugin that reads them (`@dune/plugin-admin`, - `@dune/plugin-inline-edit`), which declares them by declaration merging. Both interfaces are - now open to merging; the rule is that a key is prefixed with the name of the plugin that - consumes it, and unprefixed names are core's. At runtime nothing changes: the registry was - already keyed by string. `ResponseTransformContext.barActions` and `.adminPrefix` are replaced + `Contributions` (which is removed) and belong to the plugin that reads them + (`@dune/plugin-admin`, `@dune/plugin-inline-edit`), which exports them as typed keys + (`capabilityKey("admin.pages")`, from `@dune/core/plugin-api`). `provide`, `contribute`, `get` and + `list` accept a key and are typed by it; `Capabilities` stays closed and lists only what core + reads. There is no module augmentation: JSR refuses it in a published package. The rule is that + a key's name is prefixed with the plugin that consumes it, and unprefixed names are core's. Two + keys with the same name are the same capability, so a plugin can recreate another's key from its + documented name instead of importing it. At runtime nothing changes: the registry was already + keyed by string. `ResponseTransformContext.barActions` and `.adminPrefix` are replaced by `ResponseTransformContext.capabilities`, the same reader `MountApi.capabilities` offers: a - bar-owning plugin reads `list("bar.actions")` and `get("admin.prefix")` per request. + bar-owning plugin reads `list(BAR_ACTIONS)` and `get("admin.prefix")` per request. +- **Holding `admin`, `editor` or `author` in `roles[]` carries editing rights for any signed-in user, + with or without the admin plugin (#130).** Before, a site user got them only after a restart and + only on a site that ran the admin with the local user store; now the roles are granted at group + level and kept in step with `roles[]` live, for the site login, `user_store: db` and + `external-jwt` role claims alike. Check this on upgrade if a site uses `editor` or `author` as a + *content* tier (in `roles:` frontmatter, a payment product's `role`, or a sign-up default): those + users can now edit. Use another role name for a content tier. Nothing is refused: paid + contributors are a legitimate use. `author` stays limited to the pages it created, `admin` and + `editor` edit any page. Core logs `auth.automatic_admin_tier_grant` at start, and + `dune doctor --boot` prints an `access` line, wherever the *config* assigns such a role by itself + (a payment product's `role`, an `external-jwt` login). It is informational (a warning for + `admin`) and never fails the run. - **Site-wide permissions are checked on `app:site`, not `app:admin` (#130).** `pages.update` and the other admin-tier actions name what they protect (this site's content), not the panel that happens to administer it, so the same check works on a site with no admin, one admin @@ -365,6 +396,10 @@ where the setting lives now (see `dune migrate:config`, which reports these too) ### Fixed +- **A live editing session respects page ownership.** An `author` could join the inline-edit + WebSocket on a page it did not create and type into the shared draft, though a commit of that page + was refused. Core now applies the same rule at the socket: `admin` and `editor` any page, an + `author` only the pages it created (`createdBy`). - **A signed-in site user with an editing role can edit inline without the admin plugin (#130).** The authz system is now created whenever the site has a login (`site.auth`), a plugin provides `editor.session`, or a plugin declares `authzActions`; `mountDuneAuth` reuses diff --git a/src/auth/automatic-grants.ts b/src/auth/automatic-grants.ts new file mode 100644 index 00000000..057c74c2 --- /dev/null +++ b/src/auth/automatic-grants.ts @@ -0,0 +1,62 @@ +/** + * Where a site hands out admin-tier roles without anyone being made an editor + * by hand. + * + * `admin`, `editor` and `author` are also ordinary entries of a user's + * `roles[]`, and holding one carries editing rights (see `bootstrapRoleGrants`). + * Most sites assign them deliberately (`dune users:create`, the admin panel), + * but a few configuration paths assign roles by themselves: a payment product's + * `role`, and the role claims of an external identity provider. Doing that on + * purpose is legitimate (paid contributors, an IdP that owns staff roles), so + * nothing here refuses anything. It names the paths so that a site which did not + * mean it can see it. + * + * @module + */ + +import type { DuneConfig } from "../config/types.ts"; + +/** One automatic path to an admin-tier role. */ +export interface AutomaticGrant { + /** `warning` for `admin` (full control), `info` otherwise. */ + severity: "info" | "warning"; + message: string; +} + +const CAN: Record = { + admin: "do everything the admin panel allows, including managing users and settings", + editor: "edit every page and manage media", + author: "create pages and edit the pages they created", +}; + +const ADMIN_TIER = ["admin", "editor", "author"]; + +/** The automatic paths to an admin-tier role that `config` sets up. Empty for most sites. */ +export function automaticAdminTierGrants(config: DuneConfig): AutomaticGrant[] { + const out: AutomaticGrant[] = []; + const site = config.site; + + const payments = site?.payments; + if (payments?.secret_key) { + for (const product of payments.products ?? []) { + const role = product.role; + if (role && ADMIN_TIER.includes(role)) { + out.push({ + severity: role === "admin" ? "warning" : "info", + message: `payments product "${product.id}" gives every buyer the "${role}" role, which can ${CAN[role]}. ` + + `Intended? If "${role}" is only a content tier here, use another role name.`, + }); + } + } + } + + if (site?.auth?.mode === "external-jwt") { + out.push({ + severity: "info", + message: `the external-jwt login trusts the token's "${site.auth.jwt?.roles_claim ?? "roles"}" claim: a token that ` + + `carries "admin", "editor" or "author" grants editing rights (author: ${CAN.author}; editor: ${CAN.editor}; ` + + `admin: ${CAN.admin}). Make sure the identity provider only issues those to staff.`, + }); + } + return out; +} diff --git a/src/auth/editor.ts b/src/auth/editor.ts new file mode 100644 index 00000000..117374aa --- /dev/null +++ b/src/auth/editor.ts @@ -0,0 +1,105 @@ +/** + * Who is editing this request. + * + * Two sources, tried in this order: the site's own login (`getUser(req)`, + * populated by `mountDuneAuth()`), then whoever the `editor.session` provider + * signs in. Either way the identity only counts as an editor once + * `authz.check(pages.update)` on the site resource passes, the same decision + * for every consumer: the bar's response transform, the inline-edit WebSocket + * and `/api/inline-edit/*`. + * + * @module + */ + +import type { CapabilityReader } from "../plugin-api/capabilities.ts"; +import type { EditorIdentity } from "../inline-edit/types.ts"; +import type { DuneAuthSystem } from "./authz.ts"; +import { SITE_RESOURCE } from "./authz-schema.ts"; +import { getUser } from "./types.ts"; +import { hasSessionCookie } from "../cli/admin-bar-inject.ts"; + +/** Outcome of {@link EditorResolver}: an editor, or why there is none. */ +export type EditorResolution = + | { ok: true; editor: EditorIdentity } + | { ok: false; status: 401 | 403 }; + +/** Resolve the editor of a request. Never throws; a failing session lookup is "unauthenticated". */ +export type EditorResolver = (req: Request) => Promise; + +export interface EditorResolverOptions { + capabilities: CapabilityReader; + /** + * The authz system of the site login (it holds site users' tuples; with + * `site.auth.authz_store: db` that is not bootstrap's local one). Read per + * request: the site login mounts after the resolver is built. + */ + siteAuthz: () => DuneAuthSystem | undefined; + /** The authz system bootstrap built; decides for `editor.session` users. */ + sessionAuthz: () => DuneAuthSystem | undefined; +} + +/** Display name of a user record: username, else name, else email, else id. */ +function displayName(user: { id: string; username?: string; name?: string; email?: string }): string { + return user.username ?? user.name ?? user.email ?? user.id; +} + +function canUpdatePages(authz: DuneAuthSystem | undefined, userId: string): Promise { + // Fail closed: no authz system means nobody is an editor. + if (!authz) return Promise.resolve(false); + return authz.check({ + who: { type: "user", id: userId }, + // deno-lint-ignore no-explicit-any + canThey: "pages.update" as any, + onWhat: SITE_RESOURCE, + }).catch(() => false); +} + +/** Build the {@link EditorResolver} of a site. */ +export function createEditorResolver(opts: EditorResolverOptions): EditorResolver { + return async (req) => { + const siteUser = getUser(req); + if (siteUser) { + if (!await canUpdatePages(opts.siteAuthz(), siteUser.id)) return { ok: false, status: 403 }; + return { + ok: true, + editor: { id: siteUser.id, username: displayName(siteUser), roles: siteUser.roles, source: "site" }, + }; + } + + const provider = opts.capabilities.get("editor.session"); + if (!provider || !hasSessionCookie(req, provider.sessionCookies)) return { ok: false, status: 401 }; + const result = await provider.authenticate(req).catch(() => null); + // deno-lint-ignore no-explicit-any + const user = (result as any)?.authenticated ? (result as any).user : null; + if (!user?.id) return { ok: false, status: 401 }; + if (!await canUpdatePages(opts.sessionAuthz(), user.id as string)) return { ok: false, status: 403 }; + return { + ok: true, + editor: { + id: user.id as string, + username: displayName(user), + roles: (user.roles as string[] | undefined) ?? [], + source: "session", + }, + }; + }; +} + +/** + * Whether `editor` may edit the page with this frontmatter. An `admin` or + * `editor` may edit any page; anyone else (an `author`) only the pages they + * created, recorded as `createdBy` in the frontmatter. `null` is a path that is + * not a page, which nobody may edit. + * + * The HTTP routes of the inline-edit provider apply the same rule at commit; + * core applies it at the WebSocket too, so a live session cannot be opened on a + * page whose commit would be refused. + */ +export function mayEditPage( + editor: Pick, + frontmatter: Record | null, +): boolean { + if (!frontmatter) return false; + if (editor.roles.includes("admin") || editor.roles.includes("editor")) return true; + return frontmatter.createdBy === editor.id; +} diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 84d9b7cd..1c7071c1 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -35,7 +35,7 @@ import { describeConflict } from "../plugin-api/capabilities.ts"; import { formatNpmCacheMismatchError, isNpmCacheMismatchError } from "./npm-cache-error.ts"; export interface DoctorFinding { - category: "deno-version" | "resolution" | "npm-cache" | "lockfile" | "boot" | "capabilities"; + category: "deno-version" | "resolution" | "npm-cache" | "lockfile" | "boot" | "capabilities" | "access"; /** `info` is context for the reader and never fails the run. */ severity: "error" | "warning" | "info"; message: string; @@ -189,6 +189,11 @@ export function capabilityFindings(report: DoctorReport): DoctorFinding[] { }); } + // Admin-tier roles handed out by configuration (never an error: it is often exactly the point). + for (const g of report.automaticGrants ?? []) { + findings.push({ category: "access", severity: g.severity, message: `Editing rights are assigned automatically: ${g.message}` }); + } + if (report.scheduledActions > 0 && slot("scheduler.executor")?.status !== "active") { findings.push({ category: "capabilities", diff --git a/src/cli/response-transforms.ts b/src/cli/response-transforms.ts index 692d73ba..06897d9f 100644 --- a/src/cli/response-transforms.ts +++ b/src/cli/response-transforms.ts @@ -5,15 +5,12 @@ * `transformResponse` hook (see `ResponseTransformContext` in hooks/types.ts). * Responsibilities: * - * - Resolve the auth context once per request, enforcing the documented - * contract: `auth` is non-null only for a valid admin session that holds - * the `pages.update` permission. Plugins can rely on this and must not - * need to re-authenticate. The permission check itself is `authz.check()` — - * the sole authority every admin permission check follows - * (dec-identity-unification Phase 5c/6/7); if `authz` is somehow - * undefined (authz creation itself failed at startup — already logged - * loudly there), this fails closed rather than falling back to a - * separate, less-audited mechanism. + * - Resolve the editor once per request (`createEditorResolver()`), enforcing + * the documented contract: `auth` is non-null only for a signed-in user + * (site login or `editor.session` provider) who holds `pages.update`. + * Plugins can rely on this and must not need to re-authenticate. The + * permission check is `authz.check()`, the sole authority; it fails closed + * when authz is missing. * - Never run transforms on admin-panel paths (defense in depth — admin * routes are mounted separately and should never reach the content * catch-all, but the pipeline must not depend on routing order alone). @@ -29,23 +26,14 @@ * Used by both the production and dev request paths in fresh-app.ts. */ -/** - * Minimal auth middleware interface — concrete type is from `@dune/plugin-admin`. - * `authenticate()` is still the only way to validate an admin session cookie - * (core doesn't own admin session cookie config). - */ -interface AdminAuthMiddleware { - authenticate(req: Request): Promise; -} import type { DuneConfig } from "../config/types.ts"; import type { DunePlugin, ResponseTransformContext } from "../hooks/types.ts"; import type { CapabilityReader } from "../plugin-api/capabilities.ts"; -import type { DuneAuthSystem } from "../auth/authz.ts"; +import type { EditorResolver } from "../auth/editor.ts"; import type { PageIndex } from "../content/types.ts"; -import { highestAdminRole, roleHasPermission, SITE_RESOURCE } from "../auth/authz-schema.ts"; +import { highestAdminRole, roleHasPermission } from "../auth/authz-schema.ts"; import { applyResponseTransforms } from "../plugins/loader.ts"; -import { hasSessionCookie } from "./admin-bar-inject.ts"; import { isAdminPath } from "./serve-utils.ts"; import { scrubMarkersFromResponse } from "./marker-scrub.ts"; @@ -75,21 +63,13 @@ export interface RunResponseTransformsOptions { /** All registered plugins; filtered on `transformResponse` internally. */ plugins: DunePlugin[]; /** - * Admin auth middleware — null when the admin plugin is disabled or not yet mounted. - * When null, all sessions are treated as anonymous (transformAuth stays null). + * Resolves who is editing this request (`createEditorResolver()`): a site + * user or the `editor.session` provider's user holding `pages.update`. + * Omitted means every request is anonymous. */ - auth: AdminAuthMiddleware | null; - /** Cookies that carry the `editor.session` provider's session (`RequestAuthenticator.sessionCookies`); omitted means any cookie. */ - sessionCookies?: readonly string[]; + resolveEditor?: EditorResolver; /** What plugins provide; handed to transform plugins as `ResponseTransformContext.capabilities`. Omitted means nothing is provided. */ capabilities?: CapabilityReader; - /** - * Polizy authz system — the sole authority for the `pages.update` check - * below (dec-identity-unification Phase 5c/7). Undefined only when authz - * creation itself failed at startup (already logged loudly there — see - * `src/runtime/bootstrap.ts`), in which case the check fails closed. - */ - authz?: DuneAuthSystem; /** * This site's actual `actionToRelations` map (built-ins plus whatever * plugins contributed via `DunePlugin.authzActions`) — @@ -134,13 +114,11 @@ export async function runPluginResponseTransforms( req, response, plugins, - auth, - authz, + resolveEditor, resolve, config, adminPrefix, actionToRelations, - sessionCookies, capabilities = EMPTY_CAPABILITIES, } = opts; @@ -150,45 +128,22 @@ export async function runPluginResponseTransforms( if (isAdminPath(url.pathname, adminPrefix)) return response; let transformAuth: ResponseTransformContext["auth"] = null; - if (auth && hasSessionCookie(req, sessionCookies)) { - try { - const result = await auth.authenticate(req); - // Contract (ResponseTransformContext.auth): non-null only when the - // session is valid AND holds pages.update — same gate the pre-plugin - // admin-bar injector enforced. - // deno-lint-ignore no-explicit-any - const r = result as any; - const canUpdatePages = r?.authenticated && r?.user && ( - authz - ? await authz.check({ - who: { type: "user", id: r.user.id as string }, - // deno-lint-ignore no-explicit-any - canThey: "pages.update" as any, - onWhat: SITE_RESOURCE, - }) - : false - ); - if (canUpdatePages) { - const user = r.user as Record; - // This context field keeps its original `role: string` shape (a - // published hook type, ResponseTransformContext — minimize churn for - // plugin authors) rather than switching to `roles`. - const roles = user.roles as string[] | undefined; - // The merged User type mixes admin-tier roles with content-gating - // tags in roles[] (e.g. ["member", "admin"]) with no guaranteed - // order — roles[0] is not necessarily an admin role. The published - // role string and hasPermission() answers sourced from it must use - // the highest admin-tier entry, or the synchronous approximation - // would under-privilege relative to what authz.check() (which - // unions all the user's relations) decided just above. - const role = highestAdminRole(roles); - transformAuth = { - username: user.username as string, - role, - hasPermission: (perm) => roleHasPermission(role, perm, actionToRelations), - }; - } - } catch { /* invalid session — treat as unauthenticated */ } + if (resolveEditor) { + const resolved = await resolveEditor(req); + if (resolved.ok) { + // The context keeps its original `role: string` shape (a published hook + // type) rather than switching to `roles`. The roles list mixes admin-tier + // roles with content-gating tags in no guaranteed order, so the published + // role and the synchronous hasPermission() answers use the highest + // admin-tier entry, or they would under-privilege relative to the + // authz.check() that already decided this user may edit. + const role = highestAdminRole(resolved.editor.roles); + transformAuth = { + username: resolved.editor.username, + role, + hasPermission: (perm) => roleHasPermission(role, perm, actionToRelations), + }; + } } let out = response; diff --git a/src/hooks/types.ts b/src/hooks/types.ts index 4ee1cf69..1e857686 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -49,9 +49,9 @@ export type HookEvent = // server, a CLI command, a plugin), never by core on their behalf. Payloads // carry paths relative to the content directory. Edits made outside Dune // (an editor, git pull) fire nothing per page: they surface as onRebuild. - | "onPageCreate" // a page file was created: { sourcePath, title } - | "onPageUpdate" // a page file was written: { sourcePath } - | "onPageDelete" // a page file was deleted: { sourcePath } + | "onPageCreate" // a page file was created: { sourcePath, title, actor? } + | "onPageUpdate" // a page file was written: { sourcePath, actor? } + | "onPageDelete" // a page file was deleted: { sourcePath, actor? } | "onPageMove" // pages were renamed or reordered: PageMovePayload | "onWorkflowChange" // page workflow status changed: { sourcePath, from, to } // Public features (fired by core, so they work without an admin plugin) @@ -65,9 +65,17 @@ export type HookEvent = * file, as paths relative to the content directory. Every page at or below * `from` now lives at the same position below `to`. One event covers one * operation (a reorder renames many folders), so a handler can re-index once. + * + * The other page events carry `actor?: string` the same way: `onPageCreate` + * `{ sourcePath, title, actor? }`, `onPageUpdate` and `onPageDelete` + * `{ sourcePath, actor? }`. Whoever writes the page fires the event and fills + * `actor` when a person is behind the write, so audit and webhook plugins + * need no access to the writer's request. */ export interface PageMovePayload { moves: Array<{ from: string; to: string }>; + /** Who did it (the user's name or id), when a person did; absent for CLI and automated writes. @since 0.36.0 */ + actor?: string; } /** @@ -413,8 +421,8 @@ export interface DunePlugin { * @example * ```ts * // Gate a plugin-specific admin capability behind its own permission, - * // reachable from an `admin.routes` contribution (the admin plugin's extension point): - * // api.capabilities.contribute("admin.routes", { method: "POST", path: "/billing/refund", permission: "billing.manage", handler }) + * // reachable from an `ADMIN_ROUTES` contribution (the admin plugin's extension point): + * // api.capabilities.contribute(ADMIN_ROUTES, { method: "POST", path: "/billing/refund", permission: "billing.manage", handler }) * export default { * name: "my-billing-plugin", * version: "1.0.0", @@ -557,8 +565,8 @@ export interface ResponseTransformContext { page: { sourcePath: string; route: string; title: string | null; template: string } | null; /** * What plugins provide, as `MountApi.capabilities` offers it: the slots - * (`get("admin.prefix")`, `get("auth.session")`) and any extension point a - * plugin declares (`list("bar.actions")`). A transform reads what it needs + * (`get("admin.prefix")`, `get("editor.session")`) and any extension point a + * plugin declares (`list(BAR_ACTIONS)`). A transform reads what it needs * here per request, so a plugin module shared between sites keeps no state * of its own. * diff --git a/src/inline-edit/mod.ts b/src/inline-edit/mod.ts index 366bfacc..3ba9a644 100644 --- a/src/inline-edit/mod.ts +++ b/src/inline-edit/mod.ts @@ -1,9 +1,9 @@ /** - * Inline editing service port — the interface core's admin endpoints consume. + * Inline editing service port — the interface core's `/api/inline-edit/*` gate hands requests to. * * Core ships no implementation. A plugin provides one through * the `editor.inline` capability (e.g. `jsr:@dune/plugin-inline-edit`); without - * one, the inline-edit admin endpoints respond 501. + * one, those endpoints respond 501. * * @module * @since 0.16.0 @@ -12,5 +12,7 @@ export type { ActiveEditor, DocumentPresence, + EditorIdentity, InlineEditManager, + InlineEditRequestContext, } from "./types.ts"; diff --git a/src/inline-edit/types.ts b/src/inline-edit/types.ts index 6fa89f77..38b4e50c 100644 --- a/src/inline-edit/types.ts +++ b/src/inline-edit/types.ts @@ -1,16 +1,17 @@ /** * Inline editing service port. * - * Core defines this interface and consumes it from the admin panel routes - * (`/api/inline-edit/ws`, the presence endpoint, and the content commit / - * field-patch endpoints). It does not ship an implementation: a plugin - * provides one as the `editor.inline` capability (e.g. - * `jsr:@dune/plugin-inline-edit`), and the endpoints respond 501 when - * no plugin has filled the slot. + * Core owns who may edit: after the site's authentication has run it resolves + * the editor (a site user, or whoever the `editor.session` provider signs in), + * checks `pages.update` and, for requests that change something, the request's + * origin. It then hands the request to the plugin that provides the + * `editor.inline` capability (e.g. `jsr:@dune/plugin-inline-edit`) at + * `/api/inline-edit/ws` (a WebSocket upgrade) and `/api/inline-edit/*` (plain + * requests). Both respond 501 when no plugin has filled the slot. * - * The wire protocol spoken over the WebSocket — and any client-side editor - * UI — is entirely the providing plugin's concern; core treats both as - * opaque. + * The wire protocol spoken over the WebSocket, the HTTP routes below + * `/api/inline-edit/` and any client-side editor UI are entirely the providing + * plugin's concern; core treats them as opaque. */ // ── Presence ────────────────────────────────────────────────────────────────── @@ -29,6 +30,38 @@ export interface DocumentPresence { editors: ActiveEditor[]; } +// ── Editor ──────────────────────────────────────────────────────────────────── + +/** + * Who is editing: an authenticated user that core has already checked for + * `pages.update`. `source` says where the identity came from. + */ +export interface EditorIdentity { + id: string; + /** Display name: the username, else the name, else the email. */ + username: string; + /** Every role on the user record (admin-tier roles and content-gating tags mixed). */ + roles: string[]; + /** `site`: signed in through the site's own login; `session`: through the `editor.session` provider. */ + source: "site" | "session"; +} + +/** What core hands {@link InlineEditManager.handleRequest} besides the request. */ +export interface InlineEditRequestContext { + /** The editor, already authenticated and checked for `pages.update`. */ + editor: EditorIdentity; + /** + * The path below `/api/inline-edit/`, without a leading slash and still + * percent-encoded (`content/pages%2Fabout%2Fdefault.md/commit`). + */ + path: string; + /** + * Frontmatter of an indexed page, or null when `sourcePath` is not a page of + * this site. Lets a handler apply its own ownership rules. + */ + pageFrontmatter(sourcePath: string): Promise | null>; +} + // ── Manager ─────────────────────────────────────────────────────────────────── /** Public interface for an inline editing server manager. */ @@ -41,22 +74,14 @@ export interface InlineEditManager { handleUpgrade(req: Request, user: { id: string; name: string }): Response; /** - * Flush the live editing state for `sourcePath` to a history revision - * and write the committed Markdown back to the `.md` file. - * This is the "Save" action triggered by the client. - */ - commit(sourcePath: string, author: string): Promise; - - /** - * Patch one or more frontmatter fields on a content file. - * Writes through the history engine (creates a revision) and patches the - * `.md` file on disk. Does not touch the Markdown body. + * Handle a plain HTTP request below `/api/inline-edit/` (everything except + * the WebSocket upgrade). Core has authenticated the editor, checked + * `pages.update` and, for POST, PUT, PATCH and DELETE, that the request is + * same-origin; the handler enforces anything page-specific (which page types + * and owners this editor may write) and fires the `onPage*` events for what + * it writes. Return 404 for a path it does not serve. */ - patchFields( - sourcePath: string, - fields: Record, - author: string, - ): Promise; + handleRequest(req: Request, ctx: InlineEditRequestContext): Promise; /** * Return live presence data — which documents have active editing diff --git a/src/plugin-api/capabilities.ts b/src/plugin-api/capabilities.ts index 45d16445..97bcb497 100644 --- a/src/plugin-api/capabilities.ts +++ b/src/plugin-api/capabilities.ts @@ -15,8 +15,13 @@ * the boot, because the provider decides who is treated as an editor (the * admin bar, editing markers, the inline-edit WebSocket) and load order * must not; - * - a **contribution point** collects one item from each contributing plugin - * ({@link Contributions}). + * - a **contribution point** collects one item from each contributing plugin. + * + * A plugin that other plugins can extend declares its own keys as typed tokens + * ({@link CapabilityKey}, made with {@link capabilityKey}) and exports them next + * to its types. A token works for both shapes: `provide(KEY, factory)`, + * `contribute(KEY, item)`, `get(KEY)` and `list(KEY)` are typed by it, with no + * module augmentation (which JSR does not accept in a published package). * * Plugins declare in `setup()`; core resolves every slot factory once, after * all `setup()`s have settled and core infrastructure exists, in registration @@ -84,41 +89,49 @@ export interface AdminPrefix { } /** - * Slots: what core itself consumes, or what every plugin may rely on. - * - * Open to declaration merging. A plugin that offers an extension point of its - * own declares the key next to its types, so core's contract does not grow with - * every plugin's features: - * - * ```ts - * declare module "@dune/plugin-api" { - * interface Capabilities { "admin.editor": ContentEditorPlugin } - * } - * ``` - * - * The rule: **a key is prefixed with the name of the plugin that consumes it** - * (`admin.*` is the admin's, `bar.*` plugin-inline-edit's). Unprefixed - * namespaces (`editor`, `scheduler`, `admin.prefix`) belong to core. - * Core never reads a key it did not declare. + * Slots core itself consumes. This list is closed: it holds only what core + * reads. An extension point that belongs to a plugin is a {@link CapabilityKey} + * that plugin exports, not an entry here. */ export interface Capabilities { /** Where the admin is mounted. Consumers: core's request handling, static build and plugin-route registration. */ "admin.prefix": AdminPrefix; - /** Who is treated as an editor on this request. Consumers: the admin bar's response transform, the inline-edit WebSocket. */ + /** Who is treated as an editor on this request. Consumers: the editor resolver behind the bar's response transform and `/api/inline-edit/*`. */ "editor.session": RequestAuthenticator; /** Executes due scheduled actions. Consumer: `dune serve`, which owns the scheduler's polling loop. */ "scheduler.executor": ScheduledActionExecutor; - /** Real-time inline editing. Consumers: core's `/api/inline-edit/ws`, the admin. */ + /** Real-time inline editing and its HTTP routes. Consumers: core's `/api/inline-edit/*` gate, the admin (WebSocket, presence). */ "editor.inline": InlineEditManager; } /** - * Contribution points: one item from each contributing plugin. Open to - * declaration merging under the same rule as {@link Capabilities}. Core - * declares none: every point today belongs to the plugin that reads it. + * A typed name for a capability slot or contribution point that a plugin + * declares. Create it once with {@link capabilityKey} and export it: + * + * ```ts + * export const BAR_ACTIONS: CapabilityKey<(ctx: BarActionContext) => BarAction[]> = + * capabilityKey("bar.actions"); + * // contributor: api.capabilities.contribute(BAR_ACTIONS, ({ page }) => [...]) + * // consumer: capabilities.list(BAR_ACTIONS) + * ``` + * + * The rule for names: **prefix the name with the plugin that consumes it** + * (`admin.*` is the admin's, `bar.*` plugin-inline-edit's). Unprefixed + * namespaces (`editor`, `scheduler`, `admin.prefix`) belong to core, which + * never reads a key it did not declare. Two keys with the same name are the same + * capability at runtime, so a plugin may recreate another plugin's key from its + * documented name instead of importing it. */ -// deno-lint-ignore no-empty-interface -export interface Contributions {} +export interface CapabilityKey { + readonly name: string; + /** Phantom: carries `T` for the type checker only. Never set. */ + readonly __type?: T; +} + +/** Make a {@link CapabilityKey}. */ +export function capabilityKey(name: string): CapabilityKey { + return { name }; +} /** What a slot factory receives: core infrastructure that exists once `setup()`s have settled. */ export interface CapabilityContext { @@ -141,8 +154,11 @@ export type CapabilityFactory = (ctx: CapabilityContext) => T | undefined | P /** `PluginApi.capabilities`: how a plugin declares what it provides. */ export interface CapabilityRegistrar { + /** Provide a slot: one of core's, or a plugin's own {@link CapabilityKey}. */ provide(name: K, factory: CapabilityFactory): void; - contribute(point: K, item: Contributions[K]): void; + provide(key: CapabilityKey, factory: CapabilityFactory): void; + /** Contribute one item to a plugin's contribution point. */ + contribute(key: CapabilityKey, item: T): void; } /** One entry of {@link CapabilityReader.providers}. */ @@ -159,8 +175,9 @@ export interface CapabilityProvider { export interface CapabilityReader { /** The active provider of a slot, or undefined (nobody provides it, it declined, it failed, or resolution has not run). */ get(name: K): Capabilities[K] | undefined; + get(key: CapabilityKey): T | undefined; /** Everything contributed to a point, in plugin registration order. */ - list(point: K): readonly Contributions[K][]; + list(key: CapabilityKey): readonly T[]; /** Who provides what, for `dune doctor`. */ providers(): CapabilityProvider[]; } @@ -241,6 +258,10 @@ interface SlotEntry { value?: unknown; } +function keyName(nameOrKey: string | CapabilityKey): string { + return typeof nameOrKey === "string" ? nameOrKey : nameOrKey.name; +} + export function createCapabilityRegistry(): CapabilityRegistry { const slots = new Map(); const contributions = new Map(); @@ -248,12 +269,12 @@ export function createCapabilityRegistry(): CapabilityRegistry { let resolving = false; const reader: CapabilityReader = { - get(name) { - const slot = slots.get(name); + get(name: string | CapabilityKey) { + const slot = slots.get(keyName(name)); return slot?.status === "active" ? slot.value as never : undefined; }, - list(point) { - return (contributions.get(point) ?? []).map((c) => c.item) as never; + list(key: CapabilityKey) { + return (contributions.get(key.name) ?? []).map((c) => c.item) as never; }, providers() { const out: CapabilityProvider[] = []; @@ -272,7 +293,8 @@ export function createCapabilityRegistry(): CapabilityRegistry { registrar(provider) { return { - provide(name, factory) { + provide(nameOrKey: string | CapabilityKey, factory: CapabilityFactory) { + const name = keyName(nameOrKey); const previous = slots.get(name); if (previous && EXCLUSIVE_SLOTS.has(name) && previous.provider !== provider) { // Keep the first provider; the boot stops on the conflict (see conflicts()). @@ -288,12 +310,12 @@ export function createCapabilityRegistry(): CapabilityRegistry { slots.delete(name); } if (resolving) logger.warn("capabilities.provided_late", { capability: name, provider }); - slots.set(name, { provider, factory: factory as CapabilityFactory, status: "unresolved" }); + slots.set(name, { provider, factory, status: "unresolved" }); }, - contribute(point, item) { - const list = contributions.get(point) ?? []; + contribute(key: CapabilityKey, item: unknown) { + const list = contributions.get(key.name) ?? []; list.push({ provider, item }); - contributions.set(point, list); + contributions.set(key.name, list); }, }; }, diff --git a/src/plugin-api/doctor-report.ts b/src/plugin-api/doctor-report.ts index 464345e0..7cf8f311 100644 --- a/src/plugin-api/doctor-report.ts +++ b/src/plugin-api/doctor-report.ts @@ -23,6 +23,8 @@ export interface DoctorReport { conflicts?: CapabilityConflict[]; /** Scheduled actions waiting to run (they only run while a `scheduler.executor` is provided). */ scheduledActions: number; + /** Where the config hands out admin-tier roles automatically (a payment product's role, an identity provider's role claims). */ + automaticGrants?: Array<{ severity: "info" | "warning"; message: string }>; } /** diff --git a/src/plugin-api/mod.ts b/src/plugin-api/mod.ts index 20641be5..74eac8be 100644 --- a/src/plugin-api/mod.ts +++ b/src/plugin-api/mod.ts @@ -24,15 +24,15 @@ export type { AuthenticateResult, CapabilityContext, CapabilityFactory, + CapabilityKey, CapabilityProvider, CapabilityReader, CapabilityRegistrar, Capabilities, - Contributions, RequestAuthenticator, ScheduledActionExecutor, } from "./capabilities.ts"; -export { adminPrefixOf, createCapabilityRegistry } from "./capabilities.ts"; +export { adminPrefixOf, capabilityKey, createCapabilityRegistry } from "./capabilities.ts"; export type { CapabilityRegistry } from "./capabilities.ts"; export type { PluginApp, PluginRouteContext, PluginRouteHandler } from "./app.ts"; export type { PluginRuntime } from "./runtime.ts"; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index b5a4ef24..a51a7656 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -39,6 +39,7 @@ import type { HookRegistry } from "../hooks/types.ts"; import type { StorageAdapter } from "../storage/types.ts"; import { logger } from "../core/logger.ts"; import { writeDoctorReport } from "../plugin-api/doctor-report.ts"; +import { automaticAdminTierGrants } from "../auth/automatic-grants.ts"; import { describeConflict, invalidAdminPrefix } from "../plugin-api/capabilities.ts"; import { createPluginRuntime } from "../plugin-api/runtime.ts"; import { resolveRuntimeDir } from "../forms/config.ts"; @@ -503,6 +504,7 @@ export async function mountPlugins( await writeDoctorReport(async () => ({ providers: ctx.capabilityRegistry.reader.providers(), scheduledActions: (await ctx.pluginServices.engines.scheduler.list()).length, + automaticGrants: automaticAdminTierGrants(ctx.config), })); // mount(), registration order (admin last): a plugin's own diff --git a/src/plugins/mod.ts b/src/plugins/mod.ts index 78e79059..dd4a75e9 100644 --- a/src/plugins/mod.ts +++ b/src/plugins/mod.ts @@ -32,8 +32,8 @@ * - `PluginApi.capabilities` / `MountApi.capabilities` — what plugins provide to * core and to each other (`admin.prefix`, `editor.session`, `scheduler.executor`, * `editor.inline`). Extension points a plugin offers to others (`admin.pages`, - * `admin.routes`, `admin.editor`, `bar.actions`) are declared by that plugin, by - * declaration merging, and are not part of core's contract. + * `admin.routes`, `admin.editor`, `bar.actions`) are typed keys (`capabilityKey`) that + * plugin exports, and are not part of core's contract. * Replaces `DunePlugin.adminServices`, `adminPages`, `adminBarActions` and * `mountEarly`, and the untyped `ctx.adminContext`. See `@dune/core/plugin-api`. * diff --git a/src/runtime/bootstrap.ts b/src/runtime/bootstrap.ts index 8311c560..832cd0ce 100644 --- a/src/runtime/bootstrap.ts +++ b/src/runtime/bootstrap.ts @@ -40,6 +40,7 @@ import { createFlexEngine } from "../flex/engine.ts"; import { loadPluginAdminConfigs, loadPlugins } from "../plugins/loader.ts"; import { MetricsCollector } from "../metrics/mod.ts"; import { bootstrapRoleGrants, createDuneAuthSystem } from "../auth/authz.ts"; +import { automaticAdminTierGrants } from "../auth/automatic-grants.ts"; import type { DuneAuthSystem } from "../auth/authz.ts"; import type { AuthzLocalAdapter } from "../auth/authz-adapter-local.ts"; import type { AuthzDbAdapter } from "../auth/authz-adapter-db.ts"; @@ -552,6 +553,13 @@ export async function bootstrap( // Admin-tier roles hold their relation on the site at group level, so a // site user's role is enough, with or without an admin plugin. await bootstrapRoleGrants(bundle.authz, bundle.adapter); + // Say where the config hands out those roles by itself (a payment product's + // role, an identity provider's role claims). Informational: it is often the point. + for (const g of automaticAdminTierGrants(config)) { + logger[g.severity === "warning" ? "warn" : "info"]("auth.automatic_admin_tier_grant", { + detail: g.message, + }); + } // Admin-user tuples are bootstrapped by @dune/plugin-admin's mount() hook // after it creates the UserManager. Core only creates the empty system here. } catch (err) { diff --git a/src/runtime/register-middleware.ts b/src/runtime/register-middleware.ts index 6e67b080..9d4e61d6 100644 --- a/src/runtime/register-middleware.ts +++ b/src/runtime/register-middleware.ts @@ -18,6 +18,8 @@ import { import { scrubMarkersFromResponse } from "../cli/marker-scrub.ts"; import { isBareResponse } from "../routing/bare-response.ts"; import { hasSessionCookie } from "../cli/admin-bar-inject.ts"; +import type { EditorResolver } from "../auth/editor.ts"; +import { getUser } from "../auth/types.ts"; import { runPluginResponseTransforms } from "../cli/response-transforms.ts"; import { buildCacheControl, @@ -49,6 +51,8 @@ export interface ContentMiddlewareOptions { /** Where the admin is mounted, or null when there is no admin. Read per request. */ adminPrefix: () => string | null; routes: DuneRoutes; + /** Who is editing a request (see `createEditorResolver()`); drives the bar and marker scrubbing. */ + resolveEditor: EditorResolver; } function makeRenderJsx( @@ -91,8 +95,9 @@ export function registerContentCatchAll( siteName, adminPrefix, routes, + resolveEditor, } = opts; - const { engine, imageHandler, hooks, config, metrics, authz, authzSchema } = ctx; + const { engine, imageHandler, hooks, config, metrics, authzSchema } = ctx; // Who the admin is on this request comes from whichever plugin provides the // `editor.session` capability; with none, every visitor is anonymous here. const getAdminAuth = () => ctx.capabilities.get("editor.session") ?? null; @@ -109,9 +114,7 @@ export function registerContentCatchAll( req, response, plugins: hooks.plugins(), - auth: getAdminAuth(), - sessionCookies: getAdminAuth()?.sessionCookies, - authz, + resolveEditor, actionToRelations: authzSchema.actionToRelations as Record, resolve: engine.router.resolve, config, @@ -200,10 +203,13 @@ export function registerContentCatchAll( const policy = resolvePolicy(url.pathname, cacheRules, cacheDefaults); const ccValue = buildCacheControl(policy); - // Admin-session requests bypass the shared page cache (their response - // includes the injected admin bar and must never be stored anonymously). + // Signed-in requests bypass the shared page cache: an editor's response + // includes the injected bar and must never be stored anonymously. That + // covers the `editor.session` provider's cookies and anyone signed in + // through the site login (the bar also shows for site users who may edit). const adminAuth = getAdminAuth(); - const bypassPageCache = adminAuth ? hasSessionCookie(req, adminAuth.sessionCookies) : false; + const bypassPageCache = getUser(req) !== null || + (adminAuth ? hasSessionCookie(req, adminAuth.sessionCookies) : false); // Role-gated pages must never enter the shared page cache. The cache is // keyed by pathname only and is not partitioned by authorization, so a diff --git a/src/runtime/server.ts b/src/runtime/server.ts index 604a303f..4b85eef0 100644 --- a/src/runtime/server.ts +++ b/src/runtime/server.ts @@ -15,10 +15,10 @@ import { App, staticFiles } from "fresh"; import { captureHandlerSocketAddr, copySocketAddr } from "../security/rate-limit.ts"; import type { BootstrapResult } from "./bootstrap.ts"; import type { DuneAuthSystem } from "../auth/authz.ts"; -import { SITE_RESOURCE } from "../auth/authz-schema.ts"; +import { createEditorResolver, mayEditPage } from "../auth/editor.ts"; import { mountPlugins } from "../plugins/loader.ts"; import { mountDuneAuth } from "../auth/mount.ts"; -import { getUser, USER_HEADER } from "../auth/types.ts"; +import { USER_HEADER } from "../auth/types.ts"; import { isAdminPath, withSecurityHeaders } from "../cli/serve-utils.ts"; import { adminPrefixOf } from "../plugin-api/capabilities.ts"; import { trackRequest } from "./watchdog.ts"; @@ -187,56 +187,23 @@ export function isIndexedInlineEditPath( } /** - * Whether an authenticated admin session may perform inline edits - * (`pages.update`). `authz` is the sole authority — the flat - * `ROLE_PERMISSIONS` fallback this used to have was removed - * (dec-identity-unification Phase 5c, second half): `admin.authzStore` - * defaults to `"local"` and is created automatically whenever the admin - * panel is enabled, regardless of `site.auth`'s mode, so `authz` being - * undefined here means its creation itself failed at startup (already - * logged loudly there) — an exceptional condition that should fail closed - * (deny), not silently degrade to a materially different, less-audited - * authorization mechanism. Extracted from the `/api/inline-edit/ws` handler - * below and exported so this decision is directly unit-testable without a - * full app + WebSocket-upgrade harness. + * Same-origin gate for `/api/inline-edit/*` requests that change something. + * A browser's fetch sends `Origin`; a request without one passes only when the + * browser vouches for it (`Sec-Fetch-Site: same-origin`). A non-browser client + * holding a stolen cookie has neither. */ -export async function checkInlineEditPermission( - authz: DuneAuthSystem | undefined, - // deno-lint-ignore no-explicit-any - authResult: any, -): Promise { - if (!authz) return false; - return await checkPagesUpdateViaAuthz(authz, authResult.user.id); -} - -function checkPagesUpdateViaAuthz( - authz: DuneAuthSystem, - userId: string, -): Promise { - return authz.check({ - who: { type: "user", id: userId }, - // deno-lint-ignore no-explicit-any - canThey: "pages.update" as any, - onWhat: SITE_RESOURCE, - }); -} - -/** - * Same `pages.update` decision as {@link checkInlineEditPermission}, for a - * user resolved via the public-auth session (`src/auth/`, `getUser(req)`) - * rather than an admin session — this is what lets `/api/inline-edit/ws` - * work for a site that has `@dune/plugin-admin` decoupled entirely. Like - * every other admin permission check, this fails closed (deny) when `authz` - * is undefined — there is no role-table fallback for public-auth users (and - * after 3.0.0, none for admin sessions either): `authz.check()` is the sole - * authority, no exceptions. - */ -export async function checkInlineEditPermissionForSiteUser( - authz: DuneAuthSystem | undefined, - user: { id: string; roles: string[] }, -): Promise { - if (!authz) return false; - return await checkPagesUpdateViaAuthz(authz, user.id); +export function assertInlineEditSameOrigin(req: Request): Response | null { + const method = req.method; + if (method === "GET" || method === "HEAD" || method === "OPTIONS") return null; + const origin = req.headers.get("origin"); + if (origin) { + try { + if (new URL(origin).host === new URL(req.url).host) return null; + } catch { /* fall through to deny */ } + return Response.json({ error: "Cross-origin request rejected" }, { status: 403 }); + } + if (req.headers.get("sec-fetch-site") === "same-origin") return null; + return Response.json({ error: "Origin required" }, { status: 403 }); } // ── Factory ──────────────────────────────────────────────────────────────────── @@ -458,19 +425,34 @@ export async function createDuneApp( // after mountPlugins() so it can reuse the authz bundle bootstrap()/the // admin plugin already built (see mount.ts's own comment on this). let publicAuthz: DuneAuthSystem | null = null; + // The site login's authz system is known only once mountDuneAuth() has run, so the resolver reads it per request. + const resolveEditor = createEditorResolver({ + capabilities: ctx.capabilities, + siteAuthz: () => publicAuthz ?? ctx.authz, + sessionAuthz: () => ctx.authz, + }); if (mountAuth && config.site.auth) { publicAuthz = (await mountDuneAuth(app, ctx)).authz; } - // 6. Inline-edit WebSocket — in core so it works without @dune/plugin-admin. - // Auth prefers the public-auth session (src/auth/, getUser(req)) set by - // mountDuneAuth() above — this is what makes @dune/plugin-inline-edit - // genuinely independent of @dune/plugin-admin when a site has `auth:` - // configured. Falls back to the admin session (same cookie as /admin/*) - // when this site has no public auth configured, which today is still - // the common case. Either way, the pages.update decision itself goes - // through authz.check() when configured — the sole authority every - // other permission check follows (dec-identity-unification Phase 5c/7). + // 6. Inline editing: the WebSocket and the plugin's HTTP routes. Core owns + // who may edit; the `editor.inline` provider owns what happens. They live + // here, after mountDuneAuth(), because only requests that went through its + // middleware carry a site user (getUser(req)), and that is what lets + // @dune/plugin-inline-edit work for a site with a login and no admin + // plugin. The decision itself is authz.check(pages.update), the sole + // authority every other permission check follows. + // Frontmatter of an indexed page, or null for a path that is not one. + const pageFrontmatter = async (sourcePath: string): Promise | null> => { + const page = engine.pages.find((p) => p.sourcePath === sourcePath); + if (!page) return null; + try { + return (await engine.loadPage(page.sourcePath)).frontmatter as Record; + } catch { + return null; + } + }; + app.get("/api/inline-edit/ws", async (fc) => { const inlineEdit = ctx.capabilities.get("editor.inline"); if (!inlineEdit) { @@ -489,40 +471,36 @@ export async function createDuneApp( return new Response("Unknown path", { status: 404 }); } - let userId: string; - let username: string; - let permitted: boolean; - - const siteUser = getUser(fc.req); - if (siteUser) { - userId = siteUser.id; - username = siteUser.username ?? siteUser.name ?? siteUser.email; - // The public-auth system is the one holding site users' tuples; when - // `site.auth.authz_store` is "db" that is not bootstrap()'s local one. - permitted = await checkInlineEditPermissionForSiteUser( - publicAuthz ?? ctx.authz, - siteUser, - ); - } else { - const adminAuth = ctx.capabilities.get("editor.session"); - if (!adminAuth) return new Response("Unauthorized", { status: 401 }); - const authResult = await adminAuth.authenticate(fc.req).catch(() => - null - ); - if (!authResult?.authenticated || !authResult.user) { - return new Response("Unauthorized", { status: 401 }); - } - userId = authResult.user.id; - username = authResult.user.username ?? authResult.user.name ?? authResult.user.email ?? authResult.user.id; - permitted = await checkInlineEditPermission( - ctx.authz, - authResult, - ); + const resolved = await resolveEditor(fc.req); + if (!resolved.ok) { + return new Response(resolved.status === 401 ? "Unauthorized" : "Forbidden", { status: resolved.status }); } + const { editor } = resolved; + // An author edits only the pages they created, as at commit. + if (!mayEditPage(editor, await pageFrontmatter(sourcePath))) { + return new Response("Forbidden", { status: 403 }); + } + return inlineEdit.handleUpgrade(fc.req, { id: editor.id, name: editor.username }); + }); - if (!permitted) return new Response("Forbidden", { status: 403 }); - - return inlineEdit.handleUpgrade(fc.req, { id: userId, name: username }); + app.all("/api/inline-edit/*", async (fc) => { + const inlineEdit = ctx.capabilities.get("editor.inline"); + if (!inlineEdit) { + return Response.json({ error: "Inline editing not enabled" }, { status: 501 }); + } + const resolved = await resolveEditor(fc.req); + if (!resolved.ok) { + return Response.json({ error: resolved.status === 401 ? "Unauthorized" : "Forbidden" }, { + status: resolved.status, + }); + } + const originDenied = assertInlineEditSameOrigin(fc.req); + if (originDenied) return originDenied; + return await inlineEdit.handleRequest(fc.req, { + editor: resolved.editor, + path: new URL(fc.req.url).pathname.slice("/api/inline-edit/".length), + pageFrontmatter, + }); }); // 7. Core content API (admin API handled by fsRoutes in plugin). @@ -546,6 +524,7 @@ export async function createDuneApp( siteName, adminPrefix: getAdminPrefix, routes, + resolveEditor, }); const createHandler = app.handler.bind(app); diff --git a/tests/auth/automatic_grants_test.ts b/tests/auth/automatic_grants_test.ts new file mode 100644 index 00000000..027221fb --- /dev/null +++ b/tests/auth/automatic_grants_test.ts @@ -0,0 +1,49 @@ +/** Where a config hands out admin-tier roles by itself: named, never refused. */ + +import { assertEquals, assertStringIncludes } from "@std/assert"; +import { automaticAdminTierGrants } from "../../src/auth/automatic-grants.ts"; +import { capabilityFindings } from "../../src/cli/doctor.ts"; +import type { DuneConfig } from "../../src/config/types.ts"; + +const cfg = (site: Record) => ({ site } as unknown as DuneConfig); + +Deno.test("a site with no payments and no external login names nothing", () => { + assertEquals(automaticAdminTierGrants(cfg({})), []); + assertEquals(automaticAdminTierGrants(cfg({ auth: { mode: "dune" } })), []); +}); + +Deno.test("a payment product that grants a content tier is not mentioned; one that grants an admin-tier role is", () => { + const payments = (role: string) => ({ secret_key: "sk", products: [{ id: "pro", name: "Pro", priceId: "p", role }] }); + assertEquals(automaticAdminTierGrants(cfg({ payments: payments("member") })), []); + const author = automaticAdminTierGrants(cfg({ payments: payments("author") })); + assertEquals(author.length, 1); + assertEquals(author[0].severity, "info"); + assertStringIncludes(author[0].message, 'product "pro"'); + assertStringIncludes(author[0].message, "pages they created"); + assertEquals(automaticAdminTierGrants(cfg({ payments: payments("editor") }))[0].severity, "info"); + assertEquals(automaticAdminTierGrants(cfg({ payments: payments("admin") }))[0].severity, "warning"); +}); + +Deno.test("products without a configured provider key grant nothing yet", () => { + assertEquals( + automaticAdminTierGrants(cfg({ payments: { products: [{ id: "p", name: "P", priceId: "x", role: "admin" }] } })), + [], + ); +}); + +Deno.test("an external-jwt login names its roles claim", () => { + const [g] = automaticAdminTierGrants(cfg({ auth: { mode: "external-jwt", jwt: { roles_claim: "groups" } } })); + assertEquals(g.severity, "info"); + assertStringIncludes(g.message, '"groups"'); +}); + +Deno.test("doctor lists them as access findings that never fail the run", () => { + const findings = capabilityFindings({ + providers: [], + scheduledActions: 0, + automaticGrants: [{ severity: "warning", message: "x" }, { severity: "info", message: "y" }], + }); + const access = findings.filter((f) => f.category === "access"); + assertEquals(access.map((f) => f.severity), ["warning", "info"]); + assertEquals(access.some((f) => f.severity === "error"), false); +}); diff --git a/tests/auth/editor_test.ts b/tests/auth/editor_test.ts new file mode 100644 index 00000000..2484ab81 --- /dev/null +++ b/tests/auth/editor_test.ts @@ -0,0 +1,125 @@ +/** + * The editor resolver: who is editing a request, and whether they may. + * + * One decision for the bar, the inline-edit WebSocket and /api/inline-edit/*: + * a site user or the `editor.session` provider's user, holding `pages.update` + * by `authz.check()`, which is the sole authority and fails closed. + */ + +import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { createEditorResolver } from "../../src/auth/editor.ts"; +import type { DuneAuthSystem } from "../../src/auth/authz.ts"; +import { USER_HEADER } from "../../src/auth/types.ts"; + +function makeAuthz(allowed: boolean) { + const calls: unknown[] = []; + return { + calls, + // deno-lint-ignore no-explicit-any + check(args: any): Promise { + calls.push(args); + return Promise.resolve(allowed); + }, + } as unknown as DuneAuthSystem & { calls: unknown[] }; +} + +function provider(user: Record | null, cookies?: string[]) { + return { + sessionCookies: cookies, + authenticate: () => Promise.resolve(user ? { authenticated: true, user } : { authenticated: false }), + }; +} + +function caps(p?: ReturnType) { + return { + get: (name: string) => (name === "editor.session" ? p : undefined), + list: () => [], + providers: () => [], + // deno-lint-ignore no-explicit-any + } as any; +} + +function siteRequest(user: Record): Request { + return new Request("https://site.test/", { headers: { [USER_HEADER]: JSON.stringify(user) } }); +} + +Deno.test("editor: a site user holding pages.update is an editor, named by username, then name, then email", async () => { + const authz = makeAuthz(true); + const resolve = createEditorResolver({ capabilities: caps(), siteAuthz: () => authz, sessionAuthz: () => undefined }); + const r = await resolve(siteRequest({ id: "u1", roles: ["editor"], email: "e@x.test", name: "Ed" })); + assertEquals(r, { ok: true, editor: { id: "u1", username: "Ed", roles: ["editor"], source: "site" } }); + assertEquals((authz.calls[0] as { onWhat: unknown }).onWhat, { type: "app", id: "site" }); +}); + +Deno.test("editor: a site user the authz system denies is 403, and so is one when there is no authz system", async () => { + const denied = createEditorResolver({ + capabilities: caps(), + siteAuthz: () => makeAuthz(false), + sessionAuthz: () => undefined, + }); + assertEquals(await denied(siteRequest({ id: "u1", roles: ["editor"] })), { ok: false, status: 403 }); + const none = createEditorResolver({ capabilities: caps(), siteAuthz: () => undefined, sessionAuthz: () => undefined }); + assertEquals(await none(siteRequest({ id: "u1", roles: ["admin"] })), { ok: false, status: 403 }); +}); + +Deno.test("editor: a session-provider user is an editor when authz allows; the site's authz system is not consulted for them", async () => { + const site = makeAuthz(false); + const session = makeAuthz(true); + const resolve = createEditorResolver({ + capabilities: caps(provider({ id: "a1", username: "ada", roles: ["admin"] }, ["sid"])), + siteAuthz: () => site, + sessionAuthz: () => session, + }); + const r = await resolve(new Request("https://site.test/", { headers: { cookie: "sid=abc" } })); + assertEquals(r, { ok: true, editor: { id: "a1", username: "ada", roles: ["admin"], source: "session" } }); + assertEquals(site.calls.length, 0); +}); + +Deno.test("editor: no site user and no session cookie is 401 without asking the provider", async () => { + let asked = 0; + const p = provider({ id: "a1", roles: ["admin"] }, ["sid"]); + const counting = { ...p, authenticate: () => (asked++, p.authenticate()) }; + const resolve = createEditorResolver({ + capabilities: caps(counting), + siteAuthz: () => makeAuthz(true), + sessionAuthz: () => makeAuthz(true), + }); + assertEquals(await resolve(new Request("https://site.test/", { headers: { cookie: "other=1" } })), { + ok: false, + status: 401, + }); + assertEquals(asked, 0); +}); + +Deno.test("editor: an invalid session, a failing provider and a missing provider are all 401", async () => { + const mk = (p?: ReturnType) => + createEditorResolver({ capabilities: caps(p), siteAuthz: () => undefined, sessionAuthz: () => makeAuthz(true) }); + const withCookie = new Request("https://site.test/", { headers: { cookie: "sid=x" } }); + assertEquals(await mk(provider(null, ["sid"]))(withCookie), { ok: false, status: 401 }); + const throwing = { sessionCookies: ["sid"], authenticate: () => Promise.reject(new Error("store down")) }; + // deno-lint-ignore no-explicit-any + assertEquals(await mk(throwing as any)(withCookie), { ok: false, status: 401 }); + assertEquals(await mk(undefined)(withCookie), { ok: false, status: 401 }); +}); + +Deno.test("editor: a session user without pages.update is 403", async () => { + const resolve = createEditorResolver({ + capabilities: caps(provider({ id: "a1", roles: [] }, ["sid"])), + siteAuthz: () => undefined, + sessionAuthz: () => makeAuthz(false), + }); + const r = await resolve(new Request("https://site.test/", { headers: { cookie: "sid=x" } })); + assertEquals(r, { ok: false, status: 403 }); +}); + +import { mayEditPage } from "../../src/auth/editor.ts"; + +Deno.test("mayEditPage: admin and editor edit any page; an author only the pages they created; no page, no edit", () => { + const page = { createdBy: "a1" }; + assertEquals(mayEditPage({ id: "x", roles: ["admin"] }, page), true); + assertEquals(mayEditPage({ id: "x", roles: ["member", "editor"] }, page), true); + assertEquals(mayEditPage({ id: "a1", roles: ["author"] }, page), true); + assertEquals(mayEditPage({ id: "a2", roles: ["author"] }, page), false); + assertEquals(mayEditPage({ id: "a1", roles: ["author"] }, {}), false); + assertEquals(mayEditPage({ id: "x", roles: ["admin"] }, null), false); +}); diff --git a/tests/cli/response_transforms_test.ts b/tests/cli/response_transforms_test.ts index 0cd0eab5..a1b5f1f4 100644 --- a/tests/cli/response_transforms_test.ts +++ b/tests/cli/response_transforms_test.ts @@ -11,7 +11,12 @@ import { assertEquals, assertStrictEquals, } from "https://deno.land/std@0.224.0/assert/mod.ts"; -import { runPluginResponseTransforms } from "../../src/cli/response-transforms.ts"; +import { + type RunResponseTransformsOptions, + runPluginResponseTransforms as run, +} from "../../src/cli/response-transforms.ts"; +import { createEditorResolver } from "../../src/auth/editor.ts"; +import type { DuneAuthSystem } from "../../src/auth/authz.ts"; import type { DunePlugin, ResponseTransformContext, @@ -28,6 +33,38 @@ interface AuthResult { const config = {} as DuneConfig; +/** + * Drive the pipeline the way core does: the options a test names (a session + * provider, its cookie names, an authz system) become the editor resolver the + * pipeline is given. Tests keep stating the scenario, not the wiring. + */ +type LegacyOptions = + & Omit + & { + auth?: { authenticate(req: Request): Promise } | null; + sessionCookies?: readonly string[]; + authz?: DuneAuthSystem; + }; +function runPluginResponseTransforms(opts: LegacyOptions): Promise { + const { auth, sessionCookies, authz, ...rest } = opts; + const provider = auth ? Object.assign(auth, { sessionCookies }) : undefined; + const capabilities = rest.capabilities ?? { + get: (name: string) => (name === "editor.session" ? provider : undefined), + list: () => [], + providers: () => [], + // deno-lint-ignore no-explicit-any + } as any; + const resolveEditor = createEditorResolver({ + capabilities: { + ...capabilities, + get: (name: string) => name === "editor.session" ? provider : capabilities.get(name), + }, + siteAuthz: () => authz, + sessionAuthz: () => authz, + }); + return run({ ...rest, capabilities, resolveEditor }); +} + function makeUser(role: string): User { return { id: "u1", diff --git a/tests/plugin-api/capabilities_test.ts b/tests/plugin-api/capabilities_test.ts index 1951188f..5b5e75dc 100644 --- a/tests/plugin-api/capabilities_test.ts +++ b/tests/plugin-api/capabilities_test.ts @@ -11,21 +11,22 @@ import { MemoryStorageAdapter } from "../../src/storage/memory.ts"; import { adminPrefixOf, type CapabilityContext, + type CapabilityKey, + capabilityKey, createCapabilityRegistry, describeConflict, invalidAdminPrefix, type RequestAuthenticator, } from "../../src/plugin-api/capabilities.ts"; -// The contract names no extension point of a plugin's own. A plugin declares -// its keys next to its types by declaration merging, prefixed with its name; -// these two stand in for `admin.pages` / `admin.routes` of the admin plugin. -declare module "../../src/plugin-api/capabilities.ts" { - interface Contributions { - "test.pages": { path: string; label: string; permission: string; handler: () => Response }; - "test.routes": { method: "GET" | "POST" | "DELETE"; path: string; permission: string; handler: () => Response }; - } -} +// The contract names no extension point of a plugin's own. A plugin exports +// typed keys next to its types, prefixed with its name; these two stand in for +// `admin.pages` / `admin.routes` of the admin plugin. +const TEST_PAGES: CapabilityKey<{ path: string; label: string; permission: string; handler: () => Response }> = + capabilityKey("test.pages"); +const TEST_ROUTES: CapabilityKey< + { method: "GET" | "POST" | "DELETE"; path: string; permission: string; handler: () => Response } +> = capabilityKey("test.routes"); const auth = (name: string): RequestAuthenticator => ({ authenticate: () => Promise.resolve({ authenticated: true, user: { id: name, username: name } }), @@ -47,7 +48,7 @@ Deno.test("capabilities: an unprovided slot is simply absent", async () => { const reg = createCapabilityRegistry(); await reg.resolve(ctx); assertEquals(reg.reader.get("editor.inline"), undefined); - assertEquals(reg.reader.list("test.pages"), []); + assertEquals(reg.reader.list(TEST_PAGES), []); assertEquals(reg.reader.providers(), []); }); @@ -126,9 +127,9 @@ Deno.test("capabilities: a factory returning undefined declines the slot", async Deno.test("capabilities: contributions append in registration order and name their provider", () => { const reg = createCapabilityRegistry(); const page = (path: string) => ({ path, label: path, permission: "pages.read", handler: () => new Response("") }); - reg.registrar("x").contribute("test.pages", page("/x")); - reg.registrar("y").contribute("test.pages", page("/y")); - assertEquals(reg.reader.list("test.pages").map((p) => p.path), ["/x", "/y"]); + reg.registrar("x").contribute(TEST_PAGES, page("/x")); + reg.registrar("y").contribute(TEST_PAGES, page("/y")); + assertEquals(reg.reader.list(TEST_PAGES).map((p) => p.path), ["/x", "/y"]); assertEquals(reg.reader.providers().map((p) => `${p.name}:${p.provider}`), ["test.pages:x", "test.pages:y"]); }); @@ -140,9 +141,9 @@ Deno.test("capabilities: test.routes contributions keep their method and order", permission: "config.update", handler: () => new Response(""), }); - reg.registrar("a").contribute("test.routes", route("POST", "/api/a/save")); - reg.registrar("b").contribute("test.routes", route("DELETE", "/api/b/item")); - assertEquals(reg.reader.list("test.routes").map((r) => `${r.method} ${r.path}`), [ + reg.registrar("a").contribute(TEST_ROUTES, route("POST", "/api/a/save")); + reg.registrar("b").contribute(TEST_ROUTES, route("DELETE", "/api/b/item")); + assertEquals(reg.reader.list(TEST_ROUTES).map((r) => `${r.method} ${r.path}`), [ "POST /api/a/save", "DELETE /api/b/item", ]); @@ -185,7 +186,7 @@ Deno.test( factoryRanBeforeMount = true; return auth("provided"); }); - api.capabilities.contribute("test.pages", { path: "/p", label: "P", permission: "pages.read", handler: () => new Response("") }); + api.capabilities.contribute(TEST_PAGES, { path: "/p", label: "P", permission: "pages.read", handler: () => new Response("") }); }, }; const consumer: DunePlugin = { @@ -195,7 +196,7 @@ Deno.test( async mount({ capabilities }) { const a = capabilities.get("editor.session"); seen = a ? (await a.authenticate(new Request("http://x/"))).user?.id : undefined; - if (capabilities.list("test.pages").length !== 1) seen = "contribution-missing"; + if (capabilities.list(TEST_PAGES).length !== 1) seen = "contribution-missing"; }, }; @@ -355,3 +356,15 @@ async function removeWithRetry(root: string): Promise { } throw lastErr; } + +Deno.test("capabilities: a plugin's own slot is provided and read through its key; the same name is the same capability", async () => { + const EDITOR: CapabilityKey<{ label: string }> = capabilityKey("test.editor"); + const reg = createCapabilityRegistry(); + reg.registrar("alt").provide(EDITOR, () => ({ label: "alt" })); + assertEquals(reg.reader.get(EDITOR), undefined, "not resolved yet"); + await reg.resolve(ctx); + assertEquals(reg.reader.get(EDITOR)?.label, "alt"); + // Another plugin recreates the key from its documented name instead of importing it. + assertEquals(reg.reader.get(capabilityKey<{ label: string }>("test.editor"))?.label, "alt"); + assertEquals(reg.reader.providers(), [{ name: "test.editor", kind: "slot", provider: "alt", status: "active" }]); +}); diff --git a/tests/runtime/server_test.ts b/tests/runtime/server_test.ts index 1827ed32..0ec31a15 100644 --- a/tests/runtime/server_test.ts +++ b/tests/runtime/server_test.ts @@ -1,11 +1,9 @@ /** * Tests for src/runtime/server.ts's standalone helper functions: * - * - checkInlineEditPermission() / checkInlineEditPermissionForSiteUser() — - * the pages.update decision behind /api/inline-edit/ws, for an admin - * session and a public-auth (src/auth/) session respectively — - * dec-identity-unification Phase 5c/7's authz-first cutover, extended to - * let the inline-edit WS route work without @dune/plugin-admin at all. + * - assertInlineEditSameOrigin() — the origin gate on /api/inline-edit/* + * requests that change something. (The pages.update decision itself lives + * in tests/auth/editor_test.ts.) * - stripUserHeader() — part of the internal x-dune-user header handling * (dec-identity-unification's User rename); ensures the header is only * ever set by Dune's own middleware, never accepted from an incoming @@ -22,98 +20,36 @@ import { } from "https://deno.land/std@0.224.0/assert/mod.ts"; import { assertInlineEditWsOrigin, - checkInlineEditPermission, - checkInlineEditPermissionForSiteUser, + assertInlineEditSameOrigin, isIndexedInlineEditPath, isSafeInlineEditPath, stripUserHeader, } from "../../src/runtime/server.ts"; import { clientIp, rememberSocketAddr } from "../../src/security/rate-limit.ts"; -import type { DuneAuthSystem } from "../../src/auth/authz.ts"; import { USER_HEADER } from "../../src/auth/types.ts"; -function makeAuthz(allowed: boolean) { - const calls = { check: 0 }; - return { - calls, - // deno-lint-ignore no-explicit-any - check(_args: any): Promise { - calls.check++; - return Promise.resolve(allowed); - }, - }; -} - -const authResult = { authenticated: true, user: { id: "u1" } }; +// ── assertInlineEditSameOrigin ────────────────────────────────────────── -Deno.test("checkInlineEditPermission: allows when authz.check() allows", async () => { - const authz = makeAuthz(true); - const result = await checkInlineEditPermission( - authz as unknown as DuneAuthSystem, - authResult, - ); - assertEquals(result, true); - assertEquals(authz.calls.check, 1); -}); +function req(method: string, headers: Record = {}): Request { + return new Request("https://site.test/api/inline-edit/content/x/commit", { method, headers }); +} -Deno.test("checkInlineEditPermission: denies when authz.check() denies", async () => { - const authz = makeAuthz(false); - const result = await checkInlineEditPermission( - authz as unknown as DuneAuthSystem, - authResult, - ); - assertEquals(result, false); - assertEquals(authz.calls.check, 1); +Deno.test("assertInlineEditSameOrigin: reads need no origin", () => { + assertEquals(assertInlineEditSameOrigin(req("GET")), null); }); -Deno.test("checkInlineEditPermission: fails closed (denies) when authz is undefined, no ROLE_PERMISSIONS fallback", async () => { - const result = await checkInlineEditPermission(undefined, authResult); - assertEquals(result, false); +Deno.test("assertInlineEditSameOrigin: a write from the same origin passes", () => { + assertEquals(assertInlineEditSameOrigin(req("POST", { origin: "https://site.test" })), null); }); -// ── checkInlineEditPermissionForSiteUser ──────────────────────────────── - -Deno.test("checkInlineEditPermissionForSiteUser: authz.check() decides when configured, regardless of roles", async () => { - const authz = makeAuthz(true); - const result = await checkInlineEditPermissionForSiteUser( - authz as unknown as DuneAuthSystem, - { id: "u1", roles: [] }, - ); - assertEquals(result, true); - assertEquals(authz.calls.check, 1); +Deno.test("assertInlineEditSameOrigin: a write from another origin is refused", () => { + assertEquals(assertInlineEditSameOrigin(req("PATCH", { origin: "https://evil.test" }))?.status, 403); }); -Deno.test("checkInlineEditPermissionForSiteUser: authz.check() denial wins even with an editor role", async () => { - const authz = makeAuthz(false); - const result = await checkInlineEditPermissionForSiteUser( - authz as unknown as DuneAuthSystem, - { id: "u1", roles: ["editor"] }, - ); - assertEquals(result, false); - assertEquals(authz.calls.check, 1); -}); - -Deno.test("checkInlineEditPermissionForSiteUser: fails closed (denies) when authz is undefined, no roles[] fallback", async () => { - // An editor role used to grant inline edit access here when authz was - // undefined — that fallback was removed: authz.check() is the sole - // authority, no exceptions. - const result = await checkInlineEditPermissionForSiteUser(undefined, { - id: "u1", - roles: ["editor"], - }); - assertEquals(result, false); - - const adminResult = await checkInlineEditPermissionForSiteUser(undefined, { - id: "u1", - roles: ["admin"], - }); - assertEquals(adminResult, false); - - const unknownRoles = await checkInlineEditPermissionForSiteUser(undefined, { - id: "u1", - roles: ["member", "subscriber"], - }); - assertEquals(unknownRoles, false); +Deno.test("assertInlineEditSameOrigin: a write without Origin passes only when the browser says same-origin", () => { + assertEquals(assertInlineEditSameOrigin(req("POST"))?.status, 403); + assertEquals(assertInlineEditSameOrigin(req("POST", { "sec-fetch-site": "cross-site" }))?.status, 403); + assertEquals(assertInlineEditSameOrigin(req("POST", { "sec-fetch-site": "same-origin" })), null); }); // ── stripUserHeader ──────────────────────────────────────────────────── diff --git a/tests/runtime/site_user_editing_test.ts b/tests/runtime/site_user_editing_test.ts index c3dbf4c9..e64cd4e6 100644 --- a/tests/runtime/site_user_editing_test.ts +++ b/tests/runtime/site_user_editing_test.ts @@ -25,6 +25,19 @@ const inlineEdit: DunePlugin = { setup(api) { api.capabilities.provide("editor.inline", () => ({ handleUpgrade: () => new Response(null, { status: 204 }), + // Echo what core handed over, so the tests can see the gate's output. + handleRequest: async (req: Request, ctx: { + editor: unknown; + path: string; + pageFrontmatter(p: string): Promise; + }) => + Response.json({ + method: req.method, + editor: ctx.editor, + path: ctx.path, + title: (await ctx.pageFrontmatter("01.home/default.md") as { title?: string } | null)?.title ?? null, + missing: await ctx.pageFrontmatter("nope/default.md"), + }), } as never)); }, }; @@ -50,7 +63,7 @@ const ROLES = ["author", "editor", "admin", "member", "subscriber"]; async function withSite( plugins: DunePlugin[], fn: ( - ws: (cookie?: string) => Promise, + ws: (cookie?: string, path?: string) => Promise, cookieFor: (role: string) => Promise, plugin: { services: Awaited>["pluginServices"]; ids: Map }, ) => Promise, @@ -70,6 +83,12 @@ async function withSite( const user = await userStore.create({ email: `${role}@example.com`, provider: "local", roles: [role] }); ids.set(role, user.id); } + // A page the author created: authors edit their own pages, not others'. + await Deno.mkdir(join(root, "content", "02.mine"), { recursive: true }); + await Deno.writeTextFile( + join(root, "content", "02.mine", "default.md"), + `---\ntitle: Mine\ncreatedBy: ${ids.get("author")}\n---\n\nMine\n`, + ); const ctx = await bootstrap(root, { plugins: [inlineEdit, ...plugins] }); const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); const handler = app.handler(); @@ -87,9 +106,9 @@ async function withSite( const session = await sessions.create(ids.get(role)!); return `${SITE_COOKIE_NAME}=${session.id}`; }; - const ws = (cookie?: string) => + const ws = (cookie?: string, path = "01.home/default.md") => handler( - new Request("http://localhost/api/inline-edit/ws?path=01.home/default.md", { + new Request(`http://localhost/api/inline-edit/ws?path=${path}`, { headers: { upgrade: "websocket", origin: "http://localhost", @@ -97,12 +116,21 @@ async function withSite( }, }), ); + http = (path, init, cookie) => + handler( + new Request(`http://localhost/api/inline-edit/${path}`, { + ...init, + headers: { ...(init.headers as Record | undefined), ...(cookie ? { cookie } : {}) }, + }), + ); await fn(ws, cookieFor, { services: ctx.pluginServices, ids }); } finally { await Deno.remove(root, { recursive: true }).catch(() => {}); } } +let http: (path: string, init: RequestInit, cookie?: string) => Promise; + const opts = { sanitizeOps: false, sanitizeResources: false }; Deno.test("login only, no admin: an editor may edit, a plain member may not", opts, async () => { @@ -122,13 +150,23 @@ Deno.test("login plus a plugin providing editor.session: same answers", opts, as Deno.test("every admin-tier role may edit pages, content tags may not", opts, async () => { await withSite([], async (ws, cookieFor) => { - for (const role of ["author", "editor", "admin"]) { + for (const role of ["editor", "admin"]) { assertEquals((await ws(await cookieFor(role))).status, 204, role); } assertEquals((await ws(await cookieFor("subscriber"))).status, 403); }); }); +Deno.test("an author joins a live session only on a page they created", opts, async () => { + await withSite([], async (ws, cookieFor) => { + const author = await cookieFor("author"); + assertEquals((await ws(author, "02.mine/default.md")).status, 204); + assertEquals((await ws(author, "01.home/default.md")).status, 403); + // An editor is not limited to their own pages. + assertEquals((await ws(await cookieFor("editor"), "02.mine/default.md")).status, 204); + }); +}); + Deno.test("a role granted through a plugin's user store while the app runs applies at once", opts, async () => { await withSite([], async (ws, cookieFor, { services, ids }) => { const cookie = await cookieFor("subscriber"); @@ -143,3 +181,36 @@ Deno.test("a role granted through a plugin's user store while the app runs appli assertEquals((await ws(cookie)).status, 403); }); }); + +Deno.test("/api/inline-edit/*: core resolves the editor, then hands the request to the provider", opts, async () => { + await withSite([], async (_ws, cookieFor, { ids }) => { + const cookie = await cookieFor("editor"); + const res = await http("content/01.home%2Fdefault.md/source", { method: "GET" }, cookie); + assertEquals(res.status, 200); + assertEquals(await res.json(), { + method: "GET", + editor: { id: ids.get("editor"), username: "editor@example.com", roles: ["editor"], source: "site" }, + path: "content/01.home%2Fdefault.md/source", + title: "Home", + missing: null, + }); + }); +}); + +Deno.test("/api/inline-edit/*: 401 without a login, 403 for a user who may not edit, nothing reaches the provider", opts, async () => { + await withSite([], async (_ws, cookieFor) => { + assertEquals((await http("content/x/source", { method: "GET" })).status, 401); + assertEquals((await http("content/x/source", { method: "GET" }, await cookieFor("member"))).status, 403); + }); +}); + +Deno.test("/api/inline-edit/*: a write needs a same-origin request", opts, async () => { + await withSite([], async (_ws, cookieFor) => { + const cookie = await cookieFor("editor"); + const write = (headers: Record) => + http("content/x/commit", { method: "POST", headers }, cookie); + assertEquals((await write({})).status, 403); + assertEquals((await write({ origin: "http://evil.test" })).status, 403); + assertEquals((await write({ origin: "http://localhost" })).status, 200); + }); +});