From 0c82f7515c57dbbbec8c038a38daf83f3d39b3db Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 21:33:55 +0100 Subject: [PATCH 01/31] feat(plugin-api)!: the contract names no Fresh type (#97, decision 17) MountApi.app is a PluginApp and route handlers receive a PluginRouteContext; Fresh's App and FreshContext satisfy both structurally. Handler properties in the contribution types are methods, so a plugin that needs ctx.render() still passes a full FreshContext handler. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 14 ++++++ src/hooks/types.ts | 38 ++++++++------- src/plugin-api/app.ts | 55 ++++++++++++++++++++++ src/plugin-api/mod.ts | 1 + tests/plugin-api/plugin_app_test.ts | 73 +++++++++++++++++++++++++++++ 5 files changed, 164 insertions(+), 17 deletions(-) create mode 100644 src/plugin-api/app.ts create mode 100644 tests/plugin-api/plugin_app_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eaa1ba5..0512f546 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -161,6 +161,20 @@ where the setting lives now (see `dune migrate:config`, which reports these too) from a plugin; `services` and `capabilities` are unchanged. Migrate `mount({ bootstrap })` to `mount({ runtime })` and read the field you need from it. The `PluginRuntime` type is exported from `@dune/core/plugin-api`. +- **The plugin contract names no Fresh type (#97).** `MountApi.app` is a + `PluginApp` (`use`, `get`, `post`, `put`, `patch`, `delete`, `all`) and route + handlers (`PublicRouteRegistration`, `AdminPageRegistration`, + `AdminRouteRegistration`, `ContentEditorPlugin.pageEditorHandler`) receive a + `PluginRouteContext` (`req`, `url`, `params`, `state`, `next()`), both exported + from `@dune/core/plugin-api`. Fresh's `App` and `FreshContext` satisfy them, so + nothing changes at runtime and a handler that only reads the request keeps + working; the point is that the frozen contract no longer pins a Fresh version. + A plugin that needs Fresh-only features (`ctx.render()`, islands, + `app.fsRoutes()`, `app.layout()`, `app.route()`) depends on Fresh itself: the + handler properties are declared as methods, so a handler taking a full + `FreshContext` is still accepted, and `app` is cast to Fresh's `App`. + Breaking only for a plugin that called a Fresh-only method on `app` without a + cast (a type error, fixed with `app as unknown as App`). ### Added diff --git a/src/hooks/types.ts b/src/hooks/types.ts index c0047205..79990185 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -7,7 +7,7 @@ import type { DuneConfig } from "../config/types.ts"; import type { StorageAdapter } from "../storage/types.ts"; import type { BlueprintField } from "../blueprints/types.ts"; -import type { FreshContext } from "fresh"; +import type { PluginApp, PluginRouteContext } from "../plugin-api/app.ts"; import type { ContentApi } from "../content/api.ts"; import type { AuthzRelation } from "../auth/authz-schema.ts"; @@ -233,9 +233,12 @@ export interface PublicRouteRegistration { * Example: `/newsletter/confirm` */ path: string; - /** Fresh handler for this route. */ - // deno-lint-ignore no-explicit-any - handler: (ctx: FreshContext) => Response | Promise; + /** + * Handler for this route. Declared as a method so a handler that takes a + * full Fresh context (`ctx.render()`, islands) is accepted; core always + * passes one. + */ + handler(ctx: PluginRouteContext): Response | Promise; /** * Absolute path to an island module used by this route. * Included in the Builder's island scan so it's compiled into the bundle. @@ -328,8 +331,8 @@ export interface AdminPageRegistration { * by leaving a field out. */ permission: string; - /** Fresh GET handler for the page. */ - handler: (ctx: FreshContext) => Promise | Response; + /** GET handler for the page (a method, so a full Fresh context is accepted: see {@link PluginRouteContext}). */ + handler(ctx: PluginRouteContext): Promise | Response; } /** @@ -367,8 +370,8 @@ export interface AdminRouteRegistration { * leaving a field out. */ permission: string; - /** Fresh handler; runs only after authentication, CSRF and permission checks pass. */ - handler: (ctx: FreshContext) => Promise | Response; + /** Handler; runs only after authentication, CSRF and permission checks pass (a method, so a full Fresh context is accepted). */ + handler(ctx: PluginRouteContext): Promise | Response; } /** @@ -547,12 +550,12 @@ export interface DunePlugin { */ authzActions?: Record; /** - * Mount Fresh routes onto the app. + * Mount routes onto the app. * * Called by {@link mountPlugins} after `bootstrap()` completes and the Fresh * `App` instance is available, but before the app starts handling requests. - * Use this to register routes, middleware, and layouts that require a `FreshContext` - * (e.g. an admin panel sub-app, a dashboard, a set of auth routes). + * Use this to register routes and middleware (e.g. a dashboard, a set of + * auth routes). Handlers receive a {@link PluginRouteContext}. * * Capability slots (`auth.session`, `inline-edit`, ...) are resolved before * `mount()` runs and are read through `api.capabilities`, lazily (at request @@ -647,8 +650,7 @@ export interface ContentEditorPlugin { * The context state type is `AdminState` from `@dune/plugin-admin`; typed as * `any` here so core does not depend on admin-internal types. */ - // deno-lint-ignore no-explicit-any - pageEditorHandler(ctx: FreshContext): Response | Promise; + pageEditorHandler(ctx: PluginRouteContext): Response | Promise; /** * Optional WebSocket upgrade handler for real-time collaboration. @@ -735,11 +737,13 @@ export interface ResponseTransformContext { */ export interface MountApi { /** - * The Fresh `App` instance. Register routes, middleware, and layouts via - * `app.get()`, `app.use()`, `app.route()`, etc. + * Where routes and middleware are registered: `app.use()`, `app.get()`, + * `app.post()`, ... Core passes the Fresh `App`, but the contract promises + * only {@link PluginApp}. A plugin that needs Fresh-only features + * (`fsRoutes()`, `layout()`, `route()`) depends on Fresh and casts it, and + * then follows Fresh's versions itself. */ - // deno-lint-ignore no-explicit-any - app: import("fresh").App; + app: PluginApp; /** * The booted site: engine, config, storage, search, hooks, history, flex, * image cache, metrics, submissions, sections, authz and the job scheduler. diff --git a/src/plugin-api/app.ts b/src/plugin-api/app.ts new file mode 100644 index 00000000..58a15f52 --- /dev/null +++ b/src/plugin-api/app.ts @@ -0,0 +1,55 @@ +/** + * The route-registration surface of the contract: what a plugin's `mount()` + * sees of the Fresh `App`, and of the request context its handlers receive. + * + * Core's real `App` and `FreshContext` satisfy these structurally, so the + * contract names no Fresh type and pins no Fresh version. Only what plugins + * demonstrably use is here: registering middleware and routes, and reading the + * request, URL, route parameters and per-request state. + * + * A plugin that needs more (`ctx.render()`, islands, `app.fsRoutes()`, + * `app.layout()`) depends on Fresh itself and narrows what it is given: + * handler properties in the contract are declared as methods, so a handler + * that takes a full `FreshContext` is accepted where a `PluginRouteContext` is + * expected, and `app` can be cast to Fresh's `App`. Core always passes the + * real thing; the cost of narrowing is that such a plugin follows Fresh's + * versions, which this contract no longer promises to. + * + * @module + */ + +/** What a route handler or middleware receives. Core passes a Fresh `FreshContext`, which satisfies this. */ +export interface PluginRouteContext< + // deno-lint-ignore no-explicit-any + S = any, +> { + /** The incoming request. */ + readonly req: Request; + /** The request URL, parsed. */ + readonly url: URL; + /** Route parameters, e.g. `{ id: "7" }` for `/items/:id`. */ + readonly params: Record; + /** Per-request state shared by middleware and handlers. */ + state: S; + /** Run the rest of the chain and return its response (middleware only). */ + next(): Promise; +} + +/** A route handler or middleware. */ +// deno-lint-ignore no-explicit-any +export type PluginRouteHandler = (ctx: PluginRouteContext) => Response | Promise; + +/** What `mount()` receives as `app`. Core passes the Fresh `App`, which satisfies this. */ +export interface PluginApp { + /** Register middleware that runs for every request (or every request below a path). */ + use(...handlers: PluginRouteHandler[]): this; + use(path: string, ...handlers: PluginRouteHandler[]): this; + /** Register route handlers for a method and path (`/items/:id` style). */ + get(path: string, ...handlers: PluginRouteHandler[]): this; + post(path: string, ...handlers: PluginRouteHandler[]): this; + put(path: string, ...handlers: PluginRouteHandler[]): this; + patch(path: string, ...handlers: PluginRouteHandler[]): this; + delete(path: string, ...handlers: PluginRouteHandler[]): this; + /** Match any method. */ + all(path: string, ...handlers: PluginRouteHandler[]): this; +} diff --git a/src/plugin-api/mod.ts b/src/plugin-api/mod.ts index 688c2377..819bf8d5 100644 --- a/src/plugin-api/mod.ts +++ b/src/plugin-api/mod.ts @@ -33,5 +33,6 @@ export type { } from "./capabilities.ts"; export { createCapabilityRegistry } from "./capabilities.ts"; export type { CapabilityRegistry } from "./capabilities.ts"; +export type { PluginApp, PluginRouteContext, PluginRouteHandler } from "./app.ts"; export type { PluginRuntime } from "./runtime.ts"; export { type DuneErrorCode, isDuneError } from "./errors.ts"; diff --git a/tests/plugin-api/plugin_app_test.ts b/tests/plugin-api/plugin_app_test.ts new file mode 100644 index 00000000..add2284f --- /dev/null +++ b/tests/plugin-api/plugin_app_test.ts @@ -0,0 +1,73 @@ +import { assertEquals } from "@std/assert"; +import { join } from "@std/path"; +import { App, type FreshContext } from "fresh"; +import { bootstrap } from "../../src/runtime/bootstrap.ts"; +import { mountPlugins } from "../../src/plugins/loader.ts"; +import type { PluginApp, PluginRouteContext, PluginRouteHandler } from "../../src/plugin-api/app.ts"; +import type { AdminPageRegistration, AdminRouteRegistration, PublicRouteRegistration } from "../../src/hooks/types.ts"; +import type { DunePlugin } from "../../src/hooks/types.ts"; + +// ── Types: Fresh's App and FreshContext satisfy the contract ──────────────── + +Deno.test("the contract's route types are satisfied by Fresh's own, both ways", () => { + // deno-lint-ignore no-explicit-any + const app: PluginApp = new App(); + assertEquals(typeof app.get, "function"); + + const asContext = (fc: FreshContext<{ n: number }>): PluginRouteContext<{ n: number }> => fc; + assertEquals(typeof asContext, "function"); + + // A handler written against the contract runs inside Fresh. + const handler: PluginRouteHandler = (ctx) => new Response(ctx.url.pathname); + // deno-lint-ignore no-explicit-any + new App().get("/x", handler); + + // Contribution types take a handler written against a full Fresh context + // (they declare `handler` as a method, so the parameter is checked bivariantly). + const full = (ctx: FreshContext<{ n: number }>) => new Response(String(ctx.state.n)); + const page: AdminPageRegistration<{ n: number }> = { path: "/p", label: "P", permission: "pages.read", handler: full }; + const route: AdminRouteRegistration<{ n: number }> = { method: "POST", path: "/r", permission: "pages.update", handler: full }; + const pub: PublicRouteRegistration = { path: "/pub", handler: (ctx: FreshContext) => new Response(ctx.req.method) }; + assertEquals([page.path, route.path, pub.path], ["/p", "/r", "/pub"]); +}); + +// ── Runtime: a plugin written only against PluginApp works ────────────────── + +Deno.test( + "mount(): middleware, params, url and per-request state work through PluginApp alone", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + const root = await Deno.makeTempDir({ prefix: "dune_test_plugin_app_" }); + 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\n# Home\n"); + + const plugin: DunePlugin = { + name: "plugin-app-probe", + version: "1.0.0", + hooks: {}, + mount({ app }) { + app.use(async (ctx) => { + ctx.state = { ...ctx.state, seen: "middleware" }; + return await ctx.next(); + }); + app.get("/probe/:id", (ctx) => + Response.json({ id: ctx.params.id, path: ctx.url.pathname, state: ctx.state.seen, method: ctx.req.method })); + app.post("/probe/:id", (ctx) => new Response(`posted ${ctx.params.id}`)); + }, + }; + const boot = await bootstrap(root, { plugins: [plugin] }); + // deno-lint-ignore no-explicit-any + const app = new App(); + await mountPlugins(app, boot); + const handler = app.handler(); + + const got = await handler(new Request("http://localhost/probe/7")); + assertEquals(await got.json(), { id: "7", path: "/probe/7", state: "middleware", method: "GET" }); + const posted = await handler(new Request("http://localhost/probe/9", { method: "POST" })); + assertEquals(await posted.text(), "posted 9"); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } + }, +); From af993ba288842818c3aa03efee67ce0ad3d880bc Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 09:01:00 +0100 Subject: [PATCH 02/31] docs(plugin-api): say that an unannotated handler ctx is a PluginRouteContext (#97) The changelog named only the Fresh-only calls on `app` as breaking. A handler whose `ctx` is inferred from the contribution type no longer has render() or redirect() either: it has to be annotated FreshContext. The publicRoutes, pageEditorHandler and PublicRouteRegistration doc comments now say so instead of promising a full Fresh context. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 8 ++++++-- src/hooks/types.ts | 17 +++++++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0512f546..fb3b1f3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -173,8 +173,12 @@ where the setting lives now (see `dune migrate:config`, which reports these too) `app.fsRoutes()`, `app.layout()`, `app.route()`) depends on Fresh itself: the handler properties are declared as methods, so a handler taking a full `FreshContext` is still accepted, and `app` is cast to Fresh's `App`. - Breaking only for a plugin that called a Fresh-only method on `app` without a - cast (a type error, fixed with `app as unknown as App`). + Breaking, as type errors only, for a plugin that (a) called a Fresh-only method + on `app` without a cast (fix: `app as unknown as App`), or (b) relied on + the inferred type of a handler's `ctx` to call `ctx.render()`, `ctx.redirect()` + and the like: an unannotated `ctx` is now a `PluginRouteContext`, so write + `handler(ctx: FreshContext)`. Passing a Fresh-annotated middleware to + `app.use()` needs the same cast on `app`. ### Added diff --git a/src/hooks/types.ts b/src/hooks/types.ts index 79990185..4411d61e 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -217,8 +217,10 @@ export interface PluginApi { * this happens in `@dune/core` itself (`registerPluginPublicRoutes()`), so it * works in every `createDuneApp()` context (headless, `admin.enabled: false`, * etc.), not only when an admin package's `mount()` happens to run. Unlike - * `onRequest`, these are proper Fresh routes with access to `ctx.render()`, - * islands, and middleware. + * `onRequest`, these are proper Fresh routes: core passes a real Fresh context, + * so a handler whose `ctx` is annotated `FreshContext` can call `ctx.render()` + * and use islands. An unannotated `ctx` is a {@link PluginRouteContext}, which + * has no `render()`. * * @since 1.1.0 */ @@ -477,8 +479,10 @@ export interface DunePlugin { * Public-facing Fresh routes contributed by this plugin. * * Registered before Dune's content catch-all so they take priority. - * Handlers receive a full Fresh context with `ctx.render()`, islands, - * and middleware — no manual URL matching needed. + * Core passes a full Fresh context. Annotate a handler's `ctx` as + * `FreshContext` to use `ctx.render()` and islands; unannotated, it is a + * {@link PluginRouteContext} (`req`, `url`, `params`, `state`, `next()`). + * No manual URL matching is needed. * * @since 1.1.0 * @@ -644,8 +648,9 @@ export interface ContentEditorPlugin { /** * Handle `GET /admin/pages/edit?path=...`. * - * Receives the full Fresh context — call `ctx.render(component)` to render - * within the admin layout, or return any `Response` directly. + * Core passes the full Fresh context: annotate `ctx` as `FreshContext` and call + * `ctx.render(component)` to render within the admin layout, or return any + * `Response` directly. An unannotated `ctx` is a {@link PluginRouteContext}. * Access `ctx.state.adminContext` for engine, config, and other admin services. * The context state type is `AdminState` from `@dune/plugin-admin`; typed as * `any` here so core does not depend on admin-internal types. From fbd3636a8176076936273efa495f9dac2385a497 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 13:41:55 +0100 Subject: [PATCH 03/31] test(contract): core and @dune/plugin-api agree on the first slice of the plugin contract Compile-time assignability in both directions for the app, storage, content API, search-engine, form, blueprint, inline-edit and scheduled-action types, plus an isDuneError parity check. Needs @dune/plugin-api published to resolve outside a workspace. Co-Authored-By: Claude Sonnet 5.5 --- deno.json | 3 +- tests/contract/plugin_api_test.ts | 88 +++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 tests/contract/plugin_api_test.ts diff --git a/deno.json b/deno.json index 3380d6a7..820267d8 100644 --- a/deno.json +++ b/deno.json @@ -5,7 +5,7 @@ "author": "zumbrunn (https://zumbrunn.com)", "minimumDependencyAge": { "age": "P1D", - "exclude": ["jsr:@dune/plugin-admin", "jsr:@dune/core"] + "exclude": ["jsr:@dune/plugin-admin", "jsr:@dune/core", "jsr:@dune/plugin-api"] }, "exports": { ".": "./src/mod.ts", @@ -60,6 +60,7 @@ "./watchdog": "./src/runtime/watchdog.ts" }, "imports": { + "@dune/plugin-api": "jsr:@dune/plugin-api@^1", "@dune/plugin-admin": "jsr:@dune/plugin-admin@*", "@dune/plugin-admin/admin/mount": "jsr:@dune/plugin-admin@*/admin/mount", "@dune/plugin-admin/admin/context": "jsr:@dune/plugin-admin@*/admin/context", diff --git a/tests/contract/plugin_api_test.ts b/tests/contract/plugin_api_test.ts new file mode 100644 index 00000000..a0e60470 --- /dev/null +++ b/tests/contract/plugin_api_test.ts @@ -0,0 +1,88 @@ +/** + * `@dune/plugin-api` is a leaf package that redeclares the plugin contract. + * These tests keep core and the package from drifting apart. They are mostly + * compile-time: `deno check` is the assertion. + * + * Two directions (plugin-api extraction plan, decision 10): + * - what core hands a plugin is assignable to the package's types; + * - what a plugin hands in (an engine, an editor manager, a record) is + * accepted by core. + * Types that are identical on both sides are checked both ways. + */ + +import { assertEquals } from "@std/assert"; +import type * as P from "@dune/plugin-api"; +import { isDuneError as pluginIsDuneError } from "@dune/plugin-api"; +import type { PageIndex } from "../../src/content/types.ts"; +import type { + ContentApi, + ContentSearchResult, + PagesOptions, + ResolvedPage, + TaxonomyTerm, +} from "../../src/content/api.ts"; +import type { + FacetCounts, + InjectedSearchRecord, + SearchEngine, + SearchEngineCreateContext, + SearchFilter, + SearchOptions, + SearchRecordsCollectContext, + SearchResult, +} from "../../src/search/engine.ts"; +import type { StorageAdapter, StorageEntry, StorageStat, WatchEvent } from "../../src/storage/types.ts"; +import type { Submission, SubmissionFile, SubmissionMeta, SubmissionStatus } from "../../src/forms/submissions.ts"; +import type { BlueprintField } from "../../src/blueprints/types.ts"; +import type { InlineEditManager } from "../../src/inline-edit/types.ts"; +import type { ScheduledAction } from "../../src/workflow/types.ts"; +import type { PluginApp, PluginRouteContext } from "../../src/plugin-api/app.ts"; +import { isDuneError as coreIsDuneError } from "../../src/plugin-api/errors.ts"; + +// Both directions: identical on both sides. +function same(..._ok: [A] extends [B] ? ([B] extends [A] ? [] : [never]) : [never]): void {} +// Core -> package only: core hands out something wider. +function handsOut(): void {} +// Package -> core only: a plugin supplies it and core consumes it. +function takesIn(): void {} + +// --- what core hands a plugin --------------------------------------------- +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); + +// --- what a plugin hands in ----------------------------------------------- +takesIn(); +takesIn(); +takesIn(); + +// --- identical both ways --------------------------------------------------- +same(); +same(); +same(); +same(); +same(); +same(); +same(); +same(); +same(); +same(); +same(); +same(); +same(); +same(); +same(); +same(); + +Deno.test("isDuneError behaves the same in the package and in core", () => { + const err = { code: "ssrf_blocked" }; + for (const code of ["ssrf_blocked", "body_too_large", "weak_password", "duplicate_email"] as const) { + assertEquals(pluginIsDuneError(err, code), coreIsDuneError(err, code)); + } + assertEquals(pluginIsDuneError(new Error("x"), "ssrf_blocked"), false); +}); From 01b1c474e028d236d244418b6e0ab37af41c5710 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 14:08:12 +0100 Subject: [PATCH 04/31] test(contract): plugin services, identity, engines and history match @dune/plugin-api Co-Authored-By: Claude Sonnet 5.5 --- tests/contract/plugin_api_test.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/contract/plugin_api_test.ts b/tests/contract/plugin_api_test.ts index a0e60470..56cbf5d6 100644 --- a/tests/contract/plugin_api_test.ts +++ b/tests/contract/plugin_api_test.ts @@ -37,6 +37,19 @@ import type { BlueprintField } from "../../src/blueprints/types.ts"; import type { InlineEditManager } from "../../src/inline-edit/types.ts"; import type { ScheduledAction } from "../../src/workflow/types.ts"; import type { PluginApp, PluginRouteContext } from "../../src/plugin-api/app.ts"; +import type { PluginServices } from "../../src/plugin-api/services.ts"; +import type { User, UserCreate } from "../../src/auth/types.ts"; +import type { UserStore } from "../../src/auth/user-store.ts"; +import type { Session } from "../../src/session/types.ts"; +import type { SessionManager } from "../../src/session/mod.ts"; +import type { HistoryEngine } from "../../src/history/engine.ts"; +import type { ContentDiff, ContentRevision } from "../../src/workflow/types.ts"; +import type { WorkflowEngine } from "../../src/workflow/engine.ts"; +import type { Scheduler } from "../../src/workflow/scheduler.ts"; +import type { StagingEngine } from "../../src/staging/engine.ts"; +import type { RateLimiter } from "../../src/security/rate-limit.ts"; +import type { RateLimitStore } from "../../src/security/rate-limit-store.ts"; +import type { Logger } from "../../src/core/logger.ts"; import { isDuneError as coreIsDuneError } from "../../src/plugin-api/errors.ts"; // Both directions: identical on both sides. @@ -56,11 +69,29 @@ handsOut(); handsOut(); handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); + // --- what a plugin hands in ----------------------------------------------- takesIn(); takesIn(); takesIn(); +// --- identity: a provider supplies its own stores and cores consumes them - +same(); +same(); +same(); +same(); +same(); +same(); +same(); +same(); + // --- identical both ways --------------------------------------------------- same(); same(); From d5dee8bb1489424e269b1f0897c1f3a69f5a97fa Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 21:45:07 +0100 Subject: [PATCH 05/31] test(contract): plugin object, hooks, capabilities, config and runtime match @dune/plugin-api Co-Authored-By: Claude Sonnet 5.5 --- tests/contract/plugin_api_test.ts | 79 +++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/tests/contract/plugin_api_test.ts b/tests/contract/plugin_api_test.ts index 56cbf5d6..2892d6b7 100644 --- a/tests/contract/plugin_api_test.ts +++ b/tests/contract/plugin_api_test.ts @@ -50,6 +50,45 @@ import type { StagingEngine } from "../../src/staging/engine.ts"; import type { RateLimiter } from "../../src/security/rate-limit.ts"; import type { RateLimitStore } from "../../src/security/rate-limit-store.ts"; import type { Logger } from "../../src/core/logger.ts"; +import type { DuneConfig } from "../../src/config/types.ts"; +import type { DuneEngine } from "../../src/core/engine.ts"; +import type { Page } from "../../src/content/types.ts"; +import type { SearchManager } from "../../src/search/manager.ts"; +import type { FlexEngine } from "../../src/flex/engine.ts"; +import type { SubmissionManager } from "../../src/forms/submissions.ts"; +import type { SectionRegistry } from "../../src/sections/registry.ts"; +import type { MetricsCollector } from "../../src/metrics/mod.ts"; +import type { JobScheduler } from "../../src/jobs/mod.ts"; +import type { DuneAuthSystem } from "../../src/auth/authz.ts"; +import type { ThemeLoader } from "../../src/themes/loader.ts"; +import type { RouteResolver } from "../../src/routing/resolver.ts"; +import type { PluginRuntime } from "../../src/plugin-api/runtime.ts"; +import type { + AdminBarAction, + AdminBarActionContext, + AdminPageRegistration, + AdminRouteRegistration, + ContentEditorPlugin, + DunePlugin, + FormSubmitPayload, + HookContext, + HookEvent, + HookRegistry, + MountApi, + PageMovePayload, + PluginApi, + PublicRouteRegistration, + ResponseTransformContext, + SystemActionPayload, +} from "../../src/hooks/types.ts"; +import type { + AuthenticateResult, + Capabilities, + CapabilityContext, + CapabilityReader, + CapabilityRegistrar, + Contributions, +} from "../../src/plugin-api/capabilities.ts"; import { isDuneError as coreIsDuneError } from "../../src/plugin-api/errors.ts"; // Both directions: identical on both sides. @@ -110,6 +149,46 @@ same(); same(); same(); +// --- slice 2b: config, runtime, hooks, plugin object, capabilities --------- +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); +handsOut(); + +// What a plugin supplies and core consumes. +takesIn(); +takesIn(); +takesIn(); +takesIn(); +takesIn(); +takesIn(); +takesIn(); + +same(); +same(); +same(); +same(); +same(); +same(); +same(); + Deno.test("isDuneError behaves the same in the package and in core", () => { const err = { code: "ssrf_blocked" }; for (const code of ["ssrf_blocked", "body_too_large", "weak_password", "duplicate_email"] as const) { From 85188cadb5242db385cb6aca43facc25e78ffc1d Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 22:16:50 +0100 Subject: [PATCH 06/31] test(contract): identity types are checked core-to-package now that the package is narrower Co-Authored-By: Claude Sonnet 5.5 --- tests/contract/plugin_api_test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/contract/plugin_api_test.ts b/tests/contract/plugin_api_test.ts index 2892d6b7..6ca67772 100644 --- a/tests/contract/plugin_api_test.ts +++ b/tests/contract/plugin_api_test.ts @@ -122,11 +122,11 @@ takesIn(); takesIn(); // --- identity: a provider supplies its own stores and cores consumes them - -same(); -same(); -same(); -same(); -same(); +handsOut(); +takesIn(); +handsOut(); +handsOut(); +handsOut(); same(); same(); same(); From 5480f7eeefd1b47396569d2137c7fee8c6a3c92f Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 22:47:44 +0100 Subject: [PATCH 07/31] feat(config)!: runtime_dir and max_revisions are core settings, not admin settings Core's history engine, job state, search analytics and public sessions use the runtime directory, and the history engine uses max_revisions, so they cannot live under admin: when the admin is optional. They are system.runtime_dir and system.max_revisions now, resolved by resolveRuntimeDir() and resolveMaxRevisions(). The old admin.* keys, in both spellings, are refused with the new location (step 1 of the admin config plan). Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 9 +++++++ src/auth/mount.ts | 6 ++--- src/cli/jobs.ts | 5 ++-- src/cli/serve.ts | 5 ++-- src/cli/users-passwd.ts | 3 ++- src/config/admin-config.ts | 11 --------- src/config/defaults.ts | 2 -- src/config/legacy-keys.ts | 6 +++-- src/config/system-config.ts | 13 ++++++++++ src/forms/config.ts | 16 ++++++++++++ src/mcp/resources.ts | 3 ++- src/plugin-api/create-services.ts | 5 ++-- src/plugins/builtin.ts | 3 +-- src/plugins/loader.ts | 5 ++-- src/runtime/bootstrap.ts | 7 +++--- src/schema/config-schema.ts | 16 +++++++++--- src/search/analytics.ts | 5 ++-- tests/config/legacy_keys_test.ts | 30 ++++++++++++++++++++--- tests/config/loader_test.ts | 8 +++--- tests/plugin-api/services_test.ts | 4 +-- tests/plugins/admin_version_guard_test.ts | 6 ++--- 21 files changed, 112 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 19390a2c..2ef73e93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,15 @@ where the setting lives now (see `dune migrate:config`, which reports these too) `resolveDataDir()` (`@dune/core/forms`); before, several commands (`users:create`, `users:grant-role`, `authz:sign`, the `migrate:*` user and auth migrations) read only `admin.data_dir` and ignored `system.data_dir`. +- **`admin.runtime_dir` and `admin.max_revisions`.** They are core settings, not + the admin's: core's history engine, job state, search analytics and public + sessions all use them. They are now `runtime_dir` and `max_revisions` at the + top level of system.yaml (`system.runtime_dir`, `system.max_revisions`; + defaults `.dune/admin` and 50, so existing state stays where it is). The old + keys, in either spelling, refuse to load and say where the setting went. + `resolveRuntimeDir()` and `resolveMaxRevisions()` (`@dune/core/forms`) are the + one place that resolves them. A plugin reads the directory from + `services.paths.runtimeDir`. `@dune/plugin-admin` 4.0.0 does the same. - **The legacy `smtp:` block in form notifications.** Use `site.email`. Magic-link login emails also go through `site.email` now (they used to need the legacy `admin.notifications.email.smtp`); a site without diff --git a/src/auth/mount.ts b/src/auth/mount.ts index 85ea25ec..77919575 100644 --- a/src/auth/mount.ts +++ b/src/auth/mount.ts @@ -14,7 +14,7 @@ // deno-lint-ignore no-explicit-any import { createEmailProvider } from "../email/providers/mod.ts"; -import { resolveDataDir } from "../forms/config.ts"; +import { resolveDataDir, resolveRuntimeDir } from "../forms/config.ts"; import type { App } from "fresh"; import type { BootstrapResult } from "../runtime/bootstrap.ts"; import { createLocalUserStore } from "./user-store.ts"; @@ -77,10 +77,8 @@ export async function mountDuneAuth( const { config, storage } = ctx; const authConfig: SiteConfig["auth"] = config.site.auth; - const adminCfg = config.admin ?? - { runtime_dir: ".dune/admin" }; const dataDir = resolveDataDir(config); - const runtimeDir = adminCfg.runtime_dir ?? ".dune/admin"; + const runtimeDir = resolveRuntimeDir(config); const mode = authConfig?.mode ?? "dune"; if (mode === "external-jwt") { diff --git a/src/cli/jobs.ts b/src/cli/jobs.ts index 9db6891d..66e5677b 100644 --- a/src/cli/jobs.ts +++ b/src/cli/jobs.ts @@ -10,6 +10,7 @@ import { createEmailClient, createEmailProvider } from "../email/mod.ts"; import { createStorage } from "../storage/mod.ts"; import { loadConfig } from "../config/mod.ts"; import { bootstrap } from "../runtime/bootstrap.ts"; +import { resolveRuntimeDir } from "../forms/config.ts"; export interface JobsOptions { json?: boolean; @@ -38,7 +39,7 @@ export async function jobsListCommand(root: string, opts: JobsOptions = {}): Pro } // Resolve state directory from config - const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = resolveRuntimeDir(config); const jobStateDir = `${runtimeDir}/jobs`; // Minimal context for state reads — email is a no-op since we won't run handlers @@ -115,7 +116,7 @@ export async function jobsRunCommand(root: string, name: string, opts: JobsOptio Deno.exit(1); }); - const runtimeDir = ctx.config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = resolveRuntimeDir(ctx.config); const jobLogger = { info: (event: string, data?: Record) => console.log(`[jobs] ${event}`, data ?? ""), diff --git a/src/cli/serve.ts b/src/cli/serve.ts index ab26c324..84d5ebb9 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -35,6 +35,7 @@ import { checkLockfileStaleness } from "./lockfile.ts"; import { startWatchdog } from "../runtime/watchdog.ts"; import { type InstanceHandle, registerInstance } from "../runtime/instance-registry.ts"; import { CORE_VERSION } from "../plugins/mod.ts"; +import { resolveRuntimeDir } from "../forms/config.ts"; export interface ServeOptions { port?: number; @@ -222,7 +223,7 @@ export async function serveCommand(root: string, options: ServeOptions = {}) { // Expose the configured runtimeDir to ConsoleEmailProvider so its dev-email // directory aligns with the admin preview route regardless of runtimeDir customisation. - const runtimeDirForEmail = config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDirForEmail = resolveRuntimeDir(config); if (Deno.env.get("DUNE_ENV") === "dev") { Deno.env.set("DUNE_DEV_EMAIL_DIR", join(resolve(root), runtimeDirForEmail, "dev-email")); } @@ -235,7 +236,7 @@ export async function serveCommand(root: string, options: ServeOptions = {}) { const workers = Number(Deno.env.get("DUNE_WORKERS") ?? "1"); warnIfMultiprocess(jobDefs.length, workers); - const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = resolveRuntimeDir(config); const jobStateDir = `${runtimeDir}/jobs`; const jobLogger = { info: (event: string, data?: Record) => logger.info(event, data), diff --git a/src/cli/users-passwd.ts b/src/cli/users-passwd.ts index 5f8f7cfc..34cf0aa0 100644 --- a/src/cli/users-passwd.ts +++ b/src/cli/users-passwd.ts @@ -46,6 +46,7 @@ import { } from "../session/mod.ts"; import { acquirePassword, PasswordInputError } from "./password-input.ts"; import type { PasswordInputOptions } from "./password-input.ts"; +import { resolveRuntimeDir } from "../forms/config.ts"; export interface UsersPasswdOptions extends PasswordInputOptions { /** @@ -71,7 +72,7 @@ async function openContext(root: string, siteId?: string): Promise { }); // Same resolution as the admin plugin and core: system.data_dir. const dataDir = resolveFormsConfig(config).dataDir; - const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = resolveRuntimeDir(config); const siteAuth = (config.site as { auth?: { user_store?: string } }).auth; let userStore: UserStore; diff --git a/src/config/admin-config.ts b/src/config/admin-config.ts index 1d0c7c54..36ae4998 100644 --- a/src/config/admin-config.ts +++ b/src/config/admin-config.ts @@ -82,19 +82,8 @@ export interface AdminConfig { path: string; /** Session lifetime in seconds (default: 86400 = 24h) */ session_lifetime: number; - /** - * Runtime directory — ephemeral, machine-local, gitignored. - * Stores: sessions, scheduled actions, revision history, workflow state. - * (default: ".dune/admin") - */ - runtime_dir: string; /** Whether admin panel is enabled (default: true) */ enabled: boolean; - /** - * Maximum number of revisions to retain per page (default: 50). - * When the limit is reached, the oldest revision is pruned on each save. - */ - max_revisions?: number; /** * Maximum upload body size (in MB) for admin-side multipart uploads * (media library, content attachments). Rejected with 413 before the body diff --git a/src/config/defaults.ts b/src/config/defaults.ts index ef0d9b17..8e717f33 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -49,9 +49,7 @@ export const DEFAULT_CONFIG: DuneConfig = { admin: { path: "/admin", session_lifetime: 86400, - runtime_dir: ".dune/admin", enabled: true, - max_revisions: 50, max_upload_mb: 100, }, }; diff --git a/src/config/legacy-keys.ts b/src/config/legacy-keys.ts index c0a12541..6671d829 100644 --- a/src/config/legacy-keys.ts +++ b/src/config/legacy-keys.ts @@ -25,8 +25,6 @@ const RULES: Rule[] = [ parent: ["admin"], renames: { sessionLifetime: "session_lifetime", - runtimeDir: "runtime_dir", - maxRevisions: "max_revisions", maxUploadMb: "max_upload_mb", authzStore: "authz_store", }, @@ -78,6 +76,10 @@ const MOVED: MovedRule[] = [ incomingWebhooks: "webhooks.incoming in system.yaml", data_dir: "data_dir at the top level of system.yaml", dataDir: "data_dir at the top level of system.yaml", + runtime_dir: "runtime_dir at the top level of system.yaml", + runtimeDir: "runtime_dir at the top level of system.yaml", + max_revisions: "max_revisions at the top level of system.yaml", + maxRevisions: "max_revisions at the top level of system.yaml", }, }, { diff --git a/src/config/system-config.ts b/src/config/system-config.ts index 80858033..788b2adf 100644 --- a/src/config/system-config.ts +++ b/src/config/system-config.ts @@ -244,6 +244,19 @@ export interface SystemConfig { * Replaces `admin.dataDir`. */ data_dir?: string; + /** + * Machine-local runtime state (ephemeral, gitignored): job state, revision + * history, scheduled actions, staged drafts, search analytics, and whatever + * an admin plugin keeps. Default: ".dune/admin" (the name predates the move + * out of `admin:`; kept so existing sites keep their state). Replaces + * `admin.runtime_dir`. + */ + runtime_dir?: string; + /** + * Revisions kept per page; the oldest is pruned on each save. 0 keeps all. + * Default: 50. Replaces `admin.max_revisions`. + */ + max_revisions?: number; /** * Webhooks. `incoming` lets external systems trigger actions by POSTing to * `/api/webhook/incoming` with a token, no admin plugin needed. diff --git a/src/forms/config.ts b/src/forms/config.ts index c73438bf..68f905d3 100644 --- a/src/forms/config.ts +++ b/src/forms/config.ts @@ -24,6 +24,22 @@ export function resolveDataDir(config: DuneConfig): string { return config.system?.data_dir ?? DEFAULT_DATA_DIR; } +const DEFAULT_RUNTIME_DIR = ".dune/admin"; + +/** + * Machine-local runtime state (job state, revision history, scheduled actions, + * staged drafts, search analytics): `system.runtime_dir`, default `.dune/admin`. + * The default still carries the admin's name so existing sites keep their state. + */ +export function resolveRuntimeDir(config: DuneConfig): string { + return config.system?.runtime_dir ?? DEFAULT_RUNTIME_DIR; +} + +/** Revisions kept per page: `system.max_revisions`, default 50 (0 keeps all). */ +export function resolveMaxRevisions(config: DuneConfig): number { + return config.system?.max_revisions ?? 50; +} + /** Compute the effective settings from the loaded config. */ export function resolveFormsConfig(config: DuneConfig): ResolvedFormsConfig { return { diff --git a/src/mcp/resources.ts b/src/mcp/resources.ts index bfa6a727..09a32285 100644 --- a/src/mcp/resources.ts +++ b/src/mcp/resources.ts @@ -9,6 +9,7 @@ import { join } from "@std/path"; import type { McpResource, McpResourceContent, ResourceHandler } from "./server.ts"; import type { DuneEngine } from "../core/engine.ts"; import type { StorageAdapter } from "../storage/types.ts"; +import { resolveRuntimeDir } from "../forms/config.ts"; // ── Resource: site config ──────────────────────────────────────────────────── @@ -34,7 +35,7 @@ function makeConfigHandler(engine: DuneEngine): ResourceHandler { data_dir: config.system?.data_dir, admin: { path: config.admin?.path, - runtime_dir: config.admin?.runtime_dir, + runtime_dir: resolveRuntimeDir(config), audit: config.admin?.audit, // Omit auth_provider details (may contain connection strings) }, diff --git a/src/plugin-api/create-services.ts b/src/plugin-api/create-services.ts index 7a564faf..99fd1fdd 100644 --- a/src/plugin-api/create-services.ts +++ b/src/plugin-api/create-services.ts @@ -16,7 +16,7 @@ import { createMachineTranslator } from "../mt/factory.ts"; import { AuditLogger } from "../audit/logger.ts"; import { bootstrapAdminTuples } from "../auth/authz.ts"; import { resolveBlueprint, validateFrontmatter } from "../blueprints/validator.ts"; -import { resolveDataDir } from "../forms/config.ts"; +import { resolveDataDir, resolveRuntimeDir } from "../forms/config.ts"; import { join } from "@std/path"; import { logger } from "../core/logger.ts"; @@ -26,8 +26,7 @@ import { logger } from "../core/logger.ts"; */ export function createPluginServices(opts: PluginServicesOptions): PluginServices { const { config, storage } = opts; - const adminCfg = config.admin; - const runtimeDir = adminCfg?.runtime_dir ?? ".dune/admin"; + const runtimeDir = resolveRuntimeDir(config); return { security: { sanitizeHtml, diff --git a/src/plugins/builtin.ts b/src/plugins/builtin.ts index 66d01b21..1397257f 100644 --- a/src/plugins/builtin.ts +++ b/src/plugins/builtin.ts @@ -43,7 +43,7 @@ export const MIN_ADMIN_MAJOR = 4; * An admin plugin older than {@link MIN_ADMIN_MAJOR} still reads the camelCase * properties, which are now `undefined`, so it falls back to its own defaults. * That is harmless for a site on all defaults but silently drops any value the - * site set (`runtime_dir`, `authz_store: db`, ...). + * site set (`session_lifetime`, `max_upload_mb`, ...). */ export function adminSettingsAtRisk(config: DuneConfig): string[] { const out: string[] = []; @@ -52,7 +52,6 @@ export function adminSettingsAtRisk(config: DuneConfig): string[] { for ( const key of [ "session_lifetime", - "runtime_dir", "max_upload_mb", "authz_store", ] diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 7ae2069e..610130a9 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -40,6 +40,7 @@ import type { StorageAdapter } from "../storage/types.ts"; import { logger } from "../core/logger.ts"; import { writeDoctorReport } from "../plugin-api/doctor-report.ts"; import { createPluginRuntime } from "../plugin-api/runtime.ts"; +import { resolveRuntimeDir } from "../forms/config.ts"; /** Options for the plugin loader. */ export interface PluginLoaderOptions { @@ -420,8 +421,6 @@ export async function mountPlugins( const registeredOrder = ctx.hooks.plugins(); - const adminCfg = ctx.config.admin; - // Capability slots: every plugin has declared by now, and the infrastructure // a factory may need (history, hooks, storage) exists. A throwing factory is // logged and leaves its slot empty; it never stops the mount. @@ -431,7 +430,7 @@ export async function mountPlugins( history: ctx.history, hooks: ctx.hooks, services: ctx.pluginServices, - dataDir: adminCfg?.runtime_dir ?? ".dune/admin", + dataDir: resolveRuntimeDir(ctx.config), // NOTE: intentionally still the raw config value, not engine.contentDir. // `storage` is dual-purpose (content AND data files), so this pair only // stays correct when content.src is unset. Supporting content.src here diff --git a/src/runtime/bootstrap.ts b/src/runtime/bootstrap.ts index d363c554..c45e6c42 100644 --- a/src/runtime/bootstrap.ts +++ b/src/runtime/bootstrap.ts @@ -8,7 +8,7 @@ import { createPluginServices } from "../plugin-api/create-services.ts"; import { type CapabilityReader, type CapabilityRegistry, createCapabilityRegistry } from "../plugin-api/capabilities.ts"; import type { PluginServices } from "../plugin-api/services.ts"; -import { resolveDataDir } from "../forms/config.ts"; +import { resolveDataDir, resolveMaxRevisions, resolveRuntimeDir } from "../forms/config.ts"; import { startupStep } from "./startup-step.ts"; import { createStorageAsync } from "../storage/mod.ts"; import { loadConfig } from "../config/mod.ts"; @@ -539,16 +539,15 @@ export async function bootstrap( const adminConfig = config.admin ?? { path: "/admin", session_lifetime: 86400, - runtime_dir: ".dune/admin", enabled: true, }; - const runtimeDir = adminConfig.runtime_dir ?? ".dune/admin"; + const runtimeDir = resolveRuntimeDir(config); const dataDir = resolveDataDir(config); const history = createHistoryEngine({ storage, dataDir: runtimeDir, - maxRevisions: adminConfig.max_revisions ?? 50, + maxRevisions: resolveMaxRevisions(config), }); // 11. Authorization (Polizy) — created in core so that admin-user tuples diff --git a/src/schema/config-schema.ts b/src/schema/config-schema.ts index 03e05748..2fa1b7df 100644 --- a/src/schema/config-schema.ts +++ b/src/schema/config-schema.ts @@ -299,7 +299,7 @@ export const CONFIG_SCHEMA = { type: "boolean", default: false, description: - "Record GET /api/search queries (text and result count) to admin.runtime_dir/search-analytics.json (bounded counters, not a request log) for top-query and zero-result reports. Off by default: it contains what visitors typed.", + "Record GET /api/search queries (text and result count) to system.runtime_dir/search-analytics.json (bounded counters, not a request log) for top-query and zero-result reports. Off by default: it contains what visitors typed.", }, custom_fields: { type: "array", @@ -354,6 +354,18 @@ export const CONFIG_SCHEMA = { description: "Persistent data directory (git-tracked): form submissions, admin users. Replaces admin.dataDir.", }, + runtime_dir: { + type: "string", + default: ".dune/admin", + description: + "Machine-local runtime state (gitignored): job state, revision history, scheduled actions, search analytics. Replaces admin.runtime_dir.", + }, + max_revisions: { + type: "integer", + minimum: 0, + default: 50, + description: "Revisions kept per page (0 keeps all). Replaces admin.max_revisions.", + }, webhooks: { type: "object", description: "Webhooks that need no admin plugin", @@ -482,8 +494,6 @@ export const CONFIG_SCHEMA = { default: 86400, description: "Admin session TTL in seconds", }, - runtime_dir: { type: "string", default: ".dune/admin" }, - max_revisions: { type: "integer", minimum: 0, default: 50 }, max_upload_mb: { type: "integer", minimum: 1, default: 100 }, git_commit: { type: "boolean", diff --git a/src/search/analytics.ts b/src/search/analytics.ts index a148cc12..489e73c2 100644 --- a/src/search/analytics.ts +++ b/src/search/analytics.ts @@ -20,6 +20,7 @@ import { dirname, join } from "@std/path"; import type { DuneConfig } from "../config/types.ts"; +import { resolveRuntimeDir } from "../forms/config.ts"; // === Types === @@ -100,11 +101,11 @@ function normalise(query: string): string { /** * The recorder for a site, or null when `system.search.analytics` is off. - * The snapshot lives under `admin.runtime_dir` (default `.dune/admin`) in the site root. + * The snapshot lives under `system.runtime_dir` (default `.dune/admin`) in the site root. */ export function resolveSearchAnalytics(config: DuneConfig, root: string): SearchAnalytics | null { if (config.system?.search?.analytics !== true) return null; - const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = resolveRuntimeDir(config); return createSearchAnalytics(join(root, runtimeDir, "search-analytics.json")); } diff --git a/tests/config/legacy_keys_test.ts b/tests/config/legacy_keys_test.ts index e05f25ad..6d1da37e 100644 --- a/tests/config/legacy_keys_test.ts +++ b/tests/config/legacy_keys_test.ts @@ -44,7 +44,6 @@ metrics: admin: enabled: true maxUploadMb: 20 - runtimeDir: .dune/admin audit: logFile: data/audit.log `; @@ -65,7 +64,7 @@ Deno.test("findLegacyKeys: finds every renamed key and nothing else", () => { ], ); const system = findLegacyKeys("system", parseYaml(SYSTEM)); - assertEquals(system.length, 6); + assertEquals(system.length, 5); }); Deno.test("findLegacyKeys: a key with the same name under another parent is left alone", () => { @@ -120,10 +119,11 @@ Deno.test("loadConfig refuses a legacy key and names the new one", async () => { Deno.test("loadConfig accepts the migrated keys", async () => { const cfg = await load({ "config/site.yaml": "title: T\nauth:\n user_store: db\n", - "config/system.yaml": "admin:\n max_revisions: 7\n", + "config/system.yaml": "max_revisions: 7\nadmin:\n max_upload_mb: 7\n", }); assertEquals((cfg.site as any).auth.user_store, "db"); - assertEquals(cfg.admin?.max_revisions, 7); + assertEquals(cfg.system.max_revisions, 7); + assertEquals(cfg.admin?.max_upload_mb, 7); }); Deno.test("findMovedKeys: the removed forms aliases, admin.data_dir and the smtp block", () => { @@ -154,6 +154,28 @@ Deno.test("loadConfig refuses a moved key and says where it went", async () => { assertStringIncludes(message, "Move the moved or removed keys by hand"); }); +Deno.test("admin.runtime_dir and admin.max_revisions moved to the top level of system.yaml", async () => { + const moved = findMovedKeys( + "system", + parseYaml("admin:\n runtime_dir: x\n runtimeDir: x\n max_revisions: 1\n maxRevisions: 1\n max_upload_mb: 5\n"), + ); + assertEquals( + moved.map((k) => k.path).sort(), + ["admin.maxRevisions", "admin.max_revisions", "admin.runtimeDir", "admin.runtime_dir"], + ); + let message = ""; + try { + await load({ "config/system.yaml": "admin:\n runtime_dir: var/rt\n" }); + } catch (err) { + message = (err as Error).message; + } + assertStringIncludes(message, "admin.runtime_dir → runtime_dir at the top level of system.yaml"); + // the new location is accepted + const cfg = await load({ "config/system.yaml": "runtime_dir: var/rt\nmax_revisions: 9\n" }); + assertEquals(cfg.system.runtime_dir, "var/rt"); + assertEquals(cfg.system.max_revisions, 9); +}); + Deno.test("loadConfig rejects a config file whose top level is not a mapping", async () => { for (const [yaml, kind] of [["- a\n- b\n", "an array"], ["just a string\n", "string"]]) { let message = ""; diff --git a/tests/config/loader_test.ts b/tests/config/loader_test.ts index 5fcef875..47e0341e 100644 --- a/tests/config/loader_test.ts +++ b/tests/config/loader_test.ts @@ -189,7 +189,7 @@ Deno.test("loadConfig: merges system.yaml into system config", async () => { Deno.test("loadConfig: system.yaml `admin:` lands in config.admin, not config.system", async () => { const storage = new MemoryStorage({ - "config/system.yaml": `admin:\n enabled: false\n max_revisions: 7\ndebug: true`, + "config/system.yaml": `admin:\n enabled: false\n max_upload_mb: 7\ndebug: true`, }); const config = await loadConfig({ storage, @@ -198,7 +198,7 @@ Deno.test("loadConfig: system.yaml `admin:` lands in config.admin, not config.sy skipValidation: true, }); assertEquals(config.admin?.enabled, false); - assertEquals(config.admin?.max_revisions, 7); + assertEquals(config.admin?.max_upload_mb, 7); // Unset admin keys keep their defaults assertEquals(config.admin?.path, "/admin"); assertEquals((config.system as unknown as Record).admin, undefined); @@ -218,7 +218,7 @@ Deno.test("loadConfig: admin stays enabled when system.yaml has no admin block", Deno.test("loadConfig: env system.yaml `admin:` overrides the base file", async () => { const storage = new MemoryStorage({ - "config/system.yaml": `admin:\n enabled: true\n max_revisions: 7`, + "config/system.yaml": `admin:\n enabled: true\n max_upload_mb: 7`, "config/env/production/system.yaml": `admin:\n enabled: false`, }); const config = await loadConfig({ @@ -229,7 +229,7 @@ Deno.test("loadConfig: env system.yaml `admin:` overrides the base file", async skipValidation: true, }); assertEquals(config.admin?.enabled, false); - assertEquals(config.admin?.max_revisions, 7); + assertEquals(config.admin?.max_upload_mb, 7); }); Deno.test("loadConfig: environment-specific overrides", async () => { diff --git a/tests/plugin-api/services_test.ts b/tests/plugin-api/services_test.ts index dcd1064d..17a0921a 100644 --- a/tests/plugin-api/services_test.ts +++ b/tests/plugin-api/services_test.ts @@ -156,7 +156,7 @@ Deno.test("paths service: data dir and runtime dir default, and follow config", assertEquals(services().paths, { dataDir: "data", runtimeDir: ".dune/admin" }); const cfg = config(); cfg.system = { ...cfg.system, data_dir: "var/data" } as never; - cfg.admin = { ...cfg.admin, runtime_dir: ".state" } as never; + cfg.system = { ...cfg.system, runtime_dir: ".state" } as never; assertEquals(createPluginServices({ config: cfg, storage: new MemoryStorageAdapter() }).paths, { dataDir: "var/data", runtimeDir: ".state", @@ -183,7 +183,7 @@ Deno.test("search analytics: off by default, on with system.search.analytics, an const summary = await on.search.analytics.summarize(); assertEquals(summary.totalSearches, 2); assertEquals(summary.zeroResultQueries, [{ query: "stripe", count: 1 }]); - // the file lives under admin.runtime_dir in the site root + // the file lives under system.runtime_dir in the site root assertEquals((await Deno.stat(`${root}/.dune/admin/search-analytics.json`)).isFile, true); } finally { await Deno.remove(root, { recursive: true }); diff --git a/tests/plugins/admin_version_guard_test.ts b/tests/plugins/admin_version_guard_test.ts index 9a4fb302..6ffb158d 100644 --- a/tests/plugins/admin_version_guard_test.ts +++ b/tests/plugins/admin_version_guard_test.ts @@ -19,14 +19,12 @@ Deno.test("adminSettingsAtRisk: a default config has nothing at risk", () => { Deno.test("adminSettingsAtRisk: names every renamed admin-read key moved off its default", () => { const c = withAdmin({ - runtime_dir: "var/admin", authz_store: "db", max_upload_mb: 5, audit: { log_file: "a.log" }, auth_provider: { type: "ldap" }, }); assertEquals(adminSettingsAtRisk(c), [ - "admin.runtime_dir", "admin.max_upload_mb", "admin.authz_store", "admin.audit.log_file", @@ -36,13 +34,13 @@ Deno.test("adminSettingsAtRisk: names every renamed admin-read key moved off its Deno.test("adminSettingsAtRisk: a value equal to the default is not at risk", () => { assertEquals( - adminSettingsAtRisk(withAdmin({ runtime_dir: ".dune/admin" })), + adminSettingsAtRisk(withAdmin({ max_upload_mb: 100 })), [], ); }); Deno.test("checkAdminVersion: admin >= the minimum major is ok", () => { - const c = withAdmin({ runtime_dir: "var/admin" }); + const c = withAdmin({ max_upload_mb: 5 }); assertEquals(checkAdminVersion(`${MIN_ADMIN_MAJOR}.0.0`, c).level, "ok"); assertEquals(checkAdminVersion(`${MIN_ADMIN_MAJOR + 1}.2.3`, c).level, "ok"); }); From 0ea5229ba9c134c062e882535ba7905060c91bf5 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 23:18:34 +0100 Subject: [PATCH 08/31] feat(plugin-api)!: the admin declares where it is mounted (admin.prefix slot) (#123, step 2) Core no longer reads admin.path. The admin plugin provides the admin.prefix capability slot; core reads it lazily for page-cache and transform exclusion, onRequest short-circuit protection, plugin route reservation, hybrid static output and the dev/serve banners. With no provider there is no admin and no path is treated as one. resolveCapabilities() resolves slots idempotently for callers that run before createDuneApp(). MCP stops reporting admin.path. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 12 ++ deno.lock | 148 ++++++------------- src/cli/dev.ts | 10 +- src/cli/response-transforms.ts | 6 +- src/cli/serve-utils.ts | 4 +- src/cli/serve.ts | 6 +- src/mcp/resources.ts | 1 - src/mcp/tools.ts | 1 - src/plugin-api/capabilities.ts | 26 ++++ src/plugin-api/mod.ts | 3 +- src/plugins/loader.ts | 62 +++++--- src/plugins/mod.ts | 8 +- src/runtime/register-middleware.ts | 5 +- src/runtime/register-plugin-routes.ts | 6 +- src/runtime/server.ts | 16 +- src/ssg/builder.ts | 14 +- tests/cli/response_transforms_test.ts | 33 +++++ tests/cli/serve_utils_test.ts | 7 + tests/plugin-api/capabilities_test.ts | 56 ++++++- tests/runtime/register_plugin_routes_test.ts | 70 ++++++++- tests/ssg/builder_test.ts | 47 ++++++ 21 files changed, 378 insertions(+), 163 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ef73e93..34efe1b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,18 @@ where the setting lives now (see `dune migrate:config`, which reports these too) `resolveRuntimeDir()` and `resolveMaxRevisions()` (`@dune/core/forms`) are the one place that resolves them. A plugin reads the directory from `services.paths.runtimeDir`. `@dune/plugin-admin` 4.0.0 does the same. +- **Core no longer assumes the admin lives at `/admin` (#123, step 2).** Where the admin + is mounted is the new `admin.prefix` capability slot (`{ prefix: "/admin" }`), provided by + the admin plugin. Core reads it lazily for the things it must keep apart from content: + the page cache and response transforms skip admin paths, `onRequest` cannot short-circuit + them, plugin `publicRoutes` cannot shadow them, `dune build --hybrid` forwards them to the + Worker, and `dune dev` / `dune serve` print the admin URL. With no provider there is no + admin: `/admin/...` is an ordinary content path, nothing is reserved, and hybrid output + forwards only `/api/*`. `ResponseTransformContext.adminPrefix` and + `AdminBarActionContext.adminPrefix` are `""` when no admin is installed. The MCP server + (which loads no plugins) no longer reports `admin.path`. New: `resolveCapabilities()` + (`@dune/core/plugins`) settles plugin setup and resolves slots, idempotently, for callers + that read a slot before `createDuneApp()`. `@dune/plugin-admin` 4.0.0 provides the slot. - **The legacy `smtp:` block in form notifications.** Use `site.email`. Magic-link login emails also go through `site.email` now (they used to need the legacy `admin.notifications.email.smtp`); a site without diff --git a/deno.lock b/deno.lock index eb02d315..5f4bfdf7 100644 --- a/deno.lock +++ b/deno.lock @@ -5,13 +5,9 @@ "jsr:@deno/esbuild-plugin@^1.2.0": "1.2.1", "jsr:@deno/loader@~0.3.10": "0.3.14", "jsr:@denosaurs/plug@1": "1.1.0", - "jsr:@dune/core@~0.34.2": "0.34.14", - "jsr:@dune/plugin-admin@*": "3.0.0", - "jsr:@dune/plugin-admin@3": "3.0.0", "jsr:@fresh/build-id@1": "1.0.1", "jsr:@fresh/core@2": "2.3.3", "jsr:@std/assert@0.217": "0.217.0", - "jsr:@std/assert@1": "1.0.19", "jsr:@std/bytes@^1.0.6": "1.0.6", "jsr:@std/crypto@1": "1.1.0", "jsr:@std/encoding@1": "1.0.11", @@ -22,27 +18,24 @@ "jsr:@std/fs@1": "1.0.24", "jsr:@std/fs@^1.0.19": "1.0.24", "jsr:@std/html@^1.0.5": "1.0.7", - "jsr:@std/http@^1.0.21": "1.1.3", - "jsr:@std/internal@^1.0.12": "1.0.14", + "jsr:@std/http@^1.0.21": "1.1.4", "jsr:@std/internal@^1.0.14": "1.0.14", - "jsr:@std/json@^1.0.2": "1.1.0", - "jsr:@std/jsonc@^1.0.2": "1.0.2", + "jsr:@std/json@^1.1.0": "1.1.0", + "jsr:@std/jsonc@^1.0.2": "1.0.3", "jsr:@std/media-types@^1.1.0": "1.1.0", - "jsr:@std/net@^1.0.6": "1.0.6", + "jsr:@std/net@^1.0.6": "1.0.7", "jsr:@std/path@0.217": "0.217.0", "jsr:@std/path@1": "1.1.6", "jsr:@std/path@^1.1.1": "1.1.6", "jsr:@std/path@^1.1.2": "1.1.6", "jsr:@std/path@^1.1.4": "1.1.6", "jsr:@std/path@^1.1.5": "1.1.6", - "jsr:@std/semver@1": "1.0.8", "jsr:@std/semver@^1.0.6": "1.0.8", - "jsr:@std/uuid@^1.0.9": "1.1.1", - "jsr:@std/yaml@1": "1.2.0", - "jsr:@zip-js/zip-js@2": "2.8.61", + "jsr:@std/uuid@^1.0.9": "1.1.2", + "jsr:@std/yaml@1": "1.3.0", "npm:@mdx-js/mdx@3": "3.1.1", - "npm:@opentelemetry/api@^1.9.0": "1.9.1", - "npm:@preact/signals@^2.5.1": "2.11.1_preact@10.29.8__preact-render-to-string@6.7.0", + "npm:@opentelemetry/api@^1.9.0": "1.10.0", + "npm:@preact/signals@^2.5.1": "2.11.4_preact@10.29.8__preact-render-to-string@6.8.2_preact-render-to-string@6.8.2__preact@10.29.8", "npm:@types/nodemailer@6": "6.4.24", "npm:esbuild-wasm@~0.25.11": "0.25.12", "npm:esbuild@0.25.7": "0.25.7", @@ -50,14 +43,13 @@ "npm:gray-matter@4": "4.0.3", "npm:ioredis@5": "5.11.1", "npm:marked@15": "15.0.12", - "npm:nodemailer@6": "6.10.1", - "npm:polizy@0.6": "0.6.0", + "npm:polizy@0.6": "0.6.1", "npm:postgres@^3.4.0": "3.4.9", - "npm:preact-render-to-string@6": "6.7.0_preact@10.29.8", - "npm:preact-render-to-string@^6.6.3": "6.7.0_preact@10.29.8", - "npm:preact@10": "10.29.8_preact-render-to-string@6.7.0", - "npm:preact@^10.28.2": "10.29.8_preact-render-to-string@6.7.0", - "npm:preact@^10.29.1": "10.29.8_preact-render-to-string@6.7.0", + "npm:preact-render-to-string@6": "6.8.2_preact@10.29.8", + "npm:preact-render-to-string@^6.6.3": "6.8.2_preact@10.29.8", + "npm:preact@10": "10.29.8_preact-render-to-string@6.8.2", + "npm:preact@^10.28.2": "10.29.8_preact-render-to-string@6.8.2", + "npm:preact@^10.29.1": "10.29.8_preact-render-to-string@6.8.2", "npm:remark-gfm@4": "4.0.1", "npm:sharp@0.33": "0.33.5" }, @@ -89,42 +81,6 @@ "jsr:@std/path@1" ] }, - "@dune/core@0.34.14": { - "integrity": "73a43da4d74c507eb92078f91c62e17de91b1693022a4aa98f024acd5db8c224", - "dependencies": [ - "jsr:@db/sqlite", - "jsr:@fresh/core", - "jsr:@std/crypto", - "jsr:@std/encoding@1", - "jsr:@std/fs@1", - "jsr:@std/path@^1.1.4", - "jsr:@std/yaml", - "npm:gray-matter", - "npm:ioredis", - "npm:marked", - "npm:nodemailer", - "npm:polizy", - "npm:postgres", - "npm:preact-render-to-string@6", - "npm:preact@10" - ] - }, - "@dune/plugin-admin@3.0.0": { - "integrity": "48c7bc839a8fc4b7196a2ac5728f70c9cf4ce7bc4cf8facd7d2101e44fa70e0d", - "dependencies": [ - "jsr:@dune/core", - "jsr:@fresh/core", - "jsr:@std/encoding@1", - "jsr:@std/path@^1.1.4", - "jsr:@std/yaml", - "jsr:@zip-js/zip-js", - "npm:@types/nodemailer", - "npm:marked", - "npm:nodemailer", - "npm:preact-render-to-string@6", - "npm:preact@10" - ] - }, "@fresh/build-id@1.0.1": { "integrity": "12a2ec25fd52ae9ec68c26848a5696cd1c9b537f7c983c7e56e4fb1e7e816c20", "dependencies": [ @@ -145,7 +101,7 @@ "jsr:@std/media-types", "jsr:@std/net", "jsr:@std/path@^1.1.2", - "jsr:@std/semver@^1.0.6", + "jsr:@std/semver", "jsr:@std/uuid", "npm:@opentelemetry/api", "npm:@preact/signals", @@ -159,12 +115,6 @@ "@std/assert@0.217.0": { "integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642" }, - "@std/assert@1.0.19": { - "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", - "dependencies": [ - "jsr:@std/internal@^1.0.12" - ] - }, "@std/bytes@1.0.6": { "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" }, @@ -180,15 +130,15 @@ "@std/fs@1.0.24": { "integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877", "dependencies": [ - "jsr:@std/internal@^1.0.14", + "jsr:@std/internal", "jsr:@std/path@^1.1.5" ] }, "@std/html@1.0.7": { "integrity": "175c818905a6e75743c69c251395e273d82698e58cfe7dd76268d70e28b8d8fe" }, - "@std/http@1.1.3": { - "integrity": "73a98e05ff58aa18bea562d286172e6c815b1eddd4fcab194beda19e3683b88c", + "@std/http@1.1.4": { + "integrity": "4f1575226ca7c0df1df38b26d50265fed30869b2a63ab867edc5b92492150c8c", "dependencies": [ "jsr:@std/encoding@^1.0.11" ] @@ -199,8 +149,8 @@ "@std/json@1.1.0": { "integrity": "93330d3ed4054d6b1ffe54b075432c80a5f671be77277b978931e018014cb1cc" }, - "@std/jsonc@1.0.2": { - "integrity": "909605dae3af22bd75b1cbda8d64a32cf1fd2cf6efa3f9e224aba6d22c0f44c7", + "@std/jsonc@1.0.3": { + "integrity": "9a4bb168c6b64e8e89944e45f96390a6e6e6865f59f02f0a70b24653911f2d64", "dependencies": [ "jsr:@std/json" ] @@ -208,35 +158,32 @@ "@std/media-types@1.1.0": { "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" }, - "@std/net@1.0.6": { - "integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c" + "@std/net@1.0.7": { + "integrity": "e0b995738cfe2ed56f64591d400e55f143cff164efd690343f2deb4cf4d06d86" }, "@std/path@0.217.0": { "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11", "dependencies": [ - "jsr:@std/assert@0.217" + "jsr:@std/assert" ] }, "@std/path@1.1.6": { "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", "dependencies": [ - "jsr:@std/internal@^1.0.14" + "jsr:@std/internal" ] }, "@std/semver@1.0.8": { "integrity": "dc830e8b8b6a380c895d53fbfd1258dc253704ca57bbe1629ac65fd7830179b7" }, - "@std/uuid@1.1.1": { - "integrity": "7b49060282d90f72fec64952f8c7a2914cbe6fdba3eca665f8cedc90830154a7", + "@std/uuid@1.1.2": { + "integrity": "348b68ca357058d83e3aa463c8a72e836d7df7e9e095332b0e1ec78873b40b7a", "dependencies": [ "jsr:@std/bytes" ] }, - "@std/yaml@1.2.0": { - "integrity": "20beb41e4983ba3437dbefac62b14061ab058e8a187596f19d28ff9035f6e6cf" - }, - "@zip-js/zip-js@2.8.61": { - "integrity": "8da37318aeefa76faa915058722ab8d1ee51e068edc8be69ea0810e01d724955" + "@std/yaml@1.3.0": { + "integrity": "b250103854378b8b36cbff0fe95aec017d3d9ce86b489c67605180c602fb4922" } }, "npm": { @@ -530,14 +477,14 @@ "vfile" ] }, - "@opentelemetry/api@1.9.1": { - "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==" + "@opentelemetry/api@1.10.0": { + "integrity": "sha512-lMkyes8iHrG+mk6WH7X70MdCLI2RS0FwNBY1PyQ8UemaijE2m5mv5A0Niy3HtbYTFrgkUEQjFMXmSAk2hQDuPQ==" }, - "@preact/signals-core@1.14.4": { - "integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==" + "@preact/signals-core@1.14.5": { + "integrity": "sha512-vcRv7Ityz5JVmeiVqIluLFzT/PDS/AlhHUpw3/aUWqcVUs6bDGIPXE/6RSC4evBNWYvnniozg5Syfj33al3gkg==" }, - "@preact/signals@2.11.1_preact@10.29.8__preact-render-to-string@6.7.0": { - "integrity": "sha512-uYoD+USkacTNU02kfCBkJEV7xabKTmA78W5pnT2GymsuGEC9n/ZO+UT4S96l0dIL6KLDk77VJyUFuOMBvftVHg==", + "@preact/signals@2.11.4_preact@10.29.8__preact-render-to-string@6.8.2_preact-render-to-string@6.8.2__preact@10.29.8": { + "integrity": "sha512-Rb+5RPo9gWSaHtzJeauhHxiC8KP/IF2DEPkHPEOGOnuy2wnJzRqyeLRankUCYd6ADqtxS6GnWGv6Ie343d8aYw==", "dependencies": [ "@preact/signals-core", "preact" @@ -576,8 +523,8 @@ "@types/ms@2.1.0": { "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" }, - "@types/node@26.4.0": { - "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", + "@types/node@26.6.6": { + "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", "dependencies": [ "undici-types" ] @@ -811,8 +758,8 @@ "extend@3.0.2": { "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, - "fast-equals@5.4.1": { - "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==" + "fast-equals@5.4.5": { + "integrity": "sha512-yKEpcFuRPu/pTQL2VB8YKE2yFldrLlIsoWtJeBnAdqEhvu21OgkFT0C8njf/xYc2LSZGEr3K9JGjthHJxnVLvQ==" }, "gray-matter@4.0.3": { "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", @@ -1440,9 +1387,6 @@ "ms@2.1.3": { "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, - "nodemailer@6.10.1": { - "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==" - }, "parse-entities@4.0.2": { "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", "dependencies": [ @@ -1455,8 +1399,8 @@ "is-hexadecimal" ] }, - "polizy@0.6.0": { - "integrity": "sha512-yoTY8wmuydsMOXdDb7RntkecXQAT9kjxr/0XXpnzEJe2hgjrGo1QqEg4WG7skfzK80fbY2TeQB+z9pblgzm7cw==", + "polizy@0.6.1": { + "integrity": "sha512-YxhcG5Qb3Oms9XVqxnwTUdmL5rLTn0GqTgA3HI11yuQLIT+tIS7xTnyE2aAjuTFZRtohocTPZmx3UuNcDH2GZQ==", "dependencies": [ "fast-equals", "tslib" @@ -1465,13 +1409,13 @@ "postgres@3.4.9": { "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==" }, - "preact-render-to-string@6.7.0_preact@10.29.8": { - "integrity": "sha512-Z4WR8fmLMRpdYqJ9i7vrlXSsSrxVJydwrkEXHapexfARbWfGb7vGcnvNQnIzN0cXciMVOlz/XLoiMCi9gUsy9Q==", + "preact-render-to-string@6.8.2_preact@10.29.8": { + "integrity": "sha512-Hdx7cKD3AMYgt795TdddBpUbuGEFoL/VeA6s7VrBt9Gjt0r6rCy5wMp5xkDjQ9cbKj62frHsEx1x8yV6EjoOpg==", "dependencies": [ "preact" ] }, - "preact@10.29.8_preact-render-to-string@6.7.0": { + "preact@10.29.8_preact-render-to-string@6.8.2": { "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", "dependencies": [ "preact-render-to-string" @@ -1672,8 +1616,8 @@ "tslib@2.8.1": { "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, - "undici-types@8.3.0": { - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==" + "undici-types@8.9.0": { + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==" }, "unified@11.0.5": { "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", @@ -1850,7 +1794,7 @@ "workspace": { "dependencies": [ "jsr:@db/sqlite@0.12", - "jsr:@dune/plugin-admin@3", + "jsr:@dune/plugin-admin@*", "jsr:@fresh/core@2", "jsr:@std/assert@1", "jsr:@std/crypto@1", diff --git a/src/cli/dev.ts b/src/cli/dev.ts index 170fb06d..506059d9 100644 --- a/src/cli/dev.ts +++ b/src/cli/dev.ts @@ -17,7 +17,8 @@ import { Builder } from "jsr:@fresh/core@^2/dev"; import { bootstrap } from "../runtime/bootstrap.ts"; import { createDuneApp } from "../runtime/server.ts"; import { collectThemeIslands, collectContentIslands } from "../themes/loader.ts"; -import { isValidPluginIslandSpecifier } from "../plugins/loader.ts"; +import { isValidPluginIslandSpecifier, resolveCapabilities } from "../plugins/loader.ts"; +import { adminPrefixOf } from "../plugin-api/capabilities.ts"; import { materializeRemoteIslands } from "./remote-islands.ts"; import { checkLockfileStaleness } from "./lockfile.ts"; import { resolveContentDirPath } from "../content/content-root.ts"; @@ -85,12 +86,15 @@ export async function devCommand(root: string, options: DevOptions = {}) { await registerInstance({ mode: "dev", root, title: config.site.title ?? null, port, version: CORE_VERSION, }); - const adminPrefix = config.admin?.path ?? "/admin"; + // Slots resolve once every plugin's setup() has settled; the banner needs + // `admin.prefix` before the app (and mountPlugins) exists. + await resolveCapabilities(ctx); + const adminPrefix = adminPrefixOf(ctx.capabilities); console.log(` 📄 ${engine.pages.length} pages indexed`); console.log(` 🏷️ ${taxonomy.names().length} taxonomies`); console.log(` 🔍 Search index built`); - if (config.admin?.enabled !== false) { + if (adminPrefix) { console.log(` 🔐 Admin panel: http://localhost:${port}${adminPrefix}/`); } diff --git a/src/cli/response-transforms.ts b/src/cli/response-transforms.ts index c22951d1..d1668544 100644 --- a/src/cli/response-transforms.ts +++ b/src/cli/response-transforms.ts @@ -103,7 +103,8 @@ export interface RunResponseTransformsOptions { */ resolve: (pathname: string) => ResolvedRoute; config: DuneConfig; - adminPrefix: string; + /** Where the admin is mounted, or null when there is no admin. */ + adminPrefix: string | null; } /** @@ -201,7 +202,8 @@ export async function runPluginResponseTransforms( template: matchedPage.template, } : null, - adminPrefix, + // "" when no admin is installed: plugins that build admin links only do so for a signed-in admin. + adminPrefix: adminPrefix ?? "", // A plugin can contribute to `bar.actions` without declaring // transformResponse itself, so the list comes from the contribution // point, not from the plugins selected to run a transform. diff --git a/src/cli/serve-utils.ts b/src/cli/serve-utils.ts index c01dfc84..d57df6f9 100644 --- a/src/cli/serve-utils.ts +++ b/src/cli/serve-utils.ts @@ -46,7 +46,9 @@ export function withSecurityHeaders(response: Response): Response { * such siblings. Use this helper wherever admin paths get special * treatment so all guards share the router's notion of "admin path". */ -export function isAdminPath(pathname: string, adminPrefix: string): boolean { +export function isAdminPath(pathname: string, adminPrefix: string | null | undefined): boolean { + // No admin installed (no prefix): nothing is an admin path. + if (!adminPrefix) return false; return pathname === adminPrefix || pathname.startsWith(`${adminPrefix}/`); } diff --git a/src/cli/serve.ts b/src/cli/serve.ts index 84d5ebb9..9bc8cbcb 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -26,6 +26,7 @@ import { bootstrap } from "../runtime/bootstrap.ts"; import { createDuneApp } from "../runtime/server.ts"; import { collectThemeIslands, collectContentIslands } from "../themes/loader.ts"; import { isValidPluginIslandSpecifier } from "../plugins/loader.ts"; +import { adminPrefixOf } from "../plugin-api/capabilities.ts"; import { materializeRemoteIslands } from "./remote-islands.ts"; import { startScheduledActions } from "./serve-utils.ts"; import { scanJobs, JobScheduler, warnIfMultiprocess } from "../jobs/mod.ts"; @@ -219,7 +220,6 @@ export async function serveCommand(root: string, options: ServeOptions = {}) { instance = await registerInstance({ mode: "serve", root, title: config.site.title ?? null, port, version: CORE_VERSION, }); - const adminPrefix = config.admin?.path ?? "/admin"; // Expose the configured runtimeDir to ConsoleEmailProvider so its dev-email // directory aligns with the admin preview route regardless of runtimeDir customisation. @@ -367,7 +367,9 @@ export async function serveCommand(root: string, options: ServeOptions = {}) { console.log(` 📄 ${engine.pages.length} pages indexed`); console.log(` 🗺️ Sitemap generated`); if (feedEnabled) console.log(` 📡 RSS + Atom feeds generated`); - if (config.admin?.enabled !== false) { + // createDuneApp() resolved the slots (mountPlugins), so the prefix is known here. + const adminPrefix = adminPrefixOf(ctx.capabilities); + if (adminPrefix) { console.log(` 🔐 Admin panel: http://localhost:${port}${adminPrefix}/`); } console.log(` 🌐 http://localhost:${port}\n`); diff --git a/src/mcp/resources.ts b/src/mcp/resources.ts index 09a32285..f9b47a9d 100644 --- a/src/mcp/resources.ts +++ b/src/mcp/resources.ts @@ -34,7 +34,6 @@ function makeConfigHandler(engine: DuneEngine): ResourceHandler { theme: config.theme, data_dir: config.system?.data_dir, admin: { - path: config.admin?.path, runtime_dir: resolveRuntimeDir(config), audit: config.admin?.audit, // Omit auth_provider details (may contain connection strings) diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 4e588ccb..e709c454 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -334,7 +334,6 @@ function makeGetConfigHandler(engine: DuneEngine): ToolHandler { debugMode: config.system.debug ?? false, }, admin: { - path: config.admin?.path ?? "/admin", auditEnabled: config.admin?.audit?.enabled !== false, }, plugins: (config.pluginList ?? []).map((entry) => { diff --git a/src/plugin-api/capabilities.ts b/src/plugin-api/capabilities.ts index 5eb4d492..65fe6aa9 100644 --- a/src/plugin-api/capabilities.ts +++ b/src/plugin-api/capabilities.ts @@ -76,11 +76,23 @@ export interface ScheduledActionExecutor { execute(action: ScheduledAction): Promise; } +/** + * Where the admin lives. Core never assumes an admin exists or where it is + * mounted: it reads this slot to keep admin routes out of the page cache, the + * static build and the plugin-route namespace. No provider means no admin. + */ +export interface AdminPrefix { + /** URL path prefix the admin is mounted at: starts with `/`, no trailing slash (e.g. `/admin`). */ + readonly prefix: string; +} + /** * Slots. An `interface` so a later release can open it to declaration merging; * 1.0 ships first-party keys only and third-party keys are unsupported. */ export interface Capabilities { + /** Where the admin is mounted. Consumers: core's request handling, static build and plugin-route registration. */ + "admin.prefix": AdminPrefix; /** Who is the admin on this request. Consumers: the admin bar's response transform, the inline-edit WebSocket. */ "auth.session": RequestAuthenticator; /** Executes due scheduled actions. Consumer: `dune serve`, which owns the scheduler's polling loop. */ @@ -155,6 +167,20 @@ export interface CapabilityRegistry { resolve(ctx: CapabilityContext): Promise; } +/** + * The admin URL prefix, or `null` when no plugin provides one (or the provided + * value is not a usable prefix). Read it lazily: slots resolve after `setup()`. + */ +export function adminPrefixOf(reader: CapabilityReader): string | null { + const prefix = reader.get("admin.prefix")?.prefix; + if (typeof prefix !== "string") return null; + if (prefix.length < 2 || !prefix.startsWith("/") || prefix.endsWith("/")) { + logger.warn("capabilities.admin_prefix_invalid", { prefix }); + return null; + } + return prefix; +} + // ── Implementation ─────────────────────────────────────────────────────────── interface SlotEntry { diff --git a/src/plugin-api/mod.ts b/src/plugin-api/mod.ts index 819bf8d5..20641be5 100644 --- a/src/plugin-api/mod.ts +++ b/src/plugin-api/mod.ts @@ -20,6 +20,7 @@ export type { } from "./services.ts"; export { createPluginServices } from "./create-services.ts"; export type { + AdminPrefix, AuthenticateResult, CapabilityContext, CapabilityFactory, @@ -31,7 +32,7 @@ export type { RequestAuthenticator, ScheduledActionExecutor, } from "./capabilities.ts"; -export { createCapabilityRegistry } from "./capabilities.ts"; +export { adminPrefixOf, createCapabilityRegistry } from "./capabilities.ts"; export type { CapabilityRegistry } from "./capabilities.ts"; export type { PluginApp, PluginRouteContext, PluginRouteHandler } from "./app.ts"; export type { PluginRuntime } from "./runtime.ts"; diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 610130a9..6f940ab2 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -381,6 +381,43 @@ export async function applyResponseTransforms( return response; } +/** + * Settle every plugin's `setup()` and resolve the capability slots. + * + * Idempotent: slots that have resolved are not run again, so the app builder, + * `dune build` and any other caller that needs to read a slot can call it. + * Never throws (a throwing factory is logged and leaves its slot empty). + */ +export async function resolveCapabilities( + ctx: import("../runtime/bootstrap.ts").BootstrapResult, +): Promise { + // Plugin setup() is fire-and-forget at registration time (see + // registry.ts's registerPlugin()), so a plugin's async setup() work + // (e.g. @dune/plugin-admin's auditLogger.init()) may still be in flight + // here. Capability factories and mount() both read state setup() + // initializes, so wait for every plugin's setup() to settle before any of + // them run. + await ctx.hooks.whenSetupComplete(); + + // Capability slots: every plugin has declared by now, and the infrastructure + // a factory may need (history, hooks, storage) exists. A throwing factory is + // logged and leaves its slot empty; it never stops the mount. + await ctx.capabilityRegistry.resolve({ + storage: ctx.storage, + config: ctx.config, + history: ctx.history, + hooks: ctx.hooks, + services: ctx.pluginServices, + dataDir: resolveRuntimeDir(ctx.config), + // NOTE: intentionally still the raw config value, not engine.contentDir. + // `storage` is dual-purpose (content AND data files), so this pair only + // stays correct when content.src is unset. Supporting content.src here + // needs separate content/data storage fields; content.src's driving use + // case (multisite demo fixtures) has no admin. + contentDir: ctx.config.system.content.dir, + }); +} + /** * Resolve the plugins' capability slots, then call every plugin's `mount()` * hook in registration order. @@ -411,33 +448,10 @@ export async function mountPlugins( app: import("fresh").App, ctx: import("../runtime/bootstrap.ts").BootstrapResult, ): Promise { - // Plugin setup() is fire-and-forget at registration time (see - // registry.ts's registerPlugin()), so a plugin's async setup() work - // (e.g. @dune/plugin-admin's auditLogger.init()) may still be in flight - // here. Capability factories and mount() both read state setup() - // initializes, so wait for every plugin's setup() to settle before any of - // them run. - await ctx.hooks.whenSetupComplete(); + await resolveCapabilities(ctx); const registeredOrder = ctx.hooks.plugins(); - // Capability slots: every plugin has declared by now, and the infrastructure - // a factory may need (history, hooks, storage) exists. A throwing factory is - // logged and leaves its slot empty; it never stops the mount. - await ctx.capabilityRegistry.resolve({ - storage: ctx.storage, - config: ctx.config, - history: ctx.history, - hooks: ctx.hooks, - services: ctx.pluginServices, - dataDir: resolveRuntimeDir(ctx.config), - // NOTE: intentionally still the raw config value, not engine.contentDir. - // `storage` is dual-purpose (content AND data files), so this pair only - // stays correct when content.src is unset. Supporting content.src here - // needs separate content/data storage fields; content.src's driving use - // case (multisite demo fixtures) has no admin. - contentDir: ctx.config.system.content.dir, - }); logger.info("capabilities.resolved", { providers: ctx.capabilityRegistry.reader.providers() diff --git a/src/plugins/mod.ts b/src/plugins/mod.ts index ec970951..4b8b161f 100644 --- a/src/plugins/mod.ts +++ b/src/plugins/mod.ts @@ -97,7 +97,13 @@ export const CORE_VERSION: string = denoJson.version; */ export const CORE_INSTANCE: Readonly> = Object.freeze({}); -export { loadPlugins, loadPluginAdminConfigs, applyResponseTransforms, mountPlugins } from "./loader.ts"; +export { + applyResponseTransforms, + loadPluginAdminConfigs, + loadPlugins, + mountPlugins, + resolveCapabilities, +} from "./loader.ts"; export type { PluginLoaderOptions } from "./loader.ts"; export type { /** A button contributed to the shared admin bar UI through the `bar.actions` contribution point. */ diff --git a/src/runtime/register-middleware.ts b/src/runtime/register-middleware.ts index 2309ff4e..d40f5a92 100644 --- a/src/runtime/register-middleware.ts +++ b/src/runtime/register-middleware.ts @@ -46,7 +46,8 @@ export interface ContentMiddlewareOptions { cacheDefaults: { maxAge: number; swr: number }; feedEnabled: boolean; siteName: string; - adminPrefix: string; + /** Where the admin is mounted, or null when there is no admin. Read per request. */ + adminPrefix: () => string | null; routes: DuneRoutes; } @@ -115,7 +116,7 @@ export function registerContentCatchAll( actionToRelations: authzSchema.actionToRelations as Record, resolve: engine.router.resolve, config, - adminPrefix, + adminPrefix: adminPrefix(), }); app.all("/*", async (fc) => { diff --git a/src/runtime/register-plugin-routes.ts b/src/runtime/register-plugin-routes.ts index a116e53d..c3736f7a 100644 --- a/src/runtime/register-plugin-routes.ts +++ b/src/runtime/register-plugin-routes.ts @@ -20,8 +20,8 @@ import type { BootstrapResult } from "./bootstrap.ts"; /** Options for {@link registerPluginPublicRoutes}. */ export interface RegisterPluginPublicRoutesOptions { - /** The site's admin path prefix (e.g. "/admin"), so plugin routes can't shadow it. */ - adminPrefix: string; + /** The site's admin path prefix (e.g. "/admin"), so plugin routes can't shadow it; null when there is no admin. */ + adminPrefix: string | null; } /** @@ -62,7 +62,7 @@ export function registerPluginPublicRoutes( if (registeredFor.has(ctx)) return; registeredFor.add(ctx); const reservedPrefixes = [ - options.adminPrefix, + ...(options.adminPrefix ? [options.adminPrefix] : []), "/api/forms", "/api/webhook", "/api/inline-edit", diff --git a/src/runtime/server.ts b/src/runtime/server.ts index 2d6b13a1..2c606fed 100644 --- a/src/runtime/server.ts +++ b/src/runtime/server.ts @@ -19,6 +19,7 @@ import { mountPlugins } from "../plugins/loader.ts"; import { mountDuneAuth } from "../auth/mount.ts"; import { getUser, USER_HEADER } from "../auth/types.ts"; import { isAdminPath, withSecurityHeaders } from "../cli/serve-utils.ts"; +import { adminPrefixOf } from "../plugin-api/capabilities.ts"; import { trackRequest } from "./watchdog.ts"; import { duneRoutes } from "../routing/routes.ts"; import { buildPluginClientBundles } from "../cli/client-bundles.ts"; @@ -126,7 +127,7 @@ export function stripUserHeader(req: Request): Request { function stripSetCookieOnAdmin( res: Response, pathname: string, - prefix: string, + prefix: string | null, ): Response { if (!isAdminPath(pathname, prefix)) return res; if (!res.headers.has("set-cookie")) return res; @@ -260,7 +261,10 @@ export async function createDuneApp( const startTime = Date.now(); const feedEnabled = config.site.feed?.enabled !== false; const siteName = engine.site.title; - const adminPrefix = config.admin?.path ?? "/admin"; + // Where the admin is mounted comes from whichever plugin provides the + // `admin.prefix` slot; none means there is no admin. Slots resolve in + // mountPlugins(), after this function starts, so read it per use. + const getAdminPrefix = () => adminPrefixOf(ctx.capabilities); const routes = duneRoutes( engine, @@ -385,7 +389,7 @@ export async function createDuneApp( sanitizedReq, ); if (hookResult instanceof Response) { - if (isAdminPath(fc.url.pathname, adminPrefix)) { + if (isAdminPath(fc.url.pathname, getAdminPrefix())) { console.warn( `[dune] plugin onRequest tried to short-circuit admin path ${fc.url.pathname}; ignoring response.`, ); @@ -395,7 +399,7 @@ export async function createDuneApp( const finalResponse = stripSetCookieOnAdmin( hookResult, fc.url.pathname, - adminPrefix, + getAdminPrefix(), ); metrics?.recordRequest( fc.url.pathname, @@ -445,7 +449,7 @@ export async function createDuneApp( // admin is disabled (headless mode), nothing calls it during // mountPlugins(), so this call is what actually registers the routes — // unchanged from the behavior commit 77bf31f introduced this call to fix. - registerPluginPublicRoutes(app, ctx, { adminPrefix }); + registerPluginPublicRoutes(app, ctx, { adminPrefix: getAdminPrefix() }); // 5b. Public auth — only when the site has an `auth:` block at all, so // sites that never opted in get zero behavior change (no new directories, @@ -536,7 +540,7 @@ export async function createDuneApp( cacheDefaults, feedEnabled, siteName, - adminPrefix, + adminPrefix: getAdminPrefix, routes, }); diff --git a/src/ssg/builder.ts b/src/ssg/builder.ts index 92f2952f..48156757 100644 --- a/src/ssg/builder.ts +++ b/src/ssg/builder.ts @@ -37,6 +37,8 @@ import { hashFile, type BuildManifest, } from "./manifest.ts"; +import { resolveCapabilities } from "../plugins/loader.ts"; +import { adminPrefixOf } from "../plugin-api/capabilities.ts"; // ─── Constants ─────────────────────────────────────────────────────────────── @@ -195,7 +197,10 @@ export async function buildStatic( // ── Hybrid config ───────────────────────────────────────────────────────── if (opts.hybrid) { - await writeHybridConfig(outDir, config); + // The admin prefix comes from the `admin.prefix` slot; resolve slots first + // (idempotent). No admin means only the API is forwarded to the Worker. + await resolveCapabilities(ctx); + await writeHybridConfig(outDir, adminPrefixOf(ctx.capabilities)); } // ── Save manifest ───────────────────────────────────────────────────────── @@ -551,14 +556,13 @@ async function copyDir( * routes are forwarded to a Worker while everything else is served from the * static assets. */ -async function writeHybridConfig(outDir: string, config: DuneConfig): Promise { - const adminPath = config.admin?.path ?? "/admin"; +async function writeHybridConfig(outDir: string, adminPath: string | null): Promise { // Cloudflare Pages _routes.json const routesJson = { version: 1, description: "Dune hybrid mode — static pages + dynamic API/admin", - include: [`${adminPath}/*`, "/api/*"], + include: [...(adminPath ? [`${adminPath}/*`] : []), "/api/*"], exclude: ["/static/*", "/themes/*", "/content-media/*"], }; await Deno.writeTextFile( @@ -568,7 +572,7 @@ async function writeHybridConfig(outDir: string, config: DuneConfig): Promise { + const auth = makeAuth({}); + const { plugin, seen } = makeRecordingPlugin(); + const result = await runPluginResponseTransforms({ + req: makeReq("/admin/news"), + response: htmlResponse("

news

"), + plugins: [plugin], + auth, + resolve, + config, + adminPrefix: null, + }); + assertEquals(seen.length, 1, "the transform ran: with no admin, /admin/news is content"); + assertEquals(seen[0].adminPrefix, ""); + assertEquals((await result.text()).includes("news"), true); +}); + +Deno.test("runPluginResponseTransforms: an admin prefix still keeps its own paths out of the pipeline", async () => { + const { plugin, seen } = makeRecordingPlugin(); + const original = htmlResponse("

panel

"); + const result = await runPluginResponseTransforms({ + req: makeReq("/admin/pages"), + response: original, + plugins: [plugin], + auth: makeAuth({}), + resolve, + config, + adminPrefix: "/admin", + }); + assertEquals(seen.length, 0); + assertStrictEquals(result, original); +}); diff --git a/tests/cli/serve_utils_test.ts b/tests/cli/serve_utils_test.ts index 3812741e..3c2fa72f 100644 --- a/tests/cli/serve_utils_test.ts +++ b/tests/cli/serve_utils_test.ts @@ -184,3 +184,10 @@ Deno.test("servePluginAsset: serves encoded names and rejects encoded traversal" await Deno.remove(base, { recursive: true }); } }); + +Deno.test("isAdminPath: with no admin prefix nothing is an admin path", () => { + assertEquals(isAdminPath("/admin", null), false); + assertEquals(isAdminPath("/admin/pages", undefined), false); + assertEquals(isAdminPath("/", ""), false); + assertEquals(isAdminPath("/anything", null), false); +}); diff --git a/tests/plugin-api/capabilities_test.ts b/tests/plugin-api/capabilities_test.ts index 6f11024e..5b92853d 100644 --- a/tests/plugin-api/capabilities_test.ts +++ b/tests/plugin-api/capabilities_test.ts @@ -2,13 +2,14 @@ import { assertEquals, assertExists } from "@std/assert"; import { join } from "@std/path"; import { App } from "fresh"; import { bootstrap } from "../../src/runtime/bootstrap.ts"; -import { mountPlugins } from "../../src/plugins/loader.ts"; +import { mountPlugins, resolveCapabilities } from "../../src/plugins/loader.ts"; import { createHookRegistry } from "../../src/hooks/registry.ts"; import { DEFAULT_CONFIG } from "../../src/config/defaults.ts"; import type { DuneConfig } from "../../src/config/types.ts"; import type { DunePlugin } from "../../src/hooks/types.ts"; import { MemoryStorageAdapter } from "../../src/storage/memory.ts"; import { + adminPrefixOf, type CapabilityContext, createCapabilityRegistry, type RequestAuthenticator, @@ -189,6 +190,59 @@ Deno.test( }, ); +Deno.test("adminPrefixOf: the provided prefix, or null when there is none", async () => { + const reg = createCapabilityRegistry(); + assertEquals(adminPrefixOf(reg.reader), null, "nothing provided"); + reg.registrar("admin").provide("admin.prefix", () => ({ prefix: "/cms" })); + assertEquals(adminPrefixOf(reg.reader), null, "not resolved yet"); + await reg.resolve(ctx); + assertEquals(adminPrefixOf(reg.reader), "/cms"); +}); + +Deno.test("adminPrefixOf: refuses a prefix that is not a usable path prefix", async () => { + for (const bad of ["", "/", "admin", "/admin/"]) { + const reg = createCapabilityRegistry(); + reg.registrar("admin").provide("admin.prefix", () => ({ prefix: bad })); + await reg.resolve(ctx); + assertEquals(adminPrefixOf(reg.reader), null, `"${bad}" must not become the admin prefix`); + } +}); + +Deno.test( + "resolveCapabilities: resolves slots once, before mount, and can be called again", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + const root = await Deno.makeTempDir({ prefix: "dune_test_resolve_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"); + await Deno.mkdir(join(root, "config"), { recursive: true }); + await Deno.writeTextFile(join(root, "config", "system.yaml"), "admin:\n enabled: false\n"); + let runs = 0; + const plugin: DunePlugin = { + name: "test-admin", + version: "1.0.0", + hooks: {}, + setup(api) { + api.capabilities.provide("admin.prefix", () => { + runs++; + return { prefix: "/cms" }; + }); + }, + }; + const boot = await bootstrap(root, { plugins: [plugin] }); + await resolveCapabilities(boot); + assertEquals(adminPrefixOf(boot.capabilities), "/cms"); + await resolveCapabilities(boot); + // deno-lint-ignore no-explicit-any + await mountPlugins(new App(), boot); + assertEquals(runs, 1, "the factory runs once however many times slots are resolved"); + } finally { + await removeWithRetry(root); + } + }, +); + // bootstrap() can still hold a file handle for a moment on teardown. async function removeWithRetry(root: string): Promise { let lastErr: unknown; diff --git a/tests/runtime/register_plugin_routes_test.ts b/tests/runtime/register_plugin_routes_test.ts index 2a3fe13f..80c0d1b5 100644 --- a/tests/runtime/register_plugin_routes_test.ts +++ b/tests/runtime/register_plugin_routes_test.ts @@ -121,8 +121,20 @@ Deno.test( }, ); +/** A stand-in admin: all core needs from it is where it is mounted. */ +function adminAt(prefix: string): DunePlugin { + return { + name: "test-admin", + version: "1.0.0", + hooks: {}, + setup(api) { + api.capabilities.provide("admin.prefix", () => ({ prefix })); + }, + }; +} + Deno.test( - "registerPluginPublicRoutes: rejects a route that shadows a reserved prefix", + "registerPluginPublicRoutes: rejects a route under the prefix an admin provides", { sanitizeOps: false, sanitizeResources: false }, async () => { const plugin: DunePlugin = { @@ -136,19 +148,61 @@ Deno.test( }, ], }; - await withTestApp([plugin], async (handler) => { - // No admin package is registered, so /admin/hijack isn't claimed by - // real admin routes either — if the plugin route had registered, it - // would answer with 200. It must not: the reserved-prefix check - // rejects it regardless of whether the admin panel is actually - // running, since the check is about the path shape, not runtime - // admin state. + await withTestApp([adminAt("/admin"), plugin], async (handler) => { + // The admin package here only declares where it lives (it mounts no + // routes), so /admin/hijack would answer 200 if the plugin route had + // registered. It must not: the provided prefix is reserved. const res = await handler(new Request("http://localhost/admin/hijack")); assertEquals(res.status !== 200, true); }); }, ); +Deno.test( + "registerPluginPublicRoutes: with no admin installed, /admin is an ordinary path", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + const plugin: DunePlugin = { + name: "test-no-admin-plugin", + version: "1.0.0", + hooks: {}, + publicRoutes: [ + { + path: "/admin/status", + handler: () => new Response("mine", { status: 200 }), + }, + ], + }; + await withTestApp([plugin], async (handler) => { + const res = await handler(new Request("http://localhost/admin/status")); + assertEquals(res.status, 200); + assertEquals(await res.text(), "mine"); + }); + }, +); + +Deno.test( + "registerPluginPublicRoutes: a custom admin prefix is the reserved one, /admin is not", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + const plugin: DunePlugin = { + name: "test-custom-prefix-plugin", + version: "1.0.0", + hooks: {}, + publicRoutes: [ + { path: "/admin/open", handler: () => new Response("open", { status: 200 }) }, + { path: "/cms/closed", handler: () => new Response("closed", { status: 200 }) }, + ], + }; + await withTestApp([adminAt("/cms"), plugin], async (handler) => { + const open = await handler(new Request("http://localhost/admin/open")); + assertEquals(open.status, 200); + const closed = await handler(new Request("http://localhost/cms/closed")); + assertEquals(closed.status !== 200, true); + }); + }, +); + Deno.test( "registerPluginPublicRoutes: rejects routes that shadow core API and auth prefixes", { sanitizeOps: false, sanitizeResources: false }, diff --git a/tests/ssg/builder_test.ts b/tests/ssg/builder_test.ts index e2d79aa4..0a74b419 100644 --- a/tests/ssg/builder_test.ts +++ b/tests/ssg/builder_test.ts @@ -119,3 +119,50 @@ Deno.test( }); }, ); + +async function hybridSite(root: string): Promise { + await Deno.mkdir(join(root, "content", "01.home"), { recursive: true }); + await Deno.writeTextFile( + join(root, "content", "01.home", "default.md"), + "---\ntitle: Home\n---\n\n# Home\n", + ); + // No built-in admin: these tests supply (or withhold) the admin.prefix slot themselves. + await Deno.mkdir(join(root, "config"), { recursive: true }); + await Deno.writeTextFile(join(root, "config", "system.yaml"), "admin:\n enabled: false\n"); +} + +Deno.test( + "buildStatic hybrid: the admin routes come from the admin.prefix slot", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + await withTempSite(async (root) => { + await hybridSite(root); + const ctx = await bootstrap(root, {}); + ctx.capabilityRegistry.registrar("test-admin").provide("admin.prefix", () => ({ prefix: "/cms" })); + await buildStatic(root, ctx, { ...ssgOpts, hybrid: true }); + + const routes = JSON.parse(await Deno.readTextFile(join(root, "dist", "_routes.json"))); + assertEquals(routes.include, ["/cms/*", "/api/*"]); + const redirects = await Deno.readTextFile(join(root, "dist", "_redirects")); + assertEquals(redirects.includes("/cms/* /.netlify/functions/dune 200"), true); + assertEquals(redirects.includes("/admin/"), false); + }); + }, +); + +Deno.test( + "buildStatic hybrid: with no admin installed only the API is forwarded", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + await withTempSite(async (root) => { + await hybridSite(root); + const ctx = await bootstrap(root, {}); + await buildStatic(root, ctx, { ...ssgOpts, hybrid: true }); + + const routes = JSON.parse(await Deno.readTextFile(join(root, "dist", "_routes.json"))); + assertEquals(routes.include, ["/api/*"]); + const redirects = await Deno.readTextFile(join(root, "dist", "_redirects")); + assertEquals(redirects, "/api/* /.netlify/functions/dune 200\n"); + }); + }, +); From 439d7e4858b92eb2f57000b7afe7d65092a634db Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 23:51:39 +0100 Subject: [PATCH 09/31] chore: drop the deno.lock change that slipped into the step 2 commit Co-Authored-By: Claude Sonnet 5.5 --- deno.lock | 148 +++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 102 insertions(+), 46 deletions(-) diff --git a/deno.lock b/deno.lock index 5f4bfdf7..eb02d315 100644 --- a/deno.lock +++ b/deno.lock @@ -5,9 +5,13 @@ "jsr:@deno/esbuild-plugin@^1.2.0": "1.2.1", "jsr:@deno/loader@~0.3.10": "0.3.14", "jsr:@denosaurs/plug@1": "1.1.0", + "jsr:@dune/core@~0.34.2": "0.34.14", + "jsr:@dune/plugin-admin@*": "3.0.0", + "jsr:@dune/plugin-admin@3": "3.0.0", "jsr:@fresh/build-id@1": "1.0.1", "jsr:@fresh/core@2": "2.3.3", "jsr:@std/assert@0.217": "0.217.0", + "jsr:@std/assert@1": "1.0.19", "jsr:@std/bytes@^1.0.6": "1.0.6", "jsr:@std/crypto@1": "1.1.0", "jsr:@std/encoding@1": "1.0.11", @@ -18,24 +22,27 @@ "jsr:@std/fs@1": "1.0.24", "jsr:@std/fs@^1.0.19": "1.0.24", "jsr:@std/html@^1.0.5": "1.0.7", - "jsr:@std/http@^1.0.21": "1.1.4", + "jsr:@std/http@^1.0.21": "1.1.3", + "jsr:@std/internal@^1.0.12": "1.0.14", "jsr:@std/internal@^1.0.14": "1.0.14", - "jsr:@std/json@^1.1.0": "1.1.0", - "jsr:@std/jsonc@^1.0.2": "1.0.3", + "jsr:@std/json@^1.0.2": "1.1.0", + "jsr:@std/jsonc@^1.0.2": "1.0.2", "jsr:@std/media-types@^1.1.0": "1.1.0", - "jsr:@std/net@^1.0.6": "1.0.7", + "jsr:@std/net@^1.0.6": "1.0.6", "jsr:@std/path@0.217": "0.217.0", "jsr:@std/path@1": "1.1.6", "jsr:@std/path@^1.1.1": "1.1.6", "jsr:@std/path@^1.1.2": "1.1.6", "jsr:@std/path@^1.1.4": "1.1.6", "jsr:@std/path@^1.1.5": "1.1.6", + "jsr:@std/semver@1": "1.0.8", "jsr:@std/semver@^1.0.6": "1.0.8", - "jsr:@std/uuid@^1.0.9": "1.1.2", - "jsr:@std/yaml@1": "1.3.0", + "jsr:@std/uuid@^1.0.9": "1.1.1", + "jsr:@std/yaml@1": "1.2.0", + "jsr:@zip-js/zip-js@2": "2.8.61", "npm:@mdx-js/mdx@3": "3.1.1", - "npm:@opentelemetry/api@^1.9.0": "1.10.0", - "npm:@preact/signals@^2.5.1": "2.11.4_preact@10.29.8__preact-render-to-string@6.8.2_preact-render-to-string@6.8.2__preact@10.29.8", + "npm:@opentelemetry/api@^1.9.0": "1.9.1", + "npm:@preact/signals@^2.5.1": "2.11.1_preact@10.29.8__preact-render-to-string@6.7.0", "npm:@types/nodemailer@6": "6.4.24", "npm:esbuild-wasm@~0.25.11": "0.25.12", "npm:esbuild@0.25.7": "0.25.7", @@ -43,13 +50,14 @@ "npm:gray-matter@4": "4.0.3", "npm:ioredis@5": "5.11.1", "npm:marked@15": "15.0.12", - "npm:polizy@0.6": "0.6.1", + "npm:nodemailer@6": "6.10.1", + "npm:polizy@0.6": "0.6.0", "npm:postgres@^3.4.0": "3.4.9", - "npm:preact-render-to-string@6": "6.8.2_preact@10.29.8", - "npm:preact-render-to-string@^6.6.3": "6.8.2_preact@10.29.8", - "npm:preact@10": "10.29.8_preact-render-to-string@6.8.2", - "npm:preact@^10.28.2": "10.29.8_preact-render-to-string@6.8.2", - "npm:preact@^10.29.1": "10.29.8_preact-render-to-string@6.8.2", + "npm:preact-render-to-string@6": "6.7.0_preact@10.29.8", + "npm:preact-render-to-string@^6.6.3": "6.7.0_preact@10.29.8", + "npm:preact@10": "10.29.8_preact-render-to-string@6.7.0", + "npm:preact@^10.28.2": "10.29.8_preact-render-to-string@6.7.0", + "npm:preact@^10.29.1": "10.29.8_preact-render-to-string@6.7.0", "npm:remark-gfm@4": "4.0.1", "npm:sharp@0.33": "0.33.5" }, @@ -81,6 +89,42 @@ "jsr:@std/path@1" ] }, + "@dune/core@0.34.14": { + "integrity": "73a43da4d74c507eb92078f91c62e17de91b1693022a4aa98f024acd5db8c224", + "dependencies": [ + "jsr:@db/sqlite", + "jsr:@fresh/core", + "jsr:@std/crypto", + "jsr:@std/encoding@1", + "jsr:@std/fs@1", + "jsr:@std/path@^1.1.4", + "jsr:@std/yaml", + "npm:gray-matter", + "npm:ioredis", + "npm:marked", + "npm:nodemailer", + "npm:polizy", + "npm:postgres", + "npm:preact-render-to-string@6", + "npm:preact@10" + ] + }, + "@dune/plugin-admin@3.0.0": { + "integrity": "48c7bc839a8fc4b7196a2ac5728f70c9cf4ce7bc4cf8facd7d2101e44fa70e0d", + "dependencies": [ + "jsr:@dune/core", + "jsr:@fresh/core", + "jsr:@std/encoding@1", + "jsr:@std/path@^1.1.4", + "jsr:@std/yaml", + "jsr:@zip-js/zip-js", + "npm:@types/nodemailer", + "npm:marked", + "npm:nodemailer", + "npm:preact-render-to-string@6", + "npm:preact@10" + ] + }, "@fresh/build-id@1.0.1": { "integrity": "12a2ec25fd52ae9ec68c26848a5696cd1c9b537f7c983c7e56e4fb1e7e816c20", "dependencies": [ @@ -101,7 +145,7 @@ "jsr:@std/media-types", "jsr:@std/net", "jsr:@std/path@^1.1.2", - "jsr:@std/semver", + "jsr:@std/semver@^1.0.6", "jsr:@std/uuid", "npm:@opentelemetry/api", "npm:@preact/signals", @@ -115,6 +159,12 @@ "@std/assert@0.217.0": { "integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642" }, + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, "@std/bytes@1.0.6": { "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" }, @@ -130,15 +180,15 @@ "@std/fs@1.0.24": { "integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877", "dependencies": [ - "jsr:@std/internal", + "jsr:@std/internal@^1.0.14", "jsr:@std/path@^1.1.5" ] }, "@std/html@1.0.7": { "integrity": "175c818905a6e75743c69c251395e273d82698e58cfe7dd76268d70e28b8d8fe" }, - "@std/http@1.1.4": { - "integrity": "4f1575226ca7c0df1df38b26d50265fed30869b2a63ab867edc5b92492150c8c", + "@std/http@1.1.3": { + "integrity": "73a98e05ff58aa18bea562d286172e6c815b1eddd4fcab194beda19e3683b88c", "dependencies": [ "jsr:@std/encoding@^1.0.11" ] @@ -149,8 +199,8 @@ "@std/json@1.1.0": { "integrity": "93330d3ed4054d6b1ffe54b075432c80a5f671be77277b978931e018014cb1cc" }, - "@std/jsonc@1.0.3": { - "integrity": "9a4bb168c6b64e8e89944e45f96390a6e6e6865f59f02f0a70b24653911f2d64", + "@std/jsonc@1.0.2": { + "integrity": "909605dae3af22bd75b1cbda8d64a32cf1fd2cf6efa3f9e224aba6d22c0f44c7", "dependencies": [ "jsr:@std/json" ] @@ -158,32 +208,35 @@ "@std/media-types@1.1.0": { "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" }, - "@std/net@1.0.7": { - "integrity": "e0b995738cfe2ed56f64591d400e55f143cff164efd690343f2deb4cf4d06d86" + "@std/net@1.0.6": { + "integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c" }, "@std/path@0.217.0": { "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11", "dependencies": [ - "jsr:@std/assert" + "jsr:@std/assert@0.217" ] }, "@std/path@1.1.6": { "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", "dependencies": [ - "jsr:@std/internal" + "jsr:@std/internal@^1.0.14" ] }, "@std/semver@1.0.8": { "integrity": "dc830e8b8b6a380c895d53fbfd1258dc253704ca57bbe1629ac65fd7830179b7" }, - "@std/uuid@1.1.2": { - "integrity": "348b68ca357058d83e3aa463c8a72e836d7df7e9e095332b0e1ec78873b40b7a", + "@std/uuid@1.1.1": { + "integrity": "7b49060282d90f72fec64952f8c7a2914cbe6fdba3eca665f8cedc90830154a7", "dependencies": [ "jsr:@std/bytes" ] }, - "@std/yaml@1.3.0": { - "integrity": "b250103854378b8b36cbff0fe95aec017d3d9ce86b489c67605180c602fb4922" + "@std/yaml@1.2.0": { + "integrity": "20beb41e4983ba3437dbefac62b14061ab058e8a187596f19d28ff9035f6e6cf" + }, + "@zip-js/zip-js@2.8.61": { + "integrity": "8da37318aeefa76faa915058722ab8d1ee51e068edc8be69ea0810e01d724955" } }, "npm": { @@ -477,14 +530,14 @@ "vfile" ] }, - "@opentelemetry/api@1.10.0": { - "integrity": "sha512-lMkyes8iHrG+mk6WH7X70MdCLI2RS0FwNBY1PyQ8UemaijE2m5mv5A0Niy3HtbYTFrgkUEQjFMXmSAk2hQDuPQ==" + "@opentelemetry/api@1.9.1": { + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==" }, - "@preact/signals-core@1.14.5": { - "integrity": "sha512-vcRv7Ityz5JVmeiVqIluLFzT/PDS/AlhHUpw3/aUWqcVUs6bDGIPXE/6RSC4evBNWYvnniozg5Syfj33al3gkg==" + "@preact/signals-core@1.14.4": { + "integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==" }, - "@preact/signals@2.11.4_preact@10.29.8__preact-render-to-string@6.8.2_preact-render-to-string@6.8.2__preact@10.29.8": { - "integrity": "sha512-Rb+5RPo9gWSaHtzJeauhHxiC8KP/IF2DEPkHPEOGOnuy2wnJzRqyeLRankUCYd6ADqtxS6GnWGv6Ie343d8aYw==", + "@preact/signals@2.11.1_preact@10.29.8__preact-render-to-string@6.7.0": { + "integrity": "sha512-uYoD+USkacTNU02kfCBkJEV7xabKTmA78W5pnT2GymsuGEC9n/ZO+UT4S96l0dIL6KLDk77VJyUFuOMBvftVHg==", "dependencies": [ "@preact/signals-core", "preact" @@ -523,8 +576,8 @@ "@types/ms@2.1.0": { "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" }, - "@types/node@26.6.6": { - "integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", + "@types/node@26.4.0": { + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", "dependencies": [ "undici-types" ] @@ -758,8 +811,8 @@ "extend@3.0.2": { "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, - "fast-equals@5.4.5": { - "integrity": "sha512-yKEpcFuRPu/pTQL2VB8YKE2yFldrLlIsoWtJeBnAdqEhvu21OgkFT0C8njf/xYc2LSZGEr3K9JGjthHJxnVLvQ==" + "fast-equals@5.4.1": { + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==" }, "gray-matter@4.0.3": { "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", @@ -1387,6 +1440,9 @@ "ms@2.1.3": { "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "nodemailer@6.10.1": { + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==" + }, "parse-entities@4.0.2": { "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", "dependencies": [ @@ -1399,8 +1455,8 @@ "is-hexadecimal" ] }, - "polizy@0.6.1": { - "integrity": "sha512-YxhcG5Qb3Oms9XVqxnwTUdmL5rLTn0GqTgA3HI11yuQLIT+tIS7xTnyE2aAjuTFZRtohocTPZmx3UuNcDH2GZQ==", + "polizy@0.6.0": { + "integrity": "sha512-yoTY8wmuydsMOXdDb7RntkecXQAT9kjxr/0XXpnzEJe2hgjrGo1QqEg4WG7skfzK80fbY2TeQB+z9pblgzm7cw==", "dependencies": [ "fast-equals", "tslib" @@ -1409,13 +1465,13 @@ "postgres@3.4.9": { "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==" }, - "preact-render-to-string@6.8.2_preact@10.29.8": { - "integrity": "sha512-Hdx7cKD3AMYgt795TdddBpUbuGEFoL/VeA6s7VrBt9Gjt0r6rCy5wMp5xkDjQ9cbKj62frHsEx1x8yV6EjoOpg==", + "preact-render-to-string@6.7.0_preact@10.29.8": { + "integrity": "sha512-Z4WR8fmLMRpdYqJ9i7vrlXSsSrxVJydwrkEXHapexfARbWfGb7vGcnvNQnIzN0cXciMVOlz/XLoiMCi9gUsy9Q==", "dependencies": [ "preact" ] }, - "preact@10.29.8_preact-render-to-string@6.8.2": { + "preact@10.29.8_preact-render-to-string@6.7.0": { "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", "dependencies": [ "preact-render-to-string" @@ -1616,8 +1672,8 @@ "tslib@2.8.1": { "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, - "undici-types@8.9.0": { - "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==" + "undici-types@8.3.0": { + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==" }, "unified@11.0.5": { "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", @@ -1794,7 +1850,7 @@ "workspace": { "dependencies": [ "jsr:@db/sqlite@0.12", - "jsr:@dune/plugin-admin@*", + "jsr:@dune/plugin-admin@3", "jsr:@fresh/core@2", "jsr:@std/assert@1", "jsr:@std/crypto@1", From 8ad89d5e924d03bd1eeea151c0d9b5e12762690c Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 00:24:35 +0100 Subject: [PATCH 10/31] feat(plugins)!: core no longer loads the admin, it is a plugin you list (#123, step 4) bootstrap() stops importing @dune/plugin-admin itself. The admin runs only when site.yaml lists it under plugins:; dune new writes the entry and dune migrate:config adds it and removes admin.enabled. admin.enabled and admin.authz_store are removed, the authProvider bootstrap option and the admin version guard are gone, the authz system is created when a plugin provides auth.session or declares authzActions, plugin factories receive (config, { root, dev }), multisite builds its island snapshot from the sites' plugins, and dune doctor keys its admin check on admin.prefix. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 18 +++ src/cli/add-admin-plugin.ts | 83 ++++++++++ src/cli/doctor.ts | 19 +-- src/cli/lockfile-resolve-helper.ts | 9 -- src/cli/migrate-config.ts | 53 ++++++- src/cli/new.ts | 10 ++ src/config/admin-config.ts | 22 --- src/config/defaults.ts | 1 - src/config/legacy-keys.ts | 23 ++- src/hooks/types.ts | 2 +- src/multisite/manager.ts | 51 ++++--- src/plugin-api/create-services.ts | 2 +- src/plugins/builtin.ts | 144 ------------------ src/plugins/core-handshake.ts | 34 +++++ src/plugins/loader.ts | 21 ++- src/runtime/bootstrap.ts | 104 +++---------- src/runtime/register-plugin-routes.ts | 2 +- src/schema/config-schema.ts | 1 - tests/cli/add_admin_plugin_test.ts | 48 ++++++ tests/cli/doctor_capabilities_test.ts | 31 ++-- tests/cli/doctor_test.ts | 9 +- tests/cli/migrate_config_test.ts | 63 +++++++- tests/config/legacy_keys_test.ts | 15 +- tests/config/loader_test.ts | 19 +-- tests/core/plugin_admin_specifier_test.ts | 54 ------- tests/plugin-api/capabilities_test.ts | 2 - tests/plugins/admin_version_guard_test.ts | 69 --------- tests/plugins/capability_route_auth_test.ts | 4 +- tests/plugins/core_handshake_test.ts | 30 ++-- tests/plugins/loader_test.ts | 34 +++++ .../authz_for_session_provider_test.ts | 4 +- tests/runtime/bootstrap_admin_authz_test.ts | 43 +++++- tests/runtime/inline_edit_capability_test.ts | 2 +- tests/runtime/register_plugin_routes_test.ts | 4 +- tests/ssg/builder_test.ts | 3 - 35 files changed, 541 insertions(+), 492 deletions(-) create mode 100644 src/cli/add-admin-plugin.ts delete mode 100644 src/plugins/builtin.ts create mode 100644 src/plugins/core-handshake.ts create mode 100644 tests/cli/add_admin_plugin_test.ts delete mode 100644 tests/core/plugin_admin_specifier_test.ts delete mode 100644 tests/plugins/admin_version_guard_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 34efe1b4..8e428ced 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,24 @@ where the setting lives now (see `dune migrate:config`, which reports these too) `resolveRuntimeDir()` and `resolveMaxRevisions()` (`@dune/core/forms`) are the one place that resolves them. A plugin reads the directory from `services.paths.runtimeDir`. `@dune/plugin-admin` 4.0.0 does the same. +- **Core no longer loads the admin: it is a plugin you list (#123, step 4).** `bootstrap()` + used to import `jsr:@dune/plugin-admin@*` itself unless `admin.enabled` was false; now the + admin runs only when `config/site.yaml` lists it under `plugins:` + (`- src: jsr:@dune/plugin-admin@^4`, which `dune new` writes and `dune migrate:config` adds). + A site that lists nothing has no admin, no `auth.session`, no authz system (unless a plugin + declares `authzActions`), and says so once at boot (`bootstrap.no_admin`). Gone with it: + `admin.enabled` and `admin.authz_store` (both refused with a message; the authz store was + always local), the `authProvider` bootstrap option (build the admin plugin yourself and pass + it in `plugins`), `AdminAuthProvider`, `ADMIN_PLUGIN_SPECIFIER` / `ADMIN_MOUNT_SPECIFIER` / + `MIN_ADMIN_MAJOR` and the admin version guard, and the admin special case in + `lockfile-resolve-helper` (a listed admin is found like any plugin). The authz system is + created when some plugin provides `auth.session` or declares `authzActions`. A plugin factory + (a default export that is a function) now receives `(config, { root, dev })`. The two-core-copies + check now runs for every plugin that exports `resolvedCoreSentinel`. `dune doctor` calls a + plugin that provides `admin.prefix` without a working `auth.session` half installed. Multisite + builds one island snapshot from the island specifiers of every site's loaded plugins, instead + of importing the admin's. `dune migrate:config` deletes `admin.enabled` and adds the admin + entry (run it once: it adds the admin to a site that has none listed). - **Core no longer assumes the admin lives at `/admin` (#123, step 2).** Where the admin is mounted is the new `admin.prefix` capability slot (`{ prefix: "/admin" }`), provided by the admin plugin. Core reads it lazily for the things it must keep apart from content: diff --git a/src/cli/add-admin-plugin.ts b/src/cli/add-admin-plugin.ts new file mode 100644 index 00000000..3523866a --- /dev/null +++ b/src/cli/add-admin-plugin.ts @@ -0,0 +1,83 @@ +/** + * Adding the admin plugin to `config/site.yaml`'s `plugins:` list. + * + * Before 0.36 core loaded `@dune/plugin-admin` itself unless `admin.enabled` + * was false. It now loads only what `plugins:` lists, so `dune migrate:config` + * and `dune new` write the entry. Text-based, so comments and formatting + * survive. + * + * @module + */ + +import { parse as parseYaml } from "@std/yaml"; + +/** The specifier written for the admin: a major range, which the plugin pin rule accepts. */ +export const ADMIN_PLUGIN_ENTRY_SRC = "jsr:@dune/plugin-admin@^4"; + +export interface AddAdminResult { + text: string; + /** What happened: the entry was added, was already there, or could not be placed safely. */ + outcome: "added" | "present" | "manual"; +} + +const isAdminEntry = (e: unknown): boolean => { + const src = typeof e === "string" ? e : (e as { src?: unknown } | null)?.src; + return typeof src === "string" && /plugin-admin/.test(src); +}; + +/** + * Add `- src: jsr:@dune/plugin-admin@^4` to a site.yaml text. `manual` when the + * `plugins:` value is written in a way that cannot be extended safely (flow + * style with entries, an anchor, a non-list value). + */ +export function addAdminPluginToText(text: string): AddAdminResult { + let parsed: unknown; + try { + parsed = parseYaml(text); + } catch { + return { text, outcome: "manual" }; + } + const doc = (parsed && typeof parsed === "object" && !Array.isArray(parsed)) + ? parsed as Record + : {}; + const plugins = doc.plugins; + if (Array.isArray(plugins) && plugins.some(isAdminEntry)) return { text, outcome: "present" }; + if (plugins !== undefined && plugins !== null && !Array.isArray(plugins)) return { text, outcome: "manual" }; + + const entry = `src: ${ADMIN_PLUGIN_ENTRY_SRC}`; + const lines = text.split("\n"); + const key = lines.findIndex((l) => /^plugins\s*:/.test(l)); + + // No `plugins:` key (or an empty/null one): append a block. + if (key < 0) { + const body = text.endsWith("\n") || text === "" ? text : text + "\n"; + return { text: `${body}\nplugins:\n - ${entry}\n`, outcome: "added" }; + } + const rest = lines[key].replace(/^plugins\s*:/, "").replace(/#.*$/, "").trim(); + if (rest === "[]" || rest === "" && !Array.isArray(plugins)) { + lines[key] = `plugins:\n - ${entry}`; + return { text: lines.join("\n"), outcome: "added" }; + } + if (rest !== "") return { text, outcome: "manual" }; // flow style with entries, anchors, tags + + // Block list: insert after the last line that belongs to it. + let last = key; + let itemIndent: string | null = null; + for (let i = key + 1; i < lines.length; i++) { + const l = lines[i]; + if (/^\s*(#.*)?$/.test(l)) continue; // blank or comment: undecided + const m = l.match(/^(\s*)-(\s|$)/); + const indented = /^\s+\S/.test(l); + if (m && (itemIndent === null || m[1] === itemIndent)) { + itemIndent ??= m[1]; + last = i; + } else if (indented && itemIndent !== null) { + last = i; // a continuation line of an item (` config:` ...) + } else { + break; + } + } + if (itemIndent === null) return { text, outcome: "manual" }; + lines.splice(last + 1, 0, `${itemIndent}- ${entry}`); + return { text: lines.join("\n"), outcome: "added" }; +} diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts index 58d34a27..ce52323f 100644 --- a/src/cli/doctor.ts +++ b/src/cli/doctor.ts @@ -31,8 +31,6 @@ import { join } from "@std/path"; import { checkLockfileStaleness } from "./lockfile.ts"; import { DOCTOR_REPORT_ENV, type DoctorReport } from "../plugin-api/doctor-report.ts"; -import { createStorage } from "../storage/mod.ts"; -import { loadConfig } from "../config/mod.ts"; import { formatNpmCacheMismatchError, isNpmCacheMismatchError } from "./npm-cache-error.ts"; export interface DoctorFinding { @@ -152,7 +150,7 @@ export function searchDegradedFindings(healthStatus: unknown, bootOutput: string * - Scheduled actions with no `scheduler.executor` are a warning: they are * queued and nothing will ever run them. */ -export function capabilityFindings(report: DoctorReport, opts: { adminEnabled: boolean }): DoctorFinding[] { +export function capabilityFindings(report: DoctorReport): DoctorFinding[] { const findings: DoctorFinding[] = []; const slot = (name: string) => report.providers.find((p) => p.kind === "slot" && p.name === name); @@ -175,12 +173,14 @@ export function capabilityFindings(report: DoctorReport, opts: { adminEnabled: b } } - if (opts.adminEnabled && slot("auth.session")?.status !== "active") { + // A plugin that says it is the admin (it provides `admin.prefix`) but provides + // no working `auth.session` is half installed: its pages exist and nobody can sign in. + if (slot("admin.prefix") && slot("auth.session")?.status !== "active") { findings.push({ category: "capabilities", severity: "error", - message: `The admin is enabled but no plugin provides "auth.session", so nobody can sign in ` + - `and no editing bar is shown. Install the admin plugin, or set \`admin.enabled: false\`.`, + message: `${slot("admin.prefix")!.provider} provides "admin.prefix" (it is an admin) but no plugin provides ` + + `"auth.session", so nobody can sign in and no editing bar is shown.`, }); } @@ -294,12 +294,7 @@ async function runBootCheck(root: string): Promise { }); return findings; } - let adminEnabled = true; - try { - const config = await loadConfig({ storage: createStorage({ rootDir: root }), rootDir: root, skipConfigTs: true }); - adminEnabled = config.admin?.enabled !== false; - } catch { /* assume enabled: the safer reading */ } - return [...findings, ...capabilityFindings(report, { adminEnabled })]; + return [...findings, ...capabilityFindings(report)]; } /** Run doctor's checks and return the findings, without printing or exiting. */ diff --git a/src/cli/lockfile-resolve-helper.ts b/src/cli/lockfile-resolve-helper.ts index 3626d5fc..71f4eb41 100644 --- a/src/cli/lockfile-resolve-helper.ts +++ b/src/cli/lockfile-resolve-helper.ts @@ -24,7 +24,6 @@ import { createStorage } from "../storage/mod.ts"; import { loadConfig } from "../config/mod.ts"; import { createHookRegistry } from "../hooks/registry.ts"; import { loadPlugins } from "../plugins/loader.ts"; -import { ADMIN_MOUNT_SPECIFIER, ADMIN_PLUGIN_SPECIFIER } from "../plugins/builtin.ts"; const root = Deno.args[0] ?? "."; @@ -39,14 +38,6 @@ const pluginSpecifiers = config.pluginList .map((p) => p.src) .filter((src) => src.startsWith("jsr:") || src.startsWith("npm:") || src.startsWith("https:")); -// The built-in admin plugin is loaded by bootstrap via a variable-argument -// dynamic import that Deno's graph builder can't follow, so `deno cache -// /cli` never records it — it must be reported explicitly or a -// --frozen serve fails the moment bootstrap loads it (see plugins/builtin.ts). -if (config.admin?.enabled !== false) { - pluginSpecifiers.push(ADMIN_PLUGIN_SPECIFIER, ADMIN_MOUNT_SPECIFIER); -} - const clientEntrySpecifiers: string[] = []; for (const plugin of hooks.plugins()) { if (!plugin.clientEntries) continue; diff --git a/src/cli/migrate-config.ts b/src/cli/migrate-config.ts index 9b3158e9..20458763 100644 --- a/src/cli/migrate-config.ts +++ b/src/cli/migrate-config.ts @@ -11,6 +11,12 @@ * `smtp:` block of form notifications) are reported for a manual move and make * the command exit 1. * + * `admin.enabled` is removed (the admin now loads only when `plugins:` lists + * it): `enabled: false` is deleted and the site stays without an admin; + * otherwise the key is deleted and the admin plugin is added to the + * `plugins:` list of config/site.yaml. Run it once: a second run on a site that + * deliberately has no admin would add it. + * * Keys it cannot reach (flow style `{a: 1}`, quoted keys, mappings inside list * items) are reported and the file is left untouched so you can edit it by * hand. @@ -29,9 +35,11 @@ import { findIgnoredKeys, findLegacyKeys, findMovedKeys, + removeAdminEnabledInText, removeIgnoredKeysInText, renameLegacyKeysInText, } from "../config/legacy-keys.ts"; +import { ADMIN_PLUGIN_ENTRY_SRC, addAdminPluginToText } from "./add-admin-plugin.ts"; export interface MigrateConfigOptions { dryRun?: boolean; @@ -63,6 +71,9 @@ export async function migrateConfigCommand( let changed = 0; let unresolvedFiles = 0; let movedFiles = 0; + // `admin.enabled`: false means the site ran without the admin and keeps doing + // so; anything else (true, or the key absent) means it had one. + let adminWasDisabled = false; for (const { path, kind } of await candidateFiles(root)) { let text: string; try { @@ -71,8 +82,25 @@ export async function migrateConfigCommand( continue; // file does not exist } const rel = path.slice(root.length + 1); - const parsedBefore = parseYaml(text); - const moved = findMovedKeys(kind, parsedBefore); + let parsedBefore = parseYaml(text); + if (kind === "system") { + const admin = (parsedBefore as { admin?: { enabled?: unknown } } | null)?.admin; + if (admin && typeof admin === "object" && "enabled" in admin) { + if (admin.enabled === false && path === join(root, "config", "system.yaml")) adminWasDisabled = true; + const stripped = removeAdminEnabledInText(text); + if (stripped.removed) { + text = stripped.text; + parsedBefore = parseYaml(text); + changed++; + console.log(` ✓ ${rel}: removed admin.enabled`); + if (!opts.dryRun) await Deno.writeTextFile(path, text); + } else { + console.error(` ✗ ${rel}: could not remove admin.enabled (flow style or quoted key); delete it by hand.`); + unresolvedFiles++; + } + } + } + const moved = findMovedKeys(kind, parsedBefore).filter((k) => !(opts.dryRun && k.path === "admin.enabled")); if (moved.length > 0) { movedFiles++; console.error( @@ -113,6 +141,27 @@ export async function migrateConfigCommand( if (!opts.dryRun) await Deno.writeTextFile(path, result.text); } + const sitePath = join(root, "config", "site.yaml"); + let siteText: string | null = null; + try { + siteText = await Deno.readTextFile(sitePath); + } catch { /* no site.yaml */ } + if (siteText !== null && !adminWasDisabled) { + const added = addAdminPluginToText(siteText); + if (added.outcome === "added") { + changed++; + console.log(` ✓ config/site.yaml: added ${ADMIN_PLUGIN_ENTRY_SRC} to plugins: (core no longer loads the admin by itself)`); + if (!opts.dryRun) await Deno.writeTextFile(sitePath, added.text); + } else if (added.outcome === "manual") { + unresolvedFiles++; + console.error( + ` ✗ config/site.yaml: add the admin by hand under plugins:\n - src: ${ADMIN_PLUGIN_ENTRY_SRC}`, + ); + } + } else if (adminWasDisabled) { + console.log(" • admin.enabled was false: the admin plugin is not added. (Running this command again would add it.)"); + } + if (changed === 0 && unresolvedFiles === 0 && movedFiles === 0) { console.log(" Nothing to migrate — all config keys are already snake_case."); } else if (opts.dryRun && changed > 0) { diff --git a/src/cli/new.ts b/src/cli/new.ts index f3bead67..54199017 100644 --- a/src/cli/new.ts +++ b/src/cli/new.ts @@ -16,6 +16,11 @@ theme: taxonomies: - category - tag + +# Plugins. The admin panel is a plugin like any other: core loads only what is +# listed here. Remove this entry to run without an admin. +plugins: + - src: jsr:@dune/plugin-admin@^4 `; const SYSTEM_YAML = `content: @@ -342,6 +347,11 @@ author: taxonomies: - tag - category + +# Plugins. The admin panel is a plugin like any other: core loads only what is +# listed here. Remove this entry to run without an admin. +plugins: + - src: jsr:@dune/plugin-admin@^4 `; const HEADLESS_SYSTEM_YAML = `content: diff --git a/src/config/admin-config.ts b/src/config/admin-config.ts index 36ae4998..d226a01b 100644 --- a/src/config/admin-config.ts +++ b/src/config/admin-config.ts @@ -82,8 +82,6 @@ export interface AdminConfig { path: string; /** Session lifetime in seconds (default: 86400 = 24h) */ session_lifetime: number; - /** Whether admin panel is enabled (default: true) */ - enabled: boolean; /** * Maximum upload body size (in MB) for admin-side multipart uploads * (media library, content attachments). Rejected with 413 before the body @@ -110,26 +108,6 @@ export interface AdminConfig { * inject a custom provider through the admin plugin's `authProvider` option. */ auth_provider?: AuthProviderConfig; - /** - * Polizy authz tuple store for the admin panel's own permission checks - * (`pages.update`, `users.update`, etc.) — independent of - * `site.auth.authz_store`, which is a *public*-auth setting for site - * visitors. Default: `"local"`, created automatically whenever the admin - * panel is enabled, regardless of what (if anything) `site.auth` is - * configured to. Deliberately decoupled: an admin's tuples - * (`bootstrapAdminTuples()`) are a different identity concern from - * site-user tuples, and previously creation of this authz instance was - * accidentally gated on the public-auth config instead of its own - * setting — a site running `site.auth.mode: external-jwt` (or - * `authz_store: "db"`) would silently lose the admin-panel authz instance. - * That path now fails closed (deny) when `authz` is missing; there is no - * role-table fallback. - * - * Only `"local"` is implemented today — a `"db"` tier (mirroring - * `site.auth.authz_store`'s db-backed option) isn't wired up for the - * admin side yet. - */ - authz_store?: "local"; /** * Audit log configuration. * Records admin panel actions with actor, timestamp, IP, and outcome. diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 8e717f33..16fc8b52 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -49,7 +49,6 @@ export const DEFAULT_CONFIG: DuneConfig = { admin: { path: "/admin", session_lifetime: 86400, - enabled: true, max_upload_mb: 100, }, }; diff --git a/src/config/legacy-keys.ts b/src/config/legacy-keys.ts index 6671d829..95adc5a9 100644 --- a/src/config/legacy-keys.ts +++ b/src/config/legacy-keys.ts @@ -26,7 +26,6 @@ const RULES: Rule[] = [ renames: { sessionLifetime: "session_lifetime", maxUploadMb: "max_upload_mb", - authzStore: "authz_store", }, }, { file: "system", parent: ["admin", "audit"], renames: { logFile: "log_file" } }, @@ -76,6 +75,9 @@ const MOVED: MovedRule[] = [ incomingWebhooks: "webhooks.incoming in system.yaml", data_dir: "data_dir at the top level of system.yaml", dataDir: "data_dir at the top level of system.yaml", + enabled: "removed: the admin runs only when jsr:@dune/plugin-admin is listed under plugins: in site.yaml (delete this key to run without it)", + authz_store: "removed: the admin's authz store is always local", + authzStore: "removed: the admin's authz store is always local", runtime_dir: "runtime_dir at the top level of system.yaml", runtimeDir: "runtime_dir at the top level of system.yaml", max_revisions: "max_revisions at the top level of system.yaml", @@ -150,6 +152,23 @@ export function findIgnoredKeys(file: ConfigFileKind, parsed: unknown): MovedKey export function removeIgnoredKeysInText( file: ConfigFileKind, text: string, +): { text: string; removed: MovedKey[] } { + return removeKeysInText(IGNORED, file, text); +} + +/** `admin.enabled` in system.yaml: removed in 0.36 (the admin runs only when its plugin is listed). */ +const ADMIN_ENABLED: MovedRule[] = [{ file: "system", parent: ["admin"], keys: { enabled: "" } }]; + +/** Delete the `admin.enabled` line from a system.yaml text (comments and the rest are kept). */ +export function removeAdminEnabledInText(text: string): { text: string; removed: boolean } { + const r = removeKeysInText(ADMIN_ENABLED, "system", text); + return { text: r.text, removed: r.removed.length > 0 }; +} + +function removeKeysInText( + rules: MovedRule[], + file: ConfigFileKind, + text: string, ): { text: string; removed: MovedKey[] } { const removed: MovedKey[] = []; const stack: { indent: number; key: string }[] = []; @@ -175,7 +194,7 @@ export function removeIgnoredKeysInText( const indent = m[1].length; while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop(); const path = stack.map((x) => x.key); - const hit = IGNORED.find((r) => r.file === file && matches(r.parent, path) && m[2] in r.keys); + const hit = rules.find((r) => r.file === file && matches(r.parent, path) && m[2] in r.keys); if (hit) { removed.push({ path: [...path, m[2]].join("."), to: hit.keys[m[2]] }); skipIndent = indent; diff --git a/src/hooks/types.ts b/src/hooks/types.ts index 4411d61e..c408bae9 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -215,7 +215,7 @@ export interface PluginApi { * them onto `BootstrapResult.pluginPublicRoutes`, and `createDuneApp()` wires * them as programmatic Fresh routes before the content catch-all handler — * this happens in `@dune/core` itself (`registerPluginPublicRoutes()`), so it - * works in every `createDuneApp()` context (headless, `admin.enabled: false`, + * works in every `createDuneApp()` context (headless, no admin plugin listed, * etc.), not only when an admin package's `mount()` happens to run. Unlike * `onRequest`, these are proper Fresh routes: core passes a real Fresh context, * so a handler whose `ctx` is annotated `FreshContext` can call `ctx.render()` diff --git a/src/multisite/manager.ts b/src/multisite/manager.ts index a299333f..a179df80 100644 --- a/src/multisite/manager.ts +++ b/src/multisite/manager.ts @@ -3,7 +3,7 @@ import { Builder } from "jsr:@fresh/core@^2/dev"; import { bootstrap } from "../runtime/bootstrap.ts"; import { createDuneApp } from "../runtime/server.ts"; import { materializeRemoteIslands } from "../cli/remote-islands.ts"; -import { ADMIN_MOUNT_SPECIFIER } from "../plugins/builtin.ts"; +import { isValidPluginIslandSpecifier } from "../plugins/loader.ts"; import type { SiteEntry } from "../config/types.ts"; import type { InitializedSite } from "./types.ts"; import { logger } from "../core/logger.ts"; @@ -67,26 +67,8 @@ export class MultisiteManager { Deno.chdir(duneRoot); } - const adminBuilder = new Builder({ - root: firstSiteRoot, - islandDir: noCrawlDir, - routeDir: noCrawlDir, - }); - // Admin island specifiers from @dune/plugin-admin — loaded via non-literal - // dynamic import to avoid a publish-time circular dependency with @dune/core. - // The specifier constant lives in plugins/builtin.ts where the lockfile - // discovery helper also reports it — see that module's doc. - const adminMountPkg: string = ADMIN_MOUNT_SPECIFIER; - const { getDuneAdminIslands } = await import(adminMountPkg) as { // lockfile-safe: constant from plugins/builtin.ts, reported by lockfile discovery - getDuneAdminIslands: () => string[]; - }; - // Remote (https://) island specs are materialized as local wrappers — - // Fresh's build cache only accepts file paths. - for (const spec of await materializeRemoteIslands(getDuneAdminIslands(), firstSiteRoot)) { - adminBuilder.registerIsland(spec); - } - const applyAdminSnapshot = await adminBuilder.build({ mode: "production", snapshot: "memory" }); + const booted: { entry: SiteEntry; siteRoot: string; ctx: Awaited> }[] = []; for (const entry of cfg.sites) { // Resolve site root relative to the directory containing sites.yaml const siteRoot = isAbsolute(entry.root) @@ -113,6 +95,35 @@ export class MultisiteManager { siteId: entry.id, }); + booted.push({ entry, siteRoot, ctx }); + } + + // Island bundles are package-level, not site-specific, so one snapshot is + // shared by every site: the union of the islands the sites' plugins declare + // (the admin among them, when a site lists it). Nothing is assumed about + // which plugins exist. + const islandSpecs = new Set(); + for (const { ctx } of booted) { + for (const plugin of ctx.hooks.plugins()) { + for (const spec of plugin.islandSpecifiers ?? []) { + if (isValidPluginIslandSpecifier(spec)) islandSpecs.add(spec); + else logger.warn("plugin.islandSpecifier.rejected", { spec }); + } + } + } + const adminBuilder = new Builder({ + root: firstSiteRoot, + islandDir: noCrawlDir, + routeDir: noCrawlDir, + }); + // Remote (https://) island specs are materialized as local wrappers — + // Fresh's build cache only accepts file paths. + for (const spec of await materializeRemoteIslands([...islandSpecs], firstSiteRoot)) { + adminBuilder.registerIsland(spec); + } + const applyAdminSnapshot = await adminBuilder.build({ mode: "production", snapshot: "memory" }); + + for (const { entry, siteRoot, ctx } of booted) { // Create a per-site Fresh app (admin + content in one handler). // Apply the shared admin island snapshot so /_fresh/js/* island bundles // are served correctly without rebuilding islands per site. diff --git a/src/plugin-api/create-services.ts b/src/plugin-api/create-services.ts index 99fd1fdd..dd8fc1eb 100644 --- a/src/plugin-api/create-services.ts +++ b/src/plugin-api/create-services.ts @@ -105,7 +105,7 @@ export function createPluginServices(opts: PluginServicesOptions): PluginService function createAuditTrail(opts: PluginServicesOptions, runtimeDir: string): AuditLogger | null { const audit = opts.config.admin?.audit; - if (opts.config.admin?.enabled === false || audit?.enabled === false || !opts.root) return null; + if (audit?.enabled === false || !opts.root) return null; const root = opts.root; 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/src/plugins/builtin.ts b/src/plugins/builtin.ts deleted file mode 100644 index 1397257f..00000000 --- a/src/plugins/builtin.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * Registry specifiers for the built-in admin plugin. - * - * These are imported at runtime via *variable-argument* dynamic `import()` - * (bootstrap.ts, multisite/manager.ts) to break the circular publish-time - * dependency between @dune/core and @dune/plugin-admin — which means Deno's - * module-graph builder cannot follow them statically, so `deno cache - * /cli` never records them and `dune lockfile:sync` would produce a - * lockfile that a `--frozen` serve rejects the moment bootstrap loads the - * admin plugin. The lockfile discovery helper therefore reports these - * specifiers explicitly for admin-enabled sites. - * - * Keep every variable-imported external specifier defined HERE and consumed - * from here by both the runtime import site and lockfile-resolve-helper.ts — - * an inline string at the import site silently reopens the lockfile gap. - */ - -import { CORE_INSTANCE, CORE_VERSION } from "./mod.ts"; -import { DEFAULT_CONFIG } from "../config/defaults.ts"; -import type { DuneConfig } from "../config/types.ts"; - -/** - * The built-in admin plugin package (bootstrap.ts's dynamic import). - * - * The range is deliberately `*`: `jsr:` specifiers cannot express "3.x or 4.x", - * and 0.36 has to keep resolving while `@dune/plugin-admin` 4.0.0 (which reads - * the snake_case config keys) is not yet published, then pick it up the moment - * it is. {@link checkAdminVersion} is the real guard: bootstrap refuses an admin - * below {@link MIN_ADMIN_MAJOR} when the site sets a renamed admin key, so a - * stale lockfile or cache can never run the old camelCase reader against a - * snake_case `DuneConfig` and drop settings unnoticed. Tighten to `^4` in - * the release after admin 4.0.0 ships. - */ -export const ADMIN_PLUGIN_SPECIFIER = "jsr:@dune/plugin-admin@*"; - -/** Oldest `@dune/plugin-admin` major that reads the 0.36 snake_case config keys. */ -export const MIN_ADMIN_MAJOR = 4; - -/** - * Admin-read settings that 0.36 renamed to snake_case and that the site has - * moved off their defaults. Empty when the site configures none of them. - * - * An admin plugin older than {@link MIN_ADMIN_MAJOR} still reads the camelCase - * properties, which are now `undefined`, so it falls back to its own defaults. - * That is harmless for a site on all defaults but silently drops any value the - * site set (`session_lifetime`, `max_upload_mb`, ...). - */ -export function adminSettingsAtRisk(config: DuneConfig): string[] { - const out: string[] = []; - const admin = (config.admin ?? {}) as unknown as Record; - const defaults = DEFAULT_CONFIG.admin as unknown as Record; - for ( - const key of [ - "session_lifetime", - "max_upload_mb", - "authz_store", - ] - ) { - if ( - admin[key] !== undefined && - JSON.stringify(admin[key]) !== JSON.stringify(defaults[key]) - ) { - out.push(`admin.${key}`); - } - } - const audit = admin.audit as Record | undefined; - if (audit?.log_file !== undefined) out.push("admin.audit.log_file"); - if (admin.auth_provider !== undefined) out.push("admin.auth_provider"); - const tsx = config.system?.content?.allow_tsx_format; - if ( - tsx !== undefined && - JSON.stringify(tsx) !== - JSON.stringify(DEFAULT_CONFIG.system.content?.allow_tsx_format) - ) { - out.push("content.allow_tsx_format"); - } - return out; -} - -/** - * Outcome of checking the resolved admin plugin against the snake_case config: - * `ok` (new enough, or version unreadable — never treated as old), `warn` (old, - * but the site sets nothing it would drop), or `refuse` (old and it would - * silently ignore the listed settings). `message` is set unless `ok`. - */ -export function checkAdminVersion( - version: unknown, - config: DuneConfig, -): { level: "ok" | "warn" | "refuse"; message?: string } { - if (typeof version !== "string") return { level: "ok" }; - const major = Number.parseInt(version.split(".")[0], 10); - if (!Number.isFinite(major) || major >= MIN_ADMIN_MAJOR) { - return { level: "ok" }; - } - const atRisk = adminSettingsAtRisk(config); - const base = - `[dune] @dune/plugin-admin@${version} predates the snake_case config keys ` + - `(@dune/core ${CORE_VERSION} wants @dune/plugin-admin ${MIN_ADMIN_MAJOR}.x or newer)`; - if (atRisk.length === 0) { - return { - level: "warn", - message: - `${base}; no admin setting is customised, so it runs on defaults.`, - }; - } - return { - level: "refuse", - message: `${base}, and would silently ignore: ${atRisk.join(", ")}. ` + - `Update the plugin (\`dune lockfile:sync\` refreshes a stale deno.lock) or remove those settings.`, - }; -} - -/** The admin mount subpath used by the multisite manager. */ -export const ADMIN_MOUNT_SPECIFIER = "jsr:@dune/plugin-admin@*/admin/mount"; - -/** - * Core-instance handshake for the dynamically imported admin plugin. - * - * `@dune/plugin-admin` (≥1.1.3) re-exports the `CORE_INSTANCE` sentinel and - * `CORE_VERSION` it resolved from its own `@dune/core` dependency. If that - * sentinel is not reference-equal to ours, the resolver loaded a second copy - * of core into this process — module-level singletons are split and the admin - * surface runs different core code than the site. Should never happen with - * the plugin's floating `@0` range; this exists to catch the day that silently - * stops being true (a range regression, a yanked version forcing a split, or - * a local-path core alongside a JSR-resolved one — the last is why identity - * is compared, not version strings, which can match across two copies). - * - * Returns a warning message, or null when unified or when the plugin predates - * the handshake (no `resolvedCoreSentinel` export). - */ -export function adminCoreMismatch(adminModule: Record): string | null { - if (!("resolvedCoreSentinel" in adminModule)) return null; - if (adminModule.resolvedCoreSentinel === CORE_INSTANCE) return null; - const theirs = typeof adminModule.resolvedCoreVersion === "string" - ? adminModule.resolvedCoreVersion - : "pre-0.31 (or unknown)"; - const hint = theirs === CORE_VERSION - ? "Same version loaded twice — the site likely runs @dune/core from a local path while the plugin resolved it from JSR." - : "Check the plugin's @dune/core version range — it must include the site's core version so Deno can unify them."; - return `[dune] @dune/plugin-admin resolved its own copy of @dune/core@${theirs} ` + - `instead of sharing this process's @dune/core@${CORE_VERSION} — two core instances ` + - `are now loaded, and admin routes will not see the site's registries or singletons. ${hint}`; -} diff --git a/src/plugins/core-handshake.ts b/src/plugins/core-handshake.ts new file mode 100644 index 00000000..74bd2c17 --- /dev/null +++ b/src/plugins/core-handshake.ts @@ -0,0 +1,34 @@ +/** + * Core-instance handshake for dynamically imported plugins. + * + * A plugin that depends on `@dune/core` may re-export the `CORE_INSTANCE` + * sentinel and `CORE_VERSION` it resolved (as `resolvedCoreSentinel` and + * `resolvedCoreVersion`; `@dune/plugin-admin` does). If that sentinel is not + * reference-equal to ours, the resolver loaded a second copy of core into this + * process: module-level singletons are split and the plugin runs different core + * code than the site. Identity is compared, not version strings, which can + * match across two copies (a local-path core alongside a JSR-resolved one). + * + * @module + */ + +import { CORE_INSTANCE, CORE_VERSION } from "./mod.ts"; + +/** + * A warning message when `pluginModule` resolved a different core instance, or + * null when unified or when the plugin predates the handshake (it exports no + * `resolvedCoreSentinel`). + */ +export function pluginCoreMismatch(pluginModule: Record, pluginName: string): string | null { + if (!("resolvedCoreSentinel" in pluginModule)) return null; + if (pluginModule.resolvedCoreSentinel === CORE_INSTANCE) return null; + const theirs = typeof pluginModule.resolvedCoreVersion === "string" + ? pluginModule.resolvedCoreVersion + : "pre-0.31 (or unknown)"; + const hint = theirs === CORE_VERSION + ? "Same version loaded twice: the site likely runs @dune/core from a local path while the plugin resolved it from JSR." + : "Check the plugin's @dune/core version range: it must include the site's core version so Deno can unify them."; + return `[dune] ${pluginName} resolved its own copy of @dune/core@${theirs} ` + + `instead of sharing this process's @dune/core@${CORE_VERSION}: two core instances ` + + `are now loaded, and the plugin will not see the site's registries or singletons. ${hint}`; +} diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 6f940ab2..cc4cbe58 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -41,6 +41,7 @@ import { logger } from "../core/logger.ts"; import { writeDoctorReport } from "../plugin-api/doctor-report.ts"; import { createPluginRuntime } from "../plugin-api/runtime.ts"; import { resolveRuntimeDir } from "../forms/config.ts"; +import { pluginCoreMismatch } from "./core-handshake.ts"; /** Options for the plugin loader. */ export interface PluginLoaderOptions { @@ -49,6 +50,20 @@ export interface PluginLoaderOptions { storage: StorageAdapter; /** Absolute path to the site root directory */ root: string; + /** True under `dune dev`. Passed to plugin factories (default false). */ + dev?: boolean; +} + +/** + * What a plugin factory (a default export that is a function) is given besides + * its merged config: facts that exist before plugins load and that a plugin + * cannot get from `PluginApi`. + */ +export interface PluginFactoryContext { + /** Absolute path to the site root directory. */ + root: string; + /** True under `dune dev`. */ + dev: boolean; } /** @@ -102,7 +117,7 @@ async function discoverLocalPlugins( * 6. Call hooks.registerPlugin(plugin) */ export async function loadPlugins(options: PluginLoaderOptions): Promise { - const { config, hooks, root, storage } = options; + const { config, hooks, root, storage, dev = false } = options; const configured = config.pluginList ?? []; // Auto-discovery is opt-in — loading an arbitrary `.ts` file out of @@ -128,6 +143,8 @@ export async function loadPlugins(options: PluginLoaderOptions): Promise { ? await materializeHttpsPlugin(entry.src, entry.integrity!, root) : resolvePluginUrl(entry.src, root); const mod = await import(importUrl); // lockfile-safe: discovery (plugin specifier from site config, handled by plugin discovery subprocess) + const mismatch = pluginCoreMismatch(mod, entry.src); + if (mismatch) logger.warn("plugins.loader.core_instance_mismatch", { detail: mismatch }); const exported = mod.default; if (!exported) { @@ -143,7 +160,7 @@ export async function loadPlugins(options: PluginLoaderOptions): Promise { ...(config.plugins[exported.pluginName ?? ""] ?? {}), ...(entry.config ?? {}), }; - plugin = await exported(pluginCfg); + plugin = await exported(pluginCfg, { root, dev } satisfies PluginFactoryContext); } else if (typeof exported === "object") { plugin = exported as DunePlugin; } else { diff --git a/src/runtime/bootstrap.ts b/src/runtime/bootstrap.ts index c45e6c42..036c0009 100644 --- a/src/runtime/bootstrap.ts +++ b/src/runtime/bootstrap.ts @@ -12,7 +12,6 @@ import { resolveDataDir, resolveMaxRevisions, resolveRuntimeDir } from "../forms import { startupStep } from "./startup-step.ts"; import { createStorageAsync } from "../storage/mod.ts"; import { loadConfig } from "../config/mod.ts"; -import { ADMIN_PLUGIN_SPECIFIER, adminCoreMismatch, checkAdminVersion } from "../plugins/builtin.ts"; import { FormatRegistry } from "../content/formats/registry.ts"; import { MarkdownHandler } from "../content/formats/markdown.ts"; import { TsxHandler } from "../content/formats/tsx.ts"; @@ -70,19 +69,6 @@ import { createSubmissionManager } from "../forms/submissions.ts"; import { resolveFormsConfig } from "../forms/config.ts"; import type { StorageAdapter } from "../storage/types.ts"; -/** - * Minimal auth-provider interface for the admin panel. - * The concrete `AuthProvider` type lives in `@dune/plugin-admin/admin/auth/provider`; - * this stub lets core accept it without creating a hard publish-time dependency. - * @since 0.24.0 - */ -export interface AdminAuthProvider { - readonly type: string; - authenticate(credentials: { username: string; password: string; [k: string]: unknown }): Promise<{ id: string; [k: string]: unknown } | null>; - initiateLogin?(req: Request): Promise; - handleCallback?(req: Request): Promise<{ id: string; [k: string]: unknown } | null>; -} - /** All services produced by a successful `bootstrap()` call. */ export interface BootstrapResult { engine: DuneEngine; @@ -203,22 +189,6 @@ export interface BootstrapOptions { * Also honoured when DUNE_ENV=dev is set in the environment. */ dev?: boolean; - /** - * Custom authentication provider for the admin panel. - * - * When supplied, takes precedence over `admin.auth_provider` in system.yaml. - * Forwarded to the built-in admin plugin (`@dune/plugin-admin`). When running - * a custom admin plugin that handles its own auth, this option is ignored. - * - * @example - * ```ts - * import { bootstrap } from "@dune/core"; - * import { MyOidcProvider } from "./auth/oidc-provider.ts"; - * - * const ctx = await bootstrap("./", { authProvider: new MyOidcProvider() }); - * ``` - */ - authProvider?: AdminAuthProvider; /** * Pre-created storage adapter to use instead of the default filesystem/KV * adapter derived from `root`. When supplied, the `root` parameter is used @@ -396,7 +366,7 @@ export async function bootstrap( // import and register each plugin so their hooks are in place before // the lifecycle events fire. await loadPluginAdminConfigs(config, storage, resolveDataDir(config)); - await startupStep("Failed to load plugins", () => loadPlugins({ config, hooks, storage, root })); + await startupStep("Failed to load plugins", () => loadPlugins({ config, hooks, storage, root, dev })); // Collect plugin asset dirs, template dirs, and public routes. // pluginAdminPages are no longer aggregated here — @dune/plugin-admin's @@ -536,11 +506,6 @@ export async function bootstrap( // 10. History engine — stays in core so CapabilityContext and @dune/plugin-inline-edit // can both access it without depending on the admin plugin. - const adminConfig = config.admin ?? { - path: "/admin", - session_lifetime: 86400, - enabled: true, - }; const runtimeDir = resolveRuntimeDir(config); const dataDir = resolveDataDir(config); @@ -551,18 +516,8 @@ export async function bootstrap( }); // 11. Authorization (Polizy) — created in core so that admin-user tuples - // (@dune/plugin-admin) and site-user tuples (mountDuneAuth) share one index. - // - // Admin-side authz creation reads `admin.authzStore` — its own setting, - // independent of `site.auth`'s (public-auth) mode/authzStore. It used to - // read the public-auth config for this too, which meant a site running - // `site.auth.mode: "external-jwt"` (or `site.auth.authz_store: "db"`) - // silently lost real admin-panel permission checking as well, even though - // Admin User tuples (bootstrapAdminTuples(), @dune/plugin-admin) have nothing to do with how - // public site users authenticate. Default is "local", created whenever - // the admin panel itself is enabled — see AdminConfig.authzStore's doc - // comment (src/config/admin-config.ts) for the full reasoning. - const adminAuthzStoreCfg = adminConfig.authz_store ?? "local"; + // (the admin plugin) and site-user tuples (mountDuneAuth) share one index. + // Admin-side authz is independent of `site.auth`'s (public-auth) mode. let bootstrappedAuthz: DuneAuthSystem | undefined; let bootstrappedAuthzAdapter: AuthzLocalAdapter | AuthzDbAdapter | undefined; @@ -573,13 +528,16 @@ export async function bootstrap( return null; }); - // The authz system exists for the built-in admin (when enabled) and for any - // other admin: a plugin that provides the `auth.session` capability has - // declared itself the admin (plugins register, and so declare, before this - // point). Without it a replacement admin could never have its sessions - // authorised (`pages.update` fails closed when authz is missing). + // The authz system exists when something needs it: a plugin that provides + // `auth.session` has declared itself the admin (without authz its sessions + // could never be authorised: `pages.update` fails closed when authz is + // missing), or a plugin declares `authzActions` of its own. Plugins register, + // and so declare, before this point; settle async setup() first so a + // declaration made after an await is seen too. + await hooks.whenSetupComplete(); const hasSessionProvider = capabilityRegistry.reader.providers().some((p) => p.name === "auth.session"); - if ((adminConfig.enabled !== false || hasSessionProvider) && adminAuthzStoreCfg === "local") { + const hasAuthzActions = Object.keys(pluginAuthzActions).length > 0; + if (hasSessionProvider || hasAuthzActions) { try { const bundle = createDuneAuthSystem({ authzStore: "local", @@ -636,36 +594,14 @@ export async function bootstrap( } } - // 12. Register the built-in admin plugin before user plugins so user plugins - // can override its services through the capability registry. The plugin's setup() runs - // immediately (via hooks.registerPlugin); mount() runs later in mountPlugins(). - // Non-literal dynamic import breaks the circular publish-time dependency between - // @dune/core and @dune/plugin-admin (each would otherwise need the other to be - // on JSR before it could be published). Deno can't follow the variable import - // statically, so the specifier constant lives in plugins/builtin.ts where the - // lockfile discovery helper also reports it — see that module's doc. - if (adminConfig.enabled !== false) { - const adminPkg: string = ADMIN_PLUGIN_SPECIFIER; - const adminModule = await import(adminPkg) as Record & { // lockfile-safe: constant from plugins/builtin.ts, reported by lockfile discovery - createAdminPlugin: (config: DuneConfig, storage: StorageAdapter, opts: Record) => DunePlugin; - }; - const mismatch = adminCoreMismatch(adminModule); - if (mismatch) logger.warn("bootstrap.admin_plugin.version_mismatch", { detail: mismatch }); - const { createAdminPlugin } = adminModule; - const adminPlugin = createAdminPlugin(config, storage, { - root, - dev, - authProvider: options.authProvider, - authz: bootstrappedAuthz, - authzAdapter: bootstrappedAuthzAdapter, - hmacKey, - } as Record); - const versionCheck = checkAdminVersion(adminPlugin.version, config); - if (versionCheck.level === "refuse") throw new Error(versionCheck.message); - if (versionCheck.level === "warn") { - logger.warn("bootstrap.admin_plugin.pre_snake_case", { detail: versionCheck.message }); - } - hooks.registerPlugin(adminPlugin); + // 12. There is no built-in admin: the admin is a plugin like any other and + // loads only when site.yaml lists it under `plugins:`. Say so once when + // nothing provides the admin's prefix, so a site that upgraded without + // listing it can tell why the panel is gone. + if (!capabilityRegistry.reader.providers().some((p) => p.name === "admin.prefix")) { + logger.info("bootstrap.no_admin", { + hint: "No plugin provides the admin panel. List jsr:@dune/plugin-admin under plugins: in config/site.yaml to run it (`dune migrate:config` adds it).", + }); } // 13. Metrics collector diff --git a/src/runtime/register-plugin-routes.ts b/src/runtime/register-plugin-routes.ts index c3736f7a..3773070e 100644 --- a/src/runtime/register-plugin-routes.ts +++ b/src/runtime/register-plugin-routes.ts @@ -3,7 +3,7 @@ * * Moved here from `@dune/plugin-admin`'s `mountDuneAdmin()` so `publicRoutes` * actually works in every `createDuneApp()` context, not only when the admin - * package's `mount()` happens to run — headless sites, `admin.enabled: false`, + * package's `mount()` happens to run — headless sites, sites with no admin plugin listed, * and `bootstrap()`-only tools all get it now. `bootstrap.ts` already collects * `BootstrapResult.pluginPublicRoutes`; this is the piece that was missing — * turning that collected list into actual `app.get()`/`app.post()`/etc. calls. diff --git a/src/schema/config-schema.ts b/src/schema/config-schema.ts index 2fa1b7df..01c56b65 100644 --- a/src/schema/config-schema.ts +++ b/src/schema/config-schema.ts @@ -482,7 +482,6 @@ export const CONFIG_SCHEMA = { type: "object", description: "Admin panel settings", properties: { - enabled: { type: "boolean", default: true }, path: { type: "string", default: "/admin", diff --git a/tests/cli/add_admin_plugin_test.ts b/tests/cli/add_admin_plugin_test.ts new file mode 100644 index 00000000..fcafec29 --- /dev/null +++ b/tests/cli/add_admin_plugin_test.ts @@ -0,0 +1,48 @@ +import { assertEquals } from "@std/assert"; +import { parse as parseYaml } from "@std/yaml"; +import { ADMIN_PLUGIN_ENTRY_SRC, addAdminPluginToText } from "../../src/cli/add-admin-plugin.ts"; + +const SRC = ADMIN_PLUGIN_ENTRY_SRC; + +Deno.test("addAdminPluginToText: no plugins key appends a block, keeping comments", () => { + const r = addAdminPluginToText("# site\ntitle: T # name\n"); + assertEquals(r.outcome, "added"); + assertEquals(r.text, `# site\ntitle: T # name\n\nplugins:\n - src: ${SRC}\n`); +}); + +Deno.test("addAdminPluginToText: an empty plugins list becomes a block", () => { + for (const empty of ["plugins: []", "plugins:", "plugins: # none"]) { + const r = addAdminPluginToText(`title: T\n${empty}\ntheme:\n name: x\n`); + assertEquals(r.outcome, "added", empty); + assertEquals((parseYaml(r.text) as { plugins: { src: string }[] }).plugins, [{ src: SRC }], empty); + } +}); + +Deno.test("addAdminPluginToText: appends after the last item of a block list, including its config", () => { + const r = addAdminPluginToText("plugins:\n - src: jsr:@dune/plugin-seo@^1\n config:\n x: 1\n\n # next\n - src: ./plugins/a.ts\ntheme:\n name: x\n"); + assertEquals(r.outcome, "added"); + const doc = parseYaml(r.text) as { plugins: { src: string }[]; theme: { name: string } }; + assertEquals(doc.plugins.map((p) => p.src), ["jsr:@dune/plugin-seo@^1", "./plugins/a.ts", SRC]); + assertEquals(doc.theme.name, "x"); +}); + +Deno.test("addAdminPluginToText: an unindented block list works too", () => { + const r = addAdminPluginToText("plugins:\n- src: ./a.ts\ntitle: T\n"); + assertEquals(r.outcome, "added"); + assertEquals((parseYaml(r.text) as { plugins: { src: string }[] }).plugins.map((p) => p.src), ["./a.ts", SRC]); +}); + +Deno.test("addAdminPluginToText: an admin entry already there is left alone", () => { + for (const text of [`plugins:\n - src: ${SRC}\n`, "plugins:\n - src: jsr:@dune/plugin-admin@4.0.1\n"]) { + const r = addAdminPluginToText(text); + assertEquals(r.outcome, "present"); + assertEquals(r.text, text); + } +}); + +Deno.test("addAdminPluginToText: flow style with entries cannot be extended safely", () => { + const text = "plugins: [{ src: ./a.ts }]\n"; + const r = addAdminPluginToText(text); + assertEquals(r.outcome, "manual"); + assertEquals(r.text, text); +}); diff --git a/tests/cli/doctor_capabilities_test.ts b/tests/cli/doctor_capabilities_test.ts index 3d408a8f..daf3a72b 100644 --- a/tests/cli/doctor_capabilities_test.ts +++ b/tests/cli/doctor_capabilities_test.ts @@ -15,39 +15,43 @@ const healthy: DoctorReport = { }; Deno.test("doctor: a healthy admin site lists its providers and has no problems", () => { - const findings = capabilityFindings(healthy, { adminEnabled: true }); + const findings = capabilityFindings(healthy); assertEquals(findings.map((f) => f.severity), ["info"]); assertStringIncludes(findings[0].message, "auth.session"); assertStringIncludes(findings[0].message, "plugin-admin"); assertStringIncludes(findings[0].message, "plugin-seo"); }); -Deno.test("doctor: an enabled admin that provides no auth.session is an error", () => { - const findings = capabilityFindings({ providers: [], scheduledActions: 0 }, { adminEnabled: true }); - assertEquals(findings.map((f) => f.severity), ["error"]); - assertStringIncludes(findings[0].message, "auth.session"); - assertStringIncludes(findings[0].message, "admin.enabled: false"); +Deno.test("doctor: a plugin that provides admin.prefix but no auth.session is half an admin, an error", () => { + const findings = capabilityFindings({ providers: [slot("admin.prefix", "half-admin")], scheduledActions: 0 }); + const errors = findings.filter((f) => f.severity === "error"); + assertEquals(errors.length, 1); + assertStringIncludes(errors[0].message, "half-admin"); + assertStringIncludes(errors[0].message, "auth.session"); }); -Deno.test("doctor: no auth.session is fine when the admin is switched off", () => { - assertEquals(capabilityFindings({ providers: [], scheduledActions: 0 }, { adminEnabled: false }), []); +Deno.test("doctor: a site with no admin at all has nothing to report", () => { + assertEquals(capabilityFindings({ providers: [], scheduledActions: 0 }), []); +}); + +Deno.test("doctor: an auth.session provider without admin.prefix is not called an admin", () => { + const findings = capabilityFindings({ providers: [slot("auth.session", "sso-only")], scheduledActions: 0 }); + assertEquals(findings.filter((f) => f.severity === "error"), []); }); Deno.test("doctor: a provider whose factory threw is an error even when another slot is fine", () => { const findings = capabilityFindings( { providers: [slot("auth.session", "my-admin", "failed")], scheduledActions: 0 }, - { adminEnabled: false }, ); const errors = findings.filter((f) => f.severity === "error"); - assertEquals(errors.length, 1); + assertEquals(errors.length, 1, "a failed provider alone is one error: nothing here claims to be an admin"); assertStringIncludes(errors[0].message, "my-admin"); assertStringIncludes(errors[0].message, "capabilities.provider_failed"); }); Deno.test("doctor: an admin whose auth.session failed is reported as the failure and as missing", () => { const findings = capabilityFindings( - { providers: [slot("auth.session", "my-admin", "failed")], scheduledActions: 0 }, - { adminEnabled: true }, + { providers: [slot("admin.prefix", "my-admin"), slot("auth.session", "my-admin", "failed")], scheduledActions: 0 }, ); assertEquals(findings.filter((f) => f.severity === "error").length, 2); }); @@ -55,7 +59,6 @@ Deno.test("doctor: an admin whose auth.session failed is reported as the failure Deno.test("doctor: queued scheduled actions with no executor are a warning", () => { const findings = capabilityFindings( { providers: [slot("auth.session", "plugin-admin")], scheduledActions: 3 }, - { adminEnabled: true }, ); const warnings = findings.filter((f) => f.severity === "warning"); assertEquals(warnings.length, 1); @@ -64,7 +67,7 @@ Deno.test("doctor: queued scheduled actions with no executor are a warning", () Deno.test("doctor: scheduled actions with an executor are fine", () => { assertEquals( - capabilityFindings({ ...healthy, scheduledActions: 3 }, { adminEnabled: true }).filter((f) => f.severity !== "info"), + capabilityFindings({ ...healthy, scheduledActions: 3 }).filter((f) => f.severity !== "info"), [], ); }); diff --git a/tests/cli/doctor_test.ts b/tests/cli/doctor_test.ts index 8a835efb..204152fe 100644 --- a/tests/cli/doctor_test.ts +++ b/tests/cli/doctor_test.ts @@ -217,11 +217,14 @@ Deno.test( "runDoctorChecks: --boot reads the booted site's capability report", { sanitizeOps: false, sanitizeResources: false }, async () => { - const root = await bootFixture(JSON.stringify({ providers: [], scheduledActions: 0 })); + // This "site" reports a plugin that provides admin.prefix but no auth.session. + const root = await bootFixture(JSON.stringify({ + providers: [{ name: "admin.prefix", kind: "slot", provider: "half-admin", status: "active" }], + scheduledActions: 0, + })); try { - // admin.enabled defaults to true, and this "site" provides no auth.session. const findings = await runDoctorChecks(root, { boot: true }); - const caps = findings.filter((f) => f.category === "capabilities"); + const caps = findings.filter((f) => f.category === "capabilities" && f.severity !== "info"); assertEquals(caps.map((f) => f.severity), ["error"]); assertStringIncludes(caps[0].message, "auth.session"); } finally { diff --git a/tests/cli/migrate_config_test.ts b/tests/cli/migrate_config_test.ts index 48e056a2..eab4ea40 100644 --- a/tests/cli/migrate_config_test.ts +++ b/tests/cli/migrate_config_test.ts @@ -18,6 +18,9 @@ async function withSite( } } +/** A site.yaml tail that already lists the admin, so tests about other keys leave plugins: alone. */ +const ADMIN = "plugins:\n - src: jsr:@dune/plugin-admin@^4\n"; + const quiet = async (fn: () => Promise): Promise => { const log = console.log, err = console.error; const lines: string[] = []; @@ -34,7 +37,7 @@ const quiet = async (fn: () => Promise): Promise => { Deno.test("migrate:config rewrites site, system and env overlay files", async () => { await withSite({ - "config/site.yaml": "# c\nauth:\n userStore: db # keep\n", + "config/site.yaml": "# c\nauth:\n userStore: db # keep\n" + ADMIN, "config/system.yaml": "admin:\n maxUploadMb: 5\n", "config/env/production/system.yaml": "metrics:\n slowQueryThresholdMs: 10\n", }, async (root) => { @@ -42,7 +45,7 @@ Deno.test("migrate:config rewrites site, system and env overlay files", async () assertStringIncludes(out, "config/site.yaml"); assertEquals( await Deno.readTextFile(join(root, "config/site.yaml")), - "# c\nauth:\n user_store: db # keep\n", + "# c\nauth:\n user_store: db # keep\n" + ADMIN, ); assertEquals( await Deno.readTextFile(join(root, "config/system.yaml")), @@ -58,7 +61,7 @@ Deno.test("migrate:config rewrites site, system and env overlay files", async () }); Deno.test("migrate:config --dry-run writes nothing", async () => { - const src = "auth:\n userStore: db\n"; + const src = "auth:\n userStore: db\n" + ADMIN; await withSite({ "config/site.yaml": src }, async (root) => { const out = await quiet(() => migrateConfigCommand(root, { dryRun: true })); assertStringIncludes(out, "user_store"); @@ -68,7 +71,7 @@ Deno.test("migrate:config --dry-run writes nothing", async () => { }); Deno.test("migrate:config leaves a file with unreachable keys untouched and exits 1", async () => { - const src = "auth: { userStore: db }\n"; + const src = "auth: { userStore: db }\n" + ADMIN; await withSite({ "config/site.yaml": src }, async (root) => { const origExit = Deno.exit; let code: number | undefined; @@ -93,7 +96,7 @@ Deno.test("migrate:config leaves a file with unreachable keys untouched and exit }); Deno.test("migrate:config on a site with no legacy keys does nothing", async () => { - await withSite({ "config/site.yaml": "title: T\n" }, async (root) => { + await withSite({ "config/site.yaml": "title: T\n" + ADMIN }, async (root) => { assertStringIncludes(await quiet(() => migrateConfigCommand(root)), "Nothing to migrate"); }); }); @@ -101,7 +104,7 @@ Deno.test("migrate:config on a site with no legacy keys does nothing", async () Deno.test("migrate:config deletes keys that were declared and never read", async () => { await withSite({ "config/system.yaml": "# c\ncache:\n enabled: true\ntimezone: UTC\nadmin:\n maxUploadMb: 5\n", - "config/site.yaml": "title: T\nuploads:\n max_size_mb: 5\n", + "config/site.yaml": "title: T\nuploads:\n max_size_mb: 5\n" + ADMIN, }, async (root) => { const out = await quiet(() => migrateConfigCommand(root)); assertStringIncludes(out, "removed cache, timezone"); @@ -109,6 +112,54 @@ Deno.test("migrate:config deletes keys that were declared and never read", async await Deno.readTextFile(join(root, "config/system.yaml")), "# c\nadmin:\n max_upload_mb: 5\n", ); + assertEquals(await Deno.readTextFile(join(root, "config/site.yaml")), "title: T\n" + ADMIN); + }); +}); + +Deno.test("migrate:config adds the admin plugin to a site that had the admin by default", async () => { + await withSite({ "config/site.yaml": "# my site\ntitle: T\nplugins:\n - src: ./plugins/mine.ts\n config:\n a: 1\ntheme:\n name: x\n" }, async (root) => { + const out = await quiet(() => migrateConfigCommand(root)); + assertStringIncludes(out, "added jsr:@dune/plugin-admin@^4"); + assertEquals( + await Deno.readTextFile(join(root, "config/site.yaml")), + "# my site\ntitle: T\nplugins:\n - src: ./plugins/mine.ts\n config:\n a: 1\n - src: jsr:@dune/plugin-admin@^4\ntheme:\n name: x\n", + ); + assertStringIncludes(await quiet(() => migrateConfigCommand(root)), "Nothing to migrate"); + }); +}); + +Deno.test("migrate:config removes admin.enabled: true and adds the admin plugin", async () => { + await withSite({ + "config/site.yaml": "title: T\n", + "config/system.yaml": "admin:\n enabled: true\n max_upload_mb: 5\ndebug: true\n", + }, async (root) => { + const out = await quiet(() => migrateConfigCommand(root)); + assertStringIncludes(out, "removed admin.enabled"); + assertEquals(await Deno.readTextFile(join(root, "config/system.yaml")), "admin:\n max_upload_mb: 5\ndebug: true\n"); + assertStringIncludes(await Deno.readTextFile(join(root, "config/site.yaml")), "jsr:@dune/plugin-admin@^4"); + }); +}); + +Deno.test("migrate:config removes admin.enabled: false and leaves the site without an admin", async () => { + await withSite({ + "config/site.yaml": "title: T\n", + "config/system.yaml": "admin:\n enabled: false\ndebug: true\n", + }, async (root) => { + const out = await quiet(() => migrateConfigCommand(root)); + assertStringIncludes(out, "removed admin.enabled"); + assertStringIncludes(out, "admin plugin is not added"); + assertEquals(await Deno.readTextFile(join(root, "config/system.yaml")), "admin:\ndebug: true\n"); + assertEquals(await Deno.readTextFile(join(root, "config/site.yaml")), "title: T\n"); + }); +}); + +Deno.test("migrate:config --dry-run reports the admin changes and writes nothing", async () => { + const system = "admin:\n enabled: true\n"; + await withSite({ "config/site.yaml": "title: T\n", "config/system.yaml": system }, async (root) => { + const out = await quiet(() => migrateConfigCommand(root, { dryRun: true })); + assertStringIncludes(out, "removed admin.enabled"); + assertStringIncludes(out, "added jsr:@dune/plugin-admin@^4"); + assertEquals(await Deno.readTextFile(join(root, "config/system.yaml")), system); assertEquals(await Deno.readTextFile(join(root, "config/site.yaml")), "title: T\n"); }); }); diff --git a/tests/config/legacy_keys_test.ts b/tests/config/legacy_keys_test.ts index 6d1da37e..72aab5a7 100644 --- a/tests/config/legacy_keys_test.ts +++ b/tests/config/legacy_keys_test.ts @@ -133,7 +133,7 @@ Deno.test("findMovedKeys: the removed forms aliases, admin.data_dir and the smtp ); assertEquals( system.map((k) => k.path).sort(), - ["admin.dataDir", "admin.data_dir", "admin.honeypot", "admin.incoming_webhooks", "admin.notifications"], + ["admin.dataDir", "admin.data_dir", "admin.enabled", "admin.honeypot", "admin.incoming_webhooks", "admin.notifications"], ); const site = findMovedKeys( "site", @@ -154,6 +154,19 @@ Deno.test("loadConfig refuses a moved key and says where it went", async () => { assertStringIncludes(message, "Move the moved or removed keys by hand"); }); +Deno.test("admin.enabled and admin.authz_store are removed with a message that says what to do", async () => { + const moved = findMovedKeys("system", parseYaml("admin:\n enabled: true\n authz_store: local\n authzStore: local\n")); + assertEquals(moved.map((k) => k.path).sort(), ["admin.authzStore", "admin.authz_store", "admin.enabled"]); + let message = ""; + try { + await load({ "config/system.yaml": "admin:\n enabled: false\n" }); + } catch (err) { + message = (err as Error).message; + } + assertStringIncludes(message, "admin.enabled"); + assertStringIncludes(message, "listed under plugins:"); +}); + Deno.test("admin.runtime_dir and admin.max_revisions moved to the top level of system.yaml", async () => { const moved = findMovedKeys( "system", diff --git a/tests/config/loader_test.ts b/tests/config/loader_test.ts index 47e0341e..35ce7c33 100644 --- a/tests/config/loader_test.ts +++ b/tests/config/loader_test.ts @@ -189,7 +189,7 @@ Deno.test("loadConfig: merges system.yaml into system config", async () => { Deno.test("loadConfig: system.yaml `admin:` lands in config.admin, not config.system", async () => { const storage = new MemoryStorage({ - "config/system.yaml": `admin:\n enabled: false\n max_upload_mb: 7\ndebug: true`, + "config/system.yaml": `admin:\n max_upload_mb: 7\ndebug: true`, }); const config = await loadConfig({ storage, @@ -197,7 +197,6 @@ Deno.test("loadConfig: system.yaml `admin:` lands in config.admin, not config.sy skipConfigTs: true, skipValidation: true, }); - assertEquals(config.admin?.enabled, false); assertEquals(config.admin?.max_upload_mb, 7); // Unset admin keys keep their defaults assertEquals(config.admin?.path, "/admin"); @@ -206,20 +205,10 @@ Deno.test("loadConfig: system.yaml `admin:` lands in config.admin, not config.sy assertEquals(config.system.debug, true); }); -Deno.test("loadConfig: admin stays enabled when system.yaml has no admin block", async () => { - const config = await loadConfig({ - storage: new MemoryStorage({ "config/system.yaml": `debug: true` }), - rootDir: "/tmp/test", - skipConfigTs: true, - skipValidation: true, - }); - assertEquals(config.admin?.enabled, true); -}); - Deno.test("loadConfig: env system.yaml `admin:` overrides the base file", async () => { const storage = new MemoryStorage({ - "config/system.yaml": `admin:\n enabled: true\n max_upload_mb: 7`, - "config/env/production/system.yaml": `admin:\n enabled: false`, + "config/system.yaml": `admin:\n max_upload_mb: 7\n git_commit: false`, + "config/env/production/system.yaml": `admin:\n git_commit: true`, }); const config = await loadConfig({ storage, @@ -228,7 +217,7 @@ Deno.test("loadConfig: env system.yaml `admin:` overrides the base file", async skipConfigTs: true, skipValidation: true, }); - assertEquals(config.admin?.enabled, false); + assertEquals(config.admin?.git_commit, true); assertEquals(config.admin?.max_upload_mb, 7); }); diff --git a/tests/core/plugin_admin_specifier_test.ts b/tests/core/plugin_admin_specifier_test.ts deleted file mode 100644 index 7cd727e8..00000000 --- a/tests/core/plugin_admin_specifier_test.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Static regression tests for the `jsr:@dune/plugin-admin` dynamic import - * specifiers used to avoid a circular publish-time dependency between - * @dune/core and @dune/plugin-admin. - * - * These imports intentionally use a non-literal string (assigned to a - * variable before the `import()` call) so JSR's static publish-time analysis - * doesn't flag a cycle. Two invariants hang off that non-literal-ness: - * - * 1. The specifiers must carry a version constraint — a bare - * `jsr:@dune/plugin-admin` resolves against whatever happens to be in the - * local module cache / deno.lock at runtime, which is not guaranteed to be - * the latest published version in a fresh environment. - * 2. The import sites must use the shared constants from plugins/builtin.ts - * rather than inline strings — the lockfile discovery helper reports those - * same constants so `dune lockfile:sync` covers what Deno's graph builder - * can't follow. An inline string at an import site silently reopens the - * "synced lockfile fails --frozen serve" gap. - */ - -import { assert } from "https://deno.land/std@0.224.0/assert/mod.ts"; -import { ADMIN_MOUNT_SPECIFIER, ADMIN_PLUGIN_SPECIFIER } from "../../src/plugins/builtin.ts"; - -Deno.test("plugin-admin specifier constants carry a version constraint", () => { - for (const specifier of [ADMIN_PLUGIN_SPECIFIER, ADMIN_MOUNT_SPECIFIER]) { - assert( - /^jsr:@dune\/plugin-admin@\S/.test(specifier), - `specifier "${specifier}" is missing a version constraint (e.g. "@^1.0") — ` + - `a bare jsr: specifier resolves non-deterministically at runtime`, - ); - } -}); - -const IMPORT_SITES = [ - { file: "src/runtime/bootstrap.ts", constant: "ADMIN_PLUGIN_SPECIFIER" }, - { file: "src/multisite/manager.ts", constant: "ADMIN_MOUNT_SPECIFIER" }, - { file: "src/cli/lockfile-resolve-helper.ts", constant: "ADMIN_PLUGIN_SPECIFIER" }, - { file: "src/cli/lockfile-resolve-helper.ts", constant: "ADMIN_MOUNT_SPECIFIER" }, -]; - -Deno.test("plugin-admin import sites and the lockfile helper share the builtin.ts constants", async () => { - for (const { file, constant } of IMPORT_SITES) { - const source = await Deno.readTextFile(new URL(`../../${file}`, import.meta.url)); - assert( - source.includes(constant), - `${file}: expected a reference to ${constant} from plugins/builtin.ts — ` + - `an inline specifier string here would not be covered by dune lockfile:sync`, - ); - assert( - !source.includes(`"${ADMIN_PLUGIN_SPECIFIER}"`) && !source.includes(`"${ADMIN_MOUNT_SPECIFIER}"`), - `${file}: contains an inline plugin-admin specifier string; use the plugins/builtin.ts constant instead`, - ); - } -}); diff --git a/tests/plugin-api/capabilities_test.ts b/tests/plugin-api/capabilities_test.ts index 5b92853d..3f20f2ae 100644 --- a/tests/plugin-api/capabilities_test.ts +++ b/tests/plugin-api/capabilities_test.ts @@ -216,8 +216,6 @@ 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 enabled: false\n"); let runs = 0; const plugin: DunePlugin = { name: "test-admin", diff --git a/tests/plugins/admin_version_guard_test.ts b/tests/plugins/admin_version_guard_test.ts deleted file mode 100644 index 6ffb158d..00000000 --- a/tests/plugins/admin_version_guard_test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { assertEquals, assertStringIncludes } from "@std/assert"; -import { - adminSettingsAtRisk, - checkAdminVersion, - MIN_ADMIN_MAJOR, -} from "../../src/plugins/builtin.ts"; -import { DEFAULT_CONFIG } from "../../src/config/defaults.ts"; -import type { DuneConfig } from "../../src/config/types.ts"; - -function withAdmin(admin: Record): DuneConfig { - const c = structuredClone(DEFAULT_CONFIG); - Object.assign(c.admin as object, admin); - return c; -} - -Deno.test("adminSettingsAtRisk: a default config has nothing at risk", () => { - assertEquals(adminSettingsAtRisk(structuredClone(DEFAULT_CONFIG)), []); -}); - -Deno.test("adminSettingsAtRisk: names every renamed admin-read key moved off its default", () => { - const c = withAdmin({ - authz_store: "db", - max_upload_mb: 5, - audit: { log_file: "a.log" }, - auth_provider: { type: "ldap" }, - }); - assertEquals(adminSettingsAtRisk(c), [ - "admin.max_upload_mb", - "admin.authz_store", - "admin.audit.log_file", - "admin.auth_provider", - ]); -}); - -Deno.test("adminSettingsAtRisk: a value equal to the default is not at risk", () => { - assertEquals( - adminSettingsAtRisk(withAdmin({ max_upload_mb: 100 })), - [], - ); -}); - -Deno.test("checkAdminVersion: admin >= the minimum major is ok", () => { - const c = withAdmin({ max_upload_mb: 5 }); - assertEquals(checkAdminVersion(`${MIN_ADMIN_MAJOR}.0.0`, c).level, "ok"); - assertEquals(checkAdminVersion(`${MIN_ADMIN_MAJOR + 1}.2.3`, c).level, "ok"); -}); - -Deno.test("checkAdminVersion: old admin on an all-defaults config only warns", () => { - const r = checkAdminVersion("3.3.0", structuredClone(DEFAULT_CONFIG)); - assertEquals(r.level, "warn"); - assertStringIncludes(r.message!, "3.3.0"); -}); - -Deno.test("checkAdminVersion: old admin plus a customised renamed key refuses and names it", () => { - const r = checkAdminVersion("3.3.0", withAdmin({ authz_store: "db" })); - assertEquals(r.level, "refuse"); - assertStringIncludes(r.message!, "admin.authz_store"); -}); - -Deno.test("checkAdminVersion: an unreadable version is never treated as old", () => { - assertEquals( - checkAdminVersion(undefined, withAdmin({ authz_store: "db" })).level, - "ok", - ); - assertEquals( - checkAdminVersion("not-a-version", withAdmin({ authz_store: "db" })).level, - "ok", - ); -}); diff --git a/tests/plugins/capability_route_auth_test.ts b/tests/plugins/capability_route_auth_test.ts index 6609de87..8841b543 100644 --- a/tests/plugins/capability_route_auth_test.ts +++ b/tests/plugins/capability_route_auth_test.ts @@ -60,7 +60,7 @@ Deno.test( const ctx = await bootstrap(root, { plugins: [userPlugin, authProvider], // deno-lint-ignore no-explicit-any - configOverrides: { admin: { enabled: false } as any }, + }); // deno-lint-ignore no-explicit-any const app = new App(); @@ -99,7 +99,7 @@ Deno.test( const ctx = await bootstrap(root, { plugins: [userPlugin], // deno-lint-ignore no-explicit-any - configOverrides: { admin: { enabled: false } as any }, + }); // deno-lint-ignore no-explicit-any const app = new App(); diff --git a/tests/plugins/core_handshake_test.ts b/tests/plugins/core_handshake_test.ts index b852da5c..cc309110 100644 --- a/tests/plugins/core_handshake_test.ts +++ b/tests/plugins/core_handshake_test.ts @@ -1,44 +1,44 @@ /** - * Core-instance handshake: bootstrap's check that the dynamically imported - * admin plugin resolved the SAME @dune/core module instance as the host — + * Core-instance handshake: the plugin loader's check that a dynamically imported + * plugin resolved the SAME @dune/core module instance as the host — * compared by sentinel reference, not version string, so a same-version * dual-load (local-path core + JSR-resolved core) is caught too. */ import { assertEquals, assertStringIncludes } from "https://deno.land/std@0.224.0/assert/mod.ts"; -import { adminCoreMismatch } from "../../src/plugins/builtin.ts"; +import { pluginCoreMismatch } from "../../src/plugins/core-handshake.ts"; import { CORE_INSTANCE, CORE_VERSION } from "../../src/plugins/mod.ts"; -Deno.test("adminCoreMismatch: null when the plugin resolved this core instance", () => { +Deno.test("pluginCoreMismatch: null when the plugin resolved this core instance", () => { assertEquals( - adminCoreMismatch({ resolvedCoreSentinel: CORE_INSTANCE, resolvedCoreVersion: CORE_VERSION }), + pluginCoreMismatch({ resolvedCoreSentinel: CORE_INSTANCE, resolvedCoreVersion: CORE_VERSION }, "some-plugin"), null, ); }); -Deno.test("adminCoreMismatch: null for plugins that predate the handshake", () => { - assertEquals(adminCoreMismatch({ createAdminPlugin: () => {} }), null); +Deno.test("pluginCoreMismatch: null for plugins that predate the handshake", () => { + assertEquals(pluginCoreMismatch({ createAdminPlugin: () => {} }, "some-plugin"), null); }); -Deno.test("adminCoreMismatch: warns on a different core version", () => { - const msg = adminCoreMismatch({ +Deno.test("pluginCoreMismatch: warns on a different core version", () => { + const msg = pluginCoreMismatch({ resolvedCoreSentinel: Object.freeze({}), resolvedCoreVersion: "0.27.0", - }); + }, "some-plugin"); assertStringIncludes(msg ?? "", "@dune/core@0.27.0"); assertStringIncludes(msg ?? "", `@dune/core@${CORE_VERSION}`); assertStringIncludes(msg ?? "", "version range"); }); -Deno.test("adminCoreMismatch: same version, different instance → local-path hint", () => { - const msg = adminCoreMismatch({ +Deno.test("pluginCoreMismatch: same version, different instance → local-path hint", () => { + const msg = pluginCoreMismatch({ resolvedCoreSentinel: Object.freeze({}), resolvedCoreVersion: CORE_VERSION, - }); + }, "some-plugin"); assertStringIncludes(msg ?? "", "Same version loaded twice"); }); -Deno.test("adminCoreMismatch: sentinel export present but undefined → plugin resolved a pre-0.31 core", () => { - const msg = adminCoreMismatch({ resolvedCoreSentinel: undefined }); +Deno.test("pluginCoreMismatch: sentinel export present but undefined → plugin resolved a pre-0.31 core", () => { + const msg = pluginCoreMismatch({ resolvedCoreSentinel: undefined }, "some-plugin"); assertStringIncludes(msg ?? "", "pre-0.31"); }); diff --git a/tests/plugins/loader_test.ts b/tests/plugins/loader_test.ts index f6bf8653..6e3b9358 100644 --- a/tests/plugins/loader_test.ts +++ b/tests/plugins/loader_test.ts @@ -58,3 +58,37 @@ Deno.test("loadPlugins: accepts a ^-range jsr: specifier past version validation // the specifier made it past the version-anchoring check. await loadPlugins({ config, hooks, storage, root: "/tmp" }); }); + +Deno.test("loadPlugins: a plugin factory gets its config and the site root and dev flag", async () => { + const root = await Deno.makeTempDir({ prefix: "dune_test_factory_ctx_" }); + try { + await Deno.mkdir(`${root}/plugins`, { recursive: true }); + await Deno.writeTextFile( + `${root}/plugins/factory.ts`, + `export default function (cfg: unknown, ctx: unknown) { + (globalThis as any).__factoryArgs = { cfg, ctx }; + return { name: "factory-plugin", version: "1.0.0", hooks: {} }; + }`, + ); + const config = { + pluginList: [{ src: "./plugins/factory.ts", config: { a: 1 } }], + autoDiscoverPlugins: false, + plugins: {}, + } as unknown as DuneConfig; + const storage = { exists: () => Promise.resolve(false) } as unknown as StorageAdapter; + + for (const dev of [undefined, true]) { + const hooks = createHookRegistry({ config, storage }); + await loadPlugins({ config, hooks, storage, root, dev }); + // deno-lint-ignore no-explicit-any + const seen = (globalThis as any).__factoryArgs; + assertEquals(seen.cfg, { a: 1 }); + assertEquals(seen.ctx, { root, dev: dev ?? false }); + assertEquals(hooks.plugins().map((p) => p.name), ["factory-plugin"]); + } + } finally { + // deno-lint-ignore no-explicit-any + delete (globalThis as any).__factoryArgs; + await Deno.remove(root, { recursive: true }); + } +}); diff --git a/tests/runtime/authz_for_session_provider_test.ts b/tests/runtime/authz_for_session_provider_test.ts index d659b4ec..b8b38d13 100644 --- a/tests/runtime/authz_for_session_provider_test.ts +++ b/tests/runtime/authz_for_session_provider_test.ts @@ -1,7 +1,7 @@ /** * The authz system is created for the built-in admin (when enabled) and for any * plugin that provides `auth.session`, so a replacement admin can authorise its - * sessions even with `admin.enabled: false`. + * sessions on a site with no built-in admin. */ import { assertEquals, assertExists } from "@std/assert"; @@ -15,7 +15,7 @@ async function withSite(plugins: DunePlugin[], fn: (ctx: Awaited {}); } diff --git a/tests/runtime/bootstrap_admin_authz_test.ts b/tests/runtime/bootstrap_admin_authz_test.ts index 58947f40..fcc633d2 100644 --- a/tests/runtime/bootstrap_admin_authz_test.ts +++ b/tests/runtime/bootstrap_admin_authz_test.ts @@ -16,10 +16,22 @@ import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; import { join } from "@std/path"; import { bootstrap } from "../../src/runtime/bootstrap.ts"; +import type { DunePlugin } from "../../src/hooks/types.ts"; + +/** A stand-in admin: all core looks at is that something provides `auth.session`. */ +const admin: DunePlugin = { + name: "test-admin", + version: "1.0.0", + hooks: {}, + setup(api) { + api.capabilities.provide("auth.session", () => ({ authenticate: () => Promise.resolve({ authenticated: false }) })); + }, +}; async function withBootstrappedSite( siteYaml: string, fn: (ctx: Awaited>) => Promise, + plugins: DunePlugin[] = [admin], ): Promise { const root = await Deno.makeTempDir({ prefix: "dune_test_bootstrap_admin_authz_", @@ -33,7 +45,7 @@ async function withBootstrappedSite( await Deno.mkdir(join(root, "config"), { recursive: true }); await Deno.writeTextFile(join(root, "config", "site.yaml"), siteYaml); - const ctx = await bootstrap(root, {}); + const ctx = await bootstrap(root, { plugins }); await fn(ctx); } finally { await Deno.remove(root, { recursive: true }).catch(() => {}); @@ -67,3 +79,32 @@ Deno.test( ); }, ); + +Deno.test( + "bootstrap(): with no admin and no plugin that needs authz, no authz system is created", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + await withBootstrappedSite( + "title: Test Site\nurl: http://localhost:3000\n", + async (ctx) => { + assertEquals(ctx.authz, undefined); + }, + [], + ); + }, +); + +Deno.test( + "bootstrap(): a plugin that declares authzActions gets the authz system without an admin", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + const needsAuthz: DunePlugin = { name: "needs-authz", version: "1.0.0", hooks: {}, authzActions: { "reports.read": ["admin"] } }; + await withBootstrappedSite( + "title: Test Site\nurl: http://localhost:3000\n", + async (ctx) => { + assertEquals(typeof ctx.authz?.check, "function"); + }, + [needsAuthz], + ); + }, +); diff --git a/tests/runtime/inline_edit_capability_test.ts b/tests/runtime/inline_edit_capability_test.ts index 42574a79..53761bad 100644 --- a/tests/runtime/inline_edit_capability_test.ts +++ b/tests/runtime/inline_edit_capability_test.ts @@ -23,7 +23,7 @@ async function withApp( const ctx = await bootstrap(root, { plugins, // deno-lint-ignore no-explicit-any - configOverrides: { admin: { enabled: false } as any }, + }); const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); await fn(app.handler()); diff --git a/tests/runtime/register_plugin_routes_test.ts b/tests/runtime/register_plugin_routes_test.ts index 80c0d1b5..889573ce 100644 --- a/tests/runtime/register_plugin_routes_test.ts +++ b/tests/runtime/register_plugin_routes_test.ts @@ -5,8 +5,8 @@ * * This is the real gap the fix closes: previously the only code that turned * a plugin's `publicRoutes` into a live Fresh route lived in - * @dune/plugin-admin's mountDuneAdmin(), so a site with `admin.enabled: - * false`, headless mode without an explicit mountDuneAdmin() call, or any + * @dune/plugin-admin's mountDuneAdmin(), so a site with no admin plugin listed, + * headless mode without an explicit mountDuneAdmin() call, or any * bootstrap()-only tool never got publicRoutes wired at all — silently, no * error. bootstrap.ts always collected BootstrapResult.pluginPublicRoutes; * nothing turned that into an app.get() call unless plugin-admin's mount() diff --git a/tests/ssg/builder_test.ts b/tests/ssg/builder_test.ts index 0a74b419..469569d3 100644 --- a/tests/ssg/builder_test.ts +++ b/tests/ssg/builder_test.ts @@ -126,9 +126,6 @@ async function hybridSite(root: string): Promise { join(root, "content", "01.home", "default.md"), "---\ntitle: Home\n---\n\n# Home\n", ); - // No built-in admin: these tests supply (or withhold) the admin.prefix slot themselves. - await Deno.mkdir(join(root, "config"), { recursive: true }); - await Deno.writeTextFile(join(root, "config", "system.yaml"), "admin:\n enabled: false\n"); } Deno.test( From f72041b11bb35d12a88de856c5a43fad1b4e8b57 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 23:40:19 +0100 Subject: [PATCH 11/31] feat(plugin-api)!: machine translation and the audit trail leave core (#123, step 3) services.engines.mt/audit, PluginServicesOptions.root, SiteConfig.machine_translation and the @dune/core/mt and @dune/core/audit entry points are removed; @dune/plugin-admin owns them now. The response-transforms test stops importing types from the published admin. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 10 ++ deno.json | 2 - src/audit/logger.ts | 157 ------------------- src/audit/mod.ts | 21 --- src/audit/types.ts | 91 ----------- src/config/site-config.ts | 7 - src/mt/deepl.ts | 73 --------- src/mt/factory.ts | 36 ----- src/mt/google.ts | 48 ------ src/mt/libretranslate.ts | 69 -------- src/mt/mod.ts | 11 -- src/mt/types.ts | 31 ---- src/plugin-api/create-services.ts | 20 --- src/plugin-api/services.ts | 8 - src/runtime/bootstrap.ts | 1 - tests/cli/response_transforms_test.ts | 12 +- tests/mt/ssrf_test.ts | 11 -- tests/plugin-api/services_test.ts | 33 +--- tests/runtime/register_plugin_routes_test.ts | 9 +- 19 files changed, 23 insertions(+), 627 deletions(-) delete mode 100644 src/audit/logger.ts delete mode 100644 src/audit/mod.ts delete mode 100644 src/audit/types.ts delete mode 100644 src/mt/deepl.ts delete mode 100644 src/mt/factory.ts delete mode 100644 src/mt/google.ts delete mode 100644 src/mt/libretranslate.ts delete mode 100644 src/mt/mod.ts delete mode 100644 src/mt/types.ts delete mode 100644 tests/mt/ssrf_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e428ced..905c4ad8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,16 @@ where the setting lives now (see `dune migrate:config`, which reports these too) (which loads no plugins) no longer reports `admin.path`. New: `resolveCapabilities()` (`@dune/core/plugins`) settles plugin setup and resolves slots, idempotently, for callers that read a slot before `createDuneApp()`. `@dune/plugin-admin` 4.0.0 provides the slot. +- **Machine translation and the audit trail are the admin's, not core's (#123, step 3).** + `services.engines.mt` and `services.engines.audit` are gone from `PluginServices` + (`EngineServices` is `workflow`, `scheduler`, `staging`), `PluginServicesOptions.root` + with them, and the `@dune/core/mt` and `@dune/core/audit` entry points are removed: + `@dune/plugin-admin` 4.0.0 owns `AuditLogger`, the translators and their types, builds + them in its own `setup()`, and sends translator requests through `services.security.safeFetch`. + `SiteConfig` no longer declares `machine_translation` (the admin reads it from the site + config, as before, until its settings move in step 5); `admin.audit` is unchanged. A + published `@dune/plugin-admin` older than 4.0.0 imports the removed entry points and + cannot run on this core. - **The legacy `smtp:` block in form notifications.** Use `site.email`. Magic-link login emails also go through `site.email` now (they used to need the legacy `admin.notifications.email.smtp`); a site without diff --git a/deno.json b/deno.json index 3380d6a7..236a8180 100644 --- a/deno.json +++ b/deno.json @@ -41,8 +41,6 @@ "./staging": "./src/staging/engine.ts", "./workflow": "./src/workflow/mod.ts", "./session": "./src/session/mod.ts", - "./audit": "./src/audit/mod.ts", - "./mt": "./src/mt/mod.ts", "./security": "./src/security/mod.ts", "./history": "./src/history/mod.ts", "./metrics": "./src/metrics/mod.ts", diff --git a/src/audit/logger.ts b/src/audit/logger.ts deleted file mode 100644 index d35d72c0..00000000 --- a/src/audit/logger.ts +++ /dev/null @@ -1,157 +0,0 @@ -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 deleted file mode 100644 index f9e2427e..00000000 --- a/src/audit/mod.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * 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 { - /** Appends entries to daily-rotated JSONL files, sharded by date. */ - AuditLogger, -} from "./logger.ts"; diff --git a/src/audit/types.ts b/src/audit/types.ts deleted file mode 100644 index 3da1282b..00000000 --- a/src/audit/types.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * 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/config/site-config.ts b/src/config/site-config.ts index 2eae20ce..4039bf6a 100644 --- a/src/config/site-config.ts +++ b/src/config/site-config.ts @@ -3,7 +3,6 @@ */ import type { WorkflowStage, WorkflowTransition } from "../workflow/types.ts"; -import type { MachineTranslationConfig } from "../mt/types.ts"; import type { FormsConfig } from "./forms-config.ts"; /** @@ -137,12 +136,6 @@ export interface SiteConfig { stages: WorkflowStage[]; transitions: WorkflowTransition[]; }; - /** - * Machine translation provider configuration. - * When present (and enabled), editors can request automatic translations - * from the admin panel. - */ - machine_translation?: MachineTranslationConfig; /** * XML sitemap generation settings. * The sitemap is served at /sitemap.xml. diff --git a/src/mt/deepl.ts b/src/mt/deepl.ts deleted file mode 100644 index ff47dac3..00000000 --- a/src/mt/deepl.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * DeepL machine translation provider. - * Supports both Free (keys ending in ":fx") and Pro API tiers. - */ - -import type { MachineTranslator } from "./types.ts"; -import { safeFetch } from "../security/ssrf.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) { - // 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 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 deleted file mode 100644 index 61c17dac..00000000 --- a/src/mt/factory.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Factory for creating a MachineTranslator from config. - */ - -import type { MachineTranslationConfig, MachineTranslator } 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. - */ -export function createMachineTranslator(config: MachineTranslationConfig): 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); - case "google": - return new GoogleTranslator(key); - case "libretranslate": - return new LibreTranslateTranslator( - config.baseUrl ?? "https://libretranslate.com", - key || undefined, - ); - default: - return null; - } -} diff --git a/src/mt/google.ts b/src/mt/google.ts deleted file mode 100644 index abebbefe..00000000 --- a/src/mt/google.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Google Cloud Translation API v2 (Basic) provider. - */ - -import type { MachineTranslator } from "./types.ts"; -import { safeFetch } from "../security/ssrf.ts"; - -/** Google Cloud Translation API v2 (Basic) provider. */ -export class GoogleTranslator implements MachineTranslator { - readonly provider = "google" as const; - - constructor(private apiKey: string) {} - - 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 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 deleted file mode 100644 index d5164cee..00000000 --- a/src/mt/libretranslate.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * LibreTranslate provider — supports self-hosted instances. - * LibreTranslate does not support batch requests, so batch is implemented - * as sequential individual requests. - */ - -import type { MachineTranslator } from "./types.ts"; -import { safeFetch } from "../security/ssrf.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, - ) { - // 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 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 deleted file mode 100644 index 7fc13cc4..00000000 --- a/src/mt/mod.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Machine translation module — re-exports all public types and classes. - * - * @module - */ - -export type { MachineTranslator, MachineTranslationConfig } 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 deleted file mode 100644 index 76fd39d8..00000000 --- a/src/mt/types.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Machine translation type definitions. - */ - -/** 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/src/plugin-api/create-services.ts b/src/plugin-api/create-services.ts index dd8fc1eb..88d51fb2 100644 --- a/src/plugin-api/create-services.ts +++ b/src/plugin-api/create-services.ts @@ -12,12 +12,9 @@ import { DUMMY_HASH, hashPassword, needsRehash, verifyPassword } from "../auth/p import { createWorkflowEngine } from "../workflow/engine.ts"; import { createScheduler } from "../workflow/scheduler.ts"; import { createStagingEngine } from "../staging/engine.ts"; -import { createMachineTranslator } from "../mt/factory.ts"; -import { AuditLogger } from "../audit/logger.ts"; import { bootstrapAdminTuples } from "../auth/authz.ts"; import { resolveBlueprint, validateFrontmatter } from "../blueprints/validator.ts"; import { resolveDataDir, resolveRuntimeDir } from "../forms/config.ts"; -import { join } from "@std/path"; import { logger } from "../core/logger.ts"; /** @@ -72,8 +69,6 @@ export function createPluginServices(opts: PluginServicesOptions): PluginService workflow: createWorkflowEngine({ storage, dataDir: runtimeDir }, config.site?.workflow ?? undefined), scheduler: createScheduler({ storage, dataDir: runtimeDir }), staging: createStagingEngine({ storage, runtimeDir }), - mt: config.site?.machine_translation ? createMachineTranslator(config.site.machine_translation) : null, - audit: createAuditTrail(opts, runtimeDir), }, blueprints: { has: (template) => !!opts.getBlueprints?.()[template], @@ -102,18 +97,3 @@ export function createPluginServices(opts: PluginServicesOptions): PluginService }, }; } - -function createAuditTrail(opts: PluginServicesOptions, runtimeDir: string): AuditLogger | null { - const audit = opts.config.admin?.audit; - if (audit?.enabled === false || !opts.root) return null; - const root = opts.root; - 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/plugin-api/services.ts b/src/plugin-api/services.ts index e267cbe5..d06c2eae 100644 --- a/src/plugin-api/services.ts +++ b/src/plugin-api/services.ts @@ -29,8 +29,6 @@ import type { SearchAnalytics, SearchAnalyticsSummary } from "../search/analytic import type { WorkflowEngine } from "../workflow/engine.ts"; import type { Scheduler } from "../workflow/scheduler.ts"; import type { StagingEngine } from "../staging/engine.ts"; -import type { MachineTranslator } from "../mt/types.ts"; -import type { AuditLogger } from "../audit/logger.ts"; /** Stateless security policy. */ export interface SecurityService { @@ -106,10 +104,6 @@ export interface EngineServices { scheduler: Scheduler; /** Staged drafts of published pages. */ staging: StagingEngine; - /** The configured machine translator, or null when none is configured. */ - mt: MachineTranslator | null; - /** The audit trail, or null when `admin.audit.enabled` is false. */ - audit: AuditLogger | null; } /** The site's frontmatter schemas (blueprints), as currently loaded. */ @@ -159,8 +153,6 @@ export interface PluginServices { export interface PluginServicesOptions { config: import("../config/types.ts").DuneConfig; storage: StorageAdapter; - /** Site root. Needed for the audit trail's file path; without it `engines.audit` is null. */ - root?: string; /** The query recorder, when `system.search.analytics` is on. */ searchAnalytics?: SearchAnalytics | null; /** The blueprints currently loaded by the content engine (read at call time; they reload). */ diff --git a/src/runtime/bootstrap.ts b/src/runtime/bootstrap.ts index 036c0009..48d4475e 100644 --- a/src/runtime/bootstrap.ts +++ b/src/runtime/bootstrap.ts @@ -346,7 +346,6 @@ export async function bootstrap( const pluginServices = createPluginServices({ config, storage, - root, getBlueprints: () => engine.blueprints, searchAnalytics, getAuthz: () => authzForServices, diff --git a/tests/cli/response_transforms_test.ts b/tests/cli/response_transforms_test.ts index 6cbe65cd..6bfdfbda 100644 --- a/tests/cli/response_transforms_test.ts +++ b/tests/cli/response_transforms_test.ts @@ -17,10 +17,14 @@ import type { ResponseTransformContext, } from "../../src/hooks/types.ts"; import type { DuneConfig } from "../../src/config/types.ts"; -import type { - AuthResult, - User, -} from "jsr:@dune/plugin-admin/admin/types"; +import type { User } from "../../src/auth/types.ts"; + +/** What the fake auth middleware resolves to (the admin's result shape, kept local: core tests do not depend on the published admin). */ +interface AuthResult { + authenticated: boolean; + user?: User; + error?: string; +} const config = {} as DuneConfig; diff --git a/tests/mt/ssrf_test.ts b/tests/mt/ssrf_test.ts deleted file mode 100644 index 1d0d3326..00000000 --- a/tests/mt/ssrf_test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { assertRejects } from "https://deno.land/std@0.224.0/assert/mod.ts"; -import { LibreTranslateTranslator } from "../../src/mt/libretranslate.ts"; - -Deno.test("LibreTranslateTranslator: refuses a private destination via safeFetch", async () => { - const mt = new LibreTranslateTranslator("http://169.254.169.254"); - await assertRejects( - () => mt.translate("hello", "en", "es"), - Error, - "LibreTranslate", - ); -}); diff --git a/tests/plugin-api/services_test.ts b/tests/plugin-api/services_test.ts index 17a0921a..a32636d7 100644 --- a/tests/plugin-api/services_test.ts +++ b/tests/plugin-api/services_test.ts @@ -92,41 +92,12 @@ Deno.test("the hook registry hands plugins the services in setup()", () => { assertExists((seen2 as { security?: unknown }).security); }); -Deno.test("engines: workflow, scheduler and staging are built; no mt unless configured", () => { +Deno.test("engines: workflow, scheduler and staging are built; machine translation and the audit trail are the admin's, not core's", () => { const { engines } = services(); assertExists(engines.workflow); assertExists(engines.scheduler); assertExists(engines.staging); - assertEquals(engines.mt, null); - assertEquals(engines.audit, null, "no site root, no audit trail"); -}); - -Deno.test("engines: the audit trail logs without an explicit init and honours enabled:false", async () => { - const root = await Deno.makeTempDir(); - try { - const { engines } = createPluginServices({ config: config(), storage: new MemoryStorageAdapter(), root }); - assertExists(engines.audit); - await engines.audit!.log({ event: "page.update", actor: null, ip: null, userAgent: null, target: null, detail: {} } as never); - const res = await engines.audit!.query({ event: "page.update" }); - assertEquals(res.entries.length, 1); - - const off = config(); - off.admin = { ...off.admin, audit: { enabled: false } } as never; - assertEquals(createPluginServices({ config: off, storage: new MemoryStorageAdapter(), root }).engines.audit, null); - } finally { - await Deno.remove(root, { recursive: true }); - } -}); - -Deno.test("engines: an audit log_file outside the site root is refused", () => { - const cfg = config(); - cfg.admin = { ...cfg.admin, audit: { log_file: "../elsewhere/audit.log" } } as never; - try { - createPluginServices({ config: cfg, storage: new MemoryStorageAdapter(), root: "/srv/site" }); - throw new Error("expected a throw"); - } catch (err) { - assertEquals((err as Error).message.includes("must resolve under the site root"), true); - } + assertEquals(Object.keys(engines).sort(), ["scheduler", "staging", "workflow"]); }); Deno.test("authz service: seedAdminTuples is a no-op without an authz system", async () => { diff --git a/tests/runtime/register_plugin_routes_test.ts b/tests/runtime/register_plugin_routes_test.ts index 889573ce..0ad09318 100644 --- a/tests/runtime/register_plugin_routes_test.ts +++ b/tests/runtime/register_plugin_routes_test.ts @@ -37,10 +37,8 @@ async function withTestApp( "---\ntitle: Home\n---\n\n# Home\n", ); - // No @dune/plugin-admin (or any admin package) is passed via `plugins` - // below — this is the exact "admin package absent" scenario the fix - // targets, since no site.yaml `plugins:` list or admin auto-registration - // is involved when constructing the plugin array directly like this. + // No admin package at all: none is listed in site.yaml and none is passed + // via `plugins` below unless a test supplies its own stand-in. const ctx = await bootstrap(root, { plugins }); const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); const handler = app.handler(); @@ -348,8 +346,7 @@ Deno.test( await Deno.writeTextFile( join(root, "content", "01.home", "default.md"), "---\ntitle: Home\n---\n\n# Home\n", - ); - const ctx = await bootstrap(root, { plugins: [plugin] }); + ); const ctx = await bootstrap(root, { plugins: [plugin] }); // deno-lint-ignore no-explicit-any const app = new App(); registerPluginPublicRoutes(app, ctx, { adminPrefix: "/admin" }); From 3e609c4da0a5a4aa5ee3e936ca3ba96610f5fab9 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 01:08:10 +0100 Subject: [PATCH 12/31] feat(config)!: the admin's settings are the admin plugin's config, not core's (#123, step 5) DuneConfig.admin and its types, defaults, schema entry and validation are removed; a system.yaml admin: block is refused. The settings live under config: of the admin's entry in plugins: and dune migrate:config moves them (lifting runtime_dir, max_revisions and data_dir to the top level). MCP stops reporting admin settings and drops the audit-log resource. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 13 + deno.lock | 370 +++-------------------------- src/auth/authz-schema.ts | 2 +- src/cli/add-admin-plugin.ts | 36 ++- src/cli/migrate-config.ts | 118 ++++++++- src/config/admin-config.ts | 127 ---------- src/config/defaults.ts | 5 - src/config/dune-config.ts | 3 - src/config/forms-config.ts | 20 +- src/config/legacy-keys.ts | 16 ++ src/config/loader.ts | 25 +- src/config/role.ts | 14 ++ src/config/types.ts | 13 +- src/config/validator.ts | 42 ---- src/mcp/resources.ts | 47 +--- src/mcp/tools.ts | 3 - src/mod.ts | 1 - src/schema/config-schema.ts | 69 ------ tests/cli/add_admin_plugin_test.ts | 23 +- tests/cli/migrate_config_test.ts | 73 +++++- tests/config/legacy_keys_test.ts | 9 +- tests/config/loader_test.ts | 33 +-- tests/config/validator_test.ts | 14 -- tests/mcp/schema_test.ts | 4 +- 24 files changed, 353 insertions(+), 727 deletions(-) delete mode 100644 src/config/admin-config.ts create mode 100644 src/config/role.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 905c4ad8..202466b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,19 @@ where the setting lives now (see `dune migrate:config`, which reports these too) `resolveRuntimeDir()` and `resolveMaxRevisions()` (`@dune/core/forms`) are the one place that resolves them. A plugin reads the directory from `services.paths.runtimeDir`. `@dune/plugin-admin` 4.0.0 does the same. +- **The admin's settings are the admin plugin's config, not core's (#123, step 5).** + `DuneConfig.admin`, `AdminConfig`, `AuthProviderConfig`, `WebhookEndpointConfig` and + `WebhookContentEvent` are gone, along with the `admin` defaults, the `admin` property of the + config JSON schema, and core's validation of `admin.path` and `admin.auth_provider`. A + system.yaml `admin:` block is refused. `path`, `session_lifetime`, `max_upload_mb`, + `git_commit`, `webhooks`, `auth_provider` and `audit` are now `config:` under the admin's + entry in `plugins:` (site.yaml), read by the plugin as `config.plugins.admin`, which the + plugin validates itself. `runtime_dir`, `max_revisions` and `data_dir` go to the top level of + system.yaml. `dune migrate:config` does the whole move for the base system.yaml (an + `admin:` block in an environment overlay, or one that still holds `honeypot`, `notifications` + or `incoming_webhooks`, is left for you). The MCP server no longer reports `admin` settings + and loses its `dune://site/audit` resource: core cannot know where the admin writes its log. + `Role` is now `config/role.ts`; `WebhookNotificationConfig` lives with the forms config. - **Core no longer loads the admin: it is a plugin you list (#123, step 4).** `bootstrap()` used to import `jsr:@dune/plugin-admin@*` itself unless `admin.enabled` was false; now the admin runs only when `config/site.yaml` lists it under `plugins:` diff --git a/deno.lock b/deno.lock index eb02d315..0dcfd949 100644 --- a/deno.lock +++ b/deno.lock @@ -2,62 +2,38 @@ "version": "5", "specifiers": { "jsr:@db/sqlite@0.12": "0.12.0", - "jsr:@deno/esbuild-plugin@^1.2.0": "1.2.1", - "jsr:@deno/loader@~0.3.10": "0.3.14", "jsr:@denosaurs/plug@1": "1.1.0", - "jsr:@dune/core@~0.34.2": "0.34.14", - "jsr:@dune/plugin-admin@*": "3.0.0", - "jsr:@dune/plugin-admin@3": "3.0.0", "jsr:@fresh/build-id@1": "1.0.1", "jsr:@fresh/core@2": "2.3.3", "jsr:@std/assert@0.217": "0.217.0", - "jsr:@std/assert@1": "1.0.19", - "jsr:@std/bytes@^1.0.6": "1.0.6", - "jsr:@std/crypto@1": "1.1.0", "jsr:@std/encoding@1": "1.0.11", "jsr:@std/encoding@^1.0.10": "1.0.11", - "jsr:@std/encoding@^1.0.11": "1.0.11", "jsr:@std/fmt@1": "1.0.10", "jsr:@std/fmt@^1.0.8": "1.0.10", "jsr:@std/fs@1": "1.0.24", "jsr:@std/fs@^1.0.19": "1.0.24", "jsr:@std/html@^1.0.5": "1.0.7", - "jsr:@std/http@^1.0.21": "1.1.3", - "jsr:@std/internal@^1.0.12": "1.0.14", + "jsr:@std/http@^1.0.21": "1.1.4", "jsr:@std/internal@^1.0.14": "1.0.14", - "jsr:@std/json@^1.0.2": "1.1.0", - "jsr:@std/jsonc@^1.0.2": "1.0.2", - "jsr:@std/media-types@^1.1.0": "1.1.0", - "jsr:@std/net@^1.0.6": "1.0.6", + "jsr:@std/net@^1.0.6": "1.0.7", "jsr:@std/path@0.217": "0.217.0", "jsr:@std/path@1": "1.1.6", - "jsr:@std/path@^1.1.1": "1.1.6", "jsr:@std/path@^1.1.2": "1.1.6", "jsr:@std/path@^1.1.4": "1.1.6", "jsr:@std/path@^1.1.5": "1.1.6", - "jsr:@std/semver@1": "1.0.8", - "jsr:@std/semver@^1.0.6": "1.0.8", - "jsr:@std/uuid@^1.0.9": "1.1.1", - "jsr:@std/yaml@1": "1.2.0", - "jsr:@zip-js/zip-js@2": "2.8.61", + "jsr:@std/yaml@1": "1.3.0", "npm:@mdx-js/mdx@3": "3.1.1", - "npm:@opentelemetry/api@^1.9.0": "1.9.1", - "npm:@preact/signals@^2.5.1": "2.11.1_preact@10.29.8__preact-render-to-string@6.7.0", - "npm:@types/nodemailer@6": "6.4.24", - "npm:esbuild-wasm@~0.25.11": "0.25.12", - "npm:esbuild@0.25.7": "0.25.7", - "npm:esbuild@~0.25.5": "0.25.7", + "npm:@opentelemetry/api@^1.9.0": "1.10.0", + "npm:@preact/signals@^2.5.1": "2.11.4_preact@10.29.8__preact-render-to-string@6.8.2_preact-render-to-string@6.8.2__preact@10.29.8", "npm:gray-matter@4": "4.0.3", "npm:ioredis@5": "5.11.1", "npm:marked@15": "15.0.12", - "npm:nodemailer@6": "6.10.1", - "npm:polizy@0.6": "0.6.0", + "npm:polizy@0.6": "0.6.1", "npm:postgres@^3.4.0": "3.4.9", - "npm:preact-render-to-string@6": "6.7.0_preact@10.29.8", - "npm:preact-render-to-string@^6.6.3": "6.7.0_preact@10.29.8", - "npm:preact@10": "10.29.8_preact-render-to-string@6.7.0", - "npm:preact@^10.28.2": "10.29.8_preact-render-to-string@6.7.0", - "npm:preact@^10.29.1": "10.29.8_preact-render-to-string@6.7.0", + "npm:preact-render-to-string@6": "6.8.2_preact@10.29.8", + "npm:preact-render-to-string@^6.6.3": "6.8.2_preact@10.29.8", + "npm:preact@10": "10.29.8_preact-render-to-string@6.8.2", + "npm:preact@^10.29.1": "10.29.8_preact-render-to-string@6.8.2", "npm:remark-gfm@4": "4.0.1", "npm:sharp@0.33": "0.33.5" }, @@ -69,17 +45,6 @@ "jsr:@std/path@0.217" ] }, - "@deno/esbuild-plugin@1.2.1": { - "integrity": "df629467913adc1f960149fdfa3a3430ba8c20381c310fba096db244e6c3c9f6", - "dependencies": [ - "jsr:@deno/loader", - "jsr:@std/path@^1.1.1", - "npm:esbuild@~0.25.5" - ] - }, - "@deno/loader@0.3.14": { - "integrity": "97bc63a6cc2d27a60bcdc953f588c5213331d866d44212eebb24cebfb9b011ca" - }, "@denosaurs/plug@1.1.0": { "integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044", "dependencies": [ @@ -89,42 +54,6 @@ "jsr:@std/path@1" ] }, - "@dune/core@0.34.14": { - "integrity": "73a43da4d74c507eb92078f91c62e17de91b1693022a4aa98f024acd5db8c224", - "dependencies": [ - "jsr:@db/sqlite", - "jsr:@fresh/core", - "jsr:@std/crypto", - "jsr:@std/encoding@1", - "jsr:@std/fs@1", - "jsr:@std/path@^1.1.4", - "jsr:@std/yaml", - "npm:gray-matter", - "npm:ioredis", - "npm:marked", - "npm:nodemailer", - "npm:polizy", - "npm:postgres", - "npm:preact-render-to-string@6", - "npm:preact@10" - ] - }, - "@dune/plugin-admin@3.0.0": { - "integrity": "48c7bc839a8fc4b7196a2ac5728f70c9cf4ce7bc4cf8facd7d2101e44fa70e0d", - "dependencies": [ - "jsr:@dune/core", - "jsr:@fresh/core", - "jsr:@std/encoding@1", - "jsr:@std/path@^1.1.4", - "jsr:@std/yaml", - "jsr:@zip-js/zip-js", - "npm:@types/nodemailer", - "npm:marked", - "npm:nodemailer", - "npm:preact-render-to-string@6", - "npm:preact@10" - ] - }, "@fresh/build-id@1.0.1": { "integrity": "12a2ec25fd52ae9ec68c26848a5696cd1c9b537f7c983c7e56e4fb1e7e816c20", "dependencies": [ @@ -134,43 +63,22 @@ "@fresh/core@2.3.3": { "integrity": "6c2aad199976644352c89a1d601f3ef331b1d24cdadb53ed469324a303598383", "dependencies": [ - "jsr:@deno/esbuild-plugin", "jsr:@fresh/build-id", - "jsr:@std/encoding@^1.0.10", "jsr:@std/fmt@^1.0.8", "jsr:@std/fs@^1.0.19", "jsr:@std/html", "jsr:@std/http", - "jsr:@std/jsonc", - "jsr:@std/media-types", "jsr:@std/net", "jsr:@std/path@^1.1.2", - "jsr:@std/semver@^1.0.6", - "jsr:@std/uuid", "npm:@opentelemetry/api", "npm:@preact/signals", - "npm:esbuild-wasm", - "npm:esbuild@0.25.7", "npm:preact-render-to-string@^6.6.3", - "npm:preact@^10.28.2", "npm:preact@^10.29.1" ] }, "@std/assert@0.217.0": { "integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642" }, - "@std/assert@1.0.19": { - "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", - "dependencies": [ - "jsr:@std/internal@^1.0.12" - ] - }, - "@std/bytes@1.0.6": { - "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" - }, - "@std/crypto@1.1.0": { - "integrity": "b8d6d0a6377a32b213af2661ed7bf1062d94feac0c57def5526a8e74a95c3ec8" - }, "@std/encoding@1.0.11": { "integrity": "e7cef2f0b3153bccc17431e7c864a1de03a4b6d9647389c43e08aaa775d37385" }, @@ -180,63 +88,36 @@ "@std/fs@1.0.24": { "integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877", "dependencies": [ - "jsr:@std/internal@^1.0.14", + "jsr:@std/internal", "jsr:@std/path@^1.1.5" ] }, "@std/html@1.0.7": { "integrity": "175c818905a6e75743c69c251395e273d82698e58cfe7dd76268d70e28b8d8fe" }, - "@std/http@1.1.3": { - "integrity": "73a98e05ff58aa18bea562d286172e6c815b1eddd4fcab194beda19e3683b88c", - "dependencies": [ - "jsr:@std/encoding@^1.0.11" - ] + "@std/http@1.1.4": { + "integrity": "4f1575226ca7c0df1df38b26d50265fed30869b2a63ab867edc5b92492150c8c" }, "@std/internal@1.0.14": { "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" }, - "@std/json@1.1.0": { - "integrity": "93330d3ed4054d6b1ffe54b075432c80a5f671be77277b978931e018014cb1cc" - }, - "@std/jsonc@1.0.2": { - "integrity": "909605dae3af22bd75b1cbda8d64a32cf1fd2cf6efa3f9e224aba6d22c0f44c7", - "dependencies": [ - "jsr:@std/json" - ] - }, - "@std/media-types@1.1.0": { - "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" - }, - "@std/net@1.0.6": { - "integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c" + "@std/net@1.0.7": { + "integrity": "e0b995738cfe2ed56f64591d400e55f143cff164efd690343f2deb4cf4d06d86" }, "@std/path@0.217.0": { "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11", "dependencies": [ - "jsr:@std/assert@0.217" + "jsr:@std/assert" ] }, "@std/path@1.1.6": { "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", "dependencies": [ - "jsr:@std/internal@^1.0.14" - ] - }, - "@std/semver@1.0.8": { - "integrity": "dc830e8b8b6a380c895d53fbfd1258dc253704ca57bbe1629ac65fd7830179b7" - }, - "@std/uuid@1.1.1": { - "integrity": "7b49060282d90f72fec64952f8c7a2914cbe6fdba3eca665f8cedc90830154a7", - "dependencies": [ - "jsr:@std/bytes" + "jsr:@std/internal" ] }, - "@std/yaml@1.2.0": { - "integrity": "20beb41e4983ba3437dbefac62b14061ab058e8a187596f19d28ff9035f6e6cf" - }, - "@zip-js/zip-js@2.8.61": { - "integrity": "8da37318aeefa76faa915058722ab8d1ee51e068edc8be69ea0810e01d724955" + "@std/yaml@1.3.0": { + "integrity": "b250103854378b8b36cbff0fe95aec017d3d9ce86b489c67605180c602fb4922" } }, "npm": { @@ -246,136 +127,6 @@ "tslib" ] }, - "@esbuild/aix-ppc64@0.25.7": { - "integrity": "sha512-uD0kKFHh6ETr8TqEtaAcV+dn/2qnYbH/+8wGEdY70Qf7l1l/jmBUbrmQqwiPKAQE6cOQ7dTj6Xr0HzQDGHyceQ==", - "os": ["aix"], - "cpu": ["ppc64"] - }, - "@esbuild/android-arm64@0.25.7": { - "integrity": "sha512-p0ohDnwyIbAtztHTNUTzN5EGD/HJLs1bwysrOPgSdlIA6NDnReoVfoCyxG6W1d85jr2X80Uq5KHftyYgaK9LPQ==", - "os": ["android"], - "cpu": ["arm64"] - }, - "@esbuild/android-arm@0.25.7": { - "integrity": "sha512-Jhuet0g1k9rAJHrXGIh7sFknFuT4sfytYZpZpuZl7YKDhnPByVAm5oy2LEBmMbuYf3ejWVYCc2seX81Mk+madA==", - "os": ["android"], - "cpu": ["arm"] - }, - "@esbuild/android-x64@0.25.7": { - "integrity": "sha512-mMxIJFlSgVK23HSsII3ZX9T2xKrBCDGyk0qiZnIW10LLFFtZLkFD6imZHu7gUo2wkNZwS9Yj3mOtZD3ZPcjCcw==", - "os": ["android"], - "cpu": ["x64"] - }, - "@esbuild/darwin-arm64@0.25.7": { - "integrity": "sha512-jyOFLGP2WwRwxM8F1VpP6gcdIJc8jq2CUrURbbTouJoRO7XCkU8GdnTDFIHdcifVBT45cJlOYsZ1kSlfbKjYUQ==", - "os": ["darwin"], - "cpu": ["arm64"] - }, - "@esbuild/darwin-x64@0.25.7": { - "integrity": "sha512-m9bVWqZCwQ1BthruifvG64hG03zzz9gE2r/vYAhztBna1/+qXiHyP9WgnyZqHgGeXoimJPhAmxfbeU+nMng6ZA==", - "os": ["darwin"], - "cpu": ["x64"] - }, - "@esbuild/freebsd-arm64@0.25.7": { - "integrity": "sha512-Bss7P4r6uhr3kDzRjPNEnTm/oIBdTPRNQuwaEFWT/uvt6A1YzK/yn5kcx5ZxZ9swOga7LqeYlu7bDIpDoS01bA==", - "os": ["freebsd"], - "cpu": ["arm64"] - }, - "@esbuild/freebsd-x64@0.25.7": { - "integrity": "sha512-S3BFyjW81LXG7Vqmr37ddbThrm3A84yE7ey/ERBlK9dIiaWgrjRlre3pbG7txh1Uaxz8N7wGGQXmC9zV+LIpBQ==", - "os": ["freebsd"], - "cpu": ["x64"] - }, - "@esbuild/linux-arm64@0.25.7": { - "integrity": "sha512-HfQZQqrNOfS1Okn7PcsGUqHymL1cWGBslf78dGvtrj8q7cN3FkapFgNA4l/a5lXDwr7BqP2BSO6mz9UremNPbg==", - "os": ["linux"], - "cpu": ["arm64"] - }, - "@esbuild/linux-arm@0.25.7": { - "integrity": "sha512-JZMIci/1m5vfQuhKoFXogCKVYVfYQmoZJg8vSIMR4TUXbF+0aNlfXH3DGFEFMElT8hOTUF5hisdZhnrZO/bkDw==", - "os": ["linux"], - "cpu": ["arm"] - }, - "@esbuild/linux-ia32@0.25.7": { - "integrity": "sha512-9Jex4uVpdeofiDxnwHRgen+j6398JlX4/6SCbbEFEXN7oMO2p0ueLN+e+9DdsdPLUdqns607HmzEFnxwr7+5wQ==", - "os": ["linux"], - "cpu": ["ia32"] - }, - "@esbuild/linux-loong64@0.25.7": { - "integrity": "sha512-TG1KJqjBlN9IHQjKVUYDB0/mUGgokfhhatlay8aZ/MSORMubEvj/J1CL8YGY4EBcln4z7rKFbsH+HeAv0d471w==", - "os": ["linux"], - "cpu": ["loong64"] - }, - "@esbuild/linux-mips64el@0.25.7": { - "integrity": "sha512-Ty9Hj/lx7ikTnhOfaP7ipEm/ICcBv94i/6/WDg0OZ3BPBHhChsUbQancoWYSO0WNkEiSW5Do4febTTy4x1qYQQ==", - "os": ["linux"], - "cpu": ["mips64el"] - }, - "@esbuild/linux-ppc64@0.25.7": { - "integrity": "sha512-MrOjirGQWGReJl3BNQ58BLhUBPpWABnKrnq8Q/vZWWwAB1wuLXOIxS2JQ1LT3+5T+3jfPh0tyf5CpbyQHqnWIQ==", - "os": ["linux"], - "cpu": ["ppc64"] - }, - "@esbuild/linux-riscv64@0.25.7": { - "integrity": "sha512-9pr23/pqzyqIZEZmQXnFyqp3vpa+KBk5TotfkzGMqpw089PGm0AIowkUppHB9derQzqniGn3wVXgck19+oqiOw==", - "os": ["linux"], - "cpu": ["riscv64"] - }, - "@esbuild/linux-s390x@0.25.7": { - "integrity": "sha512-4dP11UVGh9O6Y47m8YvW8eoA3r8qL2toVZUbBKyGta8j6zdw1cn9F/Rt59/Mhv0OgY68pHIMjGXWOUaykCnx+w==", - "os": ["linux"], - "cpu": ["s390x"] - }, - "@esbuild/linux-x64@0.25.7": { - "integrity": "sha512-ghJMAJTdw/0uhz7e7YnpdX1xVn7VqA0GrWrAO2qKMuqbvgHT2VZiBv1BQ//VcHsPir4wsL3P2oPggfKPzTKoCA==", - "os": ["linux"], - "cpu": ["x64"] - }, - "@esbuild/netbsd-arm64@0.25.7": { - "integrity": "sha512-bwXGEU4ua45+u5Ci/a55B85KWaDSRS8NPOHtxy2e3etDjbz23wlry37Ffzapz69JAGGc4089TBo+dGzydQmydg==", - "os": ["netbsd"], - "cpu": ["arm64"] - }, - "@esbuild/netbsd-x64@0.25.7": { - "integrity": "sha512-tUZRvLtgLE5OyN46sPSYlgmHoBS5bx2URSrgZdW1L1teWPYVmXh+QN/sKDqkzBo/IHGcKcHLKDhBeVVkO7teEA==", - "os": ["netbsd"], - "cpu": ["x64"] - }, - "@esbuild/openbsd-arm64@0.25.7": { - "integrity": "sha512-bTJ50aoC+WDlDGBReWYiObpYvQfMjBNlKztqoNUL0iUkYtwLkBQQeEsTq/I1KyjsKA5tyov6VZaPb8UdD6ci6Q==", - "os": ["openbsd"], - "cpu": ["arm64"] - }, - "@esbuild/openbsd-x64@0.25.7": { - "integrity": "sha512-TA9XfJrgzAipFUU895jd9j2SyDh9bbNkK2I0gHcvqb/o84UeQkBpi/XmYX3cO1q/9hZokdcDqQxIi6uLVrikxg==", - "os": ["openbsd"], - "cpu": ["x64"] - }, - "@esbuild/openharmony-arm64@0.25.7": { - "integrity": "sha512-5VTtExUrWwHHEUZ/N+rPlHDwVFQ5aME7vRJES8+iQ0xC/bMYckfJ0l2n3yGIfRoXcK/wq4oXSItZAz5wslTKGw==", - "os": ["openharmony"], - "cpu": ["arm64"] - }, - "@esbuild/sunos-x64@0.25.7": { - "integrity": "sha512-umkbn7KTxsexhv2vuuJmj9kggd4AEtL32KodkJgfhNOHMPtQ55RexsaSrMb+0+jp9XL4I4o2y91PZauVN4cH3A==", - "os": ["sunos"], - "cpu": ["x64"] - }, - "@esbuild/win32-arm64@0.25.7": { - "integrity": "sha512-j20JQGP/gz8QDgzl5No5Gr4F6hurAZvtkFxAKhiv2X49yi/ih8ECK4Y35YnjlMogSKJk931iNMcd35BtZ4ghfw==", - "os": ["win32"], - "cpu": ["arm64"] - }, - "@esbuild/win32-ia32@0.25.7": { - "integrity": "sha512-4qZ6NUfoiiKZfLAXRsvFkA0hoWVM+1y2bSHXHkpdLAs/+r0LgwqYohmfZCi985c6JWHhiXP30mgZawn/XrqAkQ==", - "os": ["win32"], - "cpu": ["ia32"] - }, - "@esbuild/win32-x64@0.25.7": { - "integrity": "sha512-FaPsAHTwm+1Gfvn37Eg3E5HIpfR3i6x1AIcla/MkqAIupD4BW3MrSeUqfoTzwwJhk3WE2/KqUn4/eenEJC76VA==", - "os": ["win32"], - "cpu": ["x64"] - }, "@img/sharp-darwin-arm64@0.33.5": { "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", "optionalDependencies": [ @@ -530,14 +281,14 @@ "vfile" ] }, - "@opentelemetry/api@1.9.1": { - "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==" + "@opentelemetry/api@1.10.0": { + "integrity": "sha512-lMkyes8iHrG+mk6WH7X70MdCLI2RS0FwNBY1PyQ8UemaijE2m5mv5A0Niy3HtbYTFrgkUEQjFMXmSAk2hQDuPQ==" }, - "@preact/signals-core@1.14.4": { - "integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==" + "@preact/signals-core@1.14.5": { + "integrity": "sha512-vcRv7Ityz5JVmeiVqIluLFzT/PDS/AlhHUpw3/aUWqcVUs6bDGIPXE/6RSC4evBNWYvnniozg5Syfj33al3gkg==" }, - "@preact/signals@2.11.1_preact@10.29.8__preact-render-to-string@6.7.0": { - "integrity": "sha512-uYoD+USkacTNU02kfCBkJEV7xabKTmA78W5pnT2GymsuGEC9n/ZO+UT4S96l0dIL6KLDk77VJyUFuOMBvftVHg==", + "@preact/signals@2.11.4_preact@10.29.8__preact-render-to-string@6.8.2_preact-render-to-string@6.8.2__preact@10.29.8": { + "integrity": "sha512-Rb+5RPo9gWSaHtzJeauhHxiC8KP/IF2DEPkHPEOGOnuy2wnJzRqyeLRankUCYd6ADqtxS6GnWGv6Ie343d8aYw==", "dependencies": [ "@preact/signals-core", "preact" @@ -576,18 +327,6 @@ "@types/ms@2.1.0": { "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" }, - "@types/node@26.4.0": { - "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", - "dependencies": [ - "undici-types" - ] - }, - "@types/nodemailer@6.4.24": { - "integrity": "sha512-Ww4u0rT9wQNXh4JiQaIwx3QWdcOFXzOjQA2zc+jtFYNmQiT4mIUqcDin51bDFdkzKubFnQCZNK7FIHlPKQ/q9w==", - "dependencies": [ - "@types/node" - ] - }, "@types/unist@2.0.11": { "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==" }, @@ -712,43 +451,6 @@ "vfile-message" ] }, - "esbuild-wasm@0.25.12": { - "integrity": "sha512-rZqkjL3Y6FwLpSHzLnaEy8Ps6veCNo1kZa9EOfJvmWtBq5dJH4iVjfmOO6Mlkv9B0tt9WFPFmb/VxlgJOnueNg==", - "bin": true - }, - "esbuild@0.25.7": { - "integrity": "sha512-daJB0q2dmTzo90L9NjRaohhRWrCzYxWNFTjEi72/h+p5DcY3yn4MacWfDakHmaBaDzDiuLJsCh0+6LK/iX+c+Q==", - "optionalDependencies": [ - "@esbuild/aix-ppc64", - "@esbuild/android-arm", - "@esbuild/android-arm64", - "@esbuild/android-x64", - "@esbuild/darwin-arm64", - "@esbuild/darwin-x64", - "@esbuild/freebsd-arm64", - "@esbuild/freebsd-x64", - "@esbuild/linux-arm", - "@esbuild/linux-arm64", - "@esbuild/linux-ia32", - "@esbuild/linux-loong64", - "@esbuild/linux-mips64el", - "@esbuild/linux-ppc64", - "@esbuild/linux-riscv64", - "@esbuild/linux-s390x", - "@esbuild/linux-x64", - "@esbuild/netbsd-arm64", - "@esbuild/netbsd-x64", - "@esbuild/openbsd-arm64", - "@esbuild/openbsd-x64", - "@esbuild/openharmony-arm64", - "@esbuild/sunos-x64", - "@esbuild/win32-arm64", - "@esbuild/win32-ia32", - "@esbuild/win32-x64" - ], - "scripts": true, - "bin": true - }, "escape-string-regexp@5.0.0": { "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==" }, @@ -811,8 +513,8 @@ "extend@3.0.2": { "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, - "fast-equals@5.4.1": { - "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==" + "fast-equals@5.4.5": { + "integrity": "sha512-yKEpcFuRPu/pTQL2VB8YKE2yFldrLlIsoWtJeBnAdqEhvu21OgkFT0C8njf/xYc2LSZGEr3K9JGjthHJxnVLvQ==" }, "gray-matter@4.0.3": { "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", @@ -1440,9 +1142,6 @@ "ms@2.1.3": { "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, - "nodemailer@6.10.1": { - "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==" - }, "parse-entities@4.0.2": { "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", "dependencies": [ @@ -1455,8 +1154,8 @@ "is-hexadecimal" ] }, - "polizy@0.6.0": { - "integrity": "sha512-yoTY8wmuydsMOXdDb7RntkecXQAT9kjxr/0XXpnzEJe2hgjrGo1QqEg4WG7skfzK80fbY2TeQB+z9pblgzm7cw==", + "polizy@0.6.1": { + "integrity": "sha512-YxhcG5Qb3Oms9XVqxnwTUdmL5rLTn0GqTgA3HI11yuQLIT+tIS7xTnyE2aAjuTFZRtohocTPZmx3UuNcDH2GZQ==", "dependencies": [ "fast-equals", "tslib" @@ -1465,13 +1164,13 @@ "postgres@3.4.9": { "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==" }, - "preact-render-to-string@6.7.0_preact@10.29.8": { - "integrity": "sha512-Z4WR8fmLMRpdYqJ9i7vrlXSsSrxVJydwrkEXHapexfARbWfGb7vGcnvNQnIzN0cXciMVOlz/XLoiMCi9gUsy9Q==", + "preact-render-to-string@6.8.2_preact@10.29.8": { + "integrity": "sha512-Hdx7cKD3AMYgt795TdddBpUbuGEFoL/VeA6s7VrBt9Gjt0r6rCy5wMp5xkDjQ9cbKj62frHsEx1x8yV6EjoOpg==", "dependencies": [ "preact" ] }, - "preact@10.29.8_preact-render-to-string@6.7.0": { + "preact@10.29.8_preact-render-to-string@6.8.2": { "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", "dependencies": [ "preact-render-to-string" @@ -1672,9 +1371,6 @@ "tslib@2.8.1": { "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, - "undici-types@8.3.0": { - "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==" - }, "unified@11.0.5": { "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "dependencies": [ @@ -1850,7 +1546,7 @@ "workspace": { "dependencies": [ "jsr:@db/sqlite@0.12", - "jsr:@dune/plugin-admin@3", + "jsr:@dune/plugin-admin@*", "jsr:@fresh/core@2", "jsr:@std/assert@1", "jsr:@std/crypto@1", diff --git a/src/auth/authz-schema.ts b/src/auth/authz-schema.ts index 80a1714a..39bc3a05 100644 --- a/src/auth/authz-schema.ts +++ b/src/auth/authz-schema.ts @@ -33,7 +33,7 @@ import { defineSchema } from "polizy"; import type { AuthSchema } from "polizy"; -import type { Role } from "../config/admin-config.ts"; +import type { Role } from "../config/role.ts"; /** * A polizy relation name a plugin-contributed action can require. Limited diff --git a/src/cli/add-admin-plugin.ts b/src/cli/add-admin-plugin.ts index 3523866a..0ea8c247 100644 --- a/src/cli/add-admin-plugin.ts +++ b/src/cli/add-admin-plugin.ts @@ -9,7 +9,7 @@ * @module */ -import { parse as parseYaml } from "@std/yaml"; +import { parse as parseYaml, stringify as stringifyYaml } from "@std/yaml"; /** The specifier written for the admin: a major range, which the plugin pin rule accepts. */ export const ADMIN_PLUGIN_ENTRY_SRC = "jsr:@dune/plugin-admin@^4"; @@ -81,3 +81,37 @@ export function addAdminPluginToText(text: string): AddAdminResult { lines.splice(last + 1, 0, `${itemIndent}- ${entry}`); return { text: lines.join("\n"), outcome: "added" }; } + +export interface SetConfigResult { + text: string; + /** `set`: written under the entry; `none`: nothing to write; `manual`: no admin entry found, or it already has a `config:`. */ + outcome: "set" | "none" | "manual"; +} + +/** + * Write `config:` under the admin's entry in a site.yaml text (the admin's + * settings are its plugin config since 0.36). Refuses to merge into an + * existing `config:` block. + */ +export function setAdminPluginConfigInText(text: string, settings: Record): SetConfigResult { + if (Object.keys(settings).length === 0) return { text, outcome: "none" }; + const lines = text.split("\n"); + const idx = lines.findIndex((l) => /^\s*(-\s+)?src:\s*\S*plugin-admin/.test(l)); + if (idx < 0) return { text, outcome: "manual" }; + const m = lines[idx].match(/^(\s*)(-\s+)?src:/)!; + const keyIndent = m[1].length + (m[2]?.length ?? 0); + + let last = idx; + for (let i = idx + 1; i < lines.length; i++) { + const l = lines[i]; + if (/^\s*(#.*)?$/.test(l)) continue; + const ind = l.match(/^(\s*)/)![1].length; + if (ind < keyIndent) break; + if (ind === keyIndent && /^config\s*:/.test(l.trim())) return { text, outcome: "manual" }; + last = i; + } + const pad = " ".repeat(keyIndent); + const body = stringifyYaml(settings).trimEnd().split("\n").map((l) => `${pad} ${l}`); + lines.splice(last + 1, 0, `${pad}config:`, ...body); + return { text: lines.join("\n"), outcome: "set" }; +} diff --git a/src/cli/migrate-config.ts b/src/cli/migrate-config.ts index 20458763..aa82be1d 100644 --- a/src/cli/migrate-config.ts +++ b/src/cli/migrate-config.ts @@ -27,7 +27,7 @@ */ import { join, resolve } from "@std/path"; -import { parse as parseYaml } from "@std/yaml"; +import { parse as parseYaml, stringify as stringifyYaml } from "@std/yaml"; import { type ConfigFileKind, describeLegacyKeys, @@ -35,11 +35,12 @@ import { findIgnoredKeys, findLegacyKeys, findMovedKeys, + removeAdminBlockInText, removeAdminEnabledInText, removeIgnoredKeysInText, renameLegacyKeysInText, } from "../config/legacy-keys.ts"; -import { ADMIN_PLUGIN_ENTRY_SRC, addAdminPluginToText } from "./add-admin-plugin.ts"; +import { ADMIN_PLUGIN_ENTRY_SRC, addAdminPluginToText, setAdminPluginConfigInText } from "./add-admin-plugin.ts"; export interface MigrateConfigOptions { dryRun?: boolean; @@ -61,6 +62,39 @@ async function candidateFiles(root: string): Promise<{ path: string; kind: Confi return files; } +/** The keys of the old `admin:` block that are not the admin plugin's settings. */ +const LIFTED: Record = { + runtime_dir: "runtime_dir", + runtimeDir: "runtime_dir", + max_revisions: "max_revisions", + maxRevisions: "max_revisions", + data_dir: "data_dir", + dataDir: "data_dir", +}; +/** Keys that moved to other files and need a human (see their messages). */ +const ELSEWHERE = ["honeypot", "notifications", "incoming_webhooks", "incomingWebhooks"]; + +export interface AdminMovePlan { + /** What goes under the admin plugin's `config:`. */ + settings: Record; + /** What goes to the top level of system.yaml. */ + lifted: Record; + /** Keys that cannot be moved automatically. */ + blocked: string[]; +} + +export function planAdminMove(block: unknown): AdminMovePlan { + const plan: AdminMovePlan = { settings: {}, lifted: {}, blocked: [] }; + if (!block || typeof block !== "object" || Array.isArray(block)) return plan; + for (const [key, value] of Object.entries(block as Record)) { + if (key in LIFTED) plan.lifted[LIFTED[key]] = value; + else if (key === "authz_store" || key === "authzStore" || key === "enabled") continue; // removed (handled separately) + else if (ELSEWHERE.includes(key)) plan.blocked.push(`admin.${key}`); + else plan.settings[key] = value; + } + return plan; +} + export async function migrateConfigCommand( root: string, opts: MigrateConfigOptions = {}, @@ -100,7 +134,12 @@ export async function migrateConfigCommand( } } } - const moved = findMovedKeys(kind, parsedBefore).filter((k) => !(opts.dryRun && k.path === "admin.enabled")); + // In the base system.yaml the admin: block is moved wholesale below, so its + // keys (and the ones we lift or drop) are not reported as "move by hand". + const handledBelow = path === join(root, "config", "system.yaml") + ? (k: { path: string }) => k.path === "admin" || (k.path.startsWith("admin.") && !ELSEWHERE.some((e) => k.path === `admin.${e}`)) + : (k: { path: string }) => opts.dryRun === true && k.path === "admin.enabled"; + const moved = findMovedKeys(kind, parsedBefore).filter((k) => !handledBelow(k)); if (moved.length > 0) { movedFiles++; console.error( @@ -141,12 +180,80 @@ export async function migrateConfigCommand( if (!opts.dryRun) await Deno.writeTextFile(path, result.text); } + // The admin: block of system.yaml. Its settings are the admin plugin's config + // now (`config:` under its entry in site.yaml); runtime_dir, max_revisions and + // data_dir go to the top level of system.yaml; the block is deleted. + const systemPath = join(root, "config", "system.yaml"); const sitePath = join(root, "config", "site.yaml"); + let systemText: string | null = null; let siteText: string | null = null; + try { + systemText = await Deno.readTextFile(systemPath); + } catch { /* no system.yaml */ } try { siteText = await Deno.readTextFile(sitePath); } catch { /* no site.yaml */ } - if (siteText !== null && !adminWasDisabled) { + + // Same renames the loop above applied (it did not write them on a dry run). + if (systemText !== null) systemText = renameLegacyKeysInText("system", systemText).text; + const adminBlock = systemText === null + ? undefined + : (parseYaml(systemText) as { admin?: unknown } | null)?.admin; + const hasBlock = adminBlock !== undefined; + + if (hasBlock && systemText !== null) { + if (adminWasDisabled) { + const removed = removeAdminBlockInText(systemText); + if (removed.removed) { + changed++; + console.log(" ✓ config/system.yaml: removed the admin: block (the admin is not loaded)"); + if (!opts.dryRun) await Deno.writeTextFile(systemPath, removed.text); + } + } else { + const plan = planAdminMove(adminBlock); + if (plan.blocked.length > 0 || siteText === null) { + unresolvedFiles++; + console.error( + ` ✗ config/system.yaml: move the admin: block by hand — ` + + (siteText === null + ? "there is no config/site.yaml to hold the admin plugin's config" + : `${plan.blocked.join(", ")} belong${plan.blocked.length === 1 ? "s" : ""} elsewhere (see the messages above)`), + ); + } else { + // 1. the admin entry in plugins:, 2. the settings under its config: + const added = addAdminPluginToText(siteText); + const withConfig = added.outcome === "manual" + ? { text: siteText, outcome: "manual" as const } + : setAdminPluginConfigInText(added.text, plan.settings); + const removed = removeAdminBlockInText(systemText); + if (added.outcome === "manual" || withConfig.outcome === "manual" || !removed.removed) { + unresolvedFiles++; + console.error( + ` ✗ config/system.yaml: move the admin: block by hand into the admin plugin's config: in site.yaml ` + + `(plugins: is written in a way this command cannot extend, or the entry already has a config:)`, + ); + } else { + let nextSystem = removed.text; + if (Object.keys(plan.lifted).length > 0) { + nextSystem = nextSystem.replace(/\n*$/, "\n") + stringifyYaml(plan.lifted); + } + changed++; + const moved = Object.keys(plan.settings); + console.log( + ` ✓ config/system.yaml: moved the admin: block into the admin plugin's config:` + + (moved.length > 0 ? ` (${moved.join(", ")})` : "") + + (Object.keys(plan.lifted).length > 0 ? `; ${Object.keys(plan.lifted).join(", ")} now at the top level` : ""), + ); + if (added.outcome === "added") console.log(` ✓ config/site.yaml: added ${ADMIN_PLUGIN_ENTRY_SRC} to plugins:`); + if (!opts.dryRun) { + await Deno.writeTextFile(systemPath, nextSystem); + await Deno.writeTextFile(sitePath, withConfig.text); + } + siteText = null; // handled + } + } + } + } else if (siteText !== null && !adminWasDisabled) { const added = addAdminPluginToText(siteText); if (added.outcome === "added") { changed++; @@ -158,7 +265,8 @@ export async function migrateConfigCommand( ` ✗ config/site.yaml: add the admin by hand under plugins:\n - src: ${ADMIN_PLUGIN_ENTRY_SRC}`, ); } - } else if (adminWasDisabled) { + } + if (adminWasDisabled) { console.log(" • admin.enabled was false: the admin plugin is not added. (Running this command again would add it.)"); } diff --git a/src/config/admin-config.ts b/src/config/admin-config.ts deleted file mode 100644 index d226a01b..00000000 --- a/src/config/admin-config.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Admin panel configuration types. - */ - -/** - * User role — inlined here so that config does not depend on the admin - * plugin package. Bare name (no "Admin" prefix) per - * decisions/dec-identity-unification.md's Phase 3: role/identity concepts - * aren't admin-panel-specific, even though this type currently only gates - * admin-panel access — @dune/plugin-admin's role field uses this same type. - */ -export type Role = "admin" | "editor" | "author"; - -/** - * Auth provider configuration. Only the built-in local password auth is - * configurable here. LDAP and SAML were declared for a long time but never - * implemented (their providers threw), so they are gone; to use another - * identity source, inject your own provider through the admin plugin's - * `authProvider` option. - */ -export type AuthProviderConfig = { type: "local" }; - -/** HTTP webhook notification. */ -export interface WebhookNotificationConfig { - /** URL to POST the submission JSON to. */ - url: string; - /** - * Optional secret used to sign the request body. - * Adds X-Dune-Signature: sha256= header so the receiver can verify authenticity. - * Supports "$ENV_VAR" expansion. - */ - secret?: string; - /** - * Allow delivery to private/loopback/link-local addresses. Defaults to - * false. Only set true for documented same-network use cases (e.g. an - * internal CI bot at 10.x). Without this opt-in, the SSRF guard refuses - * cloud-metadata, container-orchestrator, and intranet targets. - */ - allow_private?: boolean; -} - -/** - * Content event types that can trigger outbound webhooks. - * Each corresponds to a hook fired by the admin panel after a CRUD operation. - */ -export type WebhookContentEvent = - | "onPageCreate" - | "onPageUpdate" - | "onPageDelete" - | "onPageMove" - | "onWorkflowChange"; - -/** - * A single outbound webhook endpoint configuration. - * Multiple endpoints can be configured under `admin.webhooks`. - */ -export interface WebhookEndpointConfig { - /** URL to POST the event payload to. */ - url: string; - /** - * Secret used to sign the request body (HMAC-SHA256). - * Adds X-Dune-Signature: sha256= header. - * Supports "$ENV_VAR" expansion. - */ - secret?: string; - /** - * Which content events should trigger this endpoint. - * @example ["onPageCreate", "onPageUpdate"] - */ - events: WebhookContentEvent[]; - /** Whether this endpoint is active (default: true). */ - enabled?: boolean; - /** Optional human-readable label shown in delivery logs. */ - label?: string; - /** Allow delivery to private/loopback/link-local addresses. Default false. */ - allow_private?: boolean; -} - -/** Admin panel configuration */ -export interface AdminConfig { - /** Admin panel route prefix (default: "/admin") */ - path: string; - /** Session lifetime in seconds (default: 86400 = 24h) */ - session_lifetime: number; - /** - * Maximum upload body size (in MB) for admin-side multipart uploads - * (media library, content attachments). Rejected with 413 before the body - * is buffered into memory. Public form submissions are gated separately at - * a fixed 55 MB ceiling. Default: 100. - */ - max_upload_mb?: number; - /** - * Automatically create a git commit after every page save via the admin panel. - * Requires the site directory to be a git repository. - * Commit message: "Admin: update {sourcePath}" - * Default: false - */ - git_commit?: boolean; - /** - * Outbound webhook endpoints fired on content mutation events - * (page create, update, delete, workflow state change). - * Multiple endpoints can be configured; each has independent event filtering. - */ - webhooks?: WebhookEndpointConfig[]; - /** - * Authentication provider configuration. Omit it (or set `type: local`) - * for the built-in local password auth. LDAP and SAML are not supported; - * inject a custom provider through the admin plugin's `authProvider` option. - */ - auth_provider?: AuthProviderConfig; - /** - * Audit log configuration. - * Records admin panel actions with actor, timestamp, IP, and outcome. - */ - audit?: { - /** - * Enable audit logging (default: true). - */ - enabled?: boolean; - /** - * Path to the JSONL audit log file. - * Relative to runtimeDir, or absolute. - * Default: "{runtimeDir}/audit.log" - */ - log_file?: string; - }; -} diff --git a/src/config/defaults.ts b/src/config/defaults.ts index 16fc8b52..4938ca36 100644 --- a/src/config/defaults.ts +++ b/src/config/defaults.ts @@ -46,9 +46,4 @@ export const DEFAULT_CONFIG: DuneConfig = { pluginList: [], themeList: [], autoDiscoverPlugins: false, - admin: { - path: "/admin", - session_lifetime: 86400, - max_upload_mb: 100, - }, }; diff --git a/src/config/dune-config.ts b/src/config/dune-config.ts index 18111dea..74cbf24c 100644 --- a/src/config/dune-config.ts +++ b/src/config/dune-config.ts @@ -4,7 +4,6 @@ import type { SiteConfig } from "./site-config.ts"; import type { SystemConfig, ThemeConfig } from "./system-config.ts"; -import type { AdminConfig } from "./admin-config.ts"; /** * A single plugin entry declared in site.yaml under the `plugins:` key. @@ -109,6 +108,4 @@ export interface DuneConfig { * you have a trusted local workflow that expects drop-in plugin files. */ autoDiscoverPlugins?: boolean; - /** Admin panel configuration (optional — defaults applied if omitted) */ - admin?: AdminConfig; } diff --git a/src/config/forms-config.ts b/src/config/forms-config.ts index f77dcedb..a177fadb 100644 --- a/src/config/forms-config.ts +++ b/src/config/forms-config.ts @@ -14,7 +14,6 @@ * @module */ -import type { WebhookNotificationConfig } from "./admin-config.ts"; /** An email sent for each new submission. */ export interface FormEmailNotificationConfig { @@ -51,3 +50,22 @@ export interface IncomingWebhookConfig { /** Actions this token may trigger. */ actions: Array<"rebuild" | "purge-cache">; } + +/** HTTP webhook notification. */ +export interface WebhookNotificationConfig { + /** URL to POST the submission JSON to. */ + url: string; + /** + * Optional secret used to sign the request body. + * Adds X-Dune-Signature: sha256= header so the receiver can verify authenticity. + * Supports "$ENV_VAR" expansion. + */ + secret?: string; + /** + * Allow delivery to private/loopback/link-local addresses. Defaults to + * false. Only set true for documented same-network use cases (e.g. an + * internal CI bot at 10.x). Without this opt-in, the SSRF guard refuses + * cloud-metadata, container-orchestrator, and intranet targets. + */ + allow_private?: boolean; +} diff --git a/src/config/legacy-keys.ts b/src/config/legacy-keys.ts index 95adc5a9..2f959d8c 100644 --- a/src/config/legacy-keys.ts +++ b/src/config/legacy-keys.ts @@ -65,6 +65,14 @@ interface MovedRule { } const MOVED: MovedRule[] = [ + { + file: "system", + parent: [], + keys: { + admin: + "the admin's settings are the admin plugin's own config now: put them under `config:` of its entry in plugins: (site.yaml); `dune migrate:config` moves them", + }, + }, { file: "system", parent: ["admin"], @@ -165,6 +173,14 @@ export function removeAdminEnabledInText(text: string): { text: string; removed: return { text: r.text, removed: r.removed.length > 0 }; } +const ADMIN_BLOCK: MovedRule[] = [{ file: "system", parent: [], keys: { admin: "" } }]; + +/** Delete the top-level `admin:` block (key and children) from a system.yaml text. */ +export function removeAdminBlockInText(text: string): { text: string; removed: boolean } { + const r = removeKeysInText(ADMIN_BLOCK, "system", text); + return { text: r.text, removed: r.removed.length > 0 }; +} + function removeKeysInText( rules: MovedRule[], file: ConfigFileKind, diff --git a/src/config/loader.ts b/src/config/loader.ts index 372c8fa5..f51c3e63 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -216,15 +216,6 @@ export interface LoadConfigOptions { skipValidation?: boolean; } -/** Merge a system.yaml `admin:` block into the top-level `admin` config. */ -function mergeAdminYaml( - config: Record, - admin: unknown, -): Record { - if (!admin || typeof admin !== "object" || Array.isArray(admin)) return config; - return deepMerge(config, { admin: admin as Record }); -} - /** * Load and merge configuration from all sources. * Returns a fully resolved DuneConfig object. @@ -292,15 +283,9 @@ export async function loadConfig(options: LoadConfigOptions): Promise 0) { - config = deepMerge(config, { system: systemWithoutAdmin }); + if (Object.keys(systemYaml).length > 0) { + config = deepMerge(config, { system: systemYaml }); } - config = mergeAdminYaml(config, adminFromSystem); // --- Layer 3: Environment-specific overrides --- const envDir = `config/env/${environment}`; @@ -312,11 +297,9 @@ export async function loadConfig(options: LoadConfigOptions): Promise 0) { config = deepMerge(config, { site: envSiteYaml }); } - const { admin: adminFromEnvSystem, ...envSystemWithoutAdmin } = envSystemYaml; - if (Object.keys(envSystemWithoutAdmin).length > 0) { - config = deepMerge(config, { system: envSystemWithoutAdmin }); + if (Object.keys(envSystemYaml).length > 0) { + config = deepMerge(config, { system: envSystemYaml }); } - config = mergeAdminYaml(config, adminFromEnvSystem); // --- Layer 4: dune.config.ts --- if (!skipConfigTs) { diff --git a/src/config/role.ts b/src/config/role.ts new file mode 100644 index 00000000..e22a4e2b --- /dev/null +++ b/src/config/role.ts @@ -0,0 +1,14 @@ +/** + * The admin-tier role vocabulary. + * + * @module + */ + +/** + * User role — inlined here so that config does not depend on the admin + * plugin package. Bare name (no "Admin" prefix) per + * decisions/dec-identity-unification.md's Phase 3: role/identity concepts + * aren't admin-panel-specific, even though this type currently only gates + * admin-panel access — @dune/plugin-admin's role field uses this same type. + */ +export type Role = "admin" | "editor" | "author"; diff --git a/src/config/types.ts b/src/config/types.ts index 8d7d6159..9a690577 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -2,7 +2,7 @@ * Configuration type definitions for Dune CMS. * * Re-exports all public types from the focused sub-modules: - * - admin-config.ts — AdminConfig, Role, AuthProviderConfig, webhook/notification types + * - role.ts — Role * - site-config.ts — SiteConfig, HttpCacheRule * - system-config.ts — SystemConfig, ThemeConfig, ConfigSource, SiteEntry, MultisiteConfig * - dune-config.ts — DuneConfig, PluginEntry @@ -10,21 +10,14 @@ * @module */ -export type { - Role, - AuthProviderConfig, - /** Admin panel configuration */ - AdminConfig, - WebhookNotificationConfig, - WebhookContentEvent, - WebhookEndpointConfig, -} from "./admin-config.ts"; +export type { Role } from "./role.ts"; export type { FormsConfig, FormNotificationsConfig, FormEmailNotificationConfig, IncomingWebhookConfig, + WebhookNotificationConfig, } from "./forms-config.ts"; export type { diff --git a/src/config/validator.ts b/src/config/validator.ts index 3d49a602..8fedadce 100644 --- a/src/config/validator.ts +++ b/src/config/validator.ts @@ -40,52 +40,10 @@ export function validateConfig(config: DuneConfig): string[] { validateThemeList(config.themeList, errors); validatePlugins(config.plugins, errors); validatePluginList(config.pluginList, errors); - validateAdmin(config.admin, errors); return errors.map(formatError); } -// --- Admin validation --- - -const ADMIN_PATH_RE = /^\/[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/; - -function validateAdmin( - admin: DuneConfig["admin"], - errors: ValidationError[], -): void { - if (!admin) return; - if (admin.auth_provider !== undefined) { - const type = (admin.auth_provider as { type?: unknown }).type; - if (type !== "local") { - errors.push({ - path: "admin.auth_provider.type", - message: "only \"local\" is supported; LDAP and SAML were never implemented. " + - "Omit auth_provider for the built-in password auth, or inject a custom provider through the admin plugin's `authProvider` option", - got: type, - }); - } - } - if (admin.path !== undefined) { - if (typeof admin.path !== "string") { - errors.push({ - path: "admin.path", - message: "must be a string starting with a leading slash", - got: admin.path, - }); - } else if (!ADMIN_PATH_RE.test(admin.path)) { - // The middleware uses pathname.startsWith(admin.path) and slices on - // its length. Anything that isn't an unambiguous absolute path with - // safe characters and no trailing slash will desync that logic and - // either lock users out or expose admin routes outside the prefix. - errors.push({ - path: "admin.path", - message: 'must start with "/", contain only [a-zA-Z0-9_-] segments, and have no trailing slash (e.g. "/admin", "/cms/admin")', - got: admin.path, - }); - } - } -} - // --- Site validation --- function validateSite(site: SiteConfig, errors: ValidationError[]): void { diff --git a/src/mcp/resources.ts b/src/mcp/resources.ts index f9b47a9d..1d108492 100644 --- a/src/mcp/resources.ts +++ b/src/mcp/resources.ts @@ -5,11 +5,9 @@ * or read on demand. Unlike tools, resources represent persistent state. */ -import { join } from "@std/path"; import type { McpResource, McpResourceContent, ResourceHandler } from "./server.ts"; import type { DuneEngine } from "../core/engine.ts"; import type { StorageAdapter } from "../storage/types.ts"; -import { resolveRuntimeDir } from "../forms/config.ts"; // ── Resource: site config ──────────────────────────────────────────────────── @@ -33,11 +31,6 @@ function makeConfigHandler(engine: DuneEngine): ResourceHandler { }, theme: config.theme, data_dir: config.system?.data_dir, - admin: { - runtime_dir: resolveRuntimeDir(config), - audit: config.admin?.audit, - // Omit auth_provider details (may contain connection strings) - }, plugins: (config.pluginList ?? []).map((entry) => { const e = entry as { src?: string; spec?: string }; return e.src ?? e.spec ?? "(unknown)"; @@ -224,41 +217,6 @@ function makeFormsHandler(storage: StorageAdapter, _root: string): ResourceHandl }; } -// ── Resource: audit log tail ────────────────────────────────────────────────── - -const AUDIT_LOG_RESOURCE: McpResource = { - uri: "dune://site/audit", - name: "Audit Log", - description: - "The 50 most recent audit log entries from the admin panel. " + - "Each entry includes event type, actor, target, outcome, and timestamp. " + - "Returns an empty array when audit logging is not enabled.", - mimeType: "application/json", -}; - -function makeAuditLogHandler(_storage: StorageAdapter, root: string): ResourceHandler { - return async () => { - // Audit log is a JSONL file; read the last 50 lines - const logPath = join(root, ".dune", "admin", "audit.log"); - const entries: unknown[] = []; - - try { - const raw = await Deno.readTextFile(logPath); - const lines = raw.trimEnd().split("\n").filter(Boolean); - const tail = lines.slice(-50); - for (const line of tail) { - try { entries.push(JSON.parse(line)); } catch { /* skip */ } - } - } catch { /* log file absent or unreadable */ } - - return { - uri: AUDIT_LOG_RESOURCE.uri, - mimeType: "application/json", - text: JSON.stringify(entries, null, 2), - }; - }; -} - // ── Registration ───────────────────────────────────────────────────────────── /** A registered MCP resource — descriptor plus its handler function. */ @@ -282,10 +240,7 @@ export function buildResources( ]; if (storage && root) { - base.push( - { meta: FORMS_RESOURCE, handler: makeFormsHandler(storage, root) }, - { meta: AUDIT_LOG_RESOURCE, handler: makeAuditLogHandler(storage, root) }, - ); + base.push({ meta: FORMS_RESOURCE, handler: makeFormsHandler(storage, root) }); } return base; diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index e709c454..dafd7f06 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -333,9 +333,6 @@ function makeGetConfigHandler(engine: DuneEngine): ToolHandler { }, debugMode: config.system.debug ?? false, }, - admin: { - auditEnabled: config.admin?.audit?.enabled !== false, - }, plugins: (config.pluginList ?? []).map((entry) => { const e = entry as { src?: string; spec?: string }; return e.src ?? e.spec ?? "(unknown)"; diff --git a/src/mod.ts b/src/mod.ts index 2111d50c..cb8c6ea4 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -91,7 +91,6 @@ export type { ThemeConfig, /** @since 0.36.0 */ TemplateConfig, - AdminConfig, } from "./config/types.ts"; // ── Content ──────────────────────────────────────────────────────────────── diff --git a/src/schema/config-schema.ts b/src/schema/config-schema.ts index 01c56b65..eb5bd01e 100644 --- a/src/schema/config-schema.ts +++ b/src/schema/config-schema.ts @@ -477,74 +477,5 @@ export const CONFIG_SCHEMA = { ], }, }, - - admin: { - type: "object", - description: "Admin panel settings", - properties: { - path: { - type: "string", - default: "/admin", - description: "Admin panel route prefix (e.g. /admin, /cms)", - }, - session_lifetime: { - type: "integer", - minimum: 0, - default: 86400, - description: "Admin session TTL in seconds", - }, - max_upload_mb: { type: "integer", minimum: 1, default: 100 }, - git_commit: { - type: "boolean", - default: false, - description: "Auto-commit page saves via git", - }, - audit: { - type: "object", - properties: { - enabled: { type: "boolean", default: true }, - log_file: { type: "string", description: "Path to JSONL audit log file" }, - }, - }, - auth_provider: { - type: "object", - description: - "Auth provider. Only the built-in local password auth is configurable (omit this, or type: local); LDAP and SAML are not supported. Inject a custom provider through the admin plugin's authProvider option.", - properties: { - type: { type: "string", enum: ["local"] }, - }, - required: ["type"], - additionalProperties: false, - }, - webhooks: { - type: "array", - description: "Outbound webhook endpoints for content events", - items: { - type: "object", - required: ["url", "events"], - properties: { - url: { type: "string", format: "uri" }, - secret: { type: "string" }, - events: { - type: "array", - items: { - type: "string", - enum: [ - "onPageCreate", - "onPageUpdate", - "onPageDelete", - "onPageMove", - "onWorkflowChange", - ], - }, - }, - enabled: { type: "boolean", default: true }, - label: { type: "string" }, - allow_private: { type: "boolean", default: false }, - }, - }, - }, - }, - }, }, } as const; diff --git a/tests/cli/add_admin_plugin_test.ts b/tests/cli/add_admin_plugin_test.ts index fcafec29..44532b18 100644 --- a/tests/cli/add_admin_plugin_test.ts +++ b/tests/cli/add_admin_plugin_test.ts @@ -1,6 +1,6 @@ import { assertEquals } from "@std/assert"; import { parse as parseYaml } from "@std/yaml"; -import { ADMIN_PLUGIN_ENTRY_SRC, addAdminPluginToText } from "../../src/cli/add-admin-plugin.ts"; +import { ADMIN_PLUGIN_ENTRY_SRC, addAdminPluginToText, setAdminPluginConfigInText } from "../../src/cli/add-admin-plugin.ts"; const SRC = ADMIN_PLUGIN_ENTRY_SRC; @@ -46,3 +46,24 @@ Deno.test("addAdminPluginToText: flow style with entries cannot be extended safe assertEquals(r.outcome, "manual"); assertEquals(r.text, text); }); + +Deno.test("setAdminPluginConfigInText: writes config: under the admin entry, leaving the rest alone", () => { + const text = `plugins:\n - src: ./a.ts\n - src: ${SRC}\n integrity: sha256:abc\n - src: ./b.ts\ntheme:\n name: x\n`; + const r = setAdminPluginConfigInText(text, { path: "/cms", audit: { enabled: false } }); + assertEquals(r.outcome, "set"); + const doc = parseYaml(r.text) as { plugins: Record[]; theme: { name: string } }; + assertEquals(doc.plugins[1], { src: SRC, integrity: "sha256:abc", config: { path: "/cms", audit: { enabled: false } } }); + assertEquals(doc.plugins[0], { src: "./a.ts" }); + assertEquals(doc.plugins[2], { src: "./b.ts" }); + assertEquals(doc.theme.name, "x"); +}); + +Deno.test("setAdminPluginConfigInText: nothing to write, no admin entry, or an existing config: are left alone", () => { + const text = `plugins:\n - src: ${SRC}\n`; + assertEquals(setAdminPluginConfigInText(text, {}), { text, outcome: "none" }); + assertEquals(setAdminPluginConfigInText("title: T\n", { path: "/cms" }).outcome, "manual"); + const withConfig = `plugins:\n - src: ${SRC}\n config:\n path: /x\n`; + const r = setAdminPluginConfigInText(withConfig, { path: "/cms" }); + assertEquals(r.outcome, "manual"); + assertEquals(r.text, withConfig); +}); diff --git a/tests/cli/migrate_config_test.ts b/tests/cli/migrate_config_test.ts index eab4ea40..dfee3c10 100644 --- a/tests/cli/migrate_config_test.ts +++ b/tests/cli/migrate_config_test.ts @@ -1,5 +1,6 @@ import { assertEquals, assertStringIncludes } from "@std/assert"; import { join } from "@std/path"; +import { parse as parseYamlText } from "@std/yaml"; import { migrateConfigCommand } from "../../src/cli/migrate-config.ts"; async function withSite( @@ -43,14 +44,12 @@ Deno.test("migrate:config rewrites site, system and env overlay files", async () }, async (root) => { const out = await quiet(() => migrateConfigCommand(root)); assertStringIncludes(out, "config/site.yaml"); + // the admin: block moved into the admin plugin's entry assertEquals( await Deno.readTextFile(join(root, "config/site.yaml")), - "# c\nauth:\n user_store: db # keep\n" + ADMIN, - ); - assertEquals( - await Deno.readTextFile(join(root, "config/system.yaml")), - "admin:\n max_upload_mb: 5\n", + "# c\nauth:\n user_store: db # keep\n" + ADMIN + " config:\n max_upload_mb: 5\n", ); + assertEquals(await Deno.readTextFile(join(root, "config/system.yaml")), ""); assertEquals( await Deno.readTextFile(join(root, "config/env/production/system.yaml")), "metrics:\n slow_query_threshold_ms: 10\n", @@ -108,11 +107,11 @@ Deno.test("migrate:config deletes keys that were declared and never read", async }, async (root) => { const out = await quiet(() => migrateConfigCommand(root)); assertStringIncludes(out, "removed cache, timezone"); + assertEquals(await Deno.readTextFile(join(root, "config/system.yaml")), "# c\n"); assertEquals( - await Deno.readTextFile(join(root, "config/system.yaml")), - "# c\nadmin:\n max_upload_mb: 5\n", + await Deno.readTextFile(join(root, "config/site.yaml")), + "title: T\n" + ADMIN + " config:\n max_upload_mb: 5\n", ); - assertEquals(await Deno.readTextFile(join(root, "config/site.yaml")), "title: T\n" + ADMIN); }); }); @@ -135,8 +134,11 @@ Deno.test("migrate:config removes admin.enabled: true and adds the admin plugin" }, async (root) => { const out = await quiet(() => migrateConfigCommand(root)); assertStringIncludes(out, "removed admin.enabled"); - assertEquals(await Deno.readTextFile(join(root, "config/system.yaml")), "admin:\n max_upload_mb: 5\ndebug: true\n"); - assertStringIncludes(await Deno.readTextFile(join(root, "config/site.yaml")), "jsr:@dune/plugin-admin@^4"); + assertEquals(await Deno.readTextFile(join(root, "config/system.yaml")), "debug: true\n"); + assertEquals( + await Deno.readTextFile(join(root, "config/site.yaml")), + "title: T\n\nplugins:\n - src: jsr:@dune/plugin-admin@^4\n config:\n max_upload_mb: 5\n", + ); }); }); @@ -148,7 +150,8 @@ Deno.test("migrate:config removes admin.enabled: false and leaves the site witho const out = await quiet(() => migrateConfigCommand(root)); assertStringIncludes(out, "removed admin.enabled"); assertStringIncludes(out, "admin plugin is not added"); - assertEquals(await Deno.readTextFile(join(root, "config/system.yaml")), "admin:\ndebug: true\n"); + assertStringIncludes(out, "removed the admin: block"); + assertEquals(await Deno.readTextFile(join(root, "config/system.yaml")), "debug: true\n"); assertEquals(await Deno.readTextFile(join(root, "config/site.yaml")), "title: T\n"); }); }); @@ -163,3 +166,51 @@ Deno.test("migrate:config --dry-run reports the admin changes and writes nothing assertEquals(await Deno.readTextFile(join(root, "config/site.yaml")), "title: T\n"); }); }); + +Deno.test("migrate:config moves the whole admin: block: settings into the plugin's config, runtime_dir and max_revisions to the top level", async () => { + await withSite({ + "config/site.yaml": "title: T\n", + "config/system.yaml": + "debug: true\nadmin:\n path: /cms\n runtime_dir: var/rt\n maxRevisions: 20\n authz_store: local\n audit:\n log_file: audit/a.log\n webhooks:\n - url: https://hooks.example.test/x\n events: [onPageUpdate]\n", + }, async (root) => { + const out = await quiet(() => migrateConfigCommand(root)); + assertStringIncludes(out, "moved the admin: block"); + assertEquals(await Deno.readTextFile(join(root, "config/system.yaml")), "debug: true\nruntime_dir: var/rt\nmax_revisions: 20\n"); + const site = parseYamlText(await Deno.readTextFile(join(root, "config/site.yaml"))) as { + plugins: { src: string; config: Record }[]; + }; + assertEquals(site.plugins[0].src, "jsr:@dune/plugin-admin@^4"); + assertEquals(site.plugins[0].config, { + path: "/cms", + audit: { log_file: "audit/a.log" }, + webhooks: [{ url: "https://hooks.example.test/x", events: ["onPageUpdate"] }], + }); + assertStringIncludes(await quiet(() => migrateConfigCommand(root)), "Nothing to migrate"); + }); +}); + +Deno.test("migrate:config leaves an admin: block holding keys that belong elsewhere for a manual move", async () => { + const system = "admin:\n path: /cms\n honeypot: x\n"; + await withSite({ "config/site.yaml": "title: T\n", "config/system.yaml": system }, async (root) => { + const origExit = Deno.exit; + let code: number | undefined; + // deno-lint-ignore no-explicit-any + (Deno as any).exit = (c?: number) => { + code = c; + throw new Error("exit"); + }; + let out = ""; + try { + out = await quiet(async () => { + try { + await migrateConfigCommand(root); + } catch { /* exit */ } + }); + } finally { + Deno.exit = origExit; + } + assertEquals(code, 1); + assertStringIncludes(out, "admin.honeypot"); + assertEquals(await Deno.readTextFile(join(root, "config/system.yaml")), system); + }); +}); diff --git a/tests/config/legacy_keys_test.ts b/tests/config/legacy_keys_test.ts index 72aab5a7..df7f60b7 100644 --- a/tests/config/legacy_keys_test.ts +++ b/tests/config/legacy_keys_test.ts @@ -119,11 +119,10 @@ Deno.test("loadConfig refuses a legacy key and names the new one", async () => { Deno.test("loadConfig accepts the migrated keys", async () => { const cfg = await load({ "config/site.yaml": "title: T\nauth:\n user_store: db\n", - "config/system.yaml": "max_revisions: 7\nadmin:\n max_upload_mb: 7\n", + "config/system.yaml": "max_revisions: 7\n", }); assertEquals((cfg.site as any).auth.user_store, "db"); assertEquals(cfg.system.max_revisions, 7); - assertEquals(cfg.admin?.max_upload_mb, 7); }); Deno.test("findMovedKeys: the removed forms aliases, admin.data_dir and the smtp block", () => { @@ -133,7 +132,7 @@ Deno.test("findMovedKeys: the removed forms aliases, admin.data_dir and the smtp ); assertEquals( system.map((k) => k.path).sort(), - ["admin.dataDir", "admin.data_dir", "admin.enabled", "admin.honeypot", "admin.incoming_webhooks", "admin.notifications"], + ["admin", "admin.dataDir", "admin.data_dir", "admin.enabled", "admin.honeypot", "admin.incoming_webhooks", "admin.notifications"], ); const site = findMovedKeys( "site", @@ -156,7 +155,7 @@ Deno.test("loadConfig refuses a moved key and says where it went", async () => { Deno.test("admin.enabled and admin.authz_store are removed with a message that says what to do", async () => { const moved = findMovedKeys("system", parseYaml("admin:\n enabled: true\n authz_store: local\n authzStore: local\n")); - assertEquals(moved.map((k) => k.path).sort(), ["admin.authzStore", "admin.authz_store", "admin.enabled"]); + assertEquals(moved.map((k) => k.path).sort(), ["admin", "admin.authzStore", "admin.authz_store", "admin.enabled"]); let message = ""; try { await load({ "config/system.yaml": "admin:\n enabled: false\n" }); @@ -174,7 +173,7 @@ Deno.test("admin.runtime_dir and admin.max_revisions moved to the top level of s ); assertEquals( moved.map((k) => k.path).sort(), - ["admin.maxRevisions", "admin.max_revisions", "admin.runtimeDir", "admin.runtime_dir"], + ["admin", "admin.maxRevisions", "admin.max_revisions", "admin.runtimeDir", "admin.runtime_dir"], ); let message = ""; try { diff --git a/tests/config/loader_test.ts b/tests/config/loader_test.ts index 35ce7c33..4417271f 100644 --- a/tests/config/loader_test.ts +++ b/tests/config/loader_test.ts @@ -187,38 +187,27 @@ Deno.test("loadConfig: merges system.yaml into system config", async () => { assertEquals(config.system.images.cache_dir, ".dune/cache/images"); }); -Deno.test("loadConfig: system.yaml `admin:` lands in config.admin, not config.system", async () => { +Deno.test("loadConfig: a system.yaml `admin:` block is refused and says where the settings went", async () => { const storage = new MemoryStorage({ "config/system.yaml": `admin:\n max_upload_mb: 7\ndebug: true`, }); - const config = await loadConfig({ - storage, - rootDir: "/tmp/test", - skipConfigTs: true, - skipValidation: true, - }); - assertEquals(config.admin?.max_upload_mb, 7); - // Unset admin keys keep their defaults - assertEquals(config.admin?.path, "/admin"); - assertEquals((config.system as unknown as Record).admin, undefined); - // The rest of system.yaml is unaffected - assertEquals(config.system.debug, true); + await assertRejects( + () => loadConfig({ storage, rootDir: "/tmp/test", skipConfigTs: true, skipValidation: true }), + Error, + "plugins:", + ); }); -Deno.test("loadConfig: env system.yaml `admin:` overrides the base file", async () => { - const storage = new MemoryStorage({ - "config/system.yaml": `admin:\n max_upload_mb: 7\n git_commit: false`, - "config/env/production/system.yaml": `admin:\n git_commit: true`, - }); +Deno.test("loadConfig: there is no DuneConfig.admin and no default admin settings", async () => { const config = await loadConfig({ - storage, + storage: new MemoryStorage({ "config/system.yaml": `debug: true` }), rootDir: "/tmp/test", - env: "production", skipConfigTs: true, skipValidation: true, }); - assertEquals(config.admin?.git_commit, true); - assertEquals(config.admin?.max_upload_mb, 7); + assertEquals((config as unknown as Record).admin, undefined); + assertEquals((config.system as unknown as Record).admin, undefined); + assertEquals(config.system.debug, true); }); Deno.test("loadConfig: environment-specific overrides", async () => { diff --git a/tests/config/validator_test.ts b/tests/config/validator_test.ts index 59338aad..b6bfcb25 100644 --- a/tests/config/validator_test.ts +++ b/tests/config/validator_test.ts @@ -119,17 +119,3 @@ Deno.test("validator: valid custom config passes", () => { const errors = validateConfig(config); assertEquals(errors.length, 0); }); - -Deno.test("validator: admin.auth_provider other than local is refused (LDAP and SAML were never implemented)", () => { - for (const type of ["ldap", "saml"]) { - const config = cloneConfig(); - // @ts-ignore: testing invalid input - config.admin = { ...(config.admin ?? {}), auth_provider: { type } }; - const errors = validateConfig(config); - assertEquals(errors.some((e) => e.includes("admin.auth_provider.type") && e.includes("never implemented")), true, type); - } - const ok = cloneConfig(); - // @ts-ignore: partial admin config is fine for this check - ok.admin = { ...(ok.admin ?? {}), auth_provider: { type: "local" } }; - assertEquals(validateConfig(ok).some((e) => e.includes("auth_provider")), false); -}); diff --git a/tests/mcp/schema_test.ts b/tests/mcp/schema_test.ts index 69fd9414..5087688a 100644 --- a/tests/mcp/schema_test.ts +++ b/tests/mcp/schema_test.ts @@ -24,13 +24,13 @@ Deno.test("CONFIG_SCHEMA: has $schema and type=object", () => { assertExists(schema.properties); }); -Deno.test("CONFIG_SCHEMA: includes site, system, theme, plugins, admin properties", () => { +Deno.test("CONFIG_SCHEMA: includes site, system, theme and plugins properties, and no admin one (the admin plugin's settings are its own)", () => { const props = (CONFIG_SCHEMA as { properties: Record }).properties; assertExists(props.site, "schema must have 'site' property"); assertExists(props.system, "schema must have 'system' property"); assertExists(props.theme, "schema must have 'theme' property"); assertExists(props.plugins, "schema must have 'plugins' property"); - assertExists(props.admin, "schema must have 'admin' property"); + assertEquals(props.admin, undefined, "the admin: block is gone from the schema"); }); Deno.test("CONFIG_SCHEMA: site.title is required", () => { From 145b7abe8e49c0c676819626ac7bbcc17ddcc90c Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 01:13:01 +0100 Subject: [PATCH 13/31] feat(config)!: machine_translation moves into the admin plugin's config too site.machine_translation is the admin's setting: migrate:config moves it under config: of the admin's entry as machine_translation, renaming apiKey/baseUrl to api_key/base_url; a site.yaml that still has it is refused. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 3 ++ src/cli/migrate-config.ts | 81 ++++++++++++++++++++------------ src/config/legacy-keys.ts | 20 ++++++-- tests/cli/migrate_config_test.ts | 22 ++++++++- 4 files changed, 89 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 202466b5..cba1d0f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,9 @@ where the setting lives now (see `dune migrate:config`, which reports these too) `admin:` block in an environment overlay, or one that still holds `honeypot`, `notifications` or `incoming_webhooks`, is left for you). The MCP server no longer reports `admin` settings and loses its `dune://site/audit` resource: core cannot know where the admin writes its log. + `site.machine_translation` is the admin's too: it moves into the same `config:` as + `machine_translation:` with `api_key` and `base_url` (they were `apiKey` and `baseUrl`), a + `site.yaml` that still has it is refused, and `migrate:config` moves and renames it. `Role` is now `config/role.ts`; `WebhookNotificationConfig` lives with the forms config. - **Core no longer loads the admin: it is a plugin you list (#123, step 4).** `bootstrap()` used to import `jsr:@dune/plugin-admin@*` itself unless `admin.enabled` was false; now the diff --git a/src/cli/migrate-config.ts b/src/cli/migrate-config.ts index aa82be1d..38838c7d 100644 --- a/src/cli/migrate-config.ts +++ b/src/cli/migrate-config.ts @@ -35,9 +35,9 @@ import { findIgnoredKeys, findLegacyKeys, findMovedKeys, - removeAdminBlockInText, removeAdminEnabledInText, removeIgnoredKeysInText, + removeTopLevelKeyInText, renameLegacyKeysInText, } from "../config/legacy-keys.ts"; import { ADMIN_PLUGIN_ENTRY_SRC, addAdminPluginToText, setAdminPluginConfigInText } from "./add-admin-plugin.ts"; @@ -83,8 +83,16 @@ export interface AdminMovePlan { blocked: string[]; } -export function planAdminMove(block: unknown): AdminMovePlan { +/** `machine_translation:` keys that were camelCase and are snake_case in the plugin's config. */ +const MT_RENAMES: Record = { apiKey: "api_key", baseUrl: "base_url" }; + +export function planAdminMove(block: unknown, machineTranslation?: unknown): AdminMovePlan { const plan: AdminMovePlan = { settings: {}, lifted: {}, blocked: [] }; + if (machineTranslation && typeof machineTranslation === "object" && !Array.isArray(machineTranslation)) { + plan.settings.machine_translation = Object.fromEntries( + Object.entries(machineTranslation as Record).map(([k, v]) => [MT_RENAMES[k] ?? k, v]), + ); + } if (!block || typeof block !== "object" || Array.isArray(block)) return plan; for (const [key, value] of Object.entries(block as Record)) { if (key in LIFTED) plan.lifted[LIFTED[key]] = value; @@ -138,6 +146,8 @@ export async function migrateConfigCommand( // keys (and the ones we lift or drop) are not reported as "move by hand". const handledBelow = path === join(root, "config", "system.yaml") ? (k: { path: string }) => k.path === "admin" || (k.path.startsWith("admin.") && !ELSEWHERE.some((e) => k.path === `admin.${e}`)) + : path === join(root, "config", "site.yaml") + ? (k: { path: string }) => k.path === "machine_translation" : (k: { path: string }) => opts.dryRun === true && k.path === "admin.enabled"; const moved = findMovedKeys(kind, parsedBefore).filter((k) => !handledBelow(k)); if (moved.length > 0) { @@ -180,9 +190,10 @@ export async function migrateConfigCommand( if (!opts.dryRun) await Deno.writeTextFile(path, result.text); } - // The admin: block of system.yaml. Its settings are the admin plugin's config - // now (`config:` under its entry in site.yaml); runtime_dir, max_revisions and - // data_dir go to the top level of system.yaml; the block is deleted. + // The admin's settings: the `admin:` block of system.yaml and `machine_translation:` of + // site.yaml. They are the admin plugin's config now (`config:` under its entry in + // site.yaml); runtime_dir, max_revisions and data_dir go to the top level of + // system.yaml; the old blocks are deleted. const systemPath = join(root, "config", "system.yaml"); const sitePath = join(root, "config", "site.yaml"); let systemText: string | null = null; @@ -196,60 +207,70 @@ export async function migrateConfigCommand( // Same renames the loop above applied (it did not write them on a dry run). if (systemText !== null) systemText = renameLegacyKeysInText("system", systemText).text; - const adminBlock = systemText === null + const adminBlock = systemText === null ? undefined : (parseYaml(systemText) as { admin?: unknown } | null)?.admin; + const mtBlock = siteText === null ? undefined - : (parseYaml(systemText) as { admin?: unknown } | null)?.admin; - const hasBlock = adminBlock !== undefined; + : (parseYaml(siteText) as { machine_translation?: unknown } | null)?.machine_translation; + const hasAdminSettings = adminBlock !== undefined || mtBlock !== undefined; + + const write = async (path: string, text: string) => { + if (!opts.dryRun) await Deno.writeTextFile(path, text); + }; - if (hasBlock && systemText !== null) { + if (hasAdminSettings) { if (adminWasDisabled) { - const removed = removeAdminBlockInText(systemText); - if (removed.removed) { - changed++; - console.log(" ✓ config/system.yaml: removed the admin: block (the admin is not loaded)"); - if (!opts.dryRun) await Deno.writeTextFile(systemPath, removed.text); - } + // The admin is not loaded, so its settings have nowhere to go: drop them. + let nextSystem = systemText; + let nextSite = siteText; + if (adminBlock !== undefined && nextSystem !== null) nextSystem = removeTopLevelKeyInText("system", nextSystem, "admin").text; + if (mtBlock !== undefined && nextSite !== null) nextSite = removeTopLevelKeyInText("site", nextSite, "machine_translation").text; + changed++; + console.log(" ✓ removed the admin: block and machine_translation (the admin is not loaded)"); + if (nextSystem !== null && adminBlock !== undefined) await write(systemPath, nextSystem); + if (nextSite !== null && mtBlock !== undefined) await write(sitePath, nextSite); } else { - const plan = planAdminMove(adminBlock); + const plan = planAdminMove(adminBlock, mtBlock); if (plan.blocked.length > 0 || siteText === null) { unresolvedFiles++; console.error( - ` ✗ config/system.yaml: move the admin: block by hand — ` + + ` ✗ move the admin's settings by hand — ` + (siteText === null ? "there is no config/site.yaml to hold the admin plugin's config" : `${plan.blocked.join(", ")} belong${plan.blocked.length === 1 ? "s" : ""} elsewhere (see the messages above)`), ); } else { - // 1. the admin entry in plugins:, 2. the settings under its config: + // 1. the admin entry in plugins:, 2. the settings under its config:, 3. delete the old blocks const added = addAdminPluginToText(siteText); const withConfig = added.outcome === "manual" ? { text: siteText, outcome: "manual" as const } : setAdminPluginConfigInText(added.text, plan.settings); - const removed = removeAdminBlockInText(systemText); - if (added.outcome === "manual" || withConfig.outcome === "manual" || !removed.removed) { + const nextSite = mtBlock !== undefined + ? removeTopLevelKeyInText("site", withConfig.text, "machine_translation") + : { text: withConfig.text, removed: true }; + const nextSystemRemoved = adminBlock !== undefined && systemText !== null + ? removeTopLevelKeyInText("system", systemText, "admin") + : { text: systemText ?? "", removed: true }; + if (added.outcome === "manual" || withConfig.outcome === "manual" || !nextSite.removed || !nextSystemRemoved.removed) { unresolvedFiles++; console.error( - ` ✗ config/system.yaml: move the admin: block by hand into the admin plugin's config: in site.yaml ` + + ` ✗ move the admin's settings by hand into the admin plugin's config: in site.yaml ` + `(plugins: is written in a way this command cannot extend, or the entry already has a config:)`, ); } else { - let nextSystem = removed.text; + let nextSystem = nextSystemRemoved.text; if (Object.keys(plan.lifted).length > 0) { nextSystem = nextSystem.replace(/\n*$/, "\n") + stringifyYaml(plan.lifted); } changed++; const moved = Object.keys(plan.settings); console.log( - ` ✓ config/system.yaml: moved the admin: block into the admin plugin's config:` + + ` ✓ moved the admin's settings into the admin plugin's config:` + (moved.length > 0 ? ` (${moved.join(", ")})` : "") + - (Object.keys(plan.lifted).length > 0 ? `; ${Object.keys(plan.lifted).join(", ")} now at the top level` : ""), + (Object.keys(plan.lifted).length > 0 ? `; ${Object.keys(plan.lifted).join(", ")} now at the top level of system.yaml` : ""), ); if (added.outcome === "added") console.log(` ✓ config/site.yaml: added ${ADMIN_PLUGIN_ENTRY_SRC} to plugins:`); - if (!opts.dryRun) { - await Deno.writeTextFile(systemPath, nextSystem); - await Deno.writeTextFile(sitePath, withConfig.text); - } - siteText = null; // handled + if (adminBlock !== undefined) await write(systemPath, nextSystem); + await write(sitePath, nextSite.text); } } } @@ -258,7 +279,7 @@ export async function migrateConfigCommand( if (added.outcome === "added") { changed++; console.log(` ✓ config/site.yaml: added ${ADMIN_PLUGIN_ENTRY_SRC} to plugins: (core no longer loads the admin by itself)`); - if (!opts.dryRun) await Deno.writeTextFile(sitePath, added.text); + await write(sitePath, added.text); } else if (added.outcome === "manual") { unresolvedFiles++; console.error( diff --git a/src/config/legacy-keys.ts b/src/config/legacy-keys.ts index 2f959d8c..341c7550 100644 --- a/src/config/legacy-keys.ts +++ b/src/config/legacy-keys.ts @@ -65,6 +65,14 @@ interface MovedRule { } const MOVED: MovedRule[] = [ + { + file: "site", + parent: [], + keys: { + machine_translation: + "the admin plugin's config (`config:` of its entry in plugins:), with api_key and base_url in snake_case; `dune migrate:config` moves it", + }, + }, { file: "system", parent: [], @@ -173,11 +181,13 @@ export function removeAdminEnabledInText(text: string): { text: string; removed: return { text: r.text, removed: r.removed.length > 0 }; } -const ADMIN_BLOCK: MovedRule[] = [{ file: "system", parent: [], keys: { admin: "" } }]; - -/** Delete the top-level `admin:` block (key and children) from a system.yaml text. */ -export function removeAdminBlockInText(text: string): { text: string; removed: boolean } { - const r = removeKeysInText(ADMIN_BLOCK, "system", text); +/** Delete a top-level key and its children from a config file's text (comments elsewhere are kept). */ +export function removeTopLevelKeyInText( + file: ConfigFileKind, + text: string, + key: string, +): { text: string; removed: boolean } { + const r = removeKeysInText([{ file, parent: [], keys: { [key]: "" } }], file, text); return { text: r.text, removed: r.removed.length > 0 }; } diff --git a/tests/cli/migrate_config_test.ts b/tests/cli/migrate_config_test.ts index dfee3c10..ac393c67 100644 --- a/tests/cli/migrate_config_test.ts +++ b/tests/cli/migrate_config_test.ts @@ -150,7 +150,7 @@ Deno.test("migrate:config removes admin.enabled: false and leaves the site witho const out = await quiet(() => migrateConfigCommand(root)); assertStringIncludes(out, "removed admin.enabled"); assertStringIncludes(out, "admin plugin is not added"); - assertStringIncludes(out, "removed the admin: block"); + assertStringIncludes(out, "removed the admin: block and machine_translation"); assertEquals(await Deno.readTextFile(join(root, "config/system.yaml")), "debug: true\n"); assertEquals(await Deno.readTextFile(join(root, "config/site.yaml")), "title: T\n"); }); @@ -174,7 +174,7 @@ Deno.test("migrate:config moves the whole admin: block: settings into the plugin "debug: true\nadmin:\n path: /cms\n runtime_dir: var/rt\n maxRevisions: 20\n authz_store: local\n audit:\n log_file: audit/a.log\n webhooks:\n - url: https://hooks.example.test/x\n events: [onPageUpdate]\n", }, async (root) => { const out = await quiet(() => migrateConfigCommand(root)); - assertStringIncludes(out, "moved the admin: block"); + assertStringIncludes(out, "moved the admin's settings into the admin plugin's config"); assertEquals(await Deno.readTextFile(join(root, "config/system.yaml")), "debug: true\nruntime_dir: var/rt\nmax_revisions: 20\n"); const site = parseYamlText(await Deno.readTextFile(join(root, "config/site.yaml"))) as { plugins: { src: string; config: Record }[]; @@ -214,3 +214,21 @@ Deno.test("migrate:config leaves an admin: block holding keys that belong elsewh assertEquals(await Deno.readTextFile(join(root, "config/system.yaml")), system); }); }); + +Deno.test("migrate:config moves site.machine_translation into the admin plugin's config with snake_case keys", async () => { + await withSite({ + "config/site.yaml": "title: T\nmachine_translation:\n provider: deepl\n apiKey: $DEEPL_KEY\n baseUrl: https://mt.example\nforms: {}\n", + }, async (root) => { + const out = await quiet(() => migrateConfigCommand(root)); + assertStringIncludes(out, "machine_translation"); + const site = parseYamlText(await Deno.readTextFile(join(root, "config/site.yaml"))) as { + machine_translation?: unknown; + plugins: { config: Record }[]; + }; + assertEquals(site.machine_translation, undefined); + assertEquals(site.plugins[0].config, { + machine_translation: { provider: "deepl", api_key: "$DEEPL_KEY", base_url: "https://mt.example" }, + }); + assertStringIncludes(await quiet(() => migrateConfigCommand(root)), "Nothing to migrate"); + }); +}); From c5de0fa83959f74bcdbf6fb37eee730d2e8b6da2 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 01:15:19 +0100 Subject: [PATCH 14/31] chore: drop the deno.lock change that slipped into the step 5 commits Co-Authored-By: Claude Sonnet 5.5 --- deno.lock | 370 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 337 insertions(+), 33 deletions(-) diff --git a/deno.lock b/deno.lock index 0dcfd949..eb02d315 100644 --- a/deno.lock +++ b/deno.lock @@ -2,38 +2,62 @@ "version": "5", "specifiers": { "jsr:@db/sqlite@0.12": "0.12.0", + "jsr:@deno/esbuild-plugin@^1.2.0": "1.2.1", + "jsr:@deno/loader@~0.3.10": "0.3.14", "jsr:@denosaurs/plug@1": "1.1.0", + "jsr:@dune/core@~0.34.2": "0.34.14", + "jsr:@dune/plugin-admin@*": "3.0.0", + "jsr:@dune/plugin-admin@3": "3.0.0", "jsr:@fresh/build-id@1": "1.0.1", "jsr:@fresh/core@2": "2.3.3", "jsr:@std/assert@0.217": "0.217.0", + "jsr:@std/assert@1": "1.0.19", + "jsr:@std/bytes@^1.0.6": "1.0.6", + "jsr:@std/crypto@1": "1.1.0", "jsr:@std/encoding@1": "1.0.11", "jsr:@std/encoding@^1.0.10": "1.0.11", + "jsr:@std/encoding@^1.0.11": "1.0.11", "jsr:@std/fmt@1": "1.0.10", "jsr:@std/fmt@^1.0.8": "1.0.10", "jsr:@std/fs@1": "1.0.24", "jsr:@std/fs@^1.0.19": "1.0.24", "jsr:@std/html@^1.0.5": "1.0.7", - "jsr:@std/http@^1.0.21": "1.1.4", + "jsr:@std/http@^1.0.21": "1.1.3", + "jsr:@std/internal@^1.0.12": "1.0.14", "jsr:@std/internal@^1.0.14": "1.0.14", - "jsr:@std/net@^1.0.6": "1.0.7", + "jsr:@std/json@^1.0.2": "1.1.0", + "jsr:@std/jsonc@^1.0.2": "1.0.2", + "jsr:@std/media-types@^1.1.0": "1.1.0", + "jsr:@std/net@^1.0.6": "1.0.6", "jsr:@std/path@0.217": "0.217.0", "jsr:@std/path@1": "1.1.6", + "jsr:@std/path@^1.1.1": "1.1.6", "jsr:@std/path@^1.1.2": "1.1.6", "jsr:@std/path@^1.1.4": "1.1.6", "jsr:@std/path@^1.1.5": "1.1.6", - "jsr:@std/yaml@1": "1.3.0", + "jsr:@std/semver@1": "1.0.8", + "jsr:@std/semver@^1.0.6": "1.0.8", + "jsr:@std/uuid@^1.0.9": "1.1.1", + "jsr:@std/yaml@1": "1.2.0", + "jsr:@zip-js/zip-js@2": "2.8.61", "npm:@mdx-js/mdx@3": "3.1.1", - "npm:@opentelemetry/api@^1.9.0": "1.10.0", - "npm:@preact/signals@^2.5.1": "2.11.4_preact@10.29.8__preact-render-to-string@6.8.2_preact-render-to-string@6.8.2__preact@10.29.8", + "npm:@opentelemetry/api@^1.9.0": "1.9.1", + "npm:@preact/signals@^2.5.1": "2.11.1_preact@10.29.8__preact-render-to-string@6.7.0", + "npm:@types/nodemailer@6": "6.4.24", + "npm:esbuild-wasm@~0.25.11": "0.25.12", + "npm:esbuild@0.25.7": "0.25.7", + "npm:esbuild@~0.25.5": "0.25.7", "npm:gray-matter@4": "4.0.3", "npm:ioredis@5": "5.11.1", "npm:marked@15": "15.0.12", - "npm:polizy@0.6": "0.6.1", + "npm:nodemailer@6": "6.10.1", + "npm:polizy@0.6": "0.6.0", "npm:postgres@^3.4.0": "3.4.9", - "npm:preact-render-to-string@6": "6.8.2_preact@10.29.8", - "npm:preact-render-to-string@^6.6.3": "6.8.2_preact@10.29.8", - "npm:preact@10": "10.29.8_preact-render-to-string@6.8.2", - "npm:preact@^10.29.1": "10.29.8_preact-render-to-string@6.8.2", + "npm:preact-render-to-string@6": "6.7.0_preact@10.29.8", + "npm:preact-render-to-string@^6.6.3": "6.7.0_preact@10.29.8", + "npm:preact@10": "10.29.8_preact-render-to-string@6.7.0", + "npm:preact@^10.28.2": "10.29.8_preact-render-to-string@6.7.0", + "npm:preact@^10.29.1": "10.29.8_preact-render-to-string@6.7.0", "npm:remark-gfm@4": "4.0.1", "npm:sharp@0.33": "0.33.5" }, @@ -45,6 +69,17 @@ "jsr:@std/path@0.217" ] }, + "@deno/esbuild-plugin@1.2.1": { + "integrity": "df629467913adc1f960149fdfa3a3430ba8c20381c310fba096db244e6c3c9f6", + "dependencies": [ + "jsr:@deno/loader", + "jsr:@std/path@^1.1.1", + "npm:esbuild@~0.25.5" + ] + }, + "@deno/loader@0.3.14": { + "integrity": "97bc63a6cc2d27a60bcdc953f588c5213331d866d44212eebb24cebfb9b011ca" + }, "@denosaurs/plug@1.1.0": { "integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044", "dependencies": [ @@ -54,6 +89,42 @@ "jsr:@std/path@1" ] }, + "@dune/core@0.34.14": { + "integrity": "73a43da4d74c507eb92078f91c62e17de91b1693022a4aa98f024acd5db8c224", + "dependencies": [ + "jsr:@db/sqlite", + "jsr:@fresh/core", + "jsr:@std/crypto", + "jsr:@std/encoding@1", + "jsr:@std/fs@1", + "jsr:@std/path@^1.1.4", + "jsr:@std/yaml", + "npm:gray-matter", + "npm:ioredis", + "npm:marked", + "npm:nodemailer", + "npm:polizy", + "npm:postgres", + "npm:preact-render-to-string@6", + "npm:preact@10" + ] + }, + "@dune/plugin-admin@3.0.0": { + "integrity": "48c7bc839a8fc4b7196a2ac5728f70c9cf4ce7bc4cf8facd7d2101e44fa70e0d", + "dependencies": [ + "jsr:@dune/core", + "jsr:@fresh/core", + "jsr:@std/encoding@1", + "jsr:@std/path@^1.1.4", + "jsr:@std/yaml", + "jsr:@zip-js/zip-js", + "npm:@types/nodemailer", + "npm:marked", + "npm:nodemailer", + "npm:preact-render-to-string@6", + "npm:preact@10" + ] + }, "@fresh/build-id@1.0.1": { "integrity": "12a2ec25fd52ae9ec68c26848a5696cd1c9b537f7c983c7e56e4fb1e7e816c20", "dependencies": [ @@ -63,22 +134,43 @@ "@fresh/core@2.3.3": { "integrity": "6c2aad199976644352c89a1d601f3ef331b1d24cdadb53ed469324a303598383", "dependencies": [ + "jsr:@deno/esbuild-plugin", "jsr:@fresh/build-id", + "jsr:@std/encoding@^1.0.10", "jsr:@std/fmt@^1.0.8", "jsr:@std/fs@^1.0.19", "jsr:@std/html", "jsr:@std/http", + "jsr:@std/jsonc", + "jsr:@std/media-types", "jsr:@std/net", "jsr:@std/path@^1.1.2", + "jsr:@std/semver@^1.0.6", + "jsr:@std/uuid", "npm:@opentelemetry/api", "npm:@preact/signals", + "npm:esbuild-wasm", + "npm:esbuild@0.25.7", "npm:preact-render-to-string@^6.6.3", + "npm:preact@^10.28.2", "npm:preact@^10.29.1" ] }, "@std/assert@0.217.0": { "integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642" }, + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal@^1.0.12" + ] + }, + "@std/bytes@1.0.6": { + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" + }, + "@std/crypto@1.1.0": { + "integrity": "b8d6d0a6377a32b213af2661ed7bf1062d94feac0c57def5526a8e74a95c3ec8" + }, "@std/encoding@1.0.11": { "integrity": "e7cef2f0b3153bccc17431e7c864a1de03a4b6d9647389c43e08aaa775d37385" }, @@ -88,36 +180,63 @@ "@std/fs@1.0.24": { "integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877", "dependencies": [ - "jsr:@std/internal", + "jsr:@std/internal@^1.0.14", "jsr:@std/path@^1.1.5" ] }, "@std/html@1.0.7": { "integrity": "175c818905a6e75743c69c251395e273d82698e58cfe7dd76268d70e28b8d8fe" }, - "@std/http@1.1.4": { - "integrity": "4f1575226ca7c0df1df38b26d50265fed30869b2a63ab867edc5b92492150c8c" + "@std/http@1.1.3": { + "integrity": "73a98e05ff58aa18bea562d286172e6c815b1eddd4fcab194beda19e3683b88c", + "dependencies": [ + "jsr:@std/encoding@^1.0.11" + ] }, "@std/internal@1.0.14": { "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" }, - "@std/net@1.0.7": { - "integrity": "e0b995738cfe2ed56f64591d400e55f143cff164efd690343f2deb4cf4d06d86" + "@std/json@1.1.0": { + "integrity": "93330d3ed4054d6b1ffe54b075432c80a5f671be77277b978931e018014cb1cc" + }, + "@std/jsonc@1.0.2": { + "integrity": "909605dae3af22bd75b1cbda8d64a32cf1fd2cf6efa3f9e224aba6d22c0f44c7", + "dependencies": [ + "jsr:@std/json" + ] + }, + "@std/media-types@1.1.0": { + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" + }, + "@std/net@1.0.6": { + "integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c" }, "@std/path@0.217.0": { "integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11", "dependencies": [ - "jsr:@std/assert" + "jsr:@std/assert@0.217" ] }, "@std/path@1.1.6": { "integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe", "dependencies": [ - "jsr:@std/internal" + "jsr:@std/internal@^1.0.14" + ] + }, + "@std/semver@1.0.8": { + "integrity": "dc830e8b8b6a380c895d53fbfd1258dc253704ca57bbe1629ac65fd7830179b7" + }, + "@std/uuid@1.1.1": { + "integrity": "7b49060282d90f72fec64952f8c7a2914cbe6fdba3eca665f8cedc90830154a7", + "dependencies": [ + "jsr:@std/bytes" ] }, - "@std/yaml@1.3.0": { - "integrity": "b250103854378b8b36cbff0fe95aec017d3d9ce86b489c67605180c602fb4922" + "@std/yaml@1.2.0": { + "integrity": "20beb41e4983ba3437dbefac62b14061ab058e8a187596f19d28ff9035f6e6cf" + }, + "@zip-js/zip-js@2.8.61": { + "integrity": "8da37318aeefa76faa915058722ab8d1ee51e068edc8be69ea0810e01d724955" } }, "npm": { @@ -127,6 +246,136 @@ "tslib" ] }, + "@esbuild/aix-ppc64@0.25.7": { + "integrity": "sha512-uD0kKFHh6ETr8TqEtaAcV+dn/2qnYbH/+8wGEdY70Qf7l1l/jmBUbrmQqwiPKAQE6cOQ7dTj6Xr0HzQDGHyceQ==", + "os": ["aix"], + "cpu": ["ppc64"] + }, + "@esbuild/android-arm64@0.25.7": { + "integrity": "sha512-p0ohDnwyIbAtztHTNUTzN5EGD/HJLs1bwysrOPgSdlIA6NDnReoVfoCyxG6W1d85jr2X80Uq5KHftyYgaK9LPQ==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@esbuild/android-arm@0.25.7": { + "integrity": "sha512-Jhuet0g1k9rAJHrXGIh7sFknFuT4sfytYZpZpuZl7YKDhnPByVAm5oy2LEBmMbuYf3ejWVYCc2seX81Mk+madA==", + "os": ["android"], + "cpu": ["arm"] + }, + "@esbuild/android-x64@0.25.7": { + "integrity": "sha512-mMxIJFlSgVK23HSsII3ZX9T2xKrBCDGyk0qiZnIW10LLFFtZLkFD6imZHu7gUo2wkNZwS9Yj3mOtZD3ZPcjCcw==", + "os": ["android"], + "cpu": ["x64"] + }, + "@esbuild/darwin-arm64@0.25.7": { + "integrity": "sha512-jyOFLGP2WwRwxM8F1VpP6gcdIJc8jq2CUrURbbTouJoRO7XCkU8GdnTDFIHdcifVBT45cJlOYsZ1kSlfbKjYUQ==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@esbuild/darwin-x64@0.25.7": { + "integrity": "sha512-m9bVWqZCwQ1BthruifvG64hG03zzz9gE2r/vYAhztBna1/+qXiHyP9WgnyZqHgGeXoimJPhAmxfbeU+nMng6ZA==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@esbuild/freebsd-arm64@0.25.7": { + "integrity": "sha512-Bss7P4r6uhr3kDzRjPNEnTm/oIBdTPRNQuwaEFWT/uvt6A1YzK/yn5kcx5ZxZ9swOga7LqeYlu7bDIpDoS01bA==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@esbuild/freebsd-x64@0.25.7": { + "integrity": "sha512-S3BFyjW81LXG7Vqmr37ddbThrm3A84yE7ey/ERBlK9dIiaWgrjRlre3pbG7txh1Uaxz8N7wGGQXmC9zV+LIpBQ==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@esbuild/linux-arm64@0.25.7": { + "integrity": "sha512-HfQZQqrNOfS1Okn7PcsGUqHymL1cWGBslf78dGvtrj8q7cN3FkapFgNA4l/a5lXDwr7BqP2BSO6mz9UremNPbg==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@esbuild/linux-arm@0.25.7": { + "integrity": "sha512-JZMIci/1m5vfQuhKoFXogCKVYVfYQmoZJg8vSIMR4TUXbF+0aNlfXH3DGFEFMElT8hOTUF5hisdZhnrZO/bkDw==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@esbuild/linux-ia32@0.25.7": { + "integrity": "sha512-9Jex4uVpdeofiDxnwHRgen+j6398JlX4/6SCbbEFEXN7oMO2p0ueLN+e+9DdsdPLUdqns607HmzEFnxwr7+5wQ==", + "os": ["linux"], + "cpu": ["ia32"] + }, + "@esbuild/linux-loong64@0.25.7": { + "integrity": "sha512-TG1KJqjBlN9IHQjKVUYDB0/mUGgokfhhatlay8aZ/MSORMubEvj/J1CL8YGY4EBcln4z7rKFbsH+HeAv0d471w==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@esbuild/linux-mips64el@0.25.7": { + "integrity": "sha512-Ty9Hj/lx7ikTnhOfaP7ipEm/ICcBv94i/6/WDg0OZ3BPBHhChsUbQancoWYSO0WNkEiSW5Do4febTTy4x1qYQQ==", + "os": ["linux"], + "cpu": ["mips64el"] + }, + "@esbuild/linux-ppc64@0.25.7": { + "integrity": "sha512-MrOjirGQWGReJl3BNQ58BLhUBPpWABnKrnq8Q/vZWWwAB1wuLXOIxS2JQ1LT3+5T+3jfPh0tyf5CpbyQHqnWIQ==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@esbuild/linux-riscv64@0.25.7": { + "integrity": "sha512-9pr23/pqzyqIZEZmQXnFyqp3vpa+KBk5TotfkzGMqpw089PGm0AIowkUppHB9derQzqniGn3wVXgck19+oqiOw==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@esbuild/linux-s390x@0.25.7": { + "integrity": "sha512-4dP11UVGh9O6Y47m8YvW8eoA3r8qL2toVZUbBKyGta8j6zdw1cn9F/Rt59/Mhv0OgY68pHIMjGXWOUaykCnx+w==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@esbuild/linux-x64@0.25.7": { + "integrity": "sha512-ghJMAJTdw/0uhz7e7YnpdX1xVn7VqA0GrWrAO2qKMuqbvgHT2VZiBv1BQ//VcHsPir4wsL3P2oPggfKPzTKoCA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@esbuild/netbsd-arm64@0.25.7": { + "integrity": "sha512-bwXGEU4ua45+u5Ci/a55B85KWaDSRS8NPOHtxy2e3etDjbz23wlry37Ffzapz69JAGGc4089TBo+dGzydQmydg==", + "os": ["netbsd"], + "cpu": ["arm64"] + }, + "@esbuild/netbsd-x64@0.25.7": { + "integrity": "sha512-tUZRvLtgLE5OyN46sPSYlgmHoBS5bx2URSrgZdW1L1teWPYVmXh+QN/sKDqkzBo/IHGcKcHLKDhBeVVkO7teEA==", + "os": ["netbsd"], + "cpu": ["x64"] + }, + "@esbuild/openbsd-arm64@0.25.7": { + "integrity": "sha512-bTJ50aoC+WDlDGBReWYiObpYvQfMjBNlKztqoNUL0iUkYtwLkBQQeEsTq/I1KyjsKA5tyov6VZaPb8UdD6ci6Q==", + "os": ["openbsd"], + "cpu": ["arm64"] + }, + "@esbuild/openbsd-x64@0.25.7": { + "integrity": "sha512-TA9XfJrgzAipFUU895jd9j2SyDh9bbNkK2I0gHcvqb/o84UeQkBpi/XmYX3cO1q/9hZokdcDqQxIi6uLVrikxg==", + "os": ["openbsd"], + "cpu": ["x64"] + }, + "@esbuild/openharmony-arm64@0.25.7": { + "integrity": "sha512-5VTtExUrWwHHEUZ/N+rPlHDwVFQ5aME7vRJES8+iQ0xC/bMYckfJ0l2n3yGIfRoXcK/wq4oXSItZAz5wslTKGw==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@esbuild/sunos-x64@0.25.7": { + "integrity": "sha512-umkbn7KTxsexhv2vuuJmj9kggd4AEtL32KodkJgfhNOHMPtQ55RexsaSrMb+0+jp9XL4I4o2y91PZauVN4cH3A==", + "os": ["sunos"], + "cpu": ["x64"] + }, + "@esbuild/win32-arm64@0.25.7": { + "integrity": "sha512-j20JQGP/gz8QDgzl5No5Gr4F6hurAZvtkFxAKhiv2X49yi/ih8ECK4Y35YnjlMogSKJk931iNMcd35BtZ4ghfw==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@esbuild/win32-ia32@0.25.7": { + "integrity": "sha512-4qZ6NUfoiiKZfLAXRsvFkA0hoWVM+1y2bSHXHkpdLAs/+r0LgwqYohmfZCi985c6JWHhiXP30mgZawn/XrqAkQ==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@esbuild/win32-x64@0.25.7": { + "integrity": "sha512-FaPsAHTwm+1Gfvn37Eg3E5HIpfR3i6x1AIcla/MkqAIupD4BW3MrSeUqfoTzwwJhk3WE2/KqUn4/eenEJC76VA==", + "os": ["win32"], + "cpu": ["x64"] + }, "@img/sharp-darwin-arm64@0.33.5": { "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", "optionalDependencies": [ @@ -281,14 +530,14 @@ "vfile" ] }, - "@opentelemetry/api@1.10.0": { - "integrity": "sha512-lMkyes8iHrG+mk6WH7X70MdCLI2RS0FwNBY1PyQ8UemaijE2m5mv5A0Niy3HtbYTFrgkUEQjFMXmSAk2hQDuPQ==" + "@opentelemetry/api@1.9.1": { + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==" }, - "@preact/signals-core@1.14.5": { - "integrity": "sha512-vcRv7Ityz5JVmeiVqIluLFzT/PDS/AlhHUpw3/aUWqcVUs6bDGIPXE/6RSC4evBNWYvnniozg5Syfj33al3gkg==" + "@preact/signals-core@1.14.4": { + "integrity": "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA==" }, - "@preact/signals@2.11.4_preact@10.29.8__preact-render-to-string@6.8.2_preact-render-to-string@6.8.2__preact@10.29.8": { - "integrity": "sha512-Rb+5RPo9gWSaHtzJeauhHxiC8KP/IF2DEPkHPEOGOnuy2wnJzRqyeLRankUCYd6ADqtxS6GnWGv6Ie343d8aYw==", + "@preact/signals@2.11.1_preact@10.29.8__preact-render-to-string@6.7.0": { + "integrity": "sha512-uYoD+USkacTNU02kfCBkJEV7xabKTmA78W5pnT2GymsuGEC9n/ZO+UT4S96l0dIL6KLDk77VJyUFuOMBvftVHg==", "dependencies": [ "@preact/signals-core", "preact" @@ -327,6 +576,18 @@ "@types/ms@2.1.0": { "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" }, + "@types/node@26.4.0": { + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", + "dependencies": [ + "undici-types" + ] + }, + "@types/nodemailer@6.4.24": { + "integrity": "sha512-Ww4u0rT9wQNXh4JiQaIwx3QWdcOFXzOjQA2zc+jtFYNmQiT4mIUqcDin51bDFdkzKubFnQCZNK7FIHlPKQ/q9w==", + "dependencies": [ + "@types/node" + ] + }, "@types/unist@2.0.11": { "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==" }, @@ -451,6 +712,43 @@ "vfile-message" ] }, + "esbuild-wasm@0.25.12": { + "integrity": "sha512-rZqkjL3Y6FwLpSHzLnaEy8Ps6veCNo1kZa9EOfJvmWtBq5dJH4iVjfmOO6Mlkv9B0tt9WFPFmb/VxlgJOnueNg==", + "bin": true + }, + "esbuild@0.25.7": { + "integrity": "sha512-daJB0q2dmTzo90L9NjRaohhRWrCzYxWNFTjEi72/h+p5DcY3yn4MacWfDakHmaBaDzDiuLJsCh0+6LK/iX+c+Q==", + "optionalDependencies": [ + "@esbuild/aix-ppc64", + "@esbuild/android-arm", + "@esbuild/android-arm64", + "@esbuild/android-x64", + "@esbuild/darwin-arm64", + "@esbuild/darwin-x64", + "@esbuild/freebsd-arm64", + "@esbuild/freebsd-x64", + "@esbuild/linux-arm", + "@esbuild/linux-arm64", + "@esbuild/linux-ia32", + "@esbuild/linux-loong64", + "@esbuild/linux-mips64el", + "@esbuild/linux-ppc64", + "@esbuild/linux-riscv64", + "@esbuild/linux-s390x", + "@esbuild/linux-x64", + "@esbuild/netbsd-arm64", + "@esbuild/netbsd-x64", + "@esbuild/openbsd-arm64", + "@esbuild/openbsd-x64", + "@esbuild/openharmony-arm64", + "@esbuild/sunos-x64", + "@esbuild/win32-arm64", + "@esbuild/win32-ia32", + "@esbuild/win32-x64" + ], + "scripts": true, + "bin": true + }, "escape-string-regexp@5.0.0": { "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==" }, @@ -513,8 +811,8 @@ "extend@3.0.2": { "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, - "fast-equals@5.4.5": { - "integrity": "sha512-yKEpcFuRPu/pTQL2VB8YKE2yFldrLlIsoWtJeBnAdqEhvu21OgkFT0C8njf/xYc2LSZGEr3K9JGjthHJxnVLvQ==" + "fast-equals@5.4.1": { + "integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==" }, "gray-matter@4.0.3": { "integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==", @@ -1142,6 +1440,9 @@ "ms@2.1.3": { "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, + "nodemailer@6.10.1": { + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==" + }, "parse-entities@4.0.2": { "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", "dependencies": [ @@ -1154,8 +1455,8 @@ "is-hexadecimal" ] }, - "polizy@0.6.1": { - "integrity": "sha512-YxhcG5Qb3Oms9XVqxnwTUdmL5rLTn0GqTgA3HI11yuQLIT+tIS7xTnyE2aAjuTFZRtohocTPZmx3UuNcDH2GZQ==", + "polizy@0.6.0": { + "integrity": "sha512-yoTY8wmuydsMOXdDb7RntkecXQAT9kjxr/0XXpnzEJe2hgjrGo1QqEg4WG7skfzK80fbY2TeQB+z9pblgzm7cw==", "dependencies": [ "fast-equals", "tslib" @@ -1164,13 +1465,13 @@ "postgres@3.4.9": { "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==" }, - "preact-render-to-string@6.8.2_preact@10.29.8": { - "integrity": "sha512-Hdx7cKD3AMYgt795TdddBpUbuGEFoL/VeA6s7VrBt9Gjt0r6rCy5wMp5xkDjQ9cbKj62frHsEx1x8yV6EjoOpg==", + "preact-render-to-string@6.7.0_preact@10.29.8": { + "integrity": "sha512-Z4WR8fmLMRpdYqJ9i7vrlXSsSrxVJydwrkEXHapexfARbWfGb7vGcnvNQnIzN0cXciMVOlz/XLoiMCi9gUsy9Q==", "dependencies": [ "preact" ] }, - "preact@10.29.8_preact-render-to-string@6.8.2": { + "preact@10.29.8_preact-render-to-string@6.7.0": { "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==", "dependencies": [ "preact-render-to-string" @@ -1371,6 +1672,9 @@ "tslib@2.8.1": { "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "undici-types@8.3.0": { + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==" + }, "unified@11.0.5": { "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", "dependencies": [ @@ -1546,7 +1850,7 @@ "workspace": { "dependencies": [ "jsr:@db/sqlite@0.12", - "jsr:@dune/plugin-admin@*", + "jsr:@dune/plugin-admin@3", "jsr:@fresh/core@2", "jsr:@std/assert@1", "jsr:@std/crypto@1", From b0d38c0878dd3fb5becbd13d9211108dbea36296 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 16:20:21 +0100 Subject: [PATCH 15/31] fix(authz)!: site users' roles grant editing, checked on app:site (#130) The authz system now exists whenever the site has a login (site.auth), a plugin provides auth.session, or a plugin declares authzActions, and mountDuneAuth reuses that one instance. admin, editor and author are granted once at group level (group: holds on app:site), so the role membership site auth already syncs is enough: no per-user tuple, no admin plugin. The resource is renamed from app:admin to app:site; tuples are derived data and re-seeded at every start. Closes #130 Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 16 +++ src/auth/authz-schema.ts | 22 ++-- src/auth/authz.ts | 29 +++++- src/auth/mount.ts | 6 +- src/cli/response-transforms.ts | 4 +- src/cli/users-create.ts | 9 +- src/cli/users-role.ts | 11 +- src/mod.ts | 6 ++ src/runtime/bootstrap.ts | 19 ++-- src/runtime/server.ts | 10 +- tests/auth/authz_plugin_actions_test.ts | 6 +- tests/auth/authz_test.ts | 26 ++--- tests/cli/users_create_test.ts | 4 +- tests/cli/users_role_test.ts | 8 +- tests/runtime/site_user_editing_test.ts | 129 ++++++++++++++++++++++++ 15 files changed, 252 insertions(+), 53 deletions(-) create mode 100644 tests/runtime/site_user_editing_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cba1d0f3..25b498b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,6 +139,13 @@ where the setting lives now (see `dune migrate:config`, which reports these too) ### Changed — BREAKING +- **Site-wide permissions are checked on `app:site`, not `app:admin` (#130).** `pages.update` + and the other admin-tier actions name what they protect (this site's content), not the panel + that happens to administer it, so the same check works on a site with no admin, one admin + plugin or another. Tuples are derived data and are re-seeded at every start; a store protected + with `DUNE_AUTHZ_HMAC_SECRET` needs `dune authz:sign` once after upgrading. A plugin that + checks the resource itself uses `SITE_RESOURCE` (`@dune/core`); `@dune/plugin-admin` 4.0.0 + and `@dune/testing` 2.0.0 already do. - **Core no longer assumes the built-in admin's session cookie or its `admin.enabled` switch (#97).** The `auth.session` provider declares the cookies that carry its session (`RequestAuthenticator.sessionCookies`); core uses them to decide whether to call @@ -335,6 +342,15 @@ where the setting lives now (see `dune migrate:config`, which reports these too) ### Fixed +- **A signed-in site user with an editing role can edit inline without the admin plugin + (#130).** The authz system is now created whenever the site has a login (`site.auth`), a + plugin provides `auth.session`, or a plugin declares `authzActions`; `mountDuneAuth` reuses + that one instance. The `admin`, `editor` and `author` roles are granted once at group level + (`group:editor` holds `editor` on `app:site`), so a user's membership, which site auth + already keeps in sync, is enough. No per-user tuple and no admin plugin needed. A role that + is only a content tag (`member`, `subscriber`, ...) still grants nothing. Also applies to an + `external-jwt` site: a role claim of `admin`, `editor` or `author` in the identity provider's + token now carries that permission. - **`search.custom_fields`, `search.fields`, `search.highlight` and `search.excerpt_length` now do what the docs say (#97).** They were validated and documented, but bootstrap never passed them to the search engine, so setting diff --git a/src/auth/authz-schema.ts b/src/auth/authz-schema.ts index 39bc3a05..b182ac46 100644 --- a/src/auth/authz-schema.ts +++ b/src/auth/authz-schema.ts @@ -5,7 +5,7 @@ * * Covers all authorization layers uniformly: * - Content gating (`roles:` frontmatter) — "access" on object type "group" - * - Admin panel access + granular admin permissions — actions on object type "app" + * - Site-wide editing permissions — actions on object `app:site` ({@link SITE_RESOURCE}) * - Inline editing — "edit" on object type "resource" * - Resource-level grants — "owner" relation on object type "resource" * @@ -16,11 +16,11 @@ * `roles: member` → authz.check({ who: user, canThey: "access", onWhat: { type: "group", id: "member" } }) * authz.addMember({ member: { type: "user", id }, group: { type: "group", id: "member" } }) * - * ## Admin panel access (top-level) - * authz.check({ who: adminUser, canThey: "access", onWhat: { type: "app", id: "admin" } }) + * ## Site access (top-level) + * authz.check({ who: adminUser, canThey: "access", onWhat: { type: "app", id: "site" } }) * - * ## Granular admin permissions - * authz.check({ who: adminUser, canThey: "pages.create", onWhat: { type: "app", id: "admin" } }) + * ## Granular permissions + * authz.check({ who: adminUser, canThey: "pages.create", onWhat: { type: "app", id: "site" } }) * * The admin permission actions below are the sole, canonical *built-in* * permission definition — `@dune/plugin-admin` no longer keeps its own @@ -35,6 +35,16 @@ import { defineSchema } from "polizy"; import type { AuthSchema } from "polizy"; import type { Role } from "../config/role.ts"; +/** + * The object every site-wide permission is checked against: "may this user + * edit this site's content". It names what is protected, not who administers + * it, so it is the same on a site with no admin panel and for any admin + * plugin. Admin-tier roles hold their relation on it: site users through + * their role's group ({@link bootstrapRoleGrants}), the admin's own accounts + * through direct tuples (`AuthzService.seedAdminTuples`). + */ +export const SITE_RESOURCE = { type: "app", id: "site" } as const; + /** * A polizy relation name a plugin-contributed action can require. Limited * to Dune's existing built-in relations (no plugin can define a *new* @@ -136,7 +146,7 @@ export function buildDuneAuthzSchema( ...DUNE_BASE_AUTHZ_ACTIONS, ...pluginActions, }, - subjectTypes: ["user"] as const, + subjectTypes: ["user", "group"] as const, objectTypes: ["group", "app", "resource"] as const, }); } diff --git a/src/auth/authz.ts b/src/auth/authz.ts index 6178e0c1..9cddc988 100644 --- a/src/auth/authz.ts +++ b/src/auth/authz.ts @@ -42,7 +42,7 @@ import { AuthSystem } from "polizy"; import type { AuthSchema } from "polizy"; -import { duneAuthzSchema } from "./authz-schema.ts"; +import { duneAuthzSchema, SITE_RESOURCE } from "./authz-schema.ts"; import { AuthzLocalAdapter } from "./authz-adapter-local.ts"; import { AuthzDbAdapter } from "./authz-adapter-db.ts"; import type { StorageAdapter } from "../storage/types.ts"; @@ -209,7 +209,7 @@ export async function bootstrapRoleTuples( /** * Bootstrap permission tuples for existing admin User records (@dune/plugin-admin). * - * Each admin user gets a direct relation tuple on `{ type: "app", id: "admin" }` + * Each admin user gets a direct relation tuple on {@link SITE_RESOURCE} * matching their role ("admin", "editor", or "author"). Idempotent — skips * tuples that already exist. * @@ -223,7 +223,7 @@ export async function bootstrapAdminTuples( ): Promise { for (const user of adminUsers) { const subject = { type: "user" as const, id: user.id }; - const object = { type: "app" as const, id: "admin" }; + const object = SITE_RESOURCE; const exists = await adapter.hasTuple(subject, user.role, object); if (!exists) { await authz.allow({ @@ -234,3 +234,26 @@ export async function bootstrapAdminTuples( } } } + +/** + * Grant each admin-tier role its relation on {@link SITE_RESOURCE} once, at + * group level: `group:editor` holds `editor` on `app:site`. A site user whose + * role is `editor` is already a member of `group:editor` (site auth keeps + * that in sync, see {@link bootstrapRoleTuples}), so membership is all it + * takes, with no per-user tuple to write, forget, or leave behind on revoke. + * The admin's own accounts get direct tuples ({@link bootstrapAdminTuples}); + * the two do not conflict, since `authz.check()` is true if either holds. + * + * Idempotent. Called wherever an authz system is created, so a site that has + * a login but no admin plugin still lets its editors edit. + */ +export async function bootstrapRoleGrants( + authz: DuneAuthSystem, + adapter: AuthzAdapterLike, +): Promise { + for (const role of ["admin", "editor", "author"] as const) { + const group = { type: "group" as const, id: role }; + if (await adapter.hasTuple(group, role, SITE_RESOURCE)) continue; + await authz.allow({ who: group, toBe: role, onWhat: SITE_RESOURCE }); + } +} diff --git a/src/auth/mount.ts b/src/auth/mount.ts index 77919575..407711b0 100644 --- a/src/auth/mount.ts +++ b/src/auth/mount.ts @@ -25,7 +25,7 @@ import { createProviders } from "./providers/mod.ts"; import { type User, USER_HEADER } from "./types.ts"; import { copySocketAddr } from "../security/rate-limit.ts"; import { InMemoryMagicTokenStore } from "./magic-link.ts"; -import { bootstrapRoleTuples, createDuneAuthSystem } from "./authz.ts"; +import { bootstrapRoleGrants, bootstrapRoleTuples, createDuneAuthSystem } from "./authz.ts"; import type { DuneAuthSystem } from "./authz.ts"; import type { AuthzLocalAdapter } from "./authz-adapter-local.ts"; import { setGatingAuthz } from "./gating.ts"; @@ -192,6 +192,7 @@ export async function mountDuneAuth( setGatingAuthz(bundle.authz, siteOrigin); mountAuthz = bundle.authz; mountAdapter = bundle.adapter; + await bootstrapRoleGrants(bundle.authz, bundle.adapter).catch(() => {}); } else if (authzStoreCfg === "local") { const existingAuthz = ctx.authz as DuneAuthSystem | undefined; const existingAdapter = ctx.authzAdapter as AuthzLocalAdapter | undefined; @@ -212,6 +213,9 @@ export async function mountDuneAuth( setGatingAuthz(bundle.authz, siteOrigin); mountAuthz = bundle.authz; mountAdapter = bundle.adapter; + // Idempotent; bootstrap() already did this for its own bundle, a headless + // caller's fresh one has not been seeded yet. + await bootstrapRoleGrants(bundle.authz, bundle.adapter).catch(() => {}); if (mode === "dune" && userStoreType !== "session") { // Bulk-bootstrap from existing site users' roles[] — idempotent. diff --git a/src/cli/response-transforms.ts b/src/cli/response-transforms.ts index d1668544..70e65066 100644 --- a/src/cli/response-transforms.ts +++ b/src/cli/response-transforms.ts @@ -41,7 +41,7 @@ import type { DuneConfig } from "../config/types.ts"; import type { AdminBarAction, AdminBarActionContext, DunePlugin, ResponseTransformContext } from "../hooks/types.ts"; import type { DuneAuthSystem } from "../auth/authz.ts"; import type { PageIndex } from "../content/types.ts"; -import { highestAdminRole, roleHasPermission } from "../auth/authz-schema.ts"; +import { highestAdminRole, roleHasPermission, SITE_RESOURCE } from "../auth/authz-schema.ts"; import { applyResponseTransforms } from "../plugins/loader.ts"; import { hasSessionCookie } from "./admin-bar-inject.ts"; import { isAdminPath } from "./serve-utils.ts"; @@ -158,7 +158,7 @@ export async function runPluginResponseTransforms( who: { type: "user", id: r.user.id as string }, // deno-lint-ignore no-explicit-any canThey: "pages.update" as any, - onWhat: { type: "app", id: "admin" }, + onWhat: SITE_RESOURCE, }) : false ); diff --git a/src/cli/users-create.ts b/src/cli/users-create.ts index 552da42f..3141e187 100644 --- a/src/cli/users-create.ts +++ b/src/cli/users-create.ts @@ -24,7 +24,7 @@ * or an OAuth provider name. * * Reuses the same UserStore primitive users-role.ts's grant/revoke-role - * commands do, and syncs the app:admin authz tuple the same way when an + * commands do, and syncs the app:site authz tuple the same way when an * admin-tier role (admin/editor/author) is included, so a pre-granted * admin-tier role is immediately effective — not just present in roles[] * but inert until the user's first login re-syncs it. @@ -52,6 +52,7 @@ import { import type { UserStore } from "../auth/user-store.ts"; import { createDbUserStore } from "../auth/user-store-db.ts"; import { createDuneAuthSystem } from "../auth/authz.ts"; +import { SITE_RESOURCE } from "../auth/authz-schema.ts"; import { loadHmacKeyFromEnv } from "../auth/authz-hmac.ts"; import { createDbAdapter } from "../db/adapters/mod.ts"; import type { DbAdapter } from "../db/types.ts"; @@ -107,7 +108,7 @@ async function openContext(root: string): Promise { return { storage, dataDir, userStore, closeDb: () => Promise.resolve() }; } -/** Grant the app:admin authz tuple for an admin-tier role, if one was requested. */ +/** Grant the app:site authz tuple for an admin-tier role, if one was requested. */ async function syncAdminTuple( ctx: Context, userId: string, @@ -127,7 +128,7 @@ async function syncAdminTuple( await authz.allow({ who: { type: "user", id: userId }, toBe: role as "admin" | "editor" | "author", - onWhat: { type: "app", id: "admin" }, + onWhat: SITE_RESOURCE, }); } @@ -207,7 +208,7 @@ export async function usersCreateCommand( })`, ); if (adminTierRole) { - console.log(` ~ Would sync app:admin tuple (none → ${adminTierRole})`); + console.log(` ~ Would sync app:site tuple (none → ${adminTierRole})`); } return; } diff --git a/src/cli/users-role.ts b/src/cli/users-role.ts index b2839dd4..9f7804f3 100644 --- a/src/cli/users-role.ts +++ b/src/cli/users-role.ts @@ -7,7 +7,7 @@ * dec-identity-unification Phase 6's role-granting mechanism. The admin * panel's `PUT /admin/api/users/:id` route already does this over HTTP; these * commands do the same two things it does — update the user's `roles[]` and - * sync the `app:admin` authz tuple `authz.check()` actually reads — for + * sync the `app:site` authz tuple `authz.check()` actually reads — for * operators without (or before) web access: first-admin bootstrap on a * headless install, scripted/CI-driven site provisioning, or granting access * to an existing OAuth/magic-link account that has never had an admin-tier @@ -39,6 +39,7 @@ import { createLocalUserStore } from "../auth/user-store.ts"; import type { UserStore } from "../auth/user-store.ts"; import { createDbUserStore } from "../auth/user-store-db.ts"; import { createDuneAuthSystem } from "../auth/authz.ts"; +import { SITE_RESOURCE } from "../auth/authz-schema.ts"; import { loadHmacKeyFromEnv } from "../auth/authz-hmac.ts"; import { createDbAdapter } from "../db/adapters/mod.ts"; import type { DbAdapter } from "../db/types.ts"; @@ -82,7 +83,7 @@ async function openContext(root: string): Promise { return { storage, dataDir, userStore, closeDb: () => Promise.resolve() }; } -/** Sync the app:admin authz tuple to match `newRole` (undefined = no admin-tier role). */ +/** Sync the app:site authz tuple to match `newRole` (undefined = no admin-tier role). */ async function syncAdminTuple( ctx: Context, userId: string, @@ -103,7 +104,7 @@ async function syncAdminTuple( await authz.disallowAllMatching({ who: { type: "user", id: userId }, was: previousRole as "admin" | "editor" | "author", - onWhat: { type: "app", id: "admin" }, + onWhat: SITE_RESOURCE, }).catch((err) => { console.warn(` ⚠️ Failed to revoke old "${previousRole}" tuple:`, err); }); @@ -112,7 +113,7 @@ async function syncAdminTuple( await authz.allow({ who: { type: "user", id: userId }, toBe: newRole as "admin" | "editor" | "author", - onWhat: { type: "app", id: "admin" }, + onWhat: SITE_RESOURCE, }); } } @@ -179,7 +180,7 @@ async function changeRole( } — would update`, ); console.log( - ` ~ ${email}: would sync app:admin tuple (${ + ` ~ ${email}: would sync app:site tuple (${ previousRole ?? "none" } → ${newRole ?? "none"})`, ); diff --git a/src/mod.ts b/src/mod.ts index cb8c6ea4..6dbc3e78 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -318,7 +318,13 @@ export { bootstrapRoleTuples, /** Bootstrap permission tuples for existing admin User records (@dune/plugin-admin). @since 1.0.0 */ bootstrapAdminTuples, + /** Grant the admin-tier roles their relation on SITE_RESOURCE at group level. @since 0.36.0 */ + bootstrapRoleGrants, } from "./auth/authz.ts"; +export { + /** The object site-wide permissions are checked on: `app:site`. @since 0.36.0 */ + SITE_RESOURCE, +} from "./auth/authz-schema.ts"; export type { /** The Dune-configured AuthSystem type. @since 1.0.0 */ DuneAuthSystem, diff --git a/src/runtime/bootstrap.ts b/src/runtime/bootstrap.ts index 48d4475e..013d6946 100644 --- a/src/runtime/bootstrap.ts +++ b/src/runtime/bootstrap.ts @@ -39,7 +39,7 @@ import { createHistoryEngine } from "../history/engine.ts"; import { createFlexEngine } from "../flex/engine.ts"; import { loadPluginAdminConfigs, loadPlugins } from "../plugins/loader.ts"; import { MetricsCollector } from "../metrics/mod.ts"; -import { createDuneAuthSystem } from "../auth/authz.ts"; +import { bootstrapRoleGrants, createDuneAuthSystem } from "../auth/authz.ts"; import type { DuneAuthSystem } from "../auth/authz.ts"; import type { AuthzLocalAdapter } from "../auth/authz-adapter-local.ts"; import type { AuthzDbAdapter } from "../auth/authz-adapter-db.ts"; @@ -528,15 +528,17 @@ export async function bootstrap( }); // The authz system exists when something needs it: a plugin that provides - // `auth.session` has declared itself the admin (without authz its sessions - // could never be authorised: `pages.update` fails closed when authz is - // missing), or a plugin declares `authzActions` of its own. Plugins register, - // and so declare, before this point; settle async setup() first so a - // declaration made after an await is seen too. + // `auth.session` (without authz its sessions could never be authorised: + // `pages.update` fails closed when authz is missing), a plugin that declares + // `authzActions` of its own, or the site's own login (`site.auth`, whose + // users are authorised through it for page gating and inline editing). + // Plugins register, and so declare, before this point; settle async setup() + // first so a declaration made after an await is seen too. await hooks.whenSetupComplete(); const hasSessionProvider = capabilityRegistry.reader.providers().some((p) => p.name === "auth.session"); const hasAuthzActions = Object.keys(pluginAuthzActions).length > 0; - if (hasSessionProvider || hasAuthzActions) { + const hasSiteAuth = !!config.site.auth; + if (hasSessionProvider || hasAuthzActions || hasSiteAuth) { try { const bundle = createDuneAuthSystem({ authzStore: "local", @@ -547,6 +549,9 @@ export async function bootstrap( bootstrappedAuthz = bundle.authz; bootstrappedAuthzAdapter = bundle.adapter; authzForServices = { authz: bundle.authz, adapter: bundle.adapter }; + // Admin-tier roles hold their relation on the site at group level, so a + // site user's role is enough, with or without an admin plugin. + await bootstrapRoleGrants(bundle.authz, bundle.adapter); // Admin-user tuples are bootstrapped by @dune/plugin-admin's mount() hook // after it creates the UserManager. Core only creates the empty system here. } catch (err) { diff --git a/src/runtime/server.ts b/src/runtime/server.ts index 2c606fed..c981336a 100644 --- a/src/runtime/server.ts +++ b/src/runtime/server.ts @@ -15,6 +15,7 @@ import { App, staticFiles } from "fresh"; import { captureHandlerSocketAddr, copySocketAddr } from "../security/rate-limit.ts"; import type { BootstrapResult } from "./bootstrap.ts"; import type { DuneAuthSystem } from "../auth/authz.ts"; +import { SITE_RESOURCE } from "../auth/authz-schema.ts"; import { mountPlugins } from "../plugins/loader.ts"; import { mountDuneAuth } from "../auth/mount.ts"; import { getUser, USER_HEADER } from "../auth/types.ts"; @@ -216,7 +217,7 @@ function checkPagesUpdateViaAuthz( who: { type: "user", id: userId }, // deno-lint-ignore no-explicit-any canThey: "pages.update" as any, - onWhat: { type: "app", id: "admin" }, + onWhat: SITE_RESOURCE, }); } @@ -456,8 +457,9 @@ export async function createDuneApp( // no /auth/* routes, no per-request middleware). mountDuneAuth() must run // after mountPlugins() so it can reuse the authz bundle bootstrap()/the // admin plugin already built (see mount.ts's own comment on this). + let publicAuthz: DuneAuthSystem | null = null; if (mountAuth && config.site.auth) { - await mountDuneAuth(app, ctx); + publicAuthz = (await mountDuneAuth(app, ctx)).authz; } // 6. Inline-edit WebSocket — in core so it works without @dune/plugin-admin. @@ -495,8 +497,10 @@ export async function createDuneApp( if (siteUser) { userId = siteUser.id; username = siteUser.username ?? siteUser.name ?? siteUser.email; + // The public-auth system is the one holding site users' tuples; when + // `site.auth.authz_store` is "db" that is not bootstrap()'s local one. permitted = await checkInlineEditPermissionForSiteUser( - ctx.authz, + publicAuthz ?? ctx.authz, siteUser, ); } else { diff --git a/tests/auth/authz_plugin_actions_test.ts b/tests/auth/authz_plugin_actions_test.ts index a27b22a7..7799f8c2 100644 --- a/tests/auth/authz_plugin_actions_test.ts +++ b/tests/auth/authz_plugin_actions_test.ts @@ -74,14 +74,14 @@ Deno.test( await authz.allow({ who: { type: "user", id: "alice" }, toBe: "admin", - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }); const allowed = await authz.check({ who: { type: "user", id: "alice" }, // deno-lint-ignore no-explicit-any canThey: "billing.manage" as any, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }); assertEquals(allowed, true); @@ -89,7 +89,7 @@ Deno.test( who: { type: "user", id: "bob" }, // deno-lint-ignore no-explicit-any canThey: "billing.manage" as any, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }); assertEquals(deniedForBob, false); } finally { diff --git a/tests/auth/authz_test.ts b/tests/auth/authz_test.ts index dc5e0573..ba660903 100644 --- a/tests/auth/authz_test.ts +++ b/tests/auth/authz_test.ts @@ -253,7 +253,7 @@ Deno.test("AuthzLocalAdapter: indexed lookups stay correct across many tuples an await adapter.write([{ subject: { type: "user", id: `user-${i}` }, relation: "editor", - object: { type: "app", id: "admin" }, + object: { type: "app", id: "site" }, }]); } @@ -271,12 +271,12 @@ Deno.test("AuthzLocalAdapter: indexed lookups stay correct across many tuples an assertEquals(evens.every((s) => Number(s.id.split("-")[1]) % 2 === 0), true); const editors = await adapter.findSubjects( - { type: "app", id: "admin" }, + { type: "app", id: "site" }, "editor", ); assertEquals(editors.length, 5); - // findObjects: user-0 is a member of "member" and "even", and editor of admin. + // findObjects: user-0 is a member of "member" and "even", and editor of the site. const user0Memberships = await adapter.findObjects({ type: "user", id: "user-0", @@ -288,7 +288,7 @@ Deno.test("AuthzLocalAdapter: indexed lookups stay correct across many tuples an id: "user-0", }, "editor"); assertEquals(user0EditorGrants.length, 1); - assertEquals(user0EditorGrants[0].id, "admin"); + assertEquals(user0EditorGrants[0].id, "site"); // hasTuple: exact subject+relation+object — must not match a near-miss // (right subject/object, wrong relation; right subject/relation, wrong object). @@ -411,7 +411,7 @@ Deno.test("AuthzLocalAdapter: indexes are rebuilt correctly when reloaded from d await first.write([{ subject: { type: "user", id: "henry" }, relation: "editor", - object: { type: "app", id: "admin" }, + object: { type: "app", id: "site" }, }]); // A fresh adapter instance over the same storage — forces the ensureLoaded() @@ -430,14 +430,14 @@ Deno.test("AuthzLocalAdapter: indexes are rebuilt correctly when reloaded from d assertStrictEquals( await second.hasTuple({ type: "user", id: "henry" }, "editor", { type: "app", - id: "admin", + id: "site", }), true, ); assertStrictEquals( await second.hasTuple({ type: "user", id: "henry" }, "member", { type: "app", - id: "admin", + id: "site", }), false, ); @@ -667,7 +667,7 @@ Deno.test("bootstrapAdminTuples: grants admin access via 'access' action", async await authz.check({ who: { type: "user", id: "admin-1" }, canThey: "access", - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }), true, ); @@ -675,7 +675,7 @@ Deno.test("bootstrapAdminTuples: grants admin access via 'access' action", async await authz.check({ who: { type: "user", id: "editor-1" }, canThey: "access", - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }), true, ); @@ -683,7 +683,7 @@ Deno.test("bootstrapAdminTuples: grants admin access via 'access' action", async await authz.check({ who: { type: "user", id: "author-1" }, canThey: "access", - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }), true, ); @@ -703,7 +703,7 @@ Deno.test("bootstrapAdminTuples: admin can manage users, editor cannot", async ( await authz.check({ who: { type: "user", id: "admin-1" }, canThey: action, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }), true, `admin should have ${action}`, @@ -712,7 +712,7 @@ Deno.test("bootstrapAdminTuples: admin can manage users, editor cannot", async ( await authz.check({ who: { type: "user", id: "editor-1" }, canThey: action, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }), false, `editor should not have ${action}`, @@ -747,7 +747,7 @@ Deno.test("bootstrapAdminTuples: unknown user has no admin access", async () => await authz.check({ who: { type: "user", id: "nobody" }, canThey: "access", - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }), false, ); diff --git a/tests/cli/users_create_test.ts b/tests/cli/users_create_test.ts index 229ffc62..5aad2d06 100644 --- a/tests/cli/users_create_test.ts +++ b/tests/cli/users_create_test.ts @@ -40,7 +40,7 @@ async function hasAdminTuple( return await adapter.hasTuple( { type: "user", id: userId }, relation, - { type: "app", id: "admin" }, + { type: "app", id: "site" }, ); } @@ -102,7 +102,7 @@ Deno.test("users:create sets arbitrary (non-admin-tier) role tags, no authz tupl }); }); -Deno.test("users:create syncs the app:admin authz tuple for an admin-tier role", async () => { +Deno.test("users:create syncs the app:site authz tuple for an admin-tier role", async () => { await withTempSite(async (root) => { await usersCreateCommand(root, "alice@example.com", { roles: ["editor"], diff --git a/tests/cli/users_role_test.ts b/tests/cli/users_role_test.ts index 828f76ff..24d6fa77 100644 --- a/tests/cli/users_role_test.ts +++ b/tests/cli/users_role_test.ts @@ -2,7 +2,7 @@ * Tests for `dune users:grant-role` / `dune users:revoke-role` — * dec-identity-unification Phase 6's CLI leg for the role-granting * mechanism. Grants/revokes an admin-tier role on an existing unified User - * (identified by email) and syncs the app:admin authz tuple + * (identified by email) and syncs the app:site authz tuple * `authz.check()` reads at permission-check time. */ @@ -80,7 +80,7 @@ async function hasAdminTuple( return await adapter.hasTuple( { type: "user", id: userId }, relation, - { type: "app", id: "admin" }, + { type: "app", id: "site" }, ); } @@ -127,7 +127,7 @@ Deno.test("users:grant-role adds the role to a role-less user's roles[]", async }); }); -Deno.test("users:grant-role syncs the app:admin authz tuple", async () => { +Deno.test("users:grant-role syncs the app:site authz tuple", async () => { await withTempSite(async (root) => { await writeUser(root, "u1.json", baseUser); @@ -242,7 +242,7 @@ Deno.test("users:revoke-role removes the role from roles[]", async () => { }); }); -Deno.test("users:revoke-role removes the app:admin authz tuple", async () => { +Deno.test("users:revoke-role removes the app:site authz tuple", async () => { await withTempSite(async (root) => { await writeUser(root, "u1.json", { ...baseUser, roles: [] }); await grantRoleCommand(root, "alice@example.com", "editor", {}); diff --git a/tests/runtime/site_user_editing_test.ts b/tests/runtime/site_user_editing_test.ts new file mode 100644 index 00000000..65bb1714 --- /dev/null +++ b/tests/runtime/site_user_editing_test.ts @@ -0,0 +1,129 @@ +/** + * A signed-in site user whose role is an editing role may edit inline, with + * or without an admin plugin (duneorg/dune#130). + * + * The site has a login (`site.auth`) and a stub plugin providing the + * `inline-edit` slot, and no admin. Before the fix the authz system did not + * exist for such a site, and where it did, a site user's role granted nothing + * because the permission lived in per-user tuples only the admin plugin wrote. + */ + +import { assertEquals } from "@std/assert"; +import { join } from "@std/path"; +import { bootstrap } from "../../src/runtime/bootstrap.ts"; +import { createDuneApp } from "../../src/runtime/server.ts"; +import type { DunePlugin } from "../../src/hooks/types.ts"; +import { createLocalUserStore } from "../../src/auth/user-store.ts"; +import { createSessionManager, createSessionStore } from "../../src/session/mod.ts"; +import { createStorage } from "../../src/storage/mod.ts"; +import { SITE_COOKIE_NAME } from "../../src/auth/middleware.ts"; + +const inlineEdit: DunePlugin = { + name: "test-inline-edit", + version: "1.0.0", + hooks: {}, + setup(api) { + api.capabilities.provide("inline-edit", () => ({ + handleUpgrade: () => new Response(null, { status: 204 }), + } as never)); + }, +}; + +const sessionProvider: DunePlugin = { + name: "test-session-provider", + version: "1.0.0", + hooks: {}, + setup(api) { + api.capabilities.provide("auth.session", () => ({ + authenticate: () => Promise.resolve({ authenticated: false }), + })); + }, +}; + +const ROLES = ["author", "editor", "admin", "member", "subscriber"]; + +/** + * Run `fn` against a site with a login and one existing user per role. The + * users exist before the site starts because that is when site auth seeds + * their group membership. + */ +async function withSite( + plugins: DunePlugin[], + fn: ( + ws: (cookie?: string) => Promise, + cookieFor: (role: string) => Promise, + ) => Promise, +) { + const root = await Deno.makeTempDir({ prefix: "dune_test_site_user_editing_" }); + 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\nHi\n"); + await Deno.mkdir(join(root, "config"), { recursive: true }); + await Deno.writeTextFile( + join(root, "config", "site.yaml"), + "title: Test\nurl: http://localhost\nauth:\n mode: dune\n", + ); + const userStore = createLocalUserStore({ storage: createStorage({ rootDir: root }), usersDir: "data/users" }); + const ids = new Map(); + for (const role of ROLES) { + const user = await userStore.create({ email: `${role}@example.com`, provider: "local", roles: [role] }); + ids.set(role, user.id); + } + const ctx = await bootstrap(root, { plugins: [inlineEdit, ...plugins] }); + const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); + const handler = app.handler(); + + const sessions = createSessionManager( + await createSessionStore({ + type: "local", + storage: ctx.storage, + sessionsDir: ".dune/admin/site-sessions", + lifetimeMs: 60_000, + }), + 60_000, + ); + const cookieFor = async (role: string) => { + const session = await sessions.create(ids.get(role)!); + return `${SITE_COOKIE_NAME}=${session.id}`; + }; + const ws = (cookie?: string) => + handler( + new Request("http://localhost/api/inline-edit/ws?path=01.home/default.md", { + headers: { + upgrade: "websocket", + origin: "http://localhost", + ...(cookie ? { cookie } : {}), + }, + }), + ); + await fn(ws, cookieFor); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +} + +const opts = { sanitizeOps: false, sanitizeResources: false }; + +Deno.test("login only, no admin: an editor may edit, a plain member may not", opts, async () => { + await withSite([], async (ws, cookieFor) => { + assertEquals((await ws(await cookieFor("editor"))).status, 204); + assertEquals((await ws(await cookieFor("member"))).status, 403); + assertEquals((await ws()).status, 401); + }); +}); + +Deno.test("login plus a plugin providing auth.session: same answers", opts, async () => { + await withSite([sessionProvider], async (ws, cookieFor) => { + assertEquals((await ws(await cookieFor("editor"))).status, 204); + assertEquals((await ws(await cookieFor("member"))).status, 403); + }); +}); + +Deno.test("every admin-tier role may edit pages, content tags may not", opts, async () => { + await withSite([], async (ws, cookieFor) => { + for (const role of ["author", "editor", "admin"]) { + assertEquals((await ws(await cookieFor(role))).status, 204, role); + } + assertEquals((await ws(await cookieFor("subscriber"))).status, 403); + }); +}); From 219119079ca6ce8b8c0f92cf4750ea745b5b8653 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 17:17:13 +0100 Subject: [PATCH 16/31] fix(authz): a role granted or revoked in a running app takes effect at once (#130) Group membership, which page gating and the admin-tier role grants read, was only seeded at startup, so any change to roles[] waited for a restart. User stores that core creates are now wrapped with withRoleSync: create, a roles update and delete make member-of-group tuples match the record. The CLI role commands sync the same way, and the payments webhook stops writing the membership twice on a synced store. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 8 ++ src/auth/mount.ts | 12 +++ src/auth/sync-roles.ts | 119 ++++++++++++++++++++++++ src/cli/users-create.ts | 10 +- src/cli/users-role.ts | 9 +- src/mod.ts | 6 ++ src/payments/manager.ts | 15 ++- src/plugin-api/create-services.ts | 5 +- tests/auth/sync_roles_test.ts | 109 ++++++++++++++++++++++ tests/payments/manager_test.ts | 25 +++++ tests/runtime/site_user_editing_test.ts | 18 +++- 11 files changed, 324 insertions(+), 12 deletions(-) create mode 100644 src/auth/sync-roles.ts create mode 100644 tests/auth/sync_roles_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 25b498b1..8a70e37a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -351,6 +351,14 @@ where the setting lives now (see `dune migrate:config`, which reports these too) is only a content tag (`member`, `subscriber`, ...) still grants nothing. Also applies to an `external-jwt` site: a role claim of `admin`, `editor` or `author` in the identity provider's token now carries that permission. +- **A role granted or revoked while the app runs now takes effect without a restart.** Group + membership (`member of group:`), which page gating and the grants above read, was only + seeded at startup, so a `roles[]` change made by an admin, a plugin or the CLI waited for the + next restart. User stores created by core (`services.users.createLocalStore()`, and the site + auth store) are now wrapped with `withRoleSync`: every `create`, `update` of `roles`, and + `delete` makes membership match the record. `dune users:create`, `users:grant-role` and + `users:revoke-role` do the same. The payments webhook no longer writes the membership twice on + such a store. - **`search.custom_fields`, `search.fields`, `search.highlight` and `search.excerpt_length` now do what the docs say (#97).** They were validated and documented, but bootstrap never passed them to the search engine, so setting diff --git a/src/auth/mount.ts b/src/auth/mount.ts index 407711b0..d8981e19 100644 --- a/src/auth/mount.ts +++ b/src/auth/mount.ts @@ -26,6 +26,7 @@ import { type User, USER_HEADER } from "./types.ts"; import { copySocketAddr } from "../security/rate-limit.ts"; import { InMemoryMagicTokenStore } from "./magic-link.ts"; import { bootstrapRoleGrants, bootstrapRoleTuples, createDuneAuthSystem } from "./authz.ts"; +import { withRoleSync } from "./sync-roles.ts"; import type { DuneAuthSystem } from "./authz.ts"; import type { AuthzLocalAdapter } from "./authz-adapter-local.ts"; import { setGatingAuthz } from "./gating.ts"; @@ -116,6 +117,17 @@ export async function mountDuneAuth( } else { userStore = createLocalUserStore({ storage, usersDir }); } + // Roles granted or revoked while the app runs (payments, an admin, a + // signup flow) reach the authz store at once. The target is read per write: + // the authz system below is created after the store. external-jwt reconciles + // per request instead, and its user store is read-only. + if (mode === "dune") { + const plain = userStore; + userStore = withRoleSync( + plain, + () => mountAuthz && mountAdapter ? { authz: mountAuthz, adapter: mountAdapter } : undefined, + ); + } // ── Session manager ───────────────────────────────────────────────────────── // Same SessionStore/SessionManager mechanism admin sessions use diff --git a/src/auth/sync-roles.ts b/src/auth/sync-roles.ts new file mode 100644 index 00000000..9df25ab7 --- /dev/null +++ b/src/auth/sync-roles.ts @@ -0,0 +1,119 @@ +/** + * Keep a user's group membership in the authz store equal to their `roles[]`. + * + * Page gating (`roles:` frontmatter) and the site-wide grants of the + * admin-tier roles ({@link bootstrapRoleGrants}) both read + * `member of group:` tuples, while `roles[]` on the user record is + * what administrators and payment hooks change. Seeding at startup + * ({@link bootstrapRoleTuples}) is not enough on its own: a role granted or + * revoked in a running app would only take effect after a restart. + * + * Wrap the user store with {@link withRoleSync} so every writer is covered + * and none has to remember to call it. + * + * @module + */ + +import type { DuneAuthSystem } from "./authz.ts"; +import type { UserStore } from "./user-store.ts"; +import { logger } from "../core/logger.ts"; + +/** What {@link syncUserRoles} needs from the authz bundle. */ +export interface RoleSyncTarget { + authz: DuneAuthSystem; + adapter: { + findObjects( + subject: { type: string; id: string }, + relation: string, + options?: { objectType?: string }, + ): Promise<{ type: string; id: string }[]>; + }; +} + +/** + * Make the user's `member of group:` tuples match `roles`: add the + * missing ones, remove the stale ones. Idempotent. + */ +export async function syncUserRoles( + target: RoleSyncTarget, + user: { id: string; roles: string[] }, +): Promise { + const who = { type: "user" as const, id: user.id }; + const wanted = new Set(user.roles); + const current = new Set( + (await target.adapter.findObjects(who, "member", { objectType: "group" })) + .map((g) => g.id), + ); + for (const role of wanted) { + if (!current.has(role)) { + await target.authz.addMember({ member: who, group: { type: "group", id: role } }); + } + } + for (const group of current) { + if (!wanted.has(group)) { + await target.authz.disallowAllMatching({ + who, + was: "member", + onWhat: { type: "group", id: group }, + }); + } + } +} + +/** Remove every tuple a deleted user holds. */ +export async function removeUserTuples( + target: RoleSyncTarget, + userId: string, +): Promise { + await target.authz.disallowAllMatching({ who: { type: "user", id: userId } }); +} + +/** + * A user store whose writes keep group membership in step with `roles[]`. + * + * `getTarget` is read on every write, so the store can be created before the + * authz system exists; with no target (no authz system) writes pass straight + * through. A failed sync is logged, not thrown: the user write has already + * happened, and the next start re-seeds membership from the records. + */ +export function withRoleSync( + store: UserStore, + getTarget: () => RoleSyncTarget | undefined, +): UserStore { + const sync = async (fn: (t: RoleSyncTarget) => Promise) => { + const target = getTarget(); + if (!target) return; + try { + await fn(target); + } catch (err) { + logger.error("authz.role_sync_failed", { reason: String(err) }); + } + }; + const overrides: Pick = { + async create(input) { + const user = await store.create(input); + await sync((t) => syncUserRoles(t, user)); + return user; + }, + async update(id, updates) { + const user = await store.update(id, updates); + if (user && updates.roles !== undefined) { + await sync((t) => syncUserRoles(t, user)); + } + return user; + }, + async delete(id) { + const deleted = await store.delete(id); + if (deleted) await sync((t) => removeUserTuples(t, id)); + return deleted; + }, + }; + // A proxy rather than a spread, so stores written as classes keep working. + return new Proxy(store, { + get(target, prop, receiver) { + if (prop in overrides) return overrides[prop as keyof typeof overrides]; + const value = Reflect.get(target, prop, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }); +} diff --git a/src/cli/users-create.ts b/src/cli/users-create.ts index 3141e187..a94717f4 100644 --- a/src/cli/users-create.ts +++ b/src/cli/users-create.ts @@ -53,6 +53,7 @@ import type { UserStore } from "../auth/user-store.ts"; import { createDbUserStore } from "../auth/user-store-db.ts"; import { createDuneAuthSystem } from "../auth/authz.ts"; import { SITE_RESOURCE } from "../auth/authz-schema.ts"; +import { syncUserRoles } from "../auth/sync-roles.ts"; import { loadHmacKeyFromEnv } from "../auth/authz-hmac.ts"; import { createDbAdapter } from "../db/adapters/mod.ts"; import type { DbAdapter } from "../db/types.ts"; @@ -113,18 +114,21 @@ async function syncAdminTuple( ctx: Context, userId: string, role: string | undefined, + roles: string[], ): Promise { - if (!role) return; + if (roles.length === 0) return; const hmacKey = await loadHmacKeyFromEnv().catch((err) => { console.error(" ⚠️ Invalid DUNE_AUTHZ_HMAC_SECRET:", err.message); return null; }); - const { authz } = createDuneAuthSystem({ + const { authz, adapter } = createDuneAuthSystem({ authzStore: "local", dataDir: ctx.dataDir, hmacKey, }, ctx.storage); + await syncUserRoles({ authz, adapter }, { id: userId, roles }); + if (!role) return; await authz.allow({ who: { type: "user", id: userId }, toBe: role as "admin" | "editor" | "author", @@ -237,7 +241,7 @@ export async function usersCreateCommand( throw err; } - await syncAdminTuple(ctx, user.id, adminTierRole); + await syncAdminTuple(ctx, user.id, adminTierRole, user.roles); console.log( ` ✓ Created ${normalizedEmail}${ diff --git a/src/cli/users-role.ts b/src/cli/users-role.ts index 9f7804f3..5d7ef9b7 100644 --- a/src/cli/users-role.ts +++ b/src/cli/users-role.ts @@ -40,6 +40,7 @@ import type { UserStore } from "../auth/user-store.ts"; import { createDbUserStore } from "../auth/user-store-db.ts"; import { createDuneAuthSystem } from "../auth/authz.ts"; import { SITE_RESOURCE } from "../auth/authz-schema.ts"; +import { syncUserRoles } from "../auth/sync-roles.ts"; import { loadHmacKeyFromEnv } from "../auth/authz-hmac.ts"; import { createDbAdapter } from "../db/adapters/mod.ts"; import type { DbAdapter } from "../db/types.ts"; @@ -89,17 +90,21 @@ async function syncAdminTuple( userId: string, previousRole: string | undefined, newRole: string | undefined, + roles: string[], ): Promise { const hmacKey = await loadHmacKeyFromEnv().catch((err) => { console.error(" ⚠️ Invalid DUNE_AUTHZ_HMAC_SECRET:", err.message); return null; }); - const { authz } = createDuneAuthSystem({ + const { authz, adapter } = createDuneAuthSystem({ authzStore: "local", dataDir: ctx.dataDir, hmacKey, }, ctx.storage); + // Group membership, which page gating and the site-wide role grants read. + await syncUserRoles({ authz, adapter }, { id: userId, roles }); + if (previousRole && previousRole !== newRole) { await authz.disallowAllMatching({ who: { type: "user", id: userId }, @@ -188,7 +193,7 @@ async function changeRole( } await ctx.userStore.update(user.id, { roles: newRoles }); - await syncAdminTuple(ctx, user.id, previousRole, newRole); + await syncAdminTuple(ctx, user.id, previousRole, newRole, newRoles); console.log( grant diff --git a/src/mod.ts b/src/mod.ts index 6dbc3e78..d939caac 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -321,6 +321,12 @@ export { /** Grant the admin-tier roles their relation on SITE_RESOURCE at group level. @since 0.36.0 */ bootstrapRoleGrants, } from "./auth/authz.ts"; +export { + /** Make a user's `member of group:` tuples match their `roles[]`. @since 0.36.0 */ + syncUserRoles, + /** Wrap a user store so create/update/delete keep group membership in step. @since 0.36.0 */ + withRoleSync, +} from "./auth/sync-roles.ts"; export { /** The object site-wide permissions are checked on: `app:site`. @since 0.36.0 */ SITE_RESOURCE, diff --git a/src/payments/manager.ts b/src/payments/manager.ts index 45a49d53..e53c3af4 100644 --- a/src/payments/manager.ts +++ b/src/payments/manager.ts @@ -165,13 +165,18 @@ export function createPaymentManager(config: PaymentManagerConfig): PaymentManag // Sync the new role into the authz tuple store so the grant takes effect // immediately without waiting for a restart + bootstrap. + // A user store wrapped with `withRoleSync` has already done this on + // update(); only add the membership when it is not there yet, so a synced + // store does not end up with the tuple twice. if (roleToGrant && updates.roles && authz) { - await authz.addMember({ - member: { type: "user", id: userId }, - group: { type: "group", id: roleToGrant }, - }).catch((err) => { + const group = { type: "group" as const, id: roleToGrant }; + const who = { type: "user" as const, id: userId }; + try { + const already = await authz.check({ who, canThey: "access", onWhat: group }); + if (!already) await authz.addMember({ member: who, group }); + } catch (err) { logger.error("payments.webhook.role_grant_failed", { userId, role: roleToGrant, reason: String(err) }); - }); + } } } diff --git a/src/plugin-api/create-services.ts b/src/plugin-api/create-services.ts index 88d51fb2..142bbdda 100644 --- a/src/plugin-api/create-services.ts +++ b/src/plugin-api/create-services.ts @@ -13,6 +13,7 @@ import { createWorkflowEngine } from "../workflow/engine.ts"; import { createScheduler } from "../workflow/scheduler.ts"; import { createStagingEngine } from "../staging/engine.ts"; import { bootstrapAdminTuples } from "../auth/authz.ts"; +import { withRoleSync } from "../auth/sync-roles.ts"; import { resolveBlueprint, validateFrontmatter } from "../blueprints/validator.ts"; import { resolveDataDir, resolveRuntimeDir } from "../forms/config.ts"; import { logger } from "../core/logger.ts"; @@ -56,7 +57,9 @@ export function createPluginServices(opts: PluginServicesOptions): PluginService }, }, users: { - createLocalStore: ({ usersDir }) => createLocalUserStore({ storage, usersDir }), + // Writes through this store keep group membership in step with roles[], + // so a role an admin grants takes effect without a restart. + createLocalStore: ({ usersDir }) => withRoleSync(createLocalUserStore({ storage, usersDir }), () => opts.getAuthz?.()), }, passwords: { hash: hashPassword, diff --git a/tests/auth/sync_roles_test.ts b/tests/auth/sync_roles_test.ts new file mode 100644 index 00000000..d3ff9980 --- /dev/null +++ b/tests/auth/sync_roles_test.ts @@ -0,0 +1,109 @@ +/** + * A role granted or revoked while the app runs reaches the authz store at + * once: group membership follows `roles[]` for every write through the user + * store, so page gating and the site-wide grants of the admin-tier roles + * need no restart. + */ + +import { assertEquals } from "@std/assert"; +import { createStorage } from "../../src/storage/mod.ts"; +import { bootstrapRoleGrants, createDuneAuthSystem } from "../../src/auth/authz.ts"; +import { SITE_RESOURCE } from "../../src/auth/authz-schema.ts"; +import { createLocalUserStore } from "../../src/auth/user-store.ts"; +import { withRoleSync } from "../../src/auth/sync-roles.ts"; + +async function withStore( + fn: ( + ctx: { + store: ReturnType; + authz: ReturnType["authz"]; + adapter: ReturnType["adapter"]; + canEdit: (id: string) => Promise; + groups: (id: string) => Promise; + }, + ) => Promise, +) { + const root = await Deno.makeTempDir({ prefix: "dune_test_sync_roles_" }); + try { + const storage = createStorage({ rootDir: root }); + const { authz, adapter } = createDuneAuthSystem({ authzStore: "local", dataDir: "data" }, storage); + await bootstrapRoleGrants(authz, adapter); + const store = withRoleSync( + createLocalUserStore({ storage, usersDir: "data/users" }), + () => ({ authz, adapter }), + ); + await fn({ + store, + authz, + adapter, + canEdit: (id) => + authz.check({ + who: { type: "user", id }, + // deno-lint-ignore no-explicit-any + canThey: "pages.update" as any, + onWhat: SITE_RESOURCE, + }), + groups: async (id) => + (await adapter.findObjects({ type: "user", id }, "member", { objectType: "group" })) + .map((g) => g.id).sort(), + }); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +} + +Deno.test("create: a user created with roles is a member of those groups", async () => { + await withStore(async ({ store, groups, canEdit }) => { + const ed = await store.create({ email: "ed@example.com", provider: "local", roles: ["editor", "vip"] }); + assertEquals(await groups(ed.id), ["editor", "vip"]); + assertEquals(await canEdit(ed.id), true); + }); +}); + +Deno.test("update: granting and revoking a role takes effect with no restart", async () => { + await withStore(async ({ store, groups, canEdit }) => { + const u = await store.create({ email: "u@example.com", provider: "local", roles: [] }); + assertEquals(await canEdit(u.id), false); + + await store.update(u.id, { roles: ["editor"] }); + assertEquals(await groups(u.id), ["editor"]); + assertEquals(await canEdit(u.id), true); + + // editor -> subscriber: the old membership goes, so the old grant goes with it. + await store.update(u.id, { roles: ["subscriber"] }); + assertEquals(await groups(u.id), ["subscriber"]); + assertEquals(await canEdit(u.id), false); + }); +}); + +Deno.test("update without roles leaves membership alone", async () => { + await withStore(async ({ store, groups }) => { + const u = await store.create({ email: "a@example.com", provider: "local", roles: ["author"] }); + await store.update(u.id, { name: "Ann" }); + assertEquals(await groups(u.id), ["author"]); + }); +}); + +Deno.test("delete: a deleted user keeps no tuples", async () => { + await withStore(async ({ store, groups, canEdit }) => { + const u = await store.create({ email: "d@example.com", provider: "local", roles: ["admin"] }); + assertEquals(await canEdit(u.id), true); + assertEquals(await store.delete(u.id), true); + assertEquals(await groups(u.id), []); + assertEquals(await canEdit(u.id), false); + }); +}); + +Deno.test("no authz target: writes pass straight through", async () => { + const root = await Deno.makeTempDir({ prefix: "dune_test_sync_roles_none_" }); + try { + const store = withRoleSync( + createLocalUserStore({ storage: createStorage({ rootDir: root }), usersDir: "data/users" }), + () => undefined, + ); + const u = await store.create({ email: "n@example.com", provider: "local", roles: ["editor"] }); + assertEquals((await store.update(u.id, { roles: [] }))?.roles, []); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +}); diff --git a/tests/payments/manager_test.ts b/tests/payments/manager_test.ts index 42e247ff..b60411ae 100644 --- a/tests/payments/manager_test.ts +++ b/tests/payments/manager_test.ts @@ -16,6 +16,7 @@ import type { PaymentProvider, CheckoutSession, WebhookEvent, Product } from ".. import type { UserStore } from "../../src/auth/user-store.ts"; import type { User, UserCreate } from "../../src/auth/types.ts"; import { createDuneAuthSystem } from "../../src/auth/authz.ts"; +import { withRoleSync } from "../../src/auth/sync-roles.ts"; // --------------------------------------------------------------------------- // Mock helpers @@ -385,6 +386,30 @@ Deno.test("handleWebhook: authz.addMember called when role is granted", async () ); }); +Deno.test("handleWebhook: on a role-synced user store the membership is written once", async () => { + const storage = makeStorage(); + const { authz, adapter } = createDuneAuthSystem({ dataDir: "data" }, storage); + + const user = makeUser({ id: "user-sync", roles: [] }); + const provider = new MockPaymentProvider(); + provider.parseWebhookResult = { type: "checkout.session.completed", userId: "user-sync", productId: "membership", raw: {} }; + const inner = new MockUserStore(); + inner.users.set(user.id, user); + + const manager = createPaymentManager({ + provider, + products: [makeProduct()], + webhookSecret: "whsec_test", + userStore: withRoleSync(inner, () => ({ authz, adapter })), + baseUrl: "https://example.com", + authz, + }); + await manager.handleWebhook(new Request("http://example.com/payments/webhook", { method: "POST", body: "{}" })); + + const groups = await adapter.findObjects({ type: "user", id: "user-sync" }, "member", { objectType: "group" }); + assertEquals(groups.map((g) => g.id), ["member"]); +}); + Deno.test("handleWebhook: authz not called when role already present", async () => { const storage = makeStorage(); const { authz } = createDuneAuthSystem({ dataDir: "data" }, storage); diff --git a/tests/runtime/site_user_editing_test.ts b/tests/runtime/site_user_editing_test.ts index 65bb1714..c743a44d 100644 --- a/tests/runtime/site_user_editing_test.ts +++ b/tests/runtime/site_user_editing_test.ts @@ -52,6 +52,7 @@ async function withSite( fn: ( ws: (cookie?: string) => Promise, cookieFor: (role: string) => Promise, + plugin: { services: Awaited>["pluginServices"]; ids: Map }, ) => Promise, ) { const root = await Deno.makeTempDir({ prefix: "dune_test_site_user_editing_" }); @@ -96,7 +97,7 @@ async function withSite( }, }), ); - await fn(ws, cookieFor); + await fn(ws, cookieFor, { services: ctx.pluginServices, ids }); } finally { await Deno.remove(root, { recursive: true }).catch(() => {}); } @@ -127,3 +128,18 @@ Deno.test("every admin-tier role may edit pages, content tags may not", opts, as assertEquals((await ws(await cookieFor("subscriber"))).status, 403); }); }); + +Deno.test("a role granted through a plugin's user store while the app runs applies at once", opts, async () => { + await withSite([], async (ws, cookieFor, { services, ids }) => { + const cookie = await cookieFor("subscriber"); + assertEquals((await ws(cookie)).status, 403); + + // What an admin plugin does: write the role through services.users. + const users = services.users.createLocalStore({ usersDir: "data/users" }); + await users.update(ids.get("subscriber")!, { roles: ["editor"] }); + assertEquals((await ws(cookie)).status, 204); + + await users.update(ids.get("subscriber")!, { roles: ["subscriber"] }); + assertEquals((await ws(cookie)).status, 403); + }); +}); From d3e5c3a25c53eeb6828962baf65ee41f8756f1f3 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 18:04:29 +0100 Subject: [PATCH 17/31] feat(plugin-api)!: the contract keeps what core consumes; plugins declare their own extension points (#127) AdminPageRegistration, AdminRouteRegistration, ContentEditorPlugin and the bar action types leave core's contract along with the keys admin.pages, admin.routes, editor.content and bar.actions: core never read them. Capabilities and Contributions are open to declaration merging, keyed by the name of the plugin that consumes the key. ResponseTransformContext loses barActions and adminPrefix; a bar-owning plugin reads them through MountApi.capabilities. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 12 ++ src/cli/response-transforms.ts | 14 +- src/hooks/types.ts | 181 +------------------------- src/plugin-api/capabilities.ts | 45 ++++--- src/plugins/mod.ts | 16 +-- src/runtime/register-middleware.ts | 1 - tests/cli/response_transforms_test.ts | 50 ------- tests/plugin-api/capabilities_test.ts | 46 +++---- tests/plugin-api/plugin_app_test.ts | 11 +- 9 files changed, 68 insertions(+), 308 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42296c94..236bb670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -139,6 +139,18 @@ where the setting lives now (see `dune migrate:config`, which reports these too) ### Changed — BREAKING +- **The contract keeps only what core consumes; each plugin declares its own extension points + (#127).** `AdminPageRegistration`, `AdminRouteRegistration`, `ContentEditorPlugin`, + `AdminBarAction` and `AdminBarActionContext` leave `@dune/core/plugins` and + `@dune/plugin-api`; core never read them. The keys `admin.pages`, `admin.routes`, + `editor.content` (now `admin.editor`) and `bar.actions` leave `Capabilities` / + `Contributions` and belong to the plugin that reads them (`@dune/plugin-admin`, + `@dune/plugin-inline-edit`), which declares them by declaration merging. Both interfaces are + now open to merging; the rule is that a key is prefixed with the name of the plugin that + consumes it, and unprefixed names are core's. At runtime nothing changes: the registry was + already keyed by string. `ResponseTransformContext.barActions` and `.adminPrefix` are gone: + a bar-owning plugin reads `bar.actions` and the `admin.prefix` slot through + `MountApi.capabilities`. - **Site-wide permissions are checked on `app:site`, not `app:admin` (#130).** `pages.update` and the other admin-tier actions name what they protect (this site's content), not the panel that happens to administer it, so the same check works on a site with no admin, one admin diff --git a/src/cli/response-transforms.ts b/src/cli/response-transforms.ts index 6f506b17..b01f3b57 100644 --- a/src/cli/response-transforms.ts +++ b/src/cli/response-transforms.ts @@ -38,7 +38,7 @@ interface AdminAuthMiddleware { authenticate(req: Request): Promise; } import type { DuneConfig } from "../config/types.ts"; -import type { AdminBarAction, AdminBarActionContext, DunePlugin, ResponseTransformContext } from "../hooks/types.ts"; +import type { DunePlugin, ResponseTransformContext } from "../hooks/types.ts"; import type { DuneAuthSystem } from "../auth/authz.ts"; import type { PageIndex } from "../content/types.ts"; import { highestAdminRole, roleHasPermission, SITE_RESOURCE } from "../auth/authz-schema.ts"; @@ -72,11 +72,6 @@ export interface RunResponseTransformsOptions { auth: AdminAuthMiddleware | null; /** Cookies that carry the `editor.session` provider's session (`RequestAuthenticator.sessionCookies`); omitted means any cookie. */ sessionCookies?: readonly string[]; - /** - * Bar buttons contributed to the `bar.actions` point, in plugin registration - * order. Handed to transform plugins as `ResponseTransformContext.barActions`. - */ - barActions?: readonly ((ctx: AdminBarActionContext) => AdminBarAction[])[]; /** * Polizy authz system — the sole authority for the `pages.update` check * below (dec-identity-unification Phase 5c/7). Undefined only when authz @@ -134,7 +129,6 @@ export async function runPluginResponseTransforms( config, adminPrefix, actionToRelations, - barActions = [], sessionCookies, } = opts; @@ -202,12 +196,6 @@ export async function runPluginResponseTransforms( template: matchedPage.template, } : null, - // "" when no admin is installed: plugins that build admin links only do so for a signed-in admin. - adminPrefix: adminPrefix ?? "", - // A plugin can contribute to `bar.actions` without declaring - // transformResponse itself, so the list comes from the contribution - // point, not from the plugins selected to run a transform. - barActions, }); } diff --git a/src/hooks/types.ts b/src/hooks/types.ts index 089a82ee..253a6f98 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -249,133 +249,6 @@ export interface PublicRouteRegistration { island?: string; } -/** - * Context passed to a `bar.actions` contribution. - * - * Deliberately the same narrowed shape {@link ResponseTransformContext} - * passes as `page`/`adminPrefix` (plus `template`, which actions need to - * decide what to show but `transformResponse` itself has never needed) — - * a plugin contributing bar actions is answering "what belongs on the bar - * for this response," the same question `transformResponse` answers for - * the page body. - * - * @since 1.2.0 - */ -export interface AdminBarActionContext { - /** Content page matching the current URL, or null for non-content routes. */ - page: { sourcePath: string; route: string; title: string | null; template: string } | null; - /** Admin panel URL prefix (e.g. `"/admin"`). */ - adminPrefix: string; -} - -/** - * A button contributed to the shared admin bar UI through the `bar.actions` - * contribution point. Rendered by whichever plugin owns the - * bar (e.g. `@dune/plugin-inline-edit`) — the contributing plugin controls - * only the button's appearance and behavior, not its position or styling. - * - * @since 1.2.0 - */ -export interface AdminBarAction { - /** - * Unique id — namespace by plugin name to avoid collisions across - * plugins, e.g. `"pdf-export:download"`. - */ - id: string; - /** Button label. */ - label: string; - /** Optional emoji/icon prefix. */ - icon?: string; - /** - * Renders as a plain link (``) when set — the common case for - * downloads and navigation. Mutually exclusive with `onClick`; `href` - * takes precedence if both are set. - */ - href?: string; - /** - * Renders as a `