Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
5480f7e
feat(config)!: runtime_dir and max_revisions are core settings, not a…
zumbrunn Nov 6, 2026
0ea5229
feat(plugin-api)!: the admin declares where it is mounted (admin.pref…
zumbrunn Nov 6, 2026
439d7e4
chore: drop the deno.lock change that slipped into the step 2 commit
zumbrunn Nov 6, 2026
8ad89d5
feat(plugins)!: core no longer loads the admin, it is a plugin you li…
zumbrunn Nov 6, 2026
f72041b
feat(plugin-api)!: machine translation and the audit trail leave core…
zumbrunn Nov 6, 2026
3e609c4
feat(config)!: the admin's settings are the admin plugin's config, no…
zumbrunn Nov 7, 2026
145b7ab
feat(config)!: machine_translation moves into the admin plugin's conf…
zumbrunn Nov 7, 2026
c5de0fa
chore: drop the deno.lock change that slipped into the step 5 commits
zumbrunn Nov 7, 2026
b0d38c0
fix(authz)!: site users' roles grant editing, checked on app:site (#130)
zumbrunn Nov 7, 2026
2191190
fix(authz): a role granted or revoked in a running app takes effect a…
zumbrunn Nov 7, 2026
ca675bb
Merge origin/main into feat/explicit-admin-loading
zumbrunn Nov 7, 2026
bf41539
Merge feat/explicit-admin-loading into feat/mt-audit-to-admin
zumbrunn Nov 7, 2026
25067b4
Merge feat/mt-audit-to-admin into feat/admin-settings-in-plugin
zumbrunn Nov 7, 2026
87db9e4
Merge feat/admin-settings-in-plugin into fix/site-user-editing
zumbrunn Nov 7, 2026
d3e5c3a
feat(plugin-api)!: the contract keeps what core consumes; plugins dec…
zumbrunn Nov 7, 2026
292a3dd
feat(plugin-api)!: transforms read capabilities from ResponseTransfor…
zumbrunn Nov 7, 2026
3c1b2be
docs: the unprefixed capability namespaces are editor, scheduler and …
zumbrunn Nov 7, 2026
efdb350
feat(plugin-api)!: core gates /api/inline-edit/* and hands requests t…
zumbrunn Nov 7, 2026
929043b
docs: changelog for the inline-edit HTTP gate and page-event actor (#…
zumbrunn Nov 7, 2026
740da47
feat(plugin-api)!: typed capability keys instead of module augmentati…
zumbrunn Nov 8, 2026
e2a696e
docs: capability keys in the plugins module comment
zumbrunn Nov 8, 2026
979fc52
fix: headless smoke boots without the admin entry; headless scaffold …
zumbrunn Nov 8, 2026
c5ed518
fix(auth): log a failed role-grant bootstrap, own-property override l…
zumbrunn Nov 8, 2026
5f85916
Merge branch 'feat/explicit-admin-loading' into feat/mt-audit-to-admin
zumbrunn Nov 8, 2026
0588f2b
Merge branch 'feat/mt-audit-to-admin' into feat/admin-settings-in-plugin
zumbrunn Nov 8, 2026
42f5f23
test: the overlay admin.enabled warning, with #131's manual-move exit
zumbrunn Nov 8, 2026
e648e84
Merge branch 'feat/admin-settings-in-plugin' into fix/site-user-editing
zumbrunn Nov 8, 2026
b204168
chore: keep deno.lock as on the base branch
zumbrunn Nov 8, 2026
2d41672
Merge branch 'fix/site-user-editing' into refactor/contract-split
zumbrunn Nov 8, 2026
2d57358
Merge branch 'refactor/contract-split' into feat/inline-edit-routes
zumbrunn Nov 8, 2026
caf77e6
feat(auth): live editing sessions respect page ownership; name where …
zumbrunn Nov 8, 2026
84547bd
fix(cli): headless scaffold registers plugin islands with registerIsland
zumbrunn Nov 8, 2026
83b8207
Merge remote-tracking branch 'origin/feat/explicit-admin-loading' int…
zumbrunn Nov 10, 2026
db082f9
Merge origin/main into feat/mt-audit-to-admin
zumbrunn Nov 10, 2026
7445cfb
fix(migrate:config): keep core settings of a disabled admin block; ne…
zumbrunn Nov 10, 2026
89a25ec
Merge remote-tracking branch 'origin/feat/mt-audit-to-admin' into fea…
zumbrunn Nov 10, 2026
5672444
Merge remote-tracking branch 'origin/main' into feat/admin-settings-i…
zumbrunn Nov 10, 2026
2b50403
Merge remote-tracking branch 'origin/feat/admin-settings-in-plugin' i…
zumbrunn Nov 10, 2026
70f485b
Merge remote-tracking branch 'origin/main' into fix/site-user-editing
zumbrunn Nov 10, 2026
2cd6e29
Merge remote-tracking branch 'origin/fix/site-user-editing' into refa…
zumbrunn Nov 10, 2026
42a1cff
Merge origin/main into refactor/contract-split
zumbrunn Nov 10, 2026
53ef3a7
Merge remote-tracking branch 'origin/refactor/contract-split' into fe…
zumbrunn Nov 10, 2026
6cbd371
Merge origin/main into feat/inline-edit-routes
zumbrunn Nov 10, 2026
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
47 changes: 41 additions & 6 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<path>/...`). `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<T>("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
Expand Down Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions src/auth/automatic-grants.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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;
}
105 changes: 105 additions & 0 deletions src/auth/editor.ts
Original file line number Diff line number Diff line change
@@ -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<EditorResolution>;

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<boolean> {
// 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<EditorIdentity, "id" | "roles">,
frontmatter: Record<string, unknown> | null,
): boolean {
if (!frontmatter) return false;
if (editor.roles.includes("admin") || editor.roles.includes("editor")) return true;
return frontmatter.createdBy === editor.id;
}
7 changes: 6 additions & 1 deletion src/cli/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading