From 662b81fd4fa420fa3cd648b445a8b501703aa130 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Wed, 4 Nov 2026 10:10:01 +0100 Subject: [PATCH 01/39] feat(admin)!: read snake_case config keys (duneorg/dune#96) The admin: block, audit.log_file, content.allow_tsx_format and the LDAP/SAML provider config types follow core's snake_case rename. Requires @dune/core 0.36 (the dependency range is bumped when it is published). Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 12 +++++++ README.md | 8 ++--- mod.ts | 26 +++++++------- src/admin/auth/csrf.ts | 2 +- src/admin/auth/ldap-provider.ts | 22 ++++++------ src/admin/auth/provider.ts | 34 +++++++++---------- src/admin/auth/saml-provider.ts | 18 +++++----- src/admin/comments.ts | 8 ++--- src/admin/routes/api/_utils.ts | 4 +-- src/admin/routes/api/config/theme-config.ts | 2 +- src/admin/routes/api/email-preview.ts | 2 +- src/admin/routes/api/email-preview/[id].ts | 2 +- src/admin/routes/api/media/upload.ts | 2 +- src/admin/routes/api/pages/[...path]/index.ts | 4 +-- src/admin/routes/api/pages/index.ts | 2 +- src/admin/routes/api/plugins/[name]/config.ts | 2 +- src/admin/routes/api/search/analytics.ts | 2 +- src/admin/routes/api/users/[id]/password.ts | 2 +- src/admin/routes/api/webhooks/deliveries.ts | 2 +- src/admin/routes/email-preview/index.tsx | 2 +- src/admin/routes/login.tsx | 2 +- src/admin/webhooks.ts | 4 +-- src/admin/workflow-actions.ts | 2 +- tests/admin/theme_config_test.ts | 2 +- tests/admin/tsx_write_test.ts | 2 +- tests/admin/webhook_deliveries_test.ts | 2 +- tests/admin/workflow_actions_test.ts | 2 +- 27 files changed, 93 insertions(+), 81 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bfdb91..74e0fab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ follows [Semantic Versioning](https://semver.org). ## [Unreleased] +### Changed — BREAKING + +- **The `admin:` block reads snake_case keys** (duneorg/dune#96, `@dune/core` 0.36): + `session_lifetime`, `data_dir`, `runtime_dir`, `max_revisions`, `max_upload_mb`, + `authz_store`, `audit.log_file`, and the LDAP/SAML provider fields (`base_dn`, + `bind_dn`, `bind_password`, `username_attr`, `email_attr`, `name_attr`, + `role_map`, `default_role`, `entity_id`, `acs_url`, `idp_metadata`, + `role_attr`). `content.allowTsxFormat` is `allow_tsx_format`. + Run `dune migrate:config` (core) to rewrite `config/system.yaml`. A config that + still carries an old key is refused by core at load time, so this cannot be + silently ignored. Requires `@dune/core` 0.36 or newer. + ## [3.3.0] — 2026-11-03 ### Changed diff --git a/README.md b/README.md index 4924321..d7a64c6 100644 --- a/README.md +++ b/README.md @@ -52,16 +52,16 @@ All admin configuration lives under the `admin:` key in `site.yaml`. admin: path: /admin # URL prefix for the admin panel (default: /admin) enabled: true # Set to false to disable entirely - sessionLifetime: 86400 # Session duration in seconds (default: 24h) - dataDir: data # Directory for users, submissions, etc. - runtimeDir: .dune/admin # Directory for sessions, audit log, etc. + session_lifetime: 86400 # Session duration in seconds (default: 24h) + data_dir: data # Directory for users, submissions, etc. + runtime_dir: .dune/admin # Directory for sessions, audit log, etc. auth_provider: type: local # Only "local" is supported today audit: enabled: true - logFile: .dune/admin/audit.log # Override the audit log path + log_file: .dune/admin/audit.log # Override the audit log path session_store: type: local # "local" (default) or "redis" diff --git a/mod.ts b/mod.ts index a0d9f90..620deaf 100644 --- a/mod.ts +++ b/mod.ts @@ -126,9 +126,9 @@ export function createAdminPlugin( async setup(_api) { const adminCfg = config.admin ?? { path: "/admin", - sessionLifetime: 86400, - dataDir: "data", - runtimeDir: ".dune/admin", + session_lifetime: 86400, + data_dir: "data", + runtime_dir: ".dune/admin", enabled: true, }; if (adminCfg.enabled === false) { @@ -139,8 +139,8 @@ export function createAdminPlugin( // Audit logger — created in setup() so it can init() its file handle early. let auditLogger: AuditLogger | null = null; if (adminCfg.audit?.enabled !== false) { - const configuredPath = adminCfg.audit?.logFile; - const runtimeDir = adminCfg.runtimeDir ?? ".dune/admin"; + const configuredPath = adminCfg.audit?.log_file; + const runtimeDir = adminCfg.runtime_dir ?? ".dune/admin"; const auditLogFile = configuredPath ? join(opts.root, configuredPath) : join(opts.root, runtimeDir, "audit.log"); @@ -149,7 +149,7 @@ export function createAdminPlugin( : opts.root + "/"; if (!auditLogFile.startsWith(containmentRoot)) { throw new Error( - `[dune] admin.audit.logFile must resolve under the site root. ` + + `[dune] admin.audit.log_file must resolve under the site root. ` + `Got: ${configuredPath} -> ${auditLogFile}`, ); } @@ -171,13 +171,13 @@ export function createAdminPlugin( const { opts: o } = setupState; const adminCfg = config.admin ?? { path: "/admin", - sessionLifetime: 86400, - dataDir: "data", - runtimeDir: ".dune/admin", + session_lifetime: 86400, + data_dir: "data", + runtime_dir: ".dune/admin", enabled: true, }; - const runtimeDir = adminCfg.runtimeDir ?? ".dune/admin"; - // `system.data_dir`, falling back to the deprecated `admin.dataDir` — + const runtimeDir = adminCfg.runtime_dir ?? ".dune/admin"; + // `system.data_dir`, falling back to the deprecated `admin.data_dir` — // the same resolution core uses for the submissions it stores. const dataDir = resolveFormsConfig(config).dataDir; @@ -213,12 +213,12 @@ export function createAdminPlugin( : undefined, storage, sessionsDir: `${runtimeDir}/sessions`, - lifetimeMs: adminCfg.sessionLifetime * 1000, + lifetimeMs: adminCfg.session_lifetime * 1000, }); const sessions = createSessionManager({ store: resolvedSessionStore, - lifetime: adminCfg.sessionLifetime, + lifetime: adminCfg.session_lifetime, }); const rateLimitStore = new LocalRateLimitStore(); diff --git a/src/admin/auth/csrf.ts b/src/admin/auth/csrf.ts index cb40c42..0141486 100644 --- a/src/admin/auth/csrf.ts +++ b/src/admin/auth/csrf.ts @@ -43,7 +43,7 @@ export function resolveCsrfSecret(state: AdminState | undefined): string | null const injected = state.adminContext.csrfSecret; if (typeof injected === "string" && injected.length > 0) return injected; - const runtimeDir = state.adminContext.config?.admin?.runtimeDir; + const runtimeDir = state.adminContext.config?.admin?.runtime_dir; if (typeof runtimeDir !== "string" || runtimeDir.length === 0) return null; return loadOrCreateCsrfSecret(runtimeDir); } diff --git a/src/admin/auth/ldap-provider.ts b/src/admin/auth/ldap-provider.ts index 21c3701..68401c8 100644 --- a/src/admin/auth/ldap-provider.ts +++ b/src/admin/auth/ldap-provider.ts @@ -13,16 +13,16 @@ * import { Client } from "npm:ldapts"; * * async authenticate(creds: AuthCredentials): Promise { - * const usernameAttr = this.config.usernameAttr ?? "uid"; - * const emailAttr = this.config.emailAttr ?? "mail"; - * const nameAttr = this.config.nameAttr ?? "cn"; + * const usernameAttr = this.config.username_attr ?? "uid"; + * const emailAttr = this.config.email_attr ?? "mail"; + * const nameAttr = this.config.name_attr ?? "cn"; * const client = new Client({ url: this.config.url }); * try { - * if (this.config.bindDn) { + * if (this.config.bind_dn) { * // Service account bind, then search for the user entry - * const bindPw = expandEnv(this.config.bindPassword ?? ""); - * await client.bind(this.config.bindDn, bindPw); - * const { searchEntries } = await client.search(this.config.baseDn, { + * const bindPw = expandEnv(this.config.bind_password ?? ""); + * await client.bind(this.config.bind_dn, bindPw); + * const { searchEntries } = await client.search(this.config.base_dn, { * // IMPORTANT: escape username to prevent LDAP injection (CWE-90) * // import { escape as ldapEscape } from "npm:ldapts"; * filter: `(${usernameAttr}=${ldapEscape(creds.username)})`, @@ -32,8 +32,8 @@ * const entry = searchEntries[0]; * // Rebind as the located user to verify their password * await client.bind(entry.dn as string, creds.password); - * const role = mapRole(entry["memberOf"] as string | string[] | undefined, this.config.roleMap) - * ?? this.config.defaultRole + * const role = mapRole(entry["memberOf"] as string | string[] | undefined, this.config.role_map) + * ?? this.config.default_role * ?? "author"; * return { * externalId: entry.dn as string, @@ -44,12 +44,12 @@ * }; * } else { * // Direct bind: construct user DN from template - * const userDn = `${usernameAttr}=${creds.username},${this.config.baseDn}`; + * const userDn = `${usernameAttr}=${creds.username},${this.config.base_dn}`; * await client.bind(userDn, creds.password); * return { * externalId: userDn, * username: creds.username, - * roles: [this.config.defaultRole ?? "author"], + * roles: [this.config.default_role ?? "author"], * }; * } * } catch { diff --git a/src/admin/auth/provider.ts b/src/admin/auth/provider.ts index 87865df..91bb467 100644 --- a/src/admin/auth/provider.ts +++ b/src/admin/auth/provider.ts @@ -88,26 +88,26 @@ export interface LdapProviderConfig { * Base DN for user search. * @example "ou=users,dc=example,dc=com" */ - baseDn: string; + base_dn: string; /** * LDAP attribute containing the login name. * Default: "sAMAccountName" (Active Directory) or "uid" (OpenLDAP). */ - usernameAttr?: string; + username_attr?: string; /** * Optional bind DN for a service account used to search the directory. * If omitted, Dune attempts a direct bind using the user's constructed DN. */ - bindDn?: string; + bind_dn?: string; /** * Service account password. Supports "$ENV_VAR" expansion. * @example "$LDAP_BIND_PASSWORD" */ - bindPassword?: string; + bind_password?: string; /** LDAP attribute to use as email. Default: "mail" */ - emailAttr?: string; + email_attr?: string; /** LDAP attribute to use as display name. Default: "cn" */ - nameAttr?: string; + name_attr?: string; /** * Map LDAP group membership to Dune roles. First match wins. * @example @@ -119,35 +119,35 @@ export interface LdapProviderConfig { * role: "editor" * ``` */ - roleMap?: Array<{ group: string; role: Role }>; + role_map?: Array<{ group: string; role: Role }>; /** Default role for authenticated users not in any mapped group. Default: "author" */ - defaultRole?: Role; + default_role?: Role; } /** SAML 2.0 authentication provider configuration. */ export interface SamlProviderConfig { type: "saml"; /** Your application's Entity ID (SP entity ID). */ - entityId: string; + entity_id: string; /** * Assertion Consumer Service URL — where the IdP POSTs the assertion after authentication. * @example "https://example.com/admin/saml/acs" */ - acsUrl: string; + acs_url: string; /** Identity provider metadata as a URL (fetched at startup) or inline XML. */ - idpMetadata: string; + idp_metadata: string; /** SAML attribute containing the username. Default: "NameID" */ - usernameAttr?: string; + username_attr?: string; /** SAML attribute containing the email. Default: "email" or "mail" */ - emailAttr?: string; + email_attr?: string; /** SAML attribute containing the display name. Default: "displayName" */ - nameAttr?: string; + name_attr?: string; /** * Map IdP group/role attribute values to Dune roles. First match wins. */ - roleMap?: Array<{ value: string; role: Role }>; + role_map?: Array<{ value: string; role: Role }>; /** SAML attribute carrying the role or group value. Default: "role" or "groups" */ - roleAttr?: string; + role_attr?: string; /** Default role for users with no matching roleMap entry. Default: "author" */ - defaultRole?: Role; + default_role?: Role; } diff --git a/src/admin/auth/saml-provider.ts b/src/admin/auth/saml-provider.ts index 840f6d2..107c9be 100644 --- a/src/admin/auth/saml-provider.ts +++ b/src/admin/auth/saml-provider.ts @@ -15,15 +15,15 @@ * // Build SP + IdP instances (ideally cached at construction time) * private buildSp() { * return saml.ServiceProvider({ - * entityID: this.config.entityId, + * entityID: this.config.entity_id, * assertionConsumerService: [ - * { Binding: saml.Constants.namespace.binding.post, Location: this.config.acsUrl }, + * { Binding: saml.Constants.namespace.binding.post, Location: this.config.acs_url }, * ], * }); * } * * private buildIdp() { - * return saml.IdentityProvider({ metadata: this.config.idpMetadata }); + * return saml.IdentityProvider({ metadata: this.config.idp_metadata }); * } * * async initiateLogin(_req: Request): Promise { @@ -53,12 +53,12 @@ * * const nameId: string = extract.nameID; * const attrs = extract.attributes ?? {}; - * const usernameAttr = this.config.usernameAttr ?? "NameID"; - * const emailAttr = this.config.emailAttr ?? "email"; - * const nameAttr = this.config.nameAttr ?? "displayName"; - * const roleAttr = this.config.roleAttr ?? "role"; - * const role = mapRole(attrs[roleAttr], this.config.roleMap) - * ?? this.config.defaultRole + * const usernameAttr = this.config.username_attr ?? "NameID"; + * const emailAttr = this.config.email_attr ?? "email"; + * const nameAttr = this.config.name_attr ?? "displayName"; + * const roleAttr = this.config.role_attr ?? "role"; + * const role = mapRole(attrs[roleAttr], this.config.role_map) + * ?? this.config.default_role * ?? "author"; * return { * externalId: nameId, diff --git a/src/admin/comments.ts b/src/admin/comments.ts index 0d0d2c9..385b916 100644 --- a/src/admin/comments.ts +++ b/src/admin/comments.ts @@ -5,11 +5,11 @@ * notifications, and a resolve/unresolve workflow suitable for editorial * sign-off processes. * - * Storage layout (inside admin.dataDir — git-tracked): + * Storage layout (inside admin.data_dir — git-tracked): * data/comments/{encodedPath}.json → { comments: PageComment[] } * encodedPath: sourcePath with "/" → "__" and "." → "-dot-" * - * Mention read-state (inside admin.runtimeDir — gitignored, machine-local): + * Mention read-state (inside admin.runtime_dir — gitignored, machine-local): * .dune/admin/mention-reads/{username}.json → { readIds: string[] } * * All public methods are async to allow future storage adapter swapping. @@ -63,9 +63,9 @@ interface MentionReadState { /** Options for createCommentManager */ export interface CommentManagerOptions { - /** admin.dataDir — git-tracked persistent storage */ + /** admin.data_dir — git-tracked persistent storage */ dataDir: string; - /** admin.runtimeDir — ephemeral machine-local storage */ + /** admin.runtime_dir — ephemeral machine-local storage */ runtimeDir: string; } diff --git a/src/admin/routes/api/_utils.ts b/src/admin/routes/api/_utils.ts index f0a34cd..75664c7 100644 --- a/src/admin/routes/api/_utils.ts +++ b/src/admin/routes/api/_utils.ts @@ -129,7 +129,7 @@ export function csrfCheck(ctx: FreshContext): Response | null { * flat table" dec-auth-storage said should be fully replaced, not coexist * with). * - * `admin.authzStore` defaults to `"local"` and `authz` is created whenever + * `admin.authz_store` defaults to `"local"` and `authz` is created whenever * the admin panel is enabled, regardless of `site.auth`'s mode — see its * doc comment in `src/config/admin-config.ts`. `authz` being undefined * here means its creation itself failed at startup (already logged loudly @@ -183,7 +183,7 @@ export function requireTsxWrite( ): Response | null { if (!isTsxSource(formatOrPath)) return null; const allowedRoles: string[] = - ctx.state.adminContext.config.system.content.allowTsxFormat ?? ["admin"]; + ctx.state.adminContext.config.system.content.allow_tsx_format ?? ["admin"]; const userRoles = ctx.state.auth?.user?.roles ?? []; if (allowedRoles.length === 0 || !userRoles.some((r) => allowedRoles.includes(r))) { return json({ diff --git a/src/admin/routes/api/config/theme-config.ts b/src/admin/routes/api/config/theme-config.ts index 20b9efb..f900393 100644 --- a/src/admin/routes/api/config/theme-config.ts +++ b/src/admin/routes/api/config/theme-config.ts @@ -58,7 +58,7 @@ export const handler = { } } - const dataDir = config.admin?.dataDir ?? "data"; + const dataDir = config.admin?.data_dir ?? "data"; const themeConfigPath = `${dataDir}/theme-config.json`; const themeName = engine.config.theme.name; diff --git a/src/admin/routes/api/email-preview.ts b/src/admin/routes/api/email-preview.ts index 306a36f..51e4fc4 100644 --- a/src/admin/routes/api/email-preview.ts +++ b/src/admin/routes/api/email-preview.ts @@ -14,7 +14,7 @@ export const handler = { return json({ error: "Email preview is only available in development mode" }, 404); } - const runtimeDir = ctx.state.adminContext.config.admin?.runtimeDir ?? ".dune/admin"; + const runtimeDir = ctx.state.adminContext.config.admin?.runtime_dir ?? ".dune/admin"; const devEmailDir = join(runtimeDir, "dev-email"); const entries: Record[] = []; diff --git a/src/admin/routes/api/email-preview/[id].ts b/src/admin/routes/api/email-preview/[id].ts index ef74c88..baef1a0 100644 --- a/src/admin/routes/api/email-preview/[id].ts +++ b/src/admin/routes/api/email-preview/[id].ts @@ -19,7 +19,7 @@ export const handler = { return json({ error: "Invalid id" }, 400); } - const runtimeDir = ctx.state.adminContext.config.admin?.runtimeDir ?? ".dune/admin"; + const runtimeDir = ctx.state.adminContext.config.admin?.runtime_dir ?? ".dune/admin"; const filePath = join(runtimeDir, "dev-email", `${id}.json`); try { diff --git a/src/admin/routes/api/media/upload.ts b/src/admin/routes/api/media/upload.ts index 7c1d0d1..3e4ac54 100644 --- a/src/admin/routes/api/media/upload.ts +++ b/src/admin/routes/api/media/upload.ts @@ -17,7 +17,7 @@ export const handler = { const { storage, config, auditLogger } = ctx.state.adminContext; const MAX_FILE_SIZE = 50 * 1024 * 1024; - const maxBodyBytes = (config.admin?.maxUploadMb ?? 100) * 1024 * 1024; + const maxBodyBytes = (config.admin?.max_upload_mb ?? 100) * 1024 * 1024; try { const tooLarge = checkBodySize(ctx.req, maxBodyBytes); diff --git a/src/admin/routes/api/pages/[...path]/index.ts b/src/admin/routes/api/pages/[...path]/index.ts index 1cf84d2..12c1f03 100644 --- a/src/admin/routes/api/pages/[...path]/index.ts +++ b/src/admin/routes/api/pages/[...path]/index.ts @@ -134,7 +134,7 @@ export const handler = { await maybeGitCommit(filePath, pagePath, undefined, config); const webhookEndpoints = config.admin?.webhooks ?? []; - const runtimeDir = config.admin?.runtimeDir ?? ".dune/admin"; + const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; if (hooks) hooks.fire("onPageUpdate", { sourcePath: page.sourcePath }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onPageUpdate", { sourcePath: page.sourcePath }, runtimeDir); @@ -176,7 +176,7 @@ export const handler = { await engine.rebuild(); const webhookEndpoints = config.admin?.webhooks ?? []; - const runtimeDir = config.admin?.runtimeDir ?? ".dune/admin"; + const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; if (hooks) hooks.fire("onPageDelete", { sourcePath: page.sourcePath }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onPageDelete", { sourcePath: page.sourcePath }, runtimeDir); diff --git a/src/admin/routes/api/pages/index.ts b/src/admin/routes/api/pages/index.ts index 675bda5..6e16914 100644 --- a/src/admin/routes/api/pages/index.ts +++ b/src/admin/routes/api/pages/index.ts @@ -68,7 +68,7 @@ export const handler = { await engine.rebuild(); const webhookEndpoints = config.admin?.webhooks ?? []; - const runtimeDir = config.admin?.runtimeDir ?? ".dune/admin"; + const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; if (hooks) hooks.fire("onPageCreate", { sourcePath: `${pagePath}/default${ext}`, title }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onPageCreate", { sourcePath: `${pagePath}/default${ext}`, title }, runtimeDir); diff --git a/src/admin/routes/api/plugins/[name]/config.ts b/src/admin/routes/api/plugins/[name]/config.ts index 8abfcb8..276557d 100644 --- a/src/admin/routes/api/plugins/[name]/config.ts +++ b/src/admin/routes/api/plugins/[name]/config.ts @@ -74,7 +74,7 @@ export const handler = { if (errors.length > 0) return json({ error: `Missing required fields: ${errors.join(", ")}` }, 422); } - const dataDir = config.admin?.dataDir ?? "data"; + const dataDir = config.admin?.data_dir ?? "data"; const filePath = `${dataDir}/plugins/${pluginName}.json`; await storage.write(filePath, new TextEncoder().encode(JSON.stringify(body, null, 2))); diff --git a/src/admin/routes/api/search/analytics.ts b/src/admin/routes/api/search/analytics.ts index 04005f9..a3c7231 100644 --- a/src/admin/routes/api/search/analytics.ts +++ b/src/admin/routes/api/search/analytics.ts @@ -12,7 +12,7 @@ export const handler = { if (denied) return denied; const { config } = ctx.state.adminContext; try { - const runtimeDir = config.admin?.runtimeDir ?? ".dune/admin"; + const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; const analyticsPath = join(runtimeDir, "search-analytics.jsonl"); const summary = await createSearchAnalytics(analyticsPath).summarize(); return json(summary); diff --git a/src/admin/routes/api/users/[id]/password.ts b/src/admin/routes/api/users/[id]/password.ts index f0892ba..19d098a 100644 --- a/src/admin/routes/api/users/[id]/password.ts +++ b/src/admin/routes/api/users/[id]/password.ts @@ -30,7 +30,7 @@ export const handler = { const session = await sessions.create(userId); res.headers.set( "Set-Cookie", - auth.createSessionCookie(session.id, config.admin?.sessionLifetime ?? 86400), + auth.createSessionCookie(session.id, config.admin?.session_lifetime ?? 86400), ); } diff --git a/src/admin/routes/api/webhooks/deliveries.ts b/src/admin/routes/api/webhooks/deliveries.ts index 46497ba..504e54e 100644 --- a/src/admin/routes/api/webhooks/deliveries.ts +++ b/src/admin/routes/api/webhooks/deliveries.ts @@ -34,7 +34,7 @@ export const handler = { const { config } = ctx.state.adminContext; try { - const runtimeDir = config.admin?.runtimeDir ?? ".dune/admin"; + const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; const logs = await listDeliveryLogs(runtimeDir, { sourcePath: path }); const items = view === "workflow" ? logs.map(toWorkflowDelivery) : logs; return json({ items, total: items.length, view }); diff --git a/src/admin/routes/email-preview/index.tsx b/src/admin/routes/email-preview/index.tsx index fa3aa36..c25e7c5 100644 --- a/src/admin/routes/email-preview/index.tsx +++ b/src/admin/routes/email-preview/index.tsx @@ -116,7 +116,7 @@ export default function EmailPreviewPage({ data }: { data: PageData }) { export const handler = { async GET(ctx: FreshContext): Promise { const { prefix } = ctx.state.adminContext; - const runtimeDir = ctx.state.adminContext.config.admin?.runtimeDir ?? ".dune/admin"; + const runtimeDir = ctx.state.adminContext.config.admin?.runtime_dir ?? ".dune/admin"; if (Deno.env.get("DUNE_ENV") !== "dev") { return ctx.render( diff --git a/src/admin/routes/login.tsx b/src/admin/routes/login.tsx index 9b7b09f..4b1fbc3 100644 --- a/src/admin/routes/login.tsx +++ b/src/admin/routes/login.tsx @@ -246,7 +246,7 @@ export const handler = { status: 302, headers: { Location: safeNext, - "Set-Cookie": auth.createSessionCookie(session.id, adminConfig.sessionLifetime), + "Set-Cookie": auth.createSessionCookie(session.id, adminConfig.session_lifetime), }, }); }, diff --git a/src/admin/webhooks.ts b/src/admin/webhooks.ts index 3aa4e51..c7a0c6f 100644 --- a/src/admin/webhooks.ts +++ b/src/admin/webhooks.ts @@ -262,7 +262,7 @@ async function deliverWithRetry( * @param endpoints - Configured webhook endpoints (from admin.webhooks) * @param event - The content event that occurred * @param payload - Event-specific data included in the POST body - * @param logDir - Base directory for delivery logs (admin.runtimeDir) + * @param logDir - Base directory for delivery logs (admin.runtime_dir) */ export function fireContentWebhooks( endpoints: WebhookEndpointConfig[], @@ -300,7 +300,7 @@ export interface ListDeliveryLogsOptions { * Reads logs from the last 7 days of log directories and returns up to `limit` * records. Silently returns an empty array if the log directory doesn't exist. * - * @param logDir - Base directory (admin.runtimeDir) + * @param logDir - Base directory (admin.runtime_dir) * @param limitOrOpts - Maximum records, or `{ limit, sourcePath }` */ export async function listDeliveryLogs( diff --git a/src/admin/workflow-actions.ts b/src/admin/workflow-actions.ts index bc7e108..3fc3c7d 100644 --- a/src/admin/workflow-actions.ts +++ b/src/admin/workflow-actions.ts @@ -73,7 +73,7 @@ export async function applyWorkflowTransition( await engine.rebuild(); const webhookEndpoints = config.admin?.webhooks ?? []; - const runtimeDir = config.admin?.runtimeDir ?? ".dune/admin"; + const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; if (hooks) hooks.fire("onWorkflowChange", { sourcePath, from: currentStatus, to: newStatus }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onWorkflowChange", { sourcePath, from: currentStatus, to: newStatus }, runtimeDir); diff --git a/tests/admin/theme_config_test.ts b/tests/admin/theme_config_test.ts index f9406f4..0b3334c 100644 --- a/tests/admin/theme_config_test.ts +++ b/tests/admin/theme_config_test.ts @@ -50,7 +50,7 @@ function makeCtx(opts: { adminContext: { engine, storage: opts.storage, - config: { admin: { dataDir: "data" } }, + config: { admin: { data_dir: "data" } }, // authz.check() always allows — this test exercises theme-config // behavior, not permission logic (ROLE_PERMISSIONS/hasPermission() // were removed in 3.0.0; authz.check() is the sole authority now). diff --git a/tests/admin/tsx_write_test.ts b/tests/admin/tsx_write_test.ts index ae0d231..f9ab51f 100644 --- a/tests/admin/tsx_write_test.ts +++ b/tests/admin/tsx_write_test.ts @@ -13,7 +13,7 @@ function ctx(roles: string[], allowTsxFormat?: string[]): any { adminContext: { config: { system: { - content: allowTsxFormat ? { allowTsxFormat } : {}, + content: allowTsxFormat ? { allow_tsx_format: allowTsxFormat } : {}, }, }, }, diff --git a/tests/admin/webhook_deliveries_test.ts b/tests/admin/webhook_deliveries_test.ts index ea88b7a..aa924c6 100644 --- a/tests/admin/webhook_deliveries_test.ts +++ b/tests/admin/webhook_deliveries_test.ts @@ -49,7 +49,7 @@ function makeCtx( state: { adminContext: { auditLogger: null, - config: { admin: { runtimeDir: opts.runtimeDir ?? ".dune/admin" } }, + config: { admin: { runtime_dir: opts.runtimeDir ?? ".dune/admin" } }, // Distinguishes which specific permission the mock holds — same // role this fixture's ROLE_PERMISSIONS-backed hasPermission() used // to play, sourced from authz.check() instead (removed in 3.0.0; diff --git a/tests/admin/workflow_actions_test.ts b/tests/admin/workflow_actions_test.ts index 8ec70c9..6e53136 100644 --- a/tests/admin/workflow_actions_test.ts +++ b/tests/admin/workflow_actions_test.ts @@ -47,7 +47,7 @@ function makePageIndex(overrides: Partial = {}): PageIndex { const CONFIG = { system: { content: { dir: "content" } }, - admin: { webhooks: [], runtimeDir: ".dune/admin" }, + admin: { webhooks: [], runtime_dir: ".dune/admin" }, } as unknown as DuneConfig; function makeEngine(pages: PageIndex[]) { From f2fc830be249a257ae03d338a19e65af973260ba Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Wed, 4 Nov 2026 10:38:23 +0100 Subject: [PATCH 02/39] feat(admin)!: data directory comes from system.data_dir only admin.data_dir is removed in core; resolve the directory with resolveDataDir(). Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 3 +++ mod.ts | 9 +++------ src/admin/routes/api/config/theme-config.ts | 3 ++- src/admin/routes/api/plugins/[name]/config.ts | 3 ++- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74e0fab..3530a9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ follows [Semantic Versioning](https://semver.org). Run `dune migrate:config` (core) to rewrite `config/system.yaml`. A config that still carries an old key is refused by core at load time, so this cannot be silently ignored. Requires `@dune/core` 0.36 or newer. +- **The data directory is `system.data_dir` only.** `admin.data_dir` no longer + exists (core removed the deprecated form aliases); the plugin resolves the + directory with `resolveDataDir()` from `@dune/core/forms`. ## [3.3.0] — 2026-11-03 diff --git a/mod.ts b/mod.ts index 620deaf..bf66b69 100644 --- a/mod.ts +++ b/mod.ts @@ -30,7 +30,7 @@ import { createWorkflowEngine } from "@dune/core/workflow"; import { createScheduler } from "@dune/core/workflow"; import type { ScheduledAction } from "@dune/core/workflow"; import { applyWorkflowTransition, SCHEDULED_ACTION_STATUS } from "./src/admin/workflow-actions.ts"; -import { resolveFormsConfig } from "@dune/core/forms"; +import { resolveDataDir } from "@dune/core/forms"; import { createStagingEngine } from "@dune/core/staging"; import { createCommentManager } from "./src/admin/comments.ts"; import { createCollabManager } from "./src/collab/mod.ts"; @@ -127,7 +127,6 @@ export function createAdminPlugin( const adminCfg = config.admin ?? { path: "/admin", session_lifetime: 86400, - data_dir: "data", runtime_dir: ".dune/admin", enabled: true, }; @@ -172,14 +171,12 @@ export function createAdminPlugin( const adminCfg = config.admin ?? { path: "/admin", session_lifetime: 86400, - data_dir: "data", runtime_dir: ".dune/admin", enabled: true, }; const runtimeDir = adminCfg.runtime_dir ?? ".dune/admin"; - // `system.data_dir`, falling back to the deprecated `admin.data_dir` — - // the same resolution core uses for the submissions it stores. - const dataDir = resolveFormsConfig(config).dataDir; + // `system.data_dir` — the same resolution core uses for the submissions it stores. + const dataDir = resolveDataDir(config); // ── Admin services ──────────────────────────────────────────────────────── diff --git a/src/admin/routes/api/config/theme-config.ts b/src/admin/routes/api/config/theme-config.ts index f900393..c2a3cc7 100644 --- a/src/admin/routes/api/config/theme-config.ts +++ b/src/admin/routes/api/config/theme-config.ts @@ -1,5 +1,6 @@ /** GET + PUT /admin/api/config/theme-config */ +import { resolveDataDir } from "@dune/core/forms"; import type { AdminState } from "../../../types.ts"; import { requirePermission, json, serverError, csrfCheck } from "../_utils.ts"; import type { BlueprintField } from "@dune/core/blueprints"; @@ -58,7 +59,7 @@ export const handler = { } } - const dataDir = config.admin?.data_dir ?? "data"; + const dataDir = resolveDataDir(config); const themeConfigPath = `${dataDir}/theme-config.json`; const themeName = engine.config.theme.name; diff --git a/src/admin/routes/api/plugins/[name]/config.ts b/src/admin/routes/api/plugins/[name]/config.ts index 276557d..b254943 100644 --- a/src/admin/routes/api/plugins/[name]/config.ts +++ b/src/admin/routes/api/plugins/[name]/config.ts @@ -1,5 +1,6 @@ /** PUT /admin/api/plugins/:name/config */ +import { resolveDataDir } from "@dune/core/forms"; import type { AdminState } from "../../../../types.ts"; import { requirePermission, json, serverError, csrfCheck } from "../../_utils.ts"; import type { BlueprintField } from "@dune/core/blueprints"; @@ -74,7 +75,7 @@ export const handler = { if (errors.length > 0) return json({ error: `Missing required fields: ${errors.join(", ")}` }, 422); } - const dataDir = config.admin?.data_dir ?? "data"; + const dataDir = resolveDataDir(config); const filePath = `${dataDir}/plugins/${pluginName}.json`; await storage.write(filePath, new TextEncoder().encode(JSON.stringify(body, null, 2))); From 0264a5edc6d22733ba3f6ea2300615ef2ec25305 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Wed, 4 Nov 2026 11:06:59 +0100 Subject: [PATCH 03/39] feat(admin)!: use core's plugin services for security, sessions, users and passwords (slice 1) Value imports of @dune/core security/session/user-store/passwords/logger: 21 -> 0. Errors matched by code; AdminContext carries services. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 11 ++++ mod.ts | 32 ++++----- src/admin/auth/local-provider.ts | 8 +-- src/admin/auth/middleware.ts | 6 +- src/admin/auth/sessions.ts | 52 ++++----------- src/admin/auth/users.ts | 10 +-- src/admin/context.ts | 3 + src/admin/mod.ts | 2 - src/admin/routes/api/_utils.ts | 7 +- src/admin/routes/api/config/index.ts | 5 +- src/admin/routes/api/media/index.ts | 2 +- src/admin/routes/api/media/meta.ts | 7 +- src/admin/routes/api/media/upload.ts | 12 ++-- src/admin/routes/api/pages/[...path]/index.ts | 13 ++-- src/admin/routes/api/pages/index.ts | 4 +- src/admin/routes/api/preview.ts | 17 +++-- src/admin/routes/api/render-markdown.ts | 3 +- src/admin/routes/api/theme-preview.ts | 5 +- src/admin/routes/api/themes/install.ts | 7 +- src/admin/routes/api/users/[id]/index.ts | 8 +-- src/admin/routes/api/users/[id]/password.ts | 7 +- src/admin/routes/api/users/index.ts | 11 ++-- src/admin/routes/api/workflow/transition.ts | 2 +- src/admin/routes/login.tsx | 27 +++++--- src/admin/webhooks.ts | 18 +++-- src/admin/workflow-actions.ts | 7 +- src/collab/manager.ts | 10 +-- src/collab/types.ts | 2 + tests/admin/auth_test.ts | 66 ++++++++++--------- tests/admin/csp_test.ts | 7 +- tests/admin/pages_edit_blueprint_test.ts | 2 + tests/admin/provisioner_test.ts | 27 ++++---- tests/admin/render_markdown_test.ts | 3 + tests/admin/workflow_actions_test.ts | 9 +-- tests/helpers/services.ts | 12 ++++ 35 files changed, 222 insertions(+), 202 deletions(-) create mode 100644 tests/helpers/services.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3530a9f..0d6fc02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,17 @@ follows [Semantic Versioning](https://semver.org). Run `dune migrate:config` (core) to rewrite `config/system.yaml`. A config that still carries an old key is refused by core at load time, so this cannot be silently ignored. Requires `@dune/core` 0.36 or newer. +- **Security policy, sessions, users and passwords come from core's `services`** + (`MountApi.services`, `@dune/core/plugin-api`) instead of imports from + `@dune/core`: HTML sanitising, SSRF-guarded fetch, body limits, client IP, the + YAML parser, password hashing/verification, the user store, rate limiting and + the logger. A core patch release now fixes them for the admin without an admin + release, and session storage (local, KV, Redis) follows `system.session_store` + including its per-site `prefix`. Errors are matched by `isDuneError(err, code)`, + not `instanceof`. Removed: the `hashPassword` / `verifyPassword` / `DUMMY_HASH` + re-exports from `@dune/plugin-admin/admin` (import them from + `@dune/core/auth/passwords`), and the old `createSessionManager` signature + (now async, takes `services`). Requires `@dune/core` with `plugin-api` (0.36). - **The data directory is `system.data_dir` only.** `admin.data_dir` no longer exists (core removed the deprecated form aliases); the plugin resolves the directory with `resolveDataDir()` from `@dune/core/forms`. diff --git a/mod.ts b/mod.ts index bf66b69..760b0fa 100644 --- a/mod.ts +++ b/mod.ts @@ -21,9 +21,7 @@ import type { StorageAdapter } from "@dune/core/storage"; import { createUserManager } from "./src/admin/auth/users.ts"; import { highestValidRole } from "./src/admin/auth/role-utils.ts"; import { createSessionManager } from "./src/admin/auth/sessions.ts"; -import { createSessionStore } from "@dune/core/session"; import { createAuthMiddleware } from "./src/admin/auth/middleware.ts"; -import { LocalRateLimitStore } from "@dune/core/security"; import { LocalAuthProvider } from "./src/admin/auth/local-provider.ts"; import type { AuthProvider } from "./src/admin/auth/provider.ts"; import { createWorkflowEngine } from "@dune/core/workflow"; @@ -45,7 +43,6 @@ import { initAdminContext } from "./src/admin/context.ts"; import type { AdminContext } from "./src/admin/context.ts"; import { createBlockEditorPlugin } from "./src/admin/block-editor-plugin.tsx"; import { getDuneAdminIslands, mountDuneAdmin, mountDuneAdminEarly } from "./src/admin/mount.ts"; -import { logger } from "@dune/core/logger"; import { sectionRegistry } from "@dune/core/sections"; import type { SectionRegistry } from "@dune/core/sections"; import * as corePlugins from "@dune/core/plugins"; @@ -164,7 +161,8 @@ export function createAdminPlugin( setupState = { config, storage, opts, auditLogger, mt }; }, - async mount({ app, bootstrap, adminServices }: MountApi) { + async mount({ app, bootstrap, adminServices, services }: MountApi) { + const { logger } = services; if (!setupState) return; // admin disabled const { opts: o } = setupState; @@ -183,6 +181,7 @@ export function createAdminPlugin( const users = createUserManager({ storage, usersDir: `${dataDir}/users`, + services, }); // Migration warning: detect users left in the old .dune/admin/users location @@ -200,25 +199,13 @@ export function createAdminPlugin( } catch { /* ignore */ } } - const sessionStoreCfg = config.system?.session_store; - const resolvedSessionStore = await createSessionStore({ - type: sessionStoreCfg?.type ?? "local", - redisUrl: sessionStoreCfg?.url - ? (sessionStoreCfg.url.startsWith("$") - ? Deno.env.get(sessionStoreCfg.url.slice(1)) - : sessionStoreCfg.url) - : undefined, - storage, + const sessions = await createSessionManager({ + services, sessionsDir: `${runtimeDir}/sessions`, - lifetimeMs: adminCfg.session_lifetime * 1000, - }); - - const sessions = createSessionManager({ - store: resolvedSessionStore, lifetime: adminCfg.session_lifetime, }); - const rateLimitStore = new LocalRateLimitStore(); + const rateLimitStore = services.rateLimit.createLocalStore(); // Auth provider: injection from bootstrap options > config > local default. let authProvider: AuthProvider; @@ -227,7 +214,7 @@ export function createAdminPlugin( } else { const provCfg = config.admin?.auth_provider; if (!provCfg || provCfg.type === "local") { - authProvider = new LocalAuthProvider(users); + authProvider = new LocalAuthProvider(users, services.passwords); } else if (provCfg.type === "ldap" || provCfg.type === "saml") { throw new Error( `[dune] auth_provider.type "${provCfg.type}" is not implemented. ` + @@ -246,6 +233,7 @@ export function createAdminPlugin( users, secure: secureCookies, trustForwardedFor: config.system?.trusted_proxies === true, + security: services.security, }); const workflow = createWorkflowEngine( @@ -270,7 +258,7 @@ export function createAdminPlugin( return; } try { - await applyWorkflowTransition({ engine: bootstrap.engine, storage, config, hooks: bootstrap.hooks, workflow }, action.sourcePath, newStatus); + await applyWorkflowTransition({ engine: bootstrap.engine, storage, config, hooks: bootstrap.hooks, workflow, services }, action.sourcePath, newStatus); } catch (err) { logger.error("scheduler.action-failed", { actionId: action.id, @@ -290,6 +278,7 @@ export function createAdminPlugin( engine: bootstrap.engine, history: bootstrap.history, contentDir: config.system.content.dir, + security: services.security, }); // Core accepts and stores public form submissions; the admin only @@ -329,6 +318,7 @@ export function createAdminPlugin( .flatMap((p) => p.adminPages ?? []); const adminContext: AdminContext = { + services, engine: bootstrap.engine, search: bootstrap.search, storage, diff --git a/src/admin/auth/local-provider.ts b/src/admin/auth/local-provider.ts index 8e9d4f0..06f5922 100644 --- a/src/admin/auth/local-provider.ts +++ b/src/admin/auth/local-provider.ts @@ -7,19 +7,19 @@ import type { AuthProvider, AuthCredentials, AuthProviderUser } from "./provider.ts"; import type { UserManager } from "./users.ts"; -import { verifyPassword, DUMMY_HASH } from "@dune/core/auth/passwords"; +import type { PasswordService } from "@dune/core/plugin-api"; export class LocalAuthProvider implements AuthProvider { readonly type = "local" as const; - constructor(private users: UserManager) {} + constructor(private users: UserManager, private passwords: Pick) {} async authenticate(creds: AuthCredentials): Promise { const user = await this.users.getByUsername(creds.username); // Always run verifyPassword even when the user is not found to prevent // username enumeration via response-time differences (timing oracle). - const hash = user?.passwordHash ?? DUMMY_HASH; - const valid = await verifyPassword(creds.password, hash); + const hash = user?.passwordHash ?? this.passwords.dummyHash; + const valid = await this.passwords.verify(creds.password, hash); if (!valid || !user || !user.enabled) return null; return { externalId: user.id, diff --git a/src/admin/auth/middleware.ts b/src/admin/auth/middleware.ts index 13da8b0..ed28b3f 100644 --- a/src/admin/auth/middleware.ts +++ b/src/admin/auth/middleware.ts @@ -7,7 +7,7 @@ import type { SessionManager } from "./sessions.ts"; import type { UserManager } from "./users.ts"; import type { AuthResult } from "../types.ts"; -import { clientIp } from "@dune/core/security"; +import type { SecurityService } from "@dune/core/plugin-api"; /** Options for {@link createAuthMiddleware}. */ export interface AuthMiddlewareConfig { @@ -29,6 +29,8 @@ export interface AuthMiddlewareConfig { * a cookie thief can spoof the original IP via a forged header. */ trustForwardedFor?: boolean; + /** Core's security service (client IP resolution). */ + security: Pick; } /** Validates session cookies. Obtain via {@link createAuthMiddleware}. */ @@ -74,7 +76,7 @@ export function createAuthMiddleware( // also send a forged forwarded header that matches the original session // IP, and the binding check is meaningless. if (session.ip) { - const requestIp = clientIp(req, { trustForwardedFor }); + const requestIp = config.security.clientIp(req, { trustForwardedFor }); // "unknown" is a missing peer (no socket stamp, no trusted XFF). // A stolen cookie that omits forwarded headers used to skip this // check; treat a missing IP as a mismatch when the session is bound. diff --git a/src/admin/auth/sessions.ts b/src/admin/auth/sessions.ts index afe39d8..aec5b23 100644 --- a/src/admin/auth/sessions.ts +++ b/src/admin/auth/sessions.ts @@ -6,31 +6,18 @@ * over whichever SessionStore backend (local/KV/Redis) is configured. This * file exists to keep the original SessionManager interface stable for * existing call sites (create() has no embeddedUser param — admin sessions - * never need one) and to preserve the storage/sessionsDir convenience - * construction path. + * never need one). */ -import type { StorageAdapter } from "@dune/core/storage"; import type { Session } from "../types.ts"; -import type { SessionStore } from "@dune/core/session"; -import { createLocalSessionStore, createSessionManager as createCoreSessionManager } from "@dune/core/session"; - -export type { SessionStore }; +import type { PluginServices } from "@dune/core/plugin-api"; /** Options for {@link createSessionManager}. */ export interface SessionManagerConfig { + /** Core's services; the session backend is chosen by core from `system.session_store`. */ + services: Pick; /** - * Pre-constructed session store. When provided, `storage` and `sessionsDir` - * are ignored. - */ - store?: SessionStore; - /** - * StorageAdapter for the local (file-backed) store. - * Required when `store` is not supplied. - */ - storage?: StorageAdapter; - /** - * Directory for session files when using the local backend. + * Directory for session files when the local backend is configured. * E.g. ".dune/admin/sessions" */ sessionsDir?: string; @@ -53,29 +40,14 @@ export interface SessionManager { } /** - * Create a session manager backed by the given store. - * - * When `config.store` is not supplied, falls back to constructing a - * LocalSessionStore from `config.storage` + `config.sessionsDir`, preserving - * the original file-backed behaviour for existing callers. + * Create the admin session manager. Core builds the underlying manager over + * the configured backend (local files, Deno KV or Redis, scoped per site). */ -export function createSessionManager(config: SessionManagerConfig): SessionManager { - const { lifetime } = config; - - const store: SessionStore = config.store ?? (() => { - if (!config.storage) { - throw new Error( - "[dune] createSessionManager: either 'store' or 'storage' must be provided.", - ); - } - return createLocalSessionStore({ - storage: config.storage, - sessionsDir: config.sessionsDir ?? ".dune/admin/sessions", - lifetimeMs: lifetime * 1000, - }); - })(); - - const core = createCoreSessionManager(store, lifetime * 1000); +export async function createSessionManager(config: SessionManagerConfig): Promise { + const core = await config.services.sessions.createManager({ + sessionsDir: config.sessionsDir ?? ".dune/admin/sessions", + lifetimeMs: config.lifetime * 1000, + }); return { // Admin sessions never carry an embeddedUser — the underlying factory's diff --git a/src/admin/auth/users.ts b/src/admin/auth/users.ts index e1f4e73..0cb861f 100644 --- a/src/admin/auth/users.ts +++ b/src/admin/auth/users.ts @@ -16,10 +16,9 @@ */ import type { StorageAdapter } from "@dune/core/storage"; -import { createLocalUserStore } from "@dune/core/auth/user-store"; import type { UserStore } from "@dune/core/auth/user-store"; import type { User, Role } from "../types.ts"; -import { hashPassword } from "@dune/core/auth/passwords"; +import type { PluginServices } from "@dune/core/plugin-api"; import { withRole } from "./role-utils.ts"; /** Options for {@link createUserManager}. */ @@ -27,6 +26,8 @@ export interface UserManagerConfig { storage: StorageAdapter; /** Directory for user files (e.g. "data/users") */ usersDir: string; + /** Core's services: the user store and password hashing. */ + services: Pick; } /** Input for {@link UserManager.create}. */ @@ -63,8 +64,9 @@ export interface UserManager { * Create a user manager backed by @dune/core's UserStore. */ export function createUserManager(config: UserManagerConfig): UserManager { - const { storage, usersDir } = config; - const store: UserStore = createLocalUserStore({ storage, usersDir }); + const { usersDir, services } = config; + const store: UserStore = services.users.createLocalStore({ usersDir }); + const hashPassword = services.passwords.hash; async function create(input: CreateUserInput): Promise { return store.create({ diff --git a/src/admin/context.ts b/src/admin/context.ts index e7958bd..997ba3d 100644 --- a/src/admin/context.ts +++ b/src/admin/context.ts @@ -28,6 +28,7 @@ import type { AuditLogger } from "@dune/core/audit"; import type { MetricsCollector } from "@dune/core/metrics"; import type { MachineTranslator } from "@dune/core/mt"; import type { RateLimitStore } from "@dune/core/security"; +import type { PluginServices } from "@dune/core/plugin-api"; import type { DuneAuthSystem } from "@dune/core/auth/authz"; import type { SearchManager } from "@dune/core/search"; import type { SectionRegistry } from "@dune/core/sections"; @@ -36,6 +37,8 @@ export type { AdminPageRegistration }; /** All services available to admin route handlers via `FreshContext.state.adminContext`. */ export interface AdminContext { + /** Services core hands plugins (security policy, sessions, passwords, ...). */ + services: PluginServices; engine: DuneEngine; search: SearchManager; storage: StorageAdapter; diff --git a/src/admin/mod.ts b/src/admin/mod.ts index 4ababa4..43a9ef7 100644 --- a/src/admin/mod.ts +++ b/src/admin/mod.ts @@ -14,8 +14,6 @@ export type { UserManager, UserManagerConfig, CreateUserInput } from "./auth/use export { createAuthMiddleware } from "./auth/middleware.ts"; export type { AuthMiddleware, AuthMiddlewareConfig } from "./auth/middleware.ts"; -export { hashPassword, verifyPassword, DUMMY_HASH } from "@dune/core/auth/passwords"; - export type { AuthProvider, AuthProviderUser, diff --git a/src/admin/routes/api/_utils.ts b/src/admin/routes/api/_utils.ts index 75664c7..56236ba 100644 --- a/src/admin/routes/api/_utils.ts +++ b/src/admin/routes/api/_utils.ts @@ -6,7 +6,6 @@ import type { FreshContext } from "fresh"; import type { AdminPermission, AdminState } from "../../types.ts"; import { highestValidRole } from "../../auth/role-utils.ts"; import { csrfTokenMatches, resolveCsrfSecret } from "../../auth/csrf.ts"; -import { clientIp } from "@dune/core/security"; // json()/serverError()/typed error classes live in ../../http.ts so the // public API (public-api.ts) and admin routes share one implementation. @@ -29,7 +28,7 @@ function logAuthzDenial( void auditLogger.log({ event, actor: actorFromAuth(ctx.state.auth ?? {}), - ip: getClientIp(ctx.req), + ip: getClientIp(ctx), userAgent: ctx.req.headers.get("user-agent") ?? null, target: { type: "route", id: ctx.url.pathname }, detail, @@ -296,8 +295,8 @@ export function validatePagePath(p: string): boolean { * attribution. Callers that need the raw claimed IP should read the headers * themselves and record them as untrusted metadata. */ -export function getClientIp(req: Request): string | null { - const ip = clientIp(req, { trustForwardedFor: false }); +export function getClientIp(ctx: FreshContext): string | null { + const ip = ctx.state.adminContext.services.security.clientIp(ctx.req, { trustForwardedFor: false }); return ip === "unknown" ? null : ip; } diff --git a/src/admin/routes/api/config/index.ts b/src/admin/routes/api/config/index.ts index bd6dba0..285df8f 100644 --- a/src/admin/routes/api/config/index.ts +++ b/src/admin/routes/api/config/index.ts @@ -3,7 +3,6 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, json, serverError, csrfCheck } from "../_utils.ts"; import { stringify as stringifyYaml } from "@std/yaml"; -import { parseUserYaml as parseYaml } from "@dune/core/security"; import type { FreshContext } from "fresh"; export const handler = { @@ -21,7 +20,7 @@ export const handler = { const denied = await requirePermission(ctx, "config.update"); if (denied) return denied; - const { engine, storage } = ctx.state.adminContext; + const { engine, storage, services } = ctx.state.adminContext; try { const body = await ctx.req.json() as { title?: string; description?: string; url?: string; @@ -32,7 +31,7 @@ export const handler = { // Read-modify-write config/site.yaml (same approach as switchTheme) const existingRaw = await storage.readText("config/site.yaml").catch(() => ""); - const existing = ((parseYaml(existingRaw || "") ?? {}) as Record); + const existing = ((services.security.parseUserYaml(existingRaw || "") ?? {}) as Record); if (body.title !== undefined) existing.title = body.title; if (body.description !== undefined) existing.description = body.description; diff --git a/src/admin/routes/api/media/index.ts b/src/admin/routes/api/media/index.ts index 6808eb1..9e5949b 100644 --- a/src/admin/routes/api/media/index.ts +++ b/src/admin/routes/api/media/index.ts @@ -86,7 +86,7 @@ export const handler = { void auditLogger?.log({ event: "media.delete", actor: actorFromAuth(ctx.state.auth), - ip: getClientIp(ctx.req), + ip: getClientIp(ctx), userAgent: ctx.req.headers.get("user-agent") ?? null, target: { type: "media", id: name }, detail: {}, diff --git a/src/admin/routes/api/media/meta.ts b/src/admin/routes/api/media/meta.ts index a25cf2c..d6aaff4 100644 --- a/src/admin/routes/api/media/meta.ts +++ b/src/admin/routes/api/media/meta.ts @@ -4,7 +4,6 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, json, serverError, csrfCheck } from "../_utils.ts"; import { dirname } from "@std/path"; import { stringify as stringifyYaml } from "@std/yaml"; -import { parseUserYaml as parseYaml } from "@dune/core/security"; import type { FreshContext } from "fresh"; export const handler = { @@ -14,7 +13,7 @@ export const handler = { const denied = await requirePermission(ctx, "media.upload"); if (denied) return denied; - const { storage, config } = ctx.state.adminContext; + const { storage, config, services } = ctx.state.adminContext; try { const body = await ctx.req.json(); const { pagePath, name, focal } = body; @@ -48,7 +47,7 @@ export const handler = { let existing: Record = {}; try { const raw = await storage.read(sidecarPath); - existing = (parseYaml(new TextDecoder().decode(raw)) as Record) ?? {}; + existing = (services.security.parseUserYaml(new TextDecoder().decode(raw)) as Record) ?? {}; } catch { return json({ ok: true }); } delete existing.focal; if (Object.keys(existing).length === 0) { @@ -60,7 +59,7 @@ export const handler = { let existing: Record = {}; try { const raw = await storage.read(sidecarPath); - existing = (parseYaml(new TextDecoder().decode(raw)) as Record) ?? {}; + existing = (services.security.parseUserYaml(new TextDecoder().decode(raw)) as Record) ?? {}; } catch { /* new sidecar */ } existing.focal = focal; await storage.write(sidecarPath, new TextEncoder().encode(stringifyYaml(existing))); diff --git a/src/admin/routes/api/media/upload.ts b/src/admin/routes/api/media/upload.ts index 3e4ac54..3116639 100644 --- a/src/admin/routes/api/media/upload.ts +++ b/src/admin/routes/api/media/upload.ts @@ -5,7 +5,7 @@ import { requirePermission, requireOwnedPage, json, serverError, actorFromAuth, import { dirname } from "@std/path"; import { isMediaFile, dirPathToRoute } from "@dune/core/content/path-utils"; import { getMimeType } from "@dune/core/content/page-loader"; -import { checkBodySize, limitedBody, BodyTooLargeError } from "@dune/core/security"; +import { isDuneError } from "@dune/core/plugin-api"; import type { FreshContext } from "fresh"; export const handler = { @@ -15,17 +15,17 @@ export const handler = { const denied = await requirePermission(ctx, "media.upload"); if (denied) return denied; - const { storage, config, auditLogger } = ctx.state.adminContext; + const { storage, config, auditLogger, services } = ctx.state.adminContext; const MAX_FILE_SIZE = 50 * 1024 * 1024; const maxBodyBytes = (config.admin?.max_upload_mb ?? 100) * 1024 * 1024; try { - const tooLarge = checkBodySize(ctx.req, maxBodyBytes); + const tooLarge = services.security.checkBodySize(ctx.req, maxBodyBytes); if (tooLarge) return tooLarge; // Also enforce via streaming counter — catches chunked uploads that // omit Content-Length and would otherwise bypass the pre-check above. - const formData = await new Response(limitedBody(ctx.req.body, maxBodyBytes), { + const formData = await new Response(services.security.limitedBody(ctx.req.body, maxBodyBytes), { headers: { "content-type": ctx.req.headers.get("content-type") ?? "" }, }).formData(); const file = formData.get("file"); @@ -60,7 +60,7 @@ export const handler = { void auditLogger?.log({ event: "media.upload", actor: actorFromAuth(ctx.state.auth), - ip: getClientIp(ctx.req), + ip: getClientIp(ctx), userAgent: ctx.req.headers.get("user-agent") ?? null, target: { type: "media", id: safeName }, detail: {}, @@ -69,7 +69,7 @@ export const handler = { return json({ ok: true, item: { name: safeName, url, type: mimeType, size: bytes.length, pagePath } }); } catch (err) { - if (err instanceof BodyTooLargeError) { + if (isDuneError(err, "body_too_large")) { return json({ error: "Request too large" }, 413); } return serverError(err); diff --git a/src/admin/routes/api/pages/[...path]/index.ts b/src/admin/routes/api/pages/[...path]/index.ts index 12c1f03..80311c3 100644 --- a/src/admin/routes/api/pages/[...path]/index.ts +++ b/src/admin/routes/api/pages/[...path]/index.ts @@ -12,7 +12,6 @@ import type { AdminState } from "../../../../types.ts"; import { requirePermission, requireTsxWrite, requirePageOwner, requireOwnedPage, pageOwnerId, json, serverError, actorFromAuth, getClientIp, csrfCheck, validatePagePath } from "../../_utils.ts"; import { stringify as stringifyYaml } from "@std/yaml"; -import { parseUserYaml as parseYaml } from "@dune/core/security"; import { resolveBlueprint, validateFrontmatter } from "@dune/core/blueprints"; import { fireContentWebhooks } from "../../../../../admin/webhooks.ts"; import type { PageFrontmatter } from "@dune/core/content/types"; @@ -73,7 +72,7 @@ export const handler = { const denied = await requirePermission(ctx, "pages.update"); if (denied) return denied; - const { engine, storage, config, hooks, auditLogger } = ctx.state.adminContext; + const { engine, storage, config, hooks, auditLogger, services } = ctx.state.adminContext; const authResult = ctx.state.auth; const pagePath = decodeURIComponent(ctx.params.path); if (!validatePagePath(pagePath)) return json({ error: "Invalid path" }, 400); @@ -93,7 +92,7 @@ export const handler = { let raw = new TextDecoder().decode(existing); const existingFmMatch = raw.match(/^---\n([\s\S]*?)\n---/); const existingFm = existingFmMatch - ? (parseYaml(existingFmMatch[1]) as Record ?? {}) + ? (services.security.parseUserYaml(existingFmMatch[1]) as Record ?? {}) : {}; const ownerDenied = requirePageOwner(ctx, pageOwnerId(existingFm)); if (ownerDenied) return ownerDenied; @@ -136,12 +135,12 @@ export const handler = { const webhookEndpoints = config.admin?.webhooks ?? []; const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; if (hooks) hooks.fire("onPageUpdate", { sourcePath: page.sourcePath }).catch(() => {}); - fireContentWebhooks(webhookEndpoints, "onPageUpdate", { sourcePath: page.sourcePath }, runtimeDir); + fireContentWebhooks(webhookEndpoints, "onPageUpdate", { sourcePath: page.sourcePath }, runtimeDir, ctx.state.adminContext.services.security); void auditLogger?.log({ event: "page.update", actor: actorFromAuth(authResult), - ip: getClientIp(ctx.req), + ip: getClientIp(ctx), userAgent: ctx.req.headers.get("user-agent") ?? null, target: { type: "page", id: pagePath }, detail: {}, @@ -178,12 +177,12 @@ export const handler = { const webhookEndpoints = config.admin?.webhooks ?? []; const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; if (hooks) hooks.fire("onPageDelete", { sourcePath: page.sourcePath }).catch(() => {}); - fireContentWebhooks(webhookEndpoints, "onPageDelete", { sourcePath: page.sourcePath }, runtimeDir); + fireContentWebhooks(webhookEndpoints, "onPageDelete", { sourcePath: page.sourcePath }, runtimeDir, ctx.state.adminContext.services.security); void auditLogger?.log({ event: "page.delete", actor: actorFromAuth(authResult), - ip: getClientIp(ctx.req), + ip: getClientIp(ctx), userAgent: ctx.req.headers.get("user-agent") ?? null, target: { type: "page", id: pagePath }, detail: {}, diff --git a/src/admin/routes/api/pages/index.ts b/src/admin/routes/api/pages/index.ts index 6e16914..291490a 100644 --- a/src/admin/routes/api/pages/index.ts +++ b/src/admin/routes/api/pages/index.ts @@ -70,12 +70,12 @@ export const handler = { const webhookEndpoints = config.admin?.webhooks ?? []; const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; if (hooks) hooks.fire("onPageCreate", { sourcePath: `${pagePath}/default${ext}`, title }).catch(() => {}); - fireContentWebhooks(webhookEndpoints, "onPageCreate", { sourcePath: `${pagePath}/default${ext}`, title }, runtimeDir); + fireContentWebhooks(webhookEndpoints, "onPageCreate", { sourcePath: `${pagePath}/default${ext}`, title }, runtimeDir, ctx.state.adminContext.services.security); void auditLogger?.log({ event: "page.create", actor: actorFromAuth(authResult), - ip: getClientIp(ctx.req), + ip: getClientIp(ctx), userAgent: ctx.req.headers.get("user-agent") ?? null, target: { type: "page", id: filePath }, detail: {}, diff --git a/src/admin/routes/api/preview.ts b/src/admin/routes/api/preview.ts index 8fd32e6..44a04f7 100644 --- a/src/admin/routes/api/preview.ts +++ b/src/admin/routes/api/preview.ts @@ -20,7 +20,6 @@ import type { AdminState } from "../../types.ts"; import { requirePermission, serverError, csrfCheck } from "./_utils.ts"; import type { FreshContext } from "fresh"; import type { DuneEngine } from "@dune/core/engine"; -import { sanitizeHtml } from "@dune/core/security"; const PREVIEW_CSP = "sandbox; script-src 'none'; object-src 'none'"; @@ -38,7 +37,11 @@ const PREVIEW_STYLE = "body{font-family:system-ui;padding:2rem;max-width:800px;margin:0 auto;line-height:1.6}img{max-width:100%}pre{background:#f5f5f5;padding:1rem;border-radius:4px;overflow-x:auto}code{background:#f0f0f0;padding:0.1em 0.3em;border-radius:2px}blockquote{border-left:3px solid #ccc;padding-left:1rem;color:#666;margin:1rem 0}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ddd;padding:0.5rem}"; /** Render a real page's saved HTML (or a 404-ish fallback) wrapped in preview CSS. */ -async function renderSavedPage(engine: DuneEngine, sourcePath: string): Promise { +async function renderSavedPage( + engine: DuneEngine, + sourcePath: string, + sanitizeHtml: (html: string) => string, +): Promise { // Exact match only — substring lookup is an IDOR (a fragment like "blog" // would match an arbitrary page the caller did not intend). const pageIndex = engine.pages.find((p) => p.sourcePath === sourcePath); @@ -58,11 +61,11 @@ export const handler = { async GET(ctx: FreshContext) { const denied = await requirePermission(ctx, "pages.read"); if (denied) return denied; - const { engine } = ctx.state.adminContext; + const { engine, services } = ctx.state.adminContext; const sourcePath = ctx.url.searchParams.get("path"); if (!sourcePath) return htmlResponse(``); try { - return await renderSavedPage(engine, sourcePath); + return await renderSavedPage(engine, sourcePath, services.security.sanitizeHtml); } catch (err) { return serverError(err); } @@ -74,7 +77,7 @@ export const handler = { const denied = await requirePermission(ctx, "pages.read"); if (denied) return denied; - const { engine } = ctx.state.adminContext; + const { engine, services } = ctx.state.adminContext; try { const body = await ctx.req.json(); const { sourcePath, content } = body; @@ -82,11 +85,11 @@ export const handler = { if (!sourcePath) { // Sanitize caller-supplied content before embedding in a text/html // response — even authenticated editors must not inject arbitrary HTML. - const safeContent = content ? sanitizeHtml(String(content)) : ""; + const safeContent = content ? services.security.sanitizeHtml(String(content)) : ""; return htmlResponse(`${safeContent}`); } - return await renderSavedPage(engine, sourcePath); + return await renderSavedPage(engine, sourcePath, services.security.sanitizeHtml); } catch (err) { return serverError(err); } diff --git a/src/admin/routes/api/render-markdown.ts b/src/admin/routes/api/render-markdown.ts index 0989d5f..5e6dc09 100644 --- a/src/admin/routes/api/render-markdown.ts +++ b/src/admin/routes/api/render-markdown.ts @@ -25,7 +25,6 @@ import { requirePermission, serverError, csrfCheck } from "./_utils.ts"; import type { FreshContext } from "fresh"; import { parse as parseYaml } from "@std/yaml"; import { Marked } from "marked"; -import { sanitizeHtml } from "@dune/core/security"; /** Maximum content size: 500 KB */ const MAX_BYTES = 500 * 1024; @@ -101,7 +100,7 @@ export const handler = { // Sanitize unless trusted if (!trusted) { - html = sanitizeHtml(html); + html = ctx.state.adminContext.services.security.sanitizeHtml(html); } // Add lazy loading to images diff --git a/src/admin/routes/api/theme-preview.ts b/src/admin/routes/api/theme-preview.ts index dbd5d7e..3f24563 100644 --- a/src/admin/routes/api/theme-preview.ts +++ b/src/admin/routes/api/theme-preview.ts @@ -2,7 +2,6 @@ import type { AdminState } from "../../types.ts"; import { requirePermission, serverError } from "./_utils.ts"; -import { sanitizeHtml } from "@dune/core/security"; import { h, type ComponentType } from "preact"; import { render as renderJsxToString } from "preact-render-to-string"; import { buildPageTitle } from "@dune/core/content/types"; @@ -27,7 +26,7 @@ export const handler = { const denied = await requirePermission(ctx, "config.read"); if (denied) return denied; - const { engine } = ctx.state.adminContext; + const { engine, services } = ctx.state.adminContext; const themeName = ctx.url.searchParams.get("theme") ?? ""; const route = ctx.url.searchParams.get("route") || "/"; @@ -59,7 +58,7 @@ export const handler = { engine.createPreviewTheme(themeName), ]); - const html = sanitizeHtml(await page.html()); + const html = services.security.sanitizeHtml(await page.html()); const templateName = previewLoader.resolveTemplateName(page) ?? "default"; const template = await previewLoader.loadTemplate(templateName); diff --git a/src/admin/routes/api/themes/install.ts b/src/admin/routes/api/themes/install.ts index 0007d59..81f3f42 100644 --- a/src/admin/routes/api/themes/install.ts +++ b/src/admin/routes/api/themes/install.ts @@ -2,7 +2,7 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, json, serverError, csrfCheck } from "../_utils.ts"; -import { safeFetch } from "@dune/core/security"; +import type { SecurityService } from "@dune/core/plugin-api"; import type { StorageAdapter } from "@dune/core/storage"; import { parse as parseYaml, stringify as stringifyYaml } from "@std/yaml"; import { fetchThemeRegistrySafe, isThemeSha256, type RegistryTheme } from "../../../theme-registry.ts"; @@ -78,6 +78,7 @@ async function installJsrTheme( async function installZipTheme( storage: StorageAdapter, + safeFetch: SecurityService["safeFetch"], slug: string, entry: RegistryTheme, ): Promise<{ filesWritten: number }> { @@ -137,7 +138,7 @@ export const handler = { const denied = await requirePermission(ctx, "config.update"); if (denied) return denied; - const { storage } = ctx.state.adminContext; + const { storage, services } = ctx.state.adminContext; try { const { slug } = await ctx.req.json() as { slug?: string }; @@ -179,7 +180,7 @@ export const handler = { error: `Registry entry for "${slug}" is missing a sha256 integrity hash`, }, 502); } - const { filesWritten } = await installZipTheme(storage, slug, entry); + const { filesWritten } = await installZipTheme(storage, services.security.safeFetch, slug, entry); console.log(`[dune/themes] 📦 Installed theme "${slug}" (${filesWritten} files)`); return json({ success: true, slug, filesWritten, method: "zip" }); } diff --git a/src/admin/routes/api/users/[id]/index.ts b/src/admin/routes/api/users/[id]/index.ts index a1d78ae..81ab1b5 100644 --- a/src/admin/routes/api/users/[id]/index.ts +++ b/src/admin/routes/api/users/[id]/index.ts @@ -4,7 +4,7 @@ import type { AdminState } from "../../../../types.ts"; import { requirePermission, json, serverError, actorFromAuth, getClientIp, csrfCheck } from "../../_utils.ts"; import { toUserInfo } from "../../../../types.ts"; import { highestValidRole } from "../../../../auth/role-utils.ts"; -import { DuplicateEmailError } from "@dune/core/auth/user-store"; +import { isDuneError } from "@dune/core/plugin-api"; import type { FreshContext } from "fresh"; export const handler = { @@ -110,7 +110,7 @@ export const handler = { try { updated = await users.update(userId, updates); } catch (err) { - if (err instanceof DuplicateEmailError) { + if (isDuneError(err, "duplicate_email")) { return json({ error: "Email already in use" }, 409); } throw err; @@ -120,7 +120,7 @@ export const handler = { void auditLogger?.log({ event: "user.update", actor: actorFromAuth(authResult), - ip: getClientIp(ctx.req), + ip: getClientIp(ctx), userAgent: ctx.req.headers.get("user-agent") ?? null, target: { type: "user", id: userId }, detail: {}, @@ -176,7 +176,7 @@ export const handler = { void auditLogger?.log({ event: "user.delete", actor: actorFromAuth(authResult), - ip: getClientIp(ctx.req), + ip: getClientIp(ctx), userAgent: ctx.req.headers.get("user-agent") ?? null, target: { type: "user", id: userId }, detail: {}, diff --git a/src/admin/routes/api/users/[id]/password.ts b/src/admin/routes/api/users/[id]/password.ts index 19d098a..764f236 100644 --- a/src/admin/routes/api/users/[id]/password.ts +++ b/src/admin/routes/api/users/[id]/password.ts @@ -2,7 +2,6 @@ import type { AdminState } from "../../../../types.ts"; import { requirePermission, json, serverError, actorFromAuth, getClientIp, csrfCheck } from "../../_utils.ts"; -import { checkPasswordStrength } from "@dune/core/security"; import type { FreshContext } from "fresh"; export const handler = { @@ -12,13 +11,13 @@ export const handler = { const denied = await requirePermission(ctx, "users.update"); if (denied) return denied; - const { users, sessions, auth, config, auditLogger } = ctx.state.adminContext; + const { users, sessions, auth, config, auditLogger, services } = ctx.state.adminContext; const authResult = ctx.state.auth; const userId = ctx.params.id; try { const { password } = await ctx.req.json(); - const strength = checkPasswordStrength(password); + const strength = services.security.checkPasswordStrength(password); if (!strength.ok) return json({ error: strength.reason }, 400); const changed = await users.changePassword(userId, password); @@ -37,7 +36,7 @@ export const handler = { void auditLogger?.log({ event: "user.password", actor: actorFromAuth(authResult), - ip: getClientIp(ctx.req), + ip: getClientIp(ctx), userAgent: ctx.req.headers.get("user-agent") ?? null, target: { type: "user", id: userId }, detail: {}, diff --git a/src/admin/routes/api/users/index.ts b/src/admin/routes/api/users/index.ts index 5de538d..55f4e3d 100644 --- a/src/admin/routes/api/users/index.ts +++ b/src/admin/routes/api/users/index.ts @@ -3,8 +3,7 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, json, serverError, actorFromAuth, getClientIp, csrfCheck } from "../_utils.ts"; import { toUserInfo } from "../../../types.ts"; -import { checkPasswordStrength } from "@dune/core/security"; -import { DuplicateEmailError } from "@dune/core/auth/user-store"; +import { isDuneError } from "@dune/core/plugin-api"; import type { FreshContext } from "fresh"; export const handler = { @@ -29,7 +28,7 @@ export const handler = { const denied = await requirePermission(ctx, "users.create"); if (denied) return denied; - const { users, auditLogger } = ctx.state.adminContext; + const { users, auditLogger, services } = ctx.state.adminContext; const authResult = ctx.state.auth; try { const body = await ctx.req.json(); @@ -39,7 +38,7 @@ export const handler = { return json({ error: "username, password, and role are required" }, 400); } - const strength = checkPasswordStrength(password); + const strength = services.security.checkPasswordStrength(password); if (!strength.ok) return json({ error: strength.reason }, 400); const VALID_ROLES = ["admin", "editor", "author"] as const; @@ -67,7 +66,7 @@ export const handler = { name: name ?? username, }); } catch (err) { - if (err instanceof DuplicateEmailError) { + if (isDuneError(err, "duplicate_email")) { return json({ error: "Email already in use" }, 409); } throw err; @@ -87,7 +86,7 @@ export const handler = { void auditLogger?.log({ event: "user.create", actor: actorFromAuth(authResult), - ip: getClientIp(ctx.req), + ip: getClientIp(ctx), userAgent: ctx.req.headers.get("user-agent") ?? null, target: { type: "user", id: user.id }, detail: {}, diff --git a/src/admin/routes/api/workflow/transition.ts b/src/admin/routes/api/workflow/transition.ts index 0a42063..5d40f3e 100644 --- a/src/admin/routes/api/workflow/transition.ts +++ b/src/admin/routes/api/workflow/transition.ts @@ -35,7 +35,7 @@ export const handler = { } const result = await applyWorkflowTransition( - { engine, storage, config, hooks, workflow }, + { engine, storage, config, hooks, workflow, services: ctx.state.adminContext.services }, sourcePath, newStatus, ); diff --git a/src/admin/routes/login.tsx b/src/admin/routes/login.tsx index 4b1fbc3..f8ca0d3 100644 --- a/src/admin/routes/login.tsx +++ b/src/admin/routes/login.tsx @@ -7,14 +7,22 @@ import type { FreshContext } from "fresh"; import type { AdminState } from "../types.ts"; -import { verifyPassword, DUMMY_HASH, needsRehash } from "@dune/core/auth/passwords"; import { findOrProvisionUser } from "../auth/provisioner.ts"; -import { RateLimiter, clientIp } from "@dune/core/security"; +import type { PluginServices } from "@dune/core/plugin-api"; +import type { RateLimiter } from "@dune/core/security"; import { actorFromAuth, csrfCheck } from "./api/_utils.ts"; // Module-level fallback limiter — used when no rateLimitStore is injected via // AdminContext (single-process deployments, tests, etc.). -const loginRateLimiter = new RateLimiter(5, 15 * 60 * 1000); +const fallbackLimiters = new WeakMap(); +function fallbackLoginLimiter(services: PluginServices): RateLimiter { + let limiter = fallbackLimiters.get(services); + if (!limiter) { + limiter = services.rateLimit.createLimiter(5, 15 * 60 * 1000); + fallbackLimiters.set(services, limiter); + } + return limiter; +} // Per-account lockout fallback (in-process Map, single-process only). // When a RateLimitStore is present on AdminContext, store-backed methods are @@ -101,7 +109,7 @@ export const handler = { const csrfDenied = csrfCheck(ctx); if (csrfDenied) return csrfDenied; - const { auth, users, sessions, prefix, auditLogger, authProvider, config, rateLimitStore } = ctx.state.adminContext; + const { auth, users, sessions, prefix, auditLogger, authProvider, config, rateLimitStore, services } = ctx.state.adminContext; const adminConfig = config.admin!; const url = ctx.url; @@ -134,7 +142,7 @@ export const handler = { // Login. Rate-limit / lockout key is IP-based — only honor forwarded // headers when the operator explicitly opts in via system.trusted_proxies. const trustForwardedFor = config.system?.trusted_proxies === true; - const ip = clientIp(ctx.req, { trustForwardedFor }); + const ip = services.security.clientIp(ctx.req, { trustForwardedFor }); // Use the store-backed rate limiter when available; fall back to the // module-level in-process limiter for single-process deployments. @@ -151,6 +159,7 @@ export const handler = { ); } } else { + const loginRateLimiter = fallbackLoginLimiter(services); if (!loginRateLimiter.check(ip)) { const retryAfter = loginRateLimiter.retryAfter(ip); return ctx.render( @@ -204,8 +213,8 @@ export const handler = { user = await findOrProvisionUser(providerUser, users); } else { const found = await users.getByUsername(username); - const hashToVerify = found?.passwordHash ?? DUMMY_HASH; - const valid = await verifyPassword(password, hashToVerify); + const hashToVerify = found?.passwordHash ?? services.passwords.dummyHash; + const valid = await services.passwords.verify(password, hashToVerify); if (!found || !found.enabled || !valid) { if (rateLimitStore) { await rateLimitStore.recordFailure(accountKey, LOGIN_LOCKOUT_WINDOW_MS); @@ -218,9 +227,9 @@ export const handler = { user = found; // Transparently upgrade legacy (low-iteration) hashes to current cost. // found.passwordHash is guaranteed set here: `valid` above can only be - // true if verifyPassword() matched a real hash, not the DUMMY_HASH + // true if verifyPassword() matched a real hash, not the dummy hash // fallback used when passwordHash is absent. - if (found.passwordHash && needsRehash(found.passwordHash)) { + if (found.passwordHash && services.passwords.needsRehash(found.passwordHash)) { try { await users.changePassword(found.id, password); } catch { diff --git a/src/admin/webhooks.ts b/src/admin/webhooks.ts index c7a0c6f..ab36545 100644 --- a/src/admin/webhooks.ts +++ b/src/admin/webhooks.ts @@ -17,7 +17,11 @@ import { join } from "@std/path"; import { encodeHex } from "@std/encoding/hex"; -import { safeFetch, SsrfBlockedError } from "@dune/core/security"; +import { isDuneError } from "@dune/core/plugin-api"; +import type { SecurityService } from "@dune/core/plugin-api"; + +/** The slice of the security service webhook delivery needs. */ +export type WebhookSecurity = Pick; import type { WebhookContentEvent, WebhookEndpointConfig } from "@dune/core/config"; // === Types === @@ -149,6 +153,7 @@ async function attemptDelivery( endpoint: WebhookEndpointConfig, body: string, attemptNumber: number, + security: WebhookSecurity, ): Promise { const timestamp = Date.now(); try { @@ -170,13 +175,13 @@ async function attemptDelivery( // bots — must be explicit). Treat any redirect as a failed attempt. let resp: Response; try { - resp = await safeFetch( + resp = await security.safeFetch( endpoint.url, { method: "POST", headers, body }, { allowPrivateDestinations: endpoint.allow_private === true }, ); } catch (err) { - const msg = err instanceof SsrfBlockedError ? err.message : String(err); + const msg = isDuneError(err, "ssrf_blocked") ? (err as Error).message : String(err); return { attemptNumber, timestamp, errorMessage: `SSRF guard: ${msg}`, success: false }; } if (resp.ok) { @@ -211,6 +216,7 @@ async function deliverWithRetry( event: WebhookContentEvent, payload: unknown, logDir: string, + security: WebhookSecurity, ): Promise { const id = encodeHex(crypto.getRandomValues(new Uint8Array(8))); const body = JSON.stringify({ event, payload, sentAt: new Date().toISOString() }); @@ -228,7 +234,7 @@ async function deliverWithRetry( }; for (let i = 0; i < 3; i++) { - const attempt = await attemptDelivery(endpoint, body, i + 1); + const attempt = await attemptDelivery(endpoint, body, i + 1, security); record.attempts.push(attempt); if (attempt.success) { @@ -263,12 +269,14 @@ async function deliverWithRetry( * @param event - The content event that occurred * @param payload - Event-specific data included in the POST body * @param logDir - Base directory for delivery logs (admin.runtime_dir) + * @param security - Core's security service (SSRF-guarded fetch) */ export function fireContentWebhooks( endpoints: WebhookEndpointConfig[], event: WebhookContentEvent, payload: unknown, logDir: string, + security: WebhookSecurity, ): void { const active = endpoints.filter( (e) => (e.enabled ?? true) && e.events.includes(event), @@ -278,7 +286,7 @@ export function fireContentWebhooks( // Fire all matching endpoints in parallel — do not await Promise.all( active.map((endpoint) => - deliverWithRetry(endpoint, event, payload, logDir).catch((err) => { + deliverWithRetry(endpoint, event, payload, logDir, security).catch((err) => { console.error(`[dune] webhook: unexpected error for ${endpoint.url}: ${err}`); }) ), diff --git a/src/admin/workflow-actions.ts b/src/admin/workflow-actions.ts index 3fc3c7d..1ee9606 100644 --- a/src/admin/workflow-actions.ts +++ b/src/admin/workflow-actions.ts @@ -12,6 +12,7 @@ * @module */ +import type { PluginServices } from "@dune/core/plugin-api"; import type { DuneEngine } from "@dune/core/engine"; import type { StorageAdapter } from "@dune/core/storage"; import type { DuneConfig } from "@dune/core/config"; @@ -25,6 +26,8 @@ export interface WorkflowActionContext { config: DuneConfig; hooks?: HookRegistry; workflow: WorkflowEngine; + /** Core's services (webhook delivery uses the SSRF-guarded fetch). */ + services: PluginServices; } /** @@ -37,7 +40,7 @@ export async function applyWorkflowTransition( sourcePath: string, newStatus: string, ): Promise<{ from: string; to: string }> { - const { workflow, engine, storage, config, hooks } = ctx; + const { workflow, engine, storage, config, hooks, services } = ctx; const pageIndex = engine.pages.find((p) => p.sourcePath === sourcePath); if (!pageIndex) throw new Error(`Page not found: ${sourcePath}`); @@ -75,7 +78,7 @@ export async function applyWorkflowTransition( const webhookEndpoints = config.admin?.webhooks ?? []; const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; if (hooks) hooks.fire("onWorkflowChange", { sourcePath, from: currentStatus, to: newStatus }).catch(() => {}); - fireContentWebhooks(webhookEndpoints, "onWorkflowChange", { sourcePath, from: currentStatus, to: newStatus }, runtimeDir); + fireContentWebhooks(webhookEndpoints, "onWorkflowChange", { sourcePath, from: currentStatus, to: newStatus }, runtimeDir, services.security); return { from: currentStatus, to: newStatus }; } diff --git a/src/collab/manager.ts b/src/collab/manager.ts index da6f9ba..b454a63 100644 --- a/src/collab/manager.ts +++ b/src/collab/manager.ts @@ -11,7 +11,6 @@ */ import { join } from "@std/path"; -import { parseUserYaml as parseYaml } from "@dune/core/security"; import type { CollabManager, CollabManagerOptions, @@ -58,7 +57,10 @@ function spliceBody(raw: string, newBody: string): string { * Parse frontmatter YAML from a raw file string. * Returns an empty object on failure. */ -function parseFrontmatter(raw: string): Record { +function parseFrontmatter( + raw: string, + parseYaml: (text: string) => unknown, +): Record { const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (!match) return {}; try { @@ -109,7 +111,7 @@ function takeToken(rate: ConnectionRate): boolean { } export function createCollabManager(options: CollabManagerOptions): CollabManager { - const { storage, engine, history, contentDir } = options; + const { storage, engine, history, contentDir, security } = options; // docId → CollabSessionState const sessions = new Map(); @@ -193,7 +195,7 @@ export function createCollabManager(options: CollabManagerOptions): CollabManage } if (history && session.lastEditor) { - const fm = parseFrontmatter(raw); + const fm = parseFrontmatter(raw, security.parseUserYaml); history.record({ sourcePath: session.docId, content: session.content, diff --git a/src/collab/types.ts b/src/collab/types.ts index b60afa0..f6fe8a2 100644 --- a/src/collab/types.ts +++ b/src/collab/types.ts @@ -108,6 +108,8 @@ export interface CollabManagerOptions { history?: HistoryEngine; /** Value of config.system.content.dir — used to build storage paths. */ contentDir: string; + /** Core's security service (safe YAML parsing of frontmatter). */ + security: Pick; } /** The CollabManager public interface exposed to the admin server. */ diff --git a/tests/admin/auth_test.ts b/tests/admin/auth_test.ts index 9be4e71..4eb8c70 100644 --- a/tests/admin/auth_test.ts +++ b/tests/admin/auth_test.ts @@ -2,6 +2,7 @@ * Tests for admin auth subsystem: passwords, sessions, users, middleware. */ +import { makeServices } from "../helpers/services.ts"; import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; import { hashPassword, verifyPassword } from "@dune/core/auth/passwords"; import { createSessionManager } from "../../src/admin/auth/sessions.ts"; @@ -74,8 +75,8 @@ Deno.test("verifyPassword: invalid hash format returns false", async () => { Deno.test("SessionManager: create returns session with ID", async () => { const storage = createMemoryStorage(); - const mgr = createSessionManager({ - storage, + const mgr = await createSessionManager({ + services: makeServices(storage), sessionsDir: ".sessions", lifetime: 3600, }); @@ -88,8 +89,8 @@ Deno.test("SessionManager: create returns session with ID", async () => { Deno.test("SessionManager: get returns valid session", async () => { const storage = createMemoryStorage(); - const mgr = createSessionManager({ - storage, + const mgr = await createSessionManager({ + services: makeServices(storage), sessionsDir: ".sessions", lifetime: 3600, }); @@ -103,8 +104,8 @@ Deno.test("SessionManager: get returns valid session", async () => { Deno.test("SessionManager: get returns null for missing session", async () => { const storage = createMemoryStorage(); - const mgr = createSessionManager({ - storage, + const mgr = await createSessionManager({ + services: makeServices(storage), sessionsDir: ".sessions", lifetime: 3600, }); @@ -114,8 +115,8 @@ Deno.test("SessionManager: get returns null for missing session", async () => { Deno.test("SessionManager: revoke deletes session", async () => { const storage = createMemoryStorage(); - const mgr = createSessionManager({ - storage, + const mgr = await createSessionManager({ + services: makeServices(storage), sessionsDir: ".sessions", lifetime: 3600, }); @@ -128,8 +129,8 @@ Deno.test("SessionManager: revoke deletes session", async () => { Deno.test("SessionManager: revokeAll removes user sessions", async () => { const storage = createMemoryStorage(); - const mgr = createSessionManager({ - storage, + const mgr = await createSessionManager({ + services: makeServices(storage), sessionsDir: ".sessions", lifetime: 3600, }); @@ -147,8 +148,8 @@ Deno.test("SessionManager: revokeAll removes user sessions", async () => { Deno.test("SessionManager: expired session returns null", async () => { const storage = createMemoryStorage(); - const mgr = createSessionManager({ - storage, + const mgr = await createSessionManager({ + services: makeServices(storage), sessionsDir: ".sessions", lifetime: 0, // Expire immediately }); @@ -163,7 +164,7 @@ Deno.test("SessionManager: expired session returns null", async () => { Deno.test("UserManager: create stores user", async () => { const storage = createMemoryStorage(); - const mgr = createUserManager({ storage, usersDir: ".users" }); + const mgr = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); const user = await mgr.create({ username: "alice", @@ -182,7 +183,7 @@ Deno.test("UserManager: create stores user", async () => { Deno.test("UserManager: getByUsername finds user", async () => { const storage = createMemoryStorage(); - const mgr = createUserManager({ storage, usersDir: ".users" }); + const mgr = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); await mgr.create({ username: "bob", @@ -199,14 +200,14 @@ Deno.test("UserManager: getByUsername finds user", async () => { Deno.test("UserManager: getByUsername returns null for missing", async () => { const storage = createMemoryStorage(); - const mgr = createUserManager({ storage, usersDir: ".users" }); + const mgr = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); assertEquals(await mgr.getByUsername("nobody"), null); }); Deno.test("UserManager: list returns all users", async () => { const storage = createMemoryStorage(); - const mgr = createUserManager({ storage, usersDir: ".users" }); + const mgr = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); await mgr.create({ username: "u1", email: "u1@test.com", password: "p", role: "admin", name: "U1" }); await mgr.create({ username: "u2", email: "u2@test.com", password: "p", role: "editor", name: "U2" }); @@ -217,7 +218,7 @@ Deno.test("UserManager: list returns all users", async () => { Deno.test("UserManager: update modifies user fields", async () => { const storage = createMemoryStorage(); - const mgr = createUserManager({ storage, usersDir: ".users" }); + const mgr = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); const user = await mgr.create({ username: "charlie", @@ -236,7 +237,7 @@ Deno.test("UserManager: update modifies user fields", async () => { Deno.test("UserManager: changePassword works", async () => { const storage = createMemoryStorage(); - const mgr = createUserManager({ storage, usersDir: ".users" }); + const mgr = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); const user = await mgr.create({ username: "dave", @@ -258,7 +259,7 @@ Deno.test("UserManager: changePassword works", async () => { Deno.test("UserManager: delete removes user", async () => { const storage = createMemoryStorage(); - const mgr = createUserManager({ storage, usersDir: ".users" }); + const mgr = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); const user = await mgr.create({ username: "ephemeral", @@ -274,7 +275,7 @@ Deno.test("UserManager: delete removes user", async () => { Deno.test("UserManager: has no default-admin bootstrap (no admin is created implicitly)", async () => { const storage = createMemoryStorage(); - const mgr = createUserManager({ storage, usersDir: ".users" }); + const mgr = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); assertEquals("ensureDefaultAdmin" in mgr, false); assertEquals(await mgr.list(), []); @@ -285,9 +286,9 @@ Deno.test("UserManager: has no default-admin bootstrap (no admin is created impl Deno.test("AuthMiddleware: authenticate fails without cookie", async () => { const storage = createMemoryStorage(); - const sessions = createSessionManager({ storage, sessionsDir: ".sess", lifetime: 3600 }); - const users = createUserManager({ storage, usersDir: ".users" }); - const auth = createAuthMiddleware({ sessions, users }); + const sessions = await createSessionManager({ services: makeServices(storage), sessionsDir: ".sess", lifetime: 3600 }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); + const auth = createAuthMiddleware({ sessions, users, security: makeServices(null).security }); const req = new Request("http://localhost/admin/"); const result = await auth.authenticate(req); @@ -297,9 +298,9 @@ Deno.test("AuthMiddleware: authenticate fails without cookie", async () => { Deno.test("AuthMiddleware: authenticate succeeds with valid session", async () => { const storage = createMemoryStorage(); - const sessions = createSessionManager({ storage, sessionsDir: ".sess", lifetime: 3600 }); - const userMgr = createUserManager({ storage, usersDir: ".users" }); - const auth = createAuthMiddleware({ sessions, users: userMgr }); + const sessions = await createSessionManager({ services: makeServices(storage), sessionsDir: ".sess", lifetime: 3600 }); + const userMgr = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); + const auth = createAuthMiddleware({ sessions, users: userMgr, security: makeServices(null).security }); const user = await userMgr.create({ username: "admin", @@ -322,12 +323,13 @@ Deno.test("AuthMiddleware: authenticate succeeds with valid session", async () = Deno.test("AuthMiddleware: bound session fails when forwarded IP is omitted", async () => { const storage = createMemoryStorage(); - const sessions = createSessionManager({ storage, sessionsDir: ".sess", lifetime: 3600 }); - const userMgr = createUserManager({ storage, usersDir: ".users" }); + const sessions = await createSessionManager({ services: makeServices(storage), sessionsDir: ".sess", lifetime: 3600 }); + const userMgr = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); const auth = createAuthMiddleware({ sessions, users: userMgr, trustForwardedFor: true, + security: makeServices(null).security, }); const user = await userMgr.create({ @@ -357,11 +359,11 @@ Deno.test("AuthMiddleware: bound session fails when forwarded IP is omitted", as assertEquals(matching.authenticated, true); }); -Deno.test("AuthMiddleware: createSessionCookie formats correctly", () => { +Deno.test("AuthMiddleware: createSessionCookie formats correctly", async () => { const storage = createMemoryStorage(); - const sessions = createSessionManager({ storage, sessionsDir: ".sess", lifetime: 3600 }); - const users = createUserManager({ storage, usersDir: ".users" }); - const auth = createAuthMiddleware({ sessions, users }); + const sessions = await createSessionManager({ services: makeServices(storage), sessionsDir: ".sess", lifetime: 3600 }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); + const auth = createAuthMiddleware({ sessions, users, security: makeServices(null).security }); const cookie = auth.createSessionCookie("abc123", 86400); assertEquals(cookie.includes("dune_session=abc123"), true); diff --git a/tests/admin/csp_test.ts b/tests/admin/csp_test.ts index 3937837..5a849e1 100644 --- a/tests/admin/csp_test.ts +++ b/tests/admin/csp_test.ts @@ -12,6 +12,7 @@ * into the CSP's script-src/style-src directives. */ +import { makeServices } from "../helpers/services.ts"; import { assertEquals, assertMatch } from "https://deno.land/std@0.224.0/assert/mod.ts"; import { handler } from "../../src/admin/routes/_middleware.ts"; import { createAuthMiddleware } from "../../src/admin/auth/middleware.ts"; @@ -54,9 +55,9 @@ function createMemoryStorage() { async function buildAdminContext() { const storage = createMemoryStorage(); - const sessions = createSessionManager({ storage, sessionsDir: ".sess", lifetime: 3600 }); - const users = createUserManager({ storage, usersDir: ".users" }); - const auth = createAuthMiddleware({ sessions, users }); + const sessions = await createSessionManager({ services: makeServices(storage), sessionsDir: ".sess", lifetime: 3600 }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); + const auth = createAuthMiddleware({ sessions, users, security: makeServices(null).security }); const user = await users.create({ username: "admin", diff --git a/tests/admin/pages_edit_blueprint_test.ts b/tests/admin/pages_edit_blueprint_test.ts index 23ee66b..4dbbb50 100644 --- a/tests/admin/pages_edit_blueprint_test.ts +++ b/tests/admin/pages_edit_blueprint_test.ts @@ -12,6 +12,7 @@ * leaving the old value in place. */ +import { makeServices } from "../helpers/services.ts"; import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; import { handler } from "../../src/admin/routes/api/pages/[...path]/index.ts"; @@ -60,6 +61,7 @@ function makeCtx(opts: { state: { auth: { authenticated: true, user: { id: "1", username: "admin", roles: ["admin"] } }, adminContext: { + services: makeServices(null), engine, storage: opts.storage, config: { system: { content: { dir: "content" } }, admin: {} }, diff --git a/tests/admin/provisioner_test.ts b/tests/admin/provisioner_test.ts index c9e6c55..f6d9c37 100644 --- a/tests/admin/provisioner_test.ts +++ b/tests/admin/provisioner_test.ts @@ -7,6 +7,7 @@ * is an array. */ +import { makeServices } from "../helpers/services.ts"; import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; import { findOrProvisionUser } from "../../src/admin/auth/provisioner.ts"; import { LocalAuthProvider } from "../../src/admin/auth/local-provider.ts"; @@ -67,7 +68,7 @@ function providerUser(overrides: Partial = {}): AuthProviderUs Deno.test("findOrProvisionUser: creates a new user at defaultRole when provider reports no roles", async () => { const storage = createMemoryStorage(); - const users = createUserManager({ storage, usersDir: ".users" }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); const created = await findOrProvisionUser(providerUser(), users, { defaultRole: "editor" }); @@ -78,7 +79,7 @@ Deno.test("findOrProvisionUser: creates a new user at defaultRole when provider Deno.test("findOrProvisionUser: accepts a provider-reported role at or below defaultRole", async () => { const storage = createMemoryStorage(); - const users = createUserManager({ storage, usersDir: ".users" }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); const created = await findOrProvisionUser( providerUser({ roles: ["author"] }), @@ -91,7 +92,7 @@ Deno.test("findOrProvisionUser: accepts a provider-reported role at or below def Deno.test("findOrProvisionUser: caps a new user's role at defaultRole even if the provider reports higher", async () => { const storage = createMemoryStorage(); - const users = createUserManager({ storage, usersDir: ".users" }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); const created = await findOrProvisionUser( providerUser({ roles: ["admin"] }), @@ -104,7 +105,7 @@ Deno.test("findOrProvisionUser: caps a new user's role at defaultRole even if th Deno.test("findOrProvisionUser: ignores unknown role strings in the roles array, falls back to defaultRole", async () => { const storage = createMemoryStorage(); - const users = createUserManager({ storage, usersDir: ".users" }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); const created = await findOrProvisionUser( providerUser({ roles: ["superadmin", "not-a-role"] }), @@ -117,7 +118,7 @@ Deno.test("findOrProvisionUser: ignores unknown role strings in the roles array, Deno.test("findOrProvisionUser: picks the highest-ranked valid role when roles has multiple entries", async () => { const storage = createMemoryStorage(); - const users = createUserManager({ storage, usersDir: ".users" }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); const created = await findOrProvisionUser( providerUser({ roles: ["author", "editor"] }), @@ -132,7 +133,7 @@ Deno.test("findOrProvisionUser: picks the highest-ranked valid role when roles h Deno.test("findOrProvisionUser: refuses to elevate an existing user's role from provider attributes", async () => { const storage = createMemoryStorage(); - const users = createUserManager({ storage, usersDir: ".users" }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); await users.create({ username: "bob", email: "bob@example.com", @@ -152,7 +153,7 @@ Deno.test("findOrProvisionUser: refuses to elevate an existing user's role from Deno.test("findOrProvisionUser: permits demoting an existing user's role from provider attributes", async () => { const storage = createMemoryStorage(); - const users = createUserManager({ storage, usersDir: ".users" }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); await users.create({ username: "bob", email: "bob@example.com", @@ -172,7 +173,7 @@ Deno.test("findOrProvisionUser: permits demoting an existing user's role from pr Deno.test("findOrProvisionUser: keeps existing user's role unchanged when provider reports no roles", async () => { const storage = createMemoryStorage(); - const users = createUserManager({ storage, usersDir: ".users" }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); await users.create({ username: "bob", email: "bob@example.com", @@ -188,7 +189,7 @@ Deno.test("findOrProvisionUser: keeps existing user's role unchanged when provid Deno.test("findOrProvisionUser: syncs display name from the provider on each login", async () => { const storage = createMemoryStorage(); - const users = createUserManager({ storage, usersDir: ".users" }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); await users.create({ username: "bob", email: "bob@example.com", @@ -210,7 +211,7 @@ Deno.test("findOrProvisionUser: syncs display name from the provider on each log Deno.test("LocalAuthProvider: authenticate returns the user's role wrapped in a roles array", async () => { const storage = createMemoryStorage(); - const users = createUserManager({ storage, usersDir: ".users" }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); await users.create({ username: "alice", email: "alice@example.com", @@ -219,7 +220,7 @@ Deno.test("LocalAuthProvider: authenticate returns the user's role wrapped in a name: "Alice", }); - const provider = new LocalAuthProvider(users); + const provider = new LocalAuthProvider(users, makeServices(null).passwords); const result = await provider.authenticate({ username: "alice", password: "password123" }); assertEquals(result?.roles, ["admin"]); @@ -227,7 +228,7 @@ Deno.test("LocalAuthProvider: authenticate returns the user's role wrapped in a Deno.test("LocalAuthProvider: authenticate returns null for wrong password", async () => { const storage = createMemoryStorage(); - const users = createUserManager({ storage, usersDir: ".users" }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); await users.create({ username: "alice", email: "alice@example.com", @@ -236,7 +237,7 @@ Deno.test("LocalAuthProvider: authenticate returns null for wrong password", asy name: "Alice", }); - const provider = new LocalAuthProvider(users); + const provider = new LocalAuthProvider(users, makeServices(null).passwords); const result = await provider.authenticate({ username: "alice", password: "wrong" }); assertEquals(result, null); diff --git a/tests/admin/render_markdown_test.ts b/tests/admin/render_markdown_test.ts index 8d64d2b..6405c2a 100644 --- a/tests/admin/render_markdown_test.ts +++ b/tests/admin/render_markdown_test.ts @@ -5,6 +5,7 @@ * No filesystem access — all tests operate on in-memory content strings. */ +import { makeServices } from "../helpers/services.ts"; import { assertEquals, assertExists, @@ -32,6 +33,7 @@ async function callRenderMarkdown(body: unknown): Promise { state: { auth: { authenticated: true, user: { id: "1", roles: ["admin"] } }, adminContext: { + services: makeServices(null), authz: { check: () => Promise.resolve(true) }, auditLogger: null, config: { system: { debug: false } }, diff --git a/tests/admin/workflow_actions_test.ts b/tests/admin/workflow_actions_test.ts index 6e53136..c6340ab 100644 --- a/tests/admin/workflow_actions_test.ts +++ b/tests/admin/workflow_actions_test.ts @@ -11,6 +11,7 @@ * executeScheduledAction-shaped callback actually applies the transition. */ +import { makeServices } from "../helpers/services.ts"; import { assertEquals, assertRejects } from "https://deno.land/std@0.224.0/assert/mod.ts"; import type { PageIndex } from "@dune/core/content/types"; import type { DuneConfig } from "@dune/core/config"; @@ -79,7 +80,7 @@ Deno.test("applyWorkflowTransition: patches status and published, rebuilds the i const result = await applyWorkflowTransition( // deno-lint-ignore no-explicit-any - { engine: engine as any, storage, config: CONFIG, workflow }, + { engine: engine as any, storage, config: CONFIG, workflow, services: makeServices(storage) }, "01.blog/01.hello/default.md", "published", ); @@ -104,7 +105,7 @@ Deno.test("applyWorkflowTransition: throws for an unknown sourcePath", async () () => applyWorkflowTransition( // deno-lint-ignore no-explicit-any - { engine: engine as any, storage, config: CONFIG, workflow }, + { engine: engine as any, storage, config: CONFIG, workflow, services: makeServices(storage) }, "does/not/exist.md", "published", ), @@ -124,7 +125,7 @@ Deno.test("applyWorkflowTransition: rejects a status value with invalid characte () => applyWorkflowTransition( // deno-lint-ignore no-explicit-any - { engine: engine as any, storage, config: CONFIG, workflow }, + { engine: engine as any, storage, config: CONFIG, workflow, services: makeServices(storage) }, "01.blog/01.hello/default.md", "not\nvalid", ), @@ -171,7 +172,7 @@ Deno.test("Scheduler + executeScheduledAction-shaped callback: a due action actu const newStatus = SCHEDULED_ACTION_STATUS[action.action]; await applyWorkflowTransition( // deno-lint-ignore no-explicit-any - { engine: engine as any, storage, config: CONFIG, workflow }, + { engine: engine as any, storage, config: CONFIG, workflow, services: makeServices(storage) }, action.sourcePath, newStatus, ); diff --git a/tests/helpers/services.ts b/tests/helpers/services.ts new file mode 100644 index 0000000..b58c457 --- /dev/null +++ b/tests/helpers/services.ts @@ -0,0 +1,12 @@ +import { createPluginServices } from "@dune/core/plugin-api"; +import type { PluginServices } from "@dune/core/plugin-api"; +import type { DuneConfig } from "@dune/core/config"; +import type { StorageAdapter } from "@dune/core/storage"; + +/** Core's plugin services over a test storage (default config: local sessions). */ +export function makeServices(storage: unknown): PluginServices { + return createPluginServices({ + config: { system: {}, site: {} } as unknown as DuneConfig, + storage: storage as StorageAdapter, + }); +} From b4c75c707149973d514661f2801da90de4dd0171 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Wed, 4 Nov 2026 21:27:00 +0100 Subject: [PATCH 04/39] feat(admin)!: use core's engines and authz seeding (plugin-api slice 2) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 5 +++++ mod.ts | 47 ++++++----------------------------------------- 2 files changed, 11 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d6fc02..7e9412e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ follows [Semantic Versioning](https://semver.org). Run `dune migrate:config` (core) to rewrite `config/system.yaml`. A config that still carries an old key is refused by core at load time, so this cannot be silently ignored. Requires `@dune/core` 0.36 or newer. +- **The workflow, scheduler, staging, machine-translation and audit engines come + from core's `services.engines`**, and admin tuples are seeded through + `services.authz.seedAdminTuples()` instead of importing `bootstrapAdminTuples`. + Core builds them once per site under `admin.runtime_dir`; the admin no longer + calls their factories. Requires `@dune/core` 0.36 or newer. - **Security policy, sessions, users and passwords come from core's `services`** (`MountApi.services`, `@dune/core/plugin-api`) instead of imports from `@dune/core`: HTML sanitising, SSRF-guarded fetch, body limits, client IP, the diff --git a/mod.ts b/mod.ts index 760b0fa..bd5a754 100644 --- a/mod.ts +++ b/mod.ts @@ -24,18 +24,13 @@ import { createSessionManager } from "./src/admin/auth/sessions.ts"; import { createAuthMiddleware } from "./src/admin/auth/middleware.ts"; import { LocalAuthProvider } from "./src/admin/auth/local-provider.ts"; import type { AuthProvider } from "./src/admin/auth/provider.ts"; -import { createWorkflowEngine } from "@dune/core/workflow"; -import { createScheduler } from "@dune/core/workflow"; import type { ScheduledAction } from "@dune/core/workflow"; import { applyWorkflowTransition, SCHEDULED_ACTION_STATUS } from "./src/admin/workflow-actions.ts"; import { resolveDataDir } from "@dune/core/forms"; -import { createStagingEngine } from "@dune/core/staging"; import { createCommentManager } from "./src/admin/comments.ts"; import { createCollabManager } from "./src/collab/mod.ts"; -import { createMachineTranslator } from "@dune/core/mt"; import type { MachineTranslator } from "@dune/core/mt"; -import { AuditLogger } from "@dune/core/audit"; -import { bootstrapAdminTuples } from "@dune/core/auth/authz"; +import type { AuditLogger } from "@dune/core/audit"; import type { DuneAuthSystem } from "@dune/core/auth/authz"; import type { AuthzLocalAdapter } from "@dune/core/auth/authz-adapter-local"; import type { AuthzDbAdapter } from "@dune/core/auth/authz-adapter-db"; @@ -120,7 +115,7 @@ export function createAdminPlugin( mountDuneAdminEarly(app as any, bootstrap, () => liveAdminContext); }, - async setup(_api) { + async setup(api) { const adminCfg = config.admin ?? { path: "/admin", session_lifetime: 86400, @@ -132,31 +127,8 @@ export function createAdminPlugin( return; } - // Audit logger — created in setup() so it can init() its file handle early. - let auditLogger: AuditLogger | null = null; - if (adminCfg.audit?.enabled !== false) { - const configuredPath = adminCfg.audit?.log_file; - const runtimeDir = adminCfg.runtime_dir ?? ".dune/admin"; - const auditLogFile = configuredPath - ? join(opts.root, configuredPath) - : join(opts.root, runtimeDir, "audit.log"); - const containmentRoot = opts.root.endsWith("/") || opts.root.endsWith("\\") - ? opts.root - : opts.root + "/"; - if (!auditLogFile.startsWith(containmentRoot)) { - throw new Error( - `[dune] admin.audit.log_file must resolve under the site root. ` + - `Got: ${configuredPath} -> ${auditLogFile}`, - ); - } - auditLogger = new AuditLogger({ logFile: auditLogFile }); - await auditLogger.init(); - } - - // Machine translation provider. - const mt: MachineTranslator | null = config.site.machine_translation - ? createMachineTranslator(config.site.machine_translation) - : null; + // Audit trail and machine translator are built by core. + const { audit: auditLogger, mt } = api.services.engines; setupState = { config, storage, opts, auditLogger, mt }; }, @@ -236,12 +208,7 @@ export function createAdminPlugin( security: services.security, }); - const workflow = createWorkflowEngine( - { storage, dataDir: runtimeDir }, - config.site.workflow ?? undefined, - ); - - const scheduler = createScheduler({ storage, dataDir: runtimeDir }); + const { workflow, scheduler, staging: stagingEngine } = services.engines; // scheduler on its own only supports CRUD (schedule/cancel/list) — it // never executes anything by itself. Whichever long-running process @@ -269,8 +236,6 @@ export function createAdminPlugin( } }; - const stagingEngine = createStagingEngine({ storage, runtimeDir }); - const commentManager = createCommentManager({ dataDir, runtimeDir }); const collabManager = createCollabManager({ @@ -397,7 +362,7 @@ export function createAdminPlugin( .filter((u) => u.enabled !== false) .map((u) => ({ id: u.id, role: highestValidRole(u.roles) })) .filter((u) => u.role !== undefined) as { id: string; role: string }[]; - await bootstrapAdminTuples(bootstrap.authz, bootstrap.authzAdapter, enabledAdminUsers); + await services.authz.seedAdminTuples(enabledAdminUsers); } catch (err) { console.warn( "[dune/authz] Admin tuple bootstrap failed — authz is up but tuples were not seeded; admin access will deny (403) until bootstrap succeeds:", From 4d3454325411cdb6edcbaad46ff656c1d8e1badb Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Wed, 4 Nov 2026 21:40:21 +0100 Subject: [PATCH 05/39] feat(admin)!: use core's blueprint and path services (plugin-api slice 3) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 5 +++++ mod.ts | 3 +-- src/admin/routes/api/config/theme-config.ts | 5 ++--- src/admin/routes/api/pages/[...path]/index.ts | 11 ++++------- src/admin/routes/api/plugins/[name]/config.ts | 3 +-- tests/admin/pages_edit_blueprint_test.ts | 2 +- tests/admin/theme_config_test.ts | 2 ++ tests/helpers/services.ts | 5 +++-- 8 files changed, 19 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e9412e..46d30a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,11 @@ follows [Semantic Versioning](https://semver.org). `services.authz.seedAdminTuples()` instead of importing `bootstrapAdminTuples`. Core builds them once per site under `admin.runtime_dir`; the admin no longer calls their factories. Requires `@dune/core` 0.36 or newer. +- **Blueprints and data paths come from core's `services`**: `services.blueprints` + (`has` / `resolve` / `validate`, read from the content engine at call time) + replaces importing `resolveBlueprint` / `validateFrontmatter`, and + `services.paths.dataDir` replaces `resolveDataDir()`. Requires `@dune/core` + 0.36 or newer. - **Security policy, sessions, users and passwords come from core's `services`** (`MountApi.services`, `@dune/core/plugin-api`) instead of imports from `@dune/core`: HTML sanitising, SSRF-guarded fetch, body limits, client IP, the diff --git a/mod.ts b/mod.ts index bd5a754..14a73db 100644 --- a/mod.ts +++ b/mod.ts @@ -26,7 +26,6 @@ import { LocalAuthProvider } from "./src/admin/auth/local-provider.ts"; import type { AuthProvider } from "./src/admin/auth/provider.ts"; import type { ScheduledAction } from "@dune/core/workflow"; import { applyWorkflowTransition, SCHEDULED_ACTION_STATUS } from "./src/admin/workflow-actions.ts"; -import { resolveDataDir } from "@dune/core/forms"; import { createCommentManager } from "./src/admin/comments.ts"; import { createCollabManager } from "./src/collab/mod.ts"; import type { MachineTranslator } from "@dune/core/mt"; @@ -146,7 +145,7 @@ export function createAdminPlugin( }; const runtimeDir = adminCfg.runtime_dir ?? ".dune/admin"; // `system.data_dir` — the same resolution core uses for the submissions it stores. - const dataDir = resolveDataDir(config); + const dataDir = services.paths.dataDir; // ── Admin services ──────────────────────────────────────────────────────── diff --git a/src/admin/routes/api/config/theme-config.ts b/src/admin/routes/api/config/theme-config.ts index c2a3cc7..6902ec0 100644 --- a/src/admin/routes/api/config/theme-config.ts +++ b/src/admin/routes/api/config/theme-config.ts @@ -1,6 +1,5 @@ /** GET + PUT /admin/api/config/theme-config */ -import { resolveDataDir } from "@dune/core/forms"; import type { AdminState } from "../../../types.ts"; import { requirePermission, json, serverError, csrfCheck } from "../_utils.ts"; import type { BlueprintField } from "@dune/core/blueprints"; @@ -25,7 +24,7 @@ export const handler = { const denied = await requirePermission(ctx, "config.update"); if (denied) return denied; - const { engine, storage, config, hooks } = ctx.state.adminContext; + const { engine, storage, hooks } = ctx.state.adminContext; try { const body = await ctx.req.json() as Record; const manifest = engine.themes.theme.manifest; @@ -59,7 +58,7 @@ export const handler = { } } - const dataDir = resolveDataDir(config); + const dataDir = ctx.state.adminContext.services.paths.dataDir; const themeConfigPath = `${dataDir}/theme-config.json`; const themeName = engine.config.theme.name; diff --git a/src/admin/routes/api/pages/[...path]/index.ts b/src/admin/routes/api/pages/[...path]/index.ts index 80311c3..638de1c 100644 --- a/src/admin/routes/api/pages/[...path]/index.ts +++ b/src/admin/routes/api/pages/[...path]/index.ts @@ -12,7 +12,6 @@ import type { AdminState } from "../../../../types.ts"; import { requirePermission, requireTsxWrite, requirePageOwner, requireOwnedPage, pageOwnerId, json, serverError, actorFromAuth, getClientIp, csrfCheck, validatePagePath } from "../../_utils.ts"; import { stringify as stringifyYaml } from "@std/yaml"; -import { resolveBlueprint, validateFrontmatter } from "@dune/core/blueprints"; import { fireContentWebhooks } from "../../../../../admin/webhooks.ts"; import type { PageFrontmatter } from "@dune/core/content/types"; import type { FreshContext } from "fresh"; @@ -51,10 +50,7 @@ export const handler = { // nothing for every site with blueprints configured. Resolve // inheritance (`extends`) the same way PUT's validateFrontmatter() // does, rather than sending the raw unresolved definition. - const rawBlueprint = engine.blueprints[page.template]; - const blueprint = rawBlueprint - ? resolveBlueprint(page.template, rawBlueprint, engine.blueprints, 0) - : null; + const blueprint = ctx.state.adminContext.services.blueprints.resolve(page.template); return json({ sourcePath: page.sourcePath, route: page.route, format: page.format, template: page.template, frontmatter: page.frontmatter, rawContent: page.rawContent, @@ -113,8 +109,9 @@ export const handler = { else delete mergedFm.createdBy; const template = (mergedFm.template as string) ?? page.template; - if (engine.blueprints[template]) { - const errors = validateFrontmatter(mergedFm as PageFrontmatter, template, engine.blueprints); + const { blueprints } = ctx.state.adminContext.services; + if (blueprints.has(template)) { + const errors = blueprints.validate(mergedFm as PageFrontmatter, template); if (errors.length > 0) { return json({ error: "Blueprint validation failed", validationErrors: errors }, 422); } diff --git a/src/admin/routes/api/plugins/[name]/config.ts b/src/admin/routes/api/plugins/[name]/config.ts index b254943..6b83b1a 100644 --- a/src/admin/routes/api/plugins/[name]/config.ts +++ b/src/admin/routes/api/plugins/[name]/config.ts @@ -1,6 +1,5 @@ /** PUT /admin/api/plugins/:name/config */ -import { resolveDataDir } from "@dune/core/forms"; import type { AdminState } from "../../../../types.ts"; import { requirePermission, json, serverError, csrfCheck } from "../../_utils.ts"; import type { BlueprintField } from "@dune/core/blueprints"; @@ -75,7 +74,7 @@ export const handler = { if (errors.length > 0) return json({ error: `Missing required fields: ${errors.join(", ")}` }, 422); } - const dataDir = resolveDataDir(config); + const dataDir = ctx.state.adminContext.services.paths.dataDir; const filePath = `${dataDir}/plugins/${pluginName}.json`; await storage.write(filePath, new TextEncoder().encode(JSON.stringify(body, null, 2))); diff --git a/tests/admin/pages_edit_blueprint_test.ts b/tests/admin/pages_edit_blueprint_test.ts index 4dbbb50..d32ceda 100644 --- a/tests/admin/pages_edit_blueprint_test.ts +++ b/tests/admin/pages_edit_blueprint_test.ts @@ -61,7 +61,7 @@ function makeCtx(opts: { state: { auth: { authenticated: true, user: { id: "1", username: "admin", roles: ["admin"] } }, adminContext: { - services: makeServices(null), + services: makeServices(null, { getBlueprints: () => engine.blueprints as never }), engine, storage: opts.storage, config: { system: { content: { dir: "content" } }, admin: {} }, diff --git a/tests/admin/theme_config_test.ts b/tests/admin/theme_config_test.ts index 0b3334c..5b206a2 100644 --- a/tests/admin/theme_config_test.ts +++ b/tests/admin/theme_config_test.ts @@ -7,6 +7,7 @@ import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; import { handler } from "../../src/admin/routes/api/config/theme-config.ts"; +import { makeServices } from "../helpers/services.ts"; // deno-lint-ignore no-explicit-any function makeStorage(initial: Record = {}): any { @@ -48,6 +49,7 @@ function makeCtx(opts: { state: { auth: { authenticated: true, user: { id: "1", username: "admin" } }, adminContext: { + services: makeServices(opts.storage), engine, storage: opts.storage, config: { admin: { data_dir: "data" } }, diff --git a/tests/helpers/services.ts b/tests/helpers/services.ts index b58c457..03f4408 100644 --- a/tests/helpers/services.ts +++ b/tests/helpers/services.ts @@ -1,12 +1,13 @@ import { createPluginServices } from "@dune/core/plugin-api"; -import type { PluginServices } from "@dune/core/plugin-api"; +import type { PluginServices, PluginServicesOptions } from "@dune/core/plugin-api"; import type { DuneConfig } from "@dune/core/config"; import type { StorageAdapter } from "@dune/core/storage"; /** Core's plugin services over a test storage (default config: local sessions). */ -export function makeServices(storage: unknown): PluginServices { +export function makeServices(storage: unknown, extra: Partial = {}): PluginServices { return createPluginServices({ config: { system: {}, site: {} } as unknown as DuneConfig, storage: storage as StorageAdapter, + ...extra, }); } From d3e6cea7993a3a7509c396d1baed4d3de9972448 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 01:26:22 +0100 Subject: [PATCH 06/39] feat(admin)!: remove the search analytics route Nothing ever wrote search-analytics.jsonl, so it always returned an empty summary. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 3 + src/admin/manifest.gen.ts | 186 +++++++++++------------ src/admin/routes/api/search/analytics.ts | 23 --- 3 files changed, 95 insertions(+), 117 deletions(-) delete mode 100644 src/admin/routes/api/search/analytics.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 46d30a7..f4e984f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ follows [Semantic Versioning](https://semver.org). replaces importing `resolveBlueprint` / `validateFrontmatter`, and `services.paths.dataDir` replaces `resolveDataDir()`. Requires `@dune/core` 0.36 or newer. +- **`GET /admin/api/search/analytics` is removed.** It summarised + `search-analytics.jsonl`, which nothing in core or the first-party plugins + ever wrote, so it always returned an empty summary. - **Security policy, sessions, users and passwords come from core's `services`** (`MountApi.services`, `@dune/core/plugin-api`) instead of imports from `@dune/core`: HTML sanitising, SSRF-guarded fetch, body limits, client IP, the diff --git a/src/admin/manifest.gen.ts b/src/admin/manifest.gen.ts index 5c81817..098eb79 100644 --- a/src/admin/manifest.gen.ts +++ b/src/admin/manifest.gen.ts @@ -51,53 +51,52 @@ import * as r44 from "./routes/api/preview.ts"; import * as r45 from "./routes/api/registry/plugins.ts"; import * as r46 from "./routes/api/registry/themes.ts"; import * as r47 from "./routes/api/render-markdown.ts"; -import * as r48 from "./routes/api/search/analytics.ts"; -import * as r49 from "./routes/api/search/engines.ts"; -import * as r50 from "./routes/api/sections.ts"; -import * as r51 from "./routes/api/staging/[...path]/index.ts"; -import * as r52 from "./routes/api/staging/[...path]/publish.ts"; -import * as r53 from "./routes/api/theme-preview.ts"; -import * as r54 from "./routes/api/themes/install.ts"; -import * as r55 from "./routes/api/users/index.ts"; -import * as r56 from "./routes/api/users/[id]/index.ts"; -import * as r57 from "./routes/api/users/[id]/password.ts"; -import * as r58 from "./routes/api/webhooks/deliveries.ts"; -import * as r59 from "./routes/api/workflow/schedule/index.ts"; -import * as r60 from "./routes/api/workflow/schedule/[id].ts"; -import * as r61 from "./routes/api/workflow/scheduled/[...path].ts"; -import * as r62 from "./routes/api/workflow/stages.ts"; -import * as r63 from "./routes/api/workflow/status/[...path].ts"; -import * as r64 from "./routes/api/workflow/transition.ts"; -import * as r65 from "./routes/audit/index.tsx"; -import * as r66 from "./routes/collab/edit-ws.ts"; -import * as r67 from "./routes/collab/ws.ts"; -import * as r68 from "./routes/config/index.tsx"; -import * as r69 from "./routes/email-preview/index.tsx"; -import * as r70 from "./routes/flex/index.tsx"; -import * as r71 from "./routes/flex/[type]/index.tsx"; -import * as r72 from "./routes/flex/[type]/[id].tsx"; -import * as r73 from "./routes/i18n/index.tsx"; -import * as r74 from "./routes/i18n/memory.tsx"; -import * as r75 from "./routes/jobs/index.tsx"; -import * as r76 from "./routes/login.tsx"; -import * as r77 from "./routes/login/logout.ts"; -import * as r78 from "./routes/marketplace/index.tsx"; -import * as r79 from "./routes/media/index.tsx"; -import * as r80 from "./routes/metrics/index.tsx"; -import * as r81 from "./routes/pages/index.tsx"; -import * as r82 from "./routes/pages/builder.tsx"; -import * as r83 from "./routes/pages/edit.tsx"; -import * as r84 from "./routes/pages/history.tsx"; -import * as r85 from "./routes/plugins/index.tsx"; -import * as r86 from "./routes/search/index.tsx"; -import * as r87 from "./routes/submissions/index.tsx"; -import * as r88 from "./routes/submissions/[form]/index.tsx"; -import * as r89 from "./routes/submissions/[form]/[id]/index.tsx"; -import * as r90 from "./routes/submissions/[form]/[id]/delete.ts"; -import * as r91 from "./routes/submissions/[form]/[id]/files/[filename].ts"; -import * as r92 from "./routes/submissions/[form]/[id]/status.ts"; -import * as r93 from "./routes/themes/index.tsx"; -import * as r94 from "./routes/users/index.tsx"; +import * as r48 from "./routes/api/search/engines.ts"; +import * as r49 from "./routes/api/sections.ts"; +import * as r50 from "./routes/api/staging/[...path]/index.ts"; +import * as r51 from "./routes/api/staging/[...path]/publish.ts"; +import * as r52 from "./routes/api/theme-preview.ts"; +import * as r53 from "./routes/api/themes/install.ts"; +import * as r54 from "./routes/api/users/index.ts"; +import * as r55 from "./routes/api/users/[id]/index.ts"; +import * as r56 from "./routes/api/users/[id]/password.ts"; +import * as r57 from "./routes/api/webhooks/deliveries.ts"; +import * as r58 from "./routes/api/workflow/schedule/index.ts"; +import * as r59 from "./routes/api/workflow/schedule/[id].ts"; +import * as r60 from "./routes/api/workflow/scheduled/[...path].ts"; +import * as r61 from "./routes/api/workflow/stages.ts"; +import * as r62 from "./routes/api/workflow/status/[...path].ts"; +import * as r63 from "./routes/api/workflow/transition.ts"; +import * as r64 from "./routes/audit/index.tsx"; +import * as r65 from "./routes/collab/edit-ws.ts"; +import * as r66 from "./routes/collab/ws.ts"; +import * as r67 from "./routes/config/index.tsx"; +import * as r68 from "./routes/email-preview/index.tsx"; +import * as r69 from "./routes/flex/index.tsx"; +import * as r70 from "./routes/flex/[type]/index.tsx"; +import * as r71 from "./routes/flex/[type]/[id].tsx"; +import * as r72 from "./routes/i18n/index.tsx"; +import * as r73 from "./routes/i18n/memory.tsx"; +import * as r74 from "./routes/jobs/index.tsx"; +import * as r75 from "./routes/login.tsx"; +import * as r76 from "./routes/login/logout.ts"; +import * as r77 from "./routes/marketplace/index.tsx"; +import * as r78 from "./routes/media/index.tsx"; +import * as r79 from "./routes/metrics/index.tsx"; +import * as r80 from "./routes/pages/index.tsx"; +import * as r81 from "./routes/pages/builder.tsx"; +import * as r82 from "./routes/pages/edit.tsx"; +import * as r83 from "./routes/pages/history.tsx"; +import * as r84 from "./routes/plugins/index.tsx"; +import * as r85 from "./routes/search/index.tsx"; +import * as r86 from "./routes/submissions/index.tsx"; +import * as r87 from "./routes/submissions/[form]/index.tsx"; +import * as r88 from "./routes/submissions/[form]/[id]/index.tsx"; +import * as r89 from "./routes/submissions/[form]/[id]/delete.ts"; +import * as r90 from "./routes/submissions/[form]/[id]/files/[filename].ts"; +import * as r91 from "./routes/submissions/[form]/[id]/status.ts"; +import * as r92 from "./routes/themes/index.tsx"; +import * as r93 from "./routes/users/index.tsx"; /** Shape of an fs-convention route module (subset Fresh cares about). */ export interface AdminRouteModule { @@ -165,53 +164,52 @@ export const adminRoutes: AdminRouteEntry[] = [ { pattern: "/api/registry/plugins", mod: r45 }, { pattern: "/api/registry/themes", mod: r46 }, { pattern: "/api/render-markdown", mod: r47 }, - { pattern: "/api/search/analytics", mod: r48 }, - { pattern: "/api/search/engines", mod: r49 }, - { pattern: "/api/sections", mod: r50 }, - { pattern: "/api/staging/:path*", mod: r51 }, - { pattern: "/api/staging/:path*/publish", mod: r52 }, - { pattern: "/api/theme-preview", mod: r53 }, - { pattern: "/api/themes/install", mod: r54 }, - { pattern: "/api/users", mod: r55 }, - { pattern: "/api/users/:id", mod: r56 }, - { pattern: "/api/users/:id/password", mod: r57 }, - { pattern: "/api/webhooks/deliveries", mod: r58 }, - { pattern: "/api/workflow/schedule", mod: r59 }, - { pattern: "/api/workflow/schedule/:id", mod: r60 }, - { pattern: "/api/workflow/scheduled/:path*", mod: r61 }, - { pattern: "/api/workflow/stages", mod: r62 }, - { pattern: "/api/workflow/status/:path*", mod: r63 }, - { pattern: "/api/workflow/transition", mod: r64 }, - { pattern: "/audit", mod: r65 }, - { pattern: "/collab/edit-ws", mod: r66 }, - { pattern: "/collab/ws", mod: r67 }, - { pattern: "/config", mod: r68 }, - { pattern: "/email-preview", mod: r69 }, - { pattern: "/flex", mod: r70 }, - { pattern: "/flex/:type", mod: r71 }, - { pattern: "/flex/:type/:id", mod: r72 }, - { pattern: "/i18n", mod: r73 }, - { pattern: "/i18n/memory", mod: r74 }, - { pattern: "/jobs", mod: r75 }, - { pattern: "/login", mod: r76 }, - { pattern: "/login/logout", mod: r77 }, - { pattern: "/marketplace", mod: r78 }, - { pattern: "/media", mod: r79 }, - { pattern: "/metrics", mod: r80 }, - { pattern: "/pages", mod: r81 }, - { pattern: "/pages/builder", mod: r82 }, - { pattern: "/pages/edit", mod: r83 }, - { pattern: "/pages/history", mod: r84 }, - { pattern: "/plugins", mod: r85 }, - { pattern: "/search", mod: r86 }, - { pattern: "/submissions", mod: r87 }, - { pattern: "/submissions/:form", mod: r88 }, - { pattern: "/submissions/:form/:id", mod: r89 }, - { pattern: "/submissions/:form/:id/delete", mod: r90 }, - { pattern: "/submissions/:form/:id/files/:filename", mod: r91 }, - { pattern: "/submissions/:form/:id/status", mod: r92 }, - { pattern: "/themes", mod: r93 }, - { pattern: "/users", mod: r94 }, + { pattern: "/api/search/engines", mod: r48 }, + { pattern: "/api/sections", mod: r49 }, + { pattern: "/api/staging/:path*", mod: r50 }, + { pattern: "/api/staging/:path*/publish", mod: r51 }, + { pattern: "/api/theme-preview", mod: r52 }, + { pattern: "/api/themes/install", mod: r53 }, + { pattern: "/api/users", mod: r54 }, + { pattern: "/api/users/:id", mod: r55 }, + { pattern: "/api/users/:id/password", mod: r56 }, + { pattern: "/api/webhooks/deliveries", mod: r57 }, + { pattern: "/api/workflow/schedule", mod: r58 }, + { pattern: "/api/workflow/schedule/:id", mod: r59 }, + { pattern: "/api/workflow/scheduled/:path*", mod: r60 }, + { pattern: "/api/workflow/stages", mod: r61 }, + { pattern: "/api/workflow/status/:path*", mod: r62 }, + { pattern: "/api/workflow/transition", mod: r63 }, + { pattern: "/audit", mod: r64 }, + { pattern: "/collab/edit-ws", mod: r65 }, + { pattern: "/collab/ws", mod: r66 }, + { pattern: "/config", mod: r67 }, + { pattern: "/email-preview", mod: r68 }, + { pattern: "/flex", mod: r69 }, + { pattern: "/flex/:type", mod: r70 }, + { pattern: "/flex/:type/:id", mod: r71 }, + { pattern: "/i18n", mod: r72 }, + { pattern: "/i18n/memory", mod: r73 }, + { pattern: "/jobs", mod: r74 }, + { pattern: "/login", mod: r75 }, + { pattern: "/login/logout", mod: r76 }, + { pattern: "/marketplace", mod: r77 }, + { pattern: "/media", mod: r78 }, + { pattern: "/metrics", mod: r79 }, + { pattern: "/pages", mod: r80 }, + { pattern: "/pages/builder", mod: r81 }, + { pattern: "/pages/edit", mod: r82 }, + { pattern: "/pages/history", mod: r83 }, + { pattern: "/plugins", mod: r84 }, + { pattern: "/search", mod: r85 }, + { pattern: "/submissions", mod: r86 }, + { pattern: "/submissions/:form", mod: r87 }, + { pattern: "/submissions/:form/:id", mod: r88 }, + { pattern: "/submissions/:form/:id/delete", mod: r89 }, + { pattern: "/submissions/:form/:id/files/:filename", mod: r90 }, + { pattern: "/submissions/:form/:id/status", mod: r91 }, + { pattern: "/themes", mod: r92 }, + { pattern: "/users", mod: r93 }, ]; /** Island files under src/admin/islands/, for Builder.registerIsland(). */ diff --git a/src/admin/routes/api/search/analytics.ts b/src/admin/routes/api/search/analytics.ts deleted file mode 100644 index a3c7231..0000000 --- a/src/admin/routes/api/search/analytics.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** GET /admin/api/search/analytics */ - -import type { AdminState } from "../../../types.ts"; -import { requirePermission, json, serverError } from "../_utils.ts"; -import { createSearchAnalytics } from "@dune/core/search"; -import { join } from "@std/path"; -import type { FreshContext } from "fresh"; - -export const handler = { - async GET(ctx: FreshContext) { - const denied = await requirePermission(ctx, "pages.read"); - if (denied) return denied; - const { config } = ctx.state.adminContext; - try { - const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; - const analyticsPath = join(runtimeDir, "search-analytics.jsonl"); - const summary = await createSearchAnalytics(analyticsPath).summarize(); - return json(summary); - } catch (err) { - return serverError(err); - } - }, -}; From 3817a4c511457358067bef690184ef2935cda8f2 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 01:35:00 +0100 Subject: [PATCH 07/39] feat(admin)!: search analytics route reads core's recorder (slice 3b) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 9 +- src/admin/manifest.gen.ts | 186 ++++++++++++----------- src/admin/routes/api/search/analytics.ts | 19 +++ tests/admin/search_analytics_test.ts | 32 ++++ 4 files changed, 151 insertions(+), 95 deletions(-) create mode 100644 src/admin/routes/api/search/analytics.ts create mode 100644 tests/admin/search_analytics_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f4e984f..a52ba42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,9 +26,12 @@ follows [Semantic Versioning](https://semver.org). replaces importing `resolveBlueprint` / `validateFrontmatter`, and `services.paths.dataDir` replaces `resolveDataDir()`. Requires `@dune/core` 0.36 or newer. -- **`GET /admin/api/search/analytics` is removed.** It summarised - `search-analytics.jsonl`, which nothing in core or the first-party plugins - ever wrote, so it always returned an empty summary. +- **`GET /admin/api/search/analytics` reads core's recorder.** Core now records + `GET /api/search` queries itself when `system.search.analytics: true` (off by + default: the log holds what visitors typed) and the route returns + `services.search.analytics.summarize()` plus an `enabled` flag. Nothing wrote + the log after the admin extraction, so the route used to return an empty + summary. Requires `@dune/core` 0.36 or newer. - **Security policy, sessions, users and passwords come from core's `services`** (`MountApi.services`, `@dune/core/plugin-api`) instead of imports from `@dune/core`: HTML sanitising, SSRF-guarded fetch, body limits, client IP, the diff --git a/src/admin/manifest.gen.ts b/src/admin/manifest.gen.ts index 098eb79..5c81817 100644 --- a/src/admin/manifest.gen.ts +++ b/src/admin/manifest.gen.ts @@ -51,52 +51,53 @@ import * as r44 from "./routes/api/preview.ts"; import * as r45 from "./routes/api/registry/plugins.ts"; import * as r46 from "./routes/api/registry/themes.ts"; import * as r47 from "./routes/api/render-markdown.ts"; -import * as r48 from "./routes/api/search/engines.ts"; -import * as r49 from "./routes/api/sections.ts"; -import * as r50 from "./routes/api/staging/[...path]/index.ts"; -import * as r51 from "./routes/api/staging/[...path]/publish.ts"; -import * as r52 from "./routes/api/theme-preview.ts"; -import * as r53 from "./routes/api/themes/install.ts"; -import * as r54 from "./routes/api/users/index.ts"; -import * as r55 from "./routes/api/users/[id]/index.ts"; -import * as r56 from "./routes/api/users/[id]/password.ts"; -import * as r57 from "./routes/api/webhooks/deliveries.ts"; -import * as r58 from "./routes/api/workflow/schedule/index.ts"; -import * as r59 from "./routes/api/workflow/schedule/[id].ts"; -import * as r60 from "./routes/api/workflow/scheduled/[...path].ts"; -import * as r61 from "./routes/api/workflow/stages.ts"; -import * as r62 from "./routes/api/workflow/status/[...path].ts"; -import * as r63 from "./routes/api/workflow/transition.ts"; -import * as r64 from "./routes/audit/index.tsx"; -import * as r65 from "./routes/collab/edit-ws.ts"; -import * as r66 from "./routes/collab/ws.ts"; -import * as r67 from "./routes/config/index.tsx"; -import * as r68 from "./routes/email-preview/index.tsx"; -import * as r69 from "./routes/flex/index.tsx"; -import * as r70 from "./routes/flex/[type]/index.tsx"; -import * as r71 from "./routes/flex/[type]/[id].tsx"; -import * as r72 from "./routes/i18n/index.tsx"; -import * as r73 from "./routes/i18n/memory.tsx"; -import * as r74 from "./routes/jobs/index.tsx"; -import * as r75 from "./routes/login.tsx"; -import * as r76 from "./routes/login/logout.ts"; -import * as r77 from "./routes/marketplace/index.tsx"; -import * as r78 from "./routes/media/index.tsx"; -import * as r79 from "./routes/metrics/index.tsx"; -import * as r80 from "./routes/pages/index.tsx"; -import * as r81 from "./routes/pages/builder.tsx"; -import * as r82 from "./routes/pages/edit.tsx"; -import * as r83 from "./routes/pages/history.tsx"; -import * as r84 from "./routes/plugins/index.tsx"; -import * as r85 from "./routes/search/index.tsx"; -import * as r86 from "./routes/submissions/index.tsx"; -import * as r87 from "./routes/submissions/[form]/index.tsx"; -import * as r88 from "./routes/submissions/[form]/[id]/index.tsx"; -import * as r89 from "./routes/submissions/[form]/[id]/delete.ts"; -import * as r90 from "./routes/submissions/[form]/[id]/files/[filename].ts"; -import * as r91 from "./routes/submissions/[form]/[id]/status.ts"; -import * as r92 from "./routes/themes/index.tsx"; -import * as r93 from "./routes/users/index.tsx"; +import * as r48 from "./routes/api/search/analytics.ts"; +import * as r49 from "./routes/api/search/engines.ts"; +import * as r50 from "./routes/api/sections.ts"; +import * as r51 from "./routes/api/staging/[...path]/index.ts"; +import * as r52 from "./routes/api/staging/[...path]/publish.ts"; +import * as r53 from "./routes/api/theme-preview.ts"; +import * as r54 from "./routes/api/themes/install.ts"; +import * as r55 from "./routes/api/users/index.ts"; +import * as r56 from "./routes/api/users/[id]/index.ts"; +import * as r57 from "./routes/api/users/[id]/password.ts"; +import * as r58 from "./routes/api/webhooks/deliveries.ts"; +import * as r59 from "./routes/api/workflow/schedule/index.ts"; +import * as r60 from "./routes/api/workflow/schedule/[id].ts"; +import * as r61 from "./routes/api/workflow/scheduled/[...path].ts"; +import * as r62 from "./routes/api/workflow/stages.ts"; +import * as r63 from "./routes/api/workflow/status/[...path].ts"; +import * as r64 from "./routes/api/workflow/transition.ts"; +import * as r65 from "./routes/audit/index.tsx"; +import * as r66 from "./routes/collab/edit-ws.ts"; +import * as r67 from "./routes/collab/ws.ts"; +import * as r68 from "./routes/config/index.tsx"; +import * as r69 from "./routes/email-preview/index.tsx"; +import * as r70 from "./routes/flex/index.tsx"; +import * as r71 from "./routes/flex/[type]/index.tsx"; +import * as r72 from "./routes/flex/[type]/[id].tsx"; +import * as r73 from "./routes/i18n/index.tsx"; +import * as r74 from "./routes/i18n/memory.tsx"; +import * as r75 from "./routes/jobs/index.tsx"; +import * as r76 from "./routes/login.tsx"; +import * as r77 from "./routes/login/logout.ts"; +import * as r78 from "./routes/marketplace/index.tsx"; +import * as r79 from "./routes/media/index.tsx"; +import * as r80 from "./routes/metrics/index.tsx"; +import * as r81 from "./routes/pages/index.tsx"; +import * as r82 from "./routes/pages/builder.tsx"; +import * as r83 from "./routes/pages/edit.tsx"; +import * as r84 from "./routes/pages/history.tsx"; +import * as r85 from "./routes/plugins/index.tsx"; +import * as r86 from "./routes/search/index.tsx"; +import * as r87 from "./routes/submissions/index.tsx"; +import * as r88 from "./routes/submissions/[form]/index.tsx"; +import * as r89 from "./routes/submissions/[form]/[id]/index.tsx"; +import * as r90 from "./routes/submissions/[form]/[id]/delete.ts"; +import * as r91 from "./routes/submissions/[form]/[id]/files/[filename].ts"; +import * as r92 from "./routes/submissions/[form]/[id]/status.ts"; +import * as r93 from "./routes/themes/index.tsx"; +import * as r94 from "./routes/users/index.tsx"; /** Shape of an fs-convention route module (subset Fresh cares about). */ export interface AdminRouteModule { @@ -164,52 +165,53 @@ export const adminRoutes: AdminRouteEntry[] = [ { pattern: "/api/registry/plugins", mod: r45 }, { pattern: "/api/registry/themes", mod: r46 }, { pattern: "/api/render-markdown", mod: r47 }, - { pattern: "/api/search/engines", mod: r48 }, - { pattern: "/api/sections", mod: r49 }, - { pattern: "/api/staging/:path*", mod: r50 }, - { pattern: "/api/staging/:path*/publish", mod: r51 }, - { pattern: "/api/theme-preview", mod: r52 }, - { pattern: "/api/themes/install", mod: r53 }, - { pattern: "/api/users", mod: r54 }, - { pattern: "/api/users/:id", mod: r55 }, - { pattern: "/api/users/:id/password", mod: r56 }, - { pattern: "/api/webhooks/deliveries", mod: r57 }, - { pattern: "/api/workflow/schedule", mod: r58 }, - { pattern: "/api/workflow/schedule/:id", mod: r59 }, - { pattern: "/api/workflow/scheduled/:path*", mod: r60 }, - { pattern: "/api/workflow/stages", mod: r61 }, - { pattern: "/api/workflow/status/:path*", mod: r62 }, - { pattern: "/api/workflow/transition", mod: r63 }, - { pattern: "/audit", mod: r64 }, - { pattern: "/collab/edit-ws", mod: r65 }, - { pattern: "/collab/ws", mod: r66 }, - { pattern: "/config", mod: r67 }, - { pattern: "/email-preview", mod: r68 }, - { pattern: "/flex", mod: r69 }, - { pattern: "/flex/:type", mod: r70 }, - { pattern: "/flex/:type/:id", mod: r71 }, - { pattern: "/i18n", mod: r72 }, - { pattern: "/i18n/memory", mod: r73 }, - { pattern: "/jobs", mod: r74 }, - { pattern: "/login", mod: r75 }, - { pattern: "/login/logout", mod: r76 }, - { pattern: "/marketplace", mod: r77 }, - { pattern: "/media", mod: r78 }, - { pattern: "/metrics", mod: r79 }, - { pattern: "/pages", mod: r80 }, - { pattern: "/pages/builder", mod: r81 }, - { pattern: "/pages/edit", mod: r82 }, - { pattern: "/pages/history", mod: r83 }, - { pattern: "/plugins", mod: r84 }, - { pattern: "/search", mod: r85 }, - { pattern: "/submissions", mod: r86 }, - { pattern: "/submissions/:form", mod: r87 }, - { pattern: "/submissions/:form/:id", mod: r88 }, - { pattern: "/submissions/:form/:id/delete", mod: r89 }, - { pattern: "/submissions/:form/:id/files/:filename", mod: r90 }, - { pattern: "/submissions/:form/:id/status", mod: r91 }, - { pattern: "/themes", mod: r92 }, - { pattern: "/users", mod: r93 }, + { pattern: "/api/search/analytics", mod: r48 }, + { pattern: "/api/search/engines", mod: r49 }, + { pattern: "/api/sections", mod: r50 }, + { pattern: "/api/staging/:path*", mod: r51 }, + { pattern: "/api/staging/:path*/publish", mod: r52 }, + { pattern: "/api/theme-preview", mod: r53 }, + { pattern: "/api/themes/install", mod: r54 }, + { pattern: "/api/users", mod: r55 }, + { pattern: "/api/users/:id", mod: r56 }, + { pattern: "/api/users/:id/password", mod: r57 }, + { pattern: "/api/webhooks/deliveries", mod: r58 }, + { pattern: "/api/workflow/schedule", mod: r59 }, + { pattern: "/api/workflow/schedule/:id", mod: r60 }, + { pattern: "/api/workflow/scheduled/:path*", mod: r61 }, + { pattern: "/api/workflow/stages", mod: r62 }, + { pattern: "/api/workflow/status/:path*", mod: r63 }, + { pattern: "/api/workflow/transition", mod: r64 }, + { pattern: "/audit", mod: r65 }, + { pattern: "/collab/edit-ws", mod: r66 }, + { pattern: "/collab/ws", mod: r67 }, + { pattern: "/config", mod: r68 }, + { pattern: "/email-preview", mod: r69 }, + { pattern: "/flex", mod: r70 }, + { pattern: "/flex/:type", mod: r71 }, + { pattern: "/flex/:type/:id", mod: r72 }, + { pattern: "/i18n", mod: r73 }, + { pattern: "/i18n/memory", mod: r74 }, + { pattern: "/jobs", mod: r75 }, + { pattern: "/login", mod: r76 }, + { pattern: "/login/logout", mod: r77 }, + { pattern: "/marketplace", mod: r78 }, + { pattern: "/media", mod: r79 }, + { pattern: "/metrics", mod: r80 }, + { pattern: "/pages", mod: r81 }, + { pattern: "/pages/builder", mod: r82 }, + { pattern: "/pages/edit", mod: r83 }, + { pattern: "/pages/history", mod: r84 }, + { pattern: "/plugins", mod: r85 }, + { pattern: "/search", mod: r86 }, + { pattern: "/submissions", mod: r87 }, + { pattern: "/submissions/:form", mod: r88 }, + { pattern: "/submissions/:form/:id", mod: r89 }, + { pattern: "/submissions/:form/:id/delete", mod: r90 }, + { pattern: "/submissions/:form/:id/files/:filename", mod: r91 }, + { pattern: "/submissions/:form/:id/status", mod: r92 }, + { pattern: "/themes", mod: r93 }, + { pattern: "/users", mod: r94 }, ]; /** Island files under src/admin/islands/, for Builder.registerIsland(). */ diff --git a/src/admin/routes/api/search/analytics.ts b/src/admin/routes/api/search/analytics.ts new file mode 100644 index 0000000..b749c94 --- /dev/null +++ b/src/admin/routes/api/search/analytics.ts @@ -0,0 +1,19 @@ +/** GET /admin/api/search/analytics */ + +import type { AdminState } from "../../../types.ts"; +import { requirePermission, json, serverError } from "../_utils.ts"; +import type { FreshContext } from "fresh"; + +export const handler = { + async GET(ctx: FreshContext) { + const denied = await requirePermission(ctx, "pages.read"); + if (denied) return denied; + const { analytics } = ctx.state.adminContext.services.search; + try { + // Core records the queries (`system.search.analytics: true`); this only reads them. + return json({ enabled: analytics.enabled, ...await analytics.summarize() }); + } catch (err) { + return serverError(err); + } + }, +}; diff --git a/tests/admin/search_analytics_test.ts b/tests/admin/search_analytics_test.ts new file mode 100644 index 0000000..5f29aca --- /dev/null +++ b/tests/admin/search_analytics_test.ts @@ -0,0 +1,32 @@ +/** GET /admin/api/search/analytics reads the summary core's recorder keeps. */ + +import { assertEquals } from "@std/assert"; +import { handler } from "../../src/admin/routes/api/search/analytics.ts"; +import { makeServices } from "../helpers/services.ts"; + +// deno-lint-ignore no-explicit-any +function ctxWith(searchAnalytics: any): any { + return { + req: new Request("https://cms.example.com/admin/api/search/analytics"), + url: new URL("https://cms.example.com/admin/api/search/analytics"), + state: { + auth: { authenticated: true, user: { id: "1", username: "admin" } }, + adminContext: { + services: makeServices(null, { searchAnalytics }), + authz: { check: () => Promise.resolve(true) }, + }, + }, + }; +} + +Deno.test("search analytics route: disabled reports enabled:false and an empty summary", async () => { + const res = await handler.GET(ctxWith(null)); + assertEquals(res.status, 200); + assertEquals(await res.json(), { enabled: false, totalSearches: 0, topQueries: [], zeroResultQueries: [] }); +}); + +Deno.test("search analytics route: enabled returns core's summary", async () => { + const summary = { totalSearches: 3, topQueries: [{ query: "deno", count: 3, avgResults: 2 }], zeroResultQueries: [] }; + const res = await handler.GET(ctxWith({ record: () => Promise.resolve(), summarize: () => Promise.resolve(summary) })); + assertEquals(await res.json(), { enabled: true, ...summary }); +}); From 8e24ba31cdf328dd684ccbe26435c95400238336 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 02:00:45 +0100 Subject: [PATCH 08/39] feat(admin): analytics summary carries since Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 3 ++- tests/admin/search_analytics_test.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a52ba42..32cf583 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,8 @@ follows [Semantic Versioning](https://semver.org). - **`GET /admin/api/search/analytics` reads core's recorder.** Core now records `GET /api/search` queries itself when `system.search.analytics: true` (off by default: the log holds what visitors typed) and the route returns - `services.search.analytics.summarize()` plus an `enabled` flag. Nothing wrote + `services.search.analytics.summarize()` plus an `enabled` flag (the summary also + carries `since`, the time of the first counted search). Nothing wrote the log after the admin extraction, so the route used to return an empty summary. Requires `@dune/core` 0.36 or newer. - **Security policy, sessions, users and passwords come from core's `services`** diff --git a/tests/admin/search_analytics_test.ts b/tests/admin/search_analytics_test.ts index 5f29aca..5accb82 100644 --- a/tests/admin/search_analytics_test.ts +++ b/tests/admin/search_analytics_test.ts @@ -22,11 +22,11 @@ function ctxWith(searchAnalytics: any): any { Deno.test("search analytics route: disabled reports enabled:false and an empty summary", async () => { const res = await handler.GET(ctxWith(null)); assertEquals(res.status, 200); - assertEquals(await res.json(), { enabled: false, totalSearches: 0, topQueries: [], zeroResultQueries: [] }); + assertEquals(await res.json(), { enabled: false, totalSearches: 0, since: null, topQueries: [], zeroResultQueries: [] }); }); Deno.test("search analytics route: enabled returns core's summary", async () => { - const summary = { totalSearches: 3, topQueries: [{ query: "deno", count: 3, avgResults: 2 }], zeroResultQueries: [] }; + const summary = { totalSearches: 3, since: 1, topQueries: [{ query: "deno", count: 3, avgResults: 2 }], zeroResultQueries: [] }; const res = await handler.GET(ctxWith({ record: () => Promise.resolve(), summarize: () => Promise.resolve(summary) })); assertEquals(await res.json(), { enabled: true, ...summary }); }); From 6446ba96d9f11652c3295033ef7077b8382d4d9c Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 11:55:24 +0100 Subject: [PATCH 09/39] feat(admin)!: provide auth.session and scheduler.executor, consume capabilities (#97, step 4) Admin no longer fills core's adminContext bag or implements mountEarly: it declares auth.session and scheduler.executor in setup(), reads inline-edit, editor.content and admin.pages through MountApi.capabilities, and reads the job scheduler from BootstrapResult.jobScheduler. mountDuneAdminEarly() is removed. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 17 +++++ mod.ts | 63 +++++++++---------- src/admin/block-editor-plugin.tsx | 2 +- src/admin/context.ts | 4 +- src/admin/guards.ts | 8 +-- src/admin/mount.ts | 80 +++++++----------------- tests/admin/capabilities_e2e_test.ts | 92 ++++++++++++++++++++++++++++ tests/admin/mount_early_test.ts | 82 ------------------------- 8 files changed, 169 insertions(+), 179 deletions(-) create mode 100644 tests/admin/capabilities_e2e_test.ts delete mode 100644 tests/admin/mount_early_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 32cf583..4f7332f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ follows [Semantic Versioning](https://semver.org). ### Changed — BREAKING +- **The admin provides and consumes core's capabilities instead of the untyped + `adminContext` bag (duneorg/dune#97, `@dune/core` 0.36).** It provides + `auth.session` (who is the admin on a request: the admin bar and the inline-edit + WebSocket's admin fallback) and `scheduler.executor` (runs due scheduled actions; + `dune serve` starts core's scheduler loop when it exists). It reads the + `inline-edit` and `editor.content` slots and the `admin.pages` contributions from + `MountApi.capabilities` instead of `adminServices` and each plugin's `adminPages`, + and no longer writes `bootstrap.adminContext`. The jobs view reads + `BootstrapResult.jobScheduler`, which `dune serve` sets after mount. +- **`mountDuneAdminEarly()` is gone** (core removed `DunePlugin.mountEarly`). + `mountDuneAdmin(app, ctx, adminContext?)` falls back to the single-site context + stored by the plugin's `mount()` instead of `ctx.adminContext`. A plugin route that + needs the admin identity calls `capabilities.get("auth.session")`; plugin routes + registered in a plugin's own `mount()` no longer sit behind the admin's + `ctx.state.adminContext` middleware, so `withGuards()` only works for pages the + admin mounts itself (`admin.pages`, GET). A contribution point for guarded + non-GET admin routes is a known gap. - **The `admin:` block reads snake_case keys** (duneorg/dune#96, `@dune/core` 0.36): `session_lifetime`, `data_dir`, `runtime_dir`, `max_revisions`, `max_upload_mb`, `authz_store`, `audit.log_file`, and the LDAP/SAML provider fields (`base_dn`, diff --git a/mod.ts b/mod.ts index 14a73db..0e92d67 100644 --- a/mod.ts +++ b/mod.ts @@ -36,7 +36,7 @@ import type { AuthzDbAdapter } from "@dune/core/auth/authz-adapter-db"; import { initAdminContext } from "./src/admin/context.ts"; import type { AdminContext } from "./src/admin/context.ts"; import { createBlockEditorPlugin } from "./src/admin/block-editor-plugin.tsx"; -import { getDuneAdminIslands, mountDuneAdmin, mountDuneAdminEarly } from "./src/admin/mount.ts"; +import { getDuneAdminIslands, mountDuneAdmin } from "./src/admin/mount.ts"; import { sectionRegistry } from "@dune/core/sections"; import type { SectionRegistry } from "@dune/core/sections"; import * as corePlugins from "@dune/core/plugins"; @@ -94,11 +94,9 @@ export function createAdminPlugin( opts: AdminPluginOptions, ): DunePlugin { let setupState: AdminSetupState | null = null; - // Set partway through mount(), once AdminContext is actually built. - // mountEarly() registers a middleware that reads this lazily (per - // request) rather than needing the built value up front — see - // mountDuneAdminEarly()'s doc comment for why that's necessary (it runs - // before mount() has built anything). + // Set at the end of mount(), once AdminContext is built. The capabilities + // declared in setup() delegate to it lazily (per request), because core + // resolves them before mount() has built the auth middleware and the rest. let liveAdminContext: AdminContext | null = null; return { @@ -108,12 +106,6 @@ export function createAdminPlugin( hooks: {}, islandSpecifiers: getDuneAdminIslands(), - mountEarly({ app, bootstrap }: MountApi) { - if (!setupState) return; // admin disabled - // deno-lint-ignore no-explicit-any - mountDuneAdminEarly(app as any, bootstrap, () => liveAdminContext); - }, - async setup(api) { const adminCfg = config.admin ?? { path: "/admin", @@ -130,9 +122,24 @@ export function createAdminPlugin( const { audit: auditLogger, mt } = api.services.engines; setupState = { config, storage, opts, auditLogger, mt }; + + // What this plugin provides to core. Core reads them at request time + // (the admin bar, the inline-edit WebSocket, `dune serve`'s scheduler + // loop); until mount() has built the context they report "nobody". + api.capabilities.provide("auth.session", () => ({ + authenticate: (req) => + liveAdminContext + ? liveAdminContext.auth.authenticate(req) + : Promise.resolve({ authenticated: false }), + })); + api.capabilities.provide("scheduler.executor", () => ({ + execute: async (action) => { + await liveAdminContext?.executeScheduledAction?.(action); + }, + })); }, - async mount({ app, bootstrap, adminServices, services }: MountApi) { + async mount({ app, bootstrap, services, capabilities }: MountApi) { const { logger } = services; if (!setupState) return; // admin disabled @@ -271,15 +278,13 @@ export function createAdminPlugin( }); // ── Default content editor ──────────────────────────────────────────────── - if (!adminServices.contentEditor) { - adminServices.contentEditor = createBlockEditorPlugin(); - } + const inlineEdit = capabilities.get("inline-edit"); + const contentEditor = capabilities.get("editor.content") ?? createBlockEditorPlugin(); // ── Build AdminContext ──────────────────────────────────────────────────── const metricsEnabled = config.system.metrics?.enabled !== false; - const pluginPages = bootstrap.hooks.plugins() - .flatMap((p) => p.adminPages ?? []); + const pluginPages = capabilities.list("admin.pages"); const adminContext: AdminContext = { services, @@ -302,14 +307,18 @@ export function createAdminPlugin( staging: stagingEngine, comments: commentManager, collab: collabManager, - inlineEdit: adminServices.inlineEdit, - contentEditor: adminServices.contentEditor, + inlineEdit, + contentEditor, imageCache: bootstrap.imageCache, auditLogger: setupState.auditLogger ?? undefined, metrics: metricsEnabled ? bootstrap.metrics : undefined, mt: setupState.mt, rateLimitStore, - pluginPages: pluginPages.length > 0 ? pluginPages : undefined, + pluginPages: pluginPages.length > 0 ? [...pluginPages] : undefined, + // Created per serving process by `dune serve`, after mount(): read lazily. + get jobScheduler() { + return bootstrap.jobScheduler; + }, authz: bootstrap.authz, // Cores older than 0.31 don't expose `sections` on BootstrapResult; // fall back to this package's own module singleton, which resolves to @@ -321,18 +330,10 @@ export function createAdminPlugin( // Keep the singleton for single-site serve paths (serve.ts, dev.ts). initAdminContext(adminContext); - // Unblocks mountEarly()'s ctx.state.adminContext middleware — see its - // doc comment. Must happen before any other plugin's route could - // conceivably run, which it does: mountPlugins() finishes every - // plugin's mount() before the app starts serving requests. + // Lets the capabilities declared in setup() answer. mountPlugins() + // finishes every plugin's mount() before the app serves requests. liveAdminContext = adminContext; - // Expose adminContext on the bootstrap result so serve.ts can wire the - // job scheduler into it after mount() returns. BootstrapResult types - // this loosely (Record) since core has no concrete - // AdminContext type — plugin-admin owns the actual shape. - bootstrap.adminContext = adminContext as unknown as Record; - // No default admin is ever created. The first admin is an explicit act: // `dune users:create --role admin --password-prompt`, or // DUNE_ADMIN_EMAIL / DUNE_ADMIN_PASSWORD at boot (handled by core before diff --git a/src/admin/block-editor-plugin.tsx b/src/admin/block-editor-plugin.tsx index fc5aa7e..66bbfb0 100644 --- a/src/admin/block-editor-plugin.tsx +++ b/src/admin/block-editor-plugin.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource preact */ /** * Built-in block editor — the default ContentEditorPlugin loaded by bootstrap - * when no plugin overrides adminServices.contentEditor. + * when no plugin provides the `editor.content` capability. * * Wraps the PageEditor island in the same way the old hardwired route did, but * now as a proper ContentEditorPlugin so it participates in the same slot that diff --git a/src/admin/context.ts b/src/admin/context.ts index 997ba3d..e3a142d 100644 --- a/src/admin/context.ts +++ b/src/admin/context.ts @@ -67,9 +67,9 @@ export interface AdminContext { staging?: StagingEngine; comments?: CommentManager; collab?: CollabManager; - /** Inline editing manager, provided by a plugin via adminServices (v0.16+). */ + /** Inline editing manager, provided by a plugin as the `inline-edit` capability. */ inlineEdit?: InlineEditManager; - /** Custom page editor, provided by a plugin via adminServices (v0.24+). Replaces the built-in block editor. */ + /** Custom page editor, provided by a plugin as the `editor.content` capability. Replaces the built-in block editor. */ contentEditor?: ContentEditorPlugin; imageCache?: ImageCache; auditLogger?: AuditLogger; diff --git a/src/admin/guards.ts b/src/admin/guards.ts index e604087..314d48c 100644 --- a/src/admin/guards.ts +++ b/src/admin/guards.ts @@ -2,10 +2,10 @@ * Public guard primitives for plugin-registered admin mutation routes. * * `DunePlugin.mount({ app })` hands a plugin author the raw Fresh `app` - * with no built-in enforcement — `adminPages` gets CSRF protection and - * permission checking for free, but `mount()`-registered routes (the only - * way to register a `POST`/`PUT`/`DELETE` admin route, or anything - * `adminPages` can't express) previously had no supported way to reuse + * with no built-in enforcement — `admin.pages` contributions get CSRF + * protection and permission checking for free, but `mount()`-registered routes + * (the only way to register a `POST`/`PUT`/`DELETE` admin route, or anything + * `admin.pages` can't express) previously had no supported way to reuse * Dune's own checks at all. * * Re-implementing these by hand is exactly what caused three real diff --git a/src/admin/mount.ts b/src/admin/mount.ts index f57603d..8c16a07 100644 --- a/src/admin/mount.ts +++ b/src/admin/mount.ts @@ -39,6 +39,7 @@ import type { BootstrapResult } from "@dune/core/bootstrap"; import { registerPluginPublicRoutes } from "@dune/core/fresh-app"; import { registerFormRoutes } from "@dune/core/forms"; import { checkPermission } from "./routes/api/_utils.ts"; +import { getAdminContext } from "./context.ts"; import type { AdminState } from "./types.ts"; import { adminIslands, @@ -51,11 +52,7 @@ import { * `ctx` objects (`BootstrapResult`) for which the `ctx.state.adminContext` * middleware has already been registered on `app` — makes a second * registration for the same bootstrap a no-op instead of stacking a - * duplicate middleware. Both `mountDuneAdminEarly()` (called from the admin - * plugin's `mountEarly()` in the normal `createDuneApp()` path) and - * `mountDuneAdmin()` itself (headless-mode direct callers, who never call - * `mountEarly()` at all) can reach this — whichever runs first wins, and the - * other silently skips. + * duplicate middleware. */ const contextMiddlewareRegisteredFor = new WeakSet(); @@ -63,13 +60,11 @@ const contextMiddlewareRegisteredFor = new WeakSet(); * Register the `ctx.state.adminContext = …` middleware every admin route * handler (and `@dune/plugin-admin`'s `withGuards()`, and any other * plugin's own guarded route) reads. Takes a getter rather than the context - * value itself so it can be registered before the `AdminContext` object - * exists yet — see `mountDuneAdminEarly()`. + * value itself. * * Exported so a headless-mode caller with an unusual setup can register this - * independently of `mountDuneAdmin()`'s other route registration, but the - * normal paths (`mountDuneAdmin()`, `mountDuneAdminEarly()`) already call - * this — most callers should use one of those instead. + * independently of `mountDuneAdmin()`'s other route registration, but + * `mountDuneAdmin()` already calls it — most callers should use that instead. */ export function registerAdminContextMiddleware( // deno-lint-ignore no-explicit-any @@ -91,11 +86,7 @@ const authMiddlewareRegisteredFor = new WeakSet(); /** * Register the admin auth middleware (`routes/_middleware.ts`'s `handler` — * authenticates the session cookie into `ctx.state.auth`, applies admin CSP - * headers). No-op if already registered on this `app` — see - * {@link mountDuneAdminEarly}, whose whole reason for existing is to - * register this (and {@link registerAdminContextMiddleware}) before other - * plugins' routes are compiled; `registerAdminRoutes()` calls this too, for - * headless-mode callers who never call `mountDuneAdminEarly()`. + * headers). No-op if already registered on this `app`. */ function registerAdminAuthMiddleware( // deno-lint-ignore no-explicit-any @@ -107,44 +98,13 @@ function registerAdminAuthMiddleware( app.use(adminMiddleware.handler as Middleware); } -/** - * Register the middleware every other plugin's own routes need to already - * be in place before it runs: - * - * - `ctx.state.adminContext` (see {@link registerAdminContextMiddleware}) - * - `ctx.state.auth` (the admin auth middleware — sets it for any request - * under the admin path prefix; a plugin's own guarded route must be - * registered under that prefix too, e.g. `/admin/my-plugin/…`, to be - * reached by it — see `@dune/plugin-admin/admin/guards`' own doc comment) - * - * This is the admin plugin's `DunePlugin.mountEarly()` implementation (see - * that hook's doc comment in `@dune/core`'s `hooks/types.ts` for why this - * needs to be a separate pass from `mountDuneAdmin()`/`mount()`). Called - * before the `AdminContext` itself has been built — `getAdminContext` is - * read lazily, per-request, so it naturally resolves to the real value once - * `mount()` (which runs after every plugin's `mountEarly()`) finishes - * building it, well before the app starts serving requests. The auth - * middleware itself needs no such laziness — it's a static generated route - * module, not something built from `AdminContext`. - * - * Headless-mode callers using `mountDuneAdmin()` directly (see this - * module's own docstring) don't need this — they already have a built - * `AdminContext` before they call anything, and their own routes are - * registered by them, after `mountDuneAdmin()`, per that docstring's - * example — so `mountDuneAdmin()`'s own (immediate, non-lazy) registration - * already covers them. - * - * @since 3.1.0 - */ -export function mountDuneAdminEarly( - // deno-lint-ignore no-explicit-any - app: App, - ctx: BootstrapResult, - getAdminContext: () => import("./context.ts").AdminContext | null, -): void { - if (ctx.config.admin?.enabled === false) return; - registerAdminContextMiddleware(app, ctx, getAdminContext); - registerAdminAuthMiddleware(app); +/** The single-site context `mount()` stored, or null before the plugin has mounted. */ +function currentAdminContextOrNull(): import("./context.ts").AdminContext | null { + try { + return getAdminContext(); + } catch { + return null; + } } /** @@ -166,20 +126,22 @@ export async function mountDuneAdmin( // deno-lint-ignore no-explicit-any app: App, ctx: BootstrapResult, - /** AdminContext built by @dune/plugin-admin — when omitted, falls back to ctx.adminContext. */ + /** + * The AdminContext built by @dune/plugin-admin's `mount()`. When omitted + * (headless-mode callers), falls back to the single-site context `mount()` + * stored via `initAdminContext()`, or none if the plugin has not mounted. + */ adminCtxOverride?: import("./context.ts").AdminContext, ): Promise { const { config } = ctx; - const adminContext = adminCtxOverride ?? - (ctx.adminContext as import("./context.ts").AdminContext | null); + const adminContext = adminCtxOverride ?? currentAdminContextOrNull(); const adminPrefix = config.admin?.path ?? "/admin"; // ── Admin panel ───────────────────────────────────────────────────────────── if (config.admin?.enabled !== false) { // Per-site admin context middleware — captures the correct context in // closure so multisite setups don't suffer from the module-level - // singleton. No-op if mountDuneAdminEarly() already registered it for - // this ctx (the normal createDuneApp() + mountPlugins() path). + // singleton. A second registration for the same ctx is a no-op. if (adminContext) { registerAdminContextMiddleware(app, ctx, () => adminContext); } @@ -284,7 +246,7 @@ export function getDuneAdminIslands(): string[] { * self-guard and pass through untouched on non-admin paths (they must, * because Fresh applies fs middleware/layouts globally too — this preserves * the exact fsRoutes() behavior). The auth middleware registration itself - * is a no-op here if `mountDuneAdminEarly()` already registered it (see + * is a no-op here if it was already registered (see * `registerAdminAuthMiddleware`) — the normal `createDuneApp()` + * `mountPlugins()` path. */ diff --git a/tests/admin/capabilities_e2e_test.ts b/tests/admin/capabilities_e2e_test.ts new file mode 100644 index 0000000..ecd14ee --- /dev/null +++ b/tests/admin/capabilities_e2e_test.ts @@ -0,0 +1,92 @@ +/** + * End to end against a real core: the admin plugin provides `auth.session` and + * `scheduler.executor`, consumes `admin.pages` contributions, and no longer + * writes core's removed `adminContext` bag. + */ + +import { assertEquals, assertExists } from "@std/assert"; +import { join } from "@std/path"; +import { bootstrap } from "@dune/core/bootstrap"; +import { createDuneApp } from "@dune/core/fresh-app"; +import type { DunePlugin } from "@dune/core/hooks"; + +async function withSite( + plugins: DunePlugin[], + fn: (ctx: Awaited>, handler: (req: Request) => Promise) => Promise, +): Promise { + const root = await Deno.makeTempDir({ prefix: "dune_admin_caps_" }); + try { + await Deno.mkdir(join(root, "content", "01.home"), { recursive: true }); + await Deno.writeTextFile(join(root, "content", "01.home", "default.md"), "---\ntitle: Home\n---\n\nHello\n"); + const ctx = await bootstrap(root, { plugins }); + const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); + await fn(ctx, app.handler()); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +} + +Deno.test( + "admin provides auth.session and scheduler.executor", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + await withSite([], async (ctx) => { + const auth = ctx.capabilities.get("auth.session"); + assertExists(auth); + assertExists(ctx.capabilities.get("scheduler.executor")); + const anonymous = await auth.authenticate(new Request("http://localhost/")); + assertEquals(anonymous.authenticated, false); + const providers = Object.fromEntries(ctx.capabilities.providers().map((p) => [p.name, p.provider])); + assertEquals(providers["auth.session"], "dune-admin"); + assertEquals(providers["scheduler.executor"], "dune-admin"); + }); + }, +); + +Deno.test( + "admin mounts admin.pages contributed by another plugin, behind its login", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + const contributor: DunePlugin = { + name: "test-page-contributor", + version: "1.0.0", + hooks: {}, + setup(api) { + api.capabilities.contribute("admin.pages", { + path: "/my-plugin", + label: "My plugin", + handler: () => new Response("secret page"), + }); + }, + }; + await withSite([contributor], async (_ctx, handler) => { + const res = await handler(new Request("http://localhost/admin/my-plugin")); + assertEquals(res.status, 302); + assertEquals(res.headers.get("location")?.startsWith("/admin/login"), true); + }); + }, +); + +Deno.test( + "admin uses the editor.content slot another plugin provides", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + const editor: DunePlugin = { + name: "test-editor", + version: "1.0.0", + hooks: {}, + setup(api) { + api.capabilities.provide("editor.content", () => ({ + pageEditorHandler: () => new Response("custom editor"), + })); + }, + }; + await withSite([editor], async (ctx) => { + assertExists(ctx.capabilities.get("editor.content")); + assertEquals( + ctx.capabilities.providers().find((p) => p.name === "editor.content")?.provider, + "test-editor", + ); + }); + }, +); diff --git a/tests/admin/mount_early_test.ts b/tests/admin/mount_early_test.ts deleted file mode 100644 index 4849d13..0000000 --- a/tests/admin/mount_early_test.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Regression test for the third-party-plugin-guarded-route bug: a plugin's - * own `mount()`-registered route (registered under the admin prefix, e.g. - * `/admin/my-plugin/…` — see `admin/guards.ts`'s own doc comment for why it - * has to be under the prefix) previously read both `ctx.state.adminContext` - * and `ctx.state.auth` as `undefined`, no matter how the request was - * authenticated, whenever that route was registered before `mountDuneAdmin()` - * ran — which is the normal case: `@dune/core`'s `mountPlugins()` calls - * `mount()` in plugin registration order, and the built-in admin plugin - * always registers last. - * - * `mountDuneAdminEarly()` (this plugin's `mountEarly()` implementation, see - * `@dune/core`'s `hooks/types.ts` for why that hook exists) registers both - * pieces of middleware — `ctx.state.adminContext` and the admin auth - * middleware that sets `ctx.state.auth` — before any plugin's `mount()` - * runs. This test registers a fake plugin's route first, then calls - * `mountDuneAdminEarly()` and `mountDuneAdmin()` in the same relative order - * `mountPlugins()` uses (early hooks before any mount(), admin's own - * mount()-equivalent last), and confirms both are visible. - */ - -import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; -import { App } from "fresh"; -import type { BootstrapResult } from "@dune/core/bootstrap"; -import { mountDuneAdmin, mountDuneAdminEarly } from "../../src/admin/mount.ts"; -import type { AdminContext } from "../../src/admin/context.ts"; - -Deno.test( - "mountDuneAdminEarly: a plugin's route registered before mountDuneAdmin() still sees adminContext and auth", - { sanitizeOps: false, sanitizeResources: false }, - async () => { - // deno-lint-ignore no-explicit-any - const app = new App(); - - // Minimal fake AdminContext — enough for routes/_middleware.ts's handler - // to authenticate the request and call ctx.next() rather than - // redirecting/403ing before it reaches our probe route. - const adminContext = { - prefix: "/admin", - auth: { - authenticate: () => - Promise.resolve({ - authenticated: true, - user: { id: "u1", roles: ["admin"] }, - }), - }, - authz: { - check: () => Promise.resolve(true), - }, - // deno-lint-ignore no-explicit-any - } as any as AdminContext; - - // deno-lint-ignore no-explicit-any - const ctx = { config: { admin: { enabled: true, path: "/admin" } } } as any as BootstrapResult; - - // Phase 1 (mountEarly, admin first) — the fix under test. - mountDuneAdminEarly(app, ctx, () => adminContext); - - // A fake plugin's own mount()-registered route, added here to mirror - // Phase 2 registration order (this plugin runs before admin's own - // mount()-equivalent below, exactly as it would in the real - // mountPlugins() loop). - app.get("/admin/my-plugin/probe", (fc) => { - return Response.json({ - // deno-lint-ignore no-explicit-any - sawAdminContext: (fc.state as any).adminContext != null, - // deno-lint-ignore no-explicit-any - sawAuth: (fc.state as any).auth != null, - }); - }); - - // Phase 2, admin last — mirrors mountPlugins()'s own ordering. - await mountDuneAdmin(app, ctx, adminContext); - - const handler = app.handler(); - const res = await handler( - new Request("http://localhost:8000/admin/my-plugin/probe"), - ); - assertEquals(res.status, 200); - assertEquals(await res.json(), { sawAdminContext: true, sawAuth: true }); - }, -); From 39f335d0969976a2b03d4f3c11f67ab83c2f9df8 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 12:56:27 +0100 Subject: [PATCH 10/39] feat(admin): mount admin.routes behind authentication, CSRF and permission checks (#97) A plugin contributes { method, path, permission?, handler } and the admin registers it under its prefix; the handler only runs after the admin login, the CSRF check on non-GET methods and the permission check. Replaces registering guarded mutation routes in mount() with withGuards(), which stops working now that the admin no longer mounts first. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 16 ++- mod.ts | 2 + src/admin/context.ts | 9 +- src/admin/mount.ts | 53 ++++++++- tests/admin/capabilities_e2e_test.ts | 37 +++++++ tests/admin/plugin_admin_routes_test.ts | 140 ++++++++++++++++++++++++ 6 files changed, 248 insertions(+), 9 deletions(-) create mode 100644 tests/admin/plugin_admin_routes_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f7332f..0205fda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,11 +19,17 @@ follows [Semantic Versioning](https://semver.org). - **`mountDuneAdminEarly()` is gone** (core removed `DunePlugin.mountEarly`). `mountDuneAdmin(app, ctx, adminContext?)` falls back to the single-site context stored by the plugin's `mount()` instead of `ctx.adminContext`. A plugin route that - needs the admin identity calls `capabilities.get("auth.session")`; plugin routes - registered in a plugin's own `mount()` no longer sit behind the admin's - `ctx.state.adminContext` middleware, so `withGuards()` only works for pages the - admin mounts itself (`admin.pages`, GET). A contribution point for guarded - non-GET admin routes is a known gap. + needs the admin identity calls `capabilities.get("auth.session")`. Routes a plugin + registers in its own `mount()` no longer sit behind the admin's + `ctx.state.adminContext` middleware, so `withGuards()` no longer works there (it + throws a `TypeError`); a guarded `POST`/`PUT`/`PATCH`/`DELETE` admin route is now a + contribution to the `admin.routes` point (below). +- **Mounts `admin.routes`**: a plugin contributes `{ method, path, permission?, handler }` + and the admin registers it under its prefix behind its own authentication (`401` + otherwise), CSRF check on every non-GET method (`403`) and the `permission` check + (`403`); the handler only runs when all three pass. `mountPluginAdminRoutes()` in + `src/admin/mount.ts` does it; a route with an unknown method or a path that does not + start with `/` is skipped and logged as `admin.routes.invalid`. - **The `admin:` block reads snake_case keys** (duneorg/dune#96, `@dune/core` 0.36): `session_lifetime`, `data_dir`, `runtime_dir`, `max_revisions`, `max_upload_mb`, `authz_store`, `audit.log_file`, and the LDAP/SAML provider fields (`base_dn`, diff --git a/mod.ts b/mod.ts index 0e92d67..4dfe59d 100644 --- a/mod.ts +++ b/mod.ts @@ -285,6 +285,7 @@ export function createAdminPlugin( const metricsEnabled = config.system.metrics?.enabled !== false; const pluginPages = capabilities.list("admin.pages"); + const pluginRoutes = capabilities.list("admin.routes"); const adminContext: AdminContext = { services, @@ -315,6 +316,7 @@ export function createAdminPlugin( mt: setupState.mt, rateLimitStore, pluginPages: pluginPages.length > 0 ? [...pluginPages] : undefined, + pluginRoutes: pluginRoutes.length > 0 ? [...pluginRoutes] : undefined, // Created per serving process by `dune serve`, after mount(): read lazily. get jobScheduler() { return bootstrap.jobScheduler; diff --git a/src/admin/context.ts b/src/admin/context.ts index e3a142d..80b47a7 100644 --- a/src/admin/context.ts +++ b/src/admin/context.ts @@ -17,7 +17,7 @@ import type { Scheduler, ScheduledAction } from "@dune/core/workflow"; import type { HistoryEngine } from "@dune/core/history"; import type { SubmissionManager } from "./submissions.ts"; import type { FlexEngine } from "@dune/core/flex"; -import type { HookRegistry, AdminPageRegistration } from "@dune/core/hooks"; +import type { HookRegistry, AdminPageRegistration, AdminRouteRegistration } from "@dune/core/hooks"; import type { ContentEditorPlugin } from "@dune/core/hooks"; import type { StagingEngine } from "@dune/core/staging"; import type { CommentManager } from "./comments.ts"; @@ -33,7 +33,7 @@ import type { DuneAuthSystem } from "@dune/core/auth/authz"; import type { SearchManager } from "@dune/core/search"; import type { SectionRegistry } from "@dune/core/sections"; -export type { AdminPageRegistration }; +export type { AdminPageRegistration, AdminRouteRegistration }; /** All services available to admin route handlers via `FreshContext.state.adminContext`. */ export interface AdminContext { @@ -108,6 +108,11 @@ export interface AdminContext { * The Fresh app registers these as programmatic routes after fsRoutes(). */ pluginPages?: AdminPageRegistration[]; + /** + * Plugin-contributed guarded routes (`admin.routes`), collected at mount. + * Mounted behind the admin's authentication, CSRF and permission checks. + */ + pluginRoutes?: AdminRouteRegistration[]; /** * HMAC key for session-bound CSRF tokens. Tests inject this; production * loads or creates `runtimeDir/csrf-secret` on first use. diff --git a/src/admin/mount.ts b/src/admin/mount.ts index 8c16a07..3714b61 100644 --- a/src/admin/mount.ts +++ b/src/admin/mount.ts @@ -38,9 +38,9 @@ import type { App, FreshContext, Middleware } from "fresh"; import type { BootstrapResult } from "@dune/core/bootstrap"; import { registerPluginPublicRoutes } from "@dune/core/fresh-app"; import { registerFormRoutes } from "@dune/core/forms"; -import { checkPermission } from "./routes/api/_utils.ts"; +import { checkPermission, type GuardedHandler, withGuards } from "./routes/api/_utils.ts"; import { getAdminContext } from "./context.ts"; -import type { AdminState } from "./types.ts"; +import type { AdminPermission, AdminState } from "./types.ts"; import { adminIslands, adminLayout, @@ -98,6 +98,51 @@ function registerAdminAuthMiddleware( app.use(adminMiddleware.handler as Middleware); } +const ROUTE_METHODS = { + GET: "get", + POST: "post", + PUT: "put", + PATCH: "patch", + DELETE: "delete", +} as const; + +/** + * Mount the plugin-contributed `admin.routes` under the admin prefix. Each + * handler runs only for an authenticated admin (`401` otherwise), after the + * CSRF check (every non-GET method) and the route's `permission` check + * (`403`), exactly as the admin's own API routes. Must run after the admin + * middleware is registered, which is what puts `ctx.state.auth` in place. + * A route with an unknown method or a path that does not start with `/` is + * skipped and logged, not mounted. + */ +export function mountPluginAdminRoutes( + // deno-lint-ignore no-explicit-any + app: App, + adminPrefix: string, + routes: readonly import("./context.ts").AdminRouteRegistration[], + logger: { warn(event: string, fields?: Record): void }, +): void { + for (const route of routes) { + const register = ROUTE_METHODS[route.method as keyof typeof ROUTE_METHODS]; + if (!register || typeof route.path !== "string" || !route.path.startsWith("/")) { + logger.warn("admin.routes.invalid", { method: route.method, path: route.path }); + continue; + } + const guarded = withGuards( + { permission: route.permission as AdminPermission | undefined }, + route.handler as unknown as GuardedHandler, + ); + app[register](`${adminPrefix}${route.path}`, (fc) => { + // deno-lint-ignore no-explicit-any + if (!(fc.state as any).auth?.authenticated) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + // deno-lint-ignore no-explicit-any + return guarded(fc as any); + }); + } +} + /** The single-site context `mount()` stored, or null before the plugin has mounted. */ function currentAdminContextOrNull(): import("./context.ts").AdminContext | null { try { @@ -158,6 +203,10 @@ export async function mountDuneAdmin( // The admin _middleware enforces authentication; here we additionally // honour the plugin-declared permission (if any) so plugin authors can // restrict access to a subset of admin roles. + if (adminContext?.pluginRoutes?.length) { + mountPluginAdminRoutes(app, adminPrefix, adminContext.pluginRoutes, adminContext.services.logger); + } + const pluginAdminPages = adminContext?.pluginPages; if (pluginAdminPages && pluginAdminPages.length > 0 && adminContext) { for (const page of pluginAdminPages) { diff --git a/tests/admin/capabilities_e2e_test.ts b/tests/admin/capabilities_e2e_test.ts index ecd14ee..38d1056 100644 --- a/tests/admin/capabilities_e2e_test.ts +++ b/tests/admin/capabilities_e2e_test.ts @@ -90,3 +90,40 @@ Deno.test( }); }, ); + +Deno.test( + "admin mounts admin.routes behind its login: an anonymous POST is sent to the login page and the handler never runs", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + let ran = 0; + const contributor: DunePlugin = { + name: "test-route-contributor", + version: "1.0.0", + hooks: {}, + setup(api) { + api.capabilities.contribute("admin.routes", { + method: "POST", + path: "/api/my-plugin/save", + permission: "config.update", + handler: () => { + ran++; + return new Response("saved"); + }, + }); + }, + }; + await withSite([contributor], async (_ctx, handler) => { + const res = await handler( + new Request("http://localhost/admin/api/my-plugin/save", { + method: "POST", + headers: { origin: "http://localhost" }, + }), + ); + // Like every admin route, the admin middleware sends an anonymous request to + // the login page before the contributed handler (or its own 401) is reached. + assertEquals(res.status, 302); + assertEquals(res.headers.get("location")?.startsWith("/admin/login"), true); + assertEquals(ran, 0); + }); + }, +); diff --git a/tests/admin/plugin_admin_routes_test.ts b/tests/admin/plugin_admin_routes_test.ts new file mode 100644 index 0000000..1152a81 --- /dev/null +++ b/tests/admin/plugin_admin_routes_test.ts @@ -0,0 +1,140 @@ +/** + * `admin.routes` contributions are mounted behind the admin's authentication, + * CSRF check and permission check; the contributed handler only runs for a + * request that passed all three. + */ + +import { assertEquals } from "@std/assert"; +import { mountPluginAdminRoutes } from "../../src/admin/mount.ts"; + +type Handler = (fc: unknown) => Response | Promise; + +function fakeApp() { + const routes = new Map(); + const app: Record = {}; + for (const m of ["get", "post", "put", "patch", "delete"]) { + app[m] = (path: string, h: Handler) => routes.set(`${m.toUpperCase()} ${path}`, h); + } + return { app: app as never, routes }; +} + +const logs: { event: string; fields?: Record }[] = []; +const logger = { warn: (event: string, fields?: Record) => logs.push({ event, fields }) }; + +function fc(opts: { + method: string; + path: string; + headers?: Record; + authenticated?: boolean; + allow?: boolean; +}) { + const url = new URL(`https://cms.example.com${opts.path}`); + return { + req: new Request(url, { method: opts.method, headers: opts.headers }), + url, + params: {}, + state: { + auth: opts.authenticated === false + ? { authenticated: false } + : { authenticated: true, user: { id: "u1", username: "alice" }, session: { id: "s1" } }, + adminContext: { + authz: { check: () => Promise.resolve(opts.allow !== false) }, + csrfSecret: "test-secret", + config: { admin: {} }, + }, + }, + }; +} + +function setup(route: Partial[2][number]> = {}) { + const { app, routes } = fakeApp(); + let ran = 0; + mountPluginAdminRoutes( + app, + "/admin", + [{ + method: "POST", + path: "/api/my-plugin/save", + permission: "config.update", + handler: () => { + ran++; + return new Response("done"); + }, + ...route, + }], + logger, + ); + return { routes, ran: () => ran }; +} + +const SAME_ORIGIN = { origin: "https://cms.example.com" }; + +Deno.test("admin.routes: mounted under the admin prefix with the contributed method", () => { + const { routes } = setup(); + assertEquals([...routes.keys()], ["POST /admin/api/my-plugin/save"]); +}); + +Deno.test("admin.routes: an unauthenticated request gets 401 and the handler does not run", async () => { + const { routes, ran } = setup(); + const res = await routes.get("POST /admin/api/my-plugin/save")!( + fc({ method: "POST", path: "/admin/api/my-plugin/save", headers: SAME_ORIGIN, authenticated: false }), + ); + assertEquals(res.status, 401); + assertEquals(ran(), 0); +}); + +Deno.test("admin.routes: a cross-origin POST is refused by the CSRF check", async () => { + const { routes, ran } = setup(); + const res = await routes.get("POST /admin/api/my-plugin/save")!( + fc({ method: "POST", path: "/admin/api/my-plugin/save", headers: { origin: "https://evil.example" } }), + ); + assertEquals(res.status, 403); + assertEquals(ran(), 0); +}); + +Deno.test("admin.routes: a request without the permission gets 403", async () => { + const { routes, ran } = setup(); + const res = await routes.get("POST /admin/api/my-plugin/save")!( + fc({ method: "POST", path: "/admin/api/my-plugin/save", headers: SAME_ORIGIN, allow: false }), + ); + assertEquals(res.status, 403); + assertEquals(ran(), 0); +}); + +Deno.test("admin.routes: an authenticated same-origin request with the permission reaches the handler", async () => { + const { routes, ran } = setup(); + const res = await routes.get("POST /admin/api/my-plugin/save")!( + fc({ method: "POST", path: "/admin/api/my-plugin/save", headers: SAME_ORIGIN }), + ); + assertEquals(res.status, 200); + assertEquals(await res.text(), "done"); + assertEquals(ran(), 1); +}); + +Deno.test("admin.routes: without a permission any authenticated admin passes, an anonymous one still does not", async () => { + const { routes, ran } = setup({ permission: undefined }); + const handler = routes.get("POST /admin/api/my-plugin/save")!; + assertEquals((await handler(fc({ method: "POST", path: "/admin/api/my-plugin/save", headers: SAME_ORIGIN }))).status, 200); + assertEquals( + (await handler(fc({ method: "POST", path: "/admin/api/my-plugin/save", headers: SAME_ORIGIN, authenticated: false }))).status, + 401, + ); + assertEquals(ran(), 1); +}); + +Deno.test("admin.routes: a GET route needs no CSRF token", async () => { + const { routes } = setup({ method: "GET", path: "/api/my-plugin/list" }); + const res = await routes.get("GET /admin/api/my-plugin/list")!( + fc({ method: "GET", path: "/admin/api/my-plugin/list" }), + ); + assertEquals(res.status, 200); +}); + +Deno.test("admin.routes: an invalid method or a path without a leading slash is skipped and logged", () => { + logs.length = 0; + const { routes } = setup({ path: "api/no-slash" }); + assertEquals(routes.size, 0); + const bad = setup({ method: "TRACE" as never }); + assertEquals(bad.routes.size, 0); + assertEquals(logs.map((l) => l.event), ["admin.routes.invalid", "admin.routes.invalid"]); +}); From 7e082b1e3f6d6c18ad83393a1752b5d5c9b111ba Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 13:24:55 +0100 Subject: [PATCH 11/39] feat(admin): auth.session declares its session cookie (#97) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 3 +++ mod.ts | 4 ++++ tests/admin/capabilities_e2e_test.ts | 1 + 3 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0205fda..4e36898 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ follows [Semantic Versioning](https://semver.org). `MountApi.capabilities` instead of `adminServices` and each plugin's `adminPages`, and no longer writes `bootstrap.adminContext`. The jobs view reads `BootstrapResult.jobScheduler`, which `dune serve` sets after mount. + `auth.session` declares `sessionCookies: ["dune_session"]`, which core uses to decide + whether to authenticate a public page and whether to keep the response out of the shared + page cache. - **`mountDuneAdminEarly()` is gone** (core removed `DunePlugin.mountEarly`). `mountDuneAdmin(app, ctx, adminContext?)` falls back to the single-site context stored by the plugin's `mount()` instead of `ctx.adminContext`. A plugin route that diff --git a/mod.ts b/mod.ts index 4dfe59d..c51b1f6 100644 --- a/mod.ts +++ b/mod.ts @@ -127,6 +127,10 @@ export function createAdminPlugin( // (the admin bar, the inline-edit WebSocket, `dune serve`'s scheduler // loop); until mount() has built the context they report "nobody". api.capabilities.provide("auth.session", () => ({ + // The cookie the admin's auth middleware sets (see createAuthMiddleware). + // Core uses it to decide whether to authenticate a public page and whether + // to keep the response out of the shared page cache. + sessionCookies: ["dune_session"], authenticate: (req) => liveAdminContext ? liveAdminContext.auth.authenticate(req) diff --git a/tests/admin/capabilities_e2e_test.ts b/tests/admin/capabilities_e2e_test.ts index 38d1056..c1893aa 100644 --- a/tests/admin/capabilities_e2e_test.ts +++ b/tests/admin/capabilities_e2e_test.ts @@ -36,6 +36,7 @@ Deno.test( assertExists(ctx.capabilities.get("scheduler.executor")); const anonymous = await auth.authenticate(new Request("http://localhost/")); assertEquals(anonymous.authenticated, false); + assertEquals(auth.sessionCookies, ["dune_session"]); const providers = Object.fromEntries(ctx.capabilities.providers().map((p) => [p.name, p.provider])); assertEquals(providers["auth.session"], "dune-admin"); assertEquals(providers["scheduler.executor"], "dune-admin"); From 83ad83476d4e7c1487b77463e39d17f673441a9d Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 14:33:59 +0100 Subject: [PATCH 12/39] feat(admin)!: admin.pages and admin.routes require a permission (#97) An entry without a permission is skipped and logged instead of being exposed to every authenticated admin. The pages mounting moves into mountPluginAdminPages() so it can be tested. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 15 +++-- src/admin/mount.ts | 82 ++++++++++++++--------- tests/admin/capabilities_e2e_test.ts | 1 + tests/admin/plugin_admin_routes_test.ts | 86 ++++++++++++++++++++++--- 4 files changed, 137 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e36898..b1dede8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,12 +27,15 @@ follows [Semantic Versioning](https://semver.org). `ctx.state.adminContext` middleware, so `withGuards()` no longer works there (it throws a `TypeError`); a guarded `POST`/`PUT`/`PATCH`/`DELETE` admin route is now a contribution to the `admin.routes` point (below). -- **Mounts `admin.routes`**: a plugin contributes `{ method, path, permission?, handler }` - and the admin registers it under its prefix behind its own authentication (`401` - otherwise), CSRF check on every non-GET method (`403`) and the `permission` check - (`403`); the handler only runs when all three pass. `mountPluginAdminRoutes()` in - `src/admin/mount.ts` does it; a route with an unknown method or a path that does not - start with `/` is skipped and logged as `admin.routes.invalid`. +- **Mounts `admin.routes`** and the `admin.pages` contributions through `mountPluginAdminRoutes()` + / `mountPluginAdminPages()` (`src/admin/mount.ts`). A route is `{ method, path, permission, handler }` + and is registered under the admin prefix behind the admin's own authentication (`401` + otherwise), the CSRF check on every non-GET method (`403`) and the `permission` check (`403`); the + handler only runs when all three pass. A page is the same for GET with a login redirect. **`permission` + is required on both** (core's `AdminPageRegistration` / `AdminRouteRegistration` type it that way): + an entry without one, or with an unknown method or a path that does not start with `/`, is skipped + and logged (`admin.routes.invalid` / `admin.pages.invalid`) instead of being exposed to every + authenticated admin, which also covers plugins written without type-checking. - **The `admin:` block reads snake_case keys** (duneorg/dune#96, `@dune/core` 0.36): `session_lifetime`, `data_dir`, `runtime_dir`, `max_revisions`, `max_upload_mb`, `authz_store`, `audit.log_file`, and the LDAP/SAML provider fields (`base_dn`, diff --git a/src/admin/mount.ts b/src/admin/mount.ts index 3714b61..0f9d20e 100644 --- a/src/admin/mount.ts +++ b/src/admin/mount.ts @@ -98,6 +98,47 @@ function registerAdminAuthMiddleware( app.use(adminMiddleware.handler as Middleware); } +/** + * Mount the plugin-contributed `admin.pages` under the admin prefix: GET pages + * rendered in the admin shell. An unauthenticated request is sent to the login + * page; an authenticated one needs the page's `permission` (403 otherwise). + * `permission` is required: an entry without one is skipped and logged as + * `admin.pages.invalid` rather than exposed to every authenticated admin, which + * also covers plugins written without type-checking. + */ +export function mountPluginAdminPages( + // deno-lint-ignore no-explicit-any + app: App, + adminPrefix: string, + pages: readonly import("./context.ts").AdminPageRegistration[], + logger: { warn(event: string, fields?: Record): void }, +): void { + for (const page of pages) { + if (typeof page.path !== "string" || !page.path.startsWith("/")) { + logger.warn("admin.pages.invalid", { path: page.path, reason: "path must start with /" }); + continue; + } + if (typeof page.permission !== "string" || page.permission === "") { + logger.warn("admin.pages.invalid", { path: page.path, reason: "permission is required" }); + continue; + } + app.get(`${adminPrefix}${page.path}`, async (fc) => { + // deno-lint-ignore no-explicit-any + const authResult = (fc.state as any).auth; + if (!authResult?.authenticated) { + return new Response(null, { status: 302, headers: { Location: `${adminPrefix}/login` } }); + } + const ok = await checkPermission( + fc as unknown as FreshContext, + page.permission as AdminPermission, + ); + if (!ok) return new Response("Forbidden", { status: 403 }); + // deno-lint-ignore no-explicit-any + return page.handler(fc as any); + }); + } +} + const ROUTE_METHODS = { GET: "get", POST: "post", @@ -112,8 +153,8 @@ const ROUTE_METHODS = { * CSRF check (every non-GET method) and the route's `permission` check * (`403`), exactly as the admin's own API routes. Must run after the admin * middleware is registered, which is what puts `ctx.state.auth` in place. - * A route with an unknown method or a path that does not start with `/` is - * skipped and logged, not mounted. + * `permission` is required. A route with an unknown method, a path that does not + * start with `/`, or no permission is skipped and logged, not mounted. */ export function mountPluginAdminRoutes( // deno-lint-ignore no-explicit-any @@ -125,11 +166,15 @@ export function mountPluginAdminRoutes( for (const route of routes) { const register = ROUTE_METHODS[route.method as keyof typeof ROUTE_METHODS]; if (!register || typeof route.path !== "string" || !route.path.startsWith("/")) { - logger.warn("admin.routes.invalid", { method: route.method, path: route.path }); + logger.warn("admin.routes.invalid", { method: route.method, path: route.path, reason: "unknown method or path without leading /" }); + continue; + } + if (typeof route.permission !== "string" || route.permission === "") { + logger.warn("admin.routes.invalid", { method: route.method, path: route.path, reason: "permission is required" }); continue; } const guarded = withGuards( - { permission: route.permission as AdminPermission | undefined }, + { permission: route.permission as AdminPermission }, route.handler as unknown as GuardedHandler, ); app[register](`${adminPrefix}${route.path}`, (fc) => { @@ -207,33 +252,8 @@ export async function mountDuneAdmin( mountPluginAdminRoutes(app, adminPrefix, adminContext.pluginRoutes, adminContext.services.logger); } - const pluginAdminPages = adminContext?.pluginPages; - if (pluginAdminPages && pluginAdminPages.length > 0 && adminContext) { - for (const page of pluginAdminPages) { - const fullPath = `${adminPrefix}${page.path}`; - app.get(fullPath, async (fc) => { - // deno-lint-ignore no-explicit-any - const authResult = (fc.state as any).auth; - if (!authResult?.authenticated) { - return new Response(null, { - status: 302, - headers: { Location: `${adminPrefix}/login` }, - }); - } - if (page.permission) { - // deno-lint-ignore no-explicit-any - const ok = await checkPermission( - fc as unknown as FreshContext, - page.permission as any, - ); - if (!ok) { - return new Response("Forbidden", { status: 403 }); - } - } - // deno-lint-ignore no-explicit-any - return page.handler(fc as any); - }); - } + if (adminContext?.pluginPages?.length) { + mountPluginAdminPages(app, adminPrefix, adminContext.pluginPages, adminContext.services.logger); } } diff --git a/tests/admin/capabilities_e2e_test.ts b/tests/admin/capabilities_e2e_test.ts index c1893aa..16085e0 100644 --- a/tests/admin/capabilities_e2e_test.ts +++ b/tests/admin/capabilities_e2e_test.ts @@ -56,6 +56,7 @@ Deno.test( api.capabilities.contribute("admin.pages", { path: "/my-plugin", label: "My plugin", + permission: "pages.read", handler: () => new Response("secret page"), }); }, diff --git a/tests/admin/plugin_admin_routes_test.ts b/tests/admin/plugin_admin_routes_test.ts index 1152a81..2085379 100644 --- a/tests/admin/plugin_admin_routes_test.ts +++ b/tests/admin/plugin_admin_routes_test.ts @@ -5,7 +5,7 @@ */ import { assertEquals } from "@std/assert"; -import { mountPluginAdminRoutes } from "../../src/admin/mount.ts"; +import { mountPluginAdminPages, mountPluginAdminRoutes } from "../../src/admin/mount.ts"; type Handler = (fc: unknown) => Response | Promise; @@ -111,15 +111,16 @@ Deno.test("admin.routes: an authenticated same-origin request with the permissio assertEquals(ran(), 1); }); -Deno.test("admin.routes: without a permission any authenticated admin passes, an anonymous one still does not", async () => { - const { routes, ran } = setup({ permission: undefined }); - const handler = routes.get("POST /admin/api/my-plugin/save")!; - assertEquals((await handler(fc({ method: "POST", path: "/admin/api/my-plugin/save", headers: SAME_ORIGIN }))).status, 200); - assertEquals( - (await handler(fc({ method: "POST", path: "/admin/api/my-plugin/save", headers: SAME_ORIGIN, authenticated: false }))).status, - 401, - ); - assertEquals(ran(), 1); +Deno.test("admin.routes: a route without a permission is refused at mount and logged, not exposed to every admin", () => { + logs.length = 0; + for (const permission of [undefined, ""]) { + const { routes } = setup({ permission: permission as never }); + assertEquals(routes.size, 0); + } + assertEquals(logs.map((l) => [l.event, l.fields?.reason]), [ + ["admin.routes.invalid", "permission is required"], + ["admin.routes.invalid", "permission is required"], + ]); }); Deno.test("admin.routes: a GET route needs no CSRF token", async () => { @@ -137,4 +138,69 @@ Deno.test("admin.routes: an invalid method or a path without a leading slash is const bad = setup({ method: "TRACE" as never }); assertEquals(bad.routes.size, 0); assertEquals(logs.map((l) => l.event), ["admin.routes.invalid", "admin.routes.invalid"]); + assertEquals(logs.map((l) => l.fields?.reason), [ + "unknown method or path without leading /", + "unknown method or path without leading /", + ]); +}); + +// ── admin.pages ────────────────────────────────────────────────────────────── + +function setupPages(page: Record = {}) { + const { app, routes } = fakeApp(); + let ran = 0; + mountPluginAdminPages( + app, + "/admin", + [{ + path: "/my-plugin", + label: "My plugin", + permission: "pages.read", + handler: () => { + ran++; + return new Response("page"); + }, + ...page, + } as never], + logger, + ); + return { routes, ran: () => ran }; +} + +Deno.test("admin.pages: an anonymous request is sent to the login page and the handler does not run", async () => { + const { routes, ran } = setupPages(); + const res = await routes.get("GET /admin/my-plugin")!( + fc({ method: "GET", path: "/admin/my-plugin", authenticated: false }), + ) as Response; + assertEquals(res.status, 302); + assertEquals(res.headers.get("location"), "/admin/login"); + assertEquals(ran(), 0); +}); + +Deno.test("admin.pages: the permission is enforced, and the handler runs when it is held", async () => { + const { routes, ran } = setupPages(); + const handler = routes.get("GET /admin/my-plugin")!; + assertEquals((await handler(fc({ method: "GET", path: "/admin/my-plugin", allow: false }))).status, 403); + assertEquals(ran(), 0); + assertEquals((await handler(fc({ method: "GET", path: "/admin/my-plugin" }))).status, 200); + assertEquals(ran(), 1); +}); + +Deno.test("admin.pages: a page without a permission is refused at mount and logged", () => { + logs.length = 0; + for (const permission of [undefined, ""]) { + const { routes } = setupPages({ permission }); + assertEquals(routes.size, 0); + } + assertEquals(logs.map((l) => [l.event, l.fields?.reason]), [ + ["admin.pages.invalid", "permission is required"], + ["admin.pages.invalid", "permission is required"], + ]); +}); + +Deno.test("admin.pages: a path without a leading slash is skipped and logged", () => { + logs.length = 0; + const { routes } = setupPages({ path: "no-slash" }); + assertEquals(routes.size, 0); + assertEquals(logs.map((l) => l.event), ["admin.pages.invalid"]); }); From 64b0a27d8d3f73aa11c12d4f81f559a3dea4500f Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 14:45:57 +0100 Subject: [PATCH 13/39] test(admin): run the admin conformance suite against plugin-admin (#97) Co-Authored-By: Claude Sonnet 5.5 --- .dune/admin/csrf-secret | 1 + CHANGELOG.md | 10 ++++ deno.json | 1 + tests/admin/conformance_test.ts | 98 +++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 .dune/admin/csrf-secret create mode 100644 tests/admin/conformance_test.ts diff --git a/.dune/admin/csrf-secret b/.dune/admin/csrf-secret new file mode 100644 index 0000000..0cfae29 --- /dev/null +++ b/.dune/admin/csrf-secret @@ -0,0 +1 @@ +e2d70ff6df338be68a3461e97fc83abfe5fa4ff393c4901a90f9d84fa88cabf6 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index b1dede8..c4cafa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ follows [Semantic Versioning](https://semver.org). ## [Unreleased] + +### Added + +- **Runs the admin conformance suite from `@dune/testing`** (`tests/admin/conformance_test.ts`): the checks + the stub admin passes, against plugin-admin over a real temp site. It covers `auth.session`, session + recognition and marker scrubbing, the declared `dune_session` cookie, mount-order independence, + `onPageUpdate` fired once by an edit, `admin.pages` / `admin.routes` mounted behind the login and the CSRF + check, and the site serving with no admin. Needs `@dune/testing` with the conformance suite + (`"@dune/testing": "jsr:@dune/testing@^1"` in `deno.json`), so it is draft-only until that is released. + ### Changed — BREAKING - **The admin provides and consumes core's capabilities instead of the untyped diff --git a/deno.json b/deno.json index 78d18cc..d5bbf5f 100644 --- a/deno.json +++ b/deno.json @@ -18,6 +18,7 @@ "./admin/auth/provider": "./src/admin/auth/provider.ts" }, "imports": { + "@dune/testing": "jsr:@dune/testing@^1", "@dune/core": "jsr:@dune/core@^0.35.3", "fresh": "jsr:@fresh/core@^2", "preact": "npm:preact@^10", diff --git a/tests/admin/conformance_test.ts b/tests/admin/conformance_test.ts new file mode 100644 index 0000000..dccc990 --- /dev/null +++ b/tests/admin/conformance_test.ts @@ -0,0 +1,98 @@ +/** + * plugin-admin against the admin conformance suite from `@dune/testing`: the + * same checks the stub admin passes, run on the real thing over a real temp + * site. If this passes, plugin-admin meets the contract core relies on. + */ + +import { join } from "@std/path"; +import { runAdminConformance } from "@dune/testing/conformance"; +import type { ConformanceApp } from "@dune/testing/conformance"; +import { bootstrap } from "@dune/core/bootstrap"; +import { createDuneApp } from "@dune/core/fresh-app"; +import { createStorage } from "@dune/core/storage"; +import { createLocalUserStore } from "@dune/core/auth/user-store"; +import { hashPassword } from "@dune/core/auth/passwords"; + +const PASSWORD = "correct horse battery staple"; +const ORIGIN = "http://localhost:8000"; + +async function removeWithRetry(root: string): Promise { + let last: unknown; + for (let i = 0; i < 5; i++) { + try { + await Deno.remove(root, { recursive: true }); + return; + } catch (err) { + last = err; + await new Promise((r) => setTimeout(r, 50)); + } + } + throw last; +} + +async function login(app: ConformanceApp, username: string): Promise { + const res = await app.request("/admin/login", { + method: "POST", + headers: { origin: ORIGIN, "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ username, password: PASSWORD }).toString(), + }); + const set = res.headers.get("set-cookie") ?? ""; + const m = set.match(/dune_session=[^;]+/); + if (!m) throw new Error(`plugin-admin login failed for ${username}: ${res.status}`); + return m[0]; +} + +runAdminConformance({ + name: "plugin-admin", + adminPrefix: "/admin", + + async createApp({ content, extraPlugins, withAdmin }) { + const root = await Deno.makeTempDir({ prefix: "dune_admin_conformance_" }); + await Deno.mkdir(join(root, "config"), { recursive: true }); + await Deno.writeTextFile(join(root, "config", "site.yaml"), 'title: "Conformance Site"\nurl: "http://localhost:8000"\n'); + for (const [path, body] of Object.entries(content)) { + await Deno.mkdir(join(root, "content", path, ".."), { recursive: true }); + await Deno.writeTextFile(join(root, "content", path), body); + } + if (withAdmin) { + const users = createLocalUserStore({ storage: createStorage({ rootDir: root }), usersDir: "data/users" }); + await users.create({ + email: "ed@example.test", + username: "ed", + name: "Ed", + provider: "local", + passwordHash: await hashPassword(PASSWORD), + roles: ["admin"], + }); + } + const ctx = await bootstrap(root, { + plugins: extraPlugins, + // deno-lint-ignore no-explicit-any + configOverrides: withAdmin ? undefined : { admin: { enabled: false } as any }, + }); + const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); + const handler = app.handler(); + return { + request: (path, init) => handler(new Request(new URL(path, ORIGIN), init)), + bootstrap: ctx, + readText: (path) => Deno.readTextFile(join(root, path)), + dispose: () => removeWithRetry(root), + }; + }, + + signInEditor: (app) => login(app, "ed"), + // No signInViewer: every admin-tier role (admin, editor, author) holds pages.update, + // and a non-admin account cannot sign in to the admin at all. + + async editPage(app, cookie, { sourcePath, content }) { + // plugin-admin's route takes the path relative to the content dir and the body after the frontmatter. + const relative = sourcePath.replace(/^content\//, ""); + const body = content.replace(/^---[\s\S]*?---\n*/, ""); + const res = await app.request(`/admin/api/pages/${relative}`, { + method: "PUT", + headers: { cookie, origin: ORIGIN, "content-type": "application/json" }, + body: JSON.stringify({ content: body }), + }); + if (res.status !== 200) throw new Error(`plugin-admin edit failed: ${res.status} ${await res.text()}`); + }, +}); From 37eb1d541342eb5642560f1cd734a21b2565869b Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 20:47:53 +0100 Subject: [PATCH 14/39] fix(admin): every route that writes a page fires its event (#97) Restore, staging publish, translate x2, dev apply, reorder (new onPageMove) and collab auto-save wrote pages and fired nothing. They now share announcePageEvent(), which fires the hook and the content webhooks. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 11 ++ mod.ts | 1 + src/admin/page-events.ts | 39 +++++ src/admin/routes/api/dev/apply.ts | 10 ++ src/admin/routes/api/history/[...rest].ts | 2 + src/admin/routes/api/i18n/translate-page.ts | 2 + src/admin/routes/api/pages/reorder.ts | 7 + src/admin/routes/api/pages/translate.ts | 2 + .../routes/api/staging/[...path]/publish.ts | 2 + src/collab/manager.ts | 9 +- src/collab/types.ts | 5 + tests/admin/page_events_test.ts | 143 ++++++++++++++++++ 12 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 src/admin/page-events.ts create mode 100644 tests/admin/page_events_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c4cafa1..745a31b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ follows [Semantic Versioning](https://semver.org). ## [Unreleased] +### Fixed + +- **Every route that writes a page now announces it (#97).** Whoever writes a page fires its event; core + does not. Six admin paths wrote pages and fired nothing, so search indexes, webhooks and audit plugins + missed them: restoring a revision (`onPageUpdate`), staging publish (`onPageUpdate`), the two translate + routes (`onPageCreate`), the dev apply endpoint (create / update / delete), reorder (new `onPageMove`, + which lists every renamed folder) and collab auto-save (`onPageUpdate`, only when the save changed the file). + The routes share `announcePageEvent()`, which fires the hook and the configured content webhooks, so + `onPageMove` is also a valid webhook event. Needs `@dune/core` with `onPageMove` (0.36). + `tests/admin/page_events_test.ts` drives create, update, reorder, delete and restore through the real routes. + ### Added - **Runs the admin conformance suite from `@dune/testing`** (`tests/admin/conformance_test.ts`): the checks diff --git a/mod.ts b/mod.ts index c51b1f6..b4e7943 100644 --- a/mod.ts +++ b/mod.ts @@ -254,6 +254,7 @@ export function createAdminPlugin( history: bootstrap.history, contentDir: config.system.content.dir, security: services.security, + hooks: bootstrap.hooks, }); // Core accepts and stores public form submissions; the admin only diff --git a/src/admin/page-events.ts b/src/admin/page-events.ts new file mode 100644 index 0000000..31e27b3 --- /dev/null +++ b/src/admin/page-events.ts @@ -0,0 +1,39 @@ +/** + * Announce a page write to plugins and content webhooks. + * + * Whoever writes a page fires its event: core never does it for the writer, and + * search indexes, webhooks and audit plugins only learn of a write through it. + * Every admin route that creates, rewrites, deletes or moves a page calls this + * after the file is on disk. Fire-and-forget: a failing handler or an + * unreachable webhook never fails the request. + * + * @module + */ + +import type { FreshContext } from "fresh"; +import type { AdminState } from "./types.ts"; +import { fireContentWebhooks } from "./webhooks.ts"; + +/** Paths are relative to the content directory. */ +export type PageEvent = + | { event: "onPageCreate"; payload: { sourcePath: string; title: string } } + | { event: "onPageUpdate"; payload: { sourcePath: string } } + | { event: "onPageDelete"; payload: { sourcePath: string } } + | { event: "onPageMove"; payload: { moves: Array<{ from: string; to: string }> } }; + +/** Fire a page event to hook handlers and to the configured content webhooks. */ +export function announcePageEvent(ctx: FreshContext, e: PageEvent): void { + const { hooks, config, services } = ctx.state.adminContext; + if (hooks) { + hooks.fire(e.event, e.payload).catch((err: unknown) => + console.warn(`[dune] ${e.event} hook failed: ${err instanceof Error ? err.message : err}`) + ); + } + fireContentWebhooks( + config.admin?.webhooks ?? [], + e.event, + e.payload, + config.admin?.runtime_dir ?? ".dune/admin", + services.security, + ); +} diff --git a/src/admin/routes/api/dev/apply.ts b/src/admin/routes/api/dev/apply.ts index 61455a2..777db57 100644 --- a/src/admin/routes/api/dev/apply.ts +++ b/src/admin/routes/api/dev/apply.ts @@ -41,6 +41,7 @@ import type { FreshContext } from "fresh"; import type { AdminState } from "../../../types.ts"; import { json, requirePermission, requireTsxWrite, requireOwnedPage, csrfCheck, validatePagePath } from "../_utils.ts"; import { ValidationError } from "../../../http.ts"; +import { announcePageEvent } from "../../../page-events.ts"; import { join } from "@std/path"; import { parse as parseYaml, stringify as stringifyYaml } from "@std/yaml"; @@ -482,10 +483,19 @@ export const handler = { try { if (change.op === "delete") { await Deno.remove(absPath); + announcePageEvent(ctx, { event: "onPageDelete", payload: { sourcePath: change.path ?? "" } }); results.push({ op: change.op, path: change.path, status: "deleted", errors: [] }); } else { const existed = await Deno.stat(absPath).then(() => true).catch(() => false); await applyWrite(contentDir, change as Change & { op: "write" | "frontmatter"; path: string }); + const sourcePath = change.path ?? ""; + const title = change.op === "frontmatter" ? change.patch?.title : undefined; + announcePageEvent( + ctx, + existed + ? { event: "onPageUpdate", payload: { sourcePath } } + : { event: "onPageCreate", payload: { sourcePath, title: typeof title === "string" ? title : "" } }, + ); results.push({ op: change.op, path: change.path, diff --git a/src/admin/routes/api/history/[...rest].ts b/src/admin/routes/api/history/[...rest].ts index d2810f6..44508b7 100644 --- a/src/admin/routes/api/history/[...rest].ts +++ b/src/admin/routes/api/history/[...rest].ts @@ -13,6 +13,7 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, requireTsxWrite, requireOwnedPage, json, serverError, csrfCheck, validatePagePath } from "../_utils.ts"; import { stringify as stringifyYaml } from "@std/yaml"; +import { announcePageEvent } from "../../../page-events.ts"; import type { FreshContext } from "fresh"; export const handler = { @@ -108,6 +109,7 @@ export const handler = { await storage.write(filePath, new TextEncoder().encode(fullContent)); await engine.rebuild(); + announcePageEvent(ctx, { event: "onPageUpdate", payload: { sourcePath: pageIndex.sourcePath } }); return json({ restored: true, revision: revNum }); } catch (err) { diff --git a/src/admin/routes/api/i18n/translate-page.ts b/src/admin/routes/api/i18n/translate-page.ts index f46bb96..8c5ad2d 100644 --- a/src/admin/routes/api/i18n/translate-page.ts +++ b/src/admin/routes/api/i18n/translate-page.ts @@ -2,6 +2,7 @@ import type { AdminState } from "../../../types.ts"; import { json, serverError, csrfCheck, requirePermission, requireTsxWrite, requireOwnedPage, validatePagePath } from "../_utils.ts"; +import { announcePageEvent } from "../../../page-events.ts"; import type { FreshContext } from "fresh"; function splitFrontmatter(content: string): { fm: string; body: string } { @@ -95,6 +96,7 @@ export const handler = { } await storage.write(`${contentDir}/${targetPath}`, new TextEncoder().encode(translatedFm + translatedBody)); + announcePageEvent(ctx, { event: "onPageCreate", payload: { sourcePath: targetPath, title: pageIndex.title ?? "" } }); engine.rebuild().catch((err: unknown) => console.error("[dune] MT translate-page rebuild error:", err)); return json({ ok: true, targetPath }); diff --git a/src/admin/routes/api/pages/reorder.ts b/src/admin/routes/api/pages/reorder.ts index fbb8ad8..a2a3107 100644 --- a/src/admin/routes/api/pages/reorder.ts +++ b/src/admin/routes/api/pages/reorder.ts @@ -3,6 +3,7 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, requireEditorOrAdmin, json, serverError, validatePagePath, csrfCheck } from "../_utils.ts"; import { dirname, basename } from "@std/path"; +import { announcePageEvent } from "../../../page-events.ts"; import type { FreshContext } from "fresh"; export const handler = { @@ -83,6 +84,12 @@ export const handler = { } await engine.rebuild(); + // Content-relative folders that were renamed; every page below `from` moved below `to`. + const rel = (p: string) => p.startsWith(`${contentDir}/`) ? p.slice(contentDir.length + 1) : p; + announcePageEvent(ctx, { + event: "onPageMove", + payload: { moves: renames.map((r) => ({ from: rel(r.oldDir), to: rel(r.newDir) })) }, + }); return json({ reordered: true }); } catch (err) { return serverError(err); diff --git a/src/admin/routes/api/pages/translate.ts b/src/admin/routes/api/pages/translate.ts index 6b6d7fd..b1c6d93 100644 --- a/src/admin/routes/api/pages/translate.ts +++ b/src/admin/routes/api/pages/translate.ts @@ -4,6 +4,7 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, requireTsxWrite, requireOwnedPage, json, serverError, csrfCheck, validatePagePath } from "../_utils.ts"; import { dirname, basename } from "@std/path"; import { parseContentFilename } from "@dune/core/content/path-utils"; +import { announcePageEvent } from "../../../page-events.ts"; import type { FreshContext } from "fresh"; export const handler = { @@ -48,6 +49,7 @@ export const handler = { const sourceBytes = await storage.read(`${contentDir}/${pageIndex.sourcePath}`); await storage.write(`${contentDir}/${targetPath}`, sourceBytes); + announcePageEvent(ctx, { event: "onPageCreate", payload: { sourcePath: targetPath, title: pageIndex.title ?? "" } }); await engine.rebuild(); return json({ created: true, path: targetPath }); diff --git a/src/admin/routes/api/staging/[...path]/publish.ts b/src/admin/routes/api/staging/[...path]/publish.ts index 1d55baa..19a5660 100644 --- a/src/admin/routes/api/staging/[...path]/publish.ts +++ b/src/admin/routes/api/staging/[...path]/publish.ts @@ -7,6 +7,7 @@ import type { AdminState } from "../../../../types.ts"; import { requirePermission, requireTsxWrite, requireOwnedPage, json, serverError, csrfCheck, validatePagePath } from "../../_utils.ts"; import { stringify as stringifyYaml } from "@std/yaml"; +import { announcePageEvent } from "../../../../page-events.ts"; import type { FreshContext } from "fresh"; async function maybeGitCommit( @@ -68,6 +69,7 @@ export const handler = { await maybeGitCommit(filePath, pagePath, authResult.user?.name, config.admin?.git_commit); await staging.discard(pagePath); await engine.rebuild(); + announcePageEvent(ctx, { event: "onPageUpdate", payload: { sourcePath: pageIndex.sourcePath } }); return json({ published: true, sourcePath: pagePath }); } catch (err) { diff --git a/src/collab/manager.ts b/src/collab/manager.ts index b454a63..0e309dc 100644 --- a/src/collab/manager.ts +++ b/src/collab/manager.ts @@ -111,7 +111,7 @@ function takeToken(rate: ConnectionRate): boolean { } export function createCollabManager(options: CollabManagerOptions): CollabManager { - const { storage, engine, history, contentDir, security } = options; + const { storage, engine, history, contentDir, security, hooks } = options; // docId → CollabSessionState const sessions = new Map(); @@ -194,6 +194,13 @@ export function createCollabManager(options: CollabManagerOptions): CollabManage return; } + // Whoever writes a page fires its event. Skip a save that changed nothing. + if (hooks && updated !== raw) { + hooks.fire("onPageUpdate", { sourcePath: session.docId }).catch((err: unknown) => + console.warn(`[collab] onPageUpdate hook failed for ${session.docId}:`, err) + ); + } + if (history && session.lastEditor) { const fm = parseFrontmatter(raw, security.parseUserYaml); history.record({ diff --git a/src/collab/types.ts b/src/collab/types.ts index f6fe8a2..ceef965 100644 --- a/src/collab/types.ts +++ b/src/collab/types.ts @@ -110,6 +110,11 @@ export interface CollabManagerOptions { contentDir: string; /** Core's security service (safe YAML parsing of frontmatter). */ security: Pick; + /** + * Hook registry. An auto-save that changes the file fires `onPageUpdate` + * (the save is already debounced, so this is not per keystroke). + */ + hooks?: Pick; } /** The CollabManager public interface exposed to the admin server. */ diff --git a/tests/admin/page_events_test.ts b/tests/admin/page_events_test.ts new file mode 100644 index 0000000..0accdbd --- /dev/null +++ b/tests/admin/page_events_test.ts @@ -0,0 +1,143 @@ +/** + * Every admin route that writes a page announces it: `onPageCreate`, + * `onPageUpdate`, `onPageDelete` and `onPageMove` reach plugins, on a real site + * through the real routes. Whoever writes a page fires its event; core does not + * do it for them, so a route that forgets is invisible to search indexes, + * webhooks and audit plugins. + */ + +import { assertEquals } from "@std/assert"; +import { join } from "@std/path"; +import type { DunePlugin } from "@dune/core/hooks"; +import { bootstrap } from "@dune/core/bootstrap"; +import { createDuneApp } from "@dune/core/fresh-app"; +import { createStorage } from "@dune/core/storage"; +import { createLocalUserStore } from "@dune/core/auth/user-store"; +import { hashPassword } from "@dune/core/auth/passwords"; + +const PASSWORD = "correct horse battery staple"; +const ORIGIN = "http://localhost:8000"; + +type Seen = Array<{ event: string; data: unknown }>; + +async function withSite( + run: (api: { + seen: Seen; + history: Awaited>["history"]; + call: (method: string, path: string, body?: unknown) => Promise; + }) => Promise, +): Promise { + const root = await Deno.makeTempDir({ prefix: "dune_admin_events_" }); + try { + await Deno.mkdir(join(root, "config"), { recursive: true }); + await Deno.writeTextFile(join(root, "config", "site.yaml"), 'title: "Events"\nurl: "http://localhost:8000"\n'); + for (const dir of ["01.alpha", "02.beta"]) { + await Deno.mkdir(join(root, "content", dir), { recursive: true }); + await Deno.writeTextFile(join(root, "content", dir, "default.md"), `---\ntitle: ${dir}\n---\n\nbody\n`); + } + const users = createLocalUserStore({ storage: createStorage({ rootDir: root }), usersDir: "data/users" }); + await users.create({ + email: "ed@example.test", + username: "ed", + name: "Ed", + provider: "local", + passwordHash: await hashPassword(PASSWORD), + roles: ["admin"], + }); + + const seen: Seen = []; + const record = (event: string) => ({ data }: { data: unknown }) => { + seen.push({ event, data }); + }; + const recorder: DunePlugin = { + name: "recorder", + version: "1.0.0", + hooks: { + onPageCreate: record("onPageCreate"), + onPageUpdate: record("onPageUpdate"), + onPageDelete: record("onPageDelete"), + onPageMove: record("onPageMove"), + } as DunePlugin["hooks"], + }; + const ctx = await bootstrap(root, { plugins: [recorder] }); + const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); + const handler = app.handler(); + const req = (path: string, init?: RequestInit) => handler(new Request(new URL(path, ORIGIN), init)); + + const login = await req("/admin/login", { + method: "POST", + headers: { origin: ORIGIN, "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ username: "ed", password: PASSWORD }).toString(), + }); + const cookie = (login.headers.get("set-cookie") ?? "").match(/dune_session=[^;]+/)?.[0]; + if (!cookie) throw new Error(`login failed: ${login.status}`); + + await run({ + seen, + history: ctx.history, + call: async (method, path, body) => { + const res = await req(path, { + method, + headers: { cookie, origin: ORIGIN, "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + // Hooks are fire-and-forget; let them settle. + await new Promise((r) => setTimeout(r, 25)); + return res; + }, + }); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +} + +Deno.test("admin page routes announce create, update, move and delete", async () => { + await withSite(async ({ seen, call }) => { + let res = await call("POST", "/admin/api/pages", { path: "blog", title: "Hello" }); + assertEquals(res.ok, true); + assertEquals(seen.splice(0), [{ event: "onPageCreate", data: { sourcePath: "blog/default.md", title: "Hello" } }]); + + res = await call("PUT", "/admin/api/pages/blog/default.md", { content: "changed" }); + assertEquals(res.status, 200); + assertEquals(seen.splice(0), [{ event: "onPageUpdate", data: { sourcePath: "blog/default.md" } }]); + + // Move 02.beta before 01.alpha: both folders are renumbered. + res = await call("POST", "/admin/api/pages/reorder", { + sourcePath: "02.beta/default.md", + targetPath: "01.alpha/default.md", + position: "before", + }); + assertEquals(res.status, 200); + assertEquals(seen.splice(0), [{ + event: "onPageMove", + data: { + moves: [ + { from: "02.beta", to: "01.beta" }, + { from: "01.alpha", to: "02.alpha" }, + ], + }, + }]); + + res = await call("DELETE", "/admin/api/pages/blog/default.md"); + assertEquals(res.status, 200); + assertEquals(seen.splice(0), [{ event: "onPageDelete", data: { sourcePath: "blog/default.md" } }]); + }); +}); + +Deno.test("history restore announces onPageUpdate", async () => { + await withSite(async ({ seen, history, call }) => { + await history.record({ + sourcePath: "01.alpha/default.md", + content: "an earlier version", + frontmatter: { title: "01.alpha" }, + author: "ed", + message: "seed", + }); + const revisions = await history.getHistory("01.alpha/default.md"); + assertEquals(revisions.length, 1); + + const res = await call("POST", `/admin/api/history/01.alpha/default.md/${revisions[0].number}/restore`); + assertEquals(res.status, 200); + assertEquals(seen.splice(0), [{ event: "onPageUpdate", data: { sourcePath: "01.alpha/default.md" } }]); + }); +}); From 8044a71782e8ad7bd661edf256eea24dfc9c6253 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 20:49:26 +0100 Subject: [PATCH 15/39] test(admin): conformance driver covers page create and delete (#97) Co-Authored-By: Claude Sonnet 5.5 --- tests/admin/conformance_test.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/admin/conformance_test.ts b/tests/admin/conformance_test.ts index dccc990..1b264c0 100644 --- a/tests/admin/conformance_test.ts +++ b/tests/admin/conformance_test.ts @@ -95,4 +95,24 @@ runAdminConformance({ }); if (res.status !== 200) throw new Error(`plugin-admin edit failed: ${res.status} ${await res.text()}`); }, + + async createPage(app, cookie, { sourcePath, title }) { + // The route takes the page folder and adds default.md itself. + const folder = sourcePath.replace(/^content\//, "").replace(/\/default\.md$/, ""); + const res = await app.request("/admin/api/pages", { + method: "POST", + headers: { cookie, origin: ORIGIN, "content-type": "application/json" }, + body: JSON.stringify({ path: folder, title }), + }); + if (!res.ok) throw new Error(`plugin-admin create failed: ${res.status} ${await res.text()}`); + }, + + async deletePage(app, cookie, { sourcePath }) { + const relative = sourcePath.replace(/^content\//, ""); + const res = await app.request(`/admin/api/pages/${relative}`, { + method: "DELETE", + headers: { cookie, origin: ORIGIN }, + }); + if (res.status !== 200) throw new Error(`plugin-admin delete failed: ${res.status} ${await res.text()}`); + }, }); From b4a95b1c027859832e1121bb6171e6d65271b4c7 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 20:51:42 +0100 Subject: [PATCH 16/39] fix(admin): register wildcard sub-routes before the bare wildcard (#18) The manifest generator put /api/staging/:path* ahead of /api/staging/:path*/publish, so publish was unreachable (the upsert handler ran and 500ed). Same for the page comments routes. Routes with more segments after a shared wildcard now sort first. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 7 +++ scripts/generate-admin-manifest.ts | 8 ++- src/admin/manifest.gen.ts | 24 ++++---- tests/admin/manifest_test.ts | 15 +++++ tests/admin/staging_publish_test.ts | 89 +++++++++++++++++++++++++++++ 5 files changed, 130 insertions(+), 13 deletions(-) create mode 100644 tests/admin/staging_publish_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 745a31b..d6093e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ follows [Semantic Versioning](https://semver.org). ### Fixed +- **Staging publish and the page-comment routes were unreachable.** The manifest generator registered a bare + wildcard (`/api/staging/:path*`) before routes that extend it (`/api/staging/:path*/publish`), so the + wildcard swallowed them: publishing a staged draft ran the upsert handler and 500ed on an empty body. The + same shadowing hit `/api/pages/:path*/comments`, `.../comments/:id` and `.../comments/:id/resolve`. The + generator now registers the route with more segments after a shared wildcard first. + `tests/admin/staging_publish_test.ts` publishes a staged draft over HTTP and checks the page file. + - **Every route that writes a page now announces it (#97).** Whoever writes a page fires its event; core does not. Six admin paths wrote pages and fired nothing, so search indexes, webhooks and audit plugins missed them: restoring a revision (`onPageUpdate`), staging publish (`onPageUpdate`), the two translate diff --git a/scripts/generate-admin-manifest.ts b/scripts/generate-admin-manifest.ts index 61fda6f..73cde8d 100644 --- a/scripts/generate-admin-manifest.ts +++ b/scripts/generate-admin-manifest.ts @@ -57,7 +57,13 @@ function comparePatterns(a: string, b: string): number { const y = sb[i]; if (x === undefined) return -1; if (y === undefined) return 1; - if (x === y) continue; + if (x === y) { + // A shared wildcard swallows everything after it, so the pattern with + // more segments following it (`/:path*/publish`) is the narrower one + // and must be registered first, or the bare `/:path*` shadows it. + if (x.endsWith("*") && sa.length !== sb.length) return sb.length - sa.length; + continue; + } const rank = (s: string) => s.endsWith("*") ? 2 : s.startsWith(":") ? 1 : 0; const rx = rank(x); const ry = rank(y); diff --git a/src/admin/manifest.gen.ts b/src/admin/manifest.gen.ts index 5c81817..13d86fa 100644 --- a/src/admin/manifest.gen.ts +++ b/src/admin/manifest.gen.ts @@ -40,10 +40,10 @@ import * as r33 from "./routes/api/page-source.ts"; import * as r34 from "./routes/api/pages/index.ts"; import * as r35 from "./routes/api/pages/reorder.ts"; import * as r36 from "./routes/api/pages/translate.ts"; -import * as r37 from "./routes/api/pages/[...path]/index.ts"; -import * as r38 from "./routes/api/pages/[...path]/comments/index.ts"; -import * as r39 from "./routes/api/pages/[...path]/comments/[id]/index.ts"; -import * as r40 from "./routes/api/pages/[...path]/comments/[id]/resolve.ts"; +import * as r37 from "./routes/api/pages/[...path]/comments/[id]/resolve.ts"; +import * as r38 from "./routes/api/pages/[...path]/comments/[id]/index.ts"; +import * as r39 from "./routes/api/pages/[...path]/comments/index.ts"; +import * as r40 from "./routes/api/pages/[...path]/index.ts"; import * as r41 from "./routes/api/plugins/index.ts"; import * as r42 from "./routes/api/plugins/install.ts"; import * as r43 from "./routes/api/plugins/[name]/config.ts"; @@ -54,8 +54,8 @@ import * as r47 from "./routes/api/render-markdown.ts"; import * as r48 from "./routes/api/search/analytics.ts"; import * as r49 from "./routes/api/search/engines.ts"; import * as r50 from "./routes/api/sections.ts"; -import * as r51 from "./routes/api/staging/[...path]/index.ts"; -import * as r52 from "./routes/api/staging/[...path]/publish.ts"; +import * as r51 from "./routes/api/staging/[...path]/publish.ts"; +import * as r52 from "./routes/api/staging/[...path]/index.ts"; import * as r53 from "./routes/api/theme-preview.ts"; import * as r54 from "./routes/api/themes/install.ts"; import * as r55 from "./routes/api/users/index.ts"; @@ -154,10 +154,10 @@ export const adminRoutes: AdminRouteEntry[] = [ { pattern: "/api/pages", mod: r34 }, { pattern: "/api/pages/reorder", mod: r35 }, { pattern: "/api/pages/translate", mod: r36 }, - { pattern: "/api/pages/:path*", mod: r37 }, - { pattern: "/api/pages/:path*/comments", mod: r38 }, - { pattern: "/api/pages/:path*/comments/:id", mod: r39 }, - { pattern: "/api/pages/:path*/comments/:id/resolve", mod: r40 }, + { pattern: "/api/pages/:path*/comments/:id/resolve", mod: r37 }, + { pattern: "/api/pages/:path*/comments/:id", mod: r38 }, + { pattern: "/api/pages/:path*/comments", mod: r39 }, + { pattern: "/api/pages/:path*", mod: r40 }, { pattern: "/api/plugins", mod: r41 }, { pattern: "/api/plugins/install", mod: r42 }, { pattern: "/api/plugins/:name/config", mod: r43 }, @@ -168,8 +168,8 @@ export const adminRoutes: AdminRouteEntry[] = [ { pattern: "/api/search/analytics", mod: r48 }, { pattern: "/api/search/engines", mod: r49 }, { pattern: "/api/sections", mod: r50 }, - { pattern: "/api/staging/:path*", mod: r51 }, - { pattern: "/api/staging/:path*/publish", mod: r52 }, + { pattern: "/api/staging/:path*/publish", mod: r51 }, + { pattern: "/api/staging/:path*", mod: r52 }, { pattern: "/api/theme-preview", mod: r53 }, { pattern: "/api/themes/install", mod: r54 }, { pattern: "/api/users", mod: r55 }, diff --git a/tests/admin/manifest_test.ts b/tests/admin/manifest_test.ts index 833044c..7f2d49c 100644 --- a/tests/admin/manifest_test.ts +++ b/tests/admin/manifest_test.ts @@ -64,6 +64,21 @@ Deno.test("staging and workflow status/scheduled routes are wildcard, not single } }); +Deno.test("admin manifest registers a wildcard's longer sub-routes before the bare wildcard", () => { + // `/x/:path*` also matches `/x/a/b/publish`, so it has to come after + // `/x/:path*/publish` or the sub-route is unreachable. + const patterns = adminRoutes.map((r) => r.pattern); + const order: Array<[string, string]> = [ + ["/api/staging/:path*/publish", "/api/staging/:path*"], + ["/api/pages/:path*/comments", "/api/pages/:path*"], + ["/api/pages/:path*/comments/:id", "/api/pages/:path*/comments"], + ["/api/pages/:path*/comments/:id/resolve", "/api/pages/:path*/comments/:id"], + ]; + for (const [first, second] of order) { + assertEquals(patterns.indexOf(first) < patterns.indexOf(second), true, `${first} must precede ${second}`); + } +}); + Deno.test("admin manifest orders static segments before dynamic ones", () => { const patterns = adminRoutes.map((r) => r.pattern); // /api/pages/reorder (static) must be registered before /api/pages/:path* diff --git a/tests/admin/staging_publish_test.ts b/tests/admin/staging_publish_test.ts new file mode 100644 index 0000000..881f42b --- /dev/null +++ b/tests/admin/staging_publish_test.ts @@ -0,0 +1,89 @@ +/** + * Publishing a staged draft goes through HTTP: the staging publish route has to + * win over the bare staging wildcard (upsert) route, whose greedy wildcard also + * matches paths ending in /publish. It writes the draft over the page file and + * announces `onPageUpdate`. + */ + +import { assertEquals, assertStringIncludes } from "@std/assert"; +import { join } from "@std/path"; +import type { DunePlugin } from "@dune/core/hooks"; +import { bootstrap } from "@dune/core/bootstrap"; +import { createDuneApp } from "@dune/core/fresh-app"; +import { createStorage } from "@dune/core/storage"; +import { createLocalUserStore } from "@dune/core/auth/user-store"; +import { hashPassword } from "@dune/core/auth/passwords"; + +const PASSWORD = "correct horse battery staple"; +const ORIGIN = "http://localhost:8000"; + +Deno.test("a staged draft is published through POST /api/staging/:path*/publish", async () => { + const root = await Deno.makeTempDir({ prefix: "dune_admin_staging_" }); + try { + await Deno.mkdir(join(root, "config"), { recursive: true }); + await Deno.writeTextFile(join(root, "config", "site.yaml"), 'title: "Staging"\nurl: "http://localhost:8000"\n'); + const pageFile = join(root, "content", "01.alpha", "default.md"); + await Deno.mkdir(join(root, "content", "01.alpha"), { recursive: true }); + await Deno.writeTextFile(pageFile, "---\ntitle: Live\n---\n\nlive body\n"); + const users = createLocalUserStore({ storage: createStorage({ rootDir: root }), usersDir: "data/users" }); + await users.create({ + email: "ed@example.test", + username: "ed", + name: "Ed", + provider: "local", + passwordHash: await hashPassword(PASSWORD), + roles: ["admin"], + }); + + const updates: unknown[] = []; + const recorder: DunePlugin = { + name: "recorder", + version: "1.0.0", + hooks: { onPageUpdate: ({ data }: { data: unknown }) => void updates.push(data) } as DunePlugin["hooks"], + }; + const ctx = await bootstrap(root, { plugins: [recorder] }); + const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); + const handler = app.handler(); + const req = (path: string, init?: RequestInit) => handler(new Request(new URL(path, ORIGIN), init)); + + const login = await req("/admin/login", { + method: "POST", + headers: { origin: ORIGIN, "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ username: "ed", password: PASSWORD }).toString(), + }); + const cookie = (login.headers.get("set-cookie") ?? "").match(/dune_session=[^;]+/)?.[0]; + if (!cookie) throw new Error(`login failed: ${login.status}`); + const call = (method: string, path: string, body?: unknown) => + req(path, { + method, + headers: { cookie, origin: ORIGIN, "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + const staged = await call("POST", "/admin/api/staging/01.alpha/default.md", { + content: "staged body", + frontmatter: { title: "Staged" }, + }); + assertEquals(staged.status, 200); + await staged.body?.cancel(); + // Staging a draft does not touch the live page. + assertStringIncludes(await Deno.readTextFile(pageFile), "live body"); + + const published = await call("POST", "/admin/api/staging/01.alpha/default.md/publish"); + assertEquals(published.status, 200); + assertEquals(await published.json(), { published: true, sourcePath: "01.alpha/default.md" }); + + const written = await Deno.readTextFile(pageFile); + assertStringIncludes(written, "title: Staged"); + assertStringIncludes(written, "staged body"); + + await new Promise((r) => setTimeout(r, 25)); + assertEquals(updates, [{ sourcePath: "01.alpha/default.md" }]); + + // The draft is consumed. + const after = await call("GET", "/admin/api/staging/01.alpha/default.md"); + assertEquals(await after.json(), { draft: null }); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +}); From 6050ac2f660becd9a7d8b8568cb2f35e9fc0fa19 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 21:20:34 +0100 Subject: [PATCH 17/39] feat(admin)!: mount() reads MountApi.runtime instead of the bootstrap result (#97) Adds mountAdminPanel() (panel only); mount() no longer re-registers public plugin routes and form endpoints, which createDuneApp() owns. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 11 ++++++++ mod.ts | 46 +++++++++++++++------------------- src/admin/mount.ts | 62 +++++++++++++++++++++++++++++++--------------- 3 files changed, 73 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 745a31b..2dd689c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,17 @@ follows [Semantic Versioning](https://semver.org). `onPageMove` is also a valid webhook event. Needs `@dune/core` with `onPageMove` (0.36). `tests/admin/page_events_test.ts` drives create, update, reorder, delete and restore through the real routes. +### Changed — BREAKING + +- **`mount()` reads the site from `MountApi.runtime`, not `MountApi.bootstrap` (#97).** Core's `mount()` + argument no longer carries the whole bootstrap result, so the admin takes engine, search, history, hooks, + submissions, flex, image cache, metrics, sections, authz and the job scheduler from the curated + `PluginRuntime`. `authz` replaces the `authz && authzAdapter` check (the adapter is no longer reachable). + `mount()` now calls the new `mountAdminPanel()` (panel routes, context middleware, `admin.pages` / + `admin.routes`) and no longer registers public plugin routes or form endpoints a second time: core's + `createDuneApp()` owns those. The headless `mountDuneAdmin(app, bootstrapResult)` is unchanged. Needs + `@dune/core` 0.36. + ### Added - **Runs the admin conformance suite from `@dune/testing`** (`tests/admin/conformance_test.ts`): the checks diff --git a/mod.ts b/mod.ts index b4e7943..ff56a2d 100644 --- a/mod.ts +++ b/mod.ts @@ -36,9 +36,7 @@ import type { AuthzDbAdapter } from "@dune/core/auth/authz-adapter-db"; import { initAdminContext } from "./src/admin/context.ts"; import type { AdminContext } from "./src/admin/context.ts"; import { createBlockEditorPlugin } from "./src/admin/block-editor-plugin.tsx"; -import { getDuneAdminIslands, mountDuneAdmin } from "./src/admin/mount.ts"; -import { sectionRegistry } from "@dune/core/sections"; -import type { SectionRegistry } from "@dune/core/sections"; +import { getDuneAdminIslands, mountAdminPanel } from "./src/admin/mount.ts"; import * as corePlugins from "@dune/core/plugins"; import denoJson from "./deno.json" with { type: "json" }; @@ -143,7 +141,7 @@ export function createAdminPlugin( })); }, - async mount({ app, bootstrap, services, capabilities }: MountApi) { + async mount({ app, runtime, services, capabilities }: MountApi) { const { logger } = services; if (!setupState) return; // admin disabled @@ -235,7 +233,7 @@ export function createAdminPlugin( return; } try { - await applyWorkflowTransition({ engine: bootstrap.engine, storage, config, hooks: bootstrap.hooks, workflow, services }, action.sourcePath, newStatus); + await applyWorkflowTransition({ engine: runtime.engine, storage, config, hooks: runtime.hooks, workflow, services }, action.sourcePath, newStatus); } catch (err) { logger.error("scheduler.action-failed", { actionId: action.id, @@ -250,16 +248,16 @@ export function createAdminPlugin( const collabManager = createCollabManager({ storage, - engine: bootstrap.engine, - history: bootstrap.history, + engine: runtime.engine, + history: runtime.history, contentDir: config.system.content.dir, security: services.security, - hooks: bootstrap.hooks, + hooks: runtime.hooks, }); // Core accepts and stores public form submissions; the admin only // manages them. An older core has no store to read. - const submissionManager = bootstrap.submissions; + const submissionManager = runtime.submissions; if (!submissionManager) { throw new Error( "@dune/plugin-admin 3.2+ needs a @dune/core that stores form submissions itself " + @@ -268,7 +266,7 @@ export function createAdminPlugin( } // The incoming webhook runs in core; keep the admin's audit trail. - bootstrap.hooks.on< + runtime.hooks.on< { action: "rebuild" | "purge-cache"; ip: string | null; userAgent: string | null } >("onSystemAction", ({ data }) => { void setupState?.auditLogger?.log({ @@ -294,8 +292,8 @@ export function createAdminPlugin( const adminContext: AdminContext = { services, - engine: bootstrap.engine, - search: bootstrap.search, + engine: runtime.engine, + search: runtime.search, storage, config, auth, @@ -306,32 +304,28 @@ export function createAdminPlugin( workflow, scheduler, executeScheduledAction, - history: bootstrap.history, + history: runtime.history, submissions: submissionManager, - flex: bootstrap.flexEngine, - hooks: bootstrap.hooks, + flex: runtime.flexEngine, + hooks: runtime.hooks, staging: stagingEngine, comments: commentManager, collab: collabManager, inlineEdit, contentEditor, - imageCache: bootstrap.imageCache, + imageCache: runtime.imageCache, auditLogger: setupState.auditLogger ?? undefined, - metrics: metricsEnabled ? bootstrap.metrics : undefined, + metrics: metricsEnabled ? runtime.metrics : undefined, mt: setupState.mt, rateLimitStore, pluginPages: pluginPages.length > 0 ? [...pluginPages] : undefined, pluginRoutes: pluginRoutes.length > 0 ? [...pluginRoutes] : undefined, // Created per serving process by `dune serve`, after mount(): read lazily. get jobScheduler() { - return bootstrap.jobScheduler; + return runtime.jobs; }, - authz: bootstrap.authz, - // Cores older than 0.31 don't expose `sections` on BootstrapResult; - // fall back to this package's own module singleton, which resolves to - // the host's copy anyway once the @0 range unifies the two. - sections: (bootstrap as unknown as { sections?: SectionRegistry }).sections ?? - sectionRegistry, + authz: runtime.authz, + sections: runtime.sections, }; // Keep the singleton for single-site serve paths (serve.ts, dev.ts). @@ -358,7 +352,7 @@ export function createAdminPlugin( // ── authz tuple bootstrap ───────────────────────────────────────────────── // Runs after core's env bootstrap so a freshly-created first admin's // tuple gets registered too, instead of being locked out on first run. - if (bootstrap.authz && bootstrap.authzAdapter) { + if (runtime.authz) { try { // users.list() returns every account in the unified store // (data/users/) — not just admin-tier ones once public site @@ -379,7 +373,7 @@ export function createAdminPlugin( } // ── Mount admin Fresh routes ────────────────────────────────────────────── - await mountDuneAdmin(app, bootstrap, adminContext); + mountAdminPanel(app, runtime, adminContext); }, }; } diff --git a/src/admin/mount.ts b/src/admin/mount.ts index 0f9d20e..79baa1f 100644 --- a/src/admin/mount.ts +++ b/src/admin/mount.ts @@ -54,7 +54,7 @@ import { * registration for the same bootstrap a no-op instead of stacking a * duplicate middleware. */ -const contextMiddlewareRegisteredFor = new WeakSet(); +const contextMiddlewareRegisteredFor = new WeakSet(); /** * Register the `ctx.state.adminContext = …` middleware every admin route @@ -69,7 +69,8 @@ const contextMiddlewareRegisteredFor = new WeakSet(); export function registerAdminContextMiddleware( // deno-lint-ignore no-explicit-any app: App, - ctx: BootstrapResult, + /** Any object identifying the site (its runtime or bootstrap result): one registration per object. */ + ctx: object, getAdminContext: () => import("./context.ts").AdminContext | null, ): void { if (contextMiddlewareRegisteredFor.has(ctx)) return; @@ -224,7 +225,45 @@ export async function mountDuneAdmin( adminCtxOverride?: import("./context.ts").AdminContext, ): Promise { const { config } = ctx; - const adminContext = adminCtxOverride ?? currentAdminContextOrNull(); + const adminPrefix = config.admin?.path ?? "/admin"; + mountAdminPanel(app, ctx, adminCtxOverride ?? currentAdminContextOrNull()); + + // ── Plugin public routes ──────────────────────────────────────────────────── + // @dune/core's own createDuneApp() registers these itself now (it collects + // BootstrapResult.pluginPublicRoutes in bootstrap.ts and wires them before + // its content catch-all) — this call only matters for the headless-mode + // path this file's own module docstring documents, where a developer calls + // bootstrap() + mountDuneAdmin() directly and never goes through + // createDuneApp() at all. Delegating to the same core function (rather than + // re-implementing the validation/shadowing logic here) keeps exactly one + // place that decides what a plugin route is allowed to do. + registerPluginPublicRoutes(app, ctx, { adminPrefix }); + + // ── Public form / webhook API ─────────────────────────────────────────────── + // Served by core, with or without the admin panel. createDuneApp() already + // registers them, so this only matters for the headless path above, and it + // is a no-op when they are already there. + registerFormRoutes(app, ctx); +} + +/** + * Mount the admin panel itself: the per-site context middleware, the admin + * routes under the admin prefix, and the plugins' `admin.pages` / `admin.routes`. + * It needs only the loaded config, so `mount()` calls it with the plugin + * runtime and `mountDuneAdmin()` (headless mode) with a full bootstrap result. + * It registers neither public plugin routes nor the form endpoints: core's + * `createDuneApp()` owns those. + * + * @param site - Identifies the site; a second mount for the same object does not stack a second context middleware. + */ +export function mountAdminPanel( + // deno-lint-ignore no-explicit-any + app: App, + site: { config: import("@dune/core/config").DuneConfig }, + adminContext: import("./context.ts").AdminContext | null, +): void { + const { config } = site; + const ctx = site; const adminPrefix = config.admin?.path ?? "/admin"; // ── Admin panel ───────────────────────────────────────────────────────────── @@ -256,23 +295,6 @@ export async function mountDuneAdmin( mountPluginAdminPages(app, adminPrefix, adminContext.pluginPages, adminContext.services.logger); } } - - // ── Plugin public routes ──────────────────────────────────────────────────── - // @dune/core's own createDuneApp() registers these itself now (it collects - // BootstrapResult.pluginPublicRoutes in bootstrap.ts and wires them before - // its content catch-all) — this call only matters for the headless-mode - // path this file's own module docstring documents, where a developer calls - // bootstrap() + mountDuneAdmin() directly and never goes through - // createDuneApp() at all. Delegating to the same core function (rather than - // re-implementing the validation/shadowing logic here) keeps exactly one - // place that decides what a plugin route is allowed to do. - registerPluginPublicRoutes(app, ctx, { adminPrefix }); - - // ── Public form / webhook API ─────────────────────────────────────────────── - // Served by core, with or without the admin panel. createDuneApp() already - // registers them, so this only matters for the headless path above, and it - // is a no-op when they are already there. - registerFormRoutes(app, ctx); } /** From 5310522800be97400b478c35208fff0d83312895 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 21:35:12 +0100 Subject: [PATCH 18/39] feat(admin): narrow the contract's PluginApp to Fresh's App (#97) mount() casts app to Fresh's App (the admin uses fsRoutes, layout, route) and the block editor declares a FreshContext handler. deno task check now also type-checks mod.ts. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 5 +++++ deno.json | 2 +- mod.ts | 6 +++++- src/admin/block-editor-plugin.tsx | 6 +++++- 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dd689c..45f0def 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,11 @@ follows [Semantic Versioning](https://semver.org). `admin.routes`) and no longer registers public plugin routes or form endpoints a second time: core's `createDuneApp()` owns those. The headless `mountDuneAdmin(app, bootstrapResult)` is unchanged. Needs `@dune/core` 0.36. +- **Narrows the contract's `PluginApp` to Fresh's `App` itself (#97).** Core's contract no longer names Fresh + types (`MountApi.app` is a `PluginApp`, handlers get a `PluginRouteContext`). The admin uses Fresh-only + features (`fsRoutes`, `layout`, `route`), so `mount()` casts `app` to Fresh's `App` and the block editor + declares `pageEditorHandler(ctx: FreshContext)`. No behaviour change. `deno task check` now also + type-checks `mod.ts`, which it silently skipped. ### Added diff --git a/deno.json b/deno.json index d5bbf5f..1b12b19 100644 --- a/deno.json +++ b/deno.json @@ -45,7 +45,7 @@ }, "tasks": { "test": "deno test -A tests/", - "check": "deno lint src/ && deno check src/**/*.ts src/**/*.tsx", + "check": "deno lint src/ && deno check mod.ts src/**/*.ts src/**/*.tsx", "test:e2e": "deno run --allow-all --unstable-kv npm:playwright test --config playwright.config.ts", "test:e2e:install": "deno run --allow-all npm:playwright install chromium", "gen:admin-manifest": "deno run --allow-read --allow-write scripts/generate-admin-manifest.ts", diff --git a/mod.ts b/mod.ts index ff56a2d..76f70cd 100644 --- a/mod.ts +++ b/mod.ts @@ -36,6 +36,7 @@ import type { AuthzDbAdapter } from "@dune/core/auth/authz-adapter-db"; import { initAdminContext } from "./src/admin/context.ts"; import type { AdminContext } from "./src/admin/context.ts"; import { createBlockEditorPlugin } from "./src/admin/block-editor-plugin.tsx"; +import type { App } from "fresh"; import { getDuneAdminIslands, mountAdminPanel } from "./src/admin/mount.ts"; import * as corePlugins from "@dune/core/plugins"; import denoJson from "./deno.json" with { type: "json" }; @@ -373,7 +374,10 @@ export function createAdminPlugin( } // ── Mount admin Fresh routes ────────────────────────────────────────────── - mountAdminPanel(app, runtime, adminContext); + // The admin uses Fresh-only features (fsRoutes, layout, route), so it depends on Fresh + // and narrows the contract's PluginApp; core always passes the real Fresh App. + // deno-lint-ignore no-explicit-any + mountAdminPanel(app as unknown as App, runtime, adminContext); }, }; } diff --git a/src/admin/block-editor-plugin.tsx b/src/admin/block-editor-plugin.tsx index 66bbfb0..a166a4b 100644 --- a/src/admin/block-editor-plugin.tsx +++ b/src/admin/block-editor-plugin.tsx @@ -9,6 +9,7 @@ */ import type { ContentEditorPlugin } from "@dune/core/hooks"; +import type { FreshContext } from "fresh"; import PageEditor from "./islands/PageEditor.tsx"; function PageEditorRoute( @@ -28,7 +29,10 @@ function PageEditorRoute( export function createBlockEditorPlugin(): ContentEditorPlugin { return { - pageEditorHandler(ctx) { + // A full Fresh context (ctx.render): the contract's PluginRouteContext is narrowed here, + // which the contract allows because this plugin depends on Fresh itself. + // deno-lint-ignore no-explicit-any + pageEditorHandler(ctx: FreshContext) { const { engine, prefix } = ctx.state.adminContext; const pagePath = ctx.url.searchParams.get("path"); if (!pagePath) { From 5023d56cec34702e63a62177d306d5eb6e70164b Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 21:58:17 +0100 Subject: [PATCH 19/39] feat(admin)!: stop registering plugin public routes and form endpoints; drop the createSubmissionManager re-export (#97, slice 4) Core's createDuneApp() owns both registrations (the admin's second call was a no-op), and the submission store is PluginRuntime.submissions. Lets core stop exporting registerPluginPublicRoutes, registerFormRoutes and createSubmissionManager. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 6 ++++++ src/admin/mod.ts | 1 - src/admin/mount.ts | 35 +++++++---------------------------- src/admin/submissions.ts | 4 ++-- 4 files changed, 15 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45f0def..2201433 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,12 @@ follows [Semantic Versioning](https://semver.org). features (`fsRoutes`, `layout`, `route`), so `mount()` casts `app` to Fresh's `App` and the block editor declares `pageEditorHandler(ctx: FreshContext)`. No behaviour change. `deno task check` now also type-checks `mod.ts`, which it silently skipped. +- **`mountDuneAdmin()` no longer registers plugin public routes or the form endpoints, and the + `createSubmissionManager` re-export is gone (#97, slice 4).** Both were core's own work: `createDuneApp()` + registers plugins' `publicRoutes` and `/api/forms/*`, `/api/webhook/incoming` itself (the admin's second call + was a no-op there), and the submission store is `PluginRuntime.submissions`. The admin now imports neither + `registerPluginPublicRoutes` nor `registerFormRoutes`, which lets core stop exporting them. `mountDuneAdmin()` + now mounts only the panel. The `Submission*` types are still re-exported. ### Added diff --git a/src/admin/mod.ts b/src/admin/mod.ts index 43a9ef7..fd52666 100644 --- a/src/admin/mod.ts +++ b/src/admin/mod.ts @@ -39,7 +39,6 @@ export type { export { toUserInfo } from "./types.ts"; // Submissions -export { createSubmissionManager } from "./submissions.ts"; export type { Submission, SubmissionStatus, diff --git a/src/admin/mount.ts b/src/admin/mount.ts index 79baa1f..ef2c943 100644 --- a/src/admin/mount.ts +++ b/src/admin/mount.ts @@ -36,8 +36,6 @@ // deno-lint-ignore no-explicit-any import type { App, FreshContext, Middleware } from "fresh"; import type { BootstrapResult } from "@dune/core/bootstrap"; -import { registerPluginPublicRoutes } from "@dune/core/fresh-app"; -import { registerFormRoutes } from "@dune/core/forms"; import { checkPermission, type GuardedHandler, withGuards } from "./routes/api/_utils.ts"; import { getAdminContext } from "./context.ts"; import type { AdminPermission, AdminState } from "./types.ts"; @@ -199,16 +197,16 @@ function currentAdminContextOrNull(): import("./context.ts").AdminContext | null } /** - * Mount Dune's admin panel and public API routes onto an existing Fresh app. + * Mount Dune's admin panel onto an existing Fresh app. * * Registers: - * - Per-site admin context middleware (required for admin route handlers) — - * a no-op here if `mountDuneAdminEarly()` already registered it (see - * `contextMiddlewareRegisteredFor`). + * - Per-site admin context middleware (required for admin route handlers) * - Admin file-system routes under `adminPrefix` (default `/admin`) - * - Plugin admin pages (programmatic routes registered by plugins) - * - Plugin public routes (registered by plugins) - * - Public form and webhook API endpoints + * - The plugins' `admin.pages` and `admin.routes` + * + * It does not register plugins' `publicRoutes` or the public form and webhook + * endpoints: `createDuneApp()` in `@dune/core` owns those, and no longer + * exports the functions that registered them. * * @param app - The Fresh App instance to mount onto. * @param ctx - A fully-bootstrapped BootstrapResult from `bootstrap()`. @@ -224,26 +222,7 @@ export async function mountDuneAdmin( */ adminCtxOverride?: import("./context.ts").AdminContext, ): Promise { - const { config } = ctx; - const adminPrefix = config.admin?.path ?? "/admin"; mountAdminPanel(app, ctx, adminCtxOverride ?? currentAdminContextOrNull()); - - // ── Plugin public routes ──────────────────────────────────────────────────── - // @dune/core's own createDuneApp() registers these itself now (it collects - // BootstrapResult.pluginPublicRoutes in bootstrap.ts and wires them before - // its content catch-all) — this call only matters for the headless-mode - // path this file's own module docstring documents, where a developer calls - // bootstrap() + mountDuneAdmin() directly and never goes through - // createDuneApp() at all. Delegating to the same core function (rather than - // re-implementing the validation/shadowing logic here) keeps exactly one - // place that decides what a plugin route is allowed to do. - registerPluginPublicRoutes(app, ctx, { adminPrefix }); - - // ── Public form / webhook API ─────────────────────────────────────────────── - // Served by core, with or without the admin panel. createDuneApp() already - // registers them, so this only matters for the headless path above, and it - // is a no-op when they are already there. - registerFormRoutes(app, ctx); } /** diff --git a/src/admin/submissions.ts b/src/admin/submissions.ts index b8ff188..f03e185 100644 --- a/src/admin/submissions.ts +++ b/src/admin/submissions.ts @@ -3,10 +3,10 @@ * * The store and its types live in `@dune/core/forms` — core accepts public * form submissions itself, so a site works without this plugin. This module - * re-exports them for the submissions UI and for existing importers. + * re-exports the types for the submissions UI. The manager itself is + * `PluginRuntime.submissions`; there is no factory to import. */ -export { createSubmissionManager } from "@dune/core/forms"; export type { Submission, SubmissionFile, From c8cedbd052c5a0095ba4c866755b0df923edef46 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Thu, 5 Nov 2026 22:20:49 +0100 Subject: [PATCH 20/39] feat(admin)!: remove the LDAP and SAML provider stubs (#97) Both authenticate() implementations threw, and mount() refused ldap/saml config anyway. Custom providers still go through the authProvider plugin option. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 10 +++ mod.ts | 8 +-- src/admin/auth/ldap-provider.ts | 99 -------------------------- src/admin/auth/provider.ts | 88 +---------------------- src/admin/auth/saml-provider.ts | 122 -------------------------------- src/admin/mod.ts | 5 -- 6 files changed, 15 insertions(+), 317 deletions(-) delete mode 100644 src/admin/auth/ldap-provider.ts delete mode 100644 src/admin/auth/saml-provider.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2201433..55dd784 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,16 @@ follows [Semantic Versioning](https://semver.org). `onPageMove` is also a valid webhook event. Needs `@dune/core` with `onPageMove` (0.36). `tests/admin/page_events_test.ts` drives create, update, reorder, delete and restore through the real routes. +### Removed — BREAKING + +- **The LDAP and SAML auth providers (#97).** `LdapAuthProvider` and `SamlAuthProvider` were stubs whose + `authenticate()` threw, and `mount()` refused `auth_provider.type: ldap | saml` anyway. They are deleted, with + `LdapProviderConfig`, `SamlProviderConfig` and the admin's `AuthProviderConfig` union (core's is now just + `{ type: "local" }`). `AuthProvider.type` is a plain string. To use another identity source, pass your own + provider through the `authProvider` plugin option; the `AuthProvider` interface (`authenticate`, + `initiateLogin`, `handleCallback`) is unchanged. An unknown `auth_provider.type` now fails with a message + saying so. + ### Changed — BREAKING - **`mount()` reads the site from `MountApi.runtime`, not `MountApi.bootstrap` (#97).** Core's `mount()` diff --git a/mod.ts b/mod.ts index 76f70cd..965064e 100644 --- a/mod.ts +++ b/mod.ts @@ -196,14 +196,10 @@ export function createAdminPlugin( const provCfg = config.admin?.auth_provider; if (!provCfg || provCfg.type === "local") { authProvider = new LocalAuthProvider(users, services.passwords); - } else if (provCfg.type === "ldap" || provCfg.type === "saml") { - throw new Error( - `[dune] auth_provider.type "${provCfg.type}" is not implemented. ` + - `Set auth_provider.type to "local" or remove the section.`, - ); } else { throw new Error( - `[dune] auth_provider.type "${(provCfg as { type?: string }).type ?? ""}" is not recognized.`, + `[dune] auth_provider.type "${(provCfg as { type?: string }).type ?? ""}" is not supported. ` + + `Only "local" is built in; inject your own provider with the \`authProvider\` plugin option.`, ); } } diff --git a/src/admin/auth/ldap-provider.ts b/src/admin/auth/ldap-provider.ts deleted file mode 100644 index 68401c8..0000000 --- a/src/admin/auth/ldap-provider.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * LDAP authentication provider. - * - * ## Status: STUB — not yet implemented - * - * This class compiles and satisfies the AuthProvider interface but `authenticate()` - * throws a NotImplemented error. To implement LDAP authentication, replace the body of - * `authenticate()` with a proper LDAP bind flow using an LDAP client library. - * - * ## Suggested implementation using ldapts (npm:ldapts): - * - * ```typescript - * import { Client } from "npm:ldapts"; - * - * async authenticate(creds: AuthCredentials): Promise { - * const usernameAttr = this.config.username_attr ?? "uid"; - * const emailAttr = this.config.email_attr ?? "mail"; - * const nameAttr = this.config.name_attr ?? "cn"; - * const client = new Client({ url: this.config.url }); - * try { - * if (this.config.bind_dn) { - * // Service account bind, then search for the user entry - * const bindPw = expandEnv(this.config.bind_password ?? ""); - * await client.bind(this.config.bind_dn, bindPw); - * const { searchEntries } = await client.search(this.config.base_dn, { - * // IMPORTANT: escape username to prevent LDAP injection (CWE-90) - * // import { escape as ldapEscape } from "npm:ldapts"; - * filter: `(${usernameAttr}=${ldapEscape(creds.username)})`, - * attributes: ["dn", emailAttr, nameAttr, "memberOf"], - * }); - * if (!searchEntries.length) return null; - * const entry = searchEntries[0]; - * // Rebind as the located user to verify their password - * await client.bind(entry.dn as string, creds.password); - * const role = mapRole(entry["memberOf"] as string | string[] | undefined, this.config.role_map) - * ?? this.config.default_role - * ?? "author"; - * return { - * externalId: entry.dn as string, - * username: creds.username, - * email: entry[emailAttr] as string | undefined, - * name: entry[nameAttr] as string | undefined, - * roles: [role], - * }; - * } else { - * // Direct bind: construct user DN from template - * const userDn = `${usernameAttr}=${creds.username},${this.config.base_dn}`; - * await client.bind(userDn, creds.password); - * return { - * externalId: userDn, - * username: creds.username, - * roles: [this.config.default_role ?? "author"], - * }; - * } - * } catch { - * return null; // Bind failure = bad credentials - * } finally { - * await client.unbind(); - * } - * } - * - * // Expand "$ENV_VAR" references in config values - * function expandEnv(value: string): string { - * return value.replace(/^\$([A-Z_][A-Z0-9_]*)$/, (_, name) => Deno.env.get(name) ?? ""); - * } - * - * // Map LDAP group membership to a Dune role (first match wins) - * function mapRole( - * memberOf: string | string[] | undefined, - * roleMap: Array<{ group: string; role: Role }> | undefined, - * ): Role | undefined { - * if (!roleMap || !memberOf) return undefined; - * const groups = Array.isArray(memberOf) ? memberOf : [memberOf]; - * for (const { group, role } of roleMap) { - * if (groups.includes(group)) return role; - * } - * return undefined; - * } - * ``` - * - * ## Required deno.json import map entry: - * ```json - * { "npm:ldapts": "npm:ldapts@^4" } - * ``` - */ - -import type { AuthProvider, AuthCredentials, AuthProviderUser, LdapProviderConfig } from "./provider.ts"; - -export class LdapAuthProvider implements AuthProvider { - readonly type = "ldap" as const; - - constructor(private config: LdapProviderConfig) {} - - async authenticate(_creds: AuthCredentials): Promise { - throw new Error( - "LdapAuthProvider is a stub — see src/admin/auth/ldap-provider.ts for the implementation guide", - ); - } -} diff --git a/src/admin/auth/provider.ts b/src/admin/auth/provider.ts index 91bb467..2db260d 100644 --- a/src/admin/auth/provider.ts +++ b/src/admin/auth/provider.ts @@ -7,7 +7,6 @@ * @module */ -import type { Role } from "@dune/core/config"; /** * User attributes returned by an external auth provider after successful authentication. @@ -41,13 +40,14 @@ export interface AuthCredentials { /** * An authentication provider handles identity verification. - * Multiple provider types are supported; the session and user-provisioning layer is shared. + * The built-in provider is `local`; any other (LDAP, SAML, OIDC, ...) is injected through + * the plugin's `authProvider` option. The session and user-provisioning layer is shared. * * Username+password flows (local, LDAP) use `authenticate()`. * Redirect-based flows (SAML, OIDC) use `initiateLogin()` + `handleCallback()`. */ export interface AuthProvider { - readonly type: "local" | "ldap" | "saml" | "oidc"; + readonly type: string; /** * Verify credentials and return provider user attributes. @@ -69,85 +69,3 @@ export interface AuthProvider { handleCallback?(req: Request): Promise; } -/** Configuration union for all supported provider types. */ -export type AuthProviderConfig = - | { type: "local" } - | LdapProviderConfig - | SamlProviderConfig; - -/** LDAP authentication provider configuration. */ -export interface LdapProviderConfig { - type: "ldap"; - /** - * LDAP server URL. - * @example "ldap://ldap.example.com:389" - * @example "ldaps://ldap.example.com:636" - */ - url: string; - /** - * Base DN for user search. - * @example "ou=users,dc=example,dc=com" - */ - base_dn: string; - /** - * LDAP attribute containing the login name. - * Default: "sAMAccountName" (Active Directory) or "uid" (OpenLDAP). - */ - username_attr?: string; - /** - * Optional bind DN for a service account used to search the directory. - * If omitted, Dune attempts a direct bind using the user's constructed DN. - */ - bind_dn?: string; - /** - * Service account password. Supports "$ENV_VAR" expansion. - * @example "$LDAP_BIND_PASSWORD" - */ - bind_password?: string; - /** LDAP attribute to use as email. Default: "mail" */ - email_attr?: string; - /** LDAP attribute to use as display name. Default: "cn" */ - name_attr?: string; - /** - * Map LDAP group membership to Dune roles. First match wins. - * @example - * ```yaml - * roleMap: - * - group: "cn=cms-admins,ou=groups,dc=example,dc=com" - * role: "admin" - * - group: "cn=cms-editors,ou=groups,dc=example,dc=com" - * role: "editor" - * ``` - */ - role_map?: Array<{ group: string; role: Role }>; - /** Default role for authenticated users not in any mapped group. Default: "author" */ - default_role?: Role; -} - -/** SAML 2.0 authentication provider configuration. */ -export interface SamlProviderConfig { - type: "saml"; - /** Your application's Entity ID (SP entity ID). */ - entity_id: string; - /** - * Assertion Consumer Service URL — where the IdP POSTs the assertion after authentication. - * @example "https://example.com/admin/saml/acs" - */ - acs_url: string; - /** Identity provider metadata as a URL (fetched at startup) or inline XML. */ - idp_metadata: string; - /** SAML attribute containing the username. Default: "NameID" */ - username_attr?: string; - /** SAML attribute containing the email. Default: "email" or "mail" */ - email_attr?: string; - /** SAML attribute containing the display name. Default: "displayName" */ - name_attr?: string; - /** - * Map IdP group/role attribute values to Dune roles. First match wins. - */ - role_map?: Array<{ value: string; role: Role }>; - /** SAML attribute carrying the role or group value. Default: "role" or "groups" */ - role_attr?: string; - /** Default role for users with no matching roleMap entry. Default: "author" */ - default_role?: Role; -} diff --git a/src/admin/auth/saml-provider.ts b/src/admin/auth/saml-provider.ts deleted file mode 100644 index 107c9be..0000000 --- a/src/admin/auth/saml-provider.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * SAML 2.0 authentication provider. - * - * ## Status: STUB — not yet implemented - * - * `authenticate()` is not applicable for SAML (no direct credential flow). - * SAML uses `initiateLogin()` to start the SP-initiated SSO redirect and - * `handleCallback()` to process the IdP's POST response to the ACS endpoint. - * - * ## Suggested implementation using samlify (npm:samlify): - * - * ```typescript - * import * as saml from "npm:samlify"; - * - * // Build SP + IdP instances (ideally cached at construction time) - * private buildSp() { - * return saml.ServiceProvider({ - * entityID: this.config.entity_id, - * assertionConsumerService: [ - * { Binding: saml.Constants.namespace.binding.post, Location: this.config.acs_url }, - * ], - * }); - * } - * - * private buildIdp() { - * return saml.IdentityProvider({ metadata: this.config.idp_metadata }); - * } - * - * async initiateLogin(_req: Request): Promise { - * const sp = this.buildSp(); - * const idp = this.buildIdp(); - * const { context } = sp.createLoginRequest(idp, "redirect"); - * return context; // redirect URL with SAMLRequest query param - * } - * - * async handleCallback(req: Request): Promise { - * const body = await req.formData(); - * const samlResponse = body.get("SAMLResponse") as string | null; - * if (!samlResponse) return null; - * - * const sp = this.buildSp(); - * const idp = this.buildIdp(); - * try { - * const { extract } = await sp.parseLoginResponse(idp, "post", { - * body: { SAMLResponse: samlResponse }, - * }); - * - * // SECURITY: before trusting extract.nameID and extract.attributes, verify: - * // 1. InResponseTo matches the stored AuthnRequest ID (replay prevention) - * // 2. Audience restriction includes this SP's entity ID - * // 3. NotBefore / NotOnOrAfter time bounds are valid - * // samlify validates the signature but NOT these by default — check your version. - * - * const nameId: string = extract.nameID; - * const attrs = extract.attributes ?? {}; - * const usernameAttr = this.config.username_attr ?? "NameID"; - * const emailAttr = this.config.email_attr ?? "email"; - * const nameAttr = this.config.name_attr ?? "displayName"; - * const roleAttr = this.config.role_attr ?? "role"; - * const role = mapRole(attrs[roleAttr], this.config.role_map) - * ?? this.config.default_role - * ?? "author"; - * return { - * externalId: nameId, - * username: (attrs[usernameAttr] as string) ?? nameId, - * email: attrs[emailAttr] as string | undefined, - * name: attrs[nameAttr] as string | undefined, - * roles: [role], - * }; - * } catch { - * return null; // Signature verification or parsing failure - * } - * } - * - * function mapRole( - * value: string | string[] | undefined, - * roleMap: Array<{ value: string; role: Role }> | undefined, - * ): Role | undefined { - * if (!roleMap || value === undefined) return undefined; - * const values = Array.isArray(value) ? value : [value]; - * for (const { value: v, role } of roleMap) { - * if (values.includes(v)) return role; - * } - * return undefined; - * } - * ``` - * - * ## Required deno.json import map entry: - * ```json - * { "npm:samlify": "npm:samlify@^2" } - * ``` - * - * ## Required route registration in server.ts: - * Register a POST /admin/saml/acs route that calls `provider.handleCallback(req)` - * and then calls `findOrProvisionUser()` followed by the session creation flow. - */ - -import type { AuthProvider, AuthCredentials, AuthProviderUser, SamlProviderConfig } from "./provider.ts"; - -export class SamlAuthProvider implements AuthProvider { - readonly type = "saml" as const; - - constructor(private config: SamlProviderConfig) {} - - async authenticate(_creds: AuthCredentials): Promise { - throw new Error( - "SamlAuthProvider uses initiateLogin()/handleCallback() — direct credential authentication is not supported for SAML", - ); - } - - async initiateLogin(_req: Request): Promise { - throw new Error( - "SamlAuthProvider is a stub — see src/admin/auth/saml-provider.ts for the implementation guide", - ); - } - - async handleCallback(_req: Request): Promise { - throw new Error( - "SamlAuthProvider is a stub — see src/admin/auth/saml-provider.ts for the implementation guide", - ); - } -} diff --git a/src/admin/mod.ts b/src/admin/mod.ts index fd52666..876d911 100644 --- a/src/admin/mod.ts +++ b/src/admin/mod.ts @@ -18,13 +18,8 @@ export type { AuthProvider, AuthProviderUser, AuthCredentials, - AuthProviderConfig, - LdapProviderConfig, - SamlProviderConfig, } from "./auth/provider.ts"; export { LocalAuthProvider } from "./auth/local-provider.ts"; -export { LdapAuthProvider } from "./auth/ldap-provider.ts"; -export { SamlAuthProvider } from "./auth/saml-provider.ts"; export { findOrProvisionUser } from "./auth/provisioner.ts"; export type { From 11e886089a122331d5ebb4882eef53cb3ac9d258 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 08:19:35 +0100 Subject: [PATCH 21/39] refactor: drop the unused imageCache field, PluginRuntime no longer carries it Co-Authored-By: Claude Sonnet 5.5 --- mod.ts | 1 - src/admin/context.ts | 2 -- 2 files changed, 3 deletions(-) diff --git a/mod.ts b/mod.ts index 965064e..dcc3b50 100644 --- a/mod.ts +++ b/mod.ts @@ -310,7 +310,6 @@ export function createAdminPlugin( collab: collabManager, inlineEdit, contentEditor, - imageCache: runtime.imageCache, auditLogger: setupState.auditLogger ?? undefined, metrics: metricsEnabled ? runtime.metrics : undefined, mt: setupState.mt, diff --git a/src/admin/context.ts b/src/admin/context.ts index 80b47a7..7925887 100644 --- a/src/admin/context.ts +++ b/src/admin/context.ts @@ -23,7 +23,6 @@ import type { StagingEngine } from "@dune/core/staging"; import type { CommentManager } from "./comments.ts"; import type { CollabManager } from "../collab/mod.ts"; import type { InlineEditManager } from "@dune/core/inline-edit"; -import type { ImageCache } from "@dune/core/images"; import type { AuditLogger } from "@dune/core/audit"; import type { MetricsCollector } from "@dune/core/metrics"; import type { MachineTranslator } from "@dune/core/mt"; @@ -71,7 +70,6 @@ export interface AdminContext { inlineEdit?: InlineEditManager; /** Custom page editor, provided by a plugin as the `editor.content` capability. Replaces the built-in block editor. */ contentEditor?: ContentEditorPlugin; - imageCache?: ImageCache; auditLogger?: AuditLogger; metrics?: MetricsCollector; mt?: MachineTranslator | null; From 0c0665940ef2f3ec82f83ce123bfbb3f472f6981 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 23:03:24 +0100 Subject: [PATCH 22/39] feat(admin)!: read the runtime directory from services.paths, not admin.runtime_dir Core moved runtime_dir and max_revisions to system.* because its own history, job state and analytics use them. The admin takes the resolved value from services.paths.runtimeDir (webhook logs, email preview, CSRF secret, workflow and page events). /api/introspect reports pageCacheEnabled: core no longer has system.cache. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 10 ++++++++++ mod.ts | 5 ++--- src/admin/auth/csrf.ts | 2 +- src/admin/comments.ts | 4 ++-- src/admin/page-events.ts | 2 +- src/admin/routes/api/email-preview.ts | 2 +- src/admin/routes/api/email-preview/[id].ts | 2 +- src/admin/routes/api/introspect.ts | 3 +-- src/admin/routes/api/pages/[...path]/index.ts | 4 ++-- src/admin/routes/api/pages/index.ts | 2 +- src/admin/routes/api/webhooks/deliveries.ts | 3 +-- src/admin/routes/email-preview/index.tsx | 2 +- src/admin/webhooks.ts | 4 ++-- src/admin/workflow-actions.ts | 2 +- tests/admin/webhook_deliveries_test.ts | 3 ++- tests/admin/workflow_actions_test.ts | 2 +- 16 files changed, 30 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55dd784..62d9dcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,16 @@ follows [Semantic Versioning](https://semver.org). `onPageMove` is also a valid webhook event. Needs `@dune/core` with `onPageMove` (0.36). `tests/admin/page_events_test.ts` drives create, update, reorder, delete and restore through the real routes. +### Changed — BREAKING (admin config move) + +- **The runtime directory comes from core, not from `admin.runtime_dir`.** `runtime_dir` (and + `max_revisions`) are `system.runtime_dir` / `system.max_revisions` in `@dune/core` 0.36: core's history, + job state, analytics and public sessions use them too. The admin reads `services.paths.runtimeDir` everywhere + it used `config.admin.runtime_dir` (webhook delivery logs, email preview, CSRF secret, workflow and page + events), so a site that sets `runtime_dir` keeps one directory for core and the admin. Needs `@dune/core` 0.36. +- **`/api/introspect` reports `pageCacheEnabled`** (from `system.page_cache`) instead of `cacheEnabled` and + `cacheDriver`: core 0.36 no longer has a `system.cache` block. + ### Removed — BREAKING - **The LDAP and SAML auth providers (#97).** `LdapAuthProvider` and `SamlAuthProvider` were stubs whose diff --git a/mod.ts b/mod.ts index dcc3b50..4af921a 100644 --- a/mod.ts +++ b/mod.ts @@ -109,7 +109,6 @@ export function createAdminPlugin( const adminCfg = config.admin ?? { path: "/admin", session_lifetime: 86400, - runtime_dir: ".dune/admin", enabled: true, }; if (adminCfg.enabled === false) { @@ -150,10 +149,10 @@ export function createAdminPlugin( const adminCfg = config.admin ?? { path: "/admin", session_lifetime: 86400, - runtime_dir: ".dune/admin", enabled: true, }; - const runtimeDir = adminCfg.runtime_dir ?? ".dune/admin"; + // `system.runtime_dir`, as core resolved it. + const runtimeDir = services.paths.runtimeDir; // `system.data_dir` — the same resolution core uses for the submissions it stores. const dataDir = services.paths.dataDir; diff --git a/src/admin/auth/csrf.ts b/src/admin/auth/csrf.ts index 0141486..6b1e73d 100644 --- a/src/admin/auth/csrf.ts +++ b/src/admin/auth/csrf.ts @@ -43,7 +43,7 @@ export function resolveCsrfSecret(state: AdminState | undefined): string | null const injected = state.adminContext.csrfSecret; if (typeof injected === "string" && injected.length > 0) return injected; - const runtimeDir = state.adminContext.config?.admin?.runtime_dir; + const runtimeDir = state.adminContext.services?.paths?.runtimeDir; if (typeof runtimeDir !== "string" || runtimeDir.length === 0) return null; return loadOrCreateCsrfSecret(runtimeDir); } diff --git a/src/admin/comments.ts b/src/admin/comments.ts index 385b916..332ac58 100644 --- a/src/admin/comments.ts +++ b/src/admin/comments.ts @@ -9,7 +9,7 @@ * data/comments/{encodedPath}.json → { comments: PageComment[] } * encodedPath: sourcePath with "/" → "__" and "." → "-dot-" * - * Mention read-state (inside admin.runtime_dir — gitignored, machine-local): + * Mention read-state (inside system.runtime_dir — gitignored, machine-local): * .dune/admin/mention-reads/{username}.json → { readIds: string[] } * * All public methods are async to allow future storage adapter swapping. @@ -65,7 +65,7 @@ interface MentionReadState { export interface CommentManagerOptions { /** admin.data_dir — git-tracked persistent storage */ dataDir: string; - /** admin.runtime_dir — ephemeral machine-local storage */ + /** system.runtime_dir — ephemeral machine-local storage */ runtimeDir: string; } diff --git a/src/admin/page-events.ts b/src/admin/page-events.ts index 31e27b3..b01e55d 100644 --- a/src/admin/page-events.ts +++ b/src/admin/page-events.ts @@ -33,7 +33,7 @@ export function announcePageEvent(ctx: FreshContext, e: PageEvent): config.admin?.webhooks ?? [], e.event, e.payload, - config.admin?.runtime_dir ?? ".dune/admin", + services.paths.runtimeDir, services.security, ); } diff --git a/src/admin/routes/api/email-preview.ts b/src/admin/routes/api/email-preview.ts index 51e4fc4..aa836a7 100644 --- a/src/admin/routes/api/email-preview.ts +++ b/src/admin/routes/api/email-preview.ts @@ -14,7 +14,7 @@ export const handler = { return json({ error: "Email preview is only available in development mode" }, 404); } - const runtimeDir = ctx.state.adminContext.config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; const devEmailDir = join(runtimeDir, "dev-email"); const entries: Record[] = []; diff --git a/src/admin/routes/api/email-preview/[id].ts b/src/admin/routes/api/email-preview/[id].ts index baef1a0..1852be3 100644 --- a/src/admin/routes/api/email-preview/[id].ts +++ b/src/admin/routes/api/email-preview/[id].ts @@ -19,7 +19,7 @@ export const handler = { return json({ error: "Invalid id" }, 400); } - const runtimeDir = ctx.state.adminContext.config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; const filePath = join(runtimeDir, "dev-email", `${id}.json`); try { diff --git a/src/admin/routes/api/introspect.ts b/src/admin/routes/api/introspect.ts index 7bc9fb3..cfba004 100644 --- a/src/admin/routes/api/introspect.ts +++ b/src/admin/routes/api/introspect.ts @@ -109,8 +109,7 @@ export const handler = { supported: config.system.languages?.supported ?? [], default: config.system.languages?.default ?? "en", }, - cacheEnabled: config.system.cache?.enabled ?? false, - cacheDriver: config.system.cache?.driver ?? "memory", + pageCacheEnabled: config.system.page_cache?.enabled ?? false, debugMode: config.system.debug ?? false, authMode: ((config as unknown as Record).auth as Record | undefined)?.mode ?? null, feedEnabled: config.site.feed?.enabled !== false, diff --git a/src/admin/routes/api/pages/[...path]/index.ts b/src/admin/routes/api/pages/[...path]/index.ts index 638de1c..5c9f8fd 100644 --- a/src/admin/routes/api/pages/[...path]/index.ts +++ b/src/admin/routes/api/pages/[...path]/index.ts @@ -130,7 +130,7 @@ export const handler = { await maybeGitCommit(filePath, pagePath, undefined, config); const webhookEndpoints = config.admin?.webhooks ?? []; - const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; if (hooks) hooks.fire("onPageUpdate", { sourcePath: page.sourcePath }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onPageUpdate", { sourcePath: page.sourcePath }, runtimeDir, ctx.state.adminContext.services.security); @@ -172,7 +172,7 @@ export const handler = { await engine.rebuild(); const webhookEndpoints = config.admin?.webhooks ?? []; - const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; if (hooks) hooks.fire("onPageDelete", { sourcePath: page.sourcePath }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onPageDelete", { sourcePath: page.sourcePath }, runtimeDir, ctx.state.adminContext.services.security); diff --git a/src/admin/routes/api/pages/index.ts b/src/admin/routes/api/pages/index.ts index 291490a..6fcd686 100644 --- a/src/admin/routes/api/pages/index.ts +++ b/src/admin/routes/api/pages/index.ts @@ -68,7 +68,7 @@ export const handler = { await engine.rebuild(); const webhookEndpoints = config.admin?.webhooks ?? []; - const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; if (hooks) hooks.fire("onPageCreate", { sourcePath: `${pagePath}/default${ext}`, title }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onPageCreate", { sourcePath: `${pagePath}/default${ext}`, title }, runtimeDir, ctx.state.adminContext.services.security); diff --git a/src/admin/routes/api/webhooks/deliveries.ts b/src/admin/routes/api/webhooks/deliveries.ts index 504e54e..1100bb0 100644 --- a/src/admin/routes/api/webhooks/deliveries.ts +++ b/src/admin/routes/api/webhooks/deliveries.ts @@ -32,9 +32,8 @@ export const handler = { return json({ error: "invalid path" }, 400); } - const { config } = ctx.state.adminContext; try { - const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; const logs = await listDeliveryLogs(runtimeDir, { sourcePath: path }); const items = view === "workflow" ? logs.map(toWorkflowDelivery) : logs; return json({ items, total: items.length, view }); diff --git a/src/admin/routes/email-preview/index.tsx b/src/admin/routes/email-preview/index.tsx index c25e7c5..7492b4d 100644 --- a/src/admin/routes/email-preview/index.tsx +++ b/src/admin/routes/email-preview/index.tsx @@ -116,7 +116,7 @@ export default function EmailPreviewPage({ data }: { data: PageData }) { export const handler = { async GET(ctx: FreshContext): Promise { const { prefix } = ctx.state.adminContext; - const runtimeDir = ctx.state.adminContext.config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; if (Deno.env.get("DUNE_ENV") !== "dev") { return ctx.render( diff --git a/src/admin/webhooks.ts b/src/admin/webhooks.ts index ab36545..3a2f814 100644 --- a/src/admin/webhooks.ts +++ b/src/admin/webhooks.ts @@ -268,7 +268,7 @@ async function deliverWithRetry( * @param endpoints - Configured webhook endpoints (from admin.webhooks) * @param event - The content event that occurred * @param payload - Event-specific data included in the POST body - * @param logDir - Base directory for delivery logs (admin.runtime_dir) + * @param logDir - Base directory for delivery logs (system.runtime_dir) * @param security - Core's security service (SSRF-guarded fetch) */ export function fireContentWebhooks( @@ -308,7 +308,7 @@ export interface ListDeliveryLogsOptions { * Reads logs from the last 7 days of log directories and returns up to `limit` * records. Silently returns an empty array if the log directory doesn't exist. * - * @param logDir - Base directory (admin.runtime_dir) + * @param logDir - Base directory (system.runtime_dir) * @param limitOrOpts - Maximum records, or `{ limit, sourcePath }` */ export async function listDeliveryLogs( diff --git a/src/admin/workflow-actions.ts b/src/admin/workflow-actions.ts index 1ee9606..84871a3 100644 --- a/src/admin/workflow-actions.ts +++ b/src/admin/workflow-actions.ts @@ -76,7 +76,7 @@ export async function applyWorkflowTransition( await engine.rebuild(); const webhookEndpoints = config.admin?.webhooks ?? []; - const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = services.paths.runtimeDir; if (hooks) hooks.fire("onWorkflowChange", { sourcePath, from: currentStatus, to: newStatus }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onWorkflowChange", { sourcePath, from: currentStatus, to: newStatus }, runtimeDir, services.security); diff --git a/tests/admin/webhook_deliveries_test.ts b/tests/admin/webhook_deliveries_test.ts index aa924c6..ef891d0 100644 --- a/tests/admin/webhook_deliveries_test.ts +++ b/tests/admin/webhook_deliveries_test.ts @@ -49,7 +49,8 @@ function makeCtx( state: { adminContext: { auditLogger: null, - config: { admin: { runtime_dir: opts.runtimeDir ?? ".dune/admin" } }, + config: {}, + services: { paths: { runtimeDir: opts.runtimeDir ?? ".dune/admin" } }, // Distinguishes which specific permission the mock holds — same // role this fixture's ROLE_PERMISSIONS-backed hasPermission() used // to play, sourced from authz.check() instead (removed in 3.0.0; diff --git a/tests/admin/workflow_actions_test.ts b/tests/admin/workflow_actions_test.ts index c6340ab..b491b96 100644 --- a/tests/admin/workflow_actions_test.ts +++ b/tests/admin/workflow_actions_test.ts @@ -48,7 +48,7 @@ function makePageIndex(overrides: Partial = {}): PageIndex { const CONFIG = { system: { content: { dir: "content" } }, - admin: { webhooks: [], runtime_dir: ".dune/admin" }, + admin: { webhooks: [] }, } as unknown as DuneConfig; function makeEngine(pages: PageIndex[]) { From 462fe1f5d452e4ce01b606b8413101f6c34364b6 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 23:19:33 +0100 Subject: [PATCH 23/39] feat(admin)!: provide the admin.prefix capability Core stops reading admin.path and learns where the admin is mounted from this slot. With the admin disabled nothing is provided and core treats /admin as an ordinary path. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 3 +++ mod.ts | 4 ++++ tests/admin/capabilities_e2e_test.ts | 30 ++++++++++++++++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62d9dcc..c262192 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ follows [Semantic Versioning](https://semver.org). job state, analytics and public sessions use them too. The admin reads `services.paths.runtimeDir` everywhere it used `config.admin.runtime_dir` (webhook delivery logs, email preview, CSRF secret, workflow and page events), so a site that sets `runtime_dir` keeps one directory for core and the admin. Needs `@dune/core` 0.36. +- **The admin provides the `admin.prefix` capability** (`{ prefix: admin.path ?? "/admin" }`), the way core learns + where the admin is mounted. Core no longer reads `admin.path` or assumes `/admin`: with no admin installed, `/admin` + is an ordinary content path. Needs `@dune/core` 0.36 (#123, step 2). - **`/api/introspect` reports `pageCacheEnabled`** (from `system.page_cache`) instead of `cacheEnabled` and `cacheDriver`: core 0.36 no longer has a `system.cache` block. diff --git a/mod.ts b/mod.ts index 4af921a..ea025b5 100644 --- a/mod.ts +++ b/mod.ts @@ -124,6 +124,10 @@ export function createAdminPlugin( // What this plugin provides to core. Core reads them at request time // (the admin bar, the inline-edit WebSocket, `dune serve`'s scheduler // loop); until mount() has built the context they report "nobody". + // Where this admin is mounted: core keeps these paths out of the page + // cache, the static build and the plugin-route namespace. The default + // `/admin` lives here, not in core. + api.capabilities.provide("admin.prefix", () => ({ prefix: adminCfg.path ?? "/admin" })); api.capabilities.provide("auth.session", () => ({ // The cookie the admin's auth middleware sets (see createAuthMiddleware). // Core uses it to decide whether to authenticate a public page and whether diff --git a/tests/admin/capabilities_e2e_test.ts b/tests/admin/capabilities_e2e_test.ts index 16085e0..d199bfc 100644 --- a/tests/admin/capabilities_e2e_test.ts +++ b/tests/admin/capabilities_e2e_test.ts @@ -44,6 +44,36 @@ Deno.test( }, ); +Deno.test( + "admin provides admin.prefix: /admin by default, admin.path when set", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + await withSite([], (ctx) => { + assertEquals(ctx.capabilities.get("admin.prefix"), { prefix: "/admin" }); + const providers = Object.fromEntries(ctx.capabilities.providers().map((p) => [p.name, p.provider])); + assertEquals(providers["admin.prefix"], "dune-admin"); + return Promise.resolve(); + }); + + const root = await Deno.makeTempDir({ prefix: "dune_admin_prefix_" }); + try { + await Deno.mkdir(join(root, "content", "01.home"), { recursive: true }); + await Deno.writeTextFile(join(root, "content", "01.home", "default.md"), "---\ntitle: Home\n---\n"); + await Deno.mkdir(join(root, "config"), { recursive: true }); + await Deno.writeTextFile(join(root, "config", "system.yaml"), "admin:\n path: /cms\n"); + const ctx = await bootstrap(root, {}); + const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); + assertEquals(ctx.capabilities.get("admin.prefix"), { prefix: "/cms" }); + // The panel answers at the provided prefix, and /admin is just a missing content page. + const handler = app.handler(); + assertEquals((await handler(new Request("http://localhost/admin/"))).status, 404); + assertEquals((await handler(new Request("http://localhost/cms/login"))).status !== 404, true); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } + }, +); + Deno.test( "admin mounts admin.pages contributed by another plugin, behind its login", { sanitizeOps: false, sanitizeResources: false }, From fda8ea3b18456099181566767e362e6a67c1ad83 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 23:48:08 +0100 Subject: [PATCH 24/39] feat(admin)!: own the audit trail and machine translation AuditLogger, the audit types and the DeepL/Google/LibreTranslate translators move here from @dune/core (the @dune/core/audit and @dune/core/mt entry points are gone). The admin builds both in setup(); translators take the fetch they use, and the admin passes services.security.safeFetch so core's SSRF policy still applies. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 10 ++ deno.json | 4 +- mod.ts | 11 ++- src/admin/context.ts | 4 +- src/admin/routes/api/_utils.ts | 2 +- src/admin/routes/api/audit.ts | 2 +- src/admin/routes/audit/index.tsx | 4 +- src/audit/logger.ts | 157 +++++++++++++++++++++++++++++++ src/audit/mod.ts | 24 +++++ src/audit/trail.ts | 37 ++++++++ src/audit/types.ts | 91 ++++++++++++++++++ src/mt/deepl.ts | 72 ++++++++++++++ src/mt/factory.ts | 41 ++++++++ src/mt/google.ts | 47 +++++++++ src/mt/libretranslate.ts | 69 ++++++++++++++ src/mt/mod.ts | 11 +++ src/mt/types.ts | 38 ++++++++ tests/audit_trail_test.ts | 47 +++++++++ tests/mt/ssrf_test.ts | 37 ++++++++ 19 files changed, 697 insertions(+), 11 deletions(-) create mode 100644 src/audit/logger.ts create mode 100644 src/audit/mod.ts create mode 100644 src/audit/trail.ts create mode 100644 src/audit/types.ts create mode 100644 src/mt/deepl.ts create mode 100644 src/mt/factory.ts create mode 100644 src/mt/google.ts create mode 100644 src/mt/libretranslate.ts create mode 100644 src/mt/mod.ts create mode 100644 src/mt/types.ts create mode 100644 tests/audit_trail_test.ts create mode 100644 tests/mt/ssrf_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c262192..20a4b6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,16 @@ follows [Semantic Versioning](https://semver.org). `onPageMove` is also a valid webhook event. Needs `@dune/core` with `onPageMove` (0.36). `tests/admin/page_events_test.ts` drives create, update, reorder, delete and restore through the real routes. +### Changed — BREAKING (machine translation and the audit trail) + +- **The admin owns the audit trail and machine translation (#123, step 3).** `AuditLogger`, the audit types, the + DeepL / Google / LibreTranslate translators and their types moved here from `@dune/core` (`@dune/core/audit` and + `@dune/core/mt` are gone); they are exported as `@dune/plugin-admin/audit` and `@dune/plugin-admin/mt`. The admin + builds both in `setup()`: the trail from `admin.audit` and `services.paths.runtimeDir` (a `log_file` outside the + site root is still refused), the translator from `site.machine_translation`. Translators take the fetch they use as + a constructor argument and the admin passes `services.security.safeFetch`, so core's SSRF policy still applies. + Needs `@dune/core` 0.36. + ### Changed — BREAKING (admin config move) - **The runtime directory comes from core, not from `admin.runtime_dir`.** `runtime_dir` (and diff --git a/deno.json b/deno.json index 1b12b19..9512072 100644 --- a/deno.json +++ b/deno.json @@ -15,7 +15,9 @@ "./admin/types": "./src/admin/types.ts", "./admin/guards": "./src/admin/guards.ts", "./admin/auth/middleware": "./src/admin/auth/middleware.ts", - "./admin/auth/provider": "./src/admin/auth/provider.ts" + "./admin/auth/provider": "./src/admin/auth/provider.ts", + "./audit": "./src/audit/mod.ts", + "./mt": "./src/mt/mod.ts" }, "imports": { "@dune/testing": "jsr:@dune/testing@^1", diff --git a/mod.ts b/mod.ts index ea025b5..30791c2 100644 --- a/mod.ts +++ b/mod.ts @@ -28,8 +28,8 @@ import type { ScheduledAction } from "@dune/core/workflow"; import { applyWorkflowTransition, SCHEDULED_ACTION_STATUS } from "./src/admin/workflow-actions.ts"; import { createCommentManager } from "./src/admin/comments.ts"; import { createCollabManager } from "./src/collab/mod.ts"; -import type { MachineTranslator } from "@dune/core/mt"; -import type { AuditLogger } from "@dune/core/audit"; +import { createMachineTranslator, type MachineTranslationConfig, type MachineTranslator } from "./src/mt/mod.ts"; +import { type AuditLogger, createAuditTrail } from "./src/audit/mod.ts"; import type { DuneAuthSystem } from "@dune/core/auth/authz"; import type { AuthzLocalAdapter } from "@dune/core/auth/authz-adapter-local"; import type { AuthzDbAdapter } from "@dune/core/auth/authz-adapter-db"; @@ -116,8 +116,11 @@ export function createAdminPlugin( return; } - // Audit trail and machine translator are built by core. - const { audit: auditLogger, mt } = api.services.engines; + // The audit trail and the machine translator are the admin's own. Translator + // requests go through core's SSRF-checked fetch. + const auditLogger = createAuditTrail({ config, root: opts.root, runtimeDir: api.services.paths.runtimeDir }); + const mtConfig = (config.site as { machine_translation?: MachineTranslationConfig }).machine_translation; + const mt = mtConfig ? createMachineTranslator(mtConfig, api.services.security.safeFetch) : null; setupState = { config, storage, opts, auditLogger, mt }; diff --git a/src/admin/context.ts b/src/admin/context.ts index 7925887..a62fc2f 100644 --- a/src/admin/context.ts +++ b/src/admin/context.ts @@ -23,9 +23,9 @@ import type { StagingEngine } from "@dune/core/staging"; import type { CommentManager } from "./comments.ts"; import type { CollabManager } from "../collab/mod.ts"; import type { InlineEditManager } from "@dune/core/inline-edit"; -import type { AuditLogger } from "@dune/core/audit"; +import type { AuditLogger } from "../audit/mod.ts"; import type { MetricsCollector } from "@dune/core/metrics"; -import type { MachineTranslator } from "@dune/core/mt"; +import type { MachineTranslator } from "../mt/mod.ts"; import type { RateLimitStore } from "@dune/core/security"; import type { PluginServices } from "@dune/core/plugin-api"; import type { DuneAuthSystem } from "@dune/core/auth/authz"; diff --git a/src/admin/routes/api/_utils.ts b/src/admin/routes/api/_utils.ts index 56236ba..0436d36 100644 --- a/src/admin/routes/api/_utils.ts +++ b/src/admin/routes/api/_utils.ts @@ -304,7 +304,7 @@ export function actorFromAuth( authResult: { user?: { id: string; username?: string; name?: string } | null; }, -): import("@dune/core/audit").AuditActor | null { +): import("../../../audit/mod.ts").AuditActor | null { if (!authResult.user) return null; const username = authResult.user.username ?? authResult.user.id; return { diff --git a/src/admin/routes/api/audit.ts b/src/admin/routes/api/audit.ts index c7f2cd1..f90c23f 100644 --- a/src/admin/routes/api/audit.ts +++ b/src/admin/routes/api/audit.ts @@ -2,7 +2,7 @@ import type { AdminState } from "../../types.ts"; import { json } from "./_utils.ts"; -import type { AuditQuery, AuditEventType } from "@dune/core/audit"; +import type { AuditQuery, AuditEventType } from "../../../audit/mod.ts"; import type { FreshContext } from "fresh"; export const handler = { diff --git a/src/admin/routes/audit/index.tsx b/src/admin/routes/audit/index.tsx index 639a363..3a85be9 100644 --- a/src/admin/routes/audit/index.tsx +++ b/src/admin/routes/audit/index.tsx @@ -17,9 +17,9 @@ export const handler = { ); } const eventFilter = ctx.url.searchParams.get("event") ?? ""; - const q: import("@dune/core/audit").AuditQuery = { limit: 50 }; + const q: import("../../../audit/mod.ts").AuditQuery = { limit: 50 }; if (eventFilter) { - q.event = eventFilter as import("@dune/core/audit").AuditEventType; + q.event = eventFilter as import("../../../audit/mod.ts").AuditEventType; } const result = await auditLogger.query(q); return ctx.render( diff --git a/src/audit/logger.ts b/src/audit/logger.ts new file mode 100644 index 0000000..d35d72c --- /dev/null +++ b/src/audit/logger.ts @@ -0,0 +1,157 @@ +import { dirname, basename, extname, join } from "@std/path"; +import { ensureDir } from "@std/fs"; +import type { + AuditEntry, + AuditLogOptions, + AuditQuery, + AuditQueryResult, +} from "./types.ts"; + +const SHARD_RE = /^(\d{4}-\d{2}-\d{2})\.jsonl$/; + +/** + * AuditLogger — appends entries to daily-rotated JSONL files. + * + * Shards are written to `{dir}/{basename}/YYYY-MM-DD.jsonl`, where `{dir}` and + * `{basename}` are derived from `logFile`. Queries enumerate only the shards + * whose date falls within `from`/`to` (or all shards when the range is open). + * The legacy single-file layout (just `logFile`) is read as a fallback so an + * upgrade from pre-sharding installs doesn't lose history. + */ +export class AuditLogger { + private readonly logFile: string; // legacy path (fallback on read) + private readonly shardDir: string; // directory holding daily shards + private readonly maxQueryEntries: number; + + constructor(opts: AuditLogOptions) { + this.logFile = opts.logFile; + const ext = extname(this.logFile) || ".log"; + const stem = basename(this.logFile, ext); + this.shardDir = join(dirname(this.logFile), stem); + this.maxQueryEntries = opts.maxQueryEntries ?? 1000; + } + + private ready: Promise | null = null; + + /** Initialize — ensure shard directory exists. Idempotent; `log()` calls it on first use. */ + init(): Promise { + return this.ready ??= ensureDir(this.shardDir).then(() => undefined, (err) => { + this.ready = null; + throw err; + }); + } + + /** Write an audit entry to today's shard */ + async log(entry: Omit): Promise { + await this.init(); + const ts = new Date(); + const full: AuditEntry = { + id: crypto.randomUUID(), + ts: ts.toISOString(), + ...entry, + }; + const line = JSON.stringify(full) + "\n"; + const shardPath = join(this.shardDir, `${ts.toISOString().slice(0, 10)}.jsonl`); + await Deno.writeTextFile(shardPath, line, { append: true }); + } + + /** Query entries — reads only shards within the requested date range */ + async query(q: AuditQuery = {}): Promise { + const shards = await this.listShards(q.from, q.to); + + const fromMs = q.from ? new Date(q.from).getTime() : -Infinity; + const toMs = q.to ? new Date(q.to).getTime() : Infinity; + + // Accumulate newest-first across shards; stop once we have enough to + // satisfy maxQueryEntries after all filters (conservative cap: read + // enough candidate lines before other filters narrow further). + const collected: AuditEntry[] = []; + const entryMatches = (e: AuditEntry): boolean => { + if (q.event && e.event !== q.event) return false; + if (q.actorId && e.actor?.userId !== q.actorId) return false; + if (q.outcome && e.outcome !== q.outcome) return false; + const ems = new Date(e.ts).getTime(); + if (ems < fromMs || ems > toMs) return false; + return true; + }; + + // Walk newest shard first. + for (const shard of shards) { + const text = await readTextIfExists(shard); + if (!text) continue; + const lines = text.split("\n"); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (!line) continue; + let entry: AuditEntry; + try { + entry = JSON.parse(line) as AuditEntry; + } catch { + continue; // tolerate a partial write at end-of-file + } + if (!entryMatches(entry)) continue; + collected.push(entry); + if (collected.length >= this.maxQueryEntries) break; + } + if (collected.length >= this.maxQueryEntries) break; + } + + const total = collected.length; + const limit = Math.min(q.limit ?? 50, 200); + const offset = q.offset ?? 0; + const entries = collected.slice(offset, offset + limit); + return { entries, total }; + } + + /** + * Enumerate shard paths that fall within [from, to], newest first. + * Always includes the legacy single-file path at the end of the list when + * it exists, so migrations don't drop old entries. + */ + private async listShards(from?: string, to?: string): Promise { + const fromDate = from ? from.slice(0, 10) : null; + const toDate = to ? to.slice(0, 10) : null; + + const dates: string[] = []; + try { + for await (const entry of Deno.readDir(this.shardDir)) { + if (!entry.isFile) continue; + const m = entry.name.match(SHARD_RE); + if (!m) continue; + const date = m[1]; + if (fromDate && date < fromDate) continue; + if (toDate && date > toDate) continue; + dates.push(date); + } + } catch { + // Shard dir missing — fall through to legacy file only. + } + + dates.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0)); // newest first + const shardPaths = dates.map((d) => join(this.shardDir, `${d}.jsonl`)); + + // Include legacy single-file log if still present — oldest-seeming, so + // it's queried last and its entries land at the tail. + if (await exists(this.logFile)) { + shardPaths.push(this.logFile); + } + return shardPaths; + } +} + +async function readTextIfExists(path: string): Promise { + try { + return await Deno.readTextFile(path); + } catch { + return null; + } +} + +async function exists(path: string): Promise { + try { + await Deno.stat(path); + return true; + } catch { + return false; + } +} diff --git a/src/audit/mod.ts b/src/audit/mod.ts new file mode 100644 index 0000000..dfa8d1b --- /dev/null +++ b/src/audit/mod.ts @@ -0,0 +1,24 @@ +/** + * Audit logging module. + * Re-exports all public types and the AuditLogger class. + * + * @module + */ + +export type { + AuditActor, + AuditTarget, + AuditEventType, + AuditEntry, + AuditLogOptions, + AuditQuery, + AuditQueryResult, +} from "./types.ts"; + +export type { AuditTrailOptions } from "./trail.ts"; +export { createAuditTrail } from "./trail.ts"; + +export { + /** Appends entries to daily-rotated JSONL files, sharded by date. */ + AuditLogger, +} from "./logger.ts"; diff --git a/src/audit/trail.ts b/src/audit/trail.ts new file mode 100644 index 0000000..13c28ec --- /dev/null +++ b/src/audit/trail.ts @@ -0,0 +1,37 @@ +/** + * Build the audit trail for a site, or null when auditing is off. + * + * @module + */ + +import { join } from "@std/path"; +import type { DuneConfig } from "@dune/core/config"; +import { AuditLogger } from "./logger.ts"; + +export interface AuditTrailOptions { + config: DuneConfig; + /** Absolute site root. Without it there is no place to put the log, so no trail. */ + root: string | undefined; + /** Site-relative runtime directory (`services.paths.runtimeDir`). */ + runtimeDir: string; +} + +/** + * The trail is on unless `admin.enabled` or `admin.audit.enabled` is false. The + * log file must resolve under the site root: a configured `log_file` that + * escapes it is refused. + */ +export function createAuditTrail(opts: AuditTrailOptions): AuditLogger | null { + const { config, root, runtimeDir } = opts; + const audit = config.admin?.audit; + if (config.admin?.enabled === false || audit?.enabled === false || !root) return null; + const logFile = audit?.log_file ? join(root, audit.log_file) : join(root, runtimeDir, "audit.log"); + const containmentRoot = root.endsWith("/") || root.endsWith("\\") ? root : root + "/"; + if (!logFile.startsWith(containmentRoot)) { + throw new Error( + `[dune] admin.audit.log_file must resolve under the site root. ` + + `Got: ${audit?.log_file} -> ${logFile}`, + ); + } + return new AuditLogger({ logFile }); +} diff --git a/src/audit/types.ts b/src/audit/types.ts new file mode 100644 index 0000000..3da1282 --- /dev/null +++ b/src/audit/types.ts @@ -0,0 +1,91 @@ +/** + * Audit logging type definitions. + */ + +/** Actor performing the action */ +export interface AuditActor { + userId: string; + username: string; + name: string; +} + +/** Target resource */ +export interface AuditTarget { + type: + | "page" + | "user" + | "config" + | "plugin" + | "theme" + | "media" + | "flex" + | "form" + | "system" + | "comment" + /** + * Used by `logAuthzDenial()` (MED-4) when the denial is keyed to an + * HTTP route rather than a content target. `id` carries the request + * pathname. + */ + | "route"; + /** Human-readable identifier (sourcePath for pages, userId for users, etc.) */ + id?: string; +} + +/** All audit event types */ +export type AuditEventType = + // Auth + | "auth.login" | "auth.logout" | "auth.login_failed" + | "auth.csrf_denied" | "auth.permission_denied" + // Pages + | "page.create" | "page.update" | "page.delete" | "page.publish" | "page.workflow" + // Config + | "config.update" + // Users + | "user.create" | "user.update" | "user.delete" | "user.password" + // Media + | "media.upload" | "media.delete" + // Plugins + | "plugin.config_update" + // Flex + | "flex.create" | "flex.update" | "flex.delete" + // System + | "system.rebuild" | "system.cache_purge"; + +/** A single audit log entry */ +export interface AuditEntry { + id: string; // crypto.randomUUID() + ts: string; // ISO 8601 + event: AuditEventType; + actor: AuditActor | null; // null for system/unauthenticated actions + ip: string | null; + userAgent: string | null; + target: AuditTarget | null; + detail: Record; // event-specific extra info + outcome: "success" | "failure"; +} + +/** Options for creating an AuditLogger */ +export interface AuditLogOptions { + /** Path to the JSONL log file (e.g. ".dune/admin/audit.log") */ + logFile: string; + /** Maximum entries returned by query() (default: 1000) */ + maxQueryEntries?: number; +} + +/** Query parameters for AuditLogger.query() */ +export interface AuditQuery { + limit?: number; + offset?: number; + event?: AuditEventType; + actorId?: string; + from?: string; // ISO date string + to?: string; // ISO date string + outcome?: "success" | "failure"; +} + +/** Result from AuditLogger.query() */ +export interface AuditQueryResult { + entries: AuditEntry[]; + total: number; +} diff --git a/src/mt/deepl.ts b/src/mt/deepl.ts new file mode 100644 index 0000000..5ad2fa1 --- /dev/null +++ b/src/mt/deepl.ts @@ -0,0 +1,72 @@ +/** + * DeepL machine translation provider. + * Supports both Free (keys ending in ":fx") and Pro API tiers. + */ + +import type { MachineTranslator, SafeFetch } from "./types.ts"; + +/** DeepL target language special cases — map generic codes to preferred variants. */ +const TARGET_LANG_MAP: Record = { + en: "EN-US", + pt: "PT-BR", +}; + +function normalizeSourceLang(lang: string): string { + return lang.toUpperCase(); +} + +function normalizeTargetLang(lang: string): string { + const upper = lang.toUpperCase(); + return TARGET_LANG_MAP[lang.toLowerCase()] ?? upper; +} + +/** + * DeepL machine translation provider. + * Supports both Free (keys ending in ":fx") and Pro API tiers. + */ +export class DeepLTranslator implements MachineTranslator { + readonly provider = "deepl" as const; + + private readonly endpoint: string; + + constructor(private apiKey: string, private safeFetch: SafeFetch) { + // Free keys end with ":fx"; they use the api-free subdomain + this.endpoint = apiKey.endsWith(":fx") + ? "https://api-free.deepl.com/v2/translate" + : "https://api.deepl.com/v2/translate"; + } + + async translate(text: string, sourceLang: string, targetLang: string): Promise { + const results = await this.translateBatch([text], sourceLang, targetLang); + return results[0]; + } + + async translateBatch(texts: string[], sourceLang: string, targetLang: string): Promise { + let res: Response; + try { + res = await this.safeFetch(this.endpoint, { + method: "POST", + headers: { + "Authorization": `DeepL-Auth-Key ${this.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + text: texts, + source_lang: normalizeSourceLang(sourceLang), + target_lang: normalizeTargetLang(targetLang), + tag_handling: "markdown", + }), + }); + } catch (err) { + throw new Error(`DeepL network error: ${err}`); + } + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`DeepL API error ${res.status}: ${body}`); + } + + const data = await res.json() as { translations: Array<{ text: string }> }; + return data.translations.map((t) => t.text); + } +} diff --git a/src/mt/factory.ts b/src/mt/factory.ts new file mode 100644 index 0000000..6a4be0c --- /dev/null +++ b/src/mt/factory.ts @@ -0,0 +1,41 @@ +/** + * Factory for creating a MachineTranslator from config. + */ + +import type { MachineTranslationConfig, MachineTranslator, SafeFetch } from "./types.ts"; +import { DeepLTranslator } from "./deepl.ts"; +import { GoogleTranslator } from "./google.ts"; +import { LibreTranslateTranslator } from "./libretranslate.ts"; + +/** + * Create a MachineTranslator from config. + * Returns null if MT is disabled or provider is unknown. + * API keys starting with "$" are expanded from the environment. + * Every provider request goes through `safeFetch` (core's SSRF policy). + */ +export function createMachineTranslator( + config: MachineTranslationConfig, + safeFetch: SafeFetch, +): MachineTranslator | null { + if (config.enabled === false) return null; + + const rawKey = config.apiKey ?? ""; + const key = rawKey.startsWith("$") + ? (Deno.env.get(rawKey.slice(1)) ?? rawKey) + : rawKey; + + switch (config.provider) { + case "deepl": + return new DeepLTranslator(key, safeFetch); + case "google": + return new GoogleTranslator(key, safeFetch); + case "libretranslate": + return new LibreTranslateTranslator( + config.baseUrl ?? "https://libretranslate.com", + key || undefined, + safeFetch, + ); + default: + return null; + } +} diff --git a/src/mt/google.ts b/src/mt/google.ts new file mode 100644 index 0000000..4032f8b --- /dev/null +++ b/src/mt/google.ts @@ -0,0 +1,47 @@ +/** + * Google Cloud Translation API v2 (Basic) provider. + */ + +import type { MachineTranslator, SafeFetch } from "./types.ts"; + +/** Google Cloud Translation API v2 (Basic) provider. */ +export class GoogleTranslator implements MachineTranslator { + readonly provider = "google" as const; + + constructor(private apiKey: string, private safeFetch: SafeFetch) {} + + async translate(text: string, sourceLang: string, targetLang: string): Promise { + const results = await this.translateBatch([text], sourceLang, targetLang); + return results[0]; + } + + async translateBatch(texts: string[], sourceLang: string, targetLang: string): Promise { + const endpoint = `https://translation.googleapis.com/language/translate/v2?key=${encodeURIComponent(this.apiKey)}`; + + let res: Response; + try { + res = await this.safeFetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + q: texts, + source: sourceLang, + target: targetLang, + format: "text", + }), + }); + } catch (err) { + throw new Error(`Google Translate network error: ${err}`); + } + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Google Translate API error ${res.status}: ${body}`); + } + + const data = await res.json() as { data: { translations: Array<{ translatedText: string }> } }; + return data.data.translations.map((t) => t.translatedText); + } +} diff --git a/src/mt/libretranslate.ts b/src/mt/libretranslate.ts new file mode 100644 index 0000000..2727eb3 --- /dev/null +++ b/src/mt/libretranslate.ts @@ -0,0 +1,69 @@ +/** + * LibreTranslate provider — supports self-hosted instances. + * LibreTranslate does not support batch requests, so batch is implemented + * as sequential individual requests. + */ + +import type { MachineTranslator, SafeFetch } from "./types.ts"; + +/** + * LibreTranslate provider — supports self-hosted instances. + * LibreTranslate does not support batch requests, so batch is implemented + * as sequential individual requests. + */ +export class LibreTranslateTranslator implements MachineTranslator { + readonly provider = "libretranslate" as const; + + constructor( + private baseUrl: string, + private apiKey: string | undefined, + private safeFetch: SafeFetch, + ) { + // Strip trailing slash for consistent URL construction + this.baseUrl = baseUrl.replace(/\/$/, ""); + } + + async translate(text: string, sourceLang: string, targetLang: string): Promise { + const endpoint = `${this.baseUrl}/translate`; + + const body: Record = { + q: text, + source: sourceLang, + target: targetLang, + format: "text", + }; + if (this.apiKey) { + body.api_key = this.apiKey; + } + + let res: Response; + try { + res = await this.safeFetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + } catch (err) { + throw new Error(`LibreTranslate network error: ${err}`); + } + + if (!res.ok) { + const respBody = await res.text().catch(() => ""); + throw new Error(`LibreTranslate API error ${res.status}: ${respBody}`); + } + + const data = await res.json() as { translatedText: string }; + return data.translatedText; + } + + async translateBatch(texts: string[], sourceLang: string, targetLang: string): Promise { + // LibreTranslate has no batch endpoint — execute sequentially + const results: string[] = []; + for (const text of texts) { + results.push(await this.translate(text, sourceLang, targetLang)); + } + return results; + } +} diff --git a/src/mt/mod.ts b/src/mt/mod.ts new file mode 100644 index 0000000..bebddde --- /dev/null +++ b/src/mt/mod.ts @@ -0,0 +1,11 @@ +/** + * Machine translation module — re-exports all public types and classes. + * + * @module + */ + +export type { MachineTranslationConfig, MachineTranslator, SafeFetch } from "./types.ts"; +export { DeepLTranslator } from "./deepl.ts"; +export { GoogleTranslator } from "./google.ts"; +export { LibreTranslateTranslator } from "./libretranslate.ts"; +export { createMachineTranslator } from "./factory.ts"; diff --git a/src/mt/types.ts b/src/mt/types.ts new file mode 100644 index 0000000..78cbd65 --- /dev/null +++ b/src/mt/types.ts @@ -0,0 +1,38 @@ +/** + * Machine translation type definitions. + */ + +/** + * Fetch used for every provider request. The admin passes + * `services.security.safeFetch`, so private and metadata destinations are + * refused by core's SSRF policy. + */ +export type SafeFetch = (url: string, init?: RequestInit) => Promise; + +/** A machine translation provider */ +export interface MachineTranslator { + /** Translate a plain-text or Markdown string */ + translate(text: string, sourceLang: string, targetLang: string): Promise; + /** Translate multiple texts in a single API call (batched) */ + translateBatch(texts: string[], sourceLang: string, targetLang: string): Promise; + /** Provider identifier */ + readonly provider: "deepl" | "google" | "libretranslate"; +} + +/** Machine translation configuration */ +export interface MachineTranslationConfig { + /** Translation provider */ + provider: "deepl" | "google" | "libretranslate"; + /** + * API key for the provider. + * Supports "$ENV_VAR" expansion. + */ + apiKey?: string; + /** + * For LibreTranslate: base URL of the self-hosted instance. + * Default: "https://libretranslate.com" + */ + baseUrl?: string; + /** Whether machine translation is enabled (default: true when this block is present) */ + enabled?: boolean; +} diff --git a/tests/audit_trail_test.ts b/tests/audit_trail_test.ts new file mode 100644 index 0000000..cec1af7 --- /dev/null +++ b/tests/audit_trail_test.ts @@ -0,0 +1,47 @@ +import { assertEquals, assertExists } from "@std/assert"; +import type { DuneConfig } from "@dune/core/config"; +import { createAuditTrail } from "../src/audit/mod.ts"; + +const config = (admin?: Record) => ({ system: {}, site: {}, admin } as unknown as DuneConfig); +const entry = { event: "page.update", actor: null, ip: null, userAgent: null, target: null, detail: {} } as never; + +Deno.test("createAuditTrail: no site root, no trail", () => { + assertEquals(createAuditTrail({ config: config(), root: undefined, runtimeDir: ".dune/admin" }), null); +}); + +Deno.test("createAuditTrail: logs without an explicit init, and enabled:false turns it off", async () => { + const root = await Deno.makeTempDir(); + try { + const trail = createAuditTrail({ config: config(), root, runtimeDir: ".dune/admin" }); + assertExists(trail); + await trail.log(entry); + assertEquals((await trail.query({ event: "page.update" })).entries.length, 1); + + for (const admin of [{ audit: { enabled: false } }, { enabled: false }]) { + assertEquals(createAuditTrail({ config: config(admin), root, runtimeDir: ".dune/admin" }), null); + } + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("createAuditTrail: the log goes under the configured runtime dir", async () => { + const root = await Deno.makeTempDir(); + try { + const trail = createAuditTrail({ config: config(), root, runtimeDir: "var/rt" })!; + await trail.log(entry); + assertEquals((await Deno.stat(`${root}/var/rt/audit`)).isDirectory, true); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("createAuditTrail: a log_file outside the site root is refused", () => { + let message = ""; + try { + createAuditTrail({ config: config({ audit: { log_file: "../elsewhere/audit.log" } }), root: "/srv/site", runtimeDir: ".dune/admin" }); + } catch (err) { + message = (err as Error).message; + } + assertEquals(message.includes("must resolve under the site root"), true); +}); diff --git a/tests/mt/ssrf_test.ts b/tests/mt/ssrf_test.ts new file mode 100644 index 0000000..df087c5 --- /dev/null +++ b/tests/mt/ssrf_test.ts @@ -0,0 +1,37 @@ +import { assertEquals, assertRejects } from "@std/assert"; +import { createMachineTranslator, LibreTranslateTranslator } from "../../src/mt/mod.ts"; +import { createPluginServices } from "@dune/core/plugin-api"; +import type { DuneConfig } from "@dune/core/config"; +import { MemoryStorageAdapter } from "@dune/core/storage"; + +// The translators fetch through whatever they are given: the admin passes +// services.security.safeFetch, so core's SSRF policy applies. +const { security } = createPluginServices({ + config: { system: {}, site: {} } as unknown as DuneConfig, + storage: new MemoryStorageAdapter(), +}); + +Deno.test("LibreTranslateTranslator: refuses a private destination via the services' safeFetch", async () => { + const mt = new LibreTranslateTranslator("http://169.254.169.254", undefined, security.safeFetch); + await assertRejects(() => mt.translate("hello", "en", "es"), Error, "LibreTranslate"); +}); + +Deno.test("createMachineTranslator: sends provider requests through the given fetch", async () => { + const seen: string[] = []; + const fake = (url: string) => { + seen.push(url); + return Promise.resolve(new Response(JSON.stringify({ translatedText: "hola" }))); + }; + const mt = createMachineTranslator( + { provider: "libretranslate", baseUrl: "https://mt.example" }, + fake, + ); + assertEquals(await mt!.translate("hello", "en", "es"), "hola"); + assertEquals(seen, ["https://mt.example/translate"]); +}); + +Deno.test("createMachineTranslator: disabled or unknown providers give none", () => { + const fake = () => Promise.resolve(new Response()); + assertEquals(createMachineTranslator({ provider: "deepl", enabled: false }, fake), null); + assertEquals(createMachineTranslator({ provider: "nope" as never }, fake), null); +}); From 0c40f61b0ff2d8fd594d7cccc66218e77032e111 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 00:28:25 +0100 Subject: [PATCH 25/39] feat(admin)!: the admin is a plugin you list: a factory that takes the site facts it cannot get from PluginApi createAdminPlugin(opts) no longer takes config and storage (they come from PluginApi in setup()), and the default export is the plugin-loader factory (config, { root, dev }) so plugins: [{ src: jsr:@dune/plugin-admin@^4 }] is enough. admin.enabled is gone (core refuses it: list the admin to run it). Tests list the admin explicitly; the conformance driver uses the 2.0.0 @dune/testing shape (hostsContributions, prefix from the admin.prefix slot). Co-Authored-By: Claude Sonnet 5.5 --- deno.json | 2 +- mod.ts | 51 +++++++++++++++------------- src/admin/mount.ts | 2 +- src/admin/types.ts | 2 -- src/audit/trail.ts | 4 +-- tests/admin/capabilities_e2e_test.ts | 5 +-- tests/admin/conformance_test.ts | 9 ++--- tests/admin/page_events_test.ts | 3 +- tests/admin/plugin_version_test.ts | 8 +---- tests/audit_trail_test.ts | 6 ++-- 10 files changed, 45 insertions(+), 47 deletions(-) diff --git a/deno.json b/deno.json index 9512072..d6274b4 100644 --- a/deno.json +++ b/deno.json @@ -20,7 +20,7 @@ "./mt": "./src/mt/mod.ts" }, "imports": { - "@dune/testing": "jsr:@dune/testing@^1", + "@dune/testing": "jsr:@dune/testing@^2", "@dune/core": "jsr:@dune/core@^0.35.3", "fresh": "jsr:@fresh/core@^2", "preact": "npm:preact@^10", diff --git a/mod.ts b/mod.ts index 30791c2..caebea6 100644 --- a/mod.ts +++ b/mod.ts @@ -14,7 +14,6 @@ * @since 0.24.0 */ -import { join } from "@std/path"; import type { DunePlugin, MountApi } from "@dune/core/hooks"; import type { DuneConfig } from "@dune/core/config"; import type { StorageAdapter } from "@dune/core/storage"; @@ -30,9 +29,6 @@ import { createCommentManager } from "./src/admin/comments.ts"; import { createCollabManager } from "./src/collab/mod.ts"; import { createMachineTranslator, type MachineTranslationConfig, type MachineTranslator } from "./src/mt/mod.ts"; import { type AuditLogger, createAuditTrail } from "./src/audit/mod.ts"; -import type { DuneAuthSystem } from "@dune/core/auth/authz"; -import type { AuthzLocalAdapter } from "@dune/core/auth/authz-adapter-local"; -import type { AuthzDbAdapter } from "@dune/core/auth/authz-adapter-db"; import { initAdminContext } from "./src/admin/context.ts"; import type { AdminContext } from "./src/admin/context.ts"; import { createBlockEditorPlugin } from "./src/admin/block-editor-plugin.tsx"; @@ -58,14 +54,14 @@ export const resolvedCoreSentinel: unknown = _core.CORE_INSTANCE; /** The `@dune/core` version this package resolved. Undefined on cores ≤0.30. */ export const resolvedCoreVersion: string | undefined = _core.CORE_VERSION; -/** Options forwarded from bootstrap() to the admin plugin factory. */ +/** Options for the admin plugin factory. */ export interface AdminPluginOptions { + /** Absolute site root (where the audit log is written). */ root: string; + /** True under `dune dev` (cookies are not marked Secure). */ dev: boolean; + /** A custom sign-in provider; takes precedence over `admin.auth_provider`. */ authProvider?: AuthProvider; - authz?: DuneAuthSystem; - authzAdapter?: AuthzLocalAdapter | AuthzDbAdapter; - hmacKey?: CryptoKey | null; } /** State created during setup(), consumed by mount(). */ @@ -81,17 +77,18 @@ interface AdminSetupState { } /** - * Factory for the built-in admin panel plugin. + * Factory for the admin panel plugin. * - * Receives bootstrap-time context (root, dev flag, optional custom auth - * provider) that can't come through `PluginApi` because they exist before - * plugins are loaded. + * Takes the facts that exist before plugins load (site root, dev flag, an + * optional custom auth provider); the site config and storage come from + * `PluginApi` in `setup()`. A site normally lists the package under `plugins:` + * and the default export below builds the plugin; call this directly to pass a + * custom `authProvider`. */ -export function createAdminPlugin( - config: DuneConfig, - storage: StorageAdapter, - opts: AdminPluginOptions, -): DunePlugin { +export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { + // Taken from PluginApi in setup(), which always runs before mount(). + let config!: DuneConfig; + let storage!: StorageAdapter; let setupState: AdminSetupState | null = null; // Set at the end of mount(), once AdminContext is built. The capabilities // declared in setup() delegate to it lazily (per request), because core @@ -106,15 +103,12 @@ export function createAdminPlugin( islandSpecifiers: getDuneAdminIslands(), async setup(api) { + config = api.config; + storage = api.storage; const adminCfg = config.admin ?? { path: "/admin", session_lifetime: 86400, - enabled: true, }; - if (adminCfg.enabled === false) { - setupState = null; - return; - } // The audit trail and the machine translator are the admin's own. Translator // requests go through core's SSRF-checked fetch. @@ -156,7 +150,6 @@ export function createAdminPlugin( const adminCfg = config.admin ?? { path: "/admin", session_lifetime: 86400, - enabled: true, }; // `system.runtime_dir`, as core resolved it. const runtimeDir = services.paths.runtimeDir; @@ -382,3 +375,15 @@ export function createAdminPlugin( }, }; } + +/** + * The plugin loader's entry point: `plugins: [{ src: jsr:@dune/plugin-admin@^4 }]` + * calls this with the entry's config and `{ root, dev }`. + */ +export default function adminPluginFactory( + _pluginConfig: Record, + ctx: { root: string; dev: boolean }, +): DunePlugin { + return createAdminPlugin({ root: ctx.root, dev: ctx.dev }); +} +adminPluginFactory.pluginName = "dune-admin"; diff --git a/src/admin/mount.ts b/src/admin/mount.ts index ef2c943..bd1f361 100644 --- a/src/admin/mount.ts +++ b/src/admin/mount.ts @@ -246,7 +246,7 @@ export function mountAdminPanel( const adminPrefix = config.admin?.path ?? "/admin"; // ── Admin panel ───────────────────────────────────────────────────────────── - if (config.admin?.enabled !== false) { + { // Per-site admin context middleware — captures the correct context in // closure so multisite setups don't suffer from the module-level // singleton. A second registration for the same ctx is a no-op. diff --git a/src/admin/types.ts b/src/admin/types.ts index f52efbf..1cdb749 100644 --- a/src/admin/types.ts +++ b/src/admin/types.ts @@ -66,8 +66,6 @@ export interface AdminConfig { * (default: ".dune/admin") */ runtimeDir: string; - /** Whether admin panel is enabled (default: true) */ - enabled: boolean; } /** Result of an auth check */ diff --git a/src/audit/trail.ts b/src/audit/trail.ts index 13c28ec..0b5b83c 100644 --- a/src/audit/trail.ts +++ b/src/audit/trail.ts @@ -17,14 +17,14 @@ export interface AuditTrailOptions { } /** - * The trail is on unless `admin.enabled` or `admin.audit.enabled` is false. The + * The trail is on unless `admin.audit.enabled` is false. The * log file must resolve under the site root: a configured `log_file` that * escapes it is refused. */ export function createAuditTrail(opts: AuditTrailOptions): AuditLogger | null { const { config, root, runtimeDir } = opts; const audit = config.admin?.audit; - if (config.admin?.enabled === false || audit?.enabled === false || !root) return null; + if (audit?.enabled === false || !root) return null; const logFile = audit?.log_file ? join(root, audit.log_file) : join(root, runtimeDir, "audit.log"); const containmentRoot = root.endsWith("/") || root.endsWith("\\") ? root : root + "/"; if (!logFile.startsWith(containmentRoot)) { diff --git a/tests/admin/capabilities_e2e_test.ts b/tests/admin/capabilities_e2e_test.ts index d199bfc..6a5cd77 100644 --- a/tests/admin/capabilities_e2e_test.ts +++ b/tests/admin/capabilities_e2e_test.ts @@ -9,6 +9,7 @@ import { join } from "@std/path"; import { bootstrap } from "@dune/core/bootstrap"; import { createDuneApp } from "@dune/core/fresh-app"; import type { DunePlugin } from "@dune/core/hooks"; +import { createAdminPlugin } from "../../mod.ts"; async function withSite( plugins: DunePlugin[], @@ -18,7 +19,7 @@ async function withSite( try { await Deno.mkdir(join(root, "content", "01.home"), { recursive: true }); await Deno.writeTextFile(join(root, "content", "01.home", "default.md"), "---\ntitle: Home\n---\n\nHello\n"); - const ctx = await bootstrap(root, { plugins }); + const ctx = await bootstrap(root, { plugins: [createAdminPlugin({ root, dev: false }), ...plugins] }); const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); await fn(ctx, app.handler()); } finally { @@ -61,7 +62,7 @@ Deno.test( await Deno.writeTextFile(join(root, "content", "01.home", "default.md"), "---\ntitle: Home\n---\n"); await Deno.mkdir(join(root, "config"), { recursive: true }); await Deno.writeTextFile(join(root, "config", "system.yaml"), "admin:\n path: /cms\n"); - const ctx = await bootstrap(root, {}); + const ctx = await bootstrap(root, { plugins: [createAdminPlugin({ root, dev: false })] }); const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); assertEquals(ctx.capabilities.get("admin.prefix"), { prefix: "/cms" }); // The panel answers at the provided prefix, and /admin is just a missing content page. diff --git a/tests/admin/conformance_test.ts b/tests/admin/conformance_test.ts index 1b264c0..bdc3676 100644 --- a/tests/admin/conformance_test.ts +++ b/tests/admin/conformance_test.ts @@ -8,6 +8,7 @@ import { join } from "@std/path"; import { runAdminConformance } from "@dune/testing/conformance"; import type { ConformanceApp } from "@dune/testing/conformance"; import { bootstrap } from "@dune/core/bootstrap"; +import { createAdminPlugin } from "../../mod.ts"; import { createDuneApp } from "@dune/core/fresh-app"; import { createStorage } from "@dune/core/storage"; import { createLocalUserStore } from "@dune/core/auth/user-store"; @@ -44,7 +45,8 @@ async function login(app: ConformanceApp, username: string): Promise { runAdminConformance({ name: "plugin-admin", - adminPrefix: "/admin", + // The prefix is read from the admin.prefix slot; this admin hosts admin.pages / admin.routes contributions. + hostsContributions: true, async createApp({ content, extraPlugins, withAdmin }) { const root = await Deno.makeTempDir({ prefix: "dune_admin_conformance_" }); @@ -65,10 +67,9 @@ runAdminConformance({ roles: ["admin"], }); } + // Plugins register in this order and mount in it, so the extras come before the admin. const ctx = await bootstrap(root, { - plugins: extraPlugins, - // deno-lint-ignore no-explicit-any - configOverrides: withAdmin ? undefined : { admin: { enabled: false } as any }, + plugins: [...extraPlugins, ...(withAdmin ? [createAdminPlugin({ root, dev: false })] : [])], }); const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); const handler = app.handler(); diff --git a/tests/admin/page_events_test.ts b/tests/admin/page_events_test.ts index 0accdbd..5b7f25a 100644 --- a/tests/admin/page_events_test.ts +++ b/tests/admin/page_events_test.ts @@ -14,6 +14,7 @@ import { createDuneApp } from "@dune/core/fresh-app"; import { createStorage } from "@dune/core/storage"; import { createLocalUserStore } from "@dune/core/auth/user-store"; import { hashPassword } from "@dune/core/auth/passwords"; +import { createAdminPlugin } from "../../mod.ts"; const PASSWORD = "correct horse battery staple"; const ORIGIN = "http://localhost:8000"; @@ -59,7 +60,7 @@ async function withSite( onPageMove: record("onPageMove"), } as DunePlugin["hooks"], }; - const ctx = await bootstrap(root, { plugins: [recorder] }); + const ctx = await bootstrap(root, { plugins: [createAdminPlugin({ root, dev: false }), recorder] }); const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); const handler = app.handler(); const req = (path: string, init?: RequestInit) => handler(new Request(new URL(path, ORIGIN), init)); diff --git a/tests/admin/plugin_version_test.ts b/tests/admin/plugin_version_test.ts index 65455f3..b1ab6ff 100644 --- a/tests/admin/plugin_version_test.ts +++ b/tests/admin/plugin_version_test.ts @@ -7,8 +7,6 @@ import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; import { createAdminPlugin } from "../../mod.ts"; -import type { DuneConfig } from "@dune/core/config"; -import type { StorageAdapter } from "@dune/core/storage"; Deno.test("admin plugin version matches deno.json", async () => { const denoJsonText = await Deno.readTextFile( @@ -16,11 +14,7 @@ Deno.test("admin plugin version matches deno.json", async () => { ); const { version } = JSON.parse(denoJsonText); - const plugin = createAdminPlugin( - {} as DuneConfig, - {} as StorageAdapter, - { root: "/tmp", dev: true }, - ); + const plugin = createAdminPlugin({ root: "/tmp", dev: true }); assertEquals(plugin.version, version); }); diff --git a/tests/audit_trail_test.ts b/tests/audit_trail_test.ts index cec1af7..74aee31 100644 --- a/tests/audit_trail_test.ts +++ b/tests/audit_trail_test.ts @@ -9,7 +9,7 @@ Deno.test("createAuditTrail: no site root, no trail", () => { assertEquals(createAuditTrail({ config: config(), root: undefined, runtimeDir: ".dune/admin" }), null); }); -Deno.test("createAuditTrail: logs without an explicit init, and enabled:false turns it off", async () => { +Deno.test("createAuditTrail: logs without an explicit init, and audit.enabled: false turns it off", async () => { const root = await Deno.makeTempDir(); try { const trail = createAuditTrail({ config: config(), root, runtimeDir: ".dune/admin" }); @@ -17,9 +17,7 @@ Deno.test("createAuditTrail: logs without an explicit init, and enabled:false tu await trail.log(entry); assertEquals((await trail.query({ event: "page.update" })).entries.length, 1); - for (const admin of [{ audit: { enabled: false } }, { enabled: false }]) { - assertEquals(createAuditTrail({ config: config(admin), root, runtimeDir: ".dune/admin" }), null); - } + assertEquals(createAuditTrail({ config: config({ audit: { enabled: false } }), root, runtimeDir: ".dune/admin" }), null); } finally { await Deno.remove(root, { recursive: true }); } From 09feb45ede78f78fa77620a014293a1c4d34684f Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 00:29:08 +0100 Subject: [PATCH 26/39] docs(admin): README and CHANGELOG for explicit loading Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 13 +++++++++++++ README.md | 36 ++++++++++++++++++++---------------- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20a4b6b..45dd8a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,19 @@ follows [Semantic Versioning](https://semver.org). `onPageMove` is also a valid webhook event. Needs `@dune/core` with `onPageMove` (0.36). `tests/admin/page_events_test.ts` drives create, update, reorder, delete and restore through the real routes. +### Changed — BREAKING (the admin is a plugin you list) + +- **`@dune/core` 0.36 no longer loads the admin by itself (#123, step 4).** List it under `plugins:` in + `config/site.yaml` (`- src: jsr:@dune/plugin-admin@^4`; `dune new` writes it and `dune migrate:config` adds it). + The package's default export is the plugin-loader factory `(config, { root, dev })`, so that is all it takes. +- **`createAdminPlugin(opts)` takes `{ root, dev, authProvider? }` only.** The site config and storage come from + `PluginApi` in `setup()`, and the unused `authz`, `authzAdapter` and `hmacKey` options are gone. To use a custom + `authProvider`, call it yourself and pass the result in `bootstrap(root, { plugins })`; core's `authProvider` + bootstrap option is gone. +- **`admin.enabled` is gone** (core refuses it: do not list the admin to run without it), along with the admin's + `enabled` config type field and the `enabled` check in `mountAdminPanel()` and the audit trail. +- Needs `@dune/core` 0.36 and `@dune/testing` 2 for the conformance run. + ### Changed — BREAKING (machine translation and the audit trail) - **The admin owns the audit trail and machine translation (#123, step 3).** `AuditLogger`, the audit types, the diff --git a/README.md b/README.md index d7a64c6..62eed13 100644 --- a/README.md +++ b/README.md @@ -17,32 +17,36 @@ Optional: `sha256` (ZIP integrity), `demoUrl`, `screenshotUrl`, `tags`, `compati ## Usage -The plugin is auto-registered by `@dune/core` bootstrap unless explicitly disabled. No configuration is required for the default setup. +The admin is a plugin like any other: `@dune/core` loads it only when `config/site.yaml` lists it. +`dune new` writes the entry, and `dune migrate:config` adds it to an existing site. -### Explicit registration (for custom wiring) +```yaml +# config/site.yaml +plugins: + - src: jsr:@dune/plugin-admin@^4 +``` + +No further configuration is required. A site that does not list it has no admin panel: it serves, +builds and runs jobs as usual, and says once at boot that nothing provides the admin. + +### Custom wiring + +To pass a custom sign-in provider, build the plugin yourself and hand it to `bootstrap()` instead of +listing it: ```ts import { createAdminPlugin } from "@dune/plugin-admin"; import { bootstrap } from "@dune/core"; -const result = await bootstrap({ root: Deno.cwd() }); -// Admin is auto-registered; pass options to override defaults: -const adminPlugin = createAdminPlugin(result.config, result.storage, { - root: Deno.cwd(), - dev: false, - // authProvider: myCustomAuthProvider, +const ctx = await bootstrap(Deno.cwd(), { + plugins: [createAdminPlugin({ root: Deno.cwd(), dev: false, authProvider: myCustomAuthProvider })], }); ``` -### Disabling the admin panel - -```yaml -# site.yaml -admin: - enabled: false -``` +### Running without the admin -This produces a headless Dune deployment — useful for statically generated sites or when you want a fully custom management interface. +Do not list it. (`admin.enabled` no longer exists: core refuses it.) This is a headless Dune deployment, +useful for statically generated sites or when you want a fully custom management interface. ## Configuration From c9079a0dfb62aefec0397a77d39b1412c5b6c384 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 01:09:21 +0100 Subject: [PATCH 27/39] feat(admin)!: the plugin is called admin and its settings are its plugin config name dune-admin -> admin, so the settings are config.plugins.admin (the config: of its entry in plugins:). adminSettings(config) is the one reader; it applies the defaults and refuses a path or auth_provider that would desync the admin. WebhookEndpointConfig, WebhookContentEvent and AuthProviderConfig move here from @dune/core, which now has no admin config at all. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 14 +++ README.md | 51 +++++---- mod.ts | 21 ++-- src/admin/mount.ts | 3 +- src/admin/page-events.ts | 3 +- src/admin/routes/api/introspect.ts | 5 +- src/admin/routes/api/media/upload.ts | 3 +- src/admin/routes/api/pages/[...path]/index.ts | 7 +- src/admin/routes/api/pages/index.ts | 3 +- .../routes/api/staging/[...path]/publish.ts | 3 +- src/admin/routes/api/users/[id]/password.ts | 3 +- src/admin/routes/login.tsx | 3 +- src/admin/settings.ts | 101 ++++++++++++++++++ src/admin/webhooks.ts | 2 +- src/admin/workflow-actions.ts | 3 +- src/audit/trail.ts | 3 +- tests/admin/capabilities_e2e_test.ts | 15 +-- tests/admin/pages_edit_blueprint_test.ts | 2 +- tests/admin/plugin_admin_routes_test.ts | 2 +- tests/admin/settings_test.ts | 54 ++++++++++ tests/admin/theme_config_test.ts | 2 +- tests/admin/workflow_actions_test.ts | 2 +- tests/audit_trail_test.ts | 2 +- 23 files changed, 249 insertions(+), 58 deletions(-) create mode 100644 src/admin/settings.ts create mode 100644 tests/admin/settings_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 45dd8a4..23bc13f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,20 @@ follows [Semantic Versioning](https://semver.org). `onPageMove` is also a valid webhook event. Needs `@dune/core` with `onPageMove` (0.36). `tests/admin/page_events_test.ts` drives create, update, reorder, delete and restore through the real routes. +### Changed — BREAKING (the admin is called `admin`, and its settings are its plugin config) + +- **The plugin is named `admin`** (it was `dune-admin`), so its settings are `config.plugins.admin`, and + `capabilities.providers()`, `dune doctor` and the logs name it `admin`. +- **Settings come from the plugin's config, not from an `admin:` block in system.yaml (#123, step 5).** + `path`, `session_lifetime`, `max_upload_mb`, `git_commit`, `webhooks`, `auth_provider` and `audit` are + `config:` under the admin's entry in `plugins:` (site.yaml); `dune migrate:config` moves an existing + `admin:` block there. Core 0.36 has no `admin` config at all and refuses the old block. The admin + validates its own settings at boot: a `path` that is not a clean prefix, or an `auth_provider` other + than `local`, stops the site instead of leaving the admin mounted where core does not protect it. + `WebhookEndpointConfig`, `WebhookContentEvent` and `AuthProviderConfig` moved here from `@dune/core`. + `runtime_dir` and `max_revisions` stay core settings (`system.yaml`). +- `adminSettings(config)` (`src/admin/settings.ts`) is the one place the settings are read. + ### Changed — BREAKING (the admin is a plugin you list) - **`@dune/core` 0.36 no longer loads the admin by itself (#123, step 4).** List it under `plugins:` in diff --git a/README.md b/README.md index 62eed13..036ad0e 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,16 @@ plugins: - src: jsr:@dune/plugin-admin@^4 ``` -No further configuration is required. A site that does not list it has no admin panel: it serves, +No further configuration is required. Settings go under `config:` of that entry: + +```yaml +plugins: + - src: jsr:@dune/plugin-admin@^4 + config: + path: /cms # URL prefix (default /admin) + session_lifetime: 3600 # seconds (default 86400) +``` + A site that does not list it has no admin panel: it serves, builds and runs jobs as usual, and says once at boot that nothing provides the admin. ### Custom wiring @@ -50,28 +59,32 @@ useful for statically generated sites or when you want a fully custom management ## Configuration -All admin configuration lives under the `admin:` key in `site.yaml`. +The admin's settings are its plugin config: `config:` under its entry in `plugins:` (`config/site.yaml`). +Core reads none of them. ```yaml -admin: - path: /admin # URL prefix for the admin panel (default: /admin) - enabled: true # Set to false to disable entirely - session_lifetime: 86400 # Session duration in seconds (default: 24h) - data_dir: data # Directory for users, submissions, etc. - runtime_dir: .dune/admin # Directory for sessions, audit log, etc. - - auth_provider: - type: local # Only "local" is supported today - - audit: - enabled: true - log_file: .dune/admin/audit.log # Override the audit log path - - session_store: - type: local # "local" (default) or "redis" - url: $REDIS_URL # Required when type is redis +plugins: + - src: jsr:@dune/plugin-admin@^4 + config: + path: /admin # URL prefix for the admin panel (default /admin) + session_lifetime: 86400 # session duration in seconds (default 24h) + max_upload_mb: 100 # admin upload limit (default 100) + git_commit: false # commit to git after each page save (default false) + auth_provider: + type: local # the only supported value; omit for the same result + audit: + enabled: true + log_file: .dune/admin/audit.log # must resolve under the site root + webhooks: # outbound webhooks on content events + - url: https://hooks.example.com/content + secret: $WEBHOOK_SECRET + events: [onPageUpdate] ``` +A `path` that is not a clean prefix, or an `auth_provider` other than `local`, stops the site at boot. +Shared settings live in core's `system.yaml`: `data_dir` (users, submissions), `runtime_dir` (sessions, audit +log, revisions) and `max_revisions`. + ### Roles and permissions The admin panel uses a three-tier role system: diff --git a/mod.ts b/mod.ts index caebea6..d0aaa84 100644 --- a/mod.ts +++ b/mod.ts @@ -36,6 +36,7 @@ import type { App } from "fresh"; import { getDuneAdminIslands, mountAdminPanel } from "./src/admin/mount.ts"; import * as corePlugins from "@dune/core/plugins"; import denoJson from "./deno.json" with { type: "json" }; +import { adminSettings } from "./src/admin/settings.ts"; // ── Core-instance handshake ────────────────────────────────────────────────── // Re-export the CORE_INSTANCE sentinel and CORE_VERSION that THIS package's @@ -96,7 +97,7 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { let liveAdminContext: AdminContext | null = null; return { - name: "dune-admin", + name: "admin", version: denoJson.version, description: "Built-in Dune admin panel", hooks: {}, @@ -105,10 +106,7 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { async setup(api) { config = api.config; storage = api.storage; - const adminCfg = config.admin ?? { - path: "/admin", - session_lifetime: 86400, - }; + const adminCfg = adminSettings(config); // throws on a bad path / auth_provider: the site does not boot // The audit trail and the machine translator are the admin's own. Translator // requests go through core's SSRF-checked fetch. @@ -124,7 +122,7 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { // Where this admin is mounted: core keeps these paths out of the page // cache, the static build and the plugin-route namespace. The default // `/admin` lives here, not in core. - api.capabilities.provide("admin.prefix", () => ({ prefix: adminCfg.path ?? "/admin" })); + api.capabilities.provide("admin.prefix", () => ({ prefix: adminCfg.path })); api.capabilities.provide("auth.session", () => ({ // The cookie the admin's auth middleware sets (see createAuthMiddleware). // Core uses it to decide whether to authenticate a public page and whether @@ -147,10 +145,7 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { if (!setupState) return; // admin disabled const { opts: o } = setupState; - const adminCfg = config.admin ?? { - path: "/admin", - session_lifetime: 86400, - }; + const adminCfg = adminSettings(config); // throws on a bad path / auth_provider: the site does not boot // `system.runtime_dir`, as core resolved it. const runtimeDir = services.paths.runtimeDir; // `system.data_dir` — the same resolution core uses for the submissions it stores. @@ -192,7 +187,7 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { if (o.authProvider) { authProvider = o.authProvider; } else { - const provCfg = config.admin?.auth_provider; + const provCfg = adminCfg.auth_provider; if (!provCfg || provCfg.type === "local") { authProvider = new LocalAuthProvider(users, services.passwords); } else { @@ -295,7 +290,7 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { auth, users, sessions, - prefix: adminCfg.path ?? "/admin", + prefix: adminCfg.path, authProvider, workflow, scheduler, @@ -386,4 +381,4 @@ export default function adminPluginFactory( ): DunePlugin { return createAdminPlugin({ root: ctx.root, dev: ctx.dev }); } -adminPluginFactory.pluginName = "dune-admin"; +adminPluginFactory.pluginName = "admin"; diff --git a/src/admin/mount.ts b/src/admin/mount.ts index bd1f361..d33c81a 100644 --- a/src/admin/mount.ts +++ b/src/admin/mount.ts @@ -45,6 +45,7 @@ import { adminMiddleware, adminRoutes, } from "./manifest.gen.ts"; +import { adminSettings } from "./settings.ts"; /** * `ctx` objects (`BootstrapResult`) for which the `ctx.state.adminContext` @@ -243,7 +244,7 @@ export function mountAdminPanel( ): void { const { config } = site; const ctx = site; - const adminPrefix = config.admin?.path ?? "/admin"; + const adminPrefix = adminSettings(config).path; // ── Admin panel ───────────────────────────────────────────────────────────── { diff --git a/src/admin/page-events.ts b/src/admin/page-events.ts index b01e55d..da4a41e 100644 --- a/src/admin/page-events.ts +++ b/src/admin/page-events.ts @@ -13,6 +13,7 @@ import type { FreshContext } from "fresh"; import type { AdminState } from "./types.ts"; import { fireContentWebhooks } from "./webhooks.ts"; +import { adminSettings } from "./settings.ts"; /** Paths are relative to the content directory. */ export type PageEvent = @@ -30,7 +31,7 @@ export function announcePageEvent(ctx: FreshContext, e: PageEvent): ); } fireContentWebhooks( - config.admin?.webhooks ?? [], + adminSettings(config).webhooks, e.event, e.payload, services.paths.runtimeDir, diff --git a/src/admin/routes/api/introspect.ts b/src/admin/routes/api/introspect.ts index cfba004..e1a554d 100644 --- a/src/admin/routes/api/introspect.ts +++ b/src/admin/routes/api/introspect.ts @@ -13,6 +13,7 @@ import type { FreshContext } from "fresh"; import type { AdminState } from "../../types.ts"; import { json, requirePermission } from "./_utils.ts"; import { PLUGIN_API_VERSION } from "@dune/core/plugins"; +import { adminSettings } from "../../settings.ts"; /** Derive Dune package version from the module URL (same logic as CLI). */ function getDuneVersion(): string { @@ -102,7 +103,7 @@ export const handler = { const configSummary = { siteTitle: config.site.title, siteUrl: config.site.url, - adminPath: config.admin?.path ?? "/admin", + adminPath: adminSettings(config).path, themeName: config.theme.name, taxonomies: config.site.taxonomies, languages: { @@ -114,7 +115,7 @@ export const handler = { authMode: ((config as unknown as Record).auth as Record | undefined)?.mode ?? null, feedEnabled: config.site.feed?.enabled !== false, workflowEnabled: !!config.site.workflow, - auditEnabled: config.admin?.audit?.enabled !== false, + auditEnabled: adminSettings(config).audit?.enabled !== false, }; // ── Response ─────────────────────────────────────────────────────────── diff --git a/src/admin/routes/api/media/upload.ts b/src/admin/routes/api/media/upload.ts index 3116639..809f019 100644 --- a/src/admin/routes/api/media/upload.ts +++ b/src/admin/routes/api/media/upload.ts @@ -7,6 +7,7 @@ import { isMediaFile, dirPathToRoute } from "@dune/core/content/path-utils"; import { getMimeType } from "@dune/core/content/page-loader"; import { isDuneError } from "@dune/core/plugin-api"; import type { FreshContext } from "fresh"; +import { adminSettings } from "../../../settings.ts"; export const handler = { async POST(ctx: FreshContext) { @@ -17,7 +18,7 @@ export const handler = { const { storage, config, auditLogger, services } = ctx.state.adminContext; const MAX_FILE_SIZE = 50 * 1024 * 1024; - const maxBodyBytes = (config.admin?.max_upload_mb ?? 100) * 1024 * 1024; + const maxBodyBytes = adminSettings(config).max_upload_mb * 1024 * 1024; try { const tooLarge = services.security.checkBodySize(ctx.req, maxBodyBytes); diff --git a/src/admin/routes/api/pages/[...path]/index.ts b/src/admin/routes/api/pages/[...path]/index.ts index 5c9f8fd..2c60120 100644 --- a/src/admin/routes/api/pages/[...path]/index.ts +++ b/src/admin/routes/api/pages/[...path]/index.ts @@ -15,12 +15,13 @@ import { stringify as stringifyYaml } from "@std/yaml"; import { fireContentWebhooks } from "../../../../../admin/webhooks.ts"; import type { PageFrontmatter } from "@dune/core/content/types"; import type { FreshContext } from "fresh"; +import { adminSettings } from "../../../../settings.ts"; async function maybeGitCommit( filePath: string, sourcePath: string, author: string | undefined, config: import("../../../../context.ts").AdminContext["config"], ): Promise { - if (!config.admin?.git_commit) return; + if (!adminSettings(config).git_commit) return; try { const msg = author ? `Admin: update ${sourcePath} (by ${author})` : `Admin: update ${sourcePath}`; const add = new Deno.Command("git", { args: ["add", filePath], stderr: "inherit" }); @@ -129,7 +130,7 @@ export const handler = { await engine.rebuild(); await maybeGitCommit(filePath, pagePath, undefined, config); - const webhookEndpoints = config.admin?.webhooks ?? []; + const webhookEndpoints = adminSettings(config).webhooks; const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; if (hooks) hooks.fire("onPageUpdate", { sourcePath: page.sourcePath }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onPageUpdate", { sourcePath: page.sourcePath }, runtimeDir, ctx.state.adminContext.services.security); @@ -171,7 +172,7 @@ export const handler = { await storage.delete(`${contentDir}/${page.sourcePath}`); await engine.rebuild(); - const webhookEndpoints = config.admin?.webhooks ?? []; + const webhookEndpoints = adminSettings(config).webhooks; const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; if (hooks) hooks.fire("onPageDelete", { sourcePath: page.sourcePath }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onPageDelete", { sourcePath: page.sourcePath }, runtimeDir, ctx.state.adminContext.services.security); diff --git a/src/admin/routes/api/pages/index.ts b/src/admin/routes/api/pages/index.ts index 6fcd686..b8f9230 100644 --- a/src/admin/routes/api/pages/index.ts +++ b/src/admin/routes/api/pages/index.ts @@ -5,6 +5,7 @@ import { requirePermission, requireTsxWrite, json, serverError, actorFromAuth, g import { fireContentWebhooks } from "../../../../admin/webhooks.ts"; import { stringify as stringifyYaml } from "@std/yaml"; import type { FreshContext } from "fresh"; +import { adminSettings } from "../../../settings.ts"; export const handler = { async GET(ctx: FreshContext) { @@ -67,7 +68,7 @@ export const handler = { await storage.write(filePath, new TextEncoder().encode(fm + "\n" + defaultContent)); await engine.rebuild(); - const webhookEndpoints = config.admin?.webhooks ?? []; + const webhookEndpoints = adminSettings(config).webhooks; const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; if (hooks) hooks.fire("onPageCreate", { sourcePath: `${pagePath}/default${ext}`, title }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onPageCreate", { sourcePath: `${pagePath}/default${ext}`, title }, runtimeDir, ctx.state.adminContext.services.security); diff --git a/src/admin/routes/api/staging/[...path]/publish.ts b/src/admin/routes/api/staging/[...path]/publish.ts index 19a5660..0ba9134 100644 --- a/src/admin/routes/api/staging/[...path]/publish.ts +++ b/src/admin/routes/api/staging/[...path]/publish.ts @@ -9,6 +9,7 @@ import { requirePermission, requireTsxWrite, requireOwnedPage, json, serverError import { stringify as stringifyYaml } from "@std/yaml"; import { announcePageEvent } from "../../../../page-events.ts"; import type { FreshContext } from "fresh"; +import { adminSettings } from "../../../../settings.ts"; async function maybeGitCommit( filePath: string, sourcePath: string, author: string | undefined, @@ -66,7 +67,7 @@ export const handler = { }); } - await maybeGitCommit(filePath, pagePath, authResult.user?.name, config.admin?.git_commit); + await maybeGitCommit(filePath, pagePath, authResult.user?.name, adminSettings(config).git_commit); await staging.discard(pagePath); await engine.rebuild(); announcePageEvent(ctx, { event: "onPageUpdate", payload: { sourcePath: pageIndex.sourcePath } }); diff --git a/src/admin/routes/api/users/[id]/password.ts b/src/admin/routes/api/users/[id]/password.ts index 764f236..c0cb66f 100644 --- a/src/admin/routes/api/users/[id]/password.ts +++ b/src/admin/routes/api/users/[id]/password.ts @@ -3,6 +3,7 @@ import type { AdminState } from "../../../../types.ts"; import { requirePermission, json, serverError, actorFromAuth, getClientIp, csrfCheck } from "../../_utils.ts"; import type { FreshContext } from "fresh"; +import { adminSettings } from "../../../../settings.ts"; export const handler = { async POST(ctx: FreshContext) { @@ -29,7 +30,7 @@ export const handler = { const session = await sessions.create(userId); res.headers.set( "Set-Cookie", - auth.createSessionCookie(session.id, config.admin?.session_lifetime ?? 86400), + auth.createSessionCookie(session.id, adminSettings(config).session_lifetime), ); } diff --git a/src/admin/routes/login.tsx b/src/admin/routes/login.tsx index f8ca0d3..7cfa79c 100644 --- a/src/admin/routes/login.tsx +++ b/src/admin/routes/login.tsx @@ -11,6 +11,7 @@ import { findOrProvisionUser } from "../auth/provisioner.ts"; import type { PluginServices } from "@dune/core/plugin-api"; import type { RateLimiter } from "@dune/core/security"; import { actorFromAuth, csrfCheck } from "./api/_utils.ts"; +import { adminSettings } from "../settings.ts"; // Module-level fallback limiter — used when no rateLimitStore is injected via // AdminContext (single-process deployments, tests, etc.). @@ -110,7 +111,7 @@ export const handler = { if (csrfDenied) return csrfDenied; const { auth, users, sessions, prefix, auditLogger, authProvider, config, rateLimitStore, services } = ctx.state.adminContext; - const adminConfig = config.admin!; + const adminConfig = adminSettings(config); const url = ctx.url; // Logout sub-action diff --git a/src/admin/settings.ts b/src/admin/settings.ts new file mode 100644 index 0000000..a470b46 --- /dev/null +++ b/src/admin/settings.ts @@ -0,0 +1,101 @@ +/** + * The admin's own settings. + * + * They are the plugin's config: `config:` under its entry in site.yaml's + * `plugins:`, which core merges into `config.plugins.admin`. Core has no + * `admin` section. Everything is read through {@link adminSettings}, which + * applies the defaults and refuses a value that would desync the admin. + * + * @module + */ + +/** Content events that can trigger an outbound webhook. */ +export type WebhookContentEvent = + | "onPageCreate" + | "onPageUpdate" + | "onPageDelete" + | "onPageMove" + | "onWorkflowChange"; + +/** One outbound webhook endpoint. */ +export interface WebhookEndpointConfig { + url: string; + /** HMAC-SHA256 signing secret; `$ENV_VAR` is expanded. */ + secret?: string; + /** Which events trigger this endpoint. */ + events: WebhookContentEvent[]; + /** Disable without removing (default true). */ + enabled?: boolean; + /** Label used in delivery logs. */ + label?: string; + /** Allow delivery to private/loopback addresses (default false). */ + allow_private?: boolean; +} + +/** Sign-in provider configuration: only the built-in local passwords are configurable. */ +export type AuthProviderConfig = { type: "local" }; + +/** The admin plugin's settings, defaults applied. */ +export interface AdminSettings { + /** URL prefix the admin is mounted at (default `/admin`). */ + path: string; + /** Admin session lifetime in seconds (default 86400). */ + session_lifetime: number; + /** Maximum admin upload body in MB (default 100). */ + max_upload_mb: number; + /** Commit to git after every page save (default false). */ + git_commit: boolean; + /** Outbound webhooks. */ + webhooks: WebhookEndpointConfig[]; + /** Sign-in provider; omit for the local passwords. */ + auth_provider?: AuthProviderConfig; + /** Audit trail: `enabled` (default true) and `log_file` (under the site root). */ + audit?: { enabled?: boolean; log_file?: string }; +} + +const PATH_RE = /^\/[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/; + +/** The part of the site config the admin reads settings from. */ +export interface SettingsSource { + plugins?: Record | undefined>; +} + +const cache = new WeakMap(); +const EMPTY = {}; + +/** + * The admin's settings with defaults applied. Throws on an unusable `path` or + * `auth_provider`, so a bad value stops the site at boot instead of leaving the + * admin mounted somewhere core does not protect. + */ +export function adminSettings(config: SettingsSource): AdminSettings { + const raw = (config.plugins?.admin ?? EMPTY) as Record; + const hit = cache.get(raw); + if (hit) return hit; + + const path = raw.path ?? "/admin"; + if (typeof path !== "string" || !PATH_RE.test(path)) { + throw new Error( + `[dune] admin plugin config: path must start with "/", contain only [a-zA-Z0-9_-] segments and have no ` + + `trailing slash (e.g. "/admin", "/cms"), got ${JSON.stringify(path)}`, + ); + } + const provider = raw.auth_provider as { type?: unknown } | undefined; + if (provider !== undefined && provider.type !== "local") { + throw new Error( + `[dune] admin plugin config: auth_provider.type must be "local" (got ${JSON.stringify(provider.type)}); ` + + `LDAP and SAML were never implemented. Pass a custom provider to createAdminPlugin({ authProvider }) instead.`, + ); + } + const settings: AdminSettings = { + path, + session_lifetime: typeof raw.session_lifetime === "number" ? raw.session_lifetime : 86400, + max_upload_mb: typeof raw.max_upload_mb === "number" ? raw.max_upload_mb : 100, + git_commit: raw.git_commit === true, + webhooks: Array.isArray(raw.webhooks) ? raw.webhooks as WebhookEndpointConfig[] : [], + auth_provider: provider as AuthProviderConfig | undefined, + audit: raw.audit as AdminSettings["audit"], + }; + cache.set(raw, settings); + return settings; +} diff --git a/src/admin/webhooks.ts b/src/admin/webhooks.ts index 3a2f814..8904e3a 100644 --- a/src/admin/webhooks.ts +++ b/src/admin/webhooks.ts @@ -22,7 +22,7 @@ import type { SecurityService } from "@dune/core/plugin-api"; /** The slice of the security service webhook delivery needs. */ export type WebhookSecurity = Pick; -import type { WebhookContentEvent, WebhookEndpointConfig } from "@dune/core/config"; +import type { WebhookContentEvent, WebhookEndpointConfig } from "./settings.ts"; // === Types === diff --git a/src/admin/workflow-actions.ts b/src/admin/workflow-actions.ts index 84871a3..1260aa7 100644 --- a/src/admin/workflow-actions.ts +++ b/src/admin/workflow-actions.ts @@ -19,6 +19,7 @@ import type { DuneConfig } from "@dune/core/config"; import type { HookRegistry } from "@dune/core/hooks"; import type { WorkflowEngine } from "@dune/core/workflow"; import { fireContentWebhooks } from "./webhooks.ts"; +import { adminSettings } from "./settings.ts"; export interface WorkflowActionContext { engine: DuneEngine; @@ -75,7 +76,7 @@ export async function applyWorkflowTransition( await storage.write(filePath, new TextEncoder().encode(updated)); await engine.rebuild(); - const webhookEndpoints = config.admin?.webhooks ?? []; + const webhookEndpoints = adminSettings(config).webhooks; const runtimeDir = services.paths.runtimeDir; if (hooks) hooks.fire("onWorkflowChange", { sourcePath, from: currentStatus, to: newStatus }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onWorkflowChange", { sourcePath, from: currentStatus, to: newStatus }, runtimeDir, services.security); diff --git a/src/audit/trail.ts b/src/audit/trail.ts index 0b5b83c..3735130 100644 --- a/src/audit/trail.ts +++ b/src/audit/trail.ts @@ -7,6 +7,7 @@ import { join } from "@std/path"; import type { DuneConfig } from "@dune/core/config"; import { AuditLogger } from "./logger.ts"; +import { adminSettings } from "../admin/settings.ts"; export interface AuditTrailOptions { config: DuneConfig; @@ -23,7 +24,7 @@ export interface AuditTrailOptions { */ export function createAuditTrail(opts: AuditTrailOptions): AuditLogger | null { const { config, root, runtimeDir } = opts; - const audit = config.admin?.audit; + const audit = adminSettings(config).audit; if (audit?.enabled === false || !root) return null; const logFile = audit?.log_file ? join(root, audit.log_file) : join(root, runtimeDir, "audit.log"); const containmentRoot = root.endsWith("/") || root.endsWith("\\") ? root : root + "/"; diff --git a/tests/admin/capabilities_e2e_test.ts b/tests/admin/capabilities_e2e_test.ts index 6a5cd77..78c559a 100644 --- a/tests/admin/capabilities_e2e_test.ts +++ b/tests/admin/capabilities_e2e_test.ts @@ -39,8 +39,8 @@ Deno.test( assertEquals(anonymous.authenticated, false); assertEquals(auth.sessionCookies, ["dune_session"]); const providers = Object.fromEntries(ctx.capabilities.providers().map((p) => [p.name, p.provider])); - assertEquals(providers["auth.session"], "dune-admin"); - assertEquals(providers["scheduler.executor"], "dune-admin"); + assertEquals(providers["auth.session"], "admin"); + assertEquals(providers["scheduler.executor"], "admin"); }); }, ); @@ -52,7 +52,7 @@ Deno.test( await withSite([], (ctx) => { assertEquals(ctx.capabilities.get("admin.prefix"), { prefix: "/admin" }); const providers = Object.fromEntries(ctx.capabilities.providers().map((p) => [p.name, p.provider])); - assertEquals(providers["admin.prefix"], "dune-admin"); + assertEquals(providers["admin.prefix"], "admin"); return Promise.resolve(); }); @@ -60,9 +60,12 @@ Deno.test( try { await Deno.mkdir(join(root, "content", "01.home"), { recursive: true }); await Deno.writeTextFile(join(root, "content", "01.home", "default.md"), "---\ntitle: Home\n---\n"); - await Deno.mkdir(join(root, "config"), { recursive: true }); - await Deno.writeTextFile(join(root, "config", "system.yaml"), "admin:\n path: /cms\n"); - const ctx = await bootstrap(root, { plugins: [createAdminPlugin({ root, dev: false })] }); + // The admin's settings are its plugin config: `config:` under its entry in site.yaml. + const ctx = await bootstrap(root, { + plugins: [createAdminPlugin({ root, dev: false })], + // deno-lint-ignore no-explicit-any + configOverrides: { plugins: { admin: { path: "/cms" } } } as any, + }); const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); assertEquals(ctx.capabilities.get("admin.prefix"), { prefix: "/cms" }); // The panel answers at the provided prefix, and /admin is just a missing content page. diff --git a/tests/admin/pages_edit_blueprint_test.ts b/tests/admin/pages_edit_blueprint_test.ts index d32ceda..08db562 100644 --- a/tests/admin/pages_edit_blueprint_test.ts +++ b/tests/admin/pages_edit_blueprint_test.ts @@ -64,7 +64,7 @@ function makeCtx(opts: { services: makeServices(null, { getBlueprints: () => engine.blueprints as never }), engine, storage: opts.storage, - config: { system: { content: { dir: "content" } }, admin: {} }, + config: { system: { content: { dir: "content" } }, plugins: {} }, // authz.check() always allows — this test exercises page-edit // behavior, not permission logic (ROLE_PERMISSIONS/hasPermission() // were removed in 3.0.0; authz.check() is the sole authority now). diff --git a/tests/admin/plugin_admin_routes_test.ts b/tests/admin/plugin_admin_routes_test.ts index 2085379..c6328bb 100644 --- a/tests/admin/plugin_admin_routes_test.ts +++ b/tests/admin/plugin_admin_routes_test.ts @@ -40,7 +40,7 @@ function fc(opts: { adminContext: { authz: { check: () => Promise.resolve(opts.allow !== false) }, csrfSecret: "test-secret", - config: { admin: {} }, + config: { plugins: {} }, }, }, }; diff --git a/tests/admin/settings_test.ts b/tests/admin/settings_test.ts new file mode 100644 index 0000000..17fc197 --- /dev/null +++ b/tests/admin/settings_test.ts @@ -0,0 +1,54 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { adminSettings } from "../../src/admin/settings.ts"; + +Deno.test("adminSettings: defaults when the plugin has no config", () => { + for (const config of [{}, { plugins: {} }, { plugins: { admin: {} } }]) { + assertEquals(adminSettings(config), { + path: "/admin", + session_lifetime: 86400, + max_upload_mb: 100, + git_commit: false, + webhooks: [], + auth_provider: undefined, + audit: undefined, + }); + } +}); + +Deno.test("adminSettings: reads the plugin's own config", () => { + const s = adminSettings({ + plugins: { + admin: { + path: "/cms", + session_lifetime: 3600, + max_upload_mb: 5, + git_commit: true, + webhooks: [{ url: "https://hooks.example.test", events: ["onPageUpdate"] }], + auth_provider: { type: "local" }, + audit: { enabled: false }, + }, + }, + }); + assertEquals(s.path, "/cms"); + assertEquals(s.session_lifetime, 3600); + assertEquals(s.max_upload_mb, 5); + assertEquals(s.git_commit, true); + assertEquals(s.webhooks.length, 1); + assertEquals(s.audit, { enabled: false }); +}); + +Deno.test("adminSettings: a path that would desync the admin stops the boot", () => { + for (const path of ["admin", "/", "/admin/", "/a b", "/admin?x", 7]) { + assertThrows(() => adminSettings({ plugins: { admin: { path } } }), Error, "path must start with"); + } +}); + +Deno.test("adminSettings: auth_provider other than local is refused", () => { + for (const type of ["ldap", "saml", undefined]) { + assertThrows(() => adminSettings({ plugins: { admin: { auth_provider: { type } } } }), Error, "auth_provider.type"); + } +}); + +Deno.test("adminSettings: another plugin's config is not the admin's", () => { + assertEquals(adminSettings({ plugins: { "dune-admin": { path: "/old" }, seo: { path: "/x" } } }).path, "/admin"); +}); diff --git a/tests/admin/theme_config_test.ts b/tests/admin/theme_config_test.ts index 5b206a2..a630dc3 100644 --- a/tests/admin/theme_config_test.ts +++ b/tests/admin/theme_config_test.ts @@ -52,7 +52,7 @@ function makeCtx(opts: { services: makeServices(opts.storage), engine, storage: opts.storage, - config: { admin: { data_dir: "data" } }, + config: { plugins: {} }, // authz.check() always allows — this test exercises theme-config // behavior, not permission logic (ROLE_PERMISSIONS/hasPermission() // were removed in 3.0.0; authz.check() is the sole authority now). diff --git a/tests/admin/workflow_actions_test.ts b/tests/admin/workflow_actions_test.ts index b491b96..f30c3df 100644 --- a/tests/admin/workflow_actions_test.ts +++ b/tests/admin/workflow_actions_test.ts @@ -48,7 +48,7 @@ function makePageIndex(overrides: Partial = {}): PageIndex { const CONFIG = { system: { content: { dir: "content" } }, - admin: { webhooks: [] }, + plugins: { admin: { webhooks: [] } }, } as unknown as DuneConfig; function makeEngine(pages: PageIndex[]) { diff --git a/tests/audit_trail_test.ts b/tests/audit_trail_test.ts index 74aee31..f111bf3 100644 --- a/tests/audit_trail_test.ts +++ b/tests/audit_trail_test.ts @@ -2,7 +2,7 @@ import { assertEquals, assertExists } from "@std/assert"; import type { DuneConfig } from "@dune/core/config"; import { createAuditTrail } from "../src/audit/mod.ts"; -const config = (admin?: Record) => ({ system: {}, site: {}, admin } as unknown as DuneConfig); +const config = (admin?: Record) => ({ system: {}, site: {}, plugins: { admin } } as unknown as DuneConfig); const entry = { event: "page.update", actor: null, ip: null, userAgent: null, target: null, detail: {} } as never; Deno.test("createAuditTrail: no site root, no trail", () => { From 541b28efb3ca571c57e3d58e159adbd0de9c4fd6 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 01:13:09 +0100 Subject: [PATCH 28/39] feat(admin)!: machine_translation is part of the admin's plugin config Read from config.plugins.admin.machine_translation with api_key / base_url (were apiKey / baseUrl). Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 ++ README.md | 3 +++ mod.ts | 4 ++-- src/admin/settings.ts | 5 +++++ src/mt/factory.ts | 4 ++-- src/mt/types.ts | 4 ++-- tests/admin/settings_test.ts | 1 + tests/mt/ssrf_test.ts | 2 +- 8 files changed, 18 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23bc13f..5c45f24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ follows [Semantic Versioning](https://semver.org). than `local`, stops the site instead of leaving the admin mounted where core does not protect it. `WebhookEndpointConfig`, `WebhookContentEvent` and `AuthProviderConfig` moved here from `@dune/core`. `runtime_dir` and `max_revisions` stay core settings (`system.yaml`). +- **`machine_translation` is a setting of the admin's config** (it was `site.machine_translation`), with `api_key` + and `base_url` (they were `apiKey` and `baseUrl`); `dune migrate:config` moves and renames it. - `adminSettings(config)` (`src/admin/settings.ts`) is the one place the settings are read. ### Changed — BREAKING (the admin is a plugin you list) diff --git a/README.md b/README.md index 036ad0e..cb5c504 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,9 @@ plugins: audit: enabled: true log_file: .dune/admin/audit.log # must resolve under the site root + machine_translation: # optional: translate pages from the editor + provider: deepl # deepl | google | libretranslate + api_key: $DEEPL_API_KEY webhooks: # outbound webhooks on content events - url: https://hooks.example.com/content secret: $WEBHOOK_SECRET diff --git a/mod.ts b/mod.ts index d0aaa84..fd1277f 100644 --- a/mod.ts +++ b/mod.ts @@ -27,7 +27,7 @@ import type { ScheduledAction } from "@dune/core/workflow"; import { applyWorkflowTransition, SCHEDULED_ACTION_STATUS } from "./src/admin/workflow-actions.ts"; import { createCommentManager } from "./src/admin/comments.ts"; import { createCollabManager } from "./src/collab/mod.ts"; -import { createMachineTranslator, type MachineTranslationConfig, type MachineTranslator } from "./src/mt/mod.ts"; +import { createMachineTranslator, type MachineTranslator } from "./src/mt/mod.ts"; import { type AuditLogger, createAuditTrail } from "./src/audit/mod.ts"; import { initAdminContext } from "./src/admin/context.ts"; import type { AdminContext } from "./src/admin/context.ts"; @@ -111,7 +111,7 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { // The audit trail and the machine translator are the admin's own. Translator // requests go through core's SSRF-checked fetch. const auditLogger = createAuditTrail({ config, root: opts.root, runtimeDir: api.services.paths.runtimeDir }); - const mtConfig = (config.site as { machine_translation?: MachineTranslationConfig }).machine_translation; + const mtConfig = adminCfg.machine_translation; const mt = mtConfig ? createMachineTranslator(mtConfig, api.services.security.safeFetch) : null; setupState = { config, storage, opts, auditLogger, mt }; diff --git a/src/admin/settings.ts b/src/admin/settings.ts index a470b46..ae13a6a 100644 --- a/src/admin/settings.ts +++ b/src/admin/settings.ts @@ -35,6 +35,8 @@ export interface WebhookEndpointConfig { /** Sign-in provider configuration: only the built-in local passwords are configurable. */ export type AuthProviderConfig = { type: "local" }; +import type { MachineTranslationConfig } from "../mt/types.ts"; + /** The admin plugin's settings, defaults applied. */ export interface AdminSettings { /** URL prefix the admin is mounted at (default `/admin`). */ @@ -51,6 +53,8 @@ export interface AdminSettings { auth_provider?: AuthProviderConfig; /** Audit trail: `enabled` (default true) and `log_file` (under the site root). */ audit?: { enabled?: boolean; log_file?: string }; + /** Machine translation provider; absent when none is configured. */ + machine_translation?: MachineTranslationConfig; } const PATH_RE = /^\/[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/; @@ -95,6 +99,7 @@ export function adminSettings(config: SettingsSource): AdminSettings { webhooks: Array.isArray(raw.webhooks) ? raw.webhooks as WebhookEndpointConfig[] : [], auth_provider: provider as AuthProviderConfig | undefined, audit: raw.audit as AdminSettings["audit"], + machine_translation: raw.machine_translation as MachineTranslationConfig | undefined, }; cache.set(raw, settings); return settings; diff --git a/src/mt/factory.ts b/src/mt/factory.ts index 6a4be0c..6a371c6 100644 --- a/src/mt/factory.ts +++ b/src/mt/factory.ts @@ -19,7 +19,7 @@ export function createMachineTranslator( ): MachineTranslator | null { if (config.enabled === false) return null; - const rawKey = config.apiKey ?? ""; + const rawKey = config.api_key ?? ""; const key = rawKey.startsWith("$") ? (Deno.env.get(rawKey.slice(1)) ?? rawKey) : rawKey; @@ -31,7 +31,7 @@ export function createMachineTranslator( return new GoogleTranslator(key, safeFetch); case "libretranslate": return new LibreTranslateTranslator( - config.baseUrl ?? "https://libretranslate.com", + config.base_url ?? "https://libretranslate.com", key || undefined, safeFetch, ); diff --git a/src/mt/types.ts b/src/mt/types.ts index 78cbd65..6806ca7 100644 --- a/src/mt/types.ts +++ b/src/mt/types.ts @@ -27,12 +27,12 @@ export interface MachineTranslationConfig { * API key for the provider. * Supports "$ENV_VAR" expansion. */ - apiKey?: string; + api_key?: string; /** * For LibreTranslate: base URL of the self-hosted instance. * Default: "https://libretranslate.com" */ - baseUrl?: string; + base_url?: string; /** Whether machine translation is enabled (default: true when this block is present) */ enabled?: boolean; } diff --git a/tests/admin/settings_test.ts b/tests/admin/settings_test.ts index 17fc197..4cf937d 100644 --- a/tests/admin/settings_test.ts +++ b/tests/admin/settings_test.ts @@ -11,6 +11,7 @@ Deno.test("adminSettings: defaults when the plugin has no config", () => { webhooks: [], auth_provider: undefined, audit: undefined, + machine_translation: undefined, }); } }); diff --git a/tests/mt/ssrf_test.ts b/tests/mt/ssrf_test.ts index df087c5..e68666c 100644 --- a/tests/mt/ssrf_test.ts +++ b/tests/mt/ssrf_test.ts @@ -23,7 +23,7 @@ Deno.test("createMachineTranslator: sends provider requests through the given fe return Promise.resolve(new Response(JSON.stringify({ translatedText: "hola" }))); }; const mt = createMachineTranslator( - { provider: "libretranslate", baseUrl: "https://mt.example" }, + { provider: "libretranslate", base_url: "https://mt.example" }, fake, ); assertEquals(await mt!.translate("hello", "en", "es"), "hola"); From 7225112a87d8b96212ce779233454fea117824b0 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 16:20:38 +0100 Subject: [PATCH 29/39] fix!: permissions are checked on app:site, not app:admin Matches @dune/core's rename (duneorg/dune#130): the resource names the site content being protected, not the panel. Co-Authored-By: Claude Sonnet 5.5 --- src/admin/routes/_middleware.ts | 6 +++--- src/admin/routes/api/_utils.ts | 2 +- src/admin/routes/api/users/[id]/index.ts | 6 +++--- src/admin/routes/api/users/index.ts | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/admin/routes/_middleware.ts b/src/admin/routes/_middleware.ts index 395ed2e..595d1a5 100644 --- a/src/admin/routes/_middleware.ts +++ b/src/admin/routes/_middleware.ts @@ -136,7 +136,7 @@ export function toAdminRelative(pathname: string, normalizedPrefix: string): str * in the union — it only ever existed on the removed `ROLE_PERMISSIONS` * table and has no corresponding action in `@dune/core`'s authz schema * (`actionToRelations` never defined it). Panel access is `canThey: "access"` - * on `{ type: "app", id: "admin" }`, enforced by this middleware's gate. + * on `{ type: "app", id: "site" }`, enforced by this middleware's gate. */ const NAV_PERMISSIONS: readonly AdminPermission[] = [ "pages.create", @@ -177,7 +177,7 @@ export async function computeNavPermissions( who: { type: "user", id: userId }, // deno-lint-ignore no-explicit-any canThey: permission as any, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }); return allowed ? permission : null; }), @@ -229,7 +229,7 @@ export async function handler( const canAccess = await adminCtx.authz.check({ who: { type: "user", id: authResult.user.id }, canThey: "access", - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }); if (!canAccess) { return withSecurityHeaders(new Response("Forbidden", { status: 403 }), frameable); diff --git a/src/admin/routes/api/_utils.ts b/src/admin/routes/api/_utils.ts index 0436d36..919be47 100644 --- a/src/admin/routes/api/_utils.ts +++ b/src/admin/routes/api/_utils.ts @@ -148,7 +148,7 @@ export async function checkPermission( return await authz.check({ who: { type: "user", id: authResult.user.id }, canThey: permission as any, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }); } diff --git a/src/admin/routes/api/users/[id]/index.ts b/src/admin/routes/api/users/[id]/index.ts index 81ab1b5..ea470f4 100644 --- a/src/admin/routes/api/users/[id]/index.ts +++ b/src/admin/routes/api/users/[id]/index.ts @@ -69,7 +69,7 @@ export const handler = { await authz.disallowAllMatching({ who: { type: "user", id: userId }, was: existingRole, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }).catch((err) => { console.warn(`[dune/authz] disallowAllMatching failed on role change for user ${userId}:`, err); }); @@ -77,7 +77,7 @@ export const handler = { await authz.allow({ who: { type: "user", id: userId }, toBe: role, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }); } if (enabled !== undefined) { @@ -96,7 +96,7 @@ export const handler = { await authz.allow({ who: { type: "user", id: userId }, toBe: effectiveRole, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }).catch((err) => { console.warn(`[dune/authz] allow failed on re-enable for user ${userId}:`, err); }); diff --git a/src/admin/routes/api/users/index.ts b/src/admin/routes/api/users/index.ts index 55f4e3d..955a1ed 100644 --- a/src/admin/routes/api/users/index.ts +++ b/src/admin/routes/api/users/index.ts @@ -79,7 +79,7 @@ export const handler = { await authz.allow({ who: { type: "user", id: user.id }, toBe: role, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }).catch(() => {}); } From c692e33f2b552cdb7a9c5988a02f6844433576f4 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 19:33:26 +0100 Subject: [PATCH 30/39] chore: use the editor.session and editor.inline slot names (duneorg/dune#125) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 10 +++++----- mod.ts | 4 ++-- src/admin/context.ts | 2 +- tests/admin/capabilities_e2e_test.ts | 8 ++++---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c45f24..ed70b83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,7 +104,7 @@ follows [Semantic Versioning](https://semver.org). ### Added - **Runs the admin conformance suite from `@dune/testing`** (`tests/admin/conformance_test.ts`): the checks - the stub admin passes, against plugin-admin over a real temp site. It covers `auth.session`, session + the stub admin passes, against plugin-admin over a real temp site. It covers `editor.session`, session recognition and marker scrubbing, the declared `dune_session` cookie, mount-order independence, `onPageUpdate` fired once by an edit, `admin.pages` / `admin.routes` mounted behind the login and the CSRF check, and the site serving with no admin. Needs `@dune/testing` with the conformance suite @@ -114,20 +114,20 @@ follows [Semantic Versioning](https://semver.org). - **The admin provides and consumes core's capabilities instead of the untyped `adminContext` bag (duneorg/dune#97, `@dune/core` 0.36).** It provides - `auth.session` (who is the admin on a request: the admin bar and the inline-edit + `editor.session` (who is the admin on a request: the admin bar and the inline-edit WebSocket's admin fallback) and `scheduler.executor` (runs due scheduled actions; `dune serve` starts core's scheduler loop when it exists). It reads the - `inline-edit` and `editor.content` slots and the `admin.pages` contributions from + `editor.inline` and `editor.content` slots and the `admin.pages` contributions from `MountApi.capabilities` instead of `adminServices` and each plugin's `adminPages`, and no longer writes `bootstrap.adminContext`. The jobs view reads `BootstrapResult.jobScheduler`, which `dune serve` sets after mount. - `auth.session` declares `sessionCookies: ["dune_session"]`, which core uses to decide + `editor.session` declares `sessionCookies: ["dune_session"]`, which core uses to decide whether to authenticate a public page and whether to keep the response out of the shared page cache. - **`mountDuneAdminEarly()` is gone** (core removed `DunePlugin.mountEarly`). `mountDuneAdmin(app, ctx, adminContext?)` falls back to the single-site context stored by the plugin's `mount()` instead of `ctx.adminContext`. A plugin route that - needs the admin identity calls `capabilities.get("auth.session")`. Routes a plugin + needs the admin identity calls `capabilities.get("editor.session")`. Routes a plugin registers in its own `mount()` no longer sit behind the admin's `ctx.state.adminContext` middleware, so `withGuards()` no longer works there (it throws a `TypeError`); a guarded `POST`/`PUT`/`PATCH`/`DELETE` admin route is now a diff --git a/mod.ts b/mod.ts index fd1277f..602d373 100644 --- a/mod.ts +++ b/mod.ts @@ -123,7 +123,7 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { // cache, the static build and the plugin-route namespace. The default // `/admin` lives here, not in core. api.capabilities.provide("admin.prefix", () => ({ prefix: adminCfg.path })); - api.capabilities.provide("auth.session", () => ({ + api.capabilities.provide("editor.session", () => ({ // The cookie the admin's auth middleware sets (see createAuthMiddleware). // Core uses it to decide whether to authenticate a public page and whether // to keep the response out of the shared page cache. @@ -272,7 +272,7 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { }); // ── Default content editor ──────────────────────────────────────────────── - const inlineEdit = capabilities.get("inline-edit"); + const inlineEdit = capabilities.get("editor.inline"); const contentEditor = capabilities.get("editor.content") ?? createBlockEditorPlugin(); // ── Build AdminContext ──────────────────────────────────────────────────── diff --git a/src/admin/context.ts b/src/admin/context.ts index a62fc2f..e90e973 100644 --- a/src/admin/context.ts +++ b/src/admin/context.ts @@ -66,7 +66,7 @@ export interface AdminContext { staging?: StagingEngine; comments?: CommentManager; collab?: CollabManager; - /** Inline editing manager, provided by a plugin as the `inline-edit` capability. */ + /** Inline editing manager, provided by a plugin as the `editor.inline` capability. */ inlineEdit?: InlineEditManager; /** Custom page editor, provided by a plugin as the `editor.content` capability. Replaces the built-in block editor. */ contentEditor?: ContentEditorPlugin; diff --git a/tests/admin/capabilities_e2e_test.ts b/tests/admin/capabilities_e2e_test.ts index 78c559a..4c22822 100644 --- a/tests/admin/capabilities_e2e_test.ts +++ b/tests/admin/capabilities_e2e_test.ts @@ -1,5 +1,5 @@ /** - * End to end against a real core: the admin plugin provides `auth.session` and + * End to end against a real core: the admin plugin provides `editor.session` and * `scheduler.executor`, consumes `admin.pages` contributions, and no longer * writes core's removed `adminContext` bag. */ @@ -28,18 +28,18 @@ async function withSite( } Deno.test( - "admin provides auth.session and scheduler.executor", + "admin provides editor.session and scheduler.executor", { sanitizeOps: false, sanitizeResources: false }, async () => { await withSite([], async (ctx) => { - const auth = ctx.capabilities.get("auth.session"); + const auth = ctx.capabilities.get("editor.session"); assertExists(auth); assertExists(ctx.capabilities.get("scheduler.executor")); const anonymous = await auth.authenticate(new Request("http://localhost/")); assertEquals(anonymous.authenticated, false); assertEquals(auth.sessionCookies, ["dune_session"]); const providers = Object.fromEntries(ctx.capabilities.providers().map((p) => [p.name, p.provider])); - assertEquals(providers["auth.session"], "admin"); + assertEquals(providers["editor.session"], "admin"); assertEquals(providers["scheduler.executor"], "admin"); }); }, From 0b494b810afefaebf1da63d6cf12a93dc7c25252 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 18:05:32 +0100 Subject: [PATCH 31/39] feat!: the admin declares its own extension points (admin.pages, admin.routes, admin.editor) (#127) The registration types leave core's contract and live here; the keys are declared by declaration merging. editor.content is renamed admin.editor. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 9 ++ deno.json | 1 + mod.ts | 13 ++- src/admin/block-editor-plugin.tsx | 4 +- src/admin/context.ts | 6 +- src/admin/contributions.ts | 141 ++++++++++++++++++++++++ src/admin/types.ts | 2 +- tests/admin/capabilities_e2e_test.ts | 8 +- tests/admin/contributions_types_test.ts | 46 ++++++++ 9 files changed, 219 insertions(+), 11 deletions(-) create mode 100644 src/admin/contributions.ts create mode 100644 tests/admin/contributions_types_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ed70b83..be318e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,15 @@ follows [Semantic Versioning](https://semver.org). `onPageMove` is also a valid webhook event. Needs `@dune/core` with `onPageMove` (0.36). `tests/admin/page_events_test.ts` drives create, update, reorder, delete and restore through the real routes. +### Changed — BREAKING (the admin declares its own extension points, #127) + +- **`AdminPageRegistration`, `AdminRouteRegistration` and `ContentEditorPlugin` live here now**, exported + from `@dune/plugin-admin` (and `@dune/plugin-admin/contributions`). Core's contract never read them. The + keys `admin.pages` and `admin.routes` are declared by this package by declaration merging, so importing + the types is what makes a plugin's `contribute("admin.pages", ...)` type-check. The `editor.content` + slot is renamed **`admin.editor`**: a key carries the name of the plugin that reads it. A plugin that + provides a replacement page editor changes the key and imports `ContentEditorPlugin` from here. + ### Changed — BREAKING (the admin is called `admin`, and its settings are its plugin config) - **The plugin is named `admin`** (it was `dune-admin`), so its settings are `config.plugins.admin`, and diff --git a/deno.json b/deno.json index d6274b4..1b48fe1 100644 --- a/deno.json +++ b/deno.json @@ -13,6 +13,7 @@ "./admin/mount": "./src/admin/mount.ts", "./admin/context": "./src/admin/context.ts", "./admin/types": "./src/admin/types.ts", + "./contributions": "./src/admin/contributions.ts", "./admin/guards": "./src/admin/guards.ts", "./admin/auth/middleware": "./src/admin/auth/middleware.ts", "./admin/auth/provider": "./src/admin/auth/provider.ts", diff --git a/mod.ts b/mod.ts index 602d373..dc7e8f6 100644 --- a/mod.ts +++ b/mod.ts @@ -52,6 +52,17 @@ const _core = corePlugins as unknown as { /** The `@dune/core` instance sentinel this package resolved. See core's `plugins/mod.ts`. */ export const resolvedCoreSentinel: unknown = _core.CORE_INSTANCE; +/** + * The admin's extension points. Importing them also teaches TypeScript the keys + * (`admin.pages`, `admin.routes`, `admin.editor`) this plugin declares on the + * capability registry. + */ +export type { + AdminPageRegistration, + AdminRouteRegistration, + ContentEditorPlugin, +} from "./src/admin/contributions.ts"; + /** The `@dune/core` version this package resolved. Undefined on cores ≤0.30. */ export const resolvedCoreVersion: string | undefined = _core.CORE_VERSION; @@ -273,7 +284,7 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { // ── Default content editor ──────────────────────────────────────────────── const inlineEdit = capabilities.get("editor.inline"); - const contentEditor = capabilities.get("editor.content") ?? createBlockEditorPlugin(); + const contentEditor = capabilities.get("admin.editor") ?? createBlockEditorPlugin(); // ── Build AdminContext ──────────────────────────────────────────────────── const metricsEnabled = config.system.metrics?.enabled !== false; diff --git a/src/admin/block-editor-plugin.tsx b/src/admin/block-editor-plugin.tsx index a166a4b..62b959d 100644 --- a/src/admin/block-editor-plugin.tsx +++ b/src/admin/block-editor-plugin.tsx @@ -1,14 +1,14 @@ /** @jsxImportSource preact */ /** * Built-in block editor — the default ContentEditorPlugin loaded by bootstrap - * when no plugin provides the `editor.content` capability. + * when no plugin provides the `admin.editor` capability. * * Wraps the PageEditor island in the same way the old hardwired route did, but * now as a proper ContentEditorPlugin so it participates in the same slot that * third-party editors use. */ -import type { ContentEditorPlugin } from "@dune/core/hooks"; +import type { ContentEditorPlugin } from "./contributions.ts"; import type { FreshContext } from "fresh"; import PageEditor from "./islands/PageEditor.tsx"; diff --git a/src/admin/context.ts b/src/admin/context.ts index e90e973..90c2178 100644 --- a/src/admin/context.ts +++ b/src/admin/context.ts @@ -17,8 +17,8 @@ import type { Scheduler, ScheduledAction } from "@dune/core/workflow"; import type { HistoryEngine } from "@dune/core/history"; import type { SubmissionManager } from "./submissions.ts"; import type { FlexEngine } from "@dune/core/flex"; -import type { HookRegistry, AdminPageRegistration, AdminRouteRegistration } from "@dune/core/hooks"; -import type { ContentEditorPlugin } from "@dune/core/hooks"; +import type { HookRegistry } from "@dune/core/hooks"; +import type { AdminPageRegistration, AdminRouteRegistration, ContentEditorPlugin } from "./contributions.ts"; import type { StagingEngine } from "@dune/core/staging"; import type { CommentManager } from "./comments.ts"; import type { CollabManager } from "../collab/mod.ts"; @@ -68,7 +68,7 @@ export interface AdminContext { collab?: CollabManager; /** Inline editing manager, provided by a plugin as the `editor.inline` capability. */ inlineEdit?: InlineEditManager; - /** Custom page editor, provided by a plugin as the `editor.content` capability. Replaces the built-in block editor. */ + /** Custom page editor, provided by a plugin as the `admin.editor` capability. Replaces the built-in block editor. */ contentEditor?: ContentEditorPlugin; auditLogger?: AuditLogger; metrics?: MetricsCollector; diff --git a/src/admin/contributions.ts b/src/admin/contributions.ts new file mode 100644 index 0000000..e60720f --- /dev/null +++ b/src/admin/contributions.ts @@ -0,0 +1,141 @@ +/** + * The admin's own extension points. + * + * Core's contract names none of these: it never reads them. The admin is their + * only consumer, so it declares them here, by declaration merging into the + * capability registry, under its own `admin.` prefix. A plugin that contributes + * an admin page, an admin route or a replacement page editor imports the types + * from `@dune/plugin-admin`; importing them is also what makes the keys known to + * TypeScript: + * + * ```ts + * import type { AdminPageRegistration } from "@dune/plugin-admin"; + * api.capabilities.contribute("admin.pages", { path: "/billing", label: "Billing", permission: "billing.manage", handler }); + * ``` + * + * @module + */ + +import type { PluginRouteContext } from "@dune/core/plugin-api"; + +/** + * A custom admin page contributed by a plugin. + * + * Plugins contribute these to the `admin.pages` point: + * `api.capabilities.contribute("admin.pages", { path, label, permission, handler })`. + * Unlike `publicRoutes`, this is **not** wired by `@dune/core`: the admin reads the list + * (`capabilities.list("admin.pages")`) and mounts each page as a programmatic route, + * enforcing its declared `permission`. A site without the admin gets no admin pages. + * + * @since 4.0.0 + */ +// deno-lint-ignore no-explicit-any +export interface AdminPageRegistration { + /** + * URL path relative to the admin prefix (must start with `/`). + * Example: `/my-plugin` registers at `/admin/my-plugin`. + */ + path: string; + /** Human-readable nav label shown in the admin sidebar */ + label: string; + /** Optional icon — emoji or inline SVG */ + icon?: string; + /** + * Admin permission required to view this page. Required, like + * {@link AdminRouteRegistration.permission}: every admin contribution says + * who may use it, so a page cannot be exposed to every authenticated admin + * by leaving a field out. + */ + permission: string; + /** GET handler for the page (a method, so a full Fresh context is accepted: see {@link PluginRouteContext}). */ + handler(ctx: PluginRouteContext): Promise | Response; +} + +/** + * A guarded admin route contributed by a plugin: the way to add a `POST`, `PUT`, + * `PATCH` or `DELETE` endpoint (or a `GET` API route that is not a sidebar page) + * under the admin prefix. + * + * Plugins contribute these to the `admin.routes` point: + * `api.capabilities.contribute("admin.routes", { method, path, permission, handler })`. + * The admin reads the list + * (`capabilities.list("admin.routes")`) and mounts each route behind its own + * authentication, with the CSRF check applied to every non-safe method and the + * declared `permission` enforced, so the handler only runs for a request that + * passed all three. A site without the admin mounts nothing. + * + * A route registered directly in a plugin's `mount()` is *not* behind the + * admin's middleware (mount order is registration order, and the admin mounts + * last), so it cannot use the admin's guards: contribute it here instead. + * + * @since 4.0.0 + */ +// deno-lint-ignore no-explicit-any +export interface AdminRouteRegistration { + /** HTTP method. Anything other than `GET` is subject to the CSRF check. */ + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + /** + * URL path relative to the admin prefix (must start with `/`). + * Example: `/my-plugin/save` registers at `/admin/my-plugin/save`. + */ + path: string; + /** + * Admin permission required (including ones the plugin declares through + * `authzActions`). Required, not optional: a mutation endpoint must say who + * may call it, so it cannot be mounted open to every authenticated admin by + * leaving a field out. + */ + permission: string; + /** Handler; runs only after authentication, CSRF and permission checks pass (a method, so a full Fresh context is accepted). */ + handler(ctx: PluginRouteContext): Promise | Response; +} + +/** + * Plugin-provided replacement for the built-in block editor. + * + * Provide it as the `admin.editor` capability: `api.capabilities.provide("admin.editor", () => impl)`. + * The edit route delegates to `pageEditorHandler` instead of the default block editor. + * + * Declared by the admin plugin, which is the only reader of the `admin.editor` slot; a plugin + * that provides one imports this type from `@dune/plugin-admin`. + * + * @since 4.0.0 + */ +export interface ContentEditorPlugin { + /** + * Handle `GET /admin/pages/edit?path=...`. + * + * 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. + */ + pageEditorHandler(ctx: PluginRouteContext): Response | Promise; + + /** + * Optional WebSocket upgrade handler for real-time collaboration. + * + * When present, `GET /admin/api/content-editor/ws?path=...` delegates here + * after auth and path validation. Return a `101 Switching Protocols` response + * via `Deno.upgradeWebSocket`. When absent the WS endpoint responds 501. + */ + wsHandler?: ( + req: Request, + user: { id: string; name: string }, + ) => Response; +} + +declare module "@dune/core/plugin-api" { + interface Capabilities { + /** A replacement page editor. Consumer: the admin. */ + "admin.editor": ContentEditorPlugin; + } + interface Contributions { + /** An extra admin page. Consumer: the admin. */ + "admin.pages": AdminPageRegistration; + /** A guarded admin route (any method) under the admin prefix. Consumer: the admin, which applies authentication, CSRF and permission checks. */ + "admin.routes": AdminRouteRegistration; + } +} diff --git a/src/admin/types.ts b/src/admin/types.ts index 1cdb749..6890004 100644 --- a/src/admin/types.ts +++ b/src/admin/types.ts @@ -4,7 +4,7 @@ * @module */ -export type { ContentEditorPlugin } from "@dune/core/hooks"; +export type { AdminPageRegistration, AdminRouteRegistration, ContentEditorPlugin } from "./contributions.ts"; // Session and Role live in core to break circular deps; import for // local use AND re-export so callers importing from admin/types get them. // Session was formerly AdminSession — renamed in diff --git a/tests/admin/capabilities_e2e_test.ts b/tests/admin/capabilities_e2e_test.ts index 4c22822..d75a4f0 100644 --- a/tests/admin/capabilities_e2e_test.ts +++ b/tests/admin/capabilities_e2e_test.ts @@ -104,7 +104,7 @@ Deno.test( ); Deno.test( - "admin uses the editor.content slot another plugin provides", + "admin uses the admin.editor slot another plugin provides", { sanitizeOps: false, sanitizeResources: false }, async () => { const editor: DunePlugin = { @@ -112,15 +112,15 @@ Deno.test( version: "1.0.0", hooks: {}, setup(api) { - api.capabilities.provide("editor.content", () => ({ + api.capabilities.provide("admin.editor", () => ({ pageEditorHandler: () => new Response("custom editor"), })); }, }; await withSite([editor], async (ctx) => { - assertExists(ctx.capabilities.get("editor.content")); + assertExists(ctx.capabilities.get("admin.editor")); assertEquals( - ctx.capabilities.providers().find((p) => p.name === "editor.content")?.provider, + ctx.capabilities.providers().find((p) => p.name === "admin.editor")?.provider, "test-editor", ); }); diff --git a/tests/admin/contributions_types_test.ts b/tests/admin/contributions_types_test.ts new file mode 100644 index 0000000..cb6cfdc --- /dev/null +++ b/tests/admin/contributions_types_test.ts @@ -0,0 +1,46 @@ +/** + * The admin declares its own extension points. These checks fail to compile if + * the declaration merging, the required `permission`, or the handler typing + * regresses; the runtime assertions only keep the tests honest. + */ + +import { assertEquals } from "@std/assert"; +import type { FreshContext } from "fresh"; +import { createCapabilityRegistry } from "@dune/core/plugin-api"; +import type { AdminPageRegistration, AdminRouteRegistration, ContentEditorPlugin } from "../../mod.ts"; + +Deno.test("importing the admin's types teaches TypeScript its keys", () => { + const reg = createCapabilityRegistry(); + const page: AdminPageRegistration = { path: "/p", label: "P", permission: "pages.read", handler: () => new Response("") }; + reg.registrar("x").contribute("admin.pages", page); + assertEquals(reg.reader.list("admin.pages").map((p) => p.path), ["/p"]); +}); + +Deno.test("an admin.pages contribution must name a permission (compile-time)", () => { + const reg = createCapabilityRegistry(); + // @ts-expect-error `permission` is required on admin.pages + reg.registrar("a").contribute("admin.pages", { path: "/x", label: "X", handler: () => new Response("") }); + assertEquals(reg.reader.list("admin.pages").length, 1); +}); + +Deno.test("an admin.routes contribution must name a permission (compile-time)", () => { + const reg = createCapabilityRegistry(); + // @ts-expect-error `permission` is required on admin.routes + reg.registrar("a").contribute("admin.routes", { method: "POST", path: "/x", handler: () => new Response("") }); + assertEquals(reg.reader.list("admin.routes").length, 1); +}); + +Deno.test("contribution handlers may be written against a full Fresh context", () => { + // `handler` is declared 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 }; + assertEquals([page.path, route.path], ["/p", "/r"]); +}); + +Deno.test("admin.editor takes a ContentEditorPlugin", () => { + const reg = createCapabilityRegistry(); + const editor: ContentEditorPlugin = { pageEditorHandler: () => new Response("") }; + reg.registrar("alt-editor").provide("admin.editor", () => editor); + assertEquals(reg.reader.providers()[0].name, "admin.editor"); +}); From 6d415e31279f7a1b79a9bc4ae66b6538d3cc2b69 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 21:19:46 +0100 Subject: [PATCH 32/39] feat!: drop the inline-edit content routes; contribute the bar's admin buttons; webhooks follow page events (duneorg/dune#127) The source/commit/fields routes moved to @dune/plugin-inline-edit (/api/inline-edit/content/*), so /admin/api/content/* is gone. The admin now contributes its own buttons to the bar's `bar.actions` point (edit source in an overlay, open in admin) instead of the bar hard-coding them. Content webhooks are a listener on the page events (onPageCreate, onPageUpdate, onPageDelete, onPageMove, onWorkflowChange), so writes from any writer reach them, not only admin routes. announcePageEvent names the signed-in user as `actor`. Co-Authored-By: Claude Sonnet 5.5 --- mod.ts | 23 +- src/admin/bar-actions.ts | 62 ++++ src/admin/manifest.gen.ts | 346 +++++++++--------- src/admin/page-events.ts | 29 +- src/admin/routes/api/content/[...rest].ts | 157 -------- src/admin/routes/api/pages/[...path]/index.ts | 16 +- src/admin/routes/api/pages/index.ts | 10 +- src/admin/routes/api/workflow/transition.ts | 2 +- src/admin/webhooks.ts | 28 ++ src/admin/workflow-actions.ts | 11 +- tests/admin/bar_actions_test.ts | 25 ++ tests/admin/page_events_test.ts | 9 +- tests/admin/webhook_listener_test.ts | 101 +++++ tests/admin/workflow_actions_test.ts | 9 +- 14 files changed, 441 insertions(+), 387 deletions(-) create mode 100644 src/admin/bar-actions.ts delete mode 100644 src/admin/routes/api/content/[...rest].ts create mode 100644 tests/admin/bar_actions_test.ts create mode 100644 tests/admin/webhook_listener_test.ts diff --git a/mod.ts b/mod.ts index dc7e8f6..121d3a8 100644 --- a/mod.ts +++ b/mod.ts @@ -37,6 +37,8 @@ import { getDuneAdminIslands, mountAdminPanel } from "./src/admin/mount.ts"; import * as corePlugins from "@dune/core/plugins"; import denoJson from "./deno.json" with { type: "json" }; import { adminSettings } from "./src/admin/settings.ts"; +import { adminBarActions } from "./src/admin/bar-actions.ts"; +import { listenForContentWebhooks } from "./src/admin/webhooks.ts"; // ── Core-instance handshake ────────────────────────────────────────────────── // Re-export the CORE_INSTANCE sentinel and CORE_VERSION that THIS package's @@ -149,6 +151,15 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { await liveAdminContext?.executeScheduledAction?.(action); }, })); + + // The admin's own buttons on the inline-edit bar. `bar.actions` is that + // plugin's extension point; contributing without importing its types + // keeps it an optional neighbour (a site without it simply never reads + // this). + (api.capabilities as unknown as { contribute(point: string, item: unknown): void }).contribute( + "bar.actions", + (ctx: { page: { sourcePath: string } | null }) => adminBarActions(adminCfg.path, ctx), + ); }, async mount({ app, runtime, services, capabilities }: MountApi) { @@ -235,7 +246,7 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { return; } try { - await applyWorkflowTransition({ engine: runtime.engine, storage, config, hooks: runtime.hooks, workflow, services }, action.sourcePath, newStatus); + await applyWorkflowTransition({ engine: runtime.engine, storage, config, hooks: runtime.hooks, workflow }, action.sourcePath, newStatus); } catch (err) { logger.error("scheduler.action-failed", { actionId: action.id, @@ -282,6 +293,16 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { }).catch(() => {}); }); + // Outbound content webhooks follow the page events, whoever wrote the + // page (an admin route, the inline-edit bar, MCP, the CLI): the rule is + // that the writer fires the event, and this is a listener like any other. + listenForContentWebhooks({ + hooks: runtime.hooks, + endpoints: () => adminSettings(config).webhooks, + runtimeDir, + security: services.security, + }); + // ── Default content editor ──────────────────────────────────────────────── const inlineEdit = capabilities.get("editor.inline"); const contentEditor = capabilities.get("admin.editor") ?? createBlockEditorPlugin(); diff --git a/src/admin/bar-actions.ts b/src/admin/bar-actions.ts new file mode 100644 index 0000000..625ba8b --- /dev/null +++ b/src/admin/bar-actions.ts @@ -0,0 +1,62 @@ +/** + * What the admin adds to the inline-edit bar. + * + * The bar belongs to `@dune/plugin-inline-edit`, which knows nothing about an + * admin; this plugin contributes its own buttons to the bar's `bar.actions` + * point: a link to the page's editor here, and a button that opens that editor + * in an overlay on the page. + * + * The contribution is made without importing the bar plugin's types (that would + * make an optional neighbour a dependency), so the shapes below are this + * plugin's own restatement of the `BarAction` contract. + * + * @module + */ + +/** A bar button, as `@dune/plugin-inline-edit` renders it. */ +export interface AdminBarAction { + id: string; + label: string; + /** Renders as a link. */ + href?: string; + /** Renders as a button running this script. */ + onClick?: string; +} + +/** The part of the bar's per-response context this plugin reads. */ +export interface AdminBarContext { + page: { sourcePath: string } | null; +} + +/** + * Script for the "Edit source" button: show the admin's page editor + * (`embedded=1` drops its sidebar and topbar, redundant over a content page) in + * a full-screen overlay. Built on first click so nothing loads until asked; + * Escape or Close hides it. + */ +function overlayScript(embeddedUrl: string): string { + return `(function(){var id='dune-source-overlay',o=document.getElementById(id);` + + `if(o){o.style.display='flex';return;}` + + `var s=document.createElement('style');s.textContent='@media print{#'+id+'{display:none!important}}';document.head.appendChild(s);` + + `o=document.createElement('div');o.id=id;` + + `o.style.cssText='position:fixed;inset:0;z-index:100000;background:rgba(0,0,0,.5);display:flex;flex-direction:column';` + + `var b=document.createElement('div');b.style.cssText='flex:none;display:flex;justify-content:flex-end;padding:8px 16px;background:#1a1a2e';` + + `var c=document.createElement('button');c.type='button';c.textContent='\\u2715 Close';` + + `c.style.cssText='background:rgba(255,255,255,.15);color:#fff;border:none;border-radius:4px;padding:4px 12px;font-size:12px;cursor:pointer;font-family:inherit';` + + `function hide(){o.style.display='none';}c.onclick=hide;b.appendChild(c);` + + `var f=document.createElement('iframe');f.title='Edit source';f.style.cssText='flex:1;width:100%;border:none;background:#fff';` + + `f.src=${JSON.stringify(embeddedUrl)};o.appendChild(b);o.appendChild(f);document.body.appendChild(o);` + + `document.addEventListener('keydown',function(e){if(e.key==='Escape')hide();});})()`; +} + +/** The admin's bar contribution for a page, or nothing off content pages. */ +export function adminBarActions(prefix: string, ctx: AdminBarContext): AdminBarAction[] { + if (!ctx.page) return []; + // The edit route is `${prefix}/pages/edit?path=` (there is no + // `/pages/` route). + const editUrl = `${prefix}/pages/edit?path=${encodeURIComponent(ctx.page.sourcePath)}`; + return [ + { id: "admin:edit-source", label: "✎ Edit source", onClick: overlayScript(`${editUrl}&embedded=1`) }, + { id: "admin:open", label: "Open in admin →", href: editUrl }, + ]; +} diff --git a/src/admin/manifest.gen.ts b/src/admin/manifest.gen.ts index 5c81817..1eb48b4 100644 --- a/src/admin/manifest.gen.ts +++ b/src/admin/manifest.gen.ts @@ -11,93 +11,92 @@ import * as r4 from "./routes/api/config/index.ts"; import * as r5 from "./routes/api/config/theme.ts"; import * as r6 from "./routes/api/config/theme-config.ts"; import * as r7 from "./routes/api/config/themes.ts"; -import * as r8 from "./routes/api/content/[...rest].ts"; -import * as r9 from "./routes/api/content-editor/ws.ts"; -import * as r10 from "./routes/api/csrf.ts"; -import * as r11 from "./routes/api/dashboard.ts"; -import * as r12 from "./routes/api/dev/apply.ts"; -import * as r13 from "./routes/api/editor/parse.ts"; -import * as r14 from "./routes/api/editor/serialize.ts"; -import * as r15 from "./routes/api/email-preview.ts"; -import * as r16 from "./routes/api/email-preview/[id].ts"; -import * as r17 from "./routes/api/flex/[type]/index.ts"; -import * as r18 from "./routes/api/flex/[type]/[id]/index.ts"; -import * as r19 from "./routes/api/history/[...rest].ts"; -import * as r20 from "./routes/api/i18n/memory/index.ts"; -import * as r21 from "./routes/api/i18n/memory/rebuild.ts"; -import * as r22 from "./routes/api/i18n/mt-status.ts"; -import * as r23 from "./routes/api/i18n/translate-page.ts"; -import * as r24 from "./routes/api/i18n/translate-segment.ts"; -import * as r25 from "./routes/api/inline-edit/presence.ts"; -import * as r26 from "./routes/api/introspect.ts"; -import * as r27 from "./routes/api/jobs.ts"; -import * as r28 from "./routes/api/jobs/[name]/run.ts"; -import * as r29 from "./routes/api/media/index.ts"; -import * as r30 from "./routes/api/media/meta.ts"; -import * as r31 from "./routes/api/media/upload.ts"; -import * as r32 from "./routes/api/metrics.ts"; -import * as r33 from "./routes/api/page-source.ts"; -import * as r34 from "./routes/api/pages/index.ts"; -import * as r35 from "./routes/api/pages/reorder.ts"; -import * as r36 from "./routes/api/pages/translate.ts"; -import * as r37 from "./routes/api/pages/[...path]/index.ts"; -import * as r38 from "./routes/api/pages/[...path]/comments/index.ts"; -import * as r39 from "./routes/api/pages/[...path]/comments/[id]/index.ts"; -import * as r40 from "./routes/api/pages/[...path]/comments/[id]/resolve.ts"; -import * as r41 from "./routes/api/plugins/index.ts"; -import * as r42 from "./routes/api/plugins/install.ts"; -import * as r43 from "./routes/api/plugins/[name]/config.ts"; -import * as r44 from "./routes/api/preview.ts"; -import * as r45 from "./routes/api/registry/plugins.ts"; -import * as r46 from "./routes/api/registry/themes.ts"; -import * as r47 from "./routes/api/render-markdown.ts"; -import * as r48 from "./routes/api/search/analytics.ts"; -import * as r49 from "./routes/api/search/engines.ts"; -import * as r50 from "./routes/api/sections.ts"; -import * as r51 from "./routes/api/staging/[...path]/index.ts"; -import * as r52 from "./routes/api/staging/[...path]/publish.ts"; -import * as r53 from "./routes/api/theme-preview.ts"; -import * as r54 from "./routes/api/themes/install.ts"; -import * as r55 from "./routes/api/users/index.ts"; -import * as r56 from "./routes/api/users/[id]/index.ts"; -import * as r57 from "./routes/api/users/[id]/password.ts"; -import * as r58 from "./routes/api/webhooks/deliveries.ts"; -import * as r59 from "./routes/api/workflow/schedule/index.ts"; -import * as r60 from "./routes/api/workflow/schedule/[id].ts"; -import * as r61 from "./routes/api/workflow/scheduled/[...path].ts"; -import * as r62 from "./routes/api/workflow/stages.ts"; -import * as r63 from "./routes/api/workflow/status/[...path].ts"; -import * as r64 from "./routes/api/workflow/transition.ts"; -import * as r65 from "./routes/audit/index.tsx"; -import * as r66 from "./routes/collab/edit-ws.ts"; -import * as r67 from "./routes/collab/ws.ts"; -import * as r68 from "./routes/config/index.tsx"; -import * as r69 from "./routes/email-preview/index.tsx"; -import * as r70 from "./routes/flex/index.tsx"; -import * as r71 from "./routes/flex/[type]/index.tsx"; -import * as r72 from "./routes/flex/[type]/[id].tsx"; -import * as r73 from "./routes/i18n/index.tsx"; -import * as r74 from "./routes/i18n/memory.tsx"; -import * as r75 from "./routes/jobs/index.tsx"; -import * as r76 from "./routes/login.tsx"; -import * as r77 from "./routes/login/logout.ts"; -import * as r78 from "./routes/marketplace/index.tsx"; -import * as r79 from "./routes/media/index.tsx"; -import * as r80 from "./routes/metrics/index.tsx"; -import * as r81 from "./routes/pages/index.tsx"; -import * as r82 from "./routes/pages/builder.tsx"; -import * as r83 from "./routes/pages/edit.tsx"; -import * as r84 from "./routes/pages/history.tsx"; -import * as r85 from "./routes/plugins/index.tsx"; -import * as r86 from "./routes/search/index.tsx"; -import * as r87 from "./routes/submissions/index.tsx"; -import * as r88 from "./routes/submissions/[form]/index.tsx"; -import * as r89 from "./routes/submissions/[form]/[id]/index.tsx"; -import * as r90 from "./routes/submissions/[form]/[id]/delete.ts"; -import * as r91 from "./routes/submissions/[form]/[id]/files/[filename].ts"; -import * as r92 from "./routes/submissions/[form]/[id]/status.ts"; -import * as r93 from "./routes/themes/index.tsx"; -import * as r94 from "./routes/users/index.tsx"; +import * as r8 from "./routes/api/content-editor/ws.ts"; +import * as r9 from "./routes/api/csrf.ts"; +import * as r10 from "./routes/api/dashboard.ts"; +import * as r11 from "./routes/api/dev/apply.ts"; +import * as r12 from "./routes/api/editor/parse.ts"; +import * as r13 from "./routes/api/editor/serialize.ts"; +import * as r14 from "./routes/api/email-preview.ts"; +import * as r15 from "./routes/api/email-preview/[id].ts"; +import * as r16 from "./routes/api/flex/[type]/index.ts"; +import * as r17 from "./routes/api/flex/[type]/[id]/index.ts"; +import * as r18 from "./routes/api/history/[...rest].ts"; +import * as r19 from "./routes/api/i18n/memory/index.ts"; +import * as r20 from "./routes/api/i18n/memory/rebuild.ts"; +import * as r21 from "./routes/api/i18n/mt-status.ts"; +import * as r22 from "./routes/api/i18n/translate-page.ts"; +import * as r23 from "./routes/api/i18n/translate-segment.ts"; +import * as r24 from "./routes/api/inline-edit/presence.ts"; +import * as r25 from "./routes/api/introspect.ts"; +import * as r26 from "./routes/api/jobs.ts"; +import * as r27 from "./routes/api/jobs/[name]/run.ts"; +import * as r28 from "./routes/api/media/index.ts"; +import * as r29 from "./routes/api/media/meta.ts"; +import * as r30 from "./routes/api/media/upload.ts"; +import * as r31 from "./routes/api/metrics.ts"; +import * as r32 from "./routes/api/page-source.ts"; +import * as r33 from "./routes/api/pages/index.ts"; +import * as r34 from "./routes/api/pages/reorder.ts"; +import * as r35 from "./routes/api/pages/translate.ts"; +import * as r36 from "./routes/api/pages/[...path]/index.ts"; +import * as r37 from "./routes/api/pages/[...path]/comments/index.ts"; +import * as r38 from "./routes/api/pages/[...path]/comments/[id]/index.ts"; +import * as r39 from "./routes/api/pages/[...path]/comments/[id]/resolve.ts"; +import * as r40 from "./routes/api/plugins/index.ts"; +import * as r41 from "./routes/api/plugins/install.ts"; +import * as r42 from "./routes/api/plugins/[name]/config.ts"; +import * as r43 from "./routes/api/preview.ts"; +import * as r44 from "./routes/api/registry/plugins.ts"; +import * as r45 from "./routes/api/registry/themes.ts"; +import * as r46 from "./routes/api/render-markdown.ts"; +import * as r47 from "./routes/api/search/analytics.ts"; +import * as r48 from "./routes/api/search/engines.ts"; +import * as r49 from "./routes/api/sections.ts"; +import * as r50 from "./routes/api/staging/[...path]/index.ts"; +import * as r51 from "./routes/api/staging/[...path]/publish.ts"; +import * as r52 from "./routes/api/theme-preview.ts"; +import * as r53 from "./routes/api/themes/install.ts"; +import * as r54 from "./routes/api/users/index.ts"; +import * as r55 from "./routes/api/users/[id]/index.ts"; +import * as r56 from "./routes/api/users/[id]/password.ts"; +import * as r57 from "./routes/api/webhooks/deliveries.ts"; +import * as r58 from "./routes/api/workflow/schedule/index.ts"; +import * as r59 from "./routes/api/workflow/schedule/[id].ts"; +import * as r60 from "./routes/api/workflow/scheduled/[...path].ts"; +import * as r61 from "./routes/api/workflow/stages.ts"; +import * as r62 from "./routes/api/workflow/status/[...path].ts"; +import * as r63 from "./routes/api/workflow/transition.ts"; +import * as r64 from "./routes/audit/index.tsx"; +import * as r65 from "./routes/collab/edit-ws.ts"; +import * as r66 from "./routes/collab/ws.ts"; +import * as r67 from "./routes/config/index.tsx"; +import * as r68 from "./routes/email-preview/index.tsx"; +import * as r69 from "./routes/flex/index.tsx"; +import * as r70 from "./routes/flex/[type]/index.tsx"; +import * as r71 from "./routes/flex/[type]/[id].tsx"; +import * as r72 from "./routes/i18n/index.tsx"; +import * as r73 from "./routes/i18n/memory.tsx"; +import * as r74 from "./routes/jobs/index.tsx"; +import * as r75 from "./routes/login.tsx"; +import * as r76 from "./routes/login/logout.ts"; +import * as r77 from "./routes/marketplace/index.tsx"; +import * as r78 from "./routes/media/index.tsx"; +import * as r79 from "./routes/metrics/index.tsx"; +import * as r80 from "./routes/pages/index.tsx"; +import * as r81 from "./routes/pages/builder.tsx"; +import * as r82 from "./routes/pages/edit.tsx"; +import * as r83 from "./routes/pages/history.tsx"; +import * as r84 from "./routes/plugins/index.tsx"; +import * as r85 from "./routes/search/index.tsx"; +import * as r86 from "./routes/submissions/index.tsx"; +import * as r87 from "./routes/submissions/[form]/index.tsx"; +import * as r88 from "./routes/submissions/[form]/[id]/index.tsx"; +import * as r89 from "./routes/submissions/[form]/[id]/delete.ts"; +import * as r90 from "./routes/submissions/[form]/[id]/files/[filename].ts"; +import * as r91 from "./routes/submissions/[form]/[id]/status.ts"; +import * as r92 from "./routes/themes/index.tsx"; +import * as r93 from "./routes/users/index.tsx"; /** Shape of an fs-convention route module (subset Fresh cares about). */ export interface AdminRouteModule { @@ -125,93 +124,92 @@ export const adminRoutes: AdminRouteEntry[] = [ { pattern: "/api/config/theme", mod: r5 }, { pattern: "/api/config/theme-config", mod: r6 }, { pattern: "/api/config/themes", mod: r7 }, - { pattern: "/api/content/:rest*", mod: r8 }, - { pattern: "/api/content-editor/ws", mod: r9 }, - { pattern: "/api/csrf", mod: r10 }, - { pattern: "/api/dashboard", mod: r11 }, - { pattern: "/api/dev/apply", mod: r12 }, - { pattern: "/api/editor/parse", mod: r13 }, - { pattern: "/api/editor/serialize", mod: r14 }, - { pattern: "/api/email-preview", mod: r15 }, - { pattern: "/api/email-preview/:id", mod: r16 }, - { pattern: "/api/flex/:type", mod: r17 }, - { pattern: "/api/flex/:type/:id", mod: r18 }, - { pattern: "/api/history/:rest*", mod: r19 }, - { pattern: "/api/i18n/memory", mod: r20 }, - { pattern: "/api/i18n/memory/rebuild", mod: r21 }, - { pattern: "/api/i18n/mt-status", mod: r22 }, - { pattern: "/api/i18n/translate-page", mod: r23 }, - { pattern: "/api/i18n/translate-segment", mod: r24 }, - { pattern: "/api/inline-edit/presence", mod: r25 }, - { pattern: "/api/introspect", mod: r26 }, - { pattern: "/api/jobs", mod: r27 }, - { pattern: "/api/jobs/:name/run", mod: r28 }, - { pattern: "/api/media", mod: r29 }, - { pattern: "/api/media/meta", mod: r30 }, - { pattern: "/api/media/upload", mod: r31 }, - { pattern: "/api/metrics", mod: r32 }, - { pattern: "/api/page-source", mod: r33 }, - { pattern: "/api/pages", mod: r34 }, - { pattern: "/api/pages/reorder", mod: r35 }, - { pattern: "/api/pages/translate", mod: r36 }, - { pattern: "/api/pages/:path*", mod: r37 }, - { pattern: "/api/pages/:path*/comments", mod: r38 }, - { pattern: "/api/pages/:path*/comments/:id", mod: r39 }, - { pattern: "/api/pages/:path*/comments/:id/resolve", mod: r40 }, - { pattern: "/api/plugins", mod: r41 }, - { pattern: "/api/plugins/install", mod: r42 }, - { pattern: "/api/plugins/:name/config", mod: r43 }, - { pattern: "/api/preview", mod: r44 }, - { pattern: "/api/registry/plugins", mod: r45 }, - { pattern: "/api/registry/themes", mod: r46 }, - { pattern: "/api/render-markdown", mod: r47 }, - { pattern: "/api/search/analytics", mod: r48 }, - { pattern: "/api/search/engines", mod: r49 }, - { pattern: "/api/sections", mod: r50 }, - { pattern: "/api/staging/:path*", mod: r51 }, - { pattern: "/api/staging/:path*/publish", mod: r52 }, - { pattern: "/api/theme-preview", mod: r53 }, - { pattern: "/api/themes/install", mod: r54 }, - { pattern: "/api/users", mod: r55 }, - { pattern: "/api/users/:id", mod: r56 }, - { pattern: "/api/users/:id/password", mod: r57 }, - { pattern: "/api/webhooks/deliveries", mod: r58 }, - { pattern: "/api/workflow/schedule", mod: r59 }, - { pattern: "/api/workflow/schedule/:id", mod: r60 }, - { pattern: "/api/workflow/scheduled/:path*", mod: r61 }, - { pattern: "/api/workflow/stages", mod: r62 }, - { pattern: "/api/workflow/status/:path*", mod: r63 }, - { pattern: "/api/workflow/transition", mod: r64 }, - { pattern: "/audit", mod: r65 }, - { pattern: "/collab/edit-ws", mod: r66 }, - { pattern: "/collab/ws", mod: r67 }, - { pattern: "/config", mod: r68 }, - { pattern: "/email-preview", mod: r69 }, - { pattern: "/flex", mod: r70 }, - { pattern: "/flex/:type", mod: r71 }, - { pattern: "/flex/:type/:id", mod: r72 }, - { pattern: "/i18n", mod: r73 }, - { pattern: "/i18n/memory", mod: r74 }, - { pattern: "/jobs", mod: r75 }, - { pattern: "/login", mod: r76 }, - { pattern: "/login/logout", mod: r77 }, - { pattern: "/marketplace", mod: r78 }, - { pattern: "/media", mod: r79 }, - { pattern: "/metrics", mod: r80 }, - { pattern: "/pages", mod: r81 }, - { pattern: "/pages/builder", mod: r82 }, - { pattern: "/pages/edit", mod: r83 }, - { pattern: "/pages/history", mod: r84 }, - { pattern: "/plugins", mod: r85 }, - { pattern: "/search", mod: r86 }, - { pattern: "/submissions", mod: r87 }, - { pattern: "/submissions/:form", mod: r88 }, - { pattern: "/submissions/:form/:id", mod: r89 }, - { pattern: "/submissions/:form/:id/delete", mod: r90 }, - { pattern: "/submissions/:form/:id/files/:filename", mod: r91 }, - { pattern: "/submissions/:form/:id/status", mod: r92 }, - { pattern: "/themes", mod: r93 }, - { pattern: "/users", mod: r94 }, + { pattern: "/api/content-editor/ws", mod: r8 }, + { pattern: "/api/csrf", mod: r9 }, + { pattern: "/api/dashboard", mod: r10 }, + { pattern: "/api/dev/apply", mod: r11 }, + { pattern: "/api/editor/parse", mod: r12 }, + { pattern: "/api/editor/serialize", mod: r13 }, + { pattern: "/api/email-preview", mod: r14 }, + { pattern: "/api/email-preview/:id", mod: r15 }, + { pattern: "/api/flex/:type", mod: r16 }, + { pattern: "/api/flex/:type/:id", mod: r17 }, + { pattern: "/api/history/:rest*", mod: r18 }, + { pattern: "/api/i18n/memory", mod: r19 }, + { pattern: "/api/i18n/memory/rebuild", mod: r20 }, + { pattern: "/api/i18n/mt-status", mod: r21 }, + { pattern: "/api/i18n/translate-page", mod: r22 }, + { pattern: "/api/i18n/translate-segment", mod: r23 }, + { pattern: "/api/inline-edit/presence", mod: r24 }, + { pattern: "/api/introspect", mod: r25 }, + { pattern: "/api/jobs", mod: r26 }, + { pattern: "/api/jobs/:name/run", mod: r27 }, + { pattern: "/api/media", mod: r28 }, + { pattern: "/api/media/meta", mod: r29 }, + { pattern: "/api/media/upload", mod: r30 }, + { pattern: "/api/metrics", mod: r31 }, + { pattern: "/api/page-source", mod: r32 }, + { pattern: "/api/pages", mod: r33 }, + { pattern: "/api/pages/reorder", mod: r34 }, + { pattern: "/api/pages/translate", mod: r35 }, + { pattern: "/api/pages/:path*", mod: r36 }, + { pattern: "/api/pages/:path*/comments", mod: r37 }, + { pattern: "/api/pages/:path*/comments/:id", mod: r38 }, + { pattern: "/api/pages/:path*/comments/:id/resolve", mod: r39 }, + { pattern: "/api/plugins", mod: r40 }, + { pattern: "/api/plugins/install", mod: r41 }, + { pattern: "/api/plugins/:name/config", mod: r42 }, + { pattern: "/api/preview", mod: r43 }, + { pattern: "/api/registry/plugins", mod: r44 }, + { pattern: "/api/registry/themes", mod: r45 }, + { pattern: "/api/render-markdown", mod: r46 }, + { pattern: "/api/search/analytics", mod: r47 }, + { pattern: "/api/search/engines", mod: r48 }, + { pattern: "/api/sections", mod: r49 }, + { pattern: "/api/staging/:path*", mod: r50 }, + { pattern: "/api/staging/:path*/publish", mod: r51 }, + { pattern: "/api/theme-preview", mod: r52 }, + { pattern: "/api/themes/install", mod: r53 }, + { pattern: "/api/users", mod: r54 }, + { pattern: "/api/users/:id", mod: r55 }, + { pattern: "/api/users/:id/password", mod: r56 }, + { pattern: "/api/webhooks/deliveries", mod: r57 }, + { pattern: "/api/workflow/schedule", mod: r58 }, + { pattern: "/api/workflow/schedule/:id", mod: r59 }, + { pattern: "/api/workflow/scheduled/:path*", mod: r60 }, + { pattern: "/api/workflow/stages", mod: r61 }, + { pattern: "/api/workflow/status/:path*", mod: r62 }, + { pattern: "/api/workflow/transition", mod: r63 }, + { pattern: "/audit", mod: r64 }, + { pattern: "/collab/edit-ws", mod: r65 }, + { pattern: "/collab/ws", mod: r66 }, + { pattern: "/config", mod: r67 }, + { pattern: "/email-preview", mod: r68 }, + { pattern: "/flex", mod: r69 }, + { pattern: "/flex/:type", mod: r70 }, + { pattern: "/flex/:type/:id", mod: r71 }, + { pattern: "/i18n", mod: r72 }, + { pattern: "/i18n/memory", mod: r73 }, + { pattern: "/jobs", mod: r74 }, + { pattern: "/login", mod: r75 }, + { pattern: "/login/logout", mod: r76 }, + { pattern: "/marketplace", mod: r77 }, + { pattern: "/media", mod: r78 }, + { pattern: "/metrics", mod: r79 }, + { pattern: "/pages", mod: r80 }, + { pattern: "/pages/builder", mod: r81 }, + { pattern: "/pages/edit", mod: r82 }, + { pattern: "/pages/history", mod: r83 }, + { pattern: "/plugins", mod: r84 }, + { pattern: "/search", mod: r85 }, + { pattern: "/submissions", mod: r86 }, + { pattern: "/submissions/:form", mod: r87 }, + { pattern: "/submissions/:form/:id", mod: r88 }, + { pattern: "/submissions/:form/:id/delete", mod: r89 }, + { pattern: "/submissions/:form/:id/files/:filename", mod: r90 }, + { pattern: "/submissions/:form/:id/status", mod: r91 }, + { pattern: "/themes", mod: r92 }, + { pattern: "/users", mod: r93 }, ]; /** Island files under src/admin/islands/, for Builder.registerIsland(). */ diff --git a/src/admin/page-events.ts b/src/admin/page-events.ts index da4a41e..a3e79ca 100644 --- a/src/admin/page-events.ts +++ b/src/admin/page-events.ts @@ -1,19 +1,19 @@ /** - * Announce a page write to plugins and content webhooks. + * Announce a page write to plugins. * * Whoever writes a page fires its event: core never does it for the writer, and * search indexes, webhooks and audit plugins only learn of a write through it. * Every admin route that creates, rewrites, deletes or moves a page calls this - * after the file is on disk. Fire-and-forget: a failing handler or an - * unreachable webhook never fails the request. + * after the file is on disk, naming the signed-in user as `actor`. The admin's + * own content webhooks are one of the listeners (registered in `mount()`), so + * they also see writes made elsewhere. Fire-and-forget: a failing handler never + * fails the request. * * @module */ import type { FreshContext } from "fresh"; import type { AdminState } from "./types.ts"; -import { fireContentWebhooks } from "./webhooks.ts"; -import { adminSettings } from "./settings.ts"; /** Paths are relative to the content directory. */ export type PageEvent = @@ -22,19 +22,12 @@ export type PageEvent = | { event: "onPageDelete"; payload: { sourcePath: string } } | { event: "onPageMove"; payload: { moves: Array<{ from: string; to: string }> } }; -/** Fire a page event to hook handlers and to the configured content webhooks. */ +/** Fire a page event to the hook handlers, with the signed-in user as `actor`. */ export function announcePageEvent(ctx: FreshContext, e: PageEvent): void { - const { hooks, config, services } = ctx.state.adminContext; - if (hooks) { - hooks.fire(e.event, e.payload).catch((err: unknown) => - console.warn(`[dune] ${e.event} hook failed: ${err instanceof Error ? err.message : err}`) - ); - } - fireContentWebhooks( - adminSettings(config).webhooks, - e.event, - e.payload, - services.paths.runtimeDir, - services.security, + const { hooks } = ctx.state.adminContext; + const user = ctx.state.auth?.user; + const actor = user ? (user.username ?? user.name ?? user.id) : undefined; + hooks?.fire(e.event, actor ? { ...e.payload, actor } : e.payload).catch((err: unknown) => + console.warn(`[dune] ${e.event} hook failed: ${err instanceof Error ? err.message : err}`) ); } diff --git a/src/admin/routes/api/content/[...rest].ts b/src/admin/routes/api/content/[...rest].ts deleted file mode 100644 index 5a1285c..0000000 --- a/src/admin/routes/api/content/[...rest].ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Inline editing content API (v0.16+). - * - * GET /admin/api/content/:encodedSourcePath/source - * Return the raw Markdown source of the content file (body only, no - * frontmatter). Used by the auto-overlay textarea editor to pre-populate - * the edit area with the current content. - * Response: `{ body: "# Heading\n..." }` - * - * POST /admin/api/content/:encodedSourcePath/commit - * Flush the live editing state to a history revision and write committed - * Markdown back to the `.md` file. This is the "Save" action. - * - * PATCH /admin/api/content/:encodedSourcePath/fields - * Update individual frontmatter fields without touching the Markdown body. - * Body: `{ fields: { title: "New title", date: "2026-06-08", ... } }` - * Special key `__body` replaces the Markdown body (used by auto-overlay). - * - * In all routes `:encodedSourcePath` is the URL-encoded page source path, - * e.g. `pages%2Fabout%2Fdefault.md`. - */ - -import type { AdminState } from "../../../types.ts"; -import { requirePermission, requireTsxWrite, requireOwnedPage, json, serverError, csrfCheck, validatePagePath } from "../_utils.ts"; -import { splitFile } from "../../../../collab/manager.ts"; -import type { FreshContext } from "fresh"; - -export const handler = { - // ── GET /admin/api/content/:path/source ───────────────────────────────────── - async GET(ctx: FreshContext) { - const denied = await requirePermission(ctx, "pages.read"); - if (denied) return denied; - - const rest = ctx.params.rest; - const parts = rest.split("/"); - - if (parts.length !== 2 || parts[1] !== "source") { - return json({ error: "Invalid route" }, 400); - } - - const sourcePath = decodeURIComponent(parts[0]); - if (!validatePagePath(sourcePath)) return json({ error: "Invalid path" }, 400); - - const { storage, config } = ctx.state.adminContext; - const contentDir = config.system.content.dir; - const filePath = `${contentDir}/${sourcePath}`; - - try { - const raw = await storage.readText(filePath); - const { body } = splitFile(raw); - return json({ body }); - } catch (err) { - return serverError(err); - } - }, - - // ── POST /admin/api/content/:path/commit ──────────────────────────────────── - async POST(ctx: FreshContext) { - const denied = await requirePermission(ctx, "pages.update"); - if (denied) return denied; - - const csrfErr = await csrfCheck(ctx); - if (csrfErr) return csrfErr; - - const { inlineEdit } = ctx.state.adminContext; - if (!inlineEdit) return json({ error: "Inline editing not enabled" }, 501); - - // rest = "encodedPath/commit" or "encodedPath/fields" - const rest = ctx.params.rest; - const parts = rest.split("/"); - - if (parts.length !== 2) { - return json({ error: "Invalid route" }, 400); - } - - const sourcePath = decodeURIComponent(parts[0]); - if (!validatePagePath(sourcePath)) return json({ error: "Invalid path" }, 400); - const tsxDenied = requireTsxWrite(ctx, sourcePath); - if (tsxDenied) return tsxDenied; - const ownerDenied = await requireOwnedPage(ctx, sourcePath); - if (ownerDenied) return ownerDenied; - - const action = parts[1]; - if (action !== "commit") { - return json({ error: "Use PATCH for field updates" }, 405); - } - - const authResult = ctx.state.auth; - const author = authResult?.user?.username ?? "unknown"; - - try { - await inlineEdit.commit(sourcePath, author); - return json({ ok: true }); - } catch (err) { - return serverError(err); - } - }, - - // ── PATCH /admin/api/content/:path/fields ─────────────────────────────────── - async PATCH(ctx: FreshContext) { - const denied = await requirePermission(ctx, "pages.update"); - if (denied) return denied; - - const csrfErr = await csrfCheck(ctx); - if (csrfErr) return csrfErr; - - const { inlineEdit } = ctx.state.adminContext; - if (!inlineEdit) return json({ error: "Inline editing not enabled" }, 501); - - const rest = ctx.params.rest; - const parts = rest.split("/"); - - if (parts.length !== 2) { - return json({ error: "Invalid route" }, 400); - } - - const sourcePath = decodeURIComponent(parts[0]); - if (!validatePagePath(sourcePath)) return json({ error: "Invalid path" }, 400); - const tsxDenied = requireTsxWrite(ctx, sourcePath); - if (tsxDenied) return tsxDenied; - const ownerDenied = await requireOwnedPage(ctx, sourcePath); - if (ownerDenied) return ownerDenied; - - const action = parts[1]; - if (action !== "fields") { - return json({ error: "Use POST for commit" }, 405); - } - - let body: unknown; - try { - body = await ctx.req.json(); - } catch { - return json({ error: "Invalid JSON body" }, 400); - } - - if ( - !body || - typeof body !== "object" || - !("fields" in body) || - typeof (body as { fields: unknown }).fields !== "object" - ) { - return json({ error: "Body must be { fields: { ... } }" }, 400); - } - - const fields = (body as { fields: Record }).fields; - - const authResult = ctx.state.auth; - const author = authResult?.user?.username ?? "unknown"; - - try { - await inlineEdit.patchFields(sourcePath, fields, author); - return json({ ok: true }); - } catch (err) { - return serverError(err); - } - }, -}; diff --git a/src/admin/routes/api/pages/[...path]/index.ts b/src/admin/routes/api/pages/[...path]/index.ts index 2c60120..e9121c5 100644 --- a/src/admin/routes/api/pages/[...path]/index.ts +++ b/src/admin/routes/api/pages/[...path]/index.ts @@ -12,7 +12,7 @@ import type { AdminState } from "../../../../types.ts"; import { requirePermission, requireTsxWrite, requirePageOwner, requireOwnedPage, pageOwnerId, json, serverError, actorFromAuth, getClientIp, csrfCheck, validatePagePath } from "../../_utils.ts"; import { stringify as stringifyYaml } from "@std/yaml"; -import { fireContentWebhooks } from "../../../../../admin/webhooks.ts"; +import { announcePageEvent } from "../../../../page-events.ts"; import type { PageFrontmatter } from "@dune/core/content/types"; import type { FreshContext } from "fresh"; import { adminSettings } from "../../../../settings.ts"; @@ -69,7 +69,7 @@ export const handler = { const denied = await requirePermission(ctx, "pages.update"); if (denied) return denied; - const { engine, storage, config, hooks, auditLogger, services } = ctx.state.adminContext; + const { engine, storage, config, auditLogger, services } = ctx.state.adminContext; const authResult = ctx.state.auth; const pagePath = decodeURIComponent(ctx.params.path); if (!validatePagePath(pagePath)) return json({ error: "Invalid path" }, 400); @@ -130,10 +130,7 @@ export const handler = { await engine.rebuild(); await maybeGitCommit(filePath, pagePath, undefined, config); - const webhookEndpoints = adminSettings(config).webhooks; - const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; - if (hooks) hooks.fire("onPageUpdate", { sourcePath: page.sourcePath }).catch(() => {}); - fireContentWebhooks(webhookEndpoints, "onPageUpdate", { sourcePath: page.sourcePath }, runtimeDir, ctx.state.adminContext.services.security); + announcePageEvent(ctx, { event: "onPageUpdate", payload: { sourcePath: page.sourcePath } }); void auditLogger?.log({ event: "page.update", @@ -157,7 +154,7 @@ export const handler = { const denied = await requirePermission(ctx, "pages.delete"); if (denied) return denied; - const { engine, storage, config, hooks, auditLogger } = ctx.state.adminContext; + const { engine, storage, config, auditLogger } = ctx.state.adminContext; const authResult = ctx.state.auth; const pagePath = decodeURIComponent(ctx.params.path); if (!validatePagePath(pagePath)) return json({ error: "Invalid path" }, 400); @@ -172,10 +169,7 @@ export const handler = { await storage.delete(`${contentDir}/${page.sourcePath}`); await engine.rebuild(); - const webhookEndpoints = adminSettings(config).webhooks; - const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; - if (hooks) hooks.fire("onPageDelete", { sourcePath: page.sourcePath }).catch(() => {}); - fireContentWebhooks(webhookEndpoints, "onPageDelete", { sourcePath: page.sourcePath }, runtimeDir, ctx.state.adminContext.services.security); + announcePageEvent(ctx, { event: "onPageDelete", payload: { sourcePath: page.sourcePath } }); void auditLogger?.log({ event: "page.delete", diff --git a/src/admin/routes/api/pages/index.ts b/src/admin/routes/api/pages/index.ts index b8f9230..9b08eab 100644 --- a/src/admin/routes/api/pages/index.ts +++ b/src/admin/routes/api/pages/index.ts @@ -2,10 +2,9 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, requireTsxWrite, json, serverError, actorFromAuth, getClientIp, validatePagePath, csrfCheck } from "../_utils.ts"; -import { fireContentWebhooks } from "../../../../admin/webhooks.ts"; +import { announcePageEvent } from "../../../page-events.ts"; import { stringify as stringifyYaml } from "@std/yaml"; import type { FreshContext } from "fresh"; -import { adminSettings } from "../../../settings.ts"; export const handler = { async GET(ctx: FreshContext) { @@ -29,7 +28,7 @@ export const handler = { const denied = await requirePermission(ctx, "pages.create"); if (denied) return denied; - const { engine, storage, config, hooks, auditLogger } = ctx.state.adminContext; + const { engine, storage, config, auditLogger } = ctx.state.adminContext; const authResult = ctx.state.auth; try { const body = await ctx.req.json(); @@ -68,10 +67,7 @@ export const handler = { await storage.write(filePath, new TextEncoder().encode(fm + "\n" + defaultContent)); await engine.rebuild(); - const webhookEndpoints = adminSettings(config).webhooks; - const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; - if (hooks) hooks.fire("onPageCreate", { sourcePath: `${pagePath}/default${ext}`, title }).catch(() => {}); - fireContentWebhooks(webhookEndpoints, "onPageCreate", { sourcePath: `${pagePath}/default${ext}`, title }, runtimeDir, ctx.state.adminContext.services.security); + announcePageEvent(ctx, { event: "onPageCreate", payload: { sourcePath: `${pagePath}/default${ext}`, title } }); void auditLogger?.log({ event: "page.create", diff --git a/src/admin/routes/api/workflow/transition.ts b/src/admin/routes/api/workflow/transition.ts index 5d40f3e..0a42063 100644 --- a/src/admin/routes/api/workflow/transition.ts +++ b/src/admin/routes/api/workflow/transition.ts @@ -35,7 +35,7 @@ export const handler = { } const result = await applyWorkflowTransition( - { engine, storage, config, hooks, workflow, services: ctx.state.adminContext.services }, + { engine, storage, config, hooks, workflow }, sourcePath, newStatus, ); diff --git a/src/admin/webhooks.ts b/src/admin/webhooks.ts index 8904e3a..b81f106 100644 --- a/src/admin/webhooks.ts +++ b/src/admin/webhooks.ts @@ -19,6 +19,7 @@ import { join } from "@std/path"; import { encodeHex } from "@std/encoding/hex"; import { isDuneError } from "@dune/core/plugin-api"; import type { SecurityService } from "@dune/core/plugin-api"; +import type { HookRegistry } from "@dune/core/hooks"; /** The slice of the security service webhook delivery needs. */ export type WebhookSecurity = Pick; @@ -295,6 +296,33 @@ export function fireContentWebhooks( }); } +/** The content events an endpoint can subscribe to. */ +const CONTENT_EVENTS: WebhookContentEvent[] = [ + "onPageCreate", + "onPageUpdate", + "onPageDelete", + "onPageMove", + "onWorkflowChange", +]; + +/** + * Deliver content webhooks for every page event that is fired, whoever fires + * it. `endpoints` is read per event, so a settings change applies without a + * restart. Delivery is fire-and-forget (see {@link fireContentWebhooks}). + */ +export function listenForContentWebhooks(opts: { + hooks: Pick; + endpoints: () => WebhookEndpointConfig[]; + runtimeDir: string; + security: WebhookSecurity; +}): void { + for (const event of CONTENT_EVENTS) { + opts.hooks.on(event, ({ data }) => { + fireContentWebhooks(opts.endpoints(), event, data, opts.runtimeDir, opts.security); + }); + } +} + export interface ListDeliveryLogsOptions { /** Maximum records to return (default: 50) */ limit?: number; diff --git a/src/admin/workflow-actions.ts b/src/admin/workflow-actions.ts index 1260aa7..9f094b2 100644 --- a/src/admin/workflow-actions.ts +++ b/src/admin/workflow-actions.ts @@ -12,14 +12,11 @@ * @module */ -import type { PluginServices } from "@dune/core/plugin-api"; import type { DuneEngine } from "@dune/core/engine"; import type { StorageAdapter } from "@dune/core/storage"; import type { DuneConfig } from "@dune/core/config"; import type { HookRegistry } from "@dune/core/hooks"; import type { WorkflowEngine } from "@dune/core/workflow"; -import { fireContentWebhooks } from "./webhooks.ts"; -import { adminSettings } from "./settings.ts"; export interface WorkflowActionContext { engine: DuneEngine; @@ -27,8 +24,6 @@ export interface WorkflowActionContext { config: DuneConfig; hooks?: HookRegistry; workflow: WorkflowEngine; - /** Core's services (webhook delivery uses the SSRF-guarded fetch). */ - services: PluginServices; } /** @@ -41,7 +36,7 @@ export async function applyWorkflowTransition( sourcePath: string, newStatus: string, ): Promise<{ from: string; to: string }> { - const { workflow, engine, storage, config, hooks, services } = ctx; + const { workflow, engine, storage, config, hooks } = ctx; const pageIndex = engine.pages.find((p) => p.sourcePath === sourcePath); if (!pageIndex) throw new Error(`Page not found: ${sourcePath}`); @@ -76,10 +71,8 @@ export async function applyWorkflowTransition( await storage.write(filePath, new TextEncoder().encode(updated)); await engine.rebuild(); - const webhookEndpoints = adminSettings(config).webhooks; - const runtimeDir = services.paths.runtimeDir; + // The admin's content webhooks listen to this event (see mount()). if (hooks) hooks.fire("onWorkflowChange", { sourcePath, from: currentStatus, to: newStatus }).catch(() => {}); - fireContentWebhooks(webhookEndpoints, "onWorkflowChange", { sourcePath, from: currentStatus, to: newStatus }, runtimeDir, services.security); return { from: currentStatus, to: newStatus }; } diff --git a/tests/admin/bar_actions_test.ts b/tests/admin/bar_actions_test.ts new file mode 100644 index 0000000..68840c8 --- /dev/null +++ b/tests/admin/bar_actions_test.ts @@ -0,0 +1,25 @@ +/** What the admin contributes to the inline-edit bar. */ + +import { assertEquals, assertStringIncludes } from "@std/assert"; +import { adminBarActions } from "../../src/admin/bar-actions.ts"; + +Deno.test("no bar buttons off content pages", () => { + assertEquals(adminBarActions("/admin", { page: null }), []); +}); + +Deno.test("an edit-source button and an open-in-admin link, at the admin's own prefix", () => { + const actions = adminBarActions("/panel", { page: { sourcePath: "03.work/01.test.mdx" } }); + assertEquals(actions.map((a) => a.id), ["admin:edit-source", "admin:open"]); + const expected = "/panel/pages/edit?path=03.work%2F01.test.mdx"; + assertEquals(actions[1].href, expected); + // The overlay shows the same page embedded; the link is a full-page navigation. + assertStringIncludes(actions[0].onClick ?? "", JSON.stringify(`${expected}&embedded=1`)); + assertEquals(actions[0].href, undefined); +}); + +Deno.test("the overlay script is valid JavaScript that builds an iframe lazily", () => { + const script = adminBarActions("/admin", { page: { sourcePath: "a.md" } })[0].onClick ?? ""; + new Function(script); // throws on a syntax error + assertStringIncludes(script, "createElement('iframe')"); + assertStringIncludes(script, "dune-source-overlay"); +}); diff --git a/tests/admin/page_events_test.ts b/tests/admin/page_events_test.ts index 5b7f25a..512eff3 100644 --- a/tests/admin/page_events_test.ts +++ b/tests/admin/page_events_test.ts @@ -96,11 +96,11 @@ Deno.test("admin page routes announce create, update, move and delete", async () await withSite(async ({ seen, call }) => { let res = await call("POST", "/admin/api/pages", { path: "blog", title: "Hello" }); assertEquals(res.ok, true); - assertEquals(seen.splice(0), [{ event: "onPageCreate", data: { sourcePath: "blog/default.md", title: "Hello" } }]); + assertEquals(seen.splice(0), [{ event: "onPageCreate", data: { sourcePath: "blog/default.md", title: "Hello", actor: "ed" } }]); res = await call("PUT", "/admin/api/pages/blog/default.md", { content: "changed" }); assertEquals(res.status, 200); - assertEquals(seen.splice(0), [{ event: "onPageUpdate", data: { sourcePath: "blog/default.md" } }]); + assertEquals(seen.splice(0), [{ event: "onPageUpdate", data: { sourcePath: "blog/default.md", actor: "ed" } }]); // Move 02.beta before 01.alpha: both folders are renumbered. res = await call("POST", "/admin/api/pages/reorder", { @@ -116,12 +116,13 @@ Deno.test("admin page routes announce create, update, move and delete", async () { from: "02.beta", to: "01.beta" }, { from: "01.alpha", to: "02.alpha" }, ], + actor: "ed", }, }]); res = await call("DELETE", "/admin/api/pages/blog/default.md"); assertEquals(res.status, 200); - assertEquals(seen.splice(0), [{ event: "onPageDelete", data: { sourcePath: "blog/default.md" } }]); + assertEquals(seen.splice(0), [{ event: "onPageDelete", data: { sourcePath: "blog/default.md", actor: "ed" } }]); }); }); @@ -139,6 +140,6 @@ Deno.test("history restore announces onPageUpdate", async () => { const res = await call("POST", `/admin/api/history/01.alpha/default.md/${revisions[0].number}/restore`); assertEquals(res.status, 200); - assertEquals(seen.splice(0), [{ event: "onPageUpdate", data: { sourcePath: "01.alpha/default.md" } }]); + assertEquals(seen.splice(0), [{ event: "onPageUpdate", data: { sourcePath: "01.alpha/default.md", actor: "ed" } }]); }); }); diff --git a/tests/admin/webhook_listener_test.ts b/tests/admin/webhook_listener_test.ts new file mode 100644 index 0000000..3e768bb --- /dev/null +++ b/tests/admin/webhook_listener_test.ts @@ -0,0 +1,101 @@ +/** + * Content webhooks follow the page events, whoever fires them: a write from the + * inline-edit bar, MCP or the CLI reaches the admin's configured endpoints just + * like one from an admin route. + */ + +import { assertEquals } from "@std/assert"; +import { join } from "@std/path"; +import { listenForContentWebhooks } from "../../src/admin/webhooks.ts"; +import type { WebhookEndpointConfig } from "../../src/admin/settings.ts"; + +type Handler = (ctx: { data: unknown }) => void; + +function fakeHooks() { + const handlers = new Map(); + return { + on: (event: string, handler: Handler) => { + handlers.set(event, [...(handlers.get(event) ?? []), handler]); + }, + fire: (event: string, data: unknown) => (handlers.get(event) ?? []).forEach((h) => h({ data })), + events: () => [...handlers.keys()].sort(), + }; +} + +async function withReceiver(run: (url: string, received: unknown[]) => Promise) { + const received: unknown[] = []; + const server = Deno.serve({ port: 0, onListen: () => {} }, async (req) => { + received.push(await req.json()); + return new Response("ok"); + }); + try { + await run(`http://127.0.0.1:${server.addr.port}/`, received); + } finally { + await server.shutdown(); + } +} + +const security = { safeFetch: (url: string, init?: RequestInit) => fetch(url, init) }; + +async function settle(received: unknown[], count: number) { + for (let i = 0; i < 100 && received.length < count; i++) await new Promise((r) => setTimeout(r, 20)); +} + +Deno.test("a page event fired by anyone is delivered, with its actor", async () => { + const runtimeDir = await Deno.makeTempDir({ prefix: "dune_webhook_listener_" }); + try { + await withReceiver(async (url, received) => { + const hooks = fakeHooks(); + const endpoints: WebhookEndpointConfig[] = [{ url, events: ["onPageUpdate"], allow_private: true }]; + listenForContentWebhooks({ + // deno-lint-ignore no-explicit-any + hooks: hooks as any, + endpoints: () => endpoints, + runtimeDir, + security, + }); + assertEquals(hooks.events(), ["onPageCreate", "onPageDelete", "onPageMove", "onPageUpdate", "onWorkflowChange"]); + + // What plugin-inline-edit fires after a save. + hooks.fire("onPageUpdate", { sourcePath: "about/default.md", actor: "alice" }); + // Not subscribed: never delivered. + hooks.fire("onPageDelete", { sourcePath: "about/default.md" }); + await settle(received, 1); + await new Promise((r) => setTimeout(r, 100)); + + assertEquals(received.length, 1); + const body = received[0] as { event: string; payload: unknown }; + assertEquals(body.event, "onPageUpdate"); + assertEquals(body.payload, { sourcePath: "about/default.md", actor: "alice" }); + }); + } finally { + await Deno.remove(runtimeDir, { recursive: true }).catch(() => {}); + } +}); + +Deno.test("the endpoint list is read per event, so a settings change applies at once", async () => { + const runtimeDir = await Deno.makeTempDir({ prefix: "dune_webhook_listener_" }); + try { + await withReceiver(async (url, received) => { + const hooks = fakeHooks(); + let endpoints: WebhookEndpointConfig[] = []; + listenForContentWebhooks({ + // deno-lint-ignore no-explicit-any + hooks: hooks as any, + endpoints: () => endpoints, + runtimeDir, + security, + }); + hooks.fire("onPageCreate", { sourcePath: "a/default.md", title: "A" }); + await new Promise((r) => setTimeout(r, 100)); + assertEquals(received.length, 0); + + endpoints = [{ url, events: ["onPageCreate"], allow_private: true }]; + hooks.fire("onPageCreate", { sourcePath: "b/default.md", title: "B" }); + await settle(received, 1); + assertEquals(received.length, 1); + }); + } finally { + await Deno.remove(runtimeDir, { recursive: true }).catch(() => {}); + } +}); diff --git a/tests/admin/workflow_actions_test.ts b/tests/admin/workflow_actions_test.ts index f30c3df..012bb38 100644 --- a/tests/admin/workflow_actions_test.ts +++ b/tests/admin/workflow_actions_test.ts @@ -11,7 +11,6 @@ * executeScheduledAction-shaped callback actually applies the transition. */ -import { makeServices } from "../helpers/services.ts"; import { assertEquals, assertRejects } from "https://deno.land/std@0.224.0/assert/mod.ts"; import type { PageIndex } from "@dune/core/content/types"; import type { DuneConfig } from "@dune/core/config"; @@ -80,7 +79,7 @@ Deno.test("applyWorkflowTransition: patches status and published, rebuilds the i const result = await applyWorkflowTransition( // deno-lint-ignore no-explicit-any - { engine: engine as any, storage, config: CONFIG, workflow, services: makeServices(storage) }, + { engine: engine as any, storage, config: CONFIG, workflow }, "01.blog/01.hello/default.md", "published", ); @@ -105,7 +104,7 @@ Deno.test("applyWorkflowTransition: throws for an unknown sourcePath", async () () => applyWorkflowTransition( // deno-lint-ignore no-explicit-any - { engine: engine as any, storage, config: CONFIG, workflow, services: makeServices(storage) }, + { engine: engine as any, storage, config: CONFIG, workflow }, "does/not/exist.md", "published", ), @@ -125,7 +124,7 @@ Deno.test("applyWorkflowTransition: rejects a status value with invalid characte () => applyWorkflowTransition( // deno-lint-ignore no-explicit-any - { engine: engine as any, storage, config: CONFIG, workflow, services: makeServices(storage) }, + { engine: engine as any, storage, config: CONFIG, workflow }, "01.blog/01.hello/default.md", "not\nvalid", ), @@ -172,7 +171,7 @@ Deno.test("Scheduler + executeScheduledAction-shaped callback: a due action actu const newStatus = SCHEDULED_ACTION_STATUS[action.action]; await applyWorkflowTransition( // deno-lint-ignore no-explicit-any - { engine: engine as any, storage, config: CONFIG, workflow, services: makeServices(storage) }, + { engine: engine as any, storage, config: CONFIG, workflow }, action.sourcePath, newStatus, ); From e8fdb44ae9871cdfd16b80d21167fcdc473c5612 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 21:20:52 +0100 Subject: [PATCH 33/39] docs: changelog for the inline-edit HTTP gate and page-event actor (#127) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index be318e0..6127cd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,15 @@ follows [Semantic Versioning](https://semver.org). ### Changed — BREAKING (the admin declares its own extension points, #127) +- **The inline-edit content routes are gone; the admin's buttons on the bar are the admin's own (#127).** + `/admin/api/content/*` (source, commit, fields) moved to `@dune/plugin-inline-edit` + (`/api/inline-edit/content/*`), served for any signed-in editor with or without this plugin. The + admin contributes "Edit source" (an overlay with its page editor) and "Open in admin" to the bar's + `bar.actions` point, so the bar needs no knowledge of the admin's URLs. +- **Content webhooks are a listener on the page events**, not a call in each admin route: a write + from the inline-edit bar, MCP or the CLI now reaches them too. `announcePageEvent()` names the + signed-in user as `actor`, which the webhook payload carries. + - **`AdminPageRegistration`, `AdminRouteRegistration` and `ContentEditorPlugin` live here now**, exported from `@dune/plugin-admin` (and `@dune/plugin-admin/contributions`). Core's contract never read them. The keys `admin.pages` and `admin.routes` are declared by this package by declaration merging, so importing From f98b81007921006e596039776de15f64d529bf83 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sun, 8 Nov 2026 11:18:59 +0100 Subject: [PATCH 34/39] feat!: export extension points as typed capability keys, not module augmentation (duneorg/dune#127) JSR rejects 'declare module' in a published package. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 +-- mod.ts | 27 ++++++-------- src/admin/bar-actions.ts | 15 ++++++-- src/admin/contributions.ts | 48 ++++++++++++------------- tests/admin/capabilities_e2e_test.ts | 10 +++--- tests/admin/contributions_types_test.ts | 29 +++++++++------ 6 files changed, 71 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6127cd2..ee73f0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,8 +30,8 @@ follows [Semantic Versioning](https://semver.org). - **`AdminPageRegistration`, `AdminRouteRegistration` and `ContentEditorPlugin` live here now**, exported from `@dune/plugin-admin` (and `@dune/plugin-admin/contributions`). Core's contract never read them. The - keys `admin.pages` and `admin.routes` are declared by this package by declaration merging, so importing - the types is what makes a plugin's `contribute("admin.pages", ...)` type-check. The `editor.content` + keys are exported from here as typed tokens, `ADMIN_PAGES`, `ADMIN_ROUTES` and `ADMIN_EDITOR` + (`@dune/plugin-admin/contributions`): a plugin writes `contribute(ADMIN_PAGES, ...)`. The `editor.content` slot is renamed **`admin.editor`**: a key carries the name of the plugin that reads it. A plugin that provides a replacement page editor changes the key and imports `ContentEditorPlugin` from here. diff --git a/mod.ts b/mod.ts index 121d3a8..dbd85b6 100644 --- a/mod.ts +++ b/mod.ts @@ -37,7 +37,8 @@ import { getDuneAdminIslands, mountAdminPanel } from "./src/admin/mount.ts"; import * as corePlugins from "@dune/core/plugins"; import denoJson from "./deno.json" with { type: "json" }; import { adminSettings } from "./src/admin/settings.ts"; -import { adminBarActions } from "./src/admin/bar-actions.ts"; +import { adminBarActions, BAR_ACTIONS } from "./src/admin/bar-actions.ts"; +import { ADMIN_EDITOR, ADMIN_PAGES, ADMIN_ROUTES } from "./src/admin/contributions.ts"; import { listenForContentWebhooks } from "./src/admin/webhooks.ts"; // ── Core-instance handshake ────────────────────────────────────────────────── @@ -54,11 +55,8 @@ const _core = corePlugins as unknown as { /** The `@dune/core` instance sentinel this package resolved. See core's `plugins/mod.ts`. */ export const resolvedCoreSentinel: unknown = _core.CORE_INSTANCE; -/** - * The admin's extension points. Importing them also teaches TypeScript the keys - * (`admin.pages`, `admin.routes`, `admin.editor`) this plugin declares on the - * capability registry. - */ +/** The admin's extension points: the typed keys (`ADMIN_PAGES`, `ADMIN_ROUTES`, `ADMIN_EDITOR`) and their item types. */ +export { ADMIN_EDITOR, ADMIN_PAGES, ADMIN_ROUTES } from "./src/admin/contributions.ts"; export type { AdminPageRegistration, AdminRouteRegistration, @@ -153,13 +151,10 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { })); // The admin's own buttons on the inline-edit bar. `bar.actions` is that - // plugin's extension point; contributing without importing its types - // keeps it an optional neighbour (a site without it simply never reads - // this). - (api.capabilities as unknown as { contribute(point: string, item: unknown): void }).contribute( - "bar.actions", - (ctx: { page: { sourcePath: string } | null }) => adminBarActions(adminCfg.path, ctx), - ); + // plugin's extension point; contributing through a key of the same name + // (see bar-actions.ts) keeps it an optional neighbour, not a dependency + // (a site without it simply never reads this). + api.capabilities.contribute(BAR_ACTIONS, (ctx) => adminBarActions(adminCfg.path, ctx)); }, async mount({ app, runtime, services, capabilities }: MountApi) { @@ -305,13 +300,13 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { // ── Default content editor ──────────────────────────────────────────────── const inlineEdit = capabilities.get("editor.inline"); - const contentEditor = capabilities.get("admin.editor") ?? createBlockEditorPlugin(); + const contentEditor = capabilities.get(ADMIN_EDITOR) ?? createBlockEditorPlugin(); // ── Build AdminContext ──────────────────────────────────────────────────── const metricsEnabled = config.system.metrics?.enabled !== false; - const pluginPages = capabilities.list("admin.pages"); - const pluginRoutes = capabilities.list("admin.routes"); + const pluginPages = capabilities.list(ADMIN_PAGES); + const pluginRoutes = capabilities.list(ADMIN_ROUTES); const adminContext: AdminContext = { services, diff --git a/src/admin/bar-actions.ts b/src/admin/bar-actions.ts index 625ba8b..0251883 100644 --- a/src/admin/bar-actions.ts +++ b/src/admin/bar-actions.ts @@ -6,13 +6,15 @@ * point: a link to the page's editor here, and a button that opens that editor * in an overlay on the page. * - * The contribution is made without importing the bar plugin's types (that would - * make an optional neighbour a dependency), so the shapes below are this - * plugin's own restatement of the `BarAction` contract. + * The contribution is made without importing the bar plugin's package (that would + * make an optional neighbour a dependency), so the shapes and the key below are + * this plugin's own restatement of the `BarAction` contract. * * @module */ +import { type CapabilityKey, capabilityKey } from "@dune/core/plugin-api"; + /** A bar button, as `@dune/plugin-inline-edit` renders it. */ export interface AdminBarAction { id: string; @@ -28,6 +30,13 @@ export interface AdminBarContext { page: { sourcePath: string } | null; } +/** + * The bar's `bar.actions` contribution point, recreated from its documented name + * so this plugin needs no dependency on the bar's package: two keys with one + * name are one capability. + */ +export const BAR_ACTIONS: CapabilityKey<(ctx: AdminBarContext) => AdminBarAction[]> = capabilityKey("bar.actions"); + /** * Script for the "Edit source" button: show the admin's page editor * (`embedded=1` drops its sidebar and topbar, redundant over a content page) in diff --git a/src/admin/contributions.ts b/src/admin/contributions.ts index e60720f..955f1ad 100644 --- a/src/admin/contributions.ts +++ b/src/admin/contributions.ts @@ -2,29 +2,28 @@ * The admin's own extension points. * * Core's contract names none of these: it never reads them. The admin is their - * only consumer, so it declares them here, by declaration merging into the - * capability registry, under its own `admin.` prefix. A plugin that contributes - * an admin page, an admin route or a replacement page editor imports the types - * from `@dune/plugin-admin`; importing them is also what makes the keys known to - * TypeScript: + * only consumer, so it declares them here, as typed keys under its own `admin.` + * prefix. A plugin that contributes an admin page, an admin route or a + * replacement page editor imports the key (which carries the type) from + * `@dune/plugin-admin`: * * ```ts - * import type { AdminPageRegistration } from "@dune/plugin-admin"; - * api.capabilities.contribute("admin.pages", { path: "/billing", label: "Billing", permission: "billing.manage", handler }); + * import { ADMIN_PAGES } from "@dune/plugin-admin"; + * api.capabilities.contribute(ADMIN_PAGES, { path: "/billing", label: "Billing", permission: "billing.manage", handler }); * ``` * * @module */ -import type { PluginRouteContext } from "@dune/core/plugin-api"; +import { type CapabilityKey, capabilityKey, type PluginRouteContext } from "@dune/core/plugin-api"; /** * A custom admin page contributed by a plugin. * * Plugins contribute these to the `admin.pages` point: - * `api.capabilities.contribute("admin.pages", { path, label, permission, handler })`. + * `api.capabilities.contribute(ADMIN_PAGES, { path, label, permission, handler })`. * Unlike `publicRoutes`, this is **not** wired by `@dune/core`: the admin reads the list - * (`capabilities.list("admin.pages")`) and mounts each page as a programmatic route, + * (`capabilities.list(ADMIN_PAGES)`) and mounts each page as a programmatic route, * enforcing its declared `permission`. A site without the admin gets no admin pages. * * @since 4.0.0 @@ -57,9 +56,9 @@ export interface AdminPageRegistration { * under the admin prefix. * * Plugins contribute these to the `admin.routes` point: - * `api.capabilities.contribute("admin.routes", { method, path, permission, handler })`. + * `api.capabilities.contribute(ADMIN_ROUTES, { method, path, permission, handler })`. * The admin reads the list - * (`capabilities.list("admin.routes")`) and mounts each route behind its own + * (`capabilities.list(ADMIN_ROUTES)`) and mounts each route behind its own * authentication, with the CSRF check applied to every non-safe method and the * declared `permission` enforced, so the handler only runs for a request that * passed all three. A site without the admin mounts nothing. @@ -93,7 +92,7 @@ export interface AdminRouteRegistration { /** * Plugin-provided replacement for the built-in block editor. * - * Provide it as the `admin.editor` capability: `api.capabilities.provide("admin.editor", () => impl)`. + * Provide it as the `admin.editor` capability: `api.capabilities.provide(ADMIN_EDITOR, () => impl)`. * The edit route delegates to `pageEditorHandler` instead of the default block editor. * * Declared by the admin plugin, which is the only reader of the `admin.editor` slot; a plugin @@ -127,15 +126,14 @@ export interface ContentEditorPlugin { ) => Response; } -declare module "@dune/core/plugin-api" { - interface Capabilities { - /** A replacement page editor. Consumer: the admin. */ - "admin.editor": ContentEditorPlugin; - } - interface Contributions { - /** An extra admin page. Consumer: the admin. */ - "admin.pages": AdminPageRegistration; - /** A guarded admin route (any method) under the admin prefix. Consumer: the admin, which applies authentication, CSRF and permission checks. */ - "admin.routes": AdminRouteRegistration; - } -} +/** A replacement page editor (slot). Consumer: the admin. */ +export const ADMIN_EDITOR: CapabilityKey = capabilityKey("admin.editor"); + +/** An extra admin page (contribution point). Consumer: the admin. */ +export const ADMIN_PAGES: CapabilityKey = capabilityKey("admin.pages"); + +/** + * A guarded admin route, any method, under the admin prefix (contribution point). + * Consumer: the admin, which applies authentication, CSRF and permission checks. + */ +export const ADMIN_ROUTES: CapabilityKey = capabilityKey("admin.routes"); diff --git a/tests/admin/capabilities_e2e_test.ts b/tests/admin/capabilities_e2e_test.ts index d75a4f0..eee5d4a 100644 --- a/tests/admin/capabilities_e2e_test.ts +++ b/tests/admin/capabilities_e2e_test.ts @@ -9,7 +9,7 @@ import { join } from "@std/path"; import { bootstrap } from "@dune/core/bootstrap"; import { createDuneApp } from "@dune/core/fresh-app"; import type { DunePlugin } from "@dune/core/hooks"; -import { createAdminPlugin } from "../../mod.ts"; +import { ADMIN_EDITOR, ADMIN_PAGES, ADMIN_ROUTES, createAdminPlugin } from "../../mod.ts"; async function withSite( plugins: DunePlugin[], @@ -87,7 +87,7 @@ Deno.test( version: "1.0.0", hooks: {}, setup(api) { - api.capabilities.contribute("admin.pages", { + api.capabilities.contribute(ADMIN_PAGES, { path: "/my-plugin", label: "My plugin", permission: "pages.read", @@ -112,13 +112,13 @@ Deno.test( version: "1.0.0", hooks: {}, setup(api) { - api.capabilities.provide("admin.editor", () => ({ + api.capabilities.provide(ADMIN_EDITOR, () => ({ pageEditorHandler: () => new Response("custom editor"), })); }, }; await withSite([editor], async (ctx) => { - assertExists(ctx.capabilities.get("admin.editor")); + assertExists(ctx.capabilities.get(ADMIN_EDITOR)); assertEquals( ctx.capabilities.providers().find((p) => p.name === "admin.editor")?.provider, "test-editor", @@ -137,7 +137,7 @@ Deno.test( version: "1.0.0", hooks: {}, setup(api) { - api.capabilities.contribute("admin.routes", { + api.capabilities.contribute(ADMIN_ROUTES, { method: "POST", path: "/api/my-plugin/save", permission: "config.update", diff --git a/tests/admin/contributions_types_test.ts b/tests/admin/contributions_types_test.ts index cb6cfdc..0f95112 100644 --- a/tests/admin/contributions_types_test.ts +++ b/tests/admin/contributions_types_test.ts @@ -1,33 +1,40 @@ /** - * The admin declares its own extension points. These checks fail to compile if - * the declaration merging, the required `permission`, or the handler typing + * The admin declares its own extension points as typed keys. These checks fail to compile if + * the key typing, the required `permission`, or the handler typing * regresses; the runtime assertions only keep the tests honest. */ import { assertEquals } from "@std/assert"; import type { FreshContext } from "fresh"; import { createCapabilityRegistry } from "@dune/core/plugin-api"; -import type { AdminPageRegistration, AdminRouteRegistration, ContentEditorPlugin } from "../../mod.ts"; +import { + ADMIN_EDITOR, + ADMIN_PAGES, + ADMIN_ROUTES, + type AdminPageRegistration, + type AdminRouteRegistration, + type ContentEditorPlugin, +} from "../../mod.ts"; -Deno.test("importing the admin's types teaches TypeScript its keys", () => { +Deno.test("the admin's keys type what is contributed and read", () => { const reg = createCapabilityRegistry(); const page: AdminPageRegistration = { path: "/p", label: "P", permission: "pages.read", handler: () => new Response("") }; - reg.registrar("x").contribute("admin.pages", page); - assertEquals(reg.reader.list("admin.pages").map((p) => p.path), ["/p"]); + reg.registrar("x").contribute(ADMIN_PAGES, page); + assertEquals(reg.reader.list(ADMIN_PAGES).map((p) => p.path), ["/p"]); }); Deno.test("an admin.pages contribution must name a permission (compile-time)", () => { const reg = createCapabilityRegistry(); // @ts-expect-error `permission` is required on admin.pages - reg.registrar("a").contribute("admin.pages", { path: "/x", label: "X", handler: () => new Response("") }); - assertEquals(reg.reader.list("admin.pages").length, 1); + reg.registrar("a").contribute(ADMIN_PAGES, { path: "/x", label: "X", handler: () => new Response("") }); + assertEquals(reg.reader.list(ADMIN_PAGES).length, 1); }); Deno.test("an admin.routes contribution must name a permission (compile-time)", () => { const reg = createCapabilityRegistry(); // @ts-expect-error `permission` is required on admin.routes - reg.registrar("a").contribute("admin.routes", { method: "POST", path: "/x", handler: () => new Response("") }); - assertEquals(reg.reader.list("admin.routes").length, 1); + reg.registrar("a").contribute(ADMIN_ROUTES, { method: "POST", path: "/x", handler: () => new Response("") }); + assertEquals(reg.reader.list(ADMIN_ROUTES).length, 1); }); Deno.test("contribution handlers may be written against a full Fresh context", () => { @@ -41,6 +48,6 @@ Deno.test("contribution handlers may be written against a full Fresh context", ( Deno.test("admin.editor takes a ContentEditorPlugin", () => { const reg = createCapabilityRegistry(); const editor: ContentEditorPlugin = { pageEditorHandler: () => new Response("") }; - reg.registrar("alt-editor").provide("admin.editor", () => editor); + reg.registrar("alt-editor").provide(ADMIN_EDITOR, () => editor); assertEquals(reg.reader.providers()[0].name, "admin.editor"); }); From 6f11536ff286d9d2437de540e73eec33192a13c8 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sun, 8 Nov 2026 11:41:29 +0100 Subject: [PATCH 35/39] fix: export the plugin factory as a separate default so the package publishes 'export default function adminPluginFactory' next to 'adminPluginFactory.pluginName = ...' is a merged declaration; JSR's type output rejects it (TS2652), so 'deno publish' failed. Same runtime shape: a function with a pluginName property. Co-Authored-By: Claude Sonnet 5.5 --- mod.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/mod.ts b/mod.ts index 602d373..f936a23 100644 --- a/mod.ts +++ b/mod.ts @@ -375,10 +375,15 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { * The plugin loader's entry point: `plugins: [{ src: jsr:@dune/plugin-admin@^4 }]` * calls this with the entry's config and `{ root, dev }`. */ -export default function adminPluginFactory( +function adminPluginFactory( _pluginConfig: Record, ctx: { root: string; dev: boolean }, ): DunePlugin { return createAdminPlugin({ root: ctx.root, dev: ctx.dev }); } adminPluginFactory.pluginName = "admin"; + +// A separate default export: declaring it inline (`export default function`) +// next to the property assignment above is a merged declaration, which JSR's +// type output rejects (TS2652) and so blocks `deno publish`. +export default adminPluginFactory; From 47be6d7d9c6d61daa0b4cf80ec54adcbf95b9b5d Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Tue, 10 Nov 2026 13:22:30 +0100 Subject: [PATCH 36/39] feat: seed direct editing tuples only for accounts that can sign in to the admin (duneorg/dune#139) A site user holding editor or author is governed by core's system.role_grants; the direct tuple an earlier version seeded for such an account is revoked. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 6 ++++ mod.ts | 32 +++++++++++------ src/admin/admin-tuples.ts | 61 ++++++++++++++++++++++++++++++++ tests/admin/admin_tuples_test.ts | 52 +++++++++++++++++++++++++++ 4 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 src/admin/admin-tuples.ts create mode 100644 tests/admin/admin_tuples_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ee73f0f..7d72fd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ follows [Semantic Versioning](https://semver.org). (`/api/inline-edit/content/*`), served for any signed-in editor with or without this plugin. The admin contributes "Edit source" (an overlay with its page editor) and "Open in admin" to the bar's `bar.actions` point, so the bar needs no knowledge of the admin's URLs. +- **The admin seeds direct editing tuples only for its own accounts (duneorg/dune#139).** Accounts that can sign + in to it (a local password) keep a direct tuple, so the panel works even when a site sets + `role_grants: {}`. A site user who holds `editor` or `author` (OAuth, magic link, `external-jwt`) + is governed by core's `system.role_grants` like anyone else; the direct tuple an earlier version + seeded for such an account is revoked at start (logged as `admin.authz.stale_direct_grants_revoked`). + On the default grants nothing changes for them: their group membership still grants the relation. - **Content webhooks are a listener on the page events**, not a call in each admin route: a write from the inline-edit bar, MCP or the CLI now reaches them too. `announcePageEvent()` names the signed-in user as `actor`, which the webhook payload carries. diff --git a/mod.ts b/mod.ts index dbd85b6..b3a71ff 100644 --- a/mod.ts +++ b/mod.ts @@ -18,7 +18,6 @@ import type { DunePlugin, MountApi } from "@dune/core/hooks"; import type { DuneConfig } from "@dune/core/config"; import type { StorageAdapter } from "@dune/core/storage"; import { createUserManager } from "./src/admin/auth/users.ts"; -import { highestValidRole } from "./src/admin/auth/role-utils.ts"; import { createSessionManager } from "./src/admin/auth/sessions.ts"; import { createAuthMiddleware } from "./src/admin/auth/middleware.ts"; import { LocalAuthProvider } from "./src/admin/auth/local-provider.ts"; @@ -38,6 +37,7 @@ import * as corePlugins from "@dune/core/plugins"; import denoJson from "./deno.json" with { type: "json" }; import { adminSettings } from "./src/admin/settings.ts"; import { adminBarActions, BAR_ACTIONS } from "./src/admin/bar-actions.ts"; +import { reconcileAdminTuples } from "./src/admin/admin-tuples.ts"; import { ADMIN_EDITOR, ADMIN_PAGES, ADMIN_ROUTES } from "./src/admin/contributions.ts"; import { listenForContentWebhooks } from "./src/admin/webhooks.ts"; @@ -371,16 +371,26 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { // tuple gets registered too, instead of being locked out on first run. if (runtime.authz) { try { - // users.list() returns every account in the unified store - // (data/users/) — not just admin-tier ones once public site - // visitors share the same store (Phase 5b). Only bootstrap tuples - // for accounts that actually carry an admin-tier role. - const allUsers = await users.list(); - const enabledAdminUsers = allUsers - .filter((u) => u.enabled !== false) - .map((u) => ({ id: u.id, role: highestValidRole(u.roles) })) - .filter((u) => u.role !== undefined) as { id: string; role: string }[]; - await services.authz.seedAdminTuples(enabledAdminUsers); + // users.list() returns every account in the unified store (data/users/), + // site visitors included. Only the admin's own accounts, the ones that + // can sign in to it, get a direct tuple; the rest are governed by + // core's `system.role_grants` (see admin-tuples.ts). + const revoked = await reconcileAdminTuples(await users.list(), { + seedAdminTuples: (accounts) => services.authz.seedAdminTuples(accounts), + revoke: async (userId, relation) => { + await runtime.authz!.disallowAllMatching({ + who: { type: "user", id: userId }, + was: relation as "admin" | "editor" | "author", + onWhat: { type: "app", id: "site" }, + }); + }, + }); + if (revoked > 0) { + logger.info("admin.authz.stale_direct_grants_revoked", { + count: revoked, + reason: "accounts that cannot sign in to the admin are governed by system.role_grants", + }); + } } catch (err) { console.warn( "[dune/authz] Admin tuple bootstrap failed — authz is up but tuples were not seeded; admin access will deny (403) until bootstrap succeeds:", diff --git a/src/admin/admin-tuples.ts b/src/admin/admin-tuples.ts new file mode 100644 index 0000000..87d5dc7 --- /dev/null +++ b/src/admin/admin-tuples.ts @@ -0,0 +1,61 @@ +/** + * Which accounts the admin gives direct editing rights. + * + * `admin`, `editor` and `author` in a user's `roles[]` already carry editing + * rights through core's group grants (`system.role_grants`), for any signed-in + * user. What the admin adds is a direct tuple for **its own accounts**, so they + * can use the panel even when a site turns the group grants off with + * `role_grants: {}`. Its own accounts are the ones that can sign in to it: the + * built-in sign-in is by password, so those with a local password. + * + * A site user (OAuth, magic link, `external-jwt`) who happens to hold an + * admin-tier role gets no direct tuple here: they are governed by + * `role_grants` like everyone else, and a direct tuple would keep them editing + * after the site switched that off. + * + * @module + */ + +import { highestValidRole } from "./auth/role-utils.ts"; + +/** The part of a user record this needs. */ +export interface StoredAccount { + id: string; + roles: string[]; + enabled?: boolean; + provider?: string; + passwordHash?: string; +} + +/** The part of core's authz this needs. */ +export interface TupleAuthz { + seedAdminTuples(users: Array<{ id: string; role: string }>): Promise; + revoke(userId: string, relation: string): Promise; +} + +/** An account that can sign in to the built-in admin: it has a local password. */ +export function isAdminAccount(user: StoredAccount): boolean { + return user.provider === "local" || !!user.passwordHash; +} + +/** + * Seed the direct tuple of every enabled admin account that holds an + * admin-tier role, and revoke the one an earlier version seeded for an + * account that cannot sign in to the admin. Returns how many it revoked. + */ +export async function reconcileAdminTuples(users: StoredAccount[], authz: TupleAuthz): Promise { + const seed: Array<{ id: string; role: string }> = []; + const stale: Array<{ id: string; role: string }> = []; + for (const user of users) { + const role = highestValidRole(user.roles); + if (role === undefined) continue; + if (isAdminAccount(user)) { + if (user.enabled !== false) seed.push({ id: user.id, role }); + } else { + stale.push({ id: user.id, role }); + } + } + await authz.seedAdminTuples(seed); + for (const { id, role } of stale) await authz.revoke(id, role); + return stale.length; +} diff --git a/tests/admin/admin_tuples_test.ts b/tests/admin/admin_tuples_test.ts new file mode 100644 index 0000000..457c18d --- /dev/null +++ b/tests/admin/admin_tuples_test.ts @@ -0,0 +1,52 @@ +/** + * The admin seeds direct editing tuples for its own accounts only; a site user + * who holds an admin-tier role is governed by core's system.role_grants. + */ + +import { assertEquals } from "@std/assert"; +import { isAdminAccount, reconcileAdminTuples, type StoredAccount } from "../../src/admin/admin-tuples.ts"; + +function fakeAuthz() { + const seeded: Array<{ id: string; role: string }> = []; + const revoked: Array<[string, string]> = []; + return { + seeded, + revoked, + seedAdminTuples: (users: Array<{ id: string; role: string }>) => (seeded.push(...users), Promise.resolve()), + revoke: (id: string, relation: string) => (revoked.push([id, relation]), Promise.resolve()), + }; +} + +const account = (over: Partial & { id: string }): StoredAccount => ({ roles: [], ...over }); + +Deno.test("a password account is an admin account; OAuth, magic-link and invited ones are not", () => { + assertEquals(isAdminAccount(account({ id: "a", provider: "local" })), true); + assertEquals(isAdminAccount(account({ id: "a", provider: "github", passwordHash: "pbkdf2:..." })), true); + for (const provider of ["github", "google", "magic", "invited", "external-jwt"]) { + assertEquals(isAdminAccount(account({ id: "a", provider })), false, provider); + } +}); + +Deno.test("only admin accounts with an admin-tier role are seeded", async () => { + const authz = fakeAuthz(); + await reconcileAdminTuples([ + account({ id: "admin1", provider: "local", roles: ["admin"] }), + account({ id: "ed1", provider: "local", roles: ["member", "editor"] }), + account({ id: "plain", provider: "local", roles: ["member"] }), + account({ id: "off", provider: "local", roles: ["admin"], enabled: false }), + account({ id: "visitor", provider: "github", roles: ["editor"] }), + ], authz); + assertEquals(authz.seeded, [{ id: "admin1", role: "admin" }, { id: "ed1", role: "editor" }]); +}); + +Deno.test("a tuple an earlier version seeded for an account that cannot sign in is revoked, and counted", async () => { + const authz = fakeAuthz(); + const n = await reconcileAdminTuples([ + account({ id: "visitor", provider: "github", roles: ["author", "member"] }), + account({ id: "buyer", provider: "magic", roles: ["editor"] }), + account({ id: "real", provider: "local", roles: ["editor"] }), + account({ id: "tagged", provider: "github", roles: ["member"] }), + ], authz); + assertEquals(n, 2); + assertEquals(authz.revoked, [["visitor", "author"], ["buyer", "editor"]]); +}); From 3503c79c78c881df92575d932238b5bcedc58f63 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Tue, 10 Nov 2026 13:23:03 +0100 Subject: [PATCH 37/39] test: role_grants {} with the admin installed Co-Authored-By: Claude Sonnet 5.5 --- tests/admin/role_grants_e2e_test.ts | 62 +++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/admin/role_grants_e2e_test.ts diff --git a/tests/admin/role_grants_e2e_test.ts b/tests/admin/role_grants_e2e_test.ts new file mode 100644 index 0000000..6198d90 --- /dev/null +++ b/tests/admin/role_grants_e2e_test.ts @@ -0,0 +1,62 @@ +/** + * With the admin installed, `system.role_grants: {}` leaves a site user who + * holds `editor` without editing rights, while the admin's own account keeps + * working. A tuple an earlier version seeded for the site user is revoked. + */ + +import { assertEquals } from "@std/assert"; +import { join } from "@std/path"; +import { bootstrap } from "@dune/core/bootstrap"; +import { createDuneApp } from "@dune/core/fresh-app"; +import { createStorage } from "@dune/core/storage"; +import { createLocalUserStore } from "@dune/core/auth/user-store"; +import { hashPassword } from "@dune/core/auth/passwords"; +import { SITE_RESOURCE } from "@dune/core/auth/authz-schema"; +import { createAdminPlugin } from "../../mod.ts"; + +async function run(systemYaml: string, preseedVisitor: boolean) { + const root = await Deno.makeTempDir({ prefix: "dune_admin_role_grants_" }); + try { + await Deno.mkdir(join(root, "config"), { recursive: true }); + await Deno.writeTextFile(join(root, "config", "site.yaml"), 'title: "T"\nurl: "http://localhost:8000"\nauth:\n mode: dune\n'); + await Deno.writeTextFile(join(root, "config", "system.yaml"), systemYaml); + 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"); + const users = createLocalUserStore({ storage: createStorage({ rootDir: root }), usersDir: "data/users" }); + const admin = await users.create({ + email: "a@example.test", + username: "ada", + provider: "local", + passwordHash: await hashPassword("correct horse battery staple"), + roles: ["admin"], + }); + const visitor = await users.create({ email: "v@example.test", provider: "github", roles: ["editor"] }); + + const ctx = await bootstrap(root, { plugins: [createAdminPlugin({ root, dev: false })] }); + if (preseedVisitor) { + // What 0.35 and the first 0.36 builds did: a direct tuple for any user with the role. + await ctx.authz!.allow({ who: { type: "user", id: visitor.id }, toBe: "editor", onWhat: SITE_RESOURCE }); + } + await createDuneApp(ctx, { root, port: 0, dev: false }); + const can = (id: string) => + // deno-lint-ignore no-explicit-any + ctx.authz!.check({ who: { type: "user", id }, canThey: "pages.update" as any, onWhat: SITE_RESOURCE }); + return { admin: await can(admin.id), visitor: await can(visitor.id) }; + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +} + +const opts = { sanitizeOps: false, sanitizeResources: false }; + +Deno.test("default grants: the admin account and the site user holding editor both edit", opts, async () => { + assertEquals(await run("debug: false\n", false), { admin: true, visitor: true }); +}); + +Deno.test("role_grants {}: the admin account keeps its rights, the site user does not", opts, async () => { + assertEquals(await run("role_grants: {}\n", false), { admin: true, visitor: false }); +}); + +Deno.test("role_grants {}: a direct tuple an earlier version seeded for the site user is revoked at start", opts, async () => { + assertEquals(await run("role_grants: {}\n", true), { admin: true, visitor: false }); +}); From 702170805b3952eae0699ae47952e7570c58eeba Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Tue, 10 Nov 2026 14:42:47 +0100 Subject: [PATCH 38/39] feat: audit page writes that no admin route announced (duneorg/dune#127) Inline edits and other writers fire the page event with the person as actor; the admin now logs page.create/update/delete for them. Events its own routes announce are marked and skipped. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 +++ mod.ts | 5 +++ src/admin/page-audit.ts | 55 +++++++++++++++++++++++++++++++ src/admin/page-events.ts | 7 +++- tests/admin/page_audit_test.ts | 59 ++++++++++++++++++++++++++++++++++ 5 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 src/admin/page-audit.ts create mode 100644 tests/admin/page_audit_test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d72fd2..bb422ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,10 @@ follows [Semantic Versioning](https://semver.org). is governed by core's `system.role_grants` like anyone else; the direct tuple an earlier version seeded for such an account is revoked at start (logged as `admin.authz.stale_direct_grants_revoked`). On the default grants nothing changes for them: their group membership still grants the relation. +- **Page writes that no admin route announced reach the audit trail (duneorg/dune#127).** The inline-edit bar, + a plugin or any other writer fires the page event with the person's name as `actor`; the admin now writes a + `page.create` / `page.update` / `page.delete` entry for it (no IP or user agent: the event does not carry + them). Events the admin's own routes announce are marked and skipped, so nothing is logged twice. - **Content webhooks are a listener on the page events**, not a call in each admin route: a write from the inline-edit bar, MCP or the CLI now reaches them too. `announcePageEvent()` names the signed-in user as `actor`, which the webhook payload carries. diff --git a/mod.ts b/mod.ts index b3a71ff..d6d0777 100644 --- a/mod.ts +++ b/mod.ts @@ -38,6 +38,7 @@ import denoJson from "./deno.json" with { type: "json" }; import { adminSettings } from "./src/admin/settings.ts"; import { adminBarActions, BAR_ACTIONS } from "./src/admin/bar-actions.ts"; import { reconcileAdminTuples } from "./src/admin/admin-tuples.ts"; +import { listenForPageAudit } from "./src/admin/page-audit.ts"; import { ADMIN_EDITOR, ADMIN_PAGES, ADMIN_ROUTES } from "./src/admin/contributions.ts"; import { listenForContentWebhooks } from "./src/admin/webhooks.ts"; @@ -298,6 +299,10 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { security: services.security, }); + // Writes that did not come through an admin route (the inline-edit bar, a + // plugin) still reach the audit trail, named by the event's `actor`. + listenForPageAudit({ hooks: runtime.hooks, audit: () => setupState?.auditLogger }); + // ── Default content editor ──────────────────────────────────────────────── const inlineEdit = capabilities.get("editor.inline"); const contentEditor = capabilities.get(ADMIN_EDITOR) ?? createBlockEditorPlugin(); diff --git a/src/admin/page-audit.ts b/src/admin/page-audit.ts new file mode 100644 index 0000000..74959ef --- /dev/null +++ b/src/admin/page-audit.ts @@ -0,0 +1,55 @@ +/** + * Audit page writes that did not come through an admin route. + * + * The admin's own routes (create, update, delete) write their audit entry + * themselves, with the request's IP and user agent. Whoever else writes a page + * (the inline-edit bar, a plugin, MCP) fires the same page event with the + * person's name as `actor`, so this listener gives those writes an entry too. + * Events the admin's routes announced carry the {@link FROM_ADMIN_ROUTE} mark + * and are skipped, so nothing is logged twice. Entries from here have no IP or + * user agent: the event does not carry them. + * + * @module + */ + +import type { HookRegistry } from "@dune/core/hooks"; +import type { AuditEventType, AuditLogger } from "../audit/mod.ts"; + +/** + * Set (non-enumerably, so it never reaches a webhook body) on the payload of + * an event an admin route announced, which that route has already audited. + */ +export const FROM_ADMIN_ROUTE: unique symbol = Symbol("dune.admin.fromRoute"); + +const EVENTS: Array<[event: "onPageCreate" | "onPageUpdate" | "onPageDelete", audit: AuditEventType]> = [ + ["onPageCreate", "page.create"], + ["onPageUpdate", "page.update"], + ["onPageDelete", "page.delete"], +]; + +/** Audit page create, update and delete events that no admin route announced. */ +export function listenForPageAudit(opts: { + hooks: Pick; + audit: () => Pick | null | undefined; +}): void { + for (const [event, auditEvent] of EVENTS) { + opts.hooks.on(event, ({ data }) => { + const payload = data as { sourcePath?: string; actor?: string; [FROM_ADMIN_ROUTE]?: true }; + if (payload[FROM_ADMIN_ROUTE]) return; + const logger = opts.audit(); + if (!logger || typeof payload.sourcePath !== "string") return; + const actor = typeof payload.actor === "string" && payload.actor !== "" + ? { userId: payload.actor, username: payload.actor, name: payload.actor } + : null; + void logger.log({ + event: auditEvent, + actor, + ip: null, + userAgent: null, + target: { type: "page", id: payload.sourcePath }, + detail: { via: "page event" }, + outcome: "success", + }).catch(() => {}); + }); + } +} diff --git a/src/admin/page-events.ts b/src/admin/page-events.ts index a3e79ca..56ea470 100644 --- a/src/admin/page-events.ts +++ b/src/admin/page-events.ts @@ -14,6 +14,7 @@ import type { FreshContext } from "fresh"; import type { AdminState } from "./types.ts"; +import { FROM_ADMIN_ROUTE } from "./page-audit.ts"; /** Paths are relative to the content directory. */ export type PageEvent = @@ -27,7 +28,11 @@ export function announcePageEvent(ctx: FreshContext, e: PageEvent): const { hooks } = ctx.state.adminContext; const user = ctx.state.auth?.user; const actor = user ? (user.username ?? user.name ?? user.id) : undefined; - hooks?.fire(e.event, actor ? { ...e.payload, actor } : e.payload).catch((err: unknown) => + // The route that announces this writes its own audit entry (with IP and user agent); + // the mark stops the page-audit listener from adding a second one. + const payload = actor ? { ...e.payload, actor } : { ...e.payload }; + Object.defineProperty(payload, FROM_ADMIN_ROUTE, { value: true, enumerable: false }); + hooks?.fire(e.event, payload).catch((err: unknown) => console.warn(`[dune] ${e.event} hook failed: ${err instanceof Error ? err.message : err}`) ); } diff --git a/tests/admin/page_audit_test.ts b/tests/admin/page_audit_test.ts new file mode 100644 index 0000000..fd1ac06 --- /dev/null +++ b/tests/admin/page_audit_test.ts @@ -0,0 +1,59 @@ +/** Page writes that no admin route announced reach the audit trail, once. */ + +import { assertEquals } from "@std/assert"; +import { FROM_ADMIN_ROUTE, listenForPageAudit } from "../../src/admin/page-audit.ts"; + +type Handler = (ctx: { data: unknown }) => void; + +function setup() { + const handlers = new Map(); + const logged: Array> = []; + listenForPageAudit({ + // deno-lint-ignore no-explicit-any + hooks: { on: (event: string, h: Handler) => handlers.set(event, [...(handlers.get(event) ?? []), h]) } as any, + audit: () => ({ log: (entry: Record) => (logged.push(entry), Promise.resolve()) }) as never, + }); + return { logged, fire: (event: string, data: unknown) => (handlers.get(event) ?? []).forEach((h) => h({ data })) }; +} + +Deno.test("an update by the inline-edit bar is audited under the editor's name", () => { + const { logged, fire } = setup(); + fire("onPageUpdate", { sourcePath: "about/default.md", actor: "alice" }); + assertEquals(logged.length, 1); + assertEquals(logged[0].event, "page.update"); + assertEquals(logged[0].actor, { userId: "alice", username: "alice", name: "alice" }); + assertEquals(logged[0].target, { type: "page", id: "about/default.md" }); + assertEquals(logged[0].ip, null); +}); + +Deno.test("create and delete are audited too; a write with no actor is audited as the system", () => { + const { logged, fire } = setup(); + fire("onPageCreate", { sourcePath: "new/default.md", title: "New" }); + fire("onPageDelete", { sourcePath: "old/default.md", actor: "bob" }); + assertEquals(logged.map((l) => l.event), ["page.create", "page.delete"]); + assertEquals(logged[0].actor, null); +}); + +Deno.test("an event an admin route announced is not audited again", () => { + const { logged, fire } = setup(); + const payload = { sourcePath: "about/default.md", actor: "ada" }; + Object.defineProperty(payload, FROM_ADMIN_ROUTE, { value: true, enumerable: false }); + fire("onPageUpdate", payload); + assertEquals(logged, []); + // The mark never shows up when the payload is serialised (webhook bodies). + assertEquals(JSON.stringify(payload), '{"sourcePath":"about/default.md","actor":"ada"}'); +}); + +Deno.test("no audit trail configured, or a payload without a path, logs nothing and does not throw", () => { + const off = [] as unknown[]; + const handlers: Array<(c: { data: unknown }) => void> = []; + listenForPageAudit({ + // deno-lint-ignore no-explicit-any + hooks: { on: (_e: string, h: (c: { data: unknown }) => void) => handlers.push(h) } as any, + audit: () => null, + }); + for (const h of handlers) h({ data: { sourcePath: "a.md" } }); + const { logged, fire } = setup(); + fire("onPageUpdate", { actor: "x" }); + assertEquals([off, logged], [[], []]); +}); From 9f37398bcae358c66799c3453bd950539172e1c7 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Tue, 10 Nov 2026 23:06:58 +0100 Subject: [PATCH 39/39] chore: release 4.0.0 (core 0.36) CORE_RANGE and the deno.json import go to 0.36 (bare minor, floors JSR's publish check at 0.36.0), the version is 4.0.0, the 4.0.0 changelog entry is dated, @dune/testing joins @dune/core in the dependency-age exclude list, and the staging-publish test installs the admin plugin explicitly and expects the `actor` that page events now carry. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 7 +++++++ deno.json | 7 ++++--- scripts/generate-core-imports.ts | 22 ++++++++-------------- tests/admin/staging_publish_test.ts | 5 +++-- 4 files changed, 22 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 602c060..7faa295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ follows [Semantic Versioning](https://semver.org). ## [Unreleased] +## [4.0.0] — 2026-11-10 + +Pairs with `@dune/core` 0.36 and needs it. The admin is a plugin you list under `plugins:` in `site.yaml` +(`jsr:@dune/plugin-admin@^4`), and its settings are that entry's `config:`; run `dune migrate:config` on the site +to move them. It declares its own extension points (typed keys), owns the audit trail and machine translation, +and no longer carries a default admin or its own copy of what moved into core. The sections below list every +breaking change. ### Fixed diff --git a/deno.json b/deno.json index 1b48fe1..41469fd 100644 --- a/deno.json +++ b/deno.json @@ -1,11 +1,12 @@ { "name": "@dune/plugin-admin", - "version": "3.3.0", + "version": "4.0.0", "license": "MIT", "minimumDependencyAge": { "age": "P1D", "exclude": [ - "jsr:@dune/core" + "jsr:@dune/core", + "jsr:@dune/testing" ] }, "exports": { @@ -21,8 +22,8 @@ "./mt": "./src/mt/mod.ts" }, "imports": { + "@dune/core": "jsr:@dune/core@0.36", "@dune/testing": "jsr:@dune/testing@^2", - "@dune/core": "jsr:@dune/core@^0.35.3", "fresh": "jsr:@fresh/core@^2", "preact": "npm:preact@^10", "preact/hooks": "npm:preact@^10/hooks", diff --git a/scripts/generate-core-imports.ts b/scripts/generate-core-imports.ts index 95d6f9a..db577cd 100644 --- a/scripts/generate-core-imports.ts +++ b/scripts/generate-core-imports.ts @@ -39,24 +39,18 @@ * deno task check:core-imports — fail (exit 1) if deno.json is stale */ -/** Bump at every core minor this package needs. Bare-minor form (e.g. "0.31") +/** Bump at every core minor this package needs. Bare-minor form (e.g. "0.36") * auto-tracks patch releases within that minor — confirmed via `deno cache`: * "0.28" resolved to the latest 0.28.x, not just 0.28.0. * - * Floored at 0.35.3: password hashing (`@dune/core/auth/passwords`) and the - * `DUNE_ADMIN_*` first-admin bootstrap moved into core there, and this package - * no longer carries its own copy or a default admin. (0.35.2 before that: the - * public form routes and the submission store moved into core, with - * `BootstrapResult.submissions`, `registerFormRoutes()` and the `onSystemAction` - * hook from `@dune/core/forms`.) A bare "0.35" would floor JSR's publish-time check at - * 0.35.0 (reproduced 2026-08-31 with "0.34": - * "invalid 'jsr:' dependency subpath: '@dune/core@0.34/auth/authz-schema', - * resolved to 0.34.0, has no export") and let a site pair this package with a - * core that cannot serve those routes. Back to a bare minor at the next core - * minor. This constant and deno.json are only kept in step by - * `check:core-imports`, so run it before merging any pin change. + * "0.36": this package (4.x) is a plugin core lists under `plugins:` and reads + * its settings from `config.plugins.admin`, which needs core 0.36.0 (core no + * longer loads the admin or has an `admin` config block). The floor is 0.36.0, + * so JSR's publish-time check (oldest version satisfying the range) sees every + * subpath this package uses. This constant and deno.json are only kept in step + * by `check:core-imports`, so run it before merging any pin change. */ -const CORE_RANGE = "^0.35.3"; +const CORE_RANGE = "0.36"; const DENO_JSON = "deno.json"; const SCAN_ROOTS = ["mod.ts", "src", "tests"]; diff --git a/tests/admin/staging_publish_test.ts b/tests/admin/staging_publish_test.ts index 881f42b..40b19f9 100644 --- a/tests/admin/staging_publish_test.ts +++ b/tests/admin/staging_publish_test.ts @@ -13,6 +13,7 @@ import { createDuneApp } from "@dune/core/fresh-app"; import { createStorage } from "@dune/core/storage"; import { createLocalUserStore } from "@dune/core/auth/user-store"; import { hashPassword } from "@dune/core/auth/passwords"; +import { createAdminPlugin } from "../../mod.ts"; const PASSWORD = "correct horse battery staple"; const ORIGIN = "http://localhost:8000"; @@ -41,7 +42,7 @@ Deno.test("a staged draft is published through POST /api/staging/:path*/publish" version: "1.0.0", hooks: { onPageUpdate: ({ data }: { data: unknown }) => void updates.push(data) } as DunePlugin["hooks"], }; - const ctx = await bootstrap(root, { plugins: [recorder] }); + const ctx = await bootstrap(root, { plugins: [createAdminPlugin({ root, dev: false }), recorder] }); const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); const handler = app.handler(); const req = (path: string, init?: RequestInit) => handler(new Request(new URL(path, ORIGIN), init)); @@ -78,7 +79,7 @@ Deno.test("a staged draft is published through POST /api/staging/:path*/publish" assertStringIncludes(written, "staged body"); await new Promise((r) => setTimeout(r, 25)); - assertEquals(updates, [{ sourcePath: "01.alpha/default.md" }]); + assertEquals(updates, [{ sourcePath: "01.alpha/default.md", actor: "ed" }]); // The draft is consumed. const after = await call("GET", "/admin/api/staging/01.alpha/default.md");