Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ 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.
- **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

### Changed
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
29 changes: 13 additions & 16 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -126,9 +126,8 @@ export function createAdminPlugin(
async setup(_api) {
const adminCfg = config.admin ?? {
path: "/admin",
sessionLifetime: 86400,
dataDir: "data",
runtimeDir: ".dune/admin",
session_lifetime: 86400,
runtime_dir: ".dune/admin",
enabled: true,
};
if (adminCfg.enabled === false) {
Expand All @@ -139,8 +138,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");
Expand All @@ -149,7 +148,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}`,
);
}
Expand All @@ -171,15 +170,13 @@ export function createAdminPlugin(
const { opts: o } = setupState;
const adminCfg = config.admin ?? {
path: "/admin",
sessionLifetime: 86400,
dataDir: "data",
runtimeDir: ".dune/admin",
session_lifetime: 86400,
runtime_dir: ".dune/admin",
enabled: true,
};
const runtimeDir = adminCfg.runtimeDir ?? ".dune/admin";
// `system.data_dir`, falling back to the deprecated `admin.dataDir` —
// the same resolution core uses for the submissions it stores.
const dataDir = resolveFormsConfig(config).dataDir;
const runtimeDir = adminCfg.runtime_dir ?? ".dune/admin";
// `system.data_dir` — the same resolution core uses for the submissions it stores.
const dataDir = resolveDataDir(config);

// ── Admin services ────────────────────────────────────────────────────────

Expand Down Expand Up @@ -213,12 +210,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();
Expand Down
2 changes: 1 addition & 1 deletion src/admin/auth/csrf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
22 changes: 11 additions & 11 deletions src/admin/auth/ldap-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,16 @@
* import { Client } from "npm:ldapts";
*
* async authenticate(creds: AuthCredentials): Promise<AuthProviderUser | null> {
* 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)})`,
Expand All @@ -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,
Expand All @@ -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 {
Expand Down
34 changes: 17 additions & 17 deletions src/admin/auth/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
18 changes: 9 additions & 9 deletions src/admin/auth/saml-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null> {
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 4 additions & 4 deletions src/admin/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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;
}

Expand Down
4 changes: 2 additions & 2 deletions src/admin/routes/api/_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export function csrfCheck(ctx: FreshContext<AdminState>): 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
Expand Down Expand Up @@ -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({
Expand Down
3 changes: 2 additions & 1 deletion src/admin/routes/api/config/theme-config.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -58,7 +59,7 @@ export const handler = {
}
}

const dataDir = config.admin?.dataDir ?? "data";
const dataDir = resolveDataDir(config);
const themeConfigPath = `${dataDir}/theme-config.json`;
const themeName = engine.config.theme.name;

Expand Down
2 changes: 1 addition & 1 deletion src/admin/routes/api/email-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>[] = [];
Expand Down
2 changes: 1 addition & 1 deletion src/admin/routes/api/email-preview/[id].ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/admin/routes/api/media/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading