From 0264a5edc6d22733ba3f6ea2300615ef2ec25305 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Wed, 4 Nov 2026 11:06:59 +0100 Subject: [PATCH] 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, + }); +}