From 6446ba96d9f11652c3295033ef7077b8382d4d9c Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 11:55:24 +0100 Subject: [PATCH 01/21] feat(admin)!: provide auth.session and scheduler.executor, consume capabilities (#97, step 4) Admin no longer fills core's adminContext bag or implements mountEarly: it declares auth.session and scheduler.executor in setup(), reads inline-edit, editor.content and admin.pages through MountApi.capabilities, and reads the job scheduler from BootstrapResult.jobScheduler. mountDuneAdminEarly() is removed. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 17 +++++ mod.ts | 63 +++++++++---------- src/admin/block-editor-plugin.tsx | 2 +- src/admin/context.ts | 4 +- src/admin/guards.ts | 8 +-- src/admin/mount.ts | 80 +++++++----------------- tests/admin/capabilities_e2e_test.ts | 92 ++++++++++++++++++++++++++++ tests/admin/mount_early_test.ts | 82 ------------------------- 8 files changed, 169 insertions(+), 179 deletions(-) create mode 100644 tests/admin/capabilities_e2e_test.ts delete mode 100644 tests/admin/mount_early_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 32cf583..4f7332f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ follows [Semantic Versioning](https://semver.org). ### Changed — BREAKING +- **The admin provides and consumes core's capabilities instead of the untyped + `adminContext` bag (duneorg/dune#97, `@dune/core` 0.36).** It provides + `auth.session` (who is the admin on a request: the admin bar and the inline-edit + WebSocket's admin fallback) and `scheduler.executor` (runs due scheduled actions; + `dune serve` starts core's scheduler loop when it exists). It reads the + `inline-edit` and `editor.content` slots and the `admin.pages` contributions from + `MountApi.capabilities` instead of `adminServices` and each plugin's `adminPages`, + and no longer writes `bootstrap.adminContext`. The jobs view reads + `BootstrapResult.jobScheduler`, which `dune serve` sets after mount. +- **`mountDuneAdminEarly()` is gone** (core removed `DunePlugin.mountEarly`). + `mountDuneAdmin(app, ctx, adminContext?)` falls back to the single-site context + stored by the plugin's `mount()` instead of `ctx.adminContext`. A plugin route that + needs the admin identity calls `capabilities.get("auth.session")`; plugin routes + registered in a plugin's own `mount()` no longer sit behind the admin's + `ctx.state.adminContext` middleware, so `withGuards()` only works for pages the + admin mounts itself (`admin.pages`, GET). A contribution point for guarded + non-GET admin routes is a known gap. - **The `admin:` block reads snake_case keys** (duneorg/dune#96, `@dune/core` 0.36): `session_lifetime`, `data_dir`, `runtime_dir`, `max_revisions`, `max_upload_mb`, `authz_store`, `audit.log_file`, and the LDAP/SAML provider fields (`base_dn`, diff --git a/mod.ts b/mod.ts index 14a73db..0e92d67 100644 --- a/mod.ts +++ b/mod.ts @@ -36,7 +36,7 @@ import type { AuthzDbAdapter } from "@dune/core/auth/authz-adapter-db"; import { initAdminContext } from "./src/admin/context.ts"; import type { AdminContext } from "./src/admin/context.ts"; import { createBlockEditorPlugin } from "./src/admin/block-editor-plugin.tsx"; -import { getDuneAdminIslands, mountDuneAdmin, mountDuneAdminEarly } from "./src/admin/mount.ts"; +import { getDuneAdminIslands, mountDuneAdmin } from "./src/admin/mount.ts"; import { sectionRegistry } from "@dune/core/sections"; import type { SectionRegistry } from "@dune/core/sections"; import * as corePlugins from "@dune/core/plugins"; @@ -94,11 +94,9 @@ export function createAdminPlugin( opts: AdminPluginOptions, ): DunePlugin { let setupState: AdminSetupState | null = null; - // Set partway through mount(), once AdminContext is actually built. - // mountEarly() registers a middleware that reads this lazily (per - // request) rather than needing the built value up front — see - // mountDuneAdminEarly()'s doc comment for why that's necessary (it runs - // before mount() has built anything). + // Set at the end of mount(), once AdminContext is built. The capabilities + // declared in setup() delegate to it lazily (per request), because core + // resolves them before mount() has built the auth middleware and the rest. let liveAdminContext: AdminContext | null = null; return { @@ -108,12 +106,6 @@ export function createAdminPlugin( hooks: {}, islandSpecifiers: getDuneAdminIslands(), - mountEarly({ app, bootstrap }: MountApi) { - if (!setupState) return; // admin disabled - // deno-lint-ignore no-explicit-any - mountDuneAdminEarly(app as any, bootstrap, () => liveAdminContext); - }, - async setup(api) { const adminCfg = config.admin ?? { path: "/admin", @@ -130,9 +122,24 @@ export function createAdminPlugin( const { audit: auditLogger, mt } = api.services.engines; setupState = { config, storage, opts, auditLogger, mt }; + + // What this plugin provides to core. Core reads them at request time + // (the admin bar, the inline-edit WebSocket, `dune serve`'s scheduler + // loop); until mount() has built the context they report "nobody". + api.capabilities.provide("auth.session", () => ({ + authenticate: (req) => + liveAdminContext + ? liveAdminContext.auth.authenticate(req) + : Promise.resolve({ authenticated: false }), + })); + api.capabilities.provide("scheduler.executor", () => ({ + execute: async (action) => { + await liveAdminContext?.executeScheduledAction?.(action); + }, + })); }, - async mount({ app, bootstrap, adminServices, services }: MountApi) { + async mount({ app, bootstrap, services, capabilities }: MountApi) { const { logger } = services; if (!setupState) return; // admin disabled @@ -271,15 +278,13 @@ export function createAdminPlugin( }); // ── Default content editor ──────────────────────────────────────────────── - if (!adminServices.contentEditor) { - adminServices.contentEditor = createBlockEditorPlugin(); - } + const inlineEdit = capabilities.get("inline-edit"); + const contentEditor = capabilities.get("editor.content") ?? createBlockEditorPlugin(); // ── Build AdminContext ──────────────────────────────────────────────────── const metricsEnabled = config.system.metrics?.enabled !== false; - const pluginPages = bootstrap.hooks.plugins() - .flatMap((p) => p.adminPages ?? []); + const pluginPages = capabilities.list("admin.pages"); const adminContext: AdminContext = { services, @@ -302,14 +307,18 @@ export function createAdminPlugin( staging: stagingEngine, comments: commentManager, collab: collabManager, - inlineEdit: adminServices.inlineEdit, - contentEditor: adminServices.contentEditor, + inlineEdit, + contentEditor, imageCache: bootstrap.imageCache, auditLogger: setupState.auditLogger ?? undefined, metrics: metricsEnabled ? bootstrap.metrics : undefined, mt: setupState.mt, rateLimitStore, - pluginPages: pluginPages.length > 0 ? pluginPages : undefined, + pluginPages: pluginPages.length > 0 ? [...pluginPages] : undefined, + // Created per serving process by `dune serve`, after mount(): read lazily. + get jobScheduler() { + return bootstrap.jobScheduler; + }, authz: bootstrap.authz, // Cores older than 0.31 don't expose `sections` on BootstrapResult; // fall back to this package's own module singleton, which resolves to @@ -321,18 +330,10 @@ export function createAdminPlugin( // Keep the singleton for single-site serve paths (serve.ts, dev.ts). initAdminContext(adminContext); - // Unblocks mountEarly()'s ctx.state.adminContext middleware — see its - // doc comment. Must happen before any other plugin's route could - // conceivably run, which it does: mountPlugins() finishes every - // plugin's mount() before the app starts serving requests. + // Lets the capabilities declared in setup() answer. mountPlugins() + // finishes every plugin's mount() before the app serves requests. liveAdminContext = adminContext; - // Expose adminContext on the bootstrap result so serve.ts can wire the - // job scheduler into it after mount() returns. BootstrapResult types - // this loosely (Record) since core has no concrete - // AdminContext type — plugin-admin owns the actual shape. - bootstrap.adminContext = adminContext as unknown as Record; - // No default admin is ever created. The first admin is an explicit act: // `dune users:create --role admin --password-prompt`, or // DUNE_ADMIN_EMAIL / DUNE_ADMIN_PASSWORD at boot (handled by core before diff --git a/src/admin/block-editor-plugin.tsx b/src/admin/block-editor-plugin.tsx index fc5aa7e..66bbfb0 100644 --- a/src/admin/block-editor-plugin.tsx +++ b/src/admin/block-editor-plugin.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource preact */ /** * Built-in block editor — the default ContentEditorPlugin loaded by bootstrap - * when no plugin overrides adminServices.contentEditor. + * when no plugin provides the `editor.content` capability. * * Wraps the PageEditor island in the same way the old hardwired route did, but * now as a proper ContentEditorPlugin so it participates in the same slot that diff --git a/src/admin/context.ts b/src/admin/context.ts index 997ba3d..e3a142d 100644 --- a/src/admin/context.ts +++ b/src/admin/context.ts @@ -67,9 +67,9 @@ export interface AdminContext { staging?: StagingEngine; comments?: CommentManager; collab?: CollabManager; - /** Inline editing manager, provided by a plugin via adminServices (v0.16+). */ + /** Inline editing manager, provided by a plugin as the `inline-edit` capability. */ inlineEdit?: InlineEditManager; - /** Custom page editor, provided by a plugin via adminServices (v0.24+). Replaces the built-in block editor. */ + /** Custom page editor, provided by a plugin as the `editor.content` capability. Replaces the built-in block editor. */ contentEditor?: ContentEditorPlugin; imageCache?: ImageCache; auditLogger?: AuditLogger; diff --git a/src/admin/guards.ts b/src/admin/guards.ts index e604087..314d48c 100644 --- a/src/admin/guards.ts +++ b/src/admin/guards.ts @@ -2,10 +2,10 @@ * Public guard primitives for plugin-registered admin mutation routes. * * `DunePlugin.mount({ app })` hands a plugin author the raw Fresh `app` - * with no built-in enforcement — `adminPages` gets CSRF protection and - * permission checking for free, but `mount()`-registered routes (the only - * way to register a `POST`/`PUT`/`DELETE` admin route, or anything - * `adminPages` can't express) previously had no supported way to reuse + * with no built-in enforcement — `admin.pages` contributions get CSRF + * protection and permission checking for free, but `mount()`-registered routes + * (the only way to register a `POST`/`PUT`/`DELETE` admin route, or anything + * `admin.pages` can't express) previously had no supported way to reuse * Dune's own checks at all. * * Re-implementing these by hand is exactly what caused three real diff --git a/src/admin/mount.ts b/src/admin/mount.ts index f57603d..8c16a07 100644 --- a/src/admin/mount.ts +++ b/src/admin/mount.ts @@ -39,6 +39,7 @@ import type { BootstrapResult } from "@dune/core/bootstrap"; import { registerPluginPublicRoutes } from "@dune/core/fresh-app"; import { registerFormRoutes } from "@dune/core/forms"; import { checkPermission } from "./routes/api/_utils.ts"; +import { getAdminContext } from "./context.ts"; import type { AdminState } from "./types.ts"; import { adminIslands, @@ -51,11 +52,7 @@ import { * `ctx` objects (`BootstrapResult`) for which the `ctx.state.adminContext` * middleware has already been registered on `app` — makes a second * registration for the same bootstrap a no-op instead of stacking a - * duplicate middleware. Both `mountDuneAdminEarly()` (called from the admin - * plugin's `mountEarly()` in the normal `createDuneApp()` path) and - * `mountDuneAdmin()` itself (headless-mode direct callers, who never call - * `mountEarly()` at all) can reach this — whichever runs first wins, and the - * other silently skips. + * duplicate middleware. */ const contextMiddlewareRegisteredFor = new WeakSet(); @@ -63,13 +60,11 @@ const contextMiddlewareRegisteredFor = new WeakSet(); * Register the `ctx.state.adminContext = …` middleware every admin route * handler (and `@dune/plugin-admin`'s `withGuards()`, and any other * plugin's own guarded route) reads. Takes a getter rather than the context - * value itself so it can be registered before the `AdminContext` object - * exists yet — see `mountDuneAdminEarly()`. + * value itself. * * Exported so a headless-mode caller with an unusual setup can register this - * independently of `mountDuneAdmin()`'s other route registration, but the - * normal paths (`mountDuneAdmin()`, `mountDuneAdminEarly()`) already call - * this — most callers should use one of those instead. + * independently of `mountDuneAdmin()`'s other route registration, but + * `mountDuneAdmin()` already calls it — most callers should use that instead. */ export function registerAdminContextMiddleware( // deno-lint-ignore no-explicit-any @@ -91,11 +86,7 @@ const authMiddlewareRegisteredFor = new WeakSet(); /** * Register the admin auth middleware (`routes/_middleware.ts`'s `handler` — * authenticates the session cookie into `ctx.state.auth`, applies admin CSP - * headers). No-op if already registered on this `app` — see - * {@link mountDuneAdminEarly}, whose whole reason for existing is to - * register this (and {@link registerAdminContextMiddleware}) before other - * plugins' routes are compiled; `registerAdminRoutes()` calls this too, for - * headless-mode callers who never call `mountDuneAdminEarly()`. + * headers). No-op if already registered on this `app`. */ function registerAdminAuthMiddleware( // deno-lint-ignore no-explicit-any @@ -107,44 +98,13 @@ function registerAdminAuthMiddleware( app.use(adminMiddleware.handler as Middleware); } -/** - * Register the middleware every other plugin's own routes need to already - * be in place before it runs: - * - * - `ctx.state.adminContext` (see {@link registerAdminContextMiddleware}) - * - `ctx.state.auth` (the admin auth middleware — sets it for any request - * under the admin path prefix; a plugin's own guarded route must be - * registered under that prefix too, e.g. `/admin/my-plugin/…`, to be - * reached by it — see `@dune/plugin-admin/admin/guards`' own doc comment) - * - * This is the admin plugin's `DunePlugin.mountEarly()` implementation (see - * that hook's doc comment in `@dune/core`'s `hooks/types.ts` for why this - * needs to be a separate pass from `mountDuneAdmin()`/`mount()`). Called - * before the `AdminContext` itself has been built — `getAdminContext` is - * read lazily, per-request, so it naturally resolves to the real value once - * `mount()` (which runs after every plugin's `mountEarly()`) finishes - * building it, well before the app starts serving requests. The auth - * middleware itself needs no such laziness — it's a static generated route - * module, not something built from `AdminContext`. - * - * Headless-mode callers using `mountDuneAdmin()` directly (see this - * module's own docstring) don't need this — they already have a built - * `AdminContext` before they call anything, and their own routes are - * registered by them, after `mountDuneAdmin()`, per that docstring's - * example — so `mountDuneAdmin()`'s own (immediate, non-lazy) registration - * already covers them. - * - * @since 3.1.0 - */ -export function mountDuneAdminEarly( - // deno-lint-ignore no-explicit-any - app: App, - ctx: BootstrapResult, - getAdminContext: () => import("./context.ts").AdminContext | null, -): void { - if (ctx.config.admin?.enabled === false) return; - registerAdminContextMiddleware(app, ctx, getAdminContext); - registerAdminAuthMiddleware(app); +/** The single-site context `mount()` stored, or null before the plugin has mounted. */ +function currentAdminContextOrNull(): import("./context.ts").AdminContext | null { + try { + return getAdminContext(); + } catch { + return null; + } } /** @@ -166,20 +126,22 @@ export async function mountDuneAdmin( // deno-lint-ignore no-explicit-any app: App, ctx: BootstrapResult, - /** AdminContext built by @dune/plugin-admin — when omitted, falls back to ctx.adminContext. */ + /** + * The AdminContext built by @dune/plugin-admin's `mount()`. When omitted + * (headless-mode callers), falls back to the single-site context `mount()` + * stored via `initAdminContext()`, or none if the plugin has not mounted. + */ adminCtxOverride?: import("./context.ts").AdminContext, ): Promise { const { config } = ctx; - const adminContext = adminCtxOverride ?? - (ctx.adminContext as import("./context.ts").AdminContext | null); + const adminContext = adminCtxOverride ?? currentAdminContextOrNull(); const adminPrefix = config.admin?.path ?? "/admin"; // ── Admin panel ───────────────────────────────────────────────────────────── if (config.admin?.enabled !== false) { // Per-site admin context middleware — captures the correct context in // closure so multisite setups don't suffer from the module-level - // singleton. No-op if mountDuneAdminEarly() already registered it for - // this ctx (the normal createDuneApp() + mountPlugins() path). + // singleton. A second registration for the same ctx is a no-op. if (adminContext) { registerAdminContextMiddleware(app, ctx, () => adminContext); } @@ -284,7 +246,7 @@ export function getDuneAdminIslands(): string[] { * self-guard and pass through untouched on non-admin paths (they must, * because Fresh applies fs middleware/layouts globally too — this preserves * the exact fsRoutes() behavior). The auth middleware registration itself - * is a no-op here if `mountDuneAdminEarly()` already registered it (see + * is a no-op here if it was already registered (see * `registerAdminAuthMiddleware`) — the normal `createDuneApp()` + * `mountPlugins()` path. */ diff --git a/tests/admin/capabilities_e2e_test.ts b/tests/admin/capabilities_e2e_test.ts new file mode 100644 index 0000000..ecd14ee --- /dev/null +++ b/tests/admin/capabilities_e2e_test.ts @@ -0,0 +1,92 @@ +/** + * End to end against a real core: the admin plugin provides `auth.session` and + * `scheduler.executor`, consumes `admin.pages` contributions, and no longer + * writes core's removed `adminContext` bag. + */ + +import { assertEquals, assertExists } from "@std/assert"; +import { join } from "@std/path"; +import { bootstrap } from "@dune/core/bootstrap"; +import { createDuneApp } from "@dune/core/fresh-app"; +import type { DunePlugin } from "@dune/core/hooks"; + +async function withSite( + plugins: DunePlugin[], + fn: (ctx: Awaited>, handler: (req: Request) => Promise) => Promise, +): Promise { + const root = await Deno.makeTempDir({ prefix: "dune_admin_caps_" }); + try { + await Deno.mkdir(join(root, "content", "01.home"), { recursive: true }); + await Deno.writeTextFile(join(root, "content", "01.home", "default.md"), "---\ntitle: Home\n---\n\nHello\n"); + const ctx = await bootstrap(root, { plugins }); + const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); + await fn(ctx, app.handler()); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +} + +Deno.test( + "admin provides auth.session and scheduler.executor", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + await withSite([], async (ctx) => { + const auth = ctx.capabilities.get("auth.session"); + assertExists(auth); + assertExists(ctx.capabilities.get("scheduler.executor")); + const anonymous = await auth.authenticate(new Request("http://localhost/")); + assertEquals(anonymous.authenticated, false); + const providers = Object.fromEntries(ctx.capabilities.providers().map((p) => [p.name, p.provider])); + assertEquals(providers["auth.session"], "dune-admin"); + assertEquals(providers["scheduler.executor"], "dune-admin"); + }); + }, +); + +Deno.test( + "admin mounts admin.pages contributed by another plugin, behind its login", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + const contributor: DunePlugin = { + name: "test-page-contributor", + version: "1.0.0", + hooks: {}, + setup(api) { + api.capabilities.contribute("admin.pages", { + path: "/my-plugin", + label: "My plugin", + handler: () => new Response("secret page"), + }); + }, + }; + await withSite([contributor], async (_ctx, handler) => { + const res = await handler(new Request("http://localhost/admin/my-plugin")); + assertEquals(res.status, 302); + assertEquals(res.headers.get("location")?.startsWith("/admin/login"), true); + }); + }, +); + +Deno.test( + "admin uses the editor.content slot another plugin provides", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + const editor: DunePlugin = { + name: "test-editor", + version: "1.0.0", + hooks: {}, + setup(api) { + api.capabilities.provide("editor.content", () => ({ + pageEditorHandler: () => new Response("custom editor"), + })); + }, + }; + await withSite([editor], async (ctx) => { + assertExists(ctx.capabilities.get("editor.content")); + assertEquals( + ctx.capabilities.providers().find((p) => p.name === "editor.content")?.provider, + "test-editor", + ); + }); + }, +); diff --git a/tests/admin/mount_early_test.ts b/tests/admin/mount_early_test.ts deleted file mode 100644 index 4849d13..0000000 --- a/tests/admin/mount_early_test.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Regression test for the third-party-plugin-guarded-route bug: a plugin's - * own `mount()`-registered route (registered under the admin prefix, e.g. - * `/admin/my-plugin/…` — see `admin/guards.ts`'s own doc comment for why it - * has to be under the prefix) previously read both `ctx.state.adminContext` - * and `ctx.state.auth` as `undefined`, no matter how the request was - * authenticated, whenever that route was registered before `mountDuneAdmin()` - * ran — which is the normal case: `@dune/core`'s `mountPlugins()` calls - * `mount()` in plugin registration order, and the built-in admin plugin - * always registers last. - * - * `mountDuneAdminEarly()` (this plugin's `mountEarly()` implementation, see - * `@dune/core`'s `hooks/types.ts` for why that hook exists) registers both - * pieces of middleware — `ctx.state.adminContext` and the admin auth - * middleware that sets `ctx.state.auth` — before any plugin's `mount()` - * runs. This test registers a fake plugin's route first, then calls - * `mountDuneAdminEarly()` and `mountDuneAdmin()` in the same relative order - * `mountPlugins()` uses (early hooks before any mount(), admin's own - * mount()-equivalent last), and confirms both are visible. - */ - -import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; -import { App } from "fresh"; -import type { BootstrapResult } from "@dune/core/bootstrap"; -import { mountDuneAdmin, mountDuneAdminEarly } from "../../src/admin/mount.ts"; -import type { AdminContext } from "../../src/admin/context.ts"; - -Deno.test( - "mountDuneAdminEarly: a plugin's route registered before mountDuneAdmin() still sees adminContext and auth", - { sanitizeOps: false, sanitizeResources: false }, - async () => { - // deno-lint-ignore no-explicit-any - const app = new App(); - - // Minimal fake AdminContext — enough for routes/_middleware.ts's handler - // to authenticate the request and call ctx.next() rather than - // redirecting/403ing before it reaches our probe route. - const adminContext = { - prefix: "/admin", - auth: { - authenticate: () => - Promise.resolve({ - authenticated: true, - user: { id: "u1", roles: ["admin"] }, - }), - }, - authz: { - check: () => Promise.resolve(true), - }, - // deno-lint-ignore no-explicit-any - } as any as AdminContext; - - // deno-lint-ignore no-explicit-any - const ctx = { config: { admin: { enabled: true, path: "/admin" } } } as any as BootstrapResult; - - // Phase 1 (mountEarly, admin first) — the fix under test. - mountDuneAdminEarly(app, ctx, () => adminContext); - - // A fake plugin's own mount()-registered route, added here to mirror - // Phase 2 registration order (this plugin runs before admin's own - // mount()-equivalent below, exactly as it would in the real - // mountPlugins() loop). - app.get("/admin/my-plugin/probe", (fc) => { - return Response.json({ - // deno-lint-ignore no-explicit-any - sawAdminContext: (fc.state as any).adminContext != null, - // deno-lint-ignore no-explicit-any - sawAuth: (fc.state as any).auth != null, - }); - }); - - // Phase 2, admin last — mirrors mountPlugins()'s own ordering. - await mountDuneAdmin(app, ctx, adminContext); - - const handler = app.handler(); - const res = await handler( - new Request("http://localhost:8000/admin/my-plugin/probe"), - ); - assertEquals(res.status, 200); - assertEquals(await res.json(), { sawAdminContext: true, sawAuth: true }); - }, -); From 39f335d0969976a2b03d4f3c11f67ab83c2f9df8 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 12:56:27 +0100 Subject: [PATCH 02/21] feat(admin): mount admin.routes behind authentication, CSRF and permission checks (#97) A plugin contributes { method, path, permission?, handler } and the admin registers it under its prefix; the handler only runs after the admin login, the CSRF check on non-GET methods and the permission check. Replaces registering guarded mutation routes in mount() with withGuards(), which stops working now that the admin no longer mounts first. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 16 ++- mod.ts | 2 + src/admin/context.ts | 9 +- src/admin/mount.ts | 53 ++++++++- tests/admin/capabilities_e2e_test.ts | 37 +++++++ tests/admin/plugin_admin_routes_test.ts | 140 ++++++++++++++++++++++++ 6 files changed, 248 insertions(+), 9 deletions(-) create mode 100644 tests/admin/plugin_admin_routes_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f7332f..0205fda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,11 +19,17 @@ follows [Semantic Versioning](https://semver.org). - **`mountDuneAdminEarly()` is gone** (core removed `DunePlugin.mountEarly`). `mountDuneAdmin(app, ctx, adminContext?)` falls back to the single-site context stored by the plugin's `mount()` instead of `ctx.adminContext`. A plugin route that - needs the admin identity calls `capabilities.get("auth.session")`; plugin routes - registered in a plugin's own `mount()` no longer sit behind the admin's - `ctx.state.adminContext` middleware, so `withGuards()` only works for pages the - admin mounts itself (`admin.pages`, GET). A contribution point for guarded - non-GET admin routes is a known gap. + needs the admin identity calls `capabilities.get("auth.session")`. Routes a plugin + registers in its own `mount()` no longer sit behind the admin's + `ctx.state.adminContext` middleware, so `withGuards()` no longer works there (it + throws a `TypeError`); a guarded `POST`/`PUT`/`PATCH`/`DELETE` admin route is now a + contribution to the `admin.routes` point (below). +- **Mounts `admin.routes`**: a plugin contributes `{ method, path, permission?, handler }` + and the admin registers it under its prefix behind its own authentication (`401` + otherwise), CSRF check on every non-GET method (`403`) and the `permission` check + (`403`); the handler only runs when all three pass. `mountPluginAdminRoutes()` in + `src/admin/mount.ts` does it; a route with an unknown method or a path that does not + start with `/` is skipped and logged as `admin.routes.invalid`. - **The `admin:` block reads snake_case keys** (duneorg/dune#96, `@dune/core` 0.36): `session_lifetime`, `data_dir`, `runtime_dir`, `max_revisions`, `max_upload_mb`, `authz_store`, `audit.log_file`, and the LDAP/SAML provider fields (`base_dn`, diff --git a/mod.ts b/mod.ts index 0e92d67..4dfe59d 100644 --- a/mod.ts +++ b/mod.ts @@ -285,6 +285,7 @@ export function createAdminPlugin( const metricsEnabled = config.system.metrics?.enabled !== false; const pluginPages = capabilities.list("admin.pages"); + const pluginRoutes = capabilities.list("admin.routes"); const adminContext: AdminContext = { services, @@ -315,6 +316,7 @@ export function createAdminPlugin( mt: setupState.mt, rateLimitStore, pluginPages: pluginPages.length > 0 ? [...pluginPages] : undefined, + pluginRoutes: pluginRoutes.length > 0 ? [...pluginRoutes] : undefined, // Created per serving process by `dune serve`, after mount(): read lazily. get jobScheduler() { return bootstrap.jobScheduler; diff --git a/src/admin/context.ts b/src/admin/context.ts index e3a142d..80b47a7 100644 --- a/src/admin/context.ts +++ b/src/admin/context.ts @@ -17,7 +17,7 @@ import type { Scheduler, ScheduledAction } from "@dune/core/workflow"; import type { HistoryEngine } from "@dune/core/history"; import type { SubmissionManager } from "./submissions.ts"; import type { FlexEngine } from "@dune/core/flex"; -import type { HookRegistry, AdminPageRegistration } from "@dune/core/hooks"; +import type { HookRegistry, AdminPageRegistration, AdminRouteRegistration } from "@dune/core/hooks"; import type { ContentEditorPlugin } from "@dune/core/hooks"; import type { StagingEngine } from "@dune/core/staging"; import type { CommentManager } from "./comments.ts"; @@ -33,7 +33,7 @@ import type { DuneAuthSystem } from "@dune/core/auth/authz"; import type { SearchManager } from "@dune/core/search"; import type { SectionRegistry } from "@dune/core/sections"; -export type { AdminPageRegistration }; +export type { AdminPageRegistration, AdminRouteRegistration }; /** All services available to admin route handlers via `FreshContext.state.adminContext`. */ export interface AdminContext { @@ -108,6 +108,11 @@ export interface AdminContext { * The Fresh app registers these as programmatic routes after fsRoutes(). */ pluginPages?: AdminPageRegistration[]; + /** + * Plugin-contributed guarded routes (`admin.routes`), collected at mount. + * Mounted behind the admin's authentication, CSRF and permission checks. + */ + pluginRoutes?: AdminRouteRegistration[]; /** * HMAC key for session-bound CSRF tokens. Tests inject this; production * loads or creates `runtimeDir/csrf-secret` on first use. diff --git a/src/admin/mount.ts b/src/admin/mount.ts index 8c16a07..3714b61 100644 --- a/src/admin/mount.ts +++ b/src/admin/mount.ts @@ -38,9 +38,9 @@ import type { App, FreshContext, Middleware } from "fresh"; import type { BootstrapResult } from "@dune/core/bootstrap"; import { registerPluginPublicRoutes } from "@dune/core/fresh-app"; import { registerFormRoutes } from "@dune/core/forms"; -import { checkPermission } from "./routes/api/_utils.ts"; +import { checkPermission, type GuardedHandler, withGuards } from "./routes/api/_utils.ts"; import { getAdminContext } from "./context.ts"; -import type { AdminState } from "./types.ts"; +import type { AdminPermission, AdminState } from "./types.ts"; import { adminIslands, adminLayout, @@ -98,6 +98,51 @@ function registerAdminAuthMiddleware( app.use(adminMiddleware.handler as Middleware); } +const ROUTE_METHODS = { + GET: "get", + POST: "post", + PUT: "put", + PATCH: "patch", + DELETE: "delete", +} as const; + +/** + * Mount the plugin-contributed `admin.routes` under the admin prefix. Each + * handler runs only for an authenticated admin (`401` otherwise), after the + * CSRF check (every non-GET method) and the route's `permission` check + * (`403`), exactly as the admin's own API routes. Must run after the admin + * middleware is registered, which is what puts `ctx.state.auth` in place. + * A route with an unknown method or a path that does not start with `/` is + * skipped and logged, not mounted. + */ +export function mountPluginAdminRoutes( + // deno-lint-ignore no-explicit-any + app: App, + adminPrefix: string, + routes: readonly import("./context.ts").AdminRouteRegistration[], + logger: { warn(event: string, fields?: Record): void }, +): void { + for (const route of routes) { + const register = ROUTE_METHODS[route.method as keyof typeof ROUTE_METHODS]; + if (!register || typeof route.path !== "string" || !route.path.startsWith("/")) { + logger.warn("admin.routes.invalid", { method: route.method, path: route.path }); + continue; + } + const guarded = withGuards( + { permission: route.permission as AdminPermission | undefined }, + route.handler as unknown as GuardedHandler, + ); + app[register](`${adminPrefix}${route.path}`, (fc) => { + // deno-lint-ignore no-explicit-any + if (!(fc.state as any).auth?.authenticated) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + // deno-lint-ignore no-explicit-any + return guarded(fc as any); + }); + } +} + /** The single-site context `mount()` stored, or null before the plugin has mounted. */ function currentAdminContextOrNull(): import("./context.ts").AdminContext | null { try { @@ -158,6 +203,10 @@ export async function mountDuneAdmin( // The admin _middleware enforces authentication; here we additionally // honour the plugin-declared permission (if any) so plugin authors can // restrict access to a subset of admin roles. + if (adminContext?.pluginRoutes?.length) { + mountPluginAdminRoutes(app, adminPrefix, adminContext.pluginRoutes, adminContext.services.logger); + } + const pluginAdminPages = adminContext?.pluginPages; if (pluginAdminPages && pluginAdminPages.length > 0 && adminContext) { for (const page of pluginAdminPages) { diff --git a/tests/admin/capabilities_e2e_test.ts b/tests/admin/capabilities_e2e_test.ts index ecd14ee..38d1056 100644 --- a/tests/admin/capabilities_e2e_test.ts +++ b/tests/admin/capabilities_e2e_test.ts @@ -90,3 +90,40 @@ Deno.test( }); }, ); + +Deno.test( + "admin mounts admin.routes behind its login: an anonymous POST is sent to the login page and the handler never runs", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + let ran = 0; + const contributor: DunePlugin = { + name: "test-route-contributor", + version: "1.0.0", + hooks: {}, + setup(api) { + api.capabilities.contribute("admin.routes", { + method: "POST", + path: "/api/my-plugin/save", + permission: "config.update", + handler: () => { + ran++; + return new Response("saved"); + }, + }); + }, + }; + await withSite([contributor], async (_ctx, handler) => { + const res = await handler( + new Request("http://localhost/admin/api/my-plugin/save", { + method: "POST", + headers: { origin: "http://localhost" }, + }), + ); + // Like every admin route, the admin middleware sends an anonymous request to + // the login page before the contributed handler (or its own 401) is reached. + assertEquals(res.status, 302); + assertEquals(res.headers.get("location")?.startsWith("/admin/login"), true); + assertEquals(ran, 0); + }); + }, +); diff --git a/tests/admin/plugin_admin_routes_test.ts b/tests/admin/plugin_admin_routes_test.ts new file mode 100644 index 0000000..1152a81 --- /dev/null +++ b/tests/admin/plugin_admin_routes_test.ts @@ -0,0 +1,140 @@ +/** + * `admin.routes` contributions are mounted behind the admin's authentication, + * CSRF check and permission check; the contributed handler only runs for a + * request that passed all three. + */ + +import { assertEquals } from "@std/assert"; +import { mountPluginAdminRoutes } from "../../src/admin/mount.ts"; + +type Handler = (fc: unknown) => Response | Promise; + +function fakeApp() { + const routes = new Map(); + const app: Record = {}; + for (const m of ["get", "post", "put", "patch", "delete"]) { + app[m] = (path: string, h: Handler) => routes.set(`${m.toUpperCase()} ${path}`, h); + } + return { app: app as never, routes }; +} + +const logs: { event: string; fields?: Record }[] = []; +const logger = { warn: (event: string, fields?: Record) => logs.push({ event, fields }) }; + +function fc(opts: { + method: string; + path: string; + headers?: Record; + authenticated?: boolean; + allow?: boolean; +}) { + const url = new URL(`https://cms.example.com${opts.path}`); + return { + req: new Request(url, { method: opts.method, headers: opts.headers }), + url, + params: {}, + state: { + auth: opts.authenticated === false + ? { authenticated: false } + : { authenticated: true, user: { id: "u1", username: "alice" }, session: { id: "s1" } }, + adminContext: { + authz: { check: () => Promise.resolve(opts.allow !== false) }, + csrfSecret: "test-secret", + config: { admin: {} }, + }, + }, + }; +} + +function setup(route: Partial[2][number]> = {}) { + const { app, routes } = fakeApp(); + let ran = 0; + mountPluginAdminRoutes( + app, + "/admin", + [{ + method: "POST", + path: "/api/my-plugin/save", + permission: "config.update", + handler: () => { + ran++; + return new Response("done"); + }, + ...route, + }], + logger, + ); + return { routes, ran: () => ran }; +} + +const SAME_ORIGIN = { origin: "https://cms.example.com" }; + +Deno.test("admin.routes: mounted under the admin prefix with the contributed method", () => { + const { routes } = setup(); + assertEquals([...routes.keys()], ["POST /admin/api/my-plugin/save"]); +}); + +Deno.test("admin.routes: an unauthenticated request gets 401 and the handler does not run", async () => { + const { routes, ran } = setup(); + const res = await routes.get("POST /admin/api/my-plugin/save")!( + fc({ method: "POST", path: "/admin/api/my-plugin/save", headers: SAME_ORIGIN, authenticated: false }), + ); + assertEquals(res.status, 401); + assertEquals(ran(), 0); +}); + +Deno.test("admin.routes: a cross-origin POST is refused by the CSRF check", async () => { + const { routes, ran } = setup(); + const res = await routes.get("POST /admin/api/my-plugin/save")!( + fc({ method: "POST", path: "/admin/api/my-plugin/save", headers: { origin: "https://evil.example" } }), + ); + assertEquals(res.status, 403); + assertEquals(ran(), 0); +}); + +Deno.test("admin.routes: a request without the permission gets 403", async () => { + const { routes, ran } = setup(); + const res = await routes.get("POST /admin/api/my-plugin/save")!( + fc({ method: "POST", path: "/admin/api/my-plugin/save", headers: SAME_ORIGIN, allow: false }), + ); + assertEquals(res.status, 403); + assertEquals(ran(), 0); +}); + +Deno.test("admin.routes: an authenticated same-origin request with the permission reaches the handler", async () => { + const { routes, ran } = setup(); + const res = await routes.get("POST /admin/api/my-plugin/save")!( + fc({ method: "POST", path: "/admin/api/my-plugin/save", headers: SAME_ORIGIN }), + ); + assertEquals(res.status, 200); + assertEquals(await res.text(), "done"); + assertEquals(ran(), 1); +}); + +Deno.test("admin.routes: without a permission any authenticated admin passes, an anonymous one still does not", async () => { + const { routes, ran } = setup({ permission: undefined }); + const handler = routes.get("POST /admin/api/my-plugin/save")!; + assertEquals((await handler(fc({ method: "POST", path: "/admin/api/my-plugin/save", headers: SAME_ORIGIN }))).status, 200); + assertEquals( + (await handler(fc({ method: "POST", path: "/admin/api/my-plugin/save", headers: SAME_ORIGIN, authenticated: false }))).status, + 401, + ); + assertEquals(ran(), 1); +}); + +Deno.test("admin.routes: a GET route needs no CSRF token", async () => { + const { routes } = setup({ method: "GET", path: "/api/my-plugin/list" }); + const res = await routes.get("GET /admin/api/my-plugin/list")!( + fc({ method: "GET", path: "/admin/api/my-plugin/list" }), + ); + assertEquals(res.status, 200); +}); + +Deno.test("admin.routes: an invalid method or a path without a leading slash is skipped and logged", () => { + logs.length = 0; + const { routes } = setup({ path: "api/no-slash" }); + assertEquals(routes.size, 0); + const bad = setup({ method: "TRACE" as never }); + assertEquals(bad.routes.size, 0); + assertEquals(logs.map((l) => l.event), ["admin.routes.invalid", "admin.routes.invalid"]); +}); From 7e082b1e3f6d6c18ad83393a1752b5d5c9b111ba Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 13:24:55 +0100 Subject: [PATCH 03/21] feat(admin): auth.session declares its session cookie (#97) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 3 +++ mod.ts | 4 ++++ tests/admin/capabilities_e2e_test.ts | 1 + 3 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0205fda..4e36898 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ follows [Semantic Versioning](https://semver.org). `MountApi.capabilities` instead of `adminServices` and each plugin's `adminPages`, and no longer writes `bootstrap.adminContext`. The jobs view reads `BootstrapResult.jobScheduler`, which `dune serve` sets after mount. + `auth.session` declares `sessionCookies: ["dune_session"]`, which core uses to decide + whether to authenticate a public page and whether to keep the response out of the shared + page cache. - **`mountDuneAdminEarly()` is gone** (core removed `DunePlugin.mountEarly`). `mountDuneAdmin(app, ctx, adminContext?)` falls back to the single-site context stored by the plugin's `mount()` instead of `ctx.adminContext`. A plugin route that diff --git a/mod.ts b/mod.ts index 4dfe59d..c51b1f6 100644 --- a/mod.ts +++ b/mod.ts @@ -127,6 +127,10 @@ export function createAdminPlugin( // (the admin bar, the inline-edit WebSocket, `dune serve`'s scheduler // loop); until mount() has built the context they report "nobody". api.capabilities.provide("auth.session", () => ({ + // The cookie the admin's auth middleware sets (see createAuthMiddleware). + // Core uses it to decide whether to authenticate a public page and whether + // to keep the response out of the shared page cache. + sessionCookies: ["dune_session"], authenticate: (req) => liveAdminContext ? liveAdminContext.auth.authenticate(req) diff --git a/tests/admin/capabilities_e2e_test.ts b/tests/admin/capabilities_e2e_test.ts index 38d1056..c1893aa 100644 --- a/tests/admin/capabilities_e2e_test.ts +++ b/tests/admin/capabilities_e2e_test.ts @@ -36,6 +36,7 @@ Deno.test( assertExists(ctx.capabilities.get("scheduler.executor")); const anonymous = await auth.authenticate(new Request("http://localhost/")); assertEquals(anonymous.authenticated, false); + assertEquals(auth.sessionCookies, ["dune_session"]); const providers = Object.fromEntries(ctx.capabilities.providers().map((p) => [p.name, p.provider])); assertEquals(providers["auth.session"], "dune-admin"); assertEquals(providers["scheduler.executor"], "dune-admin"); From 83ad83476d4e7c1487b77463e39d17f673441a9d Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 14:33:59 +0100 Subject: [PATCH 04/21] feat(admin)!: admin.pages and admin.routes require a permission (#97) An entry without a permission is skipped and logged instead of being exposed to every authenticated admin. The pages mounting moves into mountPluginAdminPages() so it can be tested. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 15 +++-- src/admin/mount.ts | 82 ++++++++++++++--------- tests/admin/capabilities_e2e_test.ts | 1 + tests/admin/plugin_admin_routes_test.ts | 86 ++++++++++++++++++++++--- 4 files changed, 137 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e36898..b1dede8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,12 +27,15 @@ follows [Semantic Versioning](https://semver.org). `ctx.state.adminContext` middleware, so `withGuards()` no longer works there (it throws a `TypeError`); a guarded `POST`/`PUT`/`PATCH`/`DELETE` admin route is now a contribution to the `admin.routes` point (below). -- **Mounts `admin.routes`**: a plugin contributes `{ method, path, permission?, handler }` - and the admin registers it under its prefix behind its own authentication (`401` - otherwise), CSRF check on every non-GET method (`403`) and the `permission` check - (`403`); the handler only runs when all three pass. `mountPluginAdminRoutes()` in - `src/admin/mount.ts` does it; a route with an unknown method or a path that does not - start with `/` is skipped and logged as `admin.routes.invalid`. +- **Mounts `admin.routes`** and the `admin.pages` contributions through `mountPluginAdminRoutes()` + / `mountPluginAdminPages()` (`src/admin/mount.ts`). A route is `{ method, path, permission, handler }` + and is registered under the admin prefix behind the admin's own authentication (`401` + otherwise), the CSRF check on every non-GET method (`403`) and the `permission` check (`403`); the + handler only runs when all three pass. A page is the same for GET with a login redirect. **`permission` + is required on both** (core's `AdminPageRegistration` / `AdminRouteRegistration` type it that way): + an entry without one, or with an unknown method or a path that does not start with `/`, is skipped + and logged (`admin.routes.invalid` / `admin.pages.invalid`) instead of being exposed to every + authenticated admin, which also covers plugins written without type-checking. - **The `admin:` block reads snake_case keys** (duneorg/dune#96, `@dune/core` 0.36): `session_lifetime`, `data_dir`, `runtime_dir`, `max_revisions`, `max_upload_mb`, `authz_store`, `audit.log_file`, and the LDAP/SAML provider fields (`base_dn`, diff --git a/src/admin/mount.ts b/src/admin/mount.ts index 3714b61..0f9d20e 100644 --- a/src/admin/mount.ts +++ b/src/admin/mount.ts @@ -98,6 +98,47 @@ function registerAdminAuthMiddleware( app.use(adminMiddleware.handler as Middleware); } +/** + * Mount the plugin-contributed `admin.pages` under the admin prefix: GET pages + * rendered in the admin shell. An unauthenticated request is sent to the login + * page; an authenticated one needs the page's `permission` (403 otherwise). + * `permission` is required: an entry without one is skipped and logged as + * `admin.pages.invalid` rather than exposed to every authenticated admin, which + * also covers plugins written without type-checking. + */ +export function mountPluginAdminPages( + // deno-lint-ignore no-explicit-any + app: App, + adminPrefix: string, + pages: readonly import("./context.ts").AdminPageRegistration[], + logger: { warn(event: string, fields?: Record): void }, +): void { + for (const page of pages) { + if (typeof page.path !== "string" || !page.path.startsWith("/")) { + logger.warn("admin.pages.invalid", { path: page.path, reason: "path must start with /" }); + continue; + } + if (typeof page.permission !== "string" || page.permission === "") { + logger.warn("admin.pages.invalid", { path: page.path, reason: "permission is required" }); + continue; + } + app.get(`${adminPrefix}${page.path}`, async (fc) => { + // deno-lint-ignore no-explicit-any + const authResult = (fc.state as any).auth; + if (!authResult?.authenticated) { + return new Response(null, { status: 302, headers: { Location: `${adminPrefix}/login` } }); + } + const ok = await checkPermission( + fc as unknown as FreshContext, + page.permission as AdminPermission, + ); + if (!ok) return new Response("Forbidden", { status: 403 }); + // deno-lint-ignore no-explicit-any + return page.handler(fc as any); + }); + } +} + const ROUTE_METHODS = { GET: "get", POST: "post", @@ -112,8 +153,8 @@ const ROUTE_METHODS = { * CSRF check (every non-GET method) and the route's `permission` check * (`403`), exactly as the admin's own API routes. Must run after the admin * middleware is registered, which is what puts `ctx.state.auth` in place. - * A route with an unknown method or a path that does not start with `/` is - * skipped and logged, not mounted. + * `permission` is required. A route with an unknown method, a path that does not + * start with `/`, or no permission is skipped and logged, not mounted. */ export function mountPluginAdminRoutes( // deno-lint-ignore no-explicit-any @@ -125,11 +166,15 @@ export function mountPluginAdminRoutes( for (const route of routes) { const register = ROUTE_METHODS[route.method as keyof typeof ROUTE_METHODS]; if (!register || typeof route.path !== "string" || !route.path.startsWith("/")) { - logger.warn("admin.routes.invalid", { method: route.method, path: route.path }); + logger.warn("admin.routes.invalid", { method: route.method, path: route.path, reason: "unknown method or path without leading /" }); + continue; + } + if (typeof route.permission !== "string" || route.permission === "") { + logger.warn("admin.routes.invalid", { method: route.method, path: route.path, reason: "permission is required" }); continue; } const guarded = withGuards( - { permission: route.permission as AdminPermission | undefined }, + { permission: route.permission as AdminPermission }, route.handler as unknown as GuardedHandler, ); app[register](`${adminPrefix}${route.path}`, (fc) => { @@ -207,33 +252,8 @@ export async function mountDuneAdmin( mountPluginAdminRoutes(app, adminPrefix, adminContext.pluginRoutes, adminContext.services.logger); } - const pluginAdminPages = adminContext?.pluginPages; - if (pluginAdminPages && pluginAdminPages.length > 0 && adminContext) { - for (const page of pluginAdminPages) { - const fullPath = `${adminPrefix}${page.path}`; - app.get(fullPath, async (fc) => { - // deno-lint-ignore no-explicit-any - const authResult = (fc.state as any).auth; - if (!authResult?.authenticated) { - return new Response(null, { - status: 302, - headers: { Location: `${adminPrefix}/login` }, - }); - } - if (page.permission) { - // deno-lint-ignore no-explicit-any - const ok = await checkPermission( - fc as unknown as FreshContext, - page.permission as any, - ); - if (!ok) { - return new Response("Forbidden", { status: 403 }); - } - } - // deno-lint-ignore no-explicit-any - return page.handler(fc as any); - }); - } + if (adminContext?.pluginPages?.length) { + mountPluginAdminPages(app, adminPrefix, adminContext.pluginPages, adminContext.services.logger); } } diff --git a/tests/admin/capabilities_e2e_test.ts b/tests/admin/capabilities_e2e_test.ts index c1893aa..16085e0 100644 --- a/tests/admin/capabilities_e2e_test.ts +++ b/tests/admin/capabilities_e2e_test.ts @@ -56,6 +56,7 @@ Deno.test( api.capabilities.contribute("admin.pages", { path: "/my-plugin", label: "My plugin", + permission: "pages.read", handler: () => new Response("secret page"), }); }, diff --git a/tests/admin/plugin_admin_routes_test.ts b/tests/admin/plugin_admin_routes_test.ts index 1152a81..2085379 100644 --- a/tests/admin/plugin_admin_routes_test.ts +++ b/tests/admin/plugin_admin_routes_test.ts @@ -5,7 +5,7 @@ */ import { assertEquals } from "@std/assert"; -import { mountPluginAdminRoutes } from "../../src/admin/mount.ts"; +import { mountPluginAdminPages, mountPluginAdminRoutes } from "../../src/admin/mount.ts"; type Handler = (fc: unknown) => Response | Promise; @@ -111,15 +111,16 @@ Deno.test("admin.routes: an authenticated same-origin request with the permissio assertEquals(ran(), 1); }); -Deno.test("admin.routes: without a permission any authenticated admin passes, an anonymous one still does not", async () => { - const { routes, ran } = setup({ permission: undefined }); - const handler = routes.get("POST /admin/api/my-plugin/save")!; - assertEquals((await handler(fc({ method: "POST", path: "/admin/api/my-plugin/save", headers: SAME_ORIGIN }))).status, 200); - assertEquals( - (await handler(fc({ method: "POST", path: "/admin/api/my-plugin/save", headers: SAME_ORIGIN, authenticated: false }))).status, - 401, - ); - assertEquals(ran(), 1); +Deno.test("admin.routes: a route without a permission is refused at mount and logged, not exposed to every admin", () => { + logs.length = 0; + for (const permission of [undefined, ""]) { + const { routes } = setup({ permission: permission as never }); + assertEquals(routes.size, 0); + } + assertEquals(logs.map((l) => [l.event, l.fields?.reason]), [ + ["admin.routes.invalid", "permission is required"], + ["admin.routes.invalid", "permission is required"], + ]); }); Deno.test("admin.routes: a GET route needs no CSRF token", async () => { @@ -137,4 +138,69 @@ Deno.test("admin.routes: an invalid method or a path without a leading slash is const bad = setup({ method: "TRACE" as never }); assertEquals(bad.routes.size, 0); assertEquals(logs.map((l) => l.event), ["admin.routes.invalid", "admin.routes.invalid"]); + assertEquals(logs.map((l) => l.fields?.reason), [ + "unknown method or path without leading /", + "unknown method or path without leading /", + ]); +}); + +// ── admin.pages ────────────────────────────────────────────────────────────── + +function setupPages(page: Record = {}) { + const { app, routes } = fakeApp(); + let ran = 0; + mountPluginAdminPages( + app, + "/admin", + [{ + path: "/my-plugin", + label: "My plugin", + permission: "pages.read", + handler: () => { + ran++; + return new Response("page"); + }, + ...page, + } as never], + logger, + ); + return { routes, ran: () => ran }; +} + +Deno.test("admin.pages: an anonymous request is sent to the login page and the handler does not run", async () => { + const { routes, ran } = setupPages(); + const res = await routes.get("GET /admin/my-plugin")!( + fc({ method: "GET", path: "/admin/my-plugin", authenticated: false }), + ) as Response; + assertEquals(res.status, 302); + assertEquals(res.headers.get("location"), "/admin/login"); + assertEquals(ran(), 0); +}); + +Deno.test("admin.pages: the permission is enforced, and the handler runs when it is held", async () => { + const { routes, ran } = setupPages(); + const handler = routes.get("GET /admin/my-plugin")!; + assertEquals((await handler(fc({ method: "GET", path: "/admin/my-plugin", allow: false }))).status, 403); + assertEquals(ran(), 0); + assertEquals((await handler(fc({ method: "GET", path: "/admin/my-plugin" }))).status, 200); + assertEquals(ran(), 1); +}); + +Deno.test("admin.pages: a page without a permission is refused at mount and logged", () => { + logs.length = 0; + for (const permission of [undefined, ""]) { + const { routes } = setupPages({ permission }); + assertEquals(routes.size, 0); + } + assertEquals(logs.map((l) => [l.event, l.fields?.reason]), [ + ["admin.pages.invalid", "permission is required"], + ["admin.pages.invalid", "permission is required"], + ]); +}); + +Deno.test("admin.pages: a path without a leading slash is skipped and logged", () => { + logs.length = 0; + const { routes } = setupPages({ path: "no-slash" }); + assertEquals(routes.size, 0); + assertEquals(logs.map((l) => l.event), ["admin.pages.invalid"]); }); From 64b0a27d8d3f73aa11c12d4f81f559a3dea4500f Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 14:45:57 +0100 Subject: [PATCH 05/21] test(admin): run the admin conformance suite against plugin-admin (#97) Co-Authored-By: Claude Sonnet 5.5 --- .dune/admin/csrf-secret | 1 + CHANGELOG.md | 10 ++++ deno.json | 1 + tests/admin/conformance_test.ts | 98 +++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 .dune/admin/csrf-secret create mode 100644 tests/admin/conformance_test.ts diff --git a/.dune/admin/csrf-secret b/.dune/admin/csrf-secret new file mode 100644 index 0000000..0cfae29 --- /dev/null +++ b/.dune/admin/csrf-secret @@ -0,0 +1 @@ +e2d70ff6df338be68a3461e97fc83abfe5fa4ff393c4901a90f9d84fa88cabf6 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index b1dede8..c4cafa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ follows [Semantic Versioning](https://semver.org). ## [Unreleased] + +### Added + +- **Runs the admin conformance suite from `@dune/testing`** (`tests/admin/conformance_test.ts`): the checks + the stub admin passes, against plugin-admin over a real temp site. It covers `auth.session`, session + recognition and marker scrubbing, the declared `dune_session` cookie, mount-order independence, + `onPageUpdate` fired once by an edit, `admin.pages` / `admin.routes` mounted behind the login and the CSRF + check, and the site serving with no admin. Needs `@dune/testing` with the conformance suite + (`"@dune/testing": "jsr:@dune/testing@^1"` in `deno.json`), so it is draft-only until that is released. + ### Changed — BREAKING - **The admin provides and consumes core's capabilities instead of the untyped diff --git a/deno.json b/deno.json index 78d18cc..d5bbf5f 100644 --- a/deno.json +++ b/deno.json @@ -18,6 +18,7 @@ "./admin/auth/provider": "./src/admin/auth/provider.ts" }, "imports": { + "@dune/testing": "jsr:@dune/testing@^1", "@dune/core": "jsr:@dune/core@^0.35.3", "fresh": "jsr:@fresh/core@^2", "preact": "npm:preact@^10", diff --git a/tests/admin/conformance_test.ts b/tests/admin/conformance_test.ts new file mode 100644 index 0000000..dccc990 --- /dev/null +++ b/tests/admin/conformance_test.ts @@ -0,0 +1,98 @@ +/** + * plugin-admin against the admin conformance suite from `@dune/testing`: the + * same checks the stub admin passes, run on the real thing over a real temp + * site. If this passes, plugin-admin meets the contract core relies on. + */ + +import { join } from "@std/path"; +import { runAdminConformance } from "@dune/testing/conformance"; +import type { ConformanceApp } from "@dune/testing/conformance"; +import { bootstrap } from "@dune/core/bootstrap"; +import { createDuneApp } from "@dune/core/fresh-app"; +import { createStorage } from "@dune/core/storage"; +import { createLocalUserStore } from "@dune/core/auth/user-store"; +import { hashPassword } from "@dune/core/auth/passwords"; + +const PASSWORD = "correct horse battery staple"; +const ORIGIN = "http://localhost:8000"; + +async function removeWithRetry(root: string): Promise { + let last: unknown; + for (let i = 0; i < 5; i++) { + try { + await Deno.remove(root, { recursive: true }); + return; + } catch (err) { + last = err; + await new Promise((r) => setTimeout(r, 50)); + } + } + throw last; +} + +async function login(app: ConformanceApp, username: string): Promise { + const res = await app.request("/admin/login", { + method: "POST", + headers: { origin: ORIGIN, "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ username, password: PASSWORD }).toString(), + }); + const set = res.headers.get("set-cookie") ?? ""; + const m = set.match(/dune_session=[^;]+/); + if (!m) throw new Error(`plugin-admin login failed for ${username}: ${res.status}`); + return m[0]; +} + +runAdminConformance({ + name: "plugin-admin", + adminPrefix: "/admin", + + async createApp({ content, extraPlugins, withAdmin }) { + const root = await Deno.makeTempDir({ prefix: "dune_admin_conformance_" }); + await Deno.mkdir(join(root, "config"), { recursive: true }); + await Deno.writeTextFile(join(root, "config", "site.yaml"), 'title: "Conformance Site"\nurl: "http://localhost:8000"\n'); + for (const [path, body] of Object.entries(content)) { + await Deno.mkdir(join(root, "content", path, ".."), { recursive: true }); + await Deno.writeTextFile(join(root, "content", path), body); + } + if (withAdmin) { + const users = createLocalUserStore({ storage: createStorage({ rootDir: root }), usersDir: "data/users" }); + await users.create({ + email: "ed@example.test", + username: "ed", + name: "Ed", + provider: "local", + passwordHash: await hashPassword(PASSWORD), + roles: ["admin"], + }); + } + const ctx = await bootstrap(root, { + plugins: extraPlugins, + // deno-lint-ignore no-explicit-any + configOverrides: withAdmin ? undefined : { admin: { enabled: false } as any }, + }); + const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); + const handler = app.handler(); + return { + request: (path, init) => handler(new Request(new URL(path, ORIGIN), init)), + bootstrap: ctx, + readText: (path) => Deno.readTextFile(join(root, path)), + dispose: () => removeWithRetry(root), + }; + }, + + signInEditor: (app) => login(app, "ed"), + // No signInViewer: every admin-tier role (admin, editor, author) holds pages.update, + // and a non-admin account cannot sign in to the admin at all. + + async editPage(app, cookie, { sourcePath, content }) { + // plugin-admin's route takes the path relative to the content dir and the body after the frontmatter. + const relative = sourcePath.replace(/^content\//, ""); + const body = content.replace(/^---[\s\S]*?---\n*/, ""); + const res = await app.request(`/admin/api/pages/${relative}`, { + method: "PUT", + headers: { cookie, origin: ORIGIN, "content-type": "application/json" }, + body: JSON.stringify({ content: body }), + }); + if (res.status !== 200) throw new Error(`plugin-admin edit failed: ${res.status} ${await res.text()}`); + }, +}); From 37eb1d541342eb5642560f1cd734a21b2565869b Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 20:47:53 +0100 Subject: [PATCH 06/21] fix(admin): every route that writes a page fires its event (#97) Restore, staging publish, translate x2, dev apply, reorder (new onPageMove) and collab auto-save wrote pages and fired nothing. They now share announcePageEvent(), which fires the hook and the content webhooks. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 11 ++ mod.ts | 1 + src/admin/page-events.ts | 39 +++++ src/admin/routes/api/dev/apply.ts | 10 ++ src/admin/routes/api/history/[...rest].ts | 2 + src/admin/routes/api/i18n/translate-page.ts | 2 + src/admin/routes/api/pages/reorder.ts | 7 + src/admin/routes/api/pages/translate.ts | 2 + .../routes/api/staging/[...path]/publish.ts | 2 + src/collab/manager.ts | 9 +- src/collab/types.ts | 5 + tests/admin/page_events_test.ts | 143 ++++++++++++++++++ 12 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 src/admin/page-events.ts create mode 100644 tests/admin/page_events_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c4cafa1..745a31b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ follows [Semantic Versioning](https://semver.org). ## [Unreleased] +### Fixed + +- **Every route that writes a page now announces it (#97).** Whoever writes a page fires its event; core + does not. Six admin paths wrote pages and fired nothing, so search indexes, webhooks and audit plugins + missed them: restoring a revision (`onPageUpdate`), staging publish (`onPageUpdate`), the two translate + routes (`onPageCreate`), the dev apply endpoint (create / update / delete), reorder (new `onPageMove`, + which lists every renamed folder) and collab auto-save (`onPageUpdate`, only when the save changed the file). + The routes share `announcePageEvent()`, which fires the hook and the configured content webhooks, so + `onPageMove` is also a valid webhook event. Needs `@dune/core` with `onPageMove` (0.36). + `tests/admin/page_events_test.ts` drives create, update, reorder, delete and restore through the real routes. + ### Added - **Runs the admin conformance suite from `@dune/testing`** (`tests/admin/conformance_test.ts`): the checks diff --git a/mod.ts b/mod.ts index c51b1f6..b4e7943 100644 --- a/mod.ts +++ b/mod.ts @@ -254,6 +254,7 @@ export function createAdminPlugin( history: bootstrap.history, contentDir: config.system.content.dir, security: services.security, + hooks: bootstrap.hooks, }); // Core accepts and stores public form submissions; the admin only diff --git a/src/admin/page-events.ts b/src/admin/page-events.ts new file mode 100644 index 0000000..31e27b3 --- /dev/null +++ b/src/admin/page-events.ts @@ -0,0 +1,39 @@ +/** + * Announce a page write to plugins and content webhooks. + * + * Whoever writes a page fires its event: core never does it for the writer, and + * search indexes, webhooks and audit plugins only learn of a write through it. + * Every admin route that creates, rewrites, deletes or moves a page calls this + * after the file is on disk. Fire-and-forget: a failing handler or an + * unreachable webhook never fails the request. + * + * @module + */ + +import type { FreshContext } from "fresh"; +import type { AdminState } from "./types.ts"; +import { fireContentWebhooks } from "./webhooks.ts"; + +/** Paths are relative to the content directory. */ +export type PageEvent = + | { event: "onPageCreate"; payload: { sourcePath: string; title: string } } + | { event: "onPageUpdate"; payload: { sourcePath: string } } + | { event: "onPageDelete"; payload: { sourcePath: string } } + | { event: "onPageMove"; payload: { moves: Array<{ from: string; to: string }> } }; + +/** Fire a page event to hook handlers and to the configured content webhooks. */ +export function announcePageEvent(ctx: FreshContext, e: PageEvent): void { + const { hooks, config, services } = ctx.state.adminContext; + if (hooks) { + hooks.fire(e.event, e.payload).catch((err: unknown) => + console.warn(`[dune] ${e.event} hook failed: ${err instanceof Error ? err.message : err}`) + ); + } + fireContentWebhooks( + config.admin?.webhooks ?? [], + e.event, + e.payload, + config.admin?.runtime_dir ?? ".dune/admin", + services.security, + ); +} diff --git a/src/admin/routes/api/dev/apply.ts b/src/admin/routes/api/dev/apply.ts index 61455a2..777db57 100644 --- a/src/admin/routes/api/dev/apply.ts +++ b/src/admin/routes/api/dev/apply.ts @@ -41,6 +41,7 @@ import type { FreshContext } from "fresh"; import type { AdminState } from "../../../types.ts"; import { json, requirePermission, requireTsxWrite, requireOwnedPage, csrfCheck, validatePagePath } from "../_utils.ts"; import { ValidationError } from "../../../http.ts"; +import { announcePageEvent } from "../../../page-events.ts"; import { join } from "@std/path"; import { parse as parseYaml, stringify as stringifyYaml } from "@std/yaml"; @@ -482,10 +483,19 @@ export const handler = { try { if (change.op === "delete") { await Deno.remove(absPath); + announcePageEvent(ctx, { event: "onPageDelete", payload: { sourcePath: change.path ?? "" } }); results.push({ op: change.op, path: change.path, status: "deleted", errors: [] }); } else { const existed = await Deno.stat(absPath).then(() => true).catch(() => false); await applyWrite(contentDir, change as Change & { op: "write" | "frontmatter"; path: string }); + const sourcePath = change.path ?? ""; + const title = change.op === "frontmatter" ? change.patch?.title : undefined; + announcePageEvent( + ctx, + existed + ? { event: "onPageUpdate", payload: { sourcePath } } + : { event: "onPageCreate", payload: { sourcePath, title: typeof title === "string" ? title : "" } }, + ); results.push({ op: change.op, path: change.path, diff --git a/src/admin/routes/api/history/[...rest].ts b/src/admin/routes/api/history/[...rest].ts index d2810f6..44508b7 100644 --- a/src/admin/routes/api/history/[...rest].ts +++ b/src/admin/routes/api/history/[...rest].ts @@ -13,6 +13,7 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, requireTsxWrite, requireOwnedPage, json, serverError, csrfCheck, validatePagePath } from "../_utils.ts"; import { stringify as stringifyYaml } from "@std/yaml"; +import { announcePageEvent } from "../../../page-events.ts"; import type { FreshContext } from "fresh"; export const handler = { @@ -108,6 +109,7 @@ export const handler = { await storage.write(filePath, new TextEncoder().encode(fullContent)); await engine.rebuild(); + announcePageEvent(ctx, { event: "onPageUpdate", payload: { sourcePath: pageIndex.sourcePath } }); return json({ restored: true, revision: revNum }); } catch (err) { diff --git a/src/admin/routes/api/i18n/translate-page.ts b/src/admin/routes/api/i18n/translate-page.ts index f46bb96..8c5ad2d 100644 --- a/src/admin/routes/api/i18n/translate-page.ts +++ b/src/admin/routes/api/i18n/translate-page.ts @@ -2,6 +2,7 @@ import type { AdminState } from "../../../types.ts"; import { json, serverError, csrfCheck, requirePermission, requireTsxWrite, requireOwnedPage, validatePagePath } from "../_utils.ts"; +import { announcePageEvent } from "../../../page-events.ts"; import type { FreshContext } from "fresh"; function splitFrontmatter(content: string): { fm: string; body: string } { @@ -95,6 +96,7 @@ export const handler = { } await storage.write(`${contentDir}/${targetPath}`, new TextEncoder().encode(translatedFm + translatedBody)); + announcePageEvent(ctx, { event: "onPageCreate", payload: { sourcePath: targetPath, title: pageIndex.title ?? "" } }); engine.rebuild().catch((err: unknown) => console.error("[dune] MT translate-page rebuild error:", err)); return json({ ok: true, targetPath }); diff --git a/src/admin/routes/api/pages/reorder.ts b/src/admin/routes/api/pages/reorder.ts index fbb8ad8..a2a3107 100644 --- a/src/admin/routes/api/pages/reorder.ts +++ b/src/admin/routes/api/pages/reorder.ts @@ -3,6 +3,7 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, requireEditorOrAdmin, json, serverError, validatePagePath, csrfCheck } from "../_utils.ts"; import { dirname, basename } from "@std/path"; +import { announcePageEvent } from "../../../page-events.ts"; import type { FreshContext } from "fresh"; export const handler = { @@ -83,6 +84,12 @@ export const handler = { } await engine.rebuild(); + // Content-relative folders that were renamed; every page below `from` moved below `to`. + const rel = (p: string) => p.startsWith(`${contentDir}/`) ? p.slice(contentDir.length + 1) : p; + announcePageEvent(ctx, { + event: "onPageMove", + payload: { moves: renames.map((r) => ({ from: rel(r.oldDir), to: rel(r.newDir) })) }, + }); return json({ reordered: true }); } catch (err) { return serverError(err); diff --git a/src/admin/routes/api/pages/translate.ts b/src/admin/routes/api/pages/translate.ts index 6b6d7fd..b1c6d93 100644 --- a/src/admin/routes/api/pages/translate.ts +++ b/src/admin/routes/api/pages/translate.ts @@ -4,6 +4,7 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, requireTsxWrite, requireOwnedPage, json, serverError, csrfCheck, validatePagePath } from "../_utils.ts"; import { dirname, basename } from "@std/path"; import { parseContentFilename } from "@dune/core/content/path-utils"; +import { announcePageEvent } from "../../../page-events.ts"; import type { FreshContext } from "fresh"; export const handler = { @@ -48,6 +49,7 @@ export const handler = { const sourceBytes = await storage.read(`${contentDir}/${pageIndex.sourcePath}`); await storage.write(`${contentDir}/${targetPath}`, sourceBytes); + announcePageEvent(ctx, { event: "onPageCreate", payload: { sourcePath: targetPath, title: pageIndex.title ?? "" } }); await engine.rebuild(); return json({ created: true, path: targetPath }); diff --git a/src/admin/routes/api/staging/[...path]/publish.ts b/src/admin/routes/api/staging/[...path]/publish.ts index 1d55baa..19a5660 100644 --- a/src/admin/routes/api/staging/[...path]/publish.ts +++ b/src/admin/routes/api/staging/[...path]/publish.ts @@ -7,6 +7,7 @@ import type { AdminState } from "../../../../types.ts"; import { requirePermission, requireTsxWrite, requireOwnedPage, json, serverError, csrfCheck, validatePagePath } from "../../_utils.ts"; import { stringify as stringifyYaml } from "@std/yaml"; +import { announcePageEvent } from "../../../../page-events.ts"; import type { FreshContext } from "fresh"; async function maybeGitCommit( @@ -68,6 +69,7 @@ export const handler = { await maybeGitCommit(filePath, pagePath, authResult.user?.name, config.admin?.git_commit); await staging.discard(pagePath); await engine.rebuild(); + announcePageEvent(ctx, { event: "onPageUpdate", payload: { sourcePath: pageIndex.sourcePath } }); return json({ published: true, sourcePath: pagePath }); } catch (err) { diff --git a/src/collab/manager.ts b/src/collab/manager.ts index b454a63..0e309dc 100644 --- a/src/collab/manager.ts +++ b/src/collab/manager.ts @@ -111,7 +111,7 @@ function takeToken(rate: ConnectionRate): boolean { } export function createCollabManager(options: CollabManagerOptions): CollabManager { - const { storage, engine, history, contentDir, security } = options; + const { storage, engine, history, contentDir, security, hooks } = options; // docId → CollabSessionState const sessions = new Map(); @@ -194,6 +194,13 @@ export function createCollabManager(options: CollabManagerOptions): CollabManage return; } + // Whoever writes a page fires its event. Skip a save that changed nothing. + if (hooks && updated !== raw) { + hooks.fire("onPageUpdate", { sourcePath: session.docId }).catch((err: unknown) => + console.warn(`[collab] onPageUpdate hook failed for ${session.docId}:`, err) + ); + } + if (history && session.lastEditor) { const fm = parseFrontmatter(raw, security.parseUserYaml); history.record({ diff --git a/src/collab/types.ts b/src/collab/types.ts index f6fe8a2..ceef965 100644 --- a/src/collab/types.ts +++ b/src/collab/types.ts @@ -110,6 +110,11 @@ export interface CollabManagerOptions { contentDir: string; /** Core's security service (safe YAML parsing of frontmatter). */ security: Pick; + /** + * Hook registry. An auto-save that changes the file fires `onPageUpdate` + * (the save is already debounced, so this is not per keystroke). + */ + hooks?: Pick; } /** The CollabManager public interface exposed to the admin server. */ diff --git a/tests/admin/page_events_test.ts b/tests/admin/page_events_test.ts new file mode 100644 index 0000000..0accdbd --- /dev/null +++ b/tests/admin/page_events_test.ts @@ -0,0 +1,143 @@ +/** + * Every admin route that writes a page announces it: `onPageCreate`, + * `onPageUpdate`, `onPageDelete` and `onPageMove` reach plugins, on a real site + * through the real routes. Whoever writes a page fires its event; core does not + * do it for them, so a route that forgets is invisible to search indexes, + * webhooks and audit plugins. + */ + +import { assertEquals } from "@std/assert"; +import { join } from "@std/path"; +import type { DunePlugin } from "@dune/core/hooks"; +import { bootstrap } from "@dune/core/bootstrap"; +import { createDuneApp } from "@dune/core/fresh-app"; +import { createStorage } from "@dune/core/storage"; +import { createLocalUserStore } from "@dune/core/auth/user-store"; +import { hashPassword } from "@dune/core/auth/passwords"; + +const PASSWORD = "correct horse battery staple"; +const ORIGIN = "http://localhost:8000"; + +type Seen = Array<{ event: string; data: unknown }>; + +async function withSite( + run: (api: { + seen: Seen; + history: Awaited>["history"]; + call: (method: string, path: string, body?: unknown) => Promise; + }) => Promise, +): Promise { + const root = await Deno.makeTempDir({ prefix: "dune_admin_events_" }); + try { + await Deno.mkdir(join(root, "config"), { recursive: true }); + await Deno.writeTextFile(join(root, "config", "site.yaml"), 'title: "Events"\nurl: "http://localhost:8000"\n'); + for (const dir of ["01.alpha", "02.beta"]) { + await Deno.mkdir(join(root, "content", dir), { recursive: true }); + await Deno.writeTextFile(join(root, "content", dir, "default.md"), `---\ntitle: ${dir}\n---\n\nbody\n`); + } + const users = createLocalUserStore({ storage: createStorage({ rootDir: root }), usersDir: "data/users" }); + await users.create({ + email: "ed@example.test", + username: "ed", + name: "Ed", + provider: "local", + passwordHash: await hashPassword(PASSWORD), + roles: ["admin"], + }); + + const seen: Seen = []; + const record = (event: string) => ({ data }: { data: unknown }) => { + seen.push({ event, data }); + }; + const recorder: DunePlugin = { + name: "recorder", + version: "1.0.0", + hooks: { + onPageCreate: record("onPageCreate"), + onPageUpdate: record("onPageUpdate"), + onPageDelete: record("onPageDelete"), + onPageMove: record("onPageMove"), + } as DunePlugin["hooks"], + }; + const ctx = await bootstrap(root, { plugins: [recorder] }); + const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); + const handler = app.handler(); + const req = (path: string, init?: RequestInit) => handler(new Request(new URL(path, ORIGIN), init)); + + const login = await req("/admin/login", { + method: "POST", + headers: { origin: ORIGIN, "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ username: "ed", password: PASSWORD }).toString(), + }); + const cookie = (login.headers.get("set-cookie") ?? "").match(/dune_session=[^;]+/)?.[0]; + if (!cookie) throw new Error(`login failed: ${login.status}`); + + await run({ + seen, + history: ctx.history, + call: async (method, path, body) => { + const res = await req(path, { + method, + headers: { cookie, origin: ORIGIN, "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + // Hooks are fire-and-forget; let them settle. + await new Promise((r) => setTimeout(r, 25)); + return res; + }, + }); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +} + +Deno.test("admin page routes announce create, update, move and delete", async () => { + await withSite(async ({ seen, call }) => { + let res = await call("POST", "/admin/api/pages", { path: "blog", title: "Hello" }); + assertEquals(res.ok, true); + assertEquals(seen.splice(0), [{ event: "onPageCreate", data: { sourcePath: "blog/default.md", title: "Hello" } }]); + + res = await call("PUT", "/admin/api/pages/blog/default.md", { content: "changed" }); + assertEquals(res.status, 200); + assertEquals(seen.splice(0), [{ event: "onPageUpdate", data: { sourcePath: "blog/default.md" } }]); + + // Move 02.beta before 01.alpha: both folders are renumbered. + res = await call("POST", "/admin/api/pages/reorder", { + sourcePath: "02.beta/default.md", + targetPath: "01.alpha/default.md", + position: "before", + }); + assertEquals(res.status, 200); + assertEquals(seen.splice(0), [{ + event: "onPageMove", + data: { + moves: [ + { from: "02.beta", to: "01.beta" }, + { from: "01.alpha", to: "02.alpha" }, + ], + }, + }]); + + res = await call("DELETE", "/admin/api/pages/blog/default.md"); + assertEquals(res.status, 200); + assertEquals(seen.splice(0), [{ event: "onPageDelete", data: { sourcePath: "blog/default.md" } }]); + }); +}); + +Deno.test("history restore announces onPageUpdate", async () => { + await withSite(async ({ seen, history, call }) => { + await history.record({ + sourcePath: "01.alpha/default.md", + content: "an earlier version", + frontmatter: { title: "01.alpha" }, + author: "ed", + message: "seed", + }); + const revisions = await history.getHistory("01.alpha/default.md"); + assertEquals(revisions.length, 1); + + const res = await call("POST", `/admin/api/history/01.alpha/default.md/${revisions[0].number}/restore`); + assertEquals(res.status, 200); + assertEquals(seen.splice(0), [{ event: "onPageUpdate", data: { sourcePath: "01.alpha/default.md" } }]); + }); +}); From 8044a71782e8ad7bd661edf256eea24dfc9c6253 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 20:49:26 +0100 Subject: [PATCH 07/21] test(admin): conformance driver covers page create and delete (#97) Co-Authored-By: Claude Sonnet 5.5 --- tests/admin/conformance_test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/admin/conformance_test.ts b/tests/admin/conformance_test.ts index dccc990..1b264c0 100644 --- a/tests/admin/conformance_test.ts +++ b/tests/admin/conformance_test.ts @@ -95,4 +95,24 @@ runAdminConformance({ }); if (res.status !== 200) throw new Error(`plugin-admin edit failed: ${res.status} ${await res.text()}`); }, + + async createPage(app, cookie, { sourcePath, title }) { + // The route takes the page folder and adds default.md itself. + const folder = sourcePath.replace(/^content\//, "").replace(/\/default\.md$/, ""); + const res = await app.request("/admin/api/pages", { + method: "POST", + headers: { cookie, origin: ORIGIN, "content-type": "application/json" }, + body: JSON.stringify({ path: folder, title }), + }); + if (!res.ok) throw new Error(`plugin-admin create failed: ${res.status} ${await res.text()}`); + }, + + async deletePage(app, cookie, { sourcePath }) { + const relative = sourcePath.replace(/^content\//, ""); + const res = await app.request(`/admin/api/pages/${relative}`, { + method: "DELETE", + headers: { cookie, origin: ORIGIN }, + }); + if (res.status !== 200) throw new Error(`plugin-admin delete failed: ${res.status} ${await res.text()}`); + }, }); From 6050ac2f660becd9a7d8b8568cb2f35e9fc0fa19 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 21:20:34 +0100 Subject: [PATCH 08/21] feat(admin)!: mount() reads MountApi.runtime instead of the bootstrap result (#97) Adds mountAdminPanel() (panel only); mount() no longer re-registers public plugin routes and form endpoints, which createDuneApp() owns. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 11 ++++++++ mod.ts | 46 +++++++++++++++------------------- src/admin/mount.ts | 62 +++++++++++++++++++++++++++++++--------------- 3 files changed, 73 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 745a31b..2dd689c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,17 @@ follows [Semantic Versioning](https://semver.org). `onPageMove` is also a valid webhook event. Needs `@dune/core` with `onPageMove` (0.36). `tests/admin/page_events_test.ts` drives create, update, reorder, delete and restore through the real routes. +### Changed — BREAKING + +- **`mount()` reads the site from `MountApi.runtime`, not `MountApi.bootstrap` (#97).** Core's `mount()` + argument no longer carries the whole bootstrap result, so the admin takes engine, search, history, hooks, + submissions, flex, image cache, metrics, sections, authz and the job scheduler from the curated + `PluginRuntime`. `authz` replaces the `authz && authzAdapter` check (the adapter is no longer reachable). + `mount()` now calls the new `mountAdminPanel()` (panel routes, context middleware, `admin.pages` / + `admin.routes`) and no longer registers public plugin routes or form endpoints a second time: core's + `createDuneApp()` owns those. The headless `mountDuneAdmin(app, bootstrapResult)` is unchanged. Needs + `@dune/core` 0.36. + ### Added - **Runs the admin conformance suite from `@dune/testing`** (`tests/admin/conformance_test.ts`): the checks diff --git a/mod.ts b/mod.ts index b4e7943..ff56a2d 100644 --- a/mod.ts +++ b/mod.ts @@ -36,9 +36,7 @@ import type { AuthzDbAdapter } from "@dune/core/auth/authz-adapter-db"; import { initAdminContext } from "./src/admin/context.ts"; import type { AdminContext } from "./src/admin/context.ts"; import { createBlockEditorPlugin } from "./src/admin/block-editor-plugin.tsx"; -import { getDuneAdminIslands, mountDuneAdmin } from "./src/admin/mount.ts"; -import { sectionRegistry } from "@dune/core/sections"; -import type { SectionRegistry } from "@dune/core/sections"; +import { getDuneAdminIslands, mountAdminPanel } from "./src/admin/mount.ts"; import * as corePlugins from "@dune/core/plugins"; import denoJson from "./deno.json" with { type: "json" }; @@ -143,7 +141,7 @@ export function createAdminPlugin( })); }, - async mount({ app, bootstrap, services, capabilities }: MountApi) { + async mount({ app, runtime, services, capabilities }: MountApi) { const { logger } = services; if (!setupState) return; // admin disabled @@ -235,7 +233,7 @@ export function createAdminPlugin( return; } try { - await applyWorkflowTransition({ engine: bootstrap.engine, storage, config, hooks: bootstrap.hooks, workflow, services }, action.sourcePath, newStatus); + await applyWorkflowTransition({ engine: runtime.engine, storage, config, hooks: runtime.hooks, workflow, services }, action.sourcePath, newStatus); } catch (err) { logger.error("scheduler.action-failed", { actionId: action.id, @@ -250,16 +248,16 @@ export function createAdminPlugin( const collabManager = createCollabManager({ storage, - engine: bootstrap.engine, - history: bootstrap.history, + engine: runtime.engine, + history: runtime.history, contentDir: config.system.content.dir, security: services.security, - hooks: bootstrap.hooks, + hooks: runtime.hooks, }); // Core accepts and stores public form submissions; the admin only // manages them. An older core has no store to read. - const submissionManager = bootstrap.submissions; + const submissionManager = runtime.submissions; if (!submissionManager) { throw new Error( "@dune/plugin-admin 3.2+ needs a @dune/core that stores form submissions itself " + @@ -268,7 +266,7 @@ export function createAdminPlugin( } // The incoming webhook runs in core; keep the admin's audit trail. - bootstrap.hooks.on< + runtime.hooks.on< { action: "rebuild" | "purge-cache"; ip: string | null; userAgent: string | null } >("onSystemAction", ({ data }) => { void setupState?.auditLogger?.log({ @@ -294,8 +292,8 @@ export function createAdminPlugin( const adminContext: AdminContext = { services, - engine: bootstrap.engine, - search: bootstrap.search, + engine: runtime.engine, + search: runtime.search, storage, config, auth, @@ -306,32 +304,28 @@ export function createAdminPlugin( workflow, scheduler, executeScheduledAction, - history: bootstrap.history, + history: runtime.history, submissions: submissionManager, - flex: bootstrap.flexEngine, - hooks: bootstrap.hooks, + flex: runtime.flexEngine, + hooks: runtime.hooks, staging: stagingEngine, comments: commentManager, collab: collabManager, inlineEdit, contentEditor, - imageCache: bootstrap.imageCache, + imageCache: runtime.imageCache, auditLogger: setupState.auditLogger ?? undefined, - metrics: metricsEnabled ? bootstrap.metrics : undefined, + metrics: metricsEnabled ? runtime.metrics : undefined, mt: setupState.mt, rateLimitStore, pluginPages: pluginPages.length > 0 ? [...pluginPages] : undefined, pluginRoutes: pluginRoutes.length > 0 ? [...pluginRoutes] : undefined, // Created per serving process by `dune serve`, after mount(): read lazily. get jobScheduler() { - return bootstrap.jobScheduler; + return runtime.jobs; }, - authz: bootstrap.authz, - // Cores older than 0.31 don't expose `sections` on BootstrapResult; - // fall back to this package's own module singleton, which resolves to - // the host's copy anyway once the @0 range unifies the two. - sections: (bootstrap as unknown as { sections?: SectionRegistry }).sections ?? - sectionRegistry, + authz: runtime.authz, + sections: runtime.sections, }; // Keep the singleton for single-site serve paths (serve.ts, dev.ts). @@ -358,7 +352,7 @@ export function createAdminPlugin( // ── authz tuple bootstrap ───────────────────────────────────────────────── // Runs after core's env bootstrap so a freshly-created first admin's // tuple gets registered too, instead of being locked out on first run. - if (bootstrap.authz && bootstrap.authzAdapter) { + if (runtime.authz) { try { // users.list() returns every account in the unified store // (data/users/) — not just admin-tier ones once public site @@ -379,7 +373,7 @@ export function createAdminPlugin( } // ── Mount admin Fresh routes ────────────────────────────────────────────── - await mountDuneAdmin(app, bootstrap, adminContext); + mountAdminPanel(app, runtime, adminContext); }, }; } diff --git a/src/admin/mount.ts b/src/admin/mount.ts index 0f9d20e..79baa1f 100644 --- a/src/admin/mount.ts +++ b/src/admin/mount.ts @@ -54,7 +54,7 @@ import { * registration for the same bootstrap a no-op instead of stacking a * duplicate middleware. */ -const contextMiddlewareRegisteredFor = new WeakSet(); +const contextMiddlewareRegisteredFor = new WeakSet(); /** * Register the `ctx.state.adminContext = …` middleware every admin route @@ -69,7 +69,8 @@ const contextMiddlewareRegisteredFor = new WeakSet(); export function registerAdminContextMiddleware( // deno-lint-ignore no-explicit-any app: App, - ctx: BootstrapResult, + /** Any object identifying the site (its runtime or bootstrap result): one registration per object. */ + ctx: object, getAdminContext: () => import("./context.ts").AdminContext | null, ): void { if (contextMiddlewareRegisteredFor.has(ctx)) return; @@ -224,7 +225,45 @@ export async function mountDuneAdmin( adminCtxOverride?: import("./context.ts").AdminContext, ): Promise { const { config } = ctx; - const adminContext = adminCtxOverride ?? currentAdminContextOrNull(); + const adminPrefix = config.admin?.path ?? "/admin"; + mountAdminPanel(app, ctx, adminCtxOverride ?? currentAdminContextOrNull()); + + // ── Plugin public routes ──────────────────────────────────────────────────── + // @dune/core's own createDuneApp() registers these itself now (it collects + // BootstrapResult.pluginPublicRoutes in bootstrap.ts and wires them before + // its content catch-all) — this call only matters for the headless-mode + // path this file's own module docstring documents, where a developer calls + // bootstrap() + mountDuneAdmin() directly and never goes through + // createDuneApp() at all. Delegating to the same core function (rather than + // re-implementing the validation/shadowing logic here) keeps exactly one + // place that decides what a plugin route is allowed to do. + registerPluginPublicRoutes(app, ctx, { adminPrefix }); + + // ── Public form / webhook API ─────────────────────────────────────────────── + // Served by core, with or without the admin panel. createDuneApp() already + // registers them, so this only matters for the headless path above, and it + // is a no-op when they are already there. + registerFormRoutes(app, ctx); +} + +/** + * Mount the admin panel itself: the per-site context middleware, the admin + * routes under the admin prefix, and the plugins' `admin.pages` / `admin.routes`. + * It needs only the loaded config, so `mount()` calls it with the plugin + * runtime and `mountDuneAdmin()` (headless mode) with a full bootstrap result. + * It registers neither public plugin routes nor the form endpoints: core's + * `createDuneApp()` owns those. + * + * @param site - Identifies the site; a second mount for the same object does not stack a second context middleware. + */ +export function mountAdminPanel( + // deno-lint-ignore no-explicit-any + app: App, + site: { config: import("@dune/core/config").DuneConfig }, + adminContext: import("./context.ts").AdminContext | null, +): void { + const { config } = site; + const ctx = site; const adminPrefix = config.admin?.path ?? "/admin"; // ── Admin panel ───────────────────────────────────────────────────────────── @@ -256,23 +295,6 @@ export async function mountDuneAdmin( mountPluginAdminPages(app, adminPrefix, adminContext.pluginPages, adminContext.services.logger); } } - - // ── Plugin public routes ──────────────────────────────────────────────────── - // @dune/core's own createDuneApp() registers these itself now (it collects - // BootstrapResult.pluginPublicRoutes in bootstrap.ts and wires them before - // its content catch-all) — this call only matters for the headless-mode - // path this file's own module docstring documents, where a developer calls - // bootstrap() + mountDuneAdmin() directly and never goes through - // createDuneApp() at all. Delegating to the same core function (rather than - // re-implementing the validation/shadowing logic here) keeps exactly one - // place that decides what a plugin route is allowed to do. - registerPluginPublicRoutes(app, ctx, { adminPrefix }); - - // ── Public form / webhook API ─────────────────────────────────────────────── - // Served by core, with or without the admin panel. createDuneApp() already - // registers them, so this only matters for the headless path above, and it - // is a no-op when they are already there. - registerFormRoutes(app, ctx); } /** From 5310522800be97400b478c35208fff0d83312895 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 21:35:12 +0100 Subject: [PATCH 09/21] feat(admin): narrow the contract's PluginApp to Fresh's App (#97) mount() casts app to Fresh's App (the admin uses fsRoutes, layout, route) and the block editor declares a FreshContext handler. deno task check now also type-checks mod.ts. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 5 +++++ deno.json | 2 +- mod.ts | 6 +++++- src/admin/block-editor-plugin.tsx | 6 +++++- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dd689c..45f0def 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,11 @@ follows [Semantic Versioning](https://semver.org). `admin.routes`) and no longer registers public plugin routes or form endpoints a second time: core's `createDuneApp()` owns those. The headless `mountDuneAdmin(app, bootstrapResult)` is unchanged. Needs `@dune/core` 0.36. +- **Narrows the contract's `PluginApp` to Fresh's `App` itself (#97).** Core's contract no longer names Fresh + types (`MountApi.app` is a `PluginApp`, handlers get a `PluginRouteContext`). The admin uses Fresh-only + features (`fsRoutes`, `layout`, `route`), so `mount()` casts `app` to Fresh's `App` and the block editor + declares `pageEditorHandler(ctx: FreshContext)`. No behaviour change. `deno task check` now also + type-checks `mod.ts`, which it silently skipped. ### Added diff --git a/deno.json b/deno.json index d5bbf5f..1b12b19 100644 --- a/deno.json +++ b/deno.json @@ -45,7 +45,7 @@ }, "tasks": { "test": "deno test -A tests/", - "check": "deno lint src/ && deno check src/**/*.ts src/**/*.tsx", + "check": "deno lint src/ && deno check mod.ts src/**/*.ts src/**/*.tsx", "test:e2e": "deno run --allow-all --unstable-kv npm:playwright test --config playwright.config.ts", "test:e2e:install": "deno run --allow-all npm:playwright install chromium", "gen:admin-manifest": "deno run --allow-read --allow-write scripts/generate-admin-manifest.ts", diff --git a/mod.ts b/mod.ts index ff56a2d..76f70cd 100644 --- a/mod.ts +++ b/mod.ts @@ -36,6 +36,7 @@ import type { AuthzDbAdapter } from "@dune/core/auth/authz-adapter-db"; import { initAdminContext } from "./src/admin/context.ts"; import type { AdminContext } from "./src/admin/context.ts"; import { createBlockEditorPlugin } from "./src/admin/block-editor-plugin.tsx"; +import type { App } from "fresh"; import { getDuneAdminIslands, mountAdminPanel } from "./src/admin/mount.ts"; import * as corePlugins from "@dune/core/plugins"; import denoJson from "./deno.json" with { type: "json" }; @@ -373,7 +374,10 @@ export function createAdminPlugin( } // ── Mount admin Fresh routes ────────────────────────────────────────────── - mountAdminPanel(app, runtime, adminContext); + // The admin uses Fresh-only features (fsRoutes, layout, route), so it depends on Fresh + // and narrows the contract's PluginApp; core always passes the real Fresh App. + // deno-lint-ignore no-explicit-any + mountAdminPanel(app as unknown as App, runtime, adminContext); }, }; } diff --git a/src/admin/block-editor-plugin.tsx b/src/admin/block-editor-plugin.tsx index 66bbfb0..a166a4b 100644 --- a/src/admin/block-editor-plugin.tsx +++ b/src/admin/block-editor-plugin.tsx @@ -9,6 +9,7 @@ */ import type { ContentEditorPlugin } from "@dune/core/hooks"; +import type { FreshContext } from "fresh"; import PageEditor from "./islands/PageEditor.tsx"; function PageEditorRoute( @@ -28,7 +29,10 @@ function PageEditorRoute( export function createBlockEditorPlugin(): ContentEditorPlugin { return { - pageEditorHandler(ctx) { + // A full Fresh context (ctx.render): the contract's PluginRouteContext is narrowed here, + // which the contract allows because this plugin depends on Fresh itself. + // deno-lint-ignore no-explicit-any + pageEditorHandler(ctx: FreshContext) { const { engine, prefix } = ctx.state.adminContext; const pagePath = ctx.url.searchParams.get("path"); if (!pagePath) { From 5023d56cec34702e63a62177d306d5eb6e70164b Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 21:58:17 +0100 Subject: [PATCH 10/21] feat(admin)!: stop registering plugin public routes and form endpoints; drop the createSubmissionManager re-export (#97, slice 4) Core's createDuneApp() owns both registrations (the admin's second call was a no-op), and the submission store is PluginRuntime.submissions. Lets core stop exporting registerPluginPublicRoutes, registerFormRoutes and createSubmissionManager. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 6 ++++++ src/admin/mod.ts | 1 - src/admin/mount.ts | 35 +++++++---------------------------- src/admin/submissions.ts | 4 ++-- 4 files changed, 15 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f0def..2201433 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,12 @@ follows [Semantic Versioning](https://semver.org). features (`fsRoutes`, `layout`, `route`), so `mount()` casts `app` to Fresh's `App` and the block editor declares `pageEditorHandler(ctx: FreshContext)`. No behaviour change. `deno task check` now also type-checks `mod.ts`, which it silently skipped. +- **`mountDuneAdmin()` no longer registers plugin public routes or the form endpoints, and the + `createSubmissionManager` re-export is gone (#97, slice 4).** Both were core's own work: `createDuneApp()` + registers plugins' `publicRoutes` and `/api/forms/*`, `/api/webhook/incoming` itself (the admin's second call + was a no-op there), and the submission store is `PluginRuntime.submissions`. The admin now imports neither + `registerPluginPublicRoutes` nor `registerFormRoutes`, which lets core stop exporting them. `mountDuneAdmin()` + now mounts only the panel. The `Submission*` types are still re-exported. ### Added diff --git a/src/admin/mod.ts b/src/admin/mod.ts index 43a9ef7..fd52666 100644 --- a/src/admin/mod.ts +++ b/src/admin/mod.ts @@ -39,7 +39,6 @@ export type { export { toUserInfo } from "./types.ts"; // Submissions -export { createSubmissionManager } from "./submissions.ts"; export type { Submission, SubmissionStatus, diff --git a/src/admin/mount.ts b/src/admin/mount.ts index 79baa1f..ef2c943 100644 --- a/src/admin/mount.ts +++ b/src/admin/mount.ts @@ -36,8 +36,6 @@ // deno-lint-ignore no-explicit-any import type { App, FreshContext, Middleware } from "fresh"; import type { BootstrapResult } from "@dune/core/bootstrap"; -import { registerPluginPublicRoutes } from "@dune/core/fresh-app"; -import { registerFormRoutes } from "@dune/core/forms"; import { checkPermission, type GuardedHandler, withGuards } from "./routes/api/_utils.ts"; import { getAdminContext } from "./context.ts"; import type { AdminPermission, AdminState } from "./types.ts"; @@ -199,16 +197,16 @@ function currentAdminContextOrNull(): import("./context.ts").AdminContext | null } /** - * Mount Dune's admin panel and public API routes onto an existing Fresh app. + * Mount Dune's admin panel onto an existing Fresh app. * * Registers: - * - Per-site admin context middleware (required for admin route handlers) — - * a no-op here if `mountDuneAdminEarly()` already registered it (see - * `contextMiddlewareRegisteredFor`). + * - Per-site admin context middleware (required for admin route handlers) * - Admin file-system routes under `adminPrefix` (default `/admin`) - * - Plugin admin pages (programmatic routes registered by plugins) - * - Plugin public routes (registered by plugins) - * - Public form and webhook API endpoints + * - The plugins' `admin.pages` and `admin.routes` + * + * It does not register plugins' `publicRoutes` or the public form and webhook + * endpoints: `createDuneApp()` in `@dune/core` owns those, and no longer + * exports the functions that registered them. * * @param app - The Fresh App instance to mount onto. * @param ctx - A fully-bootstrapped BootstrapResult from `bootstrap()`. @@ -224,26 +222,7 @@ export async function mountDuneAdmin( */ adminCtxOverride?: import("./context.ts").AdminContext, ): Promise { - const { config } = ctx; - const adminPrefix = config.admin?.path ?? "/admin"; mountAdminPanel(app, ctx, adminCtxOverride ?? currentAdminContextOrNull()); - - // ── Plugin public routes ──────────────────────────────────────────────────── - // @dune/core's own createDuneApp() registers these itself now (it collects - // BootstrapResult.pluginPublicRoutes in bootstrap.ts and wires them before - // its content catch-all) — this call only matters for the headless-mode - // path this file's own module docstring documents, where a developer calls - // bootstrap() + mountDuneAdmin() directly and never goes through - // createDuneApp() at all. Delegating to the same core function (rather than - // re-implementing the validation/shadowing logic here) keeps exactly one - // place that decides what a plugin route is allowed to do. - registerPluginPublicRoutes(app, ctx, { adminPrefix }); - - // ── Public form / webhook API ─────────────────────────────────────────────── - // Served by core, with or without the admin panel. createDuneApp() already - // registers them, so this only matters for the headless path above, and it - // is a no-op when they are already there. - registerFormRoutes(app, ctx); } /** diff --git a/src/admin/submissions.ts b/src/admin/submissions.ts index b8ff188..f03e185 100644 --- a/src/admin/submissions.ts +++ b/src/admin/submissions.ts @@ -3,10 +3,10 @@ * * The store and its types live in `@dune/core/forms` — core accepts public * form submissions itself, so a site works without this plugin. This module - * re-exports them for the submissions UI and for existing importers. + * re-exports the types for the submissions UI. The manager itself is + * `PluginRuntime.submissions`; there is no factory to import. */ -export { createSubmissionManager } from "@dune/core/forms"; export type { Submission, SubmissionFile, From c8cedbd052c5a0095ba4c866755b0df923edef46 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 22:20:49 +0100 Subject: [PATCH 11/21] feat(admin)!: remove the LDAP and SAML provider stubs (#97) Both authenticate() implementations threw, and mount() refused ldap/saml config anyway. Custom providers still go through the authProvider plugin option. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 10 +++ mod.ts | 8 +-- src/admin/auth/ldap-provider.ts | 99 -------------------------- src/admin/auth/provider.ts | 88 +---------------------- src/admin/auth/saml-provider.ts | 122 -------------------------------- src/admin/mod.ts | 5 -- 6 files changed, 15 insertions(+), 317 deletions(-) delete mode 100644 src/admin/auth/ldap-provider.ts delete mode 100644 src/admin/auth/saml-provider.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2201433..55dd784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,16 @@ follows [Semantic Versioning](https://semver.org). `onPageMove` is also a valid webhook event. Needs `@dune/core` with `onPageMove` (0.36). `tests/admin/page_events_test.ts` drives create, update, reorder, delete and restore through the real routes. +### Removed — BREAKING + +- **The LDAP and SAML auth providers (#97).** `LdapAuthProvider` and `SamlAuthProvider` were stubs whose + `authenticate()` threw, and `mount()` refused `auth_provider.type: ldap | saml` anyway. They are deleted, with + `LdapProviderConfig`, `SamlProviderConfig` and the admin's `AuthProviderConfig` union (core's is now just + `{ type: "local" }`). `AuthProvider.type` is a plain string. To use another identity source, pass your own + provider through the `authProvider` plugin option; the `AuthProvider` interface (`authenticate`, + `initiateLogin`, `handleCallback`) is unchanged. An unknown `auth_provider.type` now fails with a message + saying so. + ### Changed — BREAKING - **`mount()` reads the site from `MountApi.runtime`, not `MountApi.bootstrap` (#97).** Core's `mount()` diff --git a/mod.ts b/mod.ts index 76f70cd..965064e 100644 --- a/mod.ts +++ b/mod.ts @@ -196,14 +196,10 @@ export function createAdminPlugin( const provCfg = config.admin?.auth_provider; if (!provCfg || provCfg.type === "local") { authProvider = new LocalAuthProvider(users, services.passwords); - } else if (provCfg.type === "ldap" || provCfg.type === "saml") { - throw new Error( - `[dune] auth_provider.type "${provCfg.type}" is not implemented. ` + - `Set auth_provider.type to "local" or remove the section.`, - ); } else { throw new Error( - `[dune] auth_provider.type "${(provCfg as { type?: string }).type ?? ""}" is not recognized.`, + `[dune] auth_provider.type "${(provCfg as { type?: string }).type ?? ""}" is not supported. ` + + `Only "local" is built in; inject your own provider with the \`authProvider\` plugin option.`, ); } } diff --git a/src/admin/auth/ldap-provider.ts b/src/admin/auth/ldap-provider.ts deleted file mode 100644 index 68401c8..0000000 --- a/src/admin/auth/ldap-provider.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * LDAP authentication provider. - * - * ## Status: STUB — not yet implemented - * - * This class compiles and satisfies the AuthProvider interface but `authenticate()` - * throws a NotImplemented error. To implement LDAP authentication, replace the body of - * `authenticate()` with a proper LDAP bind flow using an LDAP client library. - * - * ## Suggested implementation using ldapts (npm:ldapts): - * - * ```typescript - * import { Client } from "npm:ldapts"; - * - * async authenticate(creds: AuthCredentials): Promise { - * const usernameAttr = this.config.username_attr ?? "uid"; - * const emailAttr = this.config.email_attr ?? "mail"; - * const nameAttr = this.config.name_attr ?? "cn"; - * const client = new Client({ url: this.config.url }); - * try { - * if (this.config.bind_dn) { - * // Service account bind, then search for the user entry - * const bindPw = expandEnv(this.config.bind_password ?? ""); - * await client.bind(this.config.bind_dn, bindPw); - * const { searchEntries } = await client.search(this.config.base_dn, { - * // IMPORTANT: escape username to prevent LDAP injection (CWE-90) - * // import { escape as ldapEscape } from "npm:ldapts"; - * filter: `(${usernameAttr}=${ldapEscape(creds.username)})`, - * attributes: ["dn", emailAttr, nameAttr, "memberOf"], - * }); - * if (!searchEntries.length) return null; - * const entry = searchEntries[0]; - * // Rebind as the located user to verify their password - * await client.bind(entry.dn as string, creds.password); - * const role = mapRole(entry["memberOf"] as string | string[] | undefined, this.config.role_map) - * ?? this.config.default_role - * ?? "author"; - * return { - * externalId: entry.dn as string, - * username: creds.username, - * email: entry[emailAttr] as string | undefined, - * name: entry[nameAttr] as string | undefined, - * roles: [role], - * }; - * } else { - * // Direct bind: construct user DN from template - * const userDn = `${usernameAttr}=${creds.username},${this.config.base_dn}`; - * await client.bind(userDn, creds.password); - * return { - * externalId: userDn, - * username: creds.username, - * roles: [this.config.default_role ?? "author"], - * }; - * } - * } catch { - * return null; // Bind failure = bad credentials - * } finally { - * await client.unbind(); - * } - * } - * - * // Expand "$ENV_VAR" references in config values - * function expandEnv(value: string): string { - * return value.replace(/^\$([A-Z_][A-Z0-9_]*)$/, (_, name) => Deno.env.get(name) ?? ""); - * } - * - * // Map LDAP group membership to a Dune role (first match wins) - * function mapRole( - * memberOf: string | string[] | undefined, - * roleMap: Array<{ group: string; role: Role }> | undefined, - * ): Role | undefined { - * if (!roleMap || !memberOf) return undefined; - * const groups = Array.isArray(memberOf) ? memberOf : [memberOf]; - * for (const { group, role } of roleMap) { - * if (groups.includes(group)) return role; - * } - * return undefined; - * } - * ``` - * - * ## Required deno.json import map entry: - * ```json - * { "npm:ldapts": "npm:ldapts@^4" } - * ``` - */ - -import type { AuthProvider, AuthCredentials, AuthProviderUser, LdapProviderConfig } from "./provider.ts"; - -export class LdapAuthProvider implements AuthProvider { - readonly type = "ldap" as const; - - constructor(private config: LdapProviderConfig) {} - - async authenticate(_creds: AuthCredentials): Promise { - throw new Error( - "LdapAuthProvider is a stub — see src/admin/auth/ldap-provider.ts for the implementation guide", - ); - } -} diff --git a/src/admin/auth/provider.ts b/src/admin/auth/provider.ts index 91bb467..2db260d 100644 --- a/src/admin/auth/provider.ts +++ b/src/admin/auth/provider.ts @@ -7,7 +7,6 @@ * @module */ -import type { Role } from "@dune/core/config"; /** * User attributes returned by an external auth provider after successful authentication. @@ -41,13 +40,14 @@ export interface AuthCredentials { /** * An authentication provider handles identity verification. - * Multiple provider types are supported; the session and user-provisioning layer is shared. + * The built-in provider is `local`; any other (LDAP, SAML, OIDC, ...) is injected through + * the plugin's `authProvider` option. The session and user-provisioning layer is shared. * * Username+password flows (local, LDAP) use `authenticate()`. * Redirect-based flows (SAML, OIDC) use `initiateLogin()` + `handleCallback()`. */ export interface AuthProvider { - readonly type: "local" | "ldap" | "saml" | "oidc"; + readonly type: string; /** * Verify credentials and return provider user attributes. @@ -69,85 +69,3 @@ export interface AuthProvider { handleCallback?(req: Request): Promise; } -/** Configuration union for all supported provider types. */ -export type AuthProviderConfig = - | { type: "local" } - | LdapProviderConfig - | SamlProviderConfig; - -/** LDAP authentication provider configuration. */ -export interface LdapProviderConfig { - type: "ldap"; - /** - * LDAP server URL. - * @example "ldap://ldap.example.com:389" - * @example "ldaps://ldap.example.com:636" - */ - url: string; - /** - * Base DN for user search. - * @example "ou=users,dc=example,dc=com" - */ - base_dn: string; - /** - * LDAP attribute containing the login name. - * Default: "sAMAccountName" (Active Directory) or "uid" (OpenLDAP). - */ - username_attr?: string; - /** - * Optional bind DN for a service account used to search the directory. - * If omitted, Dune attempts a direct bind using the user's constructed DN. - */ - bind_dn?: string; - /** - * Service account password. Supports "$ENV_VAR" expansion. - * @example "$LDAP_BIND_PASSWORD" - */ - bind_password?: string; - /** LDAP attribute to use as email. Default: "mail" */ - email_attr?: string; - /** LDAP attribute to use as display name. Default: "cn" */ - name_attr?: string; - /** - * Map LDAP group membership to Dune roles. First match wins. - * @example - * ```yaml - * roleMap: - * - group: "cn=cms-admins,ou=groups,dc=example,dc=com" - * role: "admin" - * - group: "cn=cms-editors,ou=groups,dc=example,dc=com" - * role: "editor" - * ``` - */ - role_map?: Array<{ group: string; role: Role }>; - /** Default role for authenticated users not in any mapped group. Default: "author" */ - default_role?: Role; -} - -/** SAML 2.0 authentication provider configuration. */ -export interface SamlProviderConfig { - type: "saml"; - /** Your application's Entity ID (SP entity ID). */ - entity_id: string; - /** - * Assertion Consumer Service URL — where the IdP POSTs the assertion after authentication. - * @example "https://example.com/admin/saml/acs" - */ - acs_url: string; - /** Identity provider metadata as a URL (fetched at startup) or inline XML. */ - idp_metadata: string; - /** SAML attribute containing the username. Default: "NameID" */ - username_attr?: string; - /** SAML attribute containing the email. Default: "email" or "mail" */ - email_attr?: string; - /** SAML attribute containing the display name. Default: "displayName" */ - name_attr?: string; - /** - * Map IdP group/role attribute values to Dune roles. First match wins. - */ - role_map?: Array<{ value: string; role: Role }>; - /** SAML attribute carrying the role or group value. Default: "role" or "groups" */ - role_attr?: string; - /** Default role for users with no matching roleMap entry. Default: "author" */ - default_role?: Role; -} diff --git a/src/admin/auth/saml-provider.ts b/src/admin/auth/saml-provider.ts deleted file mode 100644 index 107c9be..0000000 --- a/src/admin/auth/saml-provider.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * SAML 2.0 authentication provider. - * - * ## Status: STUB — not yet implemented - * - * `authenticate()` is not applicable for SAML (no direct credential flow). - * SAML uses `initiateLogin()` to start the SP-initiated SSO redirect and - * `handleCallback()` to process the IdP's POST response to the ACS endpoint. - * - * ## Suggested implementation using samlify (npm:samlify): - * - * ```typescript - * import * as saml from "npm:samlify"; - * - * // Build SP + IdP instances (ideally cached at construction time) - * private buildSp() { - * return saml.ServiceProvider({ - * entityID: this.config.entity_id, - * assertionConsumerService: [ - * { Binding: saml.Constants.namespace.binding.post, Location: this.config.acs_url }, - * ], - * }); - * } - * - * private buildIdp() { - * return saml.IdentityProvider({ metadata: this.config.idp_metadata }); - * } - * - * async initiateLogin(_req: Request): Promise { - * const sp = this.buildSp(); - * const idp = this.buildIdp(); - * const { context } = sp.createLoginRequest(idp, "redirect"); - * return context; // redirect URL with SAMLRequest query param - * } - * - * async handleCallback(req: Request): Promise { - * const body = await req.formData(); - * const samlResponse = body.get("SAMLResponse") as string | null; - * if (!samlResponse) return null; - * - * const sp = this.buildSp(); - * const idp = this.buildIdp(); - * try { - * const { extract } = await sp.parseLoginResponse(idp, "post", { - * body: { SAMLResponse: samlResponse }, - * }); - * - * // SECURITY: before trusting extract.nameID and extract.attributes, verify: - * // 1. InResponseTo matches the stored AuthnRequest ID (replay prevention) - * // 2. Audience restriction includes this SP's entity ID - * // 3. NotBefore / NotOnOrAfter time bounds are valid - * // samlify validates the signature but NOT these by default — check your version. - * - * const nameId: string = extract.nameID; - * const attrs = extract.attributes ?? {}; - * const usernameAttr = this.config.username_attr ?? "NameID"; - * const emailAttr = this.config.email_attr ?? "email"; - * const nameAttr = this.config.name_attr ?? "displayName"; - * const roleAttr = this.config.role_attr ?? "role"; - * const role = mapRole(attrs[roleAttr], this.config.role_map) - * ?? this.config.default_role - * ?? "author"; - * return { - * externalId: nameId, - * username: (attrs[usernameAttr] as string) ?? nameId, - * email: attrs[emailAttr] as string | undefined, - * name: attrs[nameAttr] as string | undefined, - * roles: [role], - * }; - * } catch { - * return null; // Signature verification or parsing failure - * } - * } - * - * function mapRole( - * value: string | string[] | undefined, - * roleMap: Array<{ value: string; role: Role }> | undefined, - * ): Role | undefined { - * if (!roleMap || value === undefined) return undefined; - * const values = Array.isArray(value) ? value : [value]; - * for (const { value: v, role } of roleMap) { - * if (values.includes(v)) return role; - * } - * return undefined; - * } - * ``` - * - * ## Required deno.json import map entry: - * ```json - * { "npm:samlify": "npm:samlify@^2" } - * ``` - * - * ## Required route registration in server.ts: - * Register a POST /admin/saml/acs route that calls `provider.handleCallback(req)` - * and then calls `findOrProvisionUser()` followed by the session creation flow. - */ - -import type { AuthProvider, AuthCredentials, AuthProviderUser, SamlProviderConfig } from "./provider.ts"; - -export class SamlAuthProvider implements AuthProvider { - readonly type = "saml" as const; - - constructor(private config: SamlProviderConfig) {} - - async authenticate(_creds: AuthCredentials): Promise { - throw new Error( - "SamlAuthProvider uses initiateLogin()/handleCallback() — direct credential authentication is not supported for SAML", - ); - } - - async initiateLogin(_req: Request): Promise { - throw new Error( - "SamlAuthProvider is a stub — see src/admin/auth/saml-provider.ts for the implementation guide", - ); - } - - async handleCallback(_req: Request): Promise { - throw new Error( - "SamlAuthProvider is a stub — see src/admin/auth/saml-provider.ts for the implementation guide", - ); - } -} diff --git a/src/admin/mod.ts b/src/admin/mod.ts index fd52666..876d911 100644 --- a/src/admin/mod.ts +++ b/src/admin/mod.ts @@ -18,13 +18,8 @@ export type { AuthProvider, AuthProviderUser, AuthCredentials, - AuthProviderConfig, - LdapProviderConfig, - SamlProviderConfig, } from "./auth/provider.ts"; export { LocalAuthProvider } from "./auth/local-provider.ts"; -export { LdapAuthProvider } from "./auth/ldap-provider.ts"; -export { SamlAuthProvider } from "./auth/saml-provider.ts"; export { findOrProvisionUser } from "./auth/provisioner.ts"; export type { From 11e886089a122331d5ebb4882eef53cb3ac9d258 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 08:19:35 +0100 Subject: [PATCH 12/21] refactor: drop the unused imageCache field, PluginRuntime no longer carries it Co-Authored-By: Claude Sonnet 5.5 --- mod.ts | 1 - src/admin/context.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/mod.ts b/mod.ts index 965064e..dcc3b50 100644 --- a/mod.ts +++ b/mod.ts @@ -310,7 +310,6 @@ export function createAdminPlugin( collab: collabManager, inlineEdit, contentEditor, - imageCache: runtime.imageCache, auditLogger: setupState.auditLogger ?? undefined, metrics: metricsEnabled ? runtime.metrics : undefined, mt: setupState.mt, diff --git a/src/admin/context.ts b/src/admin/context.ts index 80b47a7..7925887 100644 --- a/src/admin/context.ts +++ b/src/admin/context.ts @@ -23,7 +23,6 @@ import type { StagingEngine } from "@dune/core/staging"; import type { CommentManager } from "./comments.ts"; import type { CollabManager } from "../collab/mod.ts"; import type { InlineEditManager } from "@dune/core/inline-edit"; -import type { ImageCache } from "@dune/core/images"; import type { AuditLogger } from "@dune/core/audit"; import type { MetricsCollector } from "@dune/core/metrics"; import type { MachineTranslator } from "@dune/core/mt"; @@ -71,7 +70,6 @@ export interface AdminContext { inlineEdit?: InlineEditManager; /** Custom page editor, provided by a plugin as the `editor.content` capability. Replaces the built-in block editor. */ contentEditor?: ContentEditorPlugin; - imageCache?: ImageCache; auditLogger?: AuditLogger; metrics?: MetricsCollector; mt?: MachineTranslator | null; From 0c0665940ef2f3ec82f83ce123bfbb3f472f6981 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 23:03:24 +0100 Subject: [PATCH 13/21] feat(admin)!: read the runtime directory from services.paths, not admin.runtime_dir Core moved runtime_dir and max_revisions to system.* because its own history, job state and analytics use them. The admin takes the resolved value from services.paths.runtimeDir (webhook logs, email preview, CSRF secret, workflow and page events). /api/introspect reports pageCacheEnabled: core no longer has system.cache. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 10 ++++++++++ mod.ts | 5 ++--- src/admin/auth/csrf.ts | 2 +- src/admin/comments.ts | 4 ++-- src/admin/page-events.ts | 2 +- src/admin/routes/api/email-preview.ts | 2 +- src/admin/routes/api/email-preview/[id].ts | 2 +- src/admin/routes/api/introspect.ts | 3 +-- src/admin/routes/api/pages/[...path]/index.ts | 4 ++-- src/admin/routes/api/pages/index.ts | 2 +- src/admin/routes/api/webhooks/deliveries.ts | 3 +-- src/admin/routes/email-preview/index.tsx | 2 +- src/admin/webhooks.ts | 4 ++-- src/admin/workflow-actions.ts | 2 +- tests/admin/webhook_deliveries_test.ts | 3 ++- tests/admin/workflow_actions_test.ts | 2 +- 16 files changed, 30 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55dd784..62d9dcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,16 @@ follows [Semantic Versioning](https://semver.org). `onPageMove` is also a valid webhook event. Needs `@dune/core` with `onPageMove` (0.36). `tests/admin/page_events_test.ts` drives create, update, reorder, delete and restore through the real routes. +### Changed — BREAKING (admin config move) + +- **The runtime directory comes from core, not from `admin.runtime_dir`.** `runtime_dir` (and + `max_revisions`) are `system.runtime_dir` / `system.max_revisions` in `@dune/core` 0.36: core's history, + job state, analytics and public sessions use them too. The admin reads `services.paths.runtimeDir` everywhere + it used `config.admin.runtime_dir` (webhook delivery logs, email preview, CSRF secret, workflow and page + events), so a site that sets `runtime_dir` keeps one directory for core and the admin. Needs `@dune/core` 0.36. +- **`/api/introspect` reports `pageCacheEnabled`** (from `system.page_cache`) instead of `cacheEnabled` and + `cacheDriver`: core 0.36 no longer has a `system.cache` block. + ### Removed — BREAKING - **The LDAP and SAML auth providers (#97).** `LdapAuthProvider` and `SamlAuthProvider` were stubs whose diff --git a/mod.ts b/mod.ts index dcc3b50..4af921a 100644 --- a/mod.ts +++ b/mod.ts @@ -109,7 +109,6 @@ export function createAdminPlugin( const adminCfg = config.admin ?? { path: "/admin", session_lifetime: 86400, - runtime_dir: ".dune/admin", enabled: true, }; if (adminCfg.enabled === false) { @@ -150,10 +149,10 @@ export function createAdminPlugin( const adminCfg = config.admin ?? { path: "/admin", session_lifetime: 86400, - runtime_dir: ".dune/admin", enabled: true, }; - const runtimeDir = adminCfg.runtime_dir ?? ".dune/admin"; + // `system.runtime_dir`, as core resolved it. + const runtimeDir = services.paths.runtimeDir; // `system.data_dir` — the same resolution core uses for the submissions it stores. const dataDir = services.paths.dataDir; diff --git a/src/admin/auth/csrf.ts b/src/admin/auth/csrf.ts index 0141486..6b1e73d 100644 --- a/src/admin/auth/csrf.ts +++ b/src/admin/auth/csrf.ts @@ -43,7 +43,7 @@ export function resolveCsrfSecret(state: AdminState | undefined): string | null const injected = state.adminContext.csrfSecret; if (typeof injected === "string" && injected.length > 0) return injected; - const runtimeDir = state.adminContext.config?.admin?.runtime_dir; + const runtimeDir = state.adminContext.services?.paths?.runtimeDir; if (typeof runtimeDir !== "string" || runtimeDir.length === 0) return null; return loadOrCreateCsrfSecret(runtimeDir); } diff --git a/src/admin/comments.ts b/src/admin/comments.ts index 385b916..332ac58 100644 --- a/src/admin/comments.ts +++ b/src/admin/comments.ts @@ -9,7 +9,7 @@ * data/comments/{encodedPath}.json → { comments: PageComment[] } * encodedPath: sourcePath with "/" → "__" and "." → "-dot-" * - * Mention read-state (inside admin.runtime_dir — gitignored, machine-local): + * Mention read-state (inside system.runtime_dir — gitignored, machine-local): * .dune/admin/mention-reads/{username}.json → { readIds: string[] } * * All public methods are async to allow future storage adapter swapping. @@ -65,7 +65,7 @@ interface MentionReadState { export interface CommentManagerOptions { /** admin.data_dir — git-tracked persistent storage */ dataDir: string; - /** admin.runtime_dir — ephemeral machine-local storage */ + /** system.runtime_dir — ephemeral machine-local storage */ runtimeDir: string; } diff --git a/src/admin/page-events.ts b/src/admin/page-events.ts index 31e27b3..b01e55d 100644 --- a/src/admin/page-events.ts +++ b/src/admin/page-events.ts @@ -33,7 +33,7 @@ export function announcePageEvent(ctx: FreshContext, e: PageEvent): config.admin?.webhooks ?? [], e.event, e.payload, - config.admin?.runtime_dir ?? ".dune/admin", + services.paths.runtimeDir, services.security, ); } diff --git a/src/admin/routes/api/email-preview.ts b/src/admin/routes/api/email-preview.ts index 51e4fc4..aa836a7 100644 --- a/src/admin/routes/api/email-preview.ts +++ b/src/admin/routes/api/email-preview.ts @@ -14,7 +14,7 @@ export const handler = { return json({ error: "Email preview is only available in development mode" }, 404); } - const runtimeDir = ctx.state.adminContext.config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; const devEmailDir = join(runtimeDir, "dev-email"); const entries: Record[] = []; diff --git a/src/admin/routes/api/email-preview/[id].ts b/src/admin/routes/api/email-preview/[id].ts index baef1a0..1852be3 100644 --- a/src/admin/routes/api/email-preview/[id].ts +++ b/src/admin/routes/api/email-preview/[id].ts @@ -19,7 +19,7 @@ export const handler = { return json({ error: "Invalid id" }, 400); } - const runtimeDir = ctx.state.adminContext.config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; const filePath = join(runtimeDir, "dev-email", `${id}.json`); try { diff --git a/src/admin/routes/api/introspect.ts b/src/admin/routes/api/introspect.ts index 7bc9fb3..cfba004 100644 --- a/src/admin/routes/api/introspect.ts +++ b/src/admin/routes/api/introspect.ts @@ -109,8 +109,7 @@ export const handler = { supported: config.system.languages?.supported ?? [], default: config.system.languages?.default ?? "en", }, - cacheEnabled: config.system.cache?.enabled ?? false, - cacheDriver: config.system.cache?.driver ?? "memory", + pageCacheEnabled: config.system.page_cache?.enabled ?? false, debugMode: config.system.debug ?? false, authMode: ((config as unknown as Record).auth as Record | undefined)?.mode ?? null, feedEnabled: config.site.feed?.enabled !== false, diff --git a/src/admin/routes/api/pages/[...path]/index.ts b/src/admin/routes/api/pages/[...path]/index.ts index 638de1c..5c9f8fd 100644 --- a/src/admin/routes/api/pages/[...path]/index.ts +++ b/src/admin/routes/api/pages/[...path]/index.ts @@ -130,7 +130,7 @@ export const handler = { await maybeGitCommit(filePath, pagePath, undefined, config); const webhookEndpoints = config.admin?.webhooks ?? []; - const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; if (hooks) hooks.fire("onPageUpdate", { sourcePath: page.sourcePath }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onPageUpdate", { sourcePath: page.sourcePath }, runtimeDir, ctx.state.adminContext.services.security); @@ -172,7 +172,7 @@ export const handler = { await engine.rebuild(); const webhookEndpoints = config.admin?.webhooks ?? []; - const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; if (hooks) hooks.fire("onPageDelete", { sourcePath: page.sourcePath }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onPageDelete", { sourcePath: page.sourcePath }, runtimeDir, ctx.state.adminContext.services.security); diff --git a/src/admin/routes/api/pages/index.ts b/src/admin/routes/api/pages/index.ts index 291490a..6fcd686 100644 --- a/src/admin/routes/api/pages/index.ts +++ b/src/admin/routes/api/pages/index.ts @@ -68,7 +68,7 @@ export const handler = { await engine.rebuild(); const webhookEndpoints = config.admin?.webhooks ?? []; - const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; if (hooks) hooks.fire("onPageCreate", { sourcePath: `${pagePath}/default${ext}`, title }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onPageCreate", { sourcePath: `${pagePath}/default${ext}`, title }, runtimeDir, ctx.state.adminContext.services.security); diff --git a/src/admin/routes/api/webhooks/deliveries.ts b/src/admin/routes/api/webhooks/deliveries.ts index 504e54e..1100bb0 100644 --- a/src/admin/routes/api/webhooks/deliveries.ts +++ b/src/admin/routes/api/webhooks/deliveries.ts @@ -32,9 +32,8 @@ export const handler = { return json({ error: "invalid path" }, 400); } - const { config } = ctx.state.adminContext; try { - const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; const logs = await listDeliveryLogs(runtimeDir, { sourcePath: path }); const items = view === "workflow" ? logs.map(toWorkflowDelivery) : logs; return json({ items, total: items.length, view }); diff --git a/src/admin/routes/email-preview/index.tsx b/src/admin/routes/email-preview/index.tsx index c25e7c5..7492b4d 100644 --- a/src/admin/routes/email-preview/index.tsx +++ b/src/admin/routes/email-preview/index.tsx @@ -116,7 +116,7 @@ export default function EmailPreviewPage({ data }: { data: PageData }) { export const handler = { async GET(ctx: FreshContext): Promise { const { prefix } = ctx.state.adminContext; - const runtimeDir = ctx.state.adminContext.config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; if (Deno.env.get("DUNE_ENV") !== "dev") { return ctx.render( diff --git a/src/admin/webhooks.ts b/src/admin/webhooks.ts index ab36545..3a2f814 100644 --- a/src/admin/webhooks.ts +++ b/src/admin/webhooks.ts @@ -268,7 +268,7 @@ async function deliverWithRetry( * @param endpoints - Configured webhook endpoints (from admin.webhooks) * @param event - The content event that occurred * @param payload - Event-specific data included in the POST body - * @param logDir - Base directory for delivery logs (admin.runtime_dir) + * @param logDir - Base directory for delivery logs (system.runtime_dir) * @param security - Core's security service (SSRF-guarded fetch) */ export function fireContentWebhooks( @@ -308,7 +308,7 @@ export interface ListDeliveryLogsOptions { * Reads logs from the last 7 days of log directories and returns up to `limit` * records. Silently returns an empty array if the log directory doesn't exist. * - * @param logDir - Base directory (admin.runtime_dir) + * @param logDir - Base directory (system.runtime_dir) * @param limitOrOpts - Maximum records, or `{ limit, sourcePath }` */ export async function listDeliveryLogs( diff --git a/src/admin/workflow-actions.ts b/src/admin/workflow-actions.ts index 1ee9606..84871a3 100644 --- a/src/admin/workflow-actions.ts +++ b/src/admin/workflow-actions.ts @@ -76,7 +76,7 @@ export async function applyWorkflowTransition( await engine.rebuild(); const webhookEndpoints = config.admin?.webhooks ?? []; - const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = services.paths.runtimeDir; if (hooks) hooks.fire("onWorkflowChange", { sourcePath, from: currentStatus, to: newStatus }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onWorkflowChange", { sourcePath, from: currentStatus, to: newStatus }, runtimeDir, services.security); diff --git a/tests/admin/webhook_deliveries_test.ts b/tests/admin/webhook_deliveries_test.ts index aa924c6..ef891d0 100644 --- a/tests/admin/webhook_deliveries_test.ts +++ b/tests/admin/webhook_deliveries_test.ts @@ -49,7 +49,8 @@ function makeCtx( state: { adminContext: { auditLogger: null, - config: { admin: { runtime_dir: opts.runtimeDir ?? ".dune/admin" } }, + config: {}, + services: { paths: { runtimeDir: opts.runtimeDir ?? ".dune/admin" } }, // Distinguishes which specific permission the mock holds — same // role this fixture's ROLE_PERMISSIONS-backed hasPermission() used // to play, sourced from authz.check() instead (removed in 3.0.0; diff --git a/tests/admin/workflow_actions_test.ts b/tests/admin/workflow_actions_test.ts index c6340ab..b491b96 100644 --- a/tests/admin/workflow_actions_test.ts +++ b/tests/admin/workflow_actions_test.ts @@ -48,7 +48,7 @@ function makePageIndex(overrides: Partial = {}): PageIndex { const CONFIG = { system: { content: { dir: "content" } }, - admin: { webhooks: [], runtime_dir: ".dune/admin" }, + admin: { webhooks: [] }, } as unknown as DuneConfig; function makeEngine(pages: PageIndex[]) { From 462fe1f5d452e4ce01b606b8413101f6c34364b6 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 23:19:33 +0100 Subject: [PATCH 14/21] feat(admin)!: provide the admin.prefix capability Core stops reading admin.path and learns where the admin is mounted from this slot. With the admin disabled nothing is provided and core treats /admin as an ordinary path. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 3 +++ mod.ts | 4 ++++ tests/admin/capabilities_e2e_test.ts | 30 ++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62d9dcc..c262192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ follows [Semantic Versioning](https://semver.org). job state, analytics and public sessions use them too. The admin reads `services.paths.runtimeDir` everywhere it used `config.admin.runtime_dir` (webhook delivery logs, email preview, CSRF secret, workflow and page events), so a site that sets `runtime_dir` keeps one directory for core and the admin. Needs `@dune/core` 0.36. +- **The admin provides the `admin.prefix` capability** (`{ prefix: admin.path ?? "/admin" }`), the way core learns + where the admin is mounted. Core no longer reads `admin.path` or assumes `/admin`: with no admin installed, `/admin` + is an ordinary content path. Needs `@dune/core` 0.36 (#123, step 2). - **`/api/introspect` reports `pageCacheEnabled`** (from `system.page_cache`) instead of `cacheEnabled` and `cacheDriver`: core 0.36 no longer has a `system.cache` block. diff --git a/mod.ts b/mod.ts index 4af921a..ea025b5 100644 --- a/mod.ts +++ b/mod.ts @@ -124,6 +124,10 @@ export function createAdminPlugin( // What this plugin provides to core. Core reads them at request time // (the admin bar, the inline-edit WebSocket, `dune serve`'s scheduler // loop); until mount() has built the context they report "nobody". + // Where this admin is mounted: core keeps these paths out of the page + // cache, the static build and the plugin-route namespace. The default + // `/admin` lives here, not in core. + api.capabilities.provide("admin.prefix", () => ({ prefix: adminCfg.path ?? "/admin" })); api.capabilities.provide("auth.session", () => ({ // The cookie the admin's auth middleware sets (see createAuthMiddleware). // Core uses it to decide whether to authenticate a public page and whether diff --git a/tests/admin/capabilities_e2e_test.ts b/tests/admin/capabilities_e2e_test.ts index 16085e0..d199bfc 100644 --- a/tests/admin/capabilities_e2e_test.ts +++ b/tests/admin/capabilities_e2e_test.ts @@ -44,6 +44,36 @@ Deno.test( }, ); +Deno.test( + "admin provides admin.prefix: /admin by default, admin.path when set", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + await withSite([], (ctx) => { + assertEquals(ctx.capabilities.get("admin.prefix"), { prefix: "/admin" }); + const providers = Object.fromEntries(ctx.capabilities.providers().map((p) => [p.name, p.provider])); + assertEquals(providers["admin.prefix"], "dune-admin"); + return Promise.resolve(); + }); + + const root = await Deno.makeTempDir({ prefix: "dune_admin_prefix_" }); + try { + await Deno.mkdir(join(root, "content", "01.home"), { recursive: true }); + await Deno.writeTextFile(join(root, "content", "01.home", "default.md"), "---\ntitle: Home\n---\n"); + await Deno.mkdir(join(root, "config"), { recursive: true }); + await Deno.writeTextFile(join(root, "config", "system.yaml"), "admin:\n path: /cms\n"); + const ctx = await bootstrap(root, {}); + const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); + assertEquals(ctx.capabilities.get("admin.prefix"), { prefix: "/cms" }); + // The panel answers at the provided prefix, and /admin is just a missing content page. + const handler = app.handler(); + assertEquals((await handler(new Request("http://localhost/admin/"))).status, 404); + assertEquals((await handler(new Request("http://localhost/cms/login"))).status !== 404, true); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } + }, +); + Deno.test( "admin mounts admin.pages contributed by another plugin, behind its login", { sanitizeOps: false, sanitizeResources: false }, From fda8ea3b18456099181566767e362e6a67c1ad83 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 23:48:08 +0100 Subject: [PATCH 15/21] feat(admin)!: own the audit trail and machine translation AuditLogger, the audit types and the DeepL/Google/LibreTranslate translators move here from @dune/core (the @dune/core/audit and @dune/core/mt entry points are gone). The admin builds both in setup(); translators take the fetch they use, and the admin passes services.security.safeFetch so core's SSRF policy still applies. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 10 ++ deno.json | 4 +- mod.ts | 11 ++- src/admin/context.ts | 4 +- src/admin/routes/api/_utils.ts | 2 +- src/admin/routes/api/audit.ts | 2 +- src/admin/routes/audit/index.tsx | 4 +- src/audit/logger.ts | 157 +++++++++++++++++++++++++++++++ src/audit/mod.ts | 24 +++++ src/audit/trail.ts | 37 ++++++++ src/audit/types.ts | 91 ++++++++++++++++++ src/mt/deepl.ts | 72 ++++++++++++++ src/mt/factory.ts | 41 ++++++++ src/mt/google.ts | 47 +++++++++ src/mt/libretranslate.ts | 69 ++++++++++++++ src/mt/mod.ts | 11 +++ src/mt/types.ts | 38 ++++++++ tests/audit_trail_test.ts | 47 +++++++++ tests/mt/ssrf_test.ts | 37 ++++++++ 19 files changed, 697 insertions(+), 11 deletions(-) create mode 100644 src/audit/logger.ts create mode 100644 src/audit/mod.ts create mode 100644 src/audit/trail.ts create mode 100644 src/audit/types.ts create mode 100644 src/mt/deepl.ts create mode 100644 src/mt/factory.ts create mode 100644 src/mt/google.ts create mode 100644 src/mt/libretranslate.ts create mode 100644 src/mt/mod.ts create mode 100644 src/mt/types.ts create mode 100644 tests/audit_trail_test.ts create mode 100644 tests/mt/ssrf_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c262192..20a4b6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,16 @@ follows [Semantic Versioning](https://semver.org). `onPageMove` is also a valid webhook event. Needs `@dune/core` with `onPageMove` (0.36). `tests/admin/page_events_test.ts` drives create, update, reorder, delete and restore through the real routes. +### Changed — BREAKING (machine translation and the audit trail) + +- **The admin owns the audit trail and machine translation (#123, step 3).** `AuditLogger`, the audit types, the + DeepL / Google / LibreTranslate translators and their types moved here from `@dune/core` (`@dune/core/audit` and + `@dune/core/mt` are gone); they are exported as `@dune/plugin-admin/audit` and `@dune/plugin-admin/mt`. The admin + builds both in `setup()`: the trail from `admin.audit` and `services.paths.runtimeDir` (a `log_file` outside the + site root is still refused), the translator from `site.machine_translation`. Translators take the fetch they use as + a constructor argument and the admin passes `services.security.safeFetch`, so core's SSRF policy still applies. + Needs `@dune/core` 0.36. + ### Changed — BREAKING (admin config move) - **The runtime directory comes from core, not from `admin.runtime_dir`.** `runtime_dir` (and diff --git a/deno.json b/deno.json index 1b12b19..9512072 100644 --- a/deno.json +++ b/deno.json @@ -15,7 +15,9 @@ "./admin/types": "./src/admin/types.ts", "./admin/guards": "./src/admin/guards.ts", "./admin/auth/middleware": "./src/admin/auth/middleware.ts", - "./admin/auth/provider": "./src/admin/auth/provider.ts" + "./admin/auth/provider": "./src/admin/auth/provider.ts", + "./audit": "./src/audit/mod.ts", + "./mt": "./src/mt/mod.ts" }, "imports": { "@dune/testing": "jsr:@dune/testing@^1", diff --git a/mod.ts b/mod.ts index ea025b5..30791c2 100644 --- a/mod.ts +++ b/mod.ts @@ -28,8 +28,8 @@ import type { ScheduledAction } from "@dune/core/workflow"; import { applyWorkflowTransition, SCHEDULED_ACTION_STATUS } from "./src/admin/workflow-actions.ts"; import { createCommentManager } from "./src/admin/comments.ts"; import { createCollabManager } from "./src/collab/mod.ts"; -import type { MachineTranslator } from "@dune/core/mt"; -import type { AuditLogger } from "@dune/core/audit"; +import { createMachineTranslator, type MachineTranslationConfig, type MachineTranslator } from "./src/mt/mod.ts"; +import { type AuditLogger, createAuditTrail } from "./src/audit/mod.ts"; import type { DuneAuthSystem } from "@dune/core/auth/authz"; import type { AuthzLocalAdapter } from "@dune/core/auth/authz-adapter-local"; import type { AuthzDbAdapter } from "@dune/core/auth/authz-adapter-db"; @@ -116,8 +116,11 @@ export function createAdminPlugin( return; } - // Audit trail and machine translator are built by core. - const { audit: auditLogger, mt } = api.services.engines; + // The audit trail and the machine translator are the admin's own. Translator + // requests go through core's SSRF-checked fetch. + const auditLogger = createAuditTrail({ config, root: opts.root, runtimeDir: api.services.paths.runtimeDir }); + const mtConfig = (config.site as { machine_translation?: MachineTranslationConfig }).machine_translation; + const mt = mtConfig ? createMachineTranslator(mtConfig, api.services.security.safeFetch) : null; setupState = { config, storage, opts, auditLogger, mt }; diff --git a/src/admin/context.ts b/src/admin/context.ts index 7925887..a62fc2f 100644 --- a/src/admin/context.ts +++ b/src/admin/context.ts @@ -23,9 +23,9 @@ import type { StagingEngine } from "@dune/core/staging"; import type { CommentManager } from "./comments.ts"; import type { CollabManager } from "../collab/mod.ts"; import type { InlineEditManager } from "@dune/core/inline-edit"; -import type { AuditLogger } from "@dune/core/audit"; +import type { AuditLogger } from "../audit/mod.ts"; import type { MetricsCollector } from "@dune/core/metrics"; -import type { MachineTranslator } from "@dune/core/mt"; +import type { MachineTranslator } from "../mt/mod.ts"; import type { RateLimitStore } from "@dune/core/security"; import type { PluginServices } from "@dune/core/plugin-api"; import type { DuneAuthSystem } from "@dune/core/auth/authz"; diff --git a/src/admin/routes/api/_utils.ts b/src/admin/routes/api/_utils.ts index 56236ba..0436d36 100644 --- a/src/admin/routes/api/_utils.ts +++ b/src/admin/routes/api/_utils.ts @@ -304,7 +304,7 @@ export function actorFromAuth( authResult: { user?: { id: string; username?: string; name?: string } | null; }, -): import("@dune/core/audit").AuditActor | null { +): import("../../../audit/mod.ts").AuditActor | null { if (!authResult.user) return null; const username = authResult.user.username ?? authResult.user.id; return { diff --git a/src/admin/routes/api/audit.ts b/src/admin/routes/api/audit.ts index c7f2cd1..f90c23f 100644 --- a/src/admin/routes/api/audit.ts +++ b/src/admin/routes/api/audit.ts @@ -2,7 +2,7 @@ import type { AdminState } from "../../types.ts"; import { json } from "./_utils.ts"; -import type { AuditQuery, AuditEventType } from "@dune/core/audit"; +import type { AuditQuery, AuditEventType } from "../../../audit/mod.ts"; import type { FreshContext } from "fresh"; export const handler = { diff --git a/src/admin/routes/audit/index.tsx b/src/admin/routes/audit/index.tsx index 639a363..3a85be9 100644 --- a/src/admin/routes/audit/index.tsx +++ b/src/admin/routes/audit/index.tsx @@ -17,9 +17,9 @@ export const handler = { ); } const eventFilter = ctx.url.searchParams.get("event") ?? ""; - const q: import("@dune/core/audit").AuditQuery = { limit: 50 }; + const q: import("../../../audit/mod.ts").AuditQuery = { limit: 50 }; if (eventFilter) { - q.event = eventFilter as import("@dune/core/audit").AuditEventType; + q.event = eventFilter as import("../../../audit/mod.ts").AuditEventType; } const result = await auditLogger.query(q); return ctx.render( diff --git a/src/audit/logger.ts b/src/audit/logger.ts new file mode 100644 index 0000000..d35d72c --- /dev/null +++ b/src/audit/logger.ts @@ -0,0 +1,157 @@ +import { dirname, basename, extname, join } from "@std/path"; +import { ensureDir } from "@std/fs"; +import type { + AuditEntry, + AuditLogOptions, + AuditQuery, + AuditQueryResult, +} from "./types.ts"; + +const SHARD_RE = /^(\d{4}-\d{2}-\d{2})\.jsonl$/; + +/** + * AuditLogger — appends entries to daily-rotated JSONL files. + * + * Shards are written to `{dir}/{basename}/YYYY-MM-DD.jsonl`, where `{dir}` and + * `{basename}` are derived from `logFile`. Queries enumerate only the shards + * whose date falls within `from`/`to` (or all shards when the range is open). + * The legacy single-file layout (just `logFile`) is read as a fallback so an + * upgrade from pre-sharding installs doesn't lose history. + */ +export class AuditLogger { + private readonly logFile: string; // legacy path (fallback on read) + private readonly shardDir: string; // directory holding daily shards + private readonly maxQueryEntries: number; + + constructor(opts: AuditLogOptions) { + this.logFile = opts.logFile; + const ext = extname(this.logFile) || ".log"; + const stem = basename(this.logFile, ext); + this.shardDir = join(dirname(this.logFile), stem); + this.maxQueryEntries = opts.maxQueryEntries ?? 1000; + } + + private ready: Promise | null = null; + + /** Initialize — ensure shard directory exists. Idempotent; `log()` calls it on first use. */ + init(): Promise { + return this.ready ??= ensureDir(this.shardDir).then(() => undefined, (err) => { + this.ready = null; + throw err; + }); + } + + /** Write an audit entry to today's shard */ + async log(entry: Omit): Promise { + await this.init(); + const ts = new Date(); + const full: AuditEntry = { + id: crypto.randomUUID(), + ts: ts.toISOString(), + ...entry, + }; + const line = JSON.stringify(full) + "\n"; + const shardPath = join(this.shardDir, `${ts.toISOString().slice(0, 10)}.jsonl`); + await Deno.writeTextFile(shardPath, line, { append: true }); + } + + /** Query entries — reads only shards within the requested date range */ + async query(q: AuditQuery = {}): Promise { + const shards = await this.listShards(q.from, q.to); + + const fromMs = q.from ? new Date(q.from).getTime() : -Infinity; + const toMs = q.to ? new Date(q.to).getTime() : Infinity; + + // Accumulate newest-first across shards; stop once we have enough to + // satisfy maxQueryEntries after all filters (conservative cap: read + // enough candidate lines before other filters narrow further). + const collected: AuditEntry[] = []; + const entryMatches = (e: AuditEntry): boolean => { + if (q.event && e.event !== q.event) return false; + if (q.actorId && e.actor?.userId !== q.actorId) return false; + if (q.outcome && e.outcome !== q.outcome) return false; + const ems = new Date(e.ts).getTime(); + if (ems < fromMs || ems > toMs) return false; + return true; + }; + + // Walk newest shard first. + for (const shard of shards) { + const text = await readTextIfExists(shard); + if (!text) continue; + const lines = text.split("\n"); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (!line) continue; + let entry: AuditEntry; + try { + entry = JSON.parse(line) as AuditEntry; + } catch { + continue; // tolerate a partial write at end-of-file + } + if (!entryMatches(entry)) continue; + collected.push(entry); + if (collected.length >= this.maxQueryEntries) break; + } + if (collected.length >= this.maxQueryEntries) break; + } + + const total = collected.length; + const limit = Math.min(q.limit ?? 50, 200); + const offset = q.offset ?? 0; + const entries = collected.slice(offset, offset + limit); + return { entries, total }; + } + + /** + * Enumerate shard paths that fall within [from, to], newest first. + * Always includes the legacy single-file path at the end of the list when + * it exists, so migrations don't drop old entries. + */ + private async listShards(from?: string, to?: string): Promise { + const fromDate = from ? from.slice(0, 10) : null; + const toDate = to ? to.slice(0, 10) : null; + + const dates: string[] = []; + try { + for await (const entry of Deno.readDir(this.shardDir)) { + if (!entry.isFile) continue; + const m = entry.name.match(SHARD_RE); + if (!m) continue; + const date = m[1]; + if (fromDate && date < fromDate) continue; + if (toDate && date > toDate) continue; + dates.push(date); + } + } catch { + // Shard dir missing — fall through to legacy file only. + } + + dates.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0)); // newest first + const shardPaths = dates.map((d) => join(this.shardDir, `${d}.jsonl`)); + + // Include legacy single-file log if still present — oldest-seeming, so + // it's queried last and its entries land at the tail. + if (await exists(this.logFile)) { + shardPaths.push(this.logFile); + } + return shardPaths; + } +} + +async function readTextIfExists(path: string): Promise { + try { + return await Deno.readTextFile(path); + } catch { + return null; + } +} + +async function exists(path: string): Promise { + try { + await Deno.stat(path); + return true; + } catch { + return false; + } +} diff --git a/src/audit/mod.ts b/src/audit/mod.ts new file mode 100644 index 0000000..dfa8d1b --- /dev/null +++ b/src/audit/mod.ts @@ -0,0 +1,24 @@ +/** + * Audit logging module. + * Re-exports all public types and the AuditLogger class. + * + * @module + */ + +export type { + AuditActor, + AuditTarget, + AuditEventType, + AuditEntry, + AuditLogOptions, + AuditQuery, + AuditQueryResult, +} from "./types.ts"; + +export type { AuditTrailOptions } from "./trail.ts"; +export { createAuditTrail } from "./trail.ts"; + +export { + /** Appends entries to daily-rotated JSONL files, sharded by date. */ + AuditLogger, +} from "./logger.ts"; diff --git a/src/audit/trail.ts b/src/audit/trail.ts new file mode 100644 index 0000000..13c28ec --- /dev/null +++ b/src/audit/trail.ts @@ -0,0 +1,37 @@ +/** + * Build the audit trail for a site, or null when auditing is off. + * + * @module + */ + +import { join } from "@std/path"; +import type { DuneConfig } from "@dune/core/config"; +import { AuditLogger } from "./logger.ts"; + +export interface AuditTrailOptions { + config: DuneConfig; + /** Absolute site root. Without it there is no place to put the log, so no trail. */ + root: string | undefined; + /** Site-relative runtime directory (`services.paths.runtimeDir`). */ + runtimeDir: string; +} + +/** + * The trail is on unless `admin.enabled` or `admin.audit.enabled` is false. The + * log file must resolve under the site root: a configured `log_file` that + * escapes it is refused. + */ +export function createAuditTrail(opts: AuditTrailOptions): AuditLogger | null { + const { config, root, runtimeDir } = opts; + const audit = config.admin?.audit; + if (config.admin?.enabled === false || audit?.enabled === false || !root) return null; + const logFile = audit?.log_file ? join(root, audit.log_file) : join(root, runtimeDir, "audit.log"); + const containmentRoot = root.endsWith("/") || root.endsWith("\\") ? root : root + "/"; + if (!logFile.startsWith(containmentRoot)) { + throw new Error( + `[dune] admin.audit.log_file must resolve under the site root. ` + + `Got: ${audit?.log_file} -> ${logFile}`, + ); + } + return new AuditLogger({ logFile }); +} diff --git a/src/audit/types.ts b/src/audit/types.ts new file mode 100644 index 0000000..3da1282 --- /dev/null +++ b/src/audit/types.ts @@ -0,0 +1,91 @@ +/** + * Audit logging type definitions. + */ + +/** Actor performing the action */ +export interface AuditActor { + userId: string; + username: string; + name: string; +} + +/** Target resource */ +export interface AuditTarget { + type: + | "page" + | "user" + | "config" + | "plugin" + | "theme" + | "media" + | "flex" + | "form" + | "system" + | "comment" + /** + * Used by `logAuthzDenial()` (MED-4) when the denial is keyed to an + * HTTP route rather than a content target. `id` carries the request + * pathname. + */ + | "route"; + /** Human-readable identifier (sourcePath for pages, userId for users, etc.) */ + id?: string; +} + +/** All audit event types */ +export type AuditEventType = + // Auth + | "auth.login" | "auth.logout" | "auth.login_failed" + | "auth.csrf_denied" | "auth.permission_denied" + // Pages + | "page.create" | "page.update" | "page.delete" | "page.publish" | "page.workflow" + // Config + | "config.update" + // Users + | "user.create" | "user.update" | "user.delete" | "user.password" + // Media + | "media.upload" | "media.delete" + // Plugins + | "plugin.config_update" + // Flex + | "flex.create" | "flex.update" | "flex.delete" + // System + | "system.rebuild" | "system.cache_purge"; + +/** A single audit log entry */ +export interface AuditEntry { + id: string; // crypto.randomUUID() + ts: string; // ISO 8601 + event: AuditEventType; + actor: AuditActor | null; // null for system/unauthenticated actions + ip: string | null; + userAgent: string | null; + target: AuditTarget | null; + detail: Record; // event-specific extra info + outcome: "success" | "failure"; +} + +/** Options for creating an AuditLogger */ +export interface AuditLogOptions { + /** Path to the JSONL log file (e.g. ".dune/admin/audit.log") */ + logFile: string; + /** Maximum entries returned by query() (default: 1000) */ + maxQueryEntries?: number; +} + +/** Query parameters for AuditLogger.query() */ +export interface AuditQuery { + limit?: number; + offset?: number; + event?: AuditEventType; + actorId?: string; + from?: string; // ISO date string + to?: string; // ISO date string + outcome?: "success" | "failure"; +} + +/** Result from AuditLogger.query() */ +export interface AuditQueryResult { + entries: AuditEntry[]; + total: number; +} diff --git a/src/mt/deepl.ts b/src/mt/deepl.ts new file mode 100644 index 0000000..5ad2fa1 --- /dev/null +++ b/src/mt/deepl.ts @@ -0,0 +1,72 @@ +/** + * DeepL machine translation provider. + * Supports both Free (keys ending in ":fx") and Pro API tiers. + */ + +import type { MachineTranslator, SafeFetch } from "./types.ts"; + +/** DeepL target language special cases — map generic codes to preferred variants. */ +const TARGET_LANG_MAP: Record = { + en: "EN-US", + pt: "PT-BR", +}; + +function normalizeSourceLang(lang: string): string { + return lang.toUpperCase(); +} + +function normalizeTargetLang(lang: string): string { + const upper = lang.toUpperCase(); + return TARGET_LANG_MAP[lang.toLowerCase()] ?? upper; +} + +/** + * DeepL machine translation provider. + * Supports both Free (keys ending in ":fx") and Pro API tiers. + */ +export class DeepLTranslator implements MachineTranslator { + readonly provider = "deepl" as const; + + private readonly endpoint: string; + + constructor(private apiKey: string, private safeFetch: SafeFetch) { + // Free keys end with ":fx"; they use the api-free subdomain + this.endpoint = apiKey.endsWith(":fx") + ? "https://api-free.deepl.com/v2/translate" + : "https://api.deepl.com/v2/translate"; + } + + async translate(text: string, sourceLang: string, targetLang: string): Promise { + const results = await this.translateBatch([text], sourceLang, targetLang); + return results[0]; + } + + async translateBatch(texts: string[], sourceLang: string, targetLang: string): Promise { + let res: Response; + try { + res = await this.safeFetch(this.endpoint, { + method: "POST", + headers: { + "Authorization": `DeepL-Auth-Key ${this.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + text: texts, + source_lang: normalizeSourceLang(sourceLang), + target_lang: normalizeTargetLang(targetLang), + tag_handling: "markdown", + }), + }); + } catch (err) { + throw new Error(`DeepL network error: ${err}`); + } + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`DeepL API error ${res.status}: ${body}`); + } + + const data = await res.json() as { translations: Array<{ text: string }> }; + return data.translations.map((t) => t.text); + } +} diff --git a/src/mt/factory.ts b/src/mt/factory.ts new file mode 100644 index 0000000..6a4be0c --- /dev/null +++ b/src/mt/factory.ts @@ -0,0 +1,41 @@ +/** + * Factory for creating a MachineTranslator from config. + */ + +import type { MachineTranslationConfig, MachineTranslator, SafeFetch } from "./types.ts"; +import { DeepLTranslator } from "./deepl.ts"; +import { GoogleTranslator } from "./google.ts"; +import { LibreTranslateTranslator } from "./libretranslate.ts"; + +/** + * Create a MachineTranslator from config. + * Returns null if MT is disabled or provider is unknown. + * API keys starting with "$" are expanded from the environment. + * Every provider request goes through `safeFetch` (core's SSRF policy). + */ +export function createMachineTranslator( + config: MachineTranslationConfig, + safeFetch: SafeFetch, +): MachineTranslator | null { + if (config.enabled === false) return null; + + const rawKey = config.apiKey ?? ""; + const key = rawKey.startsWith("$") + ? (Deno.env.get(rawKey.slice(1)) ?? rawKey) + : rawKey; + + switch (config.provider) { + case "deepl": + return new DeepLTranslator(key, safeFetch); + case "google": + return new GoogleTranslator(key, safeFetch); + case "libretranslate": + return new LibreTranslateTranslator( + config.baseUrl ?? "https://libretranslate.com", + key || undefined, + safeFetch, + ); + default: + return null; + } +} diff --git a/src/mt/google.ts b/src/mt/google.ts new file mode 100644 index 0000000..4032f8b --- /dev/null +++ b/src/mt/google.ts @@ -0,0 +1,47 @@ +/** + * Google Cloud Translation API v2 (Basic) provider. + */ + +import type { MachineTranslator, SafeFetch } from "./types.ts"; + +/** Google Cloud Translation API v2 (Basic) provider. */ +export class GoogleTranslator implements MachineTranslator { + readonly provider = "google" as const; + + constructor(private apiKey: string, private safeFetch: SafeFetch) {} + + async translate(text: string, sourceLang: string, targetLang: string): Promise { + const results = await this.translateBatch([text], sourceLang, targetLang); + return results[0]; + } + + async translateBatch(texts: string[], sourceLang: string, targetLang: string): Promise { + const endpoint = `https://translation.googleapis.com/language/translate/v2?key=${encodeURIComponent(this.apiKey)}`; + + let res: Response; + try { + res = await this.safeFetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + q: texts, + source: sourceLang, + target: targetLang, + format: "text", + }), + }); + } catch (err) { + throw new Error(`Google Translate network error: ${err}`); + } + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Google Translate API error ${res.status}: ${body}`); + } + + const data = await res.json() as { data: { translations: Array<{ translatedText: string }> } }; + return data.data.translations.map((t) => t.translatedText); + } +} diff --git a/src/mt/libretranslate.ts b/src/mt/libretranslate.ts new file mode 100644 index 0000000..2727eb3 --- /dev/null +++ b/src/mt/libretranslate.ts @@ -0,0 +1,69 @@ +/** + * LibreTranslate provider — supports self-hosted instances. + * LibreTranslate does not support batch requests, so batch is implemented + * as sequential individual requests. + */ + +import type { MachineTranslator, SafeFetch } from "./types.ts"; + +/** + * LibreTranslate provider — supports self-hosted instances. + * LibreTranslate does not support batch requests, so batch is implemented + * as sequential individual requests. + */ +export class LibreTranslateTranslator implements MachineTranslator { + readonly provider = "libretranslate" as const; + + constructor( + private baseUrl: string, + private apiKey: string | undefined, + private safeFetch: SafeFetch, + ) { + // Strip trailing slash for consistent URL construction + this.baseUrl = baseUrl.replace(/\/$/, ""); + } + + async translate(text: string, sourceLang: string, targetLang: string): Promise { + const endpoint = `${this.baseUrl}/translate`; + + const body: Record = { + q: text, + source: sourceLang, + target: targetLang, + format: "text", + }; + if (this.apiKey) { + body.api_key = this.apiKey; + } + + let res: Response; + try { + res = await this.safeFetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + } catch (err) { + throw new Error(`LibreTranslate network error: ${err}`); + } + + if (!res.ok) { + const respBody = await res.text().catch(() => ""); + throw new Error(`LibreTranslate API error ${res.status}: ${respBody}`); + } + + const data = await res.json() as { translatedText: string }; + return data.translatedText; + } + + async translateBatch(texts: string[], sourceLang: string, targetLang: string): Promise { + // LibreTranslate has no batch endpoint — execute sequentially + const results: string[] = []; + for (const text of texts) { + results.push(await this.translate(text, sourceLang, targetLang)); + } + return results; + } +} diff --git a/src/mt/mod.ts b/src/mt/mod.ts new file mode 100644 index 0000000..bebddde --- /dev/null +++ b/src/mt/mod.ts @@ -0,0 +1,11 @@ +/** + * Machine translation module — re-exports all public types and classes. + * + * @module + */ + +export type { MachineTranslationConfig, MachineTranslator, SafeFetch } from "./types.ts"; +export { DeepLTranslator } from "./deepl.ts"; +export { GoogleTranslator } from "./google.ts"; +export { LibreTranslateTranslator } from "./libretranslate.ts"; +export { createMachineTranslator } from "./factory.ts"; diff --git a/src/mt/types.ts b/src/mt/types.ts new file mode 100644 index 0000000..78cbd65 --- /dev/null +++ b/src/mt/types.ts @@ -0,0 +1,38 @@ +/** + * Machine translation type definitions. + */ + +/** + * Fetch used for every provider request. The admin passes + * `services.security.safeFetch`, so private and metadata destinations are + * refused by core's SSRF policy. + */ +export type SafeFetch = (url: string, init?: RequestInit) => Promise; + +/** A machine translation provider */ +export interface MachineTranslator { + /** Translate a plain-text or Markdown string */ + translate(text: string, sourceLang: string, targetLang: string): Promise; + /** Translate multiple texts in a single API call (batched) */ + translateBatch(texts: string[], sourceLang: string, targetLang: string): Promise; + /** Provider identifier */ + readonly provider: "deepl" | "google" | "libretranslate"; +} + +/** Machine translation configuration */ +export interface MachineTranslationConfig { + /** Translation provider */ + provider: "deepl" | "google" | "libretranslate"; + /** + * API key for the provider. + * Supports "$ENV_VAR" expansion. + */ + apiKey?: string; + /** + * For LibreTranslate: base URL of the self-hosted instance. + * Default: "https://libretranslate.com" + */ + baseUrl?: string; + /** Whether machine translation is enabled (default: true when this block is present) */ + enabled?: boolean; +} diff --git a/tests/audit_trail_test.ts b/tests/audit_trail_test.ts new file mode 100644 index 0000000..cec1af7 --- /dev/null +++ b/tests/audit_trail_test.ts @@ -0,0 +1,47 @@ +import { assertEquals, assertExists } from "@std/assert"; +import type { DuneConfig } from "@dune/core/config"; +import { createAuditTrail } from "../src/audit/mod.ts"; + +const config = (admin?: Record) => ({ system: {}, site: {}, admin } as unknown as DuneConfig); +const entry = { event: "page.update", actor: null, ip: null, userAgent: null, target: null, detail: {} } as never; + +Deno.test("createAuditTrail: no site root, no trail", () => { + assertEquals(createAuditTrail({ config: config(), root: undefined, runtimeDir: ".dune/admin" }), null); +}); + +Deno.test("createAuditTrail: logs without an explicit init, and enabled:false turns it off", async () => { + const root = await Deno.makeTempDir(); + try { + const trail = createAuditTrail({ config: config(), root, runtimeDir: ".dune/admin" }); + assertExists(trail); + await trail.log(entry); + assertEquals((await trail.query({ event: "page.update" })).entries.length, 1); + + for (const admin of [{ audit: { enabled: false } }, { enabled: false }]) { + assertEquals(createAuditTrail({ config: config(admin), root, runtimeDir: ".dune/admin" }), null); + } + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("createAuditTrail: the log goes under the configured runtime dir", async () => { + const root = await Deno.makeTempDir(); + try { + const trail = createAuditTrail({ config: config(), root, runtimeDir: "var/rt" })!; + await trail.log(entry); + assertEquals((await Deno.stat(`${root}/var/rt/audit`)).isDirectory, true); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("createAuditTrail: a log_file outside the site root is refused", () => { + let message = ""; + try { + createAuditTrail({ config: config({ audit: { log_file: "../elsewhere/audit.log" } }), root: "/srv/site", runtimeDir: ".dune/admin" }); + } catch (err) { + message = (err as Error).message; + } + assertEquals(message.includes("must resolve under the site root"), true); +}); diff --git a/tests/mt/ssrf_test.ts b/tests/mt/ssrf_test.ts new file mode 100644 index 0000000..df087c5 --- /dev/null +++ b/tests/mt/ssrf_test.ts @@ -0,0 +1,37 @@ +import { assertEquals, assertRejects } from "@std/assert"; +import { createMachineTranslator, LibreTranslateTranslator } from "../../src/mt/mod.ts"; +import { createPluginServices } from "@dune/core/plugin-api"; +import type { DuneConfig } from "@dune/core/config"; +import { MemoryStorageAdapter } from "@dune/core/storage"; + +// The translators fetch through whatever they are given: the admin passes +// services.security.safeFetch, so core's SSRF policy applies. +const { security } = createPluginServices({ + config: { system: {}, site: {} } as unknown as DuneConfig, + storage: new MemoryStorageAdapter(), +}); + +Deno.test("LibreTranslateTranslator: refuses a private destination via the services' safeFetch", async () => { + const mt = new LibreTranslateTranslator("http://169.254.169.254", undefined, security.safeFetch); + await assertRejects(() => mt.translate("hello", "en", "es"), Error, "LibreTranslate"); +}); + +Deno.test("createMachineTranslator: sends provider requests through the given fetch", async () => { + const seen: string[] = []; + const fake = (url: string) => { + seen.push(url); + return Promise.resolve(new Response(JSON.stringify({ translatedText: "hola" }))); + }; + const mt = createMachineTranslator( + { provider: "libretranslate", baseUrl: "https://mt.example" }, + fake, + ); + assertEquals(await mt!.translate("hello", "en", "es"), "hola"); + assertEquals(seen, ["https://mt.example/translate"]); +}); + +Deno.test("createMachineTranslator: disabled or unknown providers give none", () => { + const fake = () => Promise.resolve(new Response()); + assertEquals(createMachineTranslator({ provider: "deepl", enabled: false }, fake), null); + assertEquals(createMachineTranslator({ provider: "nope" as never }, fake), null); +}); From 0c40f61b0ff2d8fd594d7cccc66218e77032e111 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 00:28:25 +0100 Subject: [PATCH 16/21] feat(admin)!: the admin is a plugin you list: a factory that takes the site facts it cannot get from PluginApi createAdminPlugin(opts) no longer takes config and storage (they come from PluginApi in setup()), and the default export is the plugin-loader factory (config, { root, dev }) so plugins: [{ src: jsr:@dune/plugin-admin@^4 }] is enough. admin.enabled is gone (core refuses it: list the admin to run it). Tests list the admin explicitly; the conformance driver uses the 2.0.0 @dune/testing shape (hostsContributions, prefix from the admin.prefix slot). Co-Authored-By: Claude Sonnet 5.5 --- deno.json | 2 +- mod.ts | 51 +++++++++++++++------------- src/admin/mount.ts | 2 +- src/admin/types.ts | 2 -- src/audit/trail.ts | 4 +-- tests/admin/capabilities_e2e_test.ts | 5 +-- tests/admin/conformance_test.ts | 9 ++--- tests/admin/page_events_test.ts | 3 +- tests/admin/plugin_version_test.ts | 8 +---- tests/audit_trail_test.ts | 6 ++-- 10 files changed, 45 insertions(+), 47 deletions(-) diff --git a/deno.json b/deno.json index 9512072..d6274b4 100644 --- a/deno.json +++ b/deno.json @@ -20,7 +20,7 @@ "./mt": "./src/mt/mod.ts" }, "imports": { - "@dune/testing": "jsr:@dune/testing@^1", + "@dune/testing": "jsr:@dune/testing@^2", "@dune/core": "jsr:@dune/core@^0.35.3", "fresh": "jsr:@fresh/core@^2", "preact": "npm:preact@^10", diff --git a/mod.ts b/mod.ts index 30791c2..caebea6 100644 --- a/mod.ts +++ b/mod.ts @@ -14,7 +14,6 @@ * @since 0.24.0 */ -import { join } from "@std/path"; import type { DunePlugin, MountApi } from "@dune/core/hooks"; import type { DuneConfig } from "@dune/core/config"; import type { StorageAdapter } from "@dune/core/storage"; @@ -30,9 +29,6 @@ import { createCommentManager } from "./src/admin/comments.ts"; import { createCollabManager } from "./src/collab/mod.ts"; import { createMachineTranslator, type MachineTranslationConfig, type MachineTranslator } from "./src/mt/mod.ts"; import { type AuditLogger, createAuditTrail } from "./src/audit/mod.ts"; -import type { DuneAuthSystem } from "@dune/core/auth/authz"; -import type { AuthzLocalAdapter } from "@dune/core/auth/authz-adapter-local"; -import type { AuthzDbAdapter } from "@dune/core/auth/authz-adapter-db"; import { initAdminContext } from "./src/admin/context.ts"; import type { AdminContext } from "./src/admin/context.ts"; import { createBlockEditorPlugin } from "./src/admin/block-editor-plugin.tsx"; @@ -58,14 +54,14 @@ export const resolvedCoreSentinel: unknown = _core.CORE_INSTANCE; /** The `@dune/core` version this package resolved. Undefined on cores ≤0.30. */ export const resolvedCoreVersion: string | undefined = _core.CORE_VERSION; -/** Options forwarded from bootstrap() to the admin plugin factory. */ +/** Options for the admin plugin factory. */ export interface AdminPluginOptions { + /** Absolute site root (where the audit log is written). */ root: string; + /** True under `dune dev` (cookies are not marked Secure). */ dev: boolean; + /** A custom sign-in provider; takes precedence over `admin.auth_provider`. */ authProvider?: AuthProvider; - authz?: DuneAuthSystem; - authzAdapter?: AuthzLocalAdapter | AuthzDbAdapter; - hmacKey?: CryptoKey | null; } /** State created during setup(), consumed by mount(). */ @@ -81,17 +77,18 @@ interface AdminSetupState { } /** - * Factory for the built-in admin panel plugin. + * Factory for the admin panel plugin. * - * Receives bootstrap-time context (root, dev flag, optional custom auth - * provider) that can't come through `PluginApi` because they exist before - * plugins are loaded. + * Takes the facts that exist before plugins load (site root, dev flag, an + * optional custom auth provider); the site config and storage come from + * `PluginApi` in `setup()`. A site normally lists the package under `plugins:` + * and the default export below builds the plugin; call this directly to pass a + * custom `authProvider`. */ -export function createAdminPlugin( - config: DuneConfig, - storage: StorageAdapter, - opts: AdminPluginOptions, -): DunePlugin { +export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { + // Taken from PluginApi in setup(), which always runs before mount(). + let config!: DuneConfig; + let storage!: StorageAdapter; let setupState: AdminSetupState | null = null; // Set at the end of mount(), once AdminContext is built. The capabilities // declared in setup() delegate to it lazily (per request), because core @@ -106,15 +103,12 @@ export function createAdminPlugin( islandSpecifiers: getDuneAdminIslands(), async setup(api) { + config = api.config; + storage = api.storage; const adminCfg = config.admin ?? { path: "/admin", session_lifetime: 86400, - enabled: true, }; - if (adminCfg.enabled === false) { - setupState = null; - return; - } // The audit trail and the machine translator are the admin's own. Translator // requests go through core's SSRF-checked fetch. @@ -156,7 +150,6 @@ export function createAdminPlugin( const adminCfg = config.admin ?? { path: "/admin", session_lifetime: 86400, - enabled: true, }; // `system.runtime_dir`, as core resolved it. const runtimeDir = services.paths.runtimeDir; @@ -382,3 +375,15 @@ export function createAdminPlugin( }, }; } + +/** + * The plugin loader's entry point: `plugins: [{ src: jsr:@dune/plugin-admin@^4 }]` + * calls this with the entry's config and `{ root, dev }`. + */ +export default function adminPluginFactory( + _pluginConfig: Record, + ctx: { root: string; dev: boolean }, +): DunePlugin { + return createAdminPlugin({ root: ctx.root, dev: ctx.dev }); +} +adminPluginFactory.pluginName = "dune-admin"; diff --git a/src/admin/mount.ts b/src/admin/mount.ts index ef2c943..bd1f361 100644 --- a/src/admin/mount.ts +++ b/src/admin/mount.ts @@ -246,7 +246,7 @@ export function mountAdminPanel( const adminPrefix = config.admin?.path ?? "/admin"; // ── Admin panel ───────────────────────────────────────────────────────────── - if (config.admin?.enabled !== false) { + { // Per-site admin context middleware — captures the correct context in // closure so multisite setups don't suffer from the module-level // singleton. A second registration for the same ctx is a no-op. diff --git a/src/admin/types.ts b/src/admin/types.ts index f52efbf..1cdb749 100644 --- a/src/admin/types.ts +++ b/src/admin/types.ts @@ -66,8 +66,6 @@ export interface AdminConfig { * (default: ".dune/admin") */ runtimeDir: string; - /** Whether admin panel is enabled (default: true) */ - enabled: boolean; } /** Result of an auth check */ diff --git a/src/audit/trail.ts b/src/audit/trail.ts index 13c28ec..0b5b83c 100644 --- a/src/audit/trail.ts +++ b/src/audit/trail.ts @@ -17,14 +17,14 @@ export interface AuditTrailOptions { } /** - * The trail is on unless `admin.enabled` or `admin.audit.enabled` is false. The + * The trail is on unless `admin.audit.enabled` is false. The * log file must resolve under the site root: a configured `log_file` that * escapes it is refused. */ export function createAuditTrail(opts: AuditTrailOptions): AuditLogger | null { const { config, root, runtimeDir } = opts; const audit = config.admin?.audit; - if (config.admin?.enabled === false || audit?.enabled === false || !root) return null; + if (audit?.enabled === false || !root) return null; const logFile = audit?.log_file ? join(root, audit.log_file) : join(root, runtimeDir, "audit.log"); const containmentRoot = root.endsWith("/") || root.endsWith("\\") ? root : root + "/"; if (!logFile.startsWith(containmentRoot)) { diff --git a/tests/admin/capabilities_e2e_test.ts b/tests/admin/capabilities_e2e_test.ts index d199bfc..6a5cd77 100644 --- a/tests/admin/capabilities_e2e_test.ts +++ b/tests/admin/capabilities_e2e_test.ts @@ -9,6 +9,7 @@ import { join } from "@std/path"; import { bootstrap } from "@dune/core/bootstrap"; import { createDuneApp } from "@dune/core/fresh-app"; import type { DunePlugin } from "@dune/core/hooks"; +import { createAdminPlugin } from "../../mod.ts"; async function withSite( plugins: DunePlugin[], @@ -18,7 +19,7 @@ async function withSite( try { await Deno.mkdir(join(root, "content", "01.home"), { recursive: true }); await Deno.writeTextFile(join(root, "content", "01.home", "default.md"), "---\ntitle: Home\n---\n\nHello\n"); - const ctx = await bootstrap(root, { plugins }); + const ctx = await bootstrap(root, { plugins: [createAdminPlugin({ root, dev: false }), ...plugins] }); const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); await fn(ctx, app.handler()); } finally { @@ -61,7 +62,7 @@ Deno.test( await Deno.writeTextFile(join(root, "content", "01.home", "default.md"), "---\ntitle: Home\n---\n"); await Deno.mkdir(join(root, "config"), { recursive: true }); await Deno.writeTextFile(join(root, "config", "system.yaml"), "admin:\n path: /cms\n"); - const ctx = await bootstrap(root, {}); + const ctx = await bootstrap(root, { plugins: [createAdminPlugin({ root, dev: false })] }); const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); assertEquals(ctx.capabilities.get("admin.prefix"), { prefix: "/cms" }); // The panel answers at the provided prefix, and /admin is just a missing content page. diff --git a/tests/admin/conformance_test.ts b/tests/admin/conformance_test.ts index 1b264c0..bdc3676 100644 --- a/tests/admin/conformance_test.ts +++ b/tests/admin/conformance_test.ts @@ -8,6 +8,7 @@ import { join } from "@std/path"; import { runAdminConformance } from "@dune/testing/conformance"; import type { ConformanceApp } from "@dune/testing/conformance"; import { bootstrap } from "@dune/core/bootstrap"; +import { createAdminPlugin } from "../../mod.ts"; import { createDuneApp } from "@dune/core/fresh-app"; import { createStorage } from "@dune/core/storage"; import { createLocalUserStore } from "@dune/core/auth/user-store"; @@ -44,7 +45,8 @@ async function login(app: ConformanceApp, username: string): Promise { runAdminConformance({ name: "plugin-admin", - adminPrefix: "/admin", + // The prefix is read from the admin.prefix slot; this admin hosts admin.pages / admin.routes contributions. + hostsContributions: true, async createApp({ content, extraPlugins, withAdmin }) { const root = await Deno.makeTempDir({ prefix: "dune_admin_conformance_" }); @@ -65,10 +67,9 @@ runAdminConformance({ roles: ["admin"], }); } + // Plugins register in this order and mount in it, so the extras come before the admin. const ctx = await bootstrap(root, { - plugins: extraPlugins, - // deno-lint-ignore no-explicit-any - configOverrides: withAdmin ? undefined : { admin: { enabled: false } as any }, + plugins: [...extraPlugins, ...(withAdmin ? [createAdminPlugin({ root, dev: false })] : [])], }); const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); const handler = app.handler(); diff --git a/tests/admin/page_events_test.ts b/tests/admin/page_events_test.ts index 0accdbd..5b7f25a 100644 --- a/tests/admin/page_events_test.ts +++ b/tests/admin/page_events_test.ts @@ -14,6 +14,7 @@ import { createDuneApp } from "@dune/core/fresh-app"; import { createStorage } from "@dune/core/storage"; import { createLocalUserStore } from "@dune/core/auth/user-store"; import { hashPassword } from "@dune/core/auth/passwords"; +import { createAdminPlugin } from "../../mod.ts"; const PASSWORD = "correct horse battery staple"; const ORIGIN = "http://localhost:8000"; @@ -59,7 +60,7 @@ async function withSite( onPageMove: record("onPageMove"), } as DunePlugin["hooks"], }; - const ctx = await bootstrap(root, { plugins: [recorder] }); + const ctx = await bootstrap(root, { plugins: [createAdminPlugin({ root, dev: false }), recorder] }); const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); const handler = app.handler(); const req = (path: string, init?: RequestInit) => handler(new Request(new URL(path, ORIGIN), init)); diff --git a/tests/admin/plugin_version_test.ts b/tests/admin/plugin_version_test.ts index 65455f3..b1ab6ff 100644 --- a/tests/admin/plugin_version_test.ts +++ b/tests/admin/plugin_version_test.ts @@ -7,8 +7,6 @@ import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; import { createAdminPlugin } from "../../mod.ts"; -import type { DuneConfig } from "@dune/core/config"; -import type { StorageAdapter } from "@dune/core/storage"; Deno.test("admin plugin version matches deno.json", async () => { const denoJsonText = await Deno.readTextFile( @@ -16,11 +14,7 @@ Deno.test("admin plugin version matches deno.json", async () => { ); const { version } = JSON.parse(denoJsonText); - const plugin = createAdminPlugin( - {} as DuneConfig, - {} as StorageAdapter, - { root: "/tmp", dev: true }, - ); + const plugin = createAdminPlugin({ root: "/tmp", dev: true }); assertEquals(plugin.version, version); }); diff --git a/tests/audit_trail_test.ts b/tests/audit_trail_test.ts index cec1af7..74aee31 100644 --- a/tests/audit_trail_test.ts +++ b/tests/audit_trail_test.ts @@ -9,7 +9,7 @@ Deno.test("createAuditTrail: no site root, no trail", () => { assertEquals(createAuditTrail({ config: config(), root: undefined, runtimeDir: ".dune/admin" }), null); }); -Deno.test("createAuditTrail: logs without an explicit init, and enabled:false turns it off", async () => { +Deno.test("createAuditTrail: logs without an explicit init, and audit.enabled: false turns it off", async () => { const root = await Deno.makeTempDir(); try { const trail = createAuditTrail({ config: config(), root, runtimeDir: ".dune/admin" }); @@ -17,9 +17,7 @@ Deno.test("createAuditTrail: logs without an explicit init, and enabled:false tu await trail.log(entry); assertEquals((await trail.query({ event: "page.update" })).entries.length, 1); - for (const admin of [{ audit: { enabled: false } }, { enabled: false }]) { - assertEquals(createAuditTrail({ config: config(admin), root, runtimeDir: ".dune/admin" }), null); - } + assertEquals(createAuditTrail({ config: config({ audit: { enabled: false } }), root, runtimeDir: ".dune/admin" }), null); } finally { await Deno.remove(root, { recursive: true }); } From 09feb45ede78f78fa77620a014293a1c4d34684f Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 00:29:08 +0100 Subject: [PATCH 17/21] docs(admin): README and CHANGELOG for explicit loading Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 13 +++++++++++++ README.md | 36 ++++++++++++++++++++---------------- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20a4b6b..45dd8a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,19 @@ follows [Semantic Versioning](https://semver.org). `onPageMove` is also a valid webhook event. Needs `@dune/core` with `onPageMove` (0.36). `tests/admin/page_events_test.ts` drives create, update, reorder, delete and restore through the real routes. +### Changed — BREAKING (the admin is a plugin you list) + +- **`@dune/core` 0.36 no longer loads the admin by itself (#123, step 4).** List it under `plugins:` in + `config/site.yaml` (`- src: jsr:@dune/plugin-admin@^4`; `dune new` writes it and `dune migrate:config` adds it). + The package's default export is the plugin-loader factory `(config, { root, dev })`, so that is all it takes. +- **`createAdminPlugin(opts)` takes `{ root, dev, authProvider? }` only.** The site config and storage come from + `PluginApi` in `setup()`, and the unused `authz`, `authzAdapter` and `hmacKey` options are gone. To use a custom + `authProvider`, call it yourself and pass the result in `bootstrap(root, { plugins })`; core's `authProvider` + bootstrap option is gone. +- **`admin.enabled` is gone** (core refuses it: do not list the admin to run without it), along with the admin's + `enabled` config type field and the `enabled` check in `mountAdminPanel()` and the audit trail. +- Needs `@dune/core` 0.36 and `@dune/testing` 2 for the conformance run. + ### Changed — BREAKING (machine translation and the audit trail) - **The admin owns the audit trail and machine translation (#123, step 3).** `AuditLogger`, the audit types, the diff --git a/README.md b/README.md index d7a64c6..62eed13 100644 --- a/README.md +++ b/README.md @@ -17,32 +17,36 @@ Optional: `sha256` (ZIP integrity), `demoUrl`, `screenshotUrl`, `tags`, `compati ## Usage -The plugin is auto-registered by `@dune/core` bootstrap unless explicitly disabled. No configuration is required for the default setup. +The admin is a plugin like any other: `@dune/core` loads it only when `config/site.yaml` lists it. +`dune new` writes the entry, and `dune migrate:config` adds it to an existing site. -### Explicit registration (for custom wiring) +```yaml +# config/site.yaml +plugins: + - src: jsr:@dune/plugin-admin@^4 +``` + +No further configuration is required. A site that does not list it has no admin panel: it serves, +builds and runs jobs as usual, and says once at boot that nothing provides the admin. + +### Custom wiring + +To pass a custom sign-in provider, build the plugin yourself and hand it to `bootstrap()` instead of +listing it: ```ts import { createAdminPlugin } from "@dune/plugin-admin"; import { bootstrap } from "@dune/core"; -const result = await bootstrap({ root: Deno.cwd() }); -// Admin is auto-registered; pass options to override defaults: -const adminPlugin = createAdminPlugin(result.config, result.storage, { - root: Deno.cwd(), - dev: false, - // authProvider: myCustomAuthProvider, +const ctx = await bootstrap(Deno.cwd(), { + plugins: [createAdminPlugin({ root: Deno.cwd(), dev: false, authProvider: myCustomAuthProvider })], }); ``` -### Disabling the admin panel - -```yaml -# site.yaml -admin: - enabled: false -``` +### Running without the admin -This produces a headless Dune deployment — useful for statically generated sites or when you want a fully custom management interface. +Do not list it. (`admin.enabled` no longer exists: core refuses it.) This is a headless Dune deployment, +useful for statically generated sites or when you want a fully custom management interface. ## Configuration From c9079a0dfb62aefec0397a77d39b1412c5b6c384 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 01:09:21 +0100 Subject: [PATCH 18/21] feat(admin)!: the plugin is called admin and its settings are its plugin config name dune-admin -> admin, so the settings are config.plugins.admin (the config: of its entry in plugins:). adminSettings(config) is the one reader; it applies the defaults and refuses a path or auth_provider that would desync the admin. WebhookEndpointConfig, WebhookContentEvent and AuthProviderConfig move here from @dune/core, which now has no admin config at all. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 14 +++ README.md | 51 +++++---- mod.ts | 21 ++-- src/admin/mount.ts | 3 +- src/admin/page-events.ts | 3 +- src/admin/routes/api/introspect.ts | 5 +- src/admin/routes/api/media/upload.ts | 3 +- src/admin/routes/api/pages/[...path]/index.ts | 7 +- src/admin/routes/api/pages/index.ts | 3 +- .../routes/api/staging/[...path]/publish.ts | 3 +- src/admin/routes/api/users/[id]/password.ts | 3 +- src/admin/routes/login.tsx | 3 +- src/admin/settings.ts | 101 ++++++++++++++++++ src/admin/webhooks.ts | 2 +- src/admin/workflow-actions.ts | 3 +- src/audit/trail.ts | 3 +- tests/admin/capabilities_e2e_test.ts | 15 +-- tests/admin/pages_edit_blueprint_test.ts | 2 +- tests/admin/plugin_admin_routes_test.ts | 2 +- tests/admin/settings_test.ts | 54 ++++++++++ tests/admin/theme_config_test.ts | 2 +- tests/admin/workflow_actions_test.ts | 2 +- tests/audit_trail_test.ts | 2 +- 23 files changed, 249 insertions(+), 58 deletions(-) create mode 100644 src/admin/settings.ts create mode 100644 tests/admin/settings_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 45dd8a4..23bc13f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,20 @@ follows [Semantic Versioning](https://semver.org). `onPageMove` is also a valid webhook event. Needs `@dune/core` with `onPageMove` (0.36). `tests/admin/page_events_test.ts` drives create, update, reorder, delete and restore through the real routes. +### Changed — BREAKING (the admin is called `admin`, and its settings are its plugin config) + +- **The plugin is named `admin`** (it was `dune-admin`), so its settings are `config.plugins.admin`, and + `capabilities.providers()`, `dune doctor` and the logs name it `admin`. +- **Settings come from the plugin's config, not from an `admin:` block in system.yaml (#123, step 5).** + `path`, `session_lifetime`, `max_upload_mb`, `git_commit`, `webhooks`, `auth_provider` and `audit` are + `config:` under the admin's entry in `plugins:` (site.yaml); `dune migrate:config` moves an existing + `admin:` block there. Core 0.36 has no `admin` config at all and refuses the old block. The admin + validates its own settings at boot: a `path` that is not a clean prefix, or an `auth_provider` other + than `local`, stops the site instead of leaving the admin mounted where core does not protect it. + `WebhookEndpointConfig`, `WebhookContentEvent` and `AuthProviderConfig` moved here from `@dune/core`. + `runtime_dir` and `max_revisions` stay core settings (`system.yaml`). +- `adminSettings(config)` (`src/admin/settings.ts`) is the one place the settings are read. + ### Changed — BREAKING (the admin is a plugin you list) - **`@dune/core` 0.36 no longer loads the admin by itself (#123, step 4).** List it under `plugins:` in diff --git a/README.md b/README.md index 62eed13..036ad0e 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,16 @@ plugins: - src: jsr:@dune/plugin-admin@^4 ``` -No further configuration is required. A site that does not list it has no admin panel: it serves, +No further configuration is required. Settings go under `config:` of that entry: + +```yaml +plugins: + - src: jsr:@dune/plugin-admin@^4 + config: + path: /cms # URL prefix (default /admin) + session_lifetime: 3600 # seconds (default 86400) +``` + A site that does not list it has no admin panel: it serves, builds and runs jobs as usual, and says once at boot that nothing provides the admin. ### Custom wiring @@ -50,28 +59,32 @@ useful for statically generated sites or when you want a fully custom management ## Configuration -All admin configuration lives under the `admin:` key in `site.yaml`. +The admin's settings are its plugin config: `config:` under its entry in `plugins:` (`config/site.yaml`). +Core reads none of them. ```yaml -admin: - path: /admin # URL prefix for the admin panel (default: /admin) - enabled: true # Set to false to disable entirely - session_lifetime: 86400 # Session duration in seconds (default: 24h) - data_dir: data # Directory for users, submissions, etc. - runtime_dir: .dune/admin # Directory for sessions, audit log, etc. - - auth_provider: - type: local # Only "local" is supported today - - audit: - enabled: true - log_file: .dune/admin/audit.log # Override the audit log path - - session_store: - type: local # "local" (default) or "redis" - url: $REDIS_URL # Required when type is redis +plugins: + - src: jsr:@dune/plugin-admin@^4 + config: + path: /admin # URL prefix for the admin panel (default /admin) + session_lifetime: 86400 # session duration in seconds (default 24h) + max_upload_mb: 100 # admin upload limit (default 100) + git_commit: false # commit to git after each page save (default false) + auth_provider: + type: local # the only supported value; omit for the same result + audit: + enabled: true + log_file: .dune/admin/audit.log # must resolve under the site root + webhooks: # outbound webhooks on content events + - url: https://hooks.example.com/content + secret: $WEBHOOK_SECRET + events: [onPageUpdate] ``` +A `path` that is not a clean prefix, or an `auth_provider` other than `local`, stops the site at boot. +Shared settings live in core's `system.yaml`: `data_dir` (users, submissions), `runtime_dir` (sessions, audit +log, revisions) and `max_revisions`. + ### Roles and permissions The admin panel uses a three-tier role system: diff --git a/mod.ts b/mod.ts index caebea6..d0aaa84 100644 --- a/mod.ts +++ b/mod.ts @@ -36,6 +36,7 @@ import type { App } from "fresh"; import { getDuneAdminIslands, mountAdminPanel } from "./src/admin/mount.ts"; import * as corePlugins from "@dune/core/plugins"; import denoJson from "./deno.json" with { type: "json" }; +import { adminSettings } from "./src/admin/settings.ts"; // ── Core-instance handshake ────────────────────────────────────────────────── // Re-export the CORE_INSTANCE sentinel and CORE_VERSION that THIS package's @@ -96,7 +97,7 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { let liveAdminContext: AdminContext | null = null; return { - name: "dune-admin", + name: "admin", version: denoJson.version, description: "Built-in Dune admin panel", hooks: {}, @@ -105,10 +106,7 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { async setup(api) { config = api.config; storage = api.storage; - const adminCfg = config.admin ?? { - path: "/admin", - session_lifetime: 86400, - }; + const adminCfg = adminSettings(config); // throws on a bad path / auth_provider: the site does not boot // The audit trail and the machine translator are the admin's own. Translator // requests go through core's SSRF-checked fetch. @@ -124,7 +122,7 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { // Where this admin is mounted: core keeps these paths out of the page // cache, the static build and the plugin-route namespace. The default // `/admin` lives here, not in core. - api.capabilities.provide("admin.prefix", () => ({ prefix: adminCfg.path ?? "/admin" })); + api.capabilities.provide("admin.prefix", () => ({ prefix: adminCfg.path })); api.capabilities.provide("auth.session", () => ({ // The cookie the admin's auth middleware sets (see createAuthMiddleware). // Core uses it to decide whether to authenticate a public page and whether @@ -147,10 +145,7 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { if (!setupState) return; // admin disabled const { opts: o } = setupState; - const adminCfg = config.admin ?? { - path: "/admin", - session_lifetime: 86400, - }; + const adminCfg = adminSettings(config); // throws on a bad path / auth_provider: the site does not boot // `system.runtime_dir`, as core resolved it. const runtimeDir = services.paths.runtimeDir; // `system.data_dir` — the same resolution core uses for the submissions it stores. @@ -192,7 +187,7 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { if (o.authProvider) { authProvider = o.authProvider; } else { - const provCfg = config.admin?.auth_provider; + const provCfg = adminCfg.auth_provider; if (!provCfg || provCfg.type === "local") { authProvider = new LocalAuthProvider(users, services.passwords); } else { @@ -295,7 +290,7 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { auth, users, sessions, - prefix: adminCfg.path ?? "/admin", + prefix: adminCfg.path, authProvider, workflow, scheduler, @@ -386,4 +381,4 @@ export default function adminPluginFactory( ): DunePlugin { return createAdminPlugin({ root: ctx.root, dev: ctx.dev }); } -adminPluginFactory.pluginName = "dune-admin"; +adminPluginFactory.pluginName = "admin"; diff --git a/src/admin/mount.ts b/src/admin/mount.ts index bd1f361..d33c81a 100644 --- a/src/admin/mount.ts +++ b/src/admin/mount.ts @@ -45,6 +45,7 @@ import { adminMiddleware, adminRoutes, } from "./manifest.gen.ts"; +import { adminSettings } from "./settings.ts"; /** * `ctx` objects (`BootstrapResult`) for which the `ctx.state.adminContext` @@ -243,7 +244,7 @@ export function mountAdminPanel( ): void { const { config } = site; const ctx = site; - const adminPrefix = config.admin?.path ?? "/admin"; + const adminPrefix = adminSettings(config).path; // ── Admin panel ───────────────────────────────────────────────────────────── { diff --git a/src/admin/page-events.ts b/src/admin/page-events.ts index b01e55d..da4a41e 100644 --- a/src/admin/page-events.ts +++ b/src/admin/page-events.ts @@ -13,6 +13,7 @@ import type { FreshContext } from "fresh"; import type { AdminState } from "./types.ts"; import { fireContentWebhooks } from "./webhooks.ts"; +import { adminSettings } from "./settings.ts"; /** Paths are relative to the content directory. */ export type PageEvent = @@ -30,7 +31,7 @@ export function announcePageEvent(ctx: FreshContext, e: PageEvent): ); } fireContentWebhooks( - config.admin?.webhooks ?? [], + adminSettings(config).webhooks, e.event, e.payload, services.paths.runtimeDir, diff --git a/src/admin/routes/api/introspect.ts b/src/admin/routes/api/introspect.ts index cfba004..e1a554d 100644 --- a/src/admin/routes/api/introspect.ts +++ b/src/admin/routes/api/introspect.ts @@ -13,6 +13,7 @@ import type { FreshContext } from "fresh"; import type { AdminState } from "../../types.ts"; import { json, requirePermission } from "./_utils.ts"; import { PLUGIN_API_VERSION } from "@dune/core/plugins"; +import { adminSettings } from "../../settings.ts"; /** Derive Dune package version from the module URL (same logic as CLI). */ function getDuneVersion(): string { @@ -102,7 +103,7 @@ export const handler = { const configSummary = { siteTitle: config.site.title, siteUrl: config.site.url, - adminPath: config.admin?.path ?? "/admin", + adminPath: adminSettings(config).path, themeName: config.theme.name, taxonomies: config.site.taxonomies, languages: { @@ -114,7 +115,7 @@ export const handler = { authMode: ((config as unknown as Record).auth as Record | undefined)?.mode ?? null, feedEnabled: config.site.feed?.enabled !== false, workflowEnabled: !!config.site.workflow, - auditEnabled: config.admin?.audit?.enabled !== false, + auditEnabled: adminSettings(config).audit?.enabled !== false, }; // ── Response ─────────────────────────────────────────────────────────── diff --git a/src/admin/routes/api/media/upload.ts b/src/admin/routes/api/media/upload.ts index 3116639..809f019 100644 --- a/src/admin/routes/api/media/upload.ts +++ b/src/admin/routes/api/media/upload.ts @@ -7,6 +7,7 @@ import { isMediaFile, dirPathToRoute } from "@dune/core/content/path-utils"; import { getMimeType } from "@dune/core/content/page-loader"; import { isDuneError } from "@dune/core/plugin-api"; import type { FreshContext } from "fresh"; +import { adminSettings } from "../../../settings.ts"; export const handler = { async POST(ctx: FreshContext) { @@ -17,7 +18,7 @@ export const handler = { const { storage, config, auditLogger, services } = ctx.state.adminContext; const MAX_FILE_SIZE = 50 * 1024 * 1024; - const maxBodyBytes = (config.admin?.max_upload_mb ?? 100) * 1024 * 1024; + const maxBodyBytes = adminSettings(config).max_upload_mb * 1024 * 1024; try { const tooLarge = services.security.checkBodySize(ctx.req, maxBodyBytes); diff --git a/src/admin/routes/api/pages/[...path]/index.ts b/src/admin/routes/api/pages/[...path]/index.ts index 5c9f8fd..2c60120 100644 --- a/src/admin/routes/api/pages/[...path]/index.ts +++ b/src/admin/routes/api/pages/[...path]/index.ts @@ -15,12 +15,13 @@ import { stringify as stringifyYaml } from "@std/yaml"; import { fireContentWebhooks } from "../../../../../admin/webhooks.ts"; import type { PageFrontmatter } from "@dune/core/content/types"; import type { FreshContext } from "fresh"; +import { adminSettings } from "../../../../settings.ts"; async function maybeGitCommit( filePath: string, sourcePath: string, author: string | undefined, config: import("../../../../context.ts").AdminContext["config"], ): Promise { - if (!config.admin?.git_commit) return; + if (!adminSettings(config).git_commit) return; try { const msg = author ? `Admin: update ${sourcePath} (by ${author})` : `Admin: update ${sourcePath}`; const add = new Deno.Command("git", { args: ["add", filePath], stderr: "inherit" }); @@ -129,7 +130,7 @@ export const handler = { await engine.rebuild(); await maybeGitCommit(filePath, pagePath, undefined, config); - const webhookEndpoints = config.admin?.webhooks ?? []; + const webhookEndpoints = adminSettings(config).webhooks; const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; if (hooks) hooks.fire("onPageUpdate", { sourcePath: page.sourcePath }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onPageUpdate", { sourcePath: page.sourcePath }, runtimeDir, ctx.state.adminContext.services.security); @@ -171,7 +172,7 @@ export const handler = { await storage.delete(`${contentDir}/${page.sourcePath}`); await engine.rebuild(); - const webhookEndpoints = config.admin?.webhooks ?? []; + const webhookEndpoints = adminSettings(config).webhooks; const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; if (hooks) hooks.fire("onPageDelete", { sourcePath: page.sourcePath }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onPageDelete", { sourcePath: page.sourcePath }, runtimeDir, ctx.state.adminContext.services.security); diff --git a/src/admin/routes/api/pages/index.ts b/src/admin/routes/api/pages/index.ts index 6fcd686..b8f9230 100644 --- a/src/admin/routes/api/pages/index.ts +++ b/src/admin/routes/api/pages/index.ts @@ -5,6 +5,7 @@ import { requirePermission, requireTsxWrite, json, serverError, actorFromAuth, g import { fireContentWebhooks } from "../../../../admin/webhooks.ts"; import { stringify as stringifyYaml } from "@std/yaml"; import type { FreshContext } from "fresh"; +import { adminSettings } from "../../../settings.ts"; export const handler = { async GET(ctx: FreshContext) { @@ -67,7 +68,7 @@ export const handler = { await storage.write(filePath, new TextEncoder().encode(fm + "\n" + defaultContent)); await engine.rebuild(); - const webhookEndpoints = config.admin?.webhooks ?? []; + const webhookEndpoints = adminSettings(config).webhooks; const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; if (hooks) hooks.fire("onPageCreate", { sourcePath: `${pagePath}/default${ext}`, title }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onPageCreate", { sourcePath: `${pagePath}/default${ext}`, title }, runtimeDir, ctx.state.adminContext.services.security); diff --git a/src/admin/routes/api/staging/[...path]/publish.ts b/src/admin/routes/api/staging/[...path]/publish.ts index 19a5660..0ba9134 100644 --- a/src/admin/routes/api/staging/[...path]/publish.ts +++ b/src/admin/routes/api/staging/[...path]/publish.ts @@ -9,6 +9,7 @@ import { requirePermission, requireTsxWrite, requireOwnedPage, json, serverError import { stringify as stringifyYaml } from "@std/yaml"; import { announcePageEvent } from "../../../../page-events.ts"; import type { FreshContext } from "fresh"; +import { adminSettings } from "../../../../settings.ts"; async function maybeGitCommit( filePath: string, sourcePath: string, author: string | undefined, @@ -66,7 +67,7 @@ export const handler = { }); } - await maybeGitCommit(filePath, pagePath, authResult.user?.name, config.admin?.git_commit); + await maybeGitCommit(filePath, pagePath, authResult.user?.name, adminSettings(config).git_commit); await staging.discard(pagePath); await engine.rebuild(); announcePageEvent(ctx, { event: "onPageUpdate", payload: { sourcePath: pageIndex.sourcePath } }); diff --git a/src/admin/routes/api/users/[id]/password.ts b/src/admin/routes/api/users/[id]/password.ts index 764f236..c0cb66f 100644 --- a/src/admin/routes/api/users/[id]/password.ts +++ b/src/admin/routes/api/users/[id]/password.ts @@ -3,6 +3,7 @@ import type { AdminState } from "../../../../types.ts"; import { requirePermission, json, serverError, actorFromAuth, getClientIp, csrfCheck } from "../../_utils.ts"; import type { FreshContext } from "fresh"; +import { adminSettings } from "../../../../settings.ts"; export const handler = { async POST(ctx: FreshContext) { @@ -29,7 +30,7 @@ export const handler = { const session = await sessions.create(userId); res.headers.set( "Set-Cookie", - auth.createSessionCookie(session.id, config.admin?.session_lifetime ?? 86400), + auth.createSessionCookie(session.id, adminSettings(config).session_lifetime), ); } diff --git a/src/admin/routes/login.tsx b/src/admin/routes/login.tsx index f8ca0d3..7cfa79c 100644 --- a/src/admin/routes/login.tsx +++ b/src/admin/routes/login.tsx @@ -11,6 +11,7 @@ import { findOrProvisionUser } from "../auth/provisioner.ts"; import type { PluginServices } from "@dune/core/plugin-api"; import type { RateLimiter } from "@dune/core/security"; import { actorFromAuth, csrfCheck } from "./api/_utils.ts"; +import { adminSettings } from "../settings.ts"; // Module-level fallback limiter — used when no rateLimitStore is injected via // AdminContext (single-process deployments, tests, etc.). @@ -110,7 +111,7 @@ export const handler = { if (csrfDenied) return csrfDenied; const { auth, users, sessions, prefix, auditLogger, authProvider, config, rateLimitStore, services } = ctx.state.adminContext; - const adminConfig = config.admin!; + const adminConfig = adminSettings(config); const url = ctx.url; // Logout sub-action diff --git a/src/admin/settings.ts b/src/admin/settings.ts new file mode 100644 index 0000000..a470b46 --- /dev/null +++ b/src/admin/settings.ts @@ -0,0 +1,101 @@ +/** + * The admin's own settings. + * + * They are the plugin's config: `config:` under its entry in site.yaml's + * `plugins:`, which core merges into `config.plugins.admin`. Core has no + * `admin` section. Everything is read through {@link adminSettings}, which + * applies the defaults and refuses a value that would desync the admin. + * + * @module + */ + +/** Content events that can trigger an outbound webhook. */ +export type WebhookContentEvent = + | "onPageCreate" + | "onPageUpdate" + | "onPageDelete" + | "onPageMove" + | "onWorkflowChange"; + +/** One outbound webhook endpoint. */ +export interface WebhookEndpointConfig { + url: string; + /** HMAC-SHA256 signing secret; `$ENV_VAR` is expanded. */ + secret?: string; + /** Which events trigger this endpoint. */ + events: WebhookContentEvent[]; + /** Disable without removing (default true). */ + enabled?: boolean; + /** Label used in delivery logs. */ + label?: string; + /** Allow delivery to private/loopback addresses (default false). */ + allow_private?: boolean; +} + +/** Sign-in provider configuration: only the built-in local passwords are configurable. */ +export type AuthProviderConfig = { type: "local" }; + +/** The admin plugin's settings, defaults applied. */ +export interface AdminSettings { + /** URL prefix the admin is mounted at (default `/admin`). */ + path: string; + /** Admin session lifetime in seconds (default 86400). */ + session_lifetime: number; + /** Maximum admin upload body in MB (default 100). */ + max_upload_mb: number; + /** Commit to git after every page save (default false). */ + git_commit: boolean; + /** Outbound webhooks. */ + webhooks: WebhookEndpointConfig[]; + /** Sign-in provider; omit for the local passwords. */ + auth_provider?: AuthProviderConfig; + /** Audit trail: `enabled` (default true) and `log_file` (under the site root). */ + audit?: { enabled?: boolean; log_file?: string }; +} + +const PATH_RE = /^\/[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/; + +/** The part of the site config the admin reads settings from. */ +export interface SettingsSource { + plugins?: Record | undefined>; +} + +const cache = new WeakMap(); +const EMPTY = {}; + +/** + * The admin's settings with defaults applied. Throws on an unusable `path` or + * `auth_provider`, so a bad value stops the site at boot instead of leaving the + * admin mounted somewhere core does not protect. + */ +export function adminSettings(config: SettingsSource): AdminSettings { + const raw = (config.plugins?.admin ?? EMPTY) as Record; + const hit = cache.get(raw); + if (hit) return hit; + + const path = raw.path ?? "/admin"; + if (typeof path !== "string" || !PATH_RE.test(path)) { + throw new Error( + `[dune] admin plugin config: path must start with "/", contain only [a-zA-Z0-9_-] segments and have no ` + + `trailing slash (e.g. "/admin", "/cms"), got ${JSON.stringify(path)}`, + ); + } + const provider = raw.auth_provider as { type?: unknown } | undefined; + if (provider !== undefined && provider.type !== "local") { + throw new Error( + `[dune] admin plugin config: auth_provider.type must be "local" (got ${JSON.stringify(provider.type)}); ` + + `LDAP and SAML were never implemented. Pass a custom provider to createAdminPlugin({ authProvider }) instead.`, + ); + } + const settings: AdminSettings = { + path, + session_lifetime: typeof raw.session_lifetime === "number" ? raw.session_lifetime : 86400, + max_upload_mb: typeof raw.max_upload_mb === "number" ? raw.max_upload_mb : 100, + git_commit: raw.git_commit === true, + webhooks: Array.isArray(raw.webhooks) ? raw.webhooks as WebhookEndpointConfig[] : [], + auth_provider: provider as AuthProviderConfig | undefined, + audit: raw.audit as AdminSettings["audit"], + }; + cache.set(raw, settings); + return settings; +} diff --git a/src/admin/webhooks.ts b/src/admin/webhooks.ts index 3a2f814..8904e3a 100644 --- a/src/admin/webhooks.ts +++ b/src/admin/webhooks.ts @@ -22,7 +22,7 @@ import type { SecurityService } from "@dune/core/plugin-api"; /** The slice of the security service webhook delivery needs. */ export type WebhookSecurity = Pick; -import type { WebhookContentEvent, WebhookEndpointConfig } from "@dune/core/config"; +import type { WebhookContentEvent, WebhookEndpointConfig } from "./settings.ts"; // === Types === diff --git a/src/admin/workflow-actions.ts b/src/admin/workflow-actions.ts index 84871a3..1260aa7 100644 --- a/src/admin/workflow-actions.ts +++ b/src/admin/workflow-actions.ts @@ -19,6 +19,7 @@ import type { DuneConfig } from "@dune/core/config"; import type { HookRegistry } from "@dune/core/hooks"; import type { WorkflowEngine } from "@dune/core/workflow"; import { fireContentWebhooks } from "./webhooks.ts"; +import { adminSettings } from "./settings.ts"; export interface WorkflowActionContext { engine: DuneEngine; @@ -75,7 +76,7 @@ export async function applyWorkflowTransition( await storage.write(filePath, new TextEncoder().encode(updated)); await engine.rebuild(); - const webhookEndpoints = config.admin?.webhooks ?? []; + const webhookEndpoints = adminSettings(config).webhooks; const runtimeDir = services.paths.runtimeDir; if (hooks) hooks.fire("onWorkflowChange", { sourcePath, from: currentStatus, to: newStatus }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onWorkflowChange", { sourcePath, from: currentStatus, to: newStatus }, runtimeDir, services.security); diff --git a/src/audit/trail.ts b/src/audit/trail.ts index 0b5b83c..3735130 100644 --- a/src/audit/trail.ts +++ b/src/audit/trail.ts @@ -7,6 +7,7 @@ import { join } from "@std/path"; import type { DuneConfig } from "@dune/core/config"; import { AuditLogger } from "./logger.ts"; +import { adminSettings } from "../admin/settings.ts"; export interface AuditTrailOptions { config: DuneConfig; @@ -23,7 +24,7 @@ export interface AuditTrailOptions { */ export function createAuditTrail(opts: AuditTrailOptions): AuditLogger | null { const { config, root, runtimeDir } = opts; - const audit = config.admin?.audit; + const audit = adminSettings(config).audit; if (audit?.enabled === false || !root) return null; const logFile = audit?.log_file ? join(root, audit.log_file) : join(root, runtimeDir, "audit.log"); const containmentRoot = root.endsWith("/") || root.endsWith("\\") ? root : root + "/"; diff --git a/tests/admin/capabilities_e2e_test.ts b/tests/admin/capabilities_e2e_test.ts index 6a5cd77..78c559a 100644 --- a/tests/admin/capabilities_e2e_test.ts +++ b/tests/admin/capabilities_e2e_test.ts @@ -39,8 +39,8 @@ Deno.test( assertEquals(anonymous.authenticated, false); assertEquals(auth.sessionCookies, ["dune_session"]); const providers = Object.fromEntries(ctx.capabilities.providers().map((p) => [p.name, p.provider])); - assertEquals(providers["auth.session"], "dune-admin"); - assertEquals(providers["scheduler.executor"], "dune-admin"); + assertEquals(providers["auth.session"], "admin"); + assertEquals(providers["scheduler.executor"], "admin"); }); }, ); @@ -52,7 +52,7 @@ Deno.test( await withSite([], (ctx) => { assertEquals(ctx.capabilities.get("admin.prefix"), { prefix: "/admin" }); const providers = Object.fromEntries(ctx.capabilities.providers().map((p) => [p.name, p.provider])); - assertEquals(providers["admin.prefix"], "dune-admin"); + assertEquals(providers["admin.prefix"], "admin"); return Promise.resolve(); }); @@ -60,9 +60,12 @@ Deno.test( try { await Deno.mkdir(join(root, "content", "01.home"), { recursive: true }); await Deno.writeTextFile(join(root, "content", "01.home", "default.md"), "---\ntitle: Home\n---\n"); - await Deno.mkdir(join(root, "config"), { recursive: true }); - await Deno.writeTextFile(join(root, "config", "system.yaml"), "admin:\n path: /cms\n"); - const ctx = await bootstrap(root, { plugins: [createAdminPlugin({ root, dev: false })] }); + // The admin's settings are its plugin config: `config:` under its entry in site.yaml. + const ctx = await bootstrap(root, { + plugins: [createAdminPlugin({ root, dev: false })], + // deno-lint-ignore no-explicit-any + configOverrides: { plugins: { admin: { path: "/cms" } } } as any, + }); const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); assertEquals(ctx.capabilities.get("admin.prefix"), { prefix: "/cms" }); // The panel answers at the provided prefix, and /admin is just a missing content page. diff --git a/tests/admin/pages_edit_blueprint_test.ts b/tests/admin/pages_edit_blueprint_test.ts index d32ceda..08db562 100644 --- a/tests/admin/pages_edit_blueprint_test.ts +++ b/tests/admin/pages_edit_blueprint_test.ts @@ -64,7 +64,7 @@ function makeCtx(opts: { services: makeServices(null, { getBlueprints: () => engine.blueprints as never }), engine, storage: opts.storage, - config: { system: { content: { dir: "content" } }, admin: {} }, + config: { system: { content: { dir: "content" } }, plugins: {} }, // authz.check() always allows — this test exercises page-edit // behavior, not permission logic (ROLE_PERMISSIONS/hasPermission() // were removed in 3.0.0; authz.check() is the sole authority now). diff --git a/tests/admin/plugin_admin_routes_test.ts b/tests/admin/plugin_admin_routes_test.ts index 2085379..c6328bb 100644 --- a/tests/admin/plugin_admin_routes_test.ts +++ b/tests/admin/plugin_admin_routes_test.ts @@ -40,7 +40,7 @@ function fc(opts: { adminContext: { authz: { check: () => Promise.resolve(opts.allow !== false) }, csrfSecret: "test-secret", - config: { admin: {} }, + config: { plugins: {} }, }, }, }; diff --git a/tests/admin/settings_test.ts b/tests/admin/settings_test.ts new file mode 100644 index 0000000..17fc197 --- /dev/null +++ b/tests/admin/settings_test.ts @@ -0,0 +1,54 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { adminSettings } from "../../src/admin/settings.ts"; + +Deno.test("adminSettings: defaults when the plugin has no config", () => { + for (const config of [{}, { plugins: {} }, { plugins: { admin: {} } }]) { + assertEquals(adminSettings(config), { + path: "/admin", + session_lifetime: 86400, + max_upload_mb: 100, + git_commit: false, + webhooks: [], + auth_provider: undefined, + audit: undefined, + }); + } +}); + +Deno.test("adminSettings: reads the plugin's own config", () => { + const s = adminSettings({ + plugins: { + admin: { + path: "/cms", + session_lifetime: 3600, + max_upload_mb: 5, + git_commit: true, + webhooks: [{ url: "https://hooks.example.test", events: ["onPageUpdate"] }], + auth_provider: { type: "local" }, + audit: { enabled: false }, + }, + }, + }); + assertEquals(s.path, "/cms"); + assertEquals(s.session_lifetime, 3600); + assertEquals(s.max_upload_mb, 5); + assertEquals(s.git_commit, true); + assertEquals(s.webhooks.length, 1); + assertEquals(s.audit, { enabled: false }); +}); + +Deno.test("adminSettings: a path that would desync the admin stops the boot", () => { + for (const path of ["admin", "/", "/admin/", "/a b", "/admin?x", 7]) { + assertThrows(() => adminSettings({ plugins: { admin: { path } } }), Error, "path must start with"); + } +}); + +Deno.test("adminSettings: auth_provider other than local is refused", () => { + for (const type of ["ldap", "saml", undefined]) { + assertThrows(() => adminSettings({ plugins: { admin: { auth_provider: { type } } } }), Error, "auth_provider.type"); + } +}); + +Deno.test("adminSettings: another plugin's config is not the admin's", () => { + assertEquals(adminSettings({ plugins: { "dune-admin": { path: "/old" }, seo: { path: "/x" } } }).path, "/admin"); +}); diff --git a/tests/admin/theme_config_test.ts b/tests/admin/theme_config_test.ts index 5b206a2..a630dc3 100644 --- a/tests/admin/theme_config_test.ts +++ b/tests/admin/theme_config_test.ts @@ -52,7 +52,7 @@ function makeCtx(opts: { services: makeServices(opts.storage), engine, storage: opts.storage, - config: { admin: { data_dir: "data" } }, + config: { plugins: {} }, // authz.check() always allows — this test exercises theme-config // behavior, not permission logic (ROLE_PERMISSIONS/hasPermission() // were removed in 3.0.0; authz.check() is the sole authority now). diff --git a/tests/admin/workflow_actions_test.ts b/tests/admin/workflow_actions_test.ts index b491b96..f30c3df 100644 --- a/tests/admin/workflow_actions_test.ts +++ b/tests/admin/workflow_actions_test.ts @@ -48,7 +48,7 @@ function makePageIndex(overrides: Partial = {}): PageIndex { const CONFIG = { system: { content: { dir: "content" } }, - admin: { webhooks: [] }, + plugins: { admin: { webhooks: [] } }, } as unknown as DuneConfig; function makeEngine(pages: PageIndex[]) { diff --git a/tests/audit_trail_test.ts b/tests/audit_trail_test.ts index 74aee31..f111bf3 100644 --- a/tests/audit_trail_test.ts +++ b/tests/audit_trail_test.ts @@ -2,7 +2,7 @@ import { assertEquals, assertExists } from "@std/assert"; import type { DuneConfig } from "@dune/core/config"; import { createAuditTrail } from "../src/audit/mod.ts"; -const config = (admin?: Record) => ({ system: {}, site: {}, admin } as unknown as DuneConfig); +const config = (admin?: Record) => ({ system: {}, site: {}, plugins: { admin } } as unknown as DuneConfig); const entry = { event: "page.update", actor: null, ip: null, userAgent: null, target: null, detail: {} } as never; Deno.test("createAuditTrail: no site root, no trail", () => { From 541b28efb3ca571c57e3d58e159adbd0de9c4fd6 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 01:13:09 +0100 Subject: [PATCH 19/21] feat(admin)!: machine_translation is part of the admin's plugin config Read from config.plugins.admin.machine_translation with api_key / base_url (were apiKey / baseUrl). Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 ++ README.md | 3 +++ mod.ts | 4 ++-- src/admin/settings.ts | 5 +++++ src/mt/factory.ts | 4 ++-- src/mt/types.ts | 4 ++-- tests/admin/settings_test.ts | 1 + tests/mt/ssrf_test.ts | 2 +- 8 files changed, 18 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23bc13f..5c45f24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ follows [Semantic Versioning](https://semver.org). than `local`, stops the site instead of leaving the admin mounted where core does not protect it. `WebhookEndpointConfig`, `WebhookContentEvent` and `AuthProviderConfig` moved here from `@dune/core`. `runtime_dir` and `max_revisions` stay core settings (`system.yaml`). +- **`machine_translation` is a setting of the admin's config** (it was `site.machine_translation`), with `api_key` + and `base_url` (they were `apiKey` and `baseUrl`); `dune migrate:config` moves and renames it. - `adminSettings(config)` (`src/admin/settings.ts`) is the one place the settings are read. ### Changed — BREAKING (the admin is a plugin you list) diff --git a/README.md b/README.md index 036ad0e..cb5c504 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,9 @@ plugins: audit: enabled: true log_file: .dune/admin/audit.log # must resolve under the site root + machine_translation: # optional: translate pages from the editor + provider: deepl # deepl | google | libretranslate + api_key: $DEEPL_API_KEY webhooks: # outbound webhooks on content events - url: https://hooks.example.com/content secret: $WEBHOOK_SECRET diff --git a/mod.ts b/mod.ts index d0aaa84..fd1277f 100644 --- a/mod.ts +++ b/mod.ts @@ -27,7 +27,7 @@ import type { ScheduledAction } from "@dune/core/workflow"; import { applyWorkflowTransition, SCHEDULED_ACTION_STATUS } from "./src/admin/workflow-actions.ts"; import { createCommentManager } from "./src/admin/comments.ts"; import { createCollabManager } from "./src/collab/mod.ts"; -import { createMachineTranslator, type MachineTranslationConfig, type MachineTranslator } from "./src/mt/mod.ts"; +import { createMachineTranslator, type MachineTranslator } from "./src/mt/mod.ts"; import { type AuditLogger, createAuditTrail } from "./src/audit/mod.ts"; import { initAdminContext } from "./src/admin/context.ts"; import type { AdminContext } from "./src/admin/context.ts"; @@ -111,7 +111,7 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { // The audit trail and the machine translator are the admin's own. Translator // requests go through core's SSRF-checked fetch. const auditLogger = createAuditTrail({ config, root: opts.root, runtimeDir: api.services.paths.runtimeDir }); - const mtConfig = (config.site as { machine_translation?: MachineTranslationConfig }).machine_translation; + const mtConfig = adminCfg.machine_translation; const mt = mtConfig ? createMachineTranslator(mtConfig, api.services.security.safeFetch) : null; setupState = { config, storage, opts, auditLogger, mt }; diff --git a/src/admin/settings.ts b/src/admin/settings.ts index a470b46..ae13a6a 100644 --- a/src/admin/settings.ts +++ b/src/admin/settings.ts @@ -35,6 +35,8 @@ export interface WebhookEndpointConfig { /** Sign-in provider configuration: only the built-in local passwords are configurable. */ export type AuthProviderConfig = { type: "local" }; +import type { MachineTranslationConfig } from "../mt/types.ts"; + /** The admin plugin's settings, defaults applied. */ export interface AdminSettings { /** URL prefix the admin is mounted at (default `/admin`). */ @@ -51,6 +53,8 @@ export interface AdminSettings { auth_provider?: AuthProviderConfig; /** Audit trail: `enabled` (default true) and `log_file` (under the site root). */ audit?: { enabled?: boolean; log_file?: string }; + /** Machine translation provider; absent when none is configured. */ + machine_translation?: MachineTranslationConfig; } const PATH_RE = /^\/[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/; @@ -95,6 +99,7 @@ export function adminSettings(config: SettingsSource): AdminSettings { webhooks: Array.isArray(raw.webhooks) ? raw.webhooks as WebhookEndpointConfig[] : [], auth_provider: provider as AuthProviderConfig | undefined, audit: raw.audit as AdminSettings["audit"], + machine_translation: raw.machine_translation as MachineTranslationConfig | undefined, }; cache.set(raw, settings); return settings; diff --git a/src/mt/factory.ts b/src/mt/factory.ts index 6a4be0c..6a371c6 100644 --- a/src/mt/factory.ts +++ b/src/mt/factory.ts @@ -19,7 +19,7 @@ export function createMachineTranslator( ): MachineTranslator | null { if (config.enabled === false) return null; - const rawKey = config.apiKey ?? ""; + const rawKey = config.api_key ?? ""; const key = rawKey.startsWith("$") ? (Deno.env.get(rawKey.slice(1)) ?? rawKey) : rawKey; @@ -31,7 +31,7 @@ export function createMachineTranslator( return new GoogleTranslator(key, safeFetch); case "libretranslate": return new LibreTranslateTranslator( - config.baseUrl ?? "https://libretranslate.com", + config.base_url ?? "https://libretranslate.com", key || undefined, safeFetch, ); diff --git a/src/mt/types.ts b/src/mt/types.ts index 78cbd65..6806ca7 100644 --- a/src/mt/types.ts +++ b/src/mt/types.ts @@ -27,12 +27,12 @@ export interface MachineTranslationConfig { * API key for the provider. * Supports "$ENV_VAR" expansion. */ - apiKey?: string; + api_key?: string; /** * For LibreTranslate: base URL of the self-hosted instance. * Default: "https://libretranslate.com" */ - baseUrl?: string; + base_url?: string; /** Whether machine translation is enabled (default: true when this block is present) */ enabled?: boolean; } diff --git a/tests/admin/settings_test.ts b/tests/admin/settings_test.ts index 17fc197..4cf937d 100644 --- a/tests/admin/settings_test.ts +++ b/tests/admin/settings_test.ts @@ -11,6 +11,7 @@ Deno.test("adminSettings: defaults when the plugin has no config", () => { webhooks: [], auth_provider: undefined, audit: undefined, + machine_translation: undefined, }); } }); diff --git a/tests/mt/ssrf_test.ts b/tests/mt/ssrf_test.ts index df087c5..e68666c 100644 --- a/tests/mt/ssrf_test.ts +++ b/tests/mt/ssrf_test.ts @@ -23,7 +23,7 @@ Deno.test("createMachineTranslator: sends provider requests through the given fe return Promise.resolve(new Response(JSON.stringify({ translatedText: "hola" }))); }; const mt = createMachineTranslator( - { provider: "libretranslate", baseUrl: "https://mt.example" }, + { provider: "libretranslate", base_url: "https://mt.example" }, fake, ); assertEquals(await mt!.translate("hello", "en", "es"), "hola"); From 7225112a87d8b96212ce779233454fea117824b0 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 16:20:38 +0100 Subject: [PATCH 20/21] fix!: permissions are checked on app:site, not app:admin Matches @dune/core's rename (duneorg/dune#130): the resource names the site content being protected, not the panel. Co-Authored-By: Claude Sonnet 5.5 --- src/admin/routes/_middleware.ts | 6 +++--- src/admin/routes/api/_utils.ts | 2 +- src/admin/routes/api/users/[id]/index.ts | 6 +++--- src/admin/routes/api/users/index.ts | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/admin/routes/_middleware.ts b/src/admin/routes/_middleware.ts index 395ed2e..595d1a5 100644 --- a/src/admin/routes/_middleware.ts +++ b/src/admin/routes/_middleware.ts @@ -136,7 +136,7 @@ export function toAdminRelative(pathname: string, normalizedPrefix: string): str * in the union — it only ever existed on the removed `ROLE_PERMISSIONS` * table and has no corresponding action in `@dune/core`'s authz schema * (`actionToRelations` never defined it). Panel access is `canThey: "access"` - * on `{ type: "app", id: "admin" }`, enforced by this middleware's gate. + * on `{ type: "app", id: "site" }`, enforced by this middleware's gate. */ const NAV_PERMISSIONS: readonly AdminPermission[] = [ "pages.create", @@ -177,7 +177,7 @@ export async function computeNavPermissions( who: { type: "user", id: userId }, // deno-lint-ignore no-explicit-any canThey: permission as any, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }); return allowed ? permission : null; }), @@ -229,7 +229,7 @@ export async function handler( const canAccess = await adminCtx.authz.check({ who: { type: "user", id: authResult.user.id }, canThey: "access", - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }); if (!canAccess) { return withSecurityHeaders(new Response("Forbidden", { status: 403 }), frameable); diff --git a/src/admin/routes/api/_utils.ts b/src/admin/routes/api/_utils.ts index 0436d36..919be47 100644 --- a/src/admin/routes/api/_utils.ts +++ b/src/admin/routes/api/_utils.ts @@ -148,7 +148,7 @@ export async function checkPermission( return await authz.check({ who: { type: "user", id: authResult.user.id }, canThey: permission as any, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }); } diff --git a/src/admin/routes/api/users/[id]/index.ts b/src/admin/routes/api/users/[id]/index.ts index 81ab1b5..ea470f4 100644 --- a/src/admin/routes/api/users/[id]/index.ts +++ b/src/admin/routes/api/users/[id]/index.ts @@ -69,7 +69,7 @@ export const handler = { await authz.disallowAllMatching({ who: { type: "user", id: userId }, was: existingRole, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }).catch((err) => { console.warn(`[dune/authz] disallowAllMatching failed on role change for user ${userId}:`, err); }); @@ -77,7 +77,7 @@ export const handler = { await authz.allow({ who: { type: "user", id: userId }, toBe: role, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }); } if (enabled !== undefined) { @@ -96,7 +96,7 @@ export const handler = { await authz.allow({ who: { type: "user", id: userId }, toBe: effectiveRole, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }).catch((err) => { console.warn(`[dune/authz] allow failed on re-enable for user ${userId}:`, err); }); diff --git a/src/admin/routes/api/users/index.ts b/src/admin/routes/api/users/index.ts index 55f4e3d..955a1ed 100644 --- a/src/admin/routes/api/users/index.ts +++ b/src/admin/routes/api/users/index.ts @@ -79,7 +79,7 @@ export const handler = { await authz.allow({ who: { type: "user", id: user.id }, toBe: role, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }).catch(() => {}); } From c692e33f2b552cdb7a9c5988a02f6844433576f4 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 19:33:26 +0100 Subject: [PATCH 21/21] chore: use the editor.session and editor.inline slot names (duneorg/dune#125) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 10 +++++----- mod.ts | 4 ++-- src/admin/context.ts | 2 +- tests/admin/capabilities_e2e_test.ts | 8 ++++---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c45f24..ed70b83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,7 +104,7 @@ follows [Semantic Versioning](https://semver.org). ### Added - **Runs the admin conformance suite from `@dune/testing`** (`tests/admin/conformance_test.ts`): the checks - the stub admin passes, against plugin-admin over a real temp site. It covers `auth.session`, session + the stub admin passes, against plugin-admin over a real temp site. It covers `editor.session`, session recognition and marker scrubbing, the declared `dune_session` cookie, mount-order independence, `onPageUpdate` fired once by an edit, `admin.pages` / `admin.routes` mounted behind the login and the CSRF check, and the site serving with no admin. Needs `@dune/testing` with the conformance suite @@ -114,20 +114,20 @@ follows [Semantic Versioning](https://semver.org). - **The admin provides and consumes core's capabilities instead of the untyped `adminContext` bag (duneorg/dune#97, `@dune/core` 0.36).** It provides - `auth.session` (who is the admin on a request: the admin bar and the inline-edit + `editor.session` (who is the admin on a request: the admin bar and the inline-edit WebSocket's admin fallback) and `scheduler.executor` (runs due scheduled actions; `dune serve` starts core's scheduler loop when it exists). It reads the - `inline-edit` and `editor.content` slots and the `admin.pages` contributions from + `editor.inline` and `editor.content` slots and the `admin.pages` contributions from `MountApi.capabilities` instead of `adminServices` and each plugin's `adminPages`, and no longer writes `bootstrap.adminContext`. The jobs view reads `BootstrapResult.jobScheduler`, which `dune serve` sets after mount. - `auth.session` declares `sessionCookies: ["dune_session"]`, which core uses to decide + `editor.session` declares `sessionCookies: ["dune_session"]`, which core uses to decide whether to authenticate a public page and whether to keep the response out of the shared page cache. - **`mountDuneAdminEarly()` is gone** (core removed `DunePlugin.mountEarly`). `mountDuneAdmin(app, ctx, adminContext?)` falls back to the single-site context stored by the plugin's `mount()` instead of `ctx.adminContext`. A plugin route that - needs the admin identity calls `capabilities.get("auth.session")`. Routes a plugin + needs the admin identity calls `capabilities.get("editor.session")`. Routes a plugin registers in its own `mount()` no longer sit behind the admin's `ctx.state.adminContext` middleware, so `withGuards()` no longer works there (it throws a `TypeError`); a guarded `POST`/`PUT`/`PATCH`/`DELETE` admin route is now a diff --git a/mod.ts b/mod.ts index fd1277f..602d373 100644 --- a/mod.ts +++ b/mod.ts @@ -123,7 +123,7 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { // cache, the static build and the plugin-route namespace. The default // `/admin` lives here, not in core. api.capabilities.provide("admin.prefix", () => ({ prefix: adminCfg.path })); - api.capabilities.provide("auth.session", () => ({ + api.capabilities.provide("editor.session", () => ({ // The cookie the admin's auth middleware sets (see createAuthMiddleware). // Core uses it to decide whether to authenticate a public page and whether // to keep the response out of the shared page cache. @@ -272,7 +272,7 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { }); // ── Default content editor ──────────────────────────────────────────────── - const inlineEdit = capabilities.get("inline-edit"); + const inlineEdit = capabilities.get("editor.inline"); const contentEditor = capabilities.get("editor.content") ?? createBlockEditorPlugin(); // ── Build AdminContext ──────────────────────────────────────────────────── diff --git a/src/admin/context.ts b/src/admin/context.ts index a62fc2f..e90e973 100644 --- a/src/admin/context.ts +++ b/src/admin/context.ts @@ -66,7 +66,7 @@ export interface AdminContext { staging?: StagingEngine; comments?: CommentManager; collab?: CollabManager; - /** Inline editing manager, provided by a plugin as the `inline-edit` capability. */ + /** Inline editing manager, provided by a plugin as the `editor.inline` capability. */ inlineEdit?: InlineEditManager; /** Custom page editor, provided by a plugin as the `editor.content` capability. Replaces the built-in block editor. */ contentEditor?: ContentEditorPlugin; diff --git a/tests/admin/capabilities_e2e_test.ts b/tests/admin/capabilities_e2e_test.ts index 78c559a..4c22822 100644 --- a/tests/admin/capabilities_e2e_test.ts +++ b/tests/admin/capabilities_e2e_test.ts @@ -1,5 +1,5 @@ /** - * End to end against a real core: the admin plugin provides `auth.session` and + * End to end against a real core: the admin plugin provides `editor.session` and * `scheduler.executor`, consumes `admin.pages` contributions, and no longer * writes core's removed `adminContext` bag. */ @@ -28,18 +28,18 @@ async function withSite( } Deno.test( - "admin provides auth.session and scheduler.executor", + "admin provides editor.session and scheduler.executor", { sanitizeOps: false, sanitizeResources: false }, async () => { await withSite([], async (ctx) => { - const auth = ctx.capabilities.get("auth.session"); + const auth = ctx.capabilities.get("editor.session"); assertExists(auth); assertExists(ctx.capabilities.get("scheduler.executor")); const anonymous = await auth.authenticate(new Request("http://localhost/")); assertEquals(anonymous.authenticated, false); assertEquals(auth.sessionCookies, ["dune_session"]); const providers = Object.fromEntries(ctx.capabilities.providers().map((p) => [p.name, p.provider])); - assertEquals(providers["auth.session"], "admin"); + assertEquals(providers["editor.session"], "admin"); assertEquals(providers["scheduler.executor"], "admin"); }); },