From 662b81fd4fa420fa3cd648b445a8b501703aa130 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Wed, 4 Nov 2026 10:10:01 +0100 Subject: [PATCH 1/2] 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 2/2] 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)));