diff --git a/.dune/admin/csrf-secret b/.dune/admin/csrf-secret new file mode 100644 index 0000000..0cfae29 --- /dev/null +++ b/.dune/admin/csrf-secret @@ -0,0 +1 @@ +e2d70ff6df338be68a3461e97fc83abfe5fa4ff393c4901a90f9d84fa88cabf6 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 32cf583..ed70b83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,142 @@ follows [Semantic Versioning](https://semver.org). ## [Unreleased] + +### Fixed + +- **Every route that writes a page now announces it (#97).** Whoever writes a page fires its event; core + does not. Six admin paths wrote pages and fired nothing, so search indexes, webhooks and audit plugins + missed them: restoring a revision (`onPageUpdate`), staging publish (`onPageUpdate`), the two translate + routes (`onPageCreate`), the dev apply endpoint (create / update / delete), reorder (new `onPageMove`, + which lists every renamed folder) and collab auto-save (`onPageUpdate`, only when the save changed the file). + The routes share `announcePageEvent()`, which fires the hook and the configured content webhooks, so + `onPageMove` is also a valid webhook event. Needs `@dune/core` with `onPageMove` (0.36). + `tests/admin/page_events_test.ts` drives create, update, reorder, delete and restore through the real routes. + +### Changed — BREAKING (the admin is called `admin`, and its settings are its plugin config) + +- **The plugin is named `admin`** (it was `dune-admin`), so its settings are `config.plugins.admin`, and + `capabilities.providers()`, `dune doctor` and the logs name it `admin`. +- **Settings come from the plugin's config, not from an `admin:` block in system.yaml (#123, step 5).** + `path`, `session_lifetime`, `max_upload_mb`, `git_commit`, `webhooks`, `auth_provider` and `audit` are + `config:` under the admin's entry in `plugins:` (site.yaml); `dune migrate:config` moves an existing + `admin:` block there. Core 0.36 has no `admin` config at all and refuses the old block. The admin + validates its own settings at boot: a `path` that is not a clean prefix, or an `auth_provider` other + than `local`, stops the site instead of leaving the admin mounted where core does not protect it. + `WebhookEndpointConfig`, `WebhookContentEvent` and `AuthProviderConfig` moved here from `@dune/core`. + `runtime_dir` and `max_revisions` stay core settings (`system.yaml`). +- **`machine_translation` is a setting of the admin's config** (it was `site.machine_translation`), with `api_key` + and `base_url` (they were `apiKey` and `baseUrl`); `dune migrate:config` moves and renames it. +- `adminSettings(config)` (`src/admin/settings.ts`) is the one place the settings are read. + +### Changed — BREAKING (the admin is a plugin you list) + +- **`@dune/core` 0.36 no longer loads the admin by itself (#123, step 4).** List it under `plugins:` in + `config/site.yaml` (`- src: jsr:@dune/plugin-admin@^4`; `dune new` writes it and `dune migrate:config` adds it). + The package's default export is the plugin-loader factory `(config, { root, dev })`, so that is all it takes. +- **`createAdminPlugin(opts)` takes `{ root, dev, authProvider? }` only.** The site config and storage come from + `PluginApi` in `setup()`, and the unused `authz`, `authzAdapter` and `hmacKey` options are gone. To use a custom + `authProvider`, call it yourself and pass the result in `bootstrap(root, { plugins })`; core's `authProvider` + bootstrap option is gone. +- **`admin.enabled` is gone** (core refuses it: do not list the admin to run without it), along with the admin's + `enabled` config type field and the `enabled` check in `mountAdminPanel()` and the audit trail. +- Needs `@dune/core` 0.36 and `@dune/testing` 2 for the conformance run. + +### Changed — BREAKING (machine translation and the audit trail) + +- **The admin owns the audit trail and machine translation (#123, step 3).** `AuditLogger`, the audit types, the + DeepL / Google / LibreTranslate translators and their types moved here from `@dune/core` (`@dune/core/audit` and + `@dune/core/mt` are gone); they are exported as `@dune/plugin-admin/audit` and `@dune/plugin-admin/mt`. The admin + builds both in `setup()`: the trail from `admin.audit` and `services.paths.runtimeDir` (a `log_file` outside the + site root is still refused), the translator from `site.machine_translation`. Translators take the fetch they use as + a constructor argument and the admin passes `services.security.safeFetch`, so core's SSRF policy still applies. + Needs `@dune/core` 0.36. + +### Changed — BREAKING (admin config move) + +- **The runtime directory comes from core, not from `admin.runtime_dir`.** `runtime_dir` (and + `max_revisions`) are `system.runtime_dir` / `system.max_revisions` in `@dune/core` 0.36: core's history, + job state, analytics and public sessions use them too. The admin reads `services.paths.runtimeDir` everywhere + it used `config.admin.runtime_dir` (webhook delivery logs, email preview, CSRF secret, workflow and page + events), so a site that sets `runtime_dir` keeps one directory for core and the admin. Needs `@dune/core` 0.36. +- **The admin provides the `admin.prefix` capability** (`{ prefix: admin.path ?? "/admin" }`), the way core learns + where the admin is mounted. Core no longer reads `admin.path` or assumes `/admin`: with no admin installed, `/admin` + is an ordinary content path. Needs `@dune/core` 0.36 (#123, step 2). +- **`/api/introspect` reports `pageCacheEnabled`** (from `system.page_cache`) instead of `cacheEnabled` and + `cacheDriver`: core 0.36 no longer has a `system.cache` block. + +### Removed — BREAKING + +- **The LDAP and SAML auth providers (#97).** `LdapAuthProvider` and `SamlAuthProvider` were stubs whose + `authenticate()` threw, and `mount()` refused `auth_provider.type: ldap | saml` anyway. They are deleted, with + `LdapProviderConfig`, `SamlProviderConfig` and the admin's `AuthProviderConfig` union (core's is now just + `{ type: "local" }`). `AuthProvider.type` is a plain string. To use another identity source, pass your own + provider through the `authProvider` plugin option; the `AuthProvider` interface (`authenticate`, + `initiateLogin`, `handleCallback`) is unchanged. An unknown `auth_provider.type` now fails with a message + saying so. + +### Changed — BREAKING + +- **`mount()` reads the site from `MountApi.runtime`, not `MountApi.bootstrap` (#97).** Core's `mount()` + argument no longer carries the whole bootstrap result, so the admin takes engine, search, history, hooks, + submissions, flex, image cache, metrics, sections, authz and the job scheduler from the curated + `PluginRuntime`. `authz` replaces the `authz && authzAdapter` check (the adapter is no longer reachable). + `mount()` now calls the new `mountAdminPanel()` (panel routes, context middleware, `admin.pages` / + `admin.routes`) and no longer registers public plugin routes or form endpoints a second time: core's + `createDuneApp()` owns those. The headless `mountDuneAdmin(app, bootstrapResult)` is unchanged. Needs + `@dune/core` 0.36. +- **Narrows the contract's `PluginApp` to Fresh's `App` itself (#97).** Core's contract no longer names Fresh + types (`MountApi.app` is a `PluginApp`, handlers get a `PluginRouteContext`). The admin uses Fresh-only + features (`fsRoutes`, `layout`, `route`), so `mount()` casts `app` to Fresh's `App` and the block editor + declares `pageEditorHandler(ctx: FreshContext)`. No behaviour change. `deno task check` now also + type-checks `mod.ts`, which it silently skipped. +- **`mountDuneAdmin()` no longer registers plugin public routes or the form endpoints, and the + `createSubmissionManager` re-export is gone (#97, slice 4).** Both were core's own work: `createDuneApp()` + registers plugins' `publicRoutes` and `/api/forms/*`, `/api/webhook/incoming` itself (the admin's second call + was a no-op there), and the submission store is `PluginRuntime.submissions`. The admin now imports neither + `registerPluginPublicRoutes` nor `registerFormRoutes`, which lets core stop exporting them. `mountDuneAdmin()` + now mounts only the panel. The `Submission*` types are still re-exported. + +### Added + +- **Runs the admin conformance suite from `@dune/testing`** (`tests/admin/conformance_test.ts`): the checks + the stub admin passes, against plugin-admin over a real temp site. It covers `editor.session`, session + recognition and marker scrubbing, the declared `dune_session` cookie, mount-order independence, + `onPageUpdate` fired once by an edit, `admin.pages` / `admin.routes` mounted behind the login and the CSRF + check, and the site serving with no admin. Needs `@dune/testing` with the conformance suite + (`"@dune/testing": "jsr:@dune/testing@^1"` in `deno.json`), so it is draft-only until that is released. + ### Changed — BREAKING +- **The admin provides and consumes core's capabilities instead of the untyped + `adminContext` bag (duneorg/dune#97, `@dune/core` 0.36).** It provides + `editor.session` (who is the admin on a request: the admin bar and the inline-edit + WebSocket's admin fallback) and `scheduler.executor` (runs due scheduled actions; + `dune serve` starts core's scheduler loop when it exists). It reads the + `editor.inline` and `editor.content` slots and the `admin.pages` contributions from + `MountApi.capabilities` instead of `adminServices` and each plugin's `adminPages`, + and no longer writes `bootstrap.adminContext`. The jobs view reads + `BootstrapResult.jobScheduler`, which `dune serve` sets after mount. + `editor.session` declares `sessionCookies: ["dune_session"]`, which core uses to decide + whether to authenticate a public page and whether to keep the response out of the shared + page cache. +- **`mountDuneAdminEarly()` is gone** (core removed `DunePlugin.mountEarly`). + `mountDuneAdmin(app, ctx, adminContext?)` falls back to the single-site context + stored by the plugin's `mount()` instead of `ctx.adminContext`. A plugin route that + needs the admin identity calls `capabilities.get("editor.session")`. Routes a plugin + registers in its own `mount()` no longer sit behind the admin's + `ctx.state.adminContext` middleware, so `withGuards()` no longer works there (it + throws a `TypeError`); a guarded `POST`/`PUT`/`PATCH`/`DELETE` admin route is now a + contribution to the `admin.routes` point (below). +- **Mounts `admin.routes`** and the `admin.pages` contributions through `mountPluginAdminRoutes()` + / `mountPluginAdminPages()` (`src/admin/mount.ts`). A route is `{ method, path, permission, handler }` + and is registered under the admin prefix behind the admin's own authentication (`401` + otherwise), the CSRF check on every non-GET method (`403`) and the `permission` check (`403`); the + handler only runs when all three pass. A page is the same for GET with a login redirect. **`permission` + is required on both** (core's `AdminPageRegistration` / `AdminRouteRegistration` type it that way): + an entry without one, or with an unknown method or a path that does not start with `/`, is skipped + and logged (`admin.routes.invalid` / `admin.pages.invalid`) instead of being exposed to every + authenticated admin, which also covers plugins written without type-checking. - **The `admin:` block reads snake_case keys** (duneorg/dune#96, `@dune/core` 0.36): `session_lifetime`, `data_dir`, `runtime_dir`, `max_revisions`, `max_upload_mb`, `authz_store`, `audit.log_file`, and the LDAP/SAML provider fields (`base_dn`, diff --git a/README.md b/README.md index d7a64c6..cb5c504 100644 --- a/README.md +++ b/README.md @@ -17,57 +17,77 @@ Optional: `sha256` (ZIP integrity), `demoUrl`, `screenshotUrl`, `tags`, `compati ## Usage -The plugin is auto-registered by `@dune/core` bootstrap unless explicitly disabled. No configuration is required for the default setup. +The admin is a plugin like any other: `@dune/core` loads it only when `config/site.yaml` lists it. +`dune new` writes the entry, and `dune migrate:config` adds it to an existing site. -### Explicit registration (for custom wiring) +```yaml +# config/site.yaml +plugins: + - src: jsr:@dune/plugin-admin@^4 +``` + +No further configuration is required. Settings go under `config:` of that entry: + +```yaml +plugins: + - src: jsr:@dune/plugin-admin@^4 + config: + path: /cms # URL prefix (default /admin) + session_lifetime: 3600 # seconds (default 86400) +``` + A site that does not list it has no admin panel: it serves, +builds and runs jobs as usual, and says once at boot that nothing provides the admin. + +### Custom wiring + +To pass a custom sign-in provider, build the plugin yourself and hand it to `bootstrap()` instead of +listing it: ```ts import { createAdminPlugin } from "@dune/plugin-admin"; import { bootstrap } from "@dune/core"; -const result = await bootstrap({ root: Deno.cwd() }); -// Admin is auto-registered; pass options to override defaults: -const adminPlugin = createAdminPlugin(result.config, result.storage, { - root: Deno.cwd(), - dev: false, - // authProvider: myCustomAuthProvider, +const ctx = await bootstrap(Deno.cwd(), { + plugins: [createAdminPlugin({ root: Deno.cwd(), dev: false, authProvider: myCustomAuthProvider })], }); ``` -### Disabling the admin panel - -```yaml -# site.yaml -admin: - enabled: false -``` +### Running without the admin -This produces a headless Dune deployment — useful for statically generated sites or when you want a fully custom management interface. +Do not list it. (`admin.enabled` no longer exists: core refuses it.) This is a headless Dune deployment, +useful for statically generated sites or when you want a fully custom management interface. ## Configuration -All admin configuration lives under the `admin:` key in `site.yaml`. +The admin's settings are its plugin config: `config:` under its entry in `plugins:` (`config/site.yaml`). +Core reads none of them. ```yaml -admin: - path: /admin # URL prefix for the admin panel (default: /admin) - enabled: true # Set to false to disable entirely - session_lifetime: 86400 # Session duration in seconds (default: 24h) - data_dir: data # Directory for users, submissions, etc. - runtime_dir: .dune/admin # Directory for sessions, audit log, etc. - - auth_provider: - type: local # Only "local" is supported today - - audit: - enabled: true - log_file: .dune/admin/audit.log # Override the audit log path - - session_store: - type: local # "local" (default) or "redis" - url: $REDIS_URL # Required when type is redis +plugins: + - src: jsr:@dune/plugin-admin@^4 + config: + path: /admin # URL prefix for the admin panel (default /admin) + session_lifetime: 86400 # session duration in seconds (default 24h) + max_upload_mb: 100 # admin upload limit (default 100) + git_commit: false # commit to git after each page save (default false) + auth_provider: + type: local # the only supported value; omit for the same result + audit: + enabled: true + log_file: .dune/admin/audit.log # must resolve under the site root + machine_translation: # optional: translate pages from the editor + provider: deepl # deepl | google | libretranslate + api_key: $DEEPL_API_KEY + webhooks: # outbound webhooks on content events + - url: https://hooks.example.com/content + secret: $WEBHOOK_SECRET + events: [onPageUpdate] ``` +A `path` that is not a clean prefix, or an `auth_provider` other than `local`, stops the site at boot. +Shared settings live in core's `system.yaml`: `data_dir` (users, submissions), `runtime_dir` (sessions, audit +log, revisions) and `max_revisions`. + ### Roles and permissions The admin panel uses a three-tier role system: diff --git a/deno.json b/deno.json index 78d18cc..d6274b4 100644 --- a/deno.json +++ b/deno.json @@ -15,9 +15,12 @@ "./admin/types": "./src/admin/types.ts", "./admin/guards": "./src/admin/guards.ts", "./admin/auth/middleware": "./src/admin/auth/middleware.ts", - "./admin/auth/provider": "./src/admin/auth/provider.ts" + "./admin/auth/provider": "./src/admin/auth/provider.ts", + "./audit": "./src/audit/mod.ts", + "./mt": "./src/mt/mod.ts" }, "imports": { + "@dune/testing": "jsr:@dune/testing@^2", "@dune/core": "jsr:@dune/core@^0.35.3", "fresh": "jsr:@fresh/core@^2", "preact": "npm:preact@^10", @@ -44,7 +47,7 @@ }, "tasks": { "test": "deno test -A tests/", - "check": "deno lint src/ && deno check src/**/*.ts src/**/*.tsx", + "check": "deno lint src/ && deno check mod.ts src/**/*.ts src/**/*.tsx", "test:e2e": "deno run --allow-all --unstable-kv npm:playwright test --config playwright.config.ts", "test:e2e:install": "deno run --allow-all npm:playwright install chromium", "gen:admin-manifest": "deno run --allow-read --allow-write scripts/generate-admin-manifest.ts", diff --git a/mod.ts b/mod.ts index 14a73db..602d373 100644 --- a/mod.ts +++ b/mod.ts @@ -14,7 +14,6 @@ * @since 0.24.0 */ -import { join } from "@std/path"; import type { DunePlugin, MountApi } from "@dune/core/hooks"; import type { DuneConfig } from "@dune/core/config"; import type { StorageAdapter } from "@dune/core/storage"; @@ -28,19 +27,16 @@ import type { ScheduledAction } from "@dune/core/workflow"; import { applyWorkflowTransition, SCHEDULED_ACTION_STATUS } from "./src/admin/workflow-actions.ts"; import { createCommentManager } from "./src/admin/comments.ts"; import { createCollabManager } from "./src/collab/mod.ts"; -import type { MachineTranslator } from "@dune/core/mt"; -import type { AuditLogger } from "@dune/core/audit"; -import type { DuneAuthSystem } from "@dune/core/auth/authz"; -import type { AuthzLocalAdapter } from "@dune/core/auth/authz-adapter-local"; -import type { AuthzDbAdapter } from "@dune/core/auth/authz-adapter-db"; +import { createMachineTranslator, type MachineTranslator } from "./src/mt/mod.ts"; +import { type AuditLogger, createAuditTrail } from "./src/audit/mod.ts"; import { initAdminContext } from "./src/admin/context.ts"; import type { AdminContext } from "./src/admin/context.ts"; import { createBlockEditorPlugin } from "./src/admin/block-editor-plugin.tsx"; -import { getDuneAdminIslands, mountDuneAdmin, mountDuneAdminEarly } from "./src/admin/mount.ts"; -import { sectionRegistry } from "@dune/core/sections"; -import type { SectionRegistry } from "@dune/core/sections"; +import type { App } from "fresh"; +import { getDuneAdminIslands, mountAdminPanel } from "./src/admin/mount.ts"; import * as corePlugins from "@dune/core/plugins"; import denoJson from "./deno.json" with { type: "json" }; +import { adminSettings } from "./src/admin/settings.ts"; // ── Core-instance handshake ────────────────────────────────────────────────── // Re-export the CORE_INSTANCE sentinel and CORE_VERSION that THIS package's @@ -59,14 +55,14 @@ export const resolvedCoreSentinel: unknown = _core.CORE_INSTANCE; /** The `@dune/core` version this package resolved. Undefined on cores ≤0.30. */ export const resolvedCoreVersion: string | undefined = _core.CORE_VERSION; -/** Options forwarded from bootstrap() to the admin plugin factory. */ +/** Options for the admin plugin factory. */ export interface AdminPluginOptions { + /** Absolute site root (where the audit log is written). */ root: string; + /** True under `dune dev` (cookies are not marked Secure). */ dev: boolean; + /** A custom sign-in provider; takes precedence over `admin.auth_provider`. */ authProvider?: AuthProvider; - authz?: DuneAuthSystem; - authzAdapter?: AuthzLocalAdapter | AuthzDbAdapter; - hmacKey?: CryptoKey | null; } /** State created during setup(), consumed by mount(). */ @@ -82,68 +78,76 @@ interface AdminSetupState { } /** - * Factory for the built-in admin panel plugin. + * Factory for the admin panel plugin. * - * Receives bootstrap-time context (root, dev flag, optional custom auth - * provider) that can't come through `PluginApi` because they exist before - * plugins are loaded. + * Takes the facts that exist before plugins load (site root, dev flag, an + * optional custom auth provider); the site config and storage come from + * `PluginApi` in `setup()`. A site normally lists the package under `plugins:` + * and the default export below builds the plugin; call this directly to pass a + * custom `authProvider`. */ -export function createAdminPlugin( - config: DuneConfig, - storage: StorageAdapter, - opts: AdminPluginOptions, -): DunePlugin { +export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { + // Taken from PluginApi in setup(), which always runs before mount(). + let config!: DuneConfig; + let storage!: StorageAdapter; let setupState: AdminSetupState | null = null; - // Set partway through mount(), once AdminContext is actually built. - // mountEarly() registers a middleware that reads this lazily (per - // request) rather than needing the built value up front — see - // mountDuneAdminEarly()'s doc comment for why that's necessary (it runs - // before mount() has built anything). + // Set at the end of mount(), once AdminContext is built. The capabilities + // declared in setup() delegate to it lazily (per request), because core + // resolves them before mount() has built the auth middleware and the rest. let liveAdminContext: AdminContext | null = null; return { - name: "dune-admin", + name: "admin", version: denoJson.version, description: "Built-in Dune admin panel", hooks: {}, islandSpecifiers: getDuneAdminIslands(), - mountEarly({ app, bootstrap }: MountApi) { - if (!setupState) return; // admin disabled - // deno-lint-ignore no-explicit-any - mountDuneAdminEarly(app as any, bootstrap, () => liveAdminContext); - }, - async setup(api) { - const adminCfg = config.admin ?? { - path: "/admin", - session_lifetime: 86400, - runtime_dir: ".dune/admin", - enabled: true, - }; - if (adminCfg.enabled === false) { - setupState = null; - return; - } + config = api.config; + storage = api.storage; + const adminCfg = adminSettings(config); // throws on a bad path / auth_provider: the site does not boot - // Audit trail and machine translator are built by core. - const { audit: auditLogger, mt } = api.services.engines; + // The audit trail and the machine translator are the admin's own. Translator + // requests go through core's SSRF-checked fetch. + const auditLogger = createAuditTrail({ config, root: opts.root, runtimeDir: api.services.paths.runtimeDir }); + const mtConfig = adminCfg.machine_translation; + const mt = mtConfig ? createMachineTranslator(mtConfig, api.services.security.safeFetch) : null; setupState = { config, storage, opts, auditLogger, mt }; + + // What this plugin provides to core. Core reads them at request time + // (the admin bar, the inline-edit WebSocket, `dune serve`'s scheduler + // loop); until mount() has built the context they report "nobody". + // Where this admin is mounted: core keeps these paths out of the page + // cache, the static build and the plugin-route namespace. The default + // `/admin` lives here, not in core. + api.capabilities.provide("admin.prefix", () => ({ prefix: adminCfg.path })); + api.capabilities.provide("editor.session", () => ({ + // The cookie the admin's auth middleware sets (see createAuthMiddleware). + // Core uses it to decide whether to authenticate a public page and whether + // to keep the response out of the shared page cache. + sessionCookies: ["dune_session"], + authenticate: (req) => + liveAdminContext + ? liveAdminContext.auth.authenticate(req) + : Promise.resolve({ authenticated: false }), + })); + api.capabilities.provide("scheduler.executor", () => ({ + execute: async (action) => { + await liveAdminContext?.executeScheduledAction?.(action); + }, + })); }, - async mount({ app, bootstrap, adminServices, services }: MountApi) { + async mount({ app, runtime, services, capabilities }: MountApi) { const { logger } = services; if (!setupState) return; // admin disabled const { opts: o } = setupState; - const adminCfg = config.admin ?? { - path: "/admin", - session_lifetime: 86400, - runtime_dir: ".dune/admin", - enabled: true, - }; - const runtimeDir = adminCfg.runtime_dir ?? ".dune/admin"; + const adminCfg = adminSettings(config); // throws on a bad path / auth_provider: the site does not boot + // `system.runtime_dir`, as core resolved it. + const runtimeDir = services.paths.runtimeDir; // `system.data_dir` — the same resolution core uses for the submissions it stores. const dataDir = services.paths.dataDir; @@ -183,17 +187,13 @@ export function createAdminPlugin( if (o.authProvider) { authProvider = o.authProvider; } else { - const provCfg = config.admin?.auth_provider; + const provCfg = adminCfg.auth_provider; if (!provCfg || provCfg.type === "local") { authProvider = new LocalAuthProvider(users, services.passwords); - } else if (provCfg.type === "ldap" || provCfg.type === "saml") { - throw new Error( - `[dune] auth_provider.type "${provCfg.type}" is not implemented. ` + - `Set auth_provider.type to "local" or remove the section.`, - ); } else { throw new Error( - `[dune] auth_provider.type "${(provCfg as { type?: string }).type ?? ""}" is not recognized.`, + `[dune] auth_provider.type "${(provCfg as { type?: string }).type ?? ""}" is not supported. ` + + `Only "local" is built in; inject your own provider with the \`authProvider\` plugin option.`, ); } } @@ -224,7 +224,7 @@ export function createAdminPlugin( return; } try { - await applyWorkflowTransition({ engine: bootstrap.engine, storage, config, hooks: bootstrap.hooks, workflow, services }, action.sourcePath, newStatus); + await applyWorkflowTransition({ engine: runtime.engine, storage, config, hooks: runtime.hooks, workflow, services }, action.sourcePath, newStatus); } catch (err) { logger.error("scheduler.action-failed", { actionId: action.id, @@ -239,15 +239,16 @@ export function createAdminPlugin( const collabManager = createCollabManager({ storage, - engine: bootstrap.engine, - history: bootstrap.history, + engine: runtime.engine, + history: runtime.history, contentDir: config.system.content.dir, security: services.security, + hooks: runtime.hooks, }); // Core accepts and stores public form submissions; the admin only // manages them. An older core has no store to read. - const submissionManager = bootstrap.submissions; + const submissionManager = runtime.submissions; if (!submissionManager) { throw new Error( "@dune/plugin-admin 3.2+ needs a @dune/core that stores form submissions itself " + @@ -256,7 +257,7 @@ export function createAdminPlugin( } // The incoming webhook runs in core; keep the admin's audit trail. - bootstrap.hooks.on< + runtime.hooks.on< { action: "rebuild" | "purge-cache"; ip: string | null; userAgent: string | null } >("onSystemAction", ({ data }) => { void setupState?.auditLogger?.log({ @@ -271,68 +272,59 @@ export function createAdminPlugin( }); // ── Default content editor ──────────────────────────────────────────────── - if (!adminServices.contentEditor) { - adminServices.contentEditor = createBlockEditorPlugin(); - } + const inlineEdit = capabilities.get("editor.inline"); + const contentEditor = capabilities.get("editor.content") ?? createBlockEditorPlugin(); // ── Build AdminContext ──────────────────────────────────────────────────── const metricsEnabled = config.system.metrics?.enabled !== false; - const pluginPages = bootstrap.hooks.plugins() - .flatMap((p) => p.adminPages ?? []); + const pluginPages = capabilities.list("admin.pages"); + const pluginRoutes = capabilities.list("admin.routes"); const adminContext: AdminContext = { services, - engine: bootstrap.engine, - search: bootstrap.search, + engine: runtime.engine, + search: runtime.search, storage, config, auth, users, sessions, - prefix: adminCfg.path ?? "/admin", + prefix: adminCfg.path, authProvider, workflow, scheduler, executeScheduledAction, - history: bootstrap.history, + history: runtime.history, submissions: submissionManager, - flex: bootstrap.flexEngine, - hooks: bootstrap.hooks, + flex: runtime.flexEngine, + hooks: runtime.hooks, staging: stagingEngine, comments: commentManager, collab: collabManager, - inlineEdit: adminServices.inlineEdit, - contentEditor: adminServices.contentEditor, - imageCache: bootstrap.imageCache, + inlineEdit, + contentEditor, auditLogger: setupState.auditLogger ?? undefined, - metrics: metricsEnabled ? bootstrap.metrics : undefined, + metrics: metricsEnabled ? runtime.metrics : undefined, mt: setupState.mt, rateLimitStore, - pluginPages: pluginPages.length > 0 ? pluginPages : undefined, - authz: bootstrap.authz, - // Cores older than 0.31 don't expose `sections` on BootstrapResult; - // fall back to this package's own module singleton, which resolves to - // the host's copy anyway once the @0 range unifies the two. - sections: (bootstrap as unknown as { sections?: SectionRegistry }).sections ?? - sectionRegistry, + pluginPages: pluginPages.length > 0 ? [...pluginPages] : undefined, + pluginRoutes: pluginRoutes.length > 0 ? [...pluginRoutes] : undefined, + // Created per serving process by `dune serve`, after mount(): read lazily. + get jobScheduler() { + return runtime.jobs; + }, + authz: runtime.authz, + sections: runtime.sections, }; // Keep the singleton for single-site serve paths (serve.ts, dev.ts). initAdminContext(adminContext); - // Unblocks mountEarly()'s ctx.state.adminContext middleware — see its - // doc comment. Must happen before any other plugin's route could - // conceivably run, which it does: mountPlugins() finishes every - // plugin's mount() before the app starts serving requests. + // Lets the capabilities declared in setup() answer. mountPlugins() + // finishes every plugin's mount() before the app serves requests. liveAdminContext = adminContext; - // Expose adminContext on the bootstrap result so serve.ts can wire the - // job scheduler into it after mount() returns. BootstrapResult types - // this loosely (Record) since core has no concrete - // AdminContext type — plugin-admin owns the actual shape. - bootstrap.adminContext = adminContext as unknown as Record; - // No default admin is ever created. The first admin is an explicit act: // `dune users:create --role admin --password-prompt`, or // DUNE_ADMIN_EMAIL / DUNE_ADMIN_PASSWORD at boot (handled by core before @@ -350,7 +342,7 @@ export function createAdminPlugin( // ── authz tuple bootstrap ───────────────────────────────────────────────── // Runs after core's env bootstrap so a freshly-created first admin's // tuple gets registered too, instead of being locked out on first run. - if (bootstrap.authz && bootstrap.authzAdapter) { + if (runtime.authz) { try { // users.list() returns every account in the unified store // (data/users/) — not just admin-tier ones once public site @@ -371,7 +363,22 @@ export function createAdminPlugin( } // ── Mount admin Fresh routes ────────────────────────────────────────────── - await mountDuneAdmin(app, bootstrap, adminContext); + // The admin uses Fresh-only features (fsRoutes, layout, route), so it depends on Fresh + // and narrows the contract's PluginApp; core always passes the real Fresh App. + // deno-lint-ignore no-explicit-any + mountAdminPanel(app as unknown as App, runtime, adminContext); }, }; } + +/** + * The plugin loader's entry point: `plugins: [{ src: jsr:@dune/plugin-admin@^4 }]` + * calls this with the entry's config and `{ root, dev }`. + */ +export default function adminPluginFactory( + _pluginConfig: Record, + ctx: { root: string; dev: boolean }, +): DunePlugin { + return createAdminPlugin({ root: ctx.root, dev: ctx.dev }); +} +adminPluginFactory.pluginName = "admin"; diff --git a/src/admin/auth/csrf.ts b/src/admin/auth/csrf.ts index 0141486..6b1e73d 100644 --- a/src/admin/auth/csrf.ts +++ b/src/admin/auth/csrf.ts @@ -43,7 +43,7 @@ export function resolveCsrfSecret(state: AdminState | undefined): string | null const injected = state.adminContext.csrfSecret; if (typeof injected === "string" && injected.length > 0) return injected; - const runtimeDir = state.adminContext.config?.admin?.runtime_dir; + const runtimeDir = state.adminContext.services?.paths?.runtimeDir; if (typeof runtimeDir !== "string" || runtimeDir.length === 0) return null; return loadOrCreateCsrfSecret(runtimeDir); } diff --git a/src/admin/auth/ldap-provider.ts b/src/admin/auth/ldap-provider.ts deleted file mode 100644 index 68401c8..0000000 --- a/src/admin/auth/ldap-provider.ts +++ /dev/null @@ -1,99 +0,0 @@ -/** - * LDAP authentication provider. - * - * ## Status: STUB — not yet implemented - * - * This class compiles and satisfies the AuthProvider interface but `authenticate()` - * throws a NotImplemented error. To implement LDAP authentication, replace the body of - * `authenticate()` with a proper LDAP bind flow using an LDAP client library. - * - * ## Suggested implementation using ldapts (npm:ldapts): - * - * ```typescript - * import { Client } from "npm:ldapts"; - * - * async authenticate(creds: AuthCredentials): Promise { - * const usernameAttr = this.config.username_attr ?? "uid"; - * const emailAttr = this.config.email_attr ?? "mail"; - * const nameAttr = this.config.name_attr ?? "cn"; - * const client = new Client({ url: this.config.url }); - * try { - * if (this.config.bind_dn) { - * // Service account bind, then search for the user entry - * const bindPw = expandEnv(this.config.bind_password ?? ""); - * await client.bind(this.config.bind_dn, bindPw); - * const { searchEntries } = await client.search(this.config.base_dn, { - * // IMPORTANT: escape username to prevent LDAP injection (CWE-90) - * // import { escape as ldapEscape } from "npm:ldapts"; - * filter: `(${usernameAttr}=${ldapEscape(creds.username)})`, - * attributes: ["dn", emailAttr, nameAttr, "memberOf"], - * }); - * if (!searchEntries.length) return null; - * const entry = searchEntries[0]; - * // Rebind as the located user to verify their password - * await client.bind(entry.dn as string, creds.password); - * const role = mapRole(entry["memberOf"] as string | string[] | undefined, this.config.role_map) - * ?? this.config.default_role - * ?? "author"; - * return { - * externalId: entry.dn as string, - * username: creds.username, - * email: entry[emailAttr] as string | undefined, - * name: entry[nameAttr] as string | undefined, - * roles: [role], - * }; - * } else { - * // Direct bind: construct user DN from template - * const userDn = `${usernameAttr}=${creds.username},${this.config.base_dn}`; - * await client.bind(userDn, creds.password); - * return { - * externalId: userDn, - * username: creds.username, - * roles: [this.config.default_role ?? "author"], - * }; - * } - * } catch { - * return null; // Bind failure = bad credentials - * } finally { - * await client.unbind(); - * } - * } - * - * // Expand "$ENV_VAR" references in config values - * function expandEnv(value: string): string { - * return value.replace(/^\$([A-Z_][A-Z0-9_]*)$/, (_, name) => Deno.env.get(name) ?? ""); - * } - * - * // Map LDAP group membership to a Dune role (first match wins) - * function mapRole( - * memberOf: string | string[] | undefined, - * roleMap: Array<{ group: string; role: Role }> | undefined, - * ): Role | undefined { - * if (!roleMap || !memberOf) return undefined; - * const groups = Array.isArray(memberOf) ? memberOf : [memberOf]; - * for (const { group, role } of roleMap) { - * if (groups.includes(group)) return role; - * } - * return undefined; - * } - * ``` - * - * ## Required deno.json import map entry: - * ```json - * { "npm:ldapts": "npm:ldapts@^4" } - * ``` - */ - -import type { AuthProvider, AuthCredentials, AuthProviderUser, LdapProviderConfig } from "./provider.ts"; - -export class LdapAuthProvider implements AuthProvider { - readonly type = "ldap" as const; - - constructor(private config: LdapProviderConfig) {} - - async authenticate(_creds: AuthCredentials): Promise { - throw new Error( - "LdapAuthProvider is a stub — see src/admin/auth/ldap-provider.ts for the implementation guide", - ); - } -} diff --git a/src/admin/auth/provider.ts b/src/admin/auth/provider.ts index 91bb467..2db260d 100644 --- a/src/admin/auth/provider.ts +++ b/src/admin/auth/provider.ts @@ -7,7 +7,6 @@ * @module */ -import type { Role } from "@dune/core/config"; /** * User attributes returned by an external auth provider after successful authentication. @@ -41,13 +40,14 @@ export interface AuthCredentials { /** * An authentication provider handles identity verification. - * Multiple provider types are supported; the session and user-provisioning layer is shared. + * The built-in provider is `local`; any other (LDAP, SAML, OIDC, ...) is injected through + * the plugin's `authProvider` option. The session and user-provisioning layer is shared. * * Username+password flows (local, LDAP) use `authenticate()`. * Redirect-based flows (SAML, OIDC) use `initiateLogin()` + `handleCallback()`. */ export interface AuthProvider { - readonly type: "local" | "ldap" | "saml" | "oidc"; + readonly type: string; /** * Verify credentials and return provider user attributes. @@ -69,85 +69,3 @@ export interface AuthProvider { handleCallback?(req: Request): Promise; } -/** Configuration union for all supported provider types. */ -export type AuthProviderConfig = - | { type: "local" } - | LdapProviderConfig - | SamlProviderConfig; - -/** LDAP authentication provider configuration. */ -export interface LdapProviderConfig { - type: "ldap"; - /** - * LDAP server URL. - * @example "ldap://ldap.example.com:389" - * @example "ldaps://ldap.example.com:636" - */ - url: string; - /** - * Base DN for user search. - * @example "ou=users,dc=example,dc=com" - */ - base_dn: string; - /** - * LDAP attribute containing the login name. - * Default: "sAMAccountName" (Active Directory) or "uid" (OpenLDAP). - */ - username_attr?: string; - /** - * Optional bind DN for a service account used to search the directory. - * If omitted, Dune attempts a direct bind using the user's constructed DN. - */ - bind_dn?: string; - /** - * Service account password. Supports "$ENV_VAR" expansion. - * @example "$LDAP_BIND_PASSWORD" - */ - bind_password?: string; - /** LDAP attribute to use as email. Default: "mail" */ - email_attr?: string; - /** LDAP attribute to use as display name. Default: "cn" */ - name_attr?: string; - /** - * Map LDAP group membership to Dune roles. First match wins. - * @example - * ```yaml - * roleMap: - * - group: "cn=cms-admins,ou=groups,dc=example,dc=com" - * role: "admin" - * - group: "cn=cms-editors,ou=groups,dc=example,dc=com" - * role: "editor" - * ``` - */ - role_map?: Array<{ group: string; role: Role }>; - /** Default role for authenticated users not in any mapped group. Default: "author" */ - default_role?: Role; -} - -/** SAML 2.0 authentication provider configuration. */ -export interface SamlProviderConfig { - type: "saml"; - /** Your application's Entity ID (SP entity ID). */ - entity_id: string; - /** - * Assertion Consumer Service URL — where the IdP POSTs the assertion after authentication. - * @example "https://example.com/admin/saml/acs" - */ - acs_url: string; - /** Identity provider metadata as a URL (fetched at startup) or inline XML. */ - idp_metadata: string; - /** SAML attribute containing the username. Default: "NameID" */ - username_attr?: string; - /** SAML attribute containing the email. Default: "email" or "mail" */ - email_attr?: string; - /** SAML attribute containing the display name. Default: "displayName" */ - name_attr?: string; - /** - * Map IdP group/role attribute values to Dune roles. First match wins. - */ - role_map?: Array<{ value: string; role: Role }>; - /** SAML attribute carrying the role or group value. Default: "role" or "groups" */ - role_attr?: string; - /** Default role for users with no matching roleMap entry. Default: "author" */ - default_role?: Role; -} diff --git a/src/admin/auth/saml-provider.ts b/src/admin/auth/saml-provider.ts deleted file mode 100644 index 107c9be..0000000 --- a/src/admin/auth/saml-provider.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * SAML 2.0 authentication provider. - * - * ## Status: STUB — not yet implemented - * - * `authenticate()` is not applicable for SAML (no direct credential flow). - * SAML uses `initiateLogin()` to start the SP-initiated SSO redirect and - * `handleCallback()` to process the IdP's POST response to the ACS endpoint. - * - * ## Suggested implementation using samlify (npm:samlify): - * - * ```typescript - * import * as saml from "npm:samlify"; - * - * // Build SP + IdP instances (ideally cached at construction time) - * private buildSp() { - * return saml.ServiceProvider({ - * entityID: this.config.entity_id, - * assertionConsumerService: [ - * { Binding: saml.Constants.namespace.binding.post, Location: this.config.acs_url }, - * ], - * }); - * } - * - * private buildIdp() { - * return saml.IdentityProvider({ metadata: this.config.idp_metadata }); - * } - * - * async initiateLogin(_req: Request): Promise { - * const sp = this.buildSp(); - * const idp = this.buildIdp(); - * const { context } = sp.createLoginRequest(idp, "redirect"); - * return context; // redirect URL with SAMLRequest query param - * } - * - * async handleCallback(req: Request): Promise { - * const body = await req.formData(); - * const samlResponse = body.get("SAMLResponse") as string | null; - * if (!samlResponse) return null; - * - * const sp = this.buildSp(); - * const idp = this.buildIdp(); - * try { - * const { extract } = await sp.parseLoginResponse(idp, "post", { - * body: { SAMLResponse: samlResponse }, - * }); - * - * // SECURITY: before trusting extract.nameID and extract.attributes, verify: - * // 1. InResponseTo matches the stored AuthnRequest ID (replay prevention) - * // 2. Audience restriction includes this SP's entity ID - * // 3. NotBefore / NotOnOrAfter time bounds are valid - * // samlify validates the signature but NOT these by default — check your version. - * - * const nameId: string = extract.nameID; - * const attrs = extract.attributes ?? {}; - * const usernameAttr = this.config.username_attr ?? "NameID"; - * const emailAttr = this.config.email_attr ?? "email"; - * const nameAttr = this.config.name_attr ?? "displayName"; - * const roleAttr = this.config.role_attr ?? "role"; - * const role = mapRole(attrs[roleAttr], this.config.role_map) - * ?? this.config.default_role - * ?? "author"; - * return { - * externalId: nameId, - * username: (attrs[usernameAttr] as string) ?? nameId, - * email: attrs[emailAttr] as string | undefined, - * name: attrs[nameAttr] as string | undefined, - * roles: [role], - * }; - * } catch { - * return null; // Signature verification or parsing failure - * } - * } - * - * function mapRole( - * value: string | string[] | undefined, - * roleMap: Array<{ value: string; role: Role }> | undefined, - * ): Role | undefined { - * if (!roleMap || value === undefined) return undefined; - * const values = Array.isArray(value) ? value : [value]; - * for (const { value: v, role } of roleMap) { - * if (values.includes(v)) return role; - * } - * return undefined; - * } - * ``` - * - * ## Required deno.json import map entry: - * ```json - * { "npm:samlify": "npm:samlify@^2" } - * ``` - * - * ## Required route registration in server.ts: - * Register a POST /admin/saml/acs route that calls `provider.handleCallback(req)` - * and then calls `findOrProvisionUser()` followed by the session creation flow. - */ - -import type { AuthProvider, AuthCredentials, AuthProviderUser, SamlProviderConfig } from "./provider.ts"; - -export class SamlAuthProvider implements AuthProvider { - readonly type = "saml" as const; - - constructor(private config: SamlProviderConfig) {} - - async authenticate(_creds: AuthCredentials): Promise { - throw new Error( - "SamlAuthProvider uses initiateLogin()/handleCallback() — direct credential authentication is not supported for SAML", - ); - } - - async initiateLogin(_req: Request): Promise { - throw new Error( - "SamlAuthProvider is a stub — see src/admin/auth/saml-provider.ts for the implementation guide", - ); - } - - async handleCallback(_req: Request): Promise { - throw new Error( - "SamlAuthProvider is a stub — see src/admin/auth/saml-provider.ts for the implementation guide", - ); - } -} diff --git a/src/admin/block-editor-plugin.tsx b/src/admin/block-editor-plugin.tsx index fc5aa7e..a166a4b 100644 --- a/src/admin/block-editor-plugin.tsx +++ b/src/admin/block-editor-plugin.tsx @@ -1,7 +1,7 @@ /** @jsxImportSource preact */ /** * Built-in block editor — the default ContentEditorPlugin loaded by bootstrap - * when no plugin overrides adminServices.contentEditor. + * when no plugin provides the `editor.content` capability. * * Wraps the PageEditor island in the same way the old hardwired route did, but * now as a proper ContentEditorPlugin so it participates in the same slot that @@ -9,6 +9,7 @@ */ import type { ContentEditorPlugin } from "@dune/core/hooks"; +import type { FreshContext } from "fresh"; import PageEditor from "./islands/PageEditor.tsx"; function PageEditorRoute( @@ -28,7 +29,10 @@ function PageEditorRoute( export function createBlockEditorPlugin(): ContentEditorPlugin { return { - pageEditorHandler(ctx) { + // A full Fresh context (ctx.render): the contract's PluginRouteContext is narrowed here, + // which the contract allows because this plugin depends on Fresh itself. + // deno-lint-ignore no-explicit-any + pageEditorHandler(ctx: FreshContext) { const { engine, prefix } = ctx.state.adminContext; const pagePath = ctx.url.searchParams.get("path"); if (!pagePath) { diff --git a/src/admin/comments.ts b/src/admin/comments.ts index 385b916..332ac58 100644 --- a/src/admin/comments.ts +++ b/src/admin/comments.ts @@ -9,7 +9,7 @@ * data/comments/{encodedPath}.json → { comments: PageComment[] } * encodedPath: sourcePath with "/" → "__" and "." → "-dot-" * - * Mention read-state (inside admin.runtime_dir — gitignored, machine-local): + * Mention read-state (inside system.runtime_dir — gitignored, machine-local): * .dune/admin/mention-reads/{username}.json → { readIds: string[] } * * All public methods are async to allow future storage adapter swapping. @@ -65,7 +65,7 @@ interface MentionReadState { export interface CommentManagerOptions { /** admin.data_dir — git-tracked persistent storage */ dataDir: string; - /** admin.runtime_dir — ephemeral machine-local storage */ + /** system.runtime_dir — ephemeral machine-local storage */ runtimeDir: string; } diff --git a/src/admin/context.ts b/src/admin/context.ts index 997ba3d..e90e973 100644 --- a/src/admin/context.ts +++ b/src/admin/context.ts @@ -17,23 +17,22 @@ import type { Scheduler, ScheduledAction } from "@dune/core/workflow"; import type { HistoryEngine } from "@dune/core/history"; import type { SubmissionManager } from "./submissions.ts"; import type { FlexEngine } from "@dune/core/flex"; -import type { HookRegistry, AdminPageRegistration } from "@dune/core/hooks"; +import type { HookRegistry, AdminPageRegistration, AdminRouteRegistration } from "@dune/core/hooks"; import type { ContentEditorPlugin } from "@dune/core/hooks"; import type { StagingEngine } from "@dune/core/staging"; import type { CommentManager } from "./comments.ts"; import type { CollabManager } from "../collab/mod.ts"; import type { InlineEditManager } from "@dune/core/inline-edit"; -import type { ImageCache } from "@dune/core/images"; -import type { AuditLogger } from "@dune/core/audit"; +import type { AuditLogger } from "../audit/mod.ts"; import type { MetricsCollector } from "@dune/core/metrics"; -import type { MachineTranslator } from "@dune/core/mt"; +import type { MachineTranslator } from "../mt/mod.ts"; import type { RateLimitStore } from "@dune/core/security"; import type { PluginServices } from "@dune/core/plugin-api"; import type { DuneAuthSystem } from "@dune/core/auth/authz"; import type { SearchManager } from "@dune/core/search"; import type { SectionRegistry } from "@dune/core/sections"; -export type { AdminPageRegistration }; +export type { AdminPageRegistration, AdminRouteRegistration }; /** All services available to admin route handlers via `FreshContext.state.adminContext`. */ export interface AdminContext { @@ -67,11 +66,10 @@ export interface AdminContext { staging?: StagingEngine; comments?: CommentManager; collab?: CollabManager; - /** Inline editing manager, provided by a plugin via adminServices (v0.16+). */ + /** Inline editing manager, provided by a plugin as the `editor.inline` capability. */ inlineEdit?: InlineEditManager; - /** Custom page editor, provided by a plugin via adminServices (v0.24+). Replaces the built-in block editor. */ + /** Custom page editor, provided by a plugin as the `editor.content` capability. Replaces the built-in block editor. */ contentEditor?: ContentEditorPlugin; - imageCache?: ImageCache; auditLogger?: AuditLogger; metrics?: MetricsCollector; mt?: MachineTranslator | null; @@ -108,6 +106,11 @@ export interface AdminContext { * The Fresh app registers these as programmatic routes after fsRoutes(). */ pluginPages?: AdminPageRegistration[]; + /** + * Plugin-contributed guarded routes (`admin.routes`), collected at mount. + * Mounted behind the admin's authentication, CSRF and permission checks. + */ + pluginRoutes?: AdminRouteRegistration[]; /** * HMAC key for session-bound CSRF tokens. Tests inject this; production * loads or creates `runtimeDir/csrf-secret` on first use. diff --git a/src/admin/guards.ts b/src/admin/guards.ts index e604087..314d48c 100644 --- a/src/admin/guards.ts +++ b/src/admin/guards.ts @@ -2,10 +2,10 @@ * Public guard primitives for plugin-registered admin mutation routes. * * `DunePlugin.mount({ app })` hands a plugin author the raw Fresh `app` - * with no built-in enforcement — `adminPages` gets CSRF protection and - * permission checking for free, but `mount()`-registered routes (the only - * way to register a `POST`/`PUT`/`DELETE` admin route, or anything - * `adminPages` can't express) previously had no supported way to reuse + * with no built-in enforcement — `admin.pages` contributions get CSRF + * protection and permission checking for free, but `mount()`-registered routes + * (the only way to register a `POST`/`PUT`/`DELETE` admin route, or anything + * `admin.pages` can't express) previously had no supported way to reuse * Dune's own checks at all. * * Re-implementing these by hand is exactly what caused three real diff --git a/src/admin/mod.ts b/src/admin/mod.ts index 43a9ef7..876d911 100644 --- a/src/admin/mod.ts +++ b/src/admin/mod.ts @@ -18,13 +18,8 @@ export type { AuthProvider, AuthProviderUser, AuthCredentials, - AuthProviderConfig, - LdapProviderConfig, - SamlProviderConfig, } from "./auth/provider.ts"; export { LocalAuthProvider } from "./auth/local-provider.ts"; -export { LdapAuthProvider } from "./auth/ldap-provider.ts"; -export { SamlAuthProvider } from "./auth/saml-provider.ts"; export { findOrProvisionUser } from "./auth/provisioner.ts"; export type { @@ -39,7 +34,6 @@ export type { export { toUserInfo } from "./types.ts"; // Submissions -export { createSubmissionManager } from "./submissions.ts"; export type { Submission, SubmissionStatus, diff --git a/src/admin/mount.ts b/src/admin/mount.ts index f57603d..d33c81a 100644 --- a/src/admin/mount.ts +++ b/src/admin/mount.ts @@ -36,45 +36,40 @@ // deno-lint-ignore no-explicit-any import type { App, FreshContext, Middleware } from "fresh"; import type { BootstrapResult } from "@dune/core/bootstrap"; -import { registerPluginPublicRoutes } from "@dune/core/fresh-app"; -import { registerFormRoutes } from "@dune/core/forms"; -import { checkPermission } from "./routes/api/_utils.ts"; -import type { AdminState } from "./types.ts"; +import { checkPermission, type GuardedHandler, withGuards } from "./routes/api/_utils.ts"; +import { getAdminContext } from "./context.ts"; +import type { AdminPermission, AdminState } from "./types.ts"; import { adminIslands, adminLayout, adminMiddleware, adminRoutes, } from "./manifest.gen.ts"; +import { adminSettings } from "./settings.ts"; /** * `ctx` objects (`BootstrapResult`) for which the `ctx.state.adminContext` * middleware has already been registered on `app` — makes a second * registration for the same bootstrap a no-op instead of stacking a - * duplicate middleware. Both `mountDuneAdminEarly()` (called from the admin - * plugin's `mountEarly()` in the normal `createDuneApp()` path) and - * `mountDuneAdmin()` itself (headless-mode direct callers, who never call - * `mountEarly()` at all) can reach this — whichever runs first wins, and the - * other silently skips. + * duplicate middleware. */ -const contextMiddlewareRegisteredFor = new WeakSet(); +const contextMiddlewareRegisteredFor = new WeakSet(); /** * Register the `ctx.state.adminContext = …` middleware every admin route * handler (and `@dune/plugin-admin`'s `withGuards()`, and any other * plugin's own guarded route) reads. Takes a getter rather than the context - * value itself so it can be registered before the `AdminContext` object - * exists yet — see `mountDuneAdminEarly()`. + * value itself. * * Exported so a headless-mode caller with an unusual setup can register this - * independently of `mountDuneAdmin()`'s other route registration, but the - * normal paths (`mountDuneAdmin()`, `mountDuneAdminEarly()`) already call - * this — most callers should use one of those instead. + * independently of `mountDuneAdmin()`'s other route registration, but + * `mountDuneAdmin()` already calls it — most callers should use that instead. */ export function registerAdminContextMiddleware( // deno-lint-ignore no-explicit-any app: App, - ctx: BootstrapResult, + /** Any object identifying the site (its runtime or bootstrap result): one registration per object. */ + ctx: object, getAdminContext: () => import("./context.ts").AdminContext | null, ): void { if (contextMiddlewareRegisteredFor.has(ctx)) return; @@ -91,11 +86,7 @@ const authMiddlewareRegisteredFor = new WeakSet(); /** * Register the admin auth middleware (`routes/_middleware.ts`'s `handler` — * authenticates the session cookie into `ctx.state.auth`, applies admin CSP - * headers). No-op if already registered on this `app` — see - * {@link mountDuneAdminEarly}, whose whole reason for existing is to - * register this (and {@link registerAdminContextMiddleware}) before other - * plugins' routes are compiled; `registerAdminRoutes()` calls this too, for - * headless-mode callers who never call `mountDuneAdminEarly()`. + * headers). No-op if already registered on this `app`. */ function registerAdminAuthMiddleware( // deno-lint-ignore no-explicit-any @@ -108,56 +99,115 @@ function registerAdminAuthMiddleware( } /** - * Register the middleware every other plugin's own routes need to already - * be in place before it runs: - * - * - `ctx.state.adminContext` (see {@link registerAdminContextMiddleware}) - * - `ctx.state.auth` (the admin auth middleware — sets it for any request - * under the admin path prefix; a plugin's own guarded route must be - * registered under that prefix too, e.g. `/admin/my-plugin/…`, to be - * reached by it — see `@dune/plugin-admin/admin/guards`' own doc comment) - * - * This is the admin plugin's `DunePlugin.mountEarly()` implementation (see - * that hook's doc comment in `@dune/core`'s `hooks/types.ts` for why this - * needs to be a separate pass from `mountDuneAdmin()`/`mount()`). Called - * before the `AdminContext` itself has been built — `getAdminContext` is - * read lazily, per-request, so it naturally resolves to the real value once - * `mount()` (which runs after every plugin's `mountEarly()`) finishes - * building it, well before the app starts serving requests. The auth - * middleware itself needs no such laziness — it's a static generated route - * module, not something built from `AdminContext`. - * - * Headless-mode callers using `mountDuneAdmin()` directly (see this - * module's own docstring) don't need this — they already have a built - * `AdminContext` before they call anything, and their own routes are - * registered by them, after `mountDuneAdmin()`, per that docstring's - * example — so `mountDuneAdmin()`'s own (immediate, non-lazy) registration - * already covers them. - * - * @since 3.1.0 + * Mount the plugin-contributed `admin.pages` under the admin prefix: GET pages + * rendered in the admin shell. An unauthenticated request is sent to the login + * page; an authenticated one needs the page's `permission` (403 otherwise). + * `permission` is required: an entry without one is skipped and logged as + * `admin.pages.invalid` rather than exposed to every authenticated admin, which + * also covers plugins written without type-checking. */ -export function mountDuneAdminEarly( +export function mountPluginAdminPages( // deno-lint-ignore no-explicit-any app: App, - ctx: BootstrapResult, - getAdminContext: () => import("./context.ts").AdminContext | null, + adminPrefix: string, + pages: readonly import("./context.ts").AdminPageRegistration[], + logger: { warn(event: string, fields?: Record): void }, ): void { - if (ctx.config.admin?.enabled === false) return; - registerAdminContextMiddleware(app, ctx, getAdminContext); - registerAdminAuthMiddleware(app); + for (const page of pages) { + if (typeof page.path !== "string" || !page.path.startsWith("/")) { + logger.warn("admin.pages.invalid", { path: page.path, reason: "path must start with /" }); + continue; + } + if (typeof page.permission !== "string" || page.permission === "") { + logger.warn("admin.pages.invalid", { path: page.path, reason: "permission is required" }); + continue; + } + app.get(`${adminPrefix}${page.path}`, async (fc) => { + // deno-lint-ignore no-explicit-any + const authResult = (fc.state as any).auth; + if (!authResult?.authenticated) { + return new Response(null, { status: 302, headers: { Location: `${adminPrefix}/login` } }); + } + const ok = await checkPermission( + fc as unknown as FreshContext, + page.permission as AdminPermission, + ); + if (!ok) return new Response("Forbidden", { status: 403 }); + // deno-lint-ignore no-explicit-any + return page.handler(fc as any); + }); + } } +const ROUTE_METHODS = { + GET: "get", + POST: "post", + PUT: "put", + PATCH: "patch", + DELETE: "delete", +} as const; + /** - * Mount Dune's admin panel and public API routes onto an existing Fresh app. + * Mount the plugin-contributed `admin.routes` under the admin prefix. Each + * handler runs only for an authenticated admin (`401` otherwise), after the + * CSRF check (every non-GET method) and the route's `permission` check + * (`403`), exactly as the admin's own API routes. Must run after the admin + * middleware is registered, which is what puts `ctx.state.auth` in place. + * `permission` is required. A route with an unknown method, a path that does not + * start with `/`, or no permission is skipped and logged, not mounted. + */ +export function mountPluginAdminRoutes( + // deno-lint-ignore no-explicit-any + app: App, + adminPrefix: string, + routes: readonly import("./context.ts").AdminRouteRegistration[], + logger: { warn(event: string, fields?: Record): void }, +): void { + for (const route of routes) { + const register = ROUTE_METHODS[route.method as keyof typeof ROUTE_METHODS]; + if (!register || typeof route.path !== "string" || !route.path.startsWith("/")) { + logger.warn("admin.routes.invalid", { method: route.method, path: route.path, reason: "unknown method or path without leading /" }); + continue; + } + if (typeof route.permission !== "string" || route.permission === "") { + logger.warn("admin.routes.invalid", { method: route.method, path: route.path, reason: "permission is required" }); + continue; + } + const guarded = withGuards( + { permission: route.permission as AdminPermission }, + route.handler as unknown as GuardedHandler, + ); + app[register](`${adminPrefix}${route.path}`, (fc) => { + // deno-lint-ignore no-explicit-any + if (!(fc.state as any).auth?.authenticated) { + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + // deno-lint-ignore no-explicit-any + return guarded(fc as any); + }); + } +} + +/** The single-site context `mount()` stored, or null before the plugin has mounted. */ +function currentAdminContextOrNull(): import("./context.ts").AdminContext | null { + try { + return getAdminContext(); + } catch { + return null; + } +} + +/** + * Mount Dune's admin panel onto an existing Fresh app. * * Registers: - * - Per-site admin context middleware (required for admin route handlers) — - * a no-op here if `mountDuneAdminEarly()` already registered it (see - * `contextMiddlewareRegisteredFor`). + * - Per-site admin context middleware (required for admin route handlers) * - Admin file-system routes under `adminPrefix` (default `/admin`) - * - Plugin admin pages (programmatic routes registered by plugins) - * - Plugin public routes (registered by plugins) - * - Public form and webhook API endpoints + * - The plugins' `admin.pages` and `admin.routes` + * + * It does not register plugins' `publicRoutes` or the public form and webhook + * endpoints: `createDuneApp()` in `@dune/core` owns those, and no longer + * exports the functions that registered them. * * @param app - The Fresh App instance to mount onto. * @param ctx - A fully-bootstrapped BootstrapResult from `bootstrap()`. @@ -166,20 +216,41 @@ export async function mountDuneAdmin( // deno-lint-ignore no-explicit-any app: App, ctx: BootstrapResult, - /** AdminContext built by @dune/plugin-admin — when omitted, falls back to ctx.adminContext. */ + /** + * The AdminContext built by @dune/plugin-admin's `mount()`. When omitted + * (headless-mode callers), falls back to the single-site context `mount()` + * stored via `initAdminContext()`, or none if the plugin has not mounted. + */ adminCtxOverride?: import("./context.ts").AdminContext, ): Promise { - const { config } = ctx; - const adminContext = adminCtxOverride ?? - (ctx.adminContext as import("./context.ts").AdminContext | null); - const adminPrefix = config.admin?.path ?? "/admin"; + mountAdminPanel(app, ctx, adminCtxOverride ?? currentAdminContextOrNull()); +} + +/** + * Mount the admin panel itself: the per-site context middleware, the admin + * routes under the admin prefix, and the plugins' `admin.pages` / `admin.routes`. + * It needs only the loaded config, so `mount()` calls it with the plugin + * runtime and `mountDuneAdmin()` (headless mode) with a full bootstrap result. + * It registers neither public plugin routes nor the form endpoints: core's + * `createDuneApp()` owns those. + * + * @param site - Identifies the site; a second mount for the same object does not stack a second context middleware. + */ +export function mountAdminPanel( + // deno-lint-ignore no-explicit-any + app: App, + site: { config: import("@dune/core/config").DuneConfig }, + adminContext: import("./context.ts").AdminContext | null, +): void { + const { config } = site; + const ctx = site; + const adminPrefix = adminSettings(config).path; // ── Admin panel ───────────────────────────────────────────────────────────── - if (config.admin?.enabled !== false) { + { // Per-site admin context middleware — captures the correct context in // closure so multisite setups don't suffer from the module-level - // singleton. No-op if mountDuneAdminEarly() already registered it for - // this ctx (the normal createDuneApp() + mountPlugins() path). + // singleton. A second registration for the same ctx is a no-op. if (adminContext) { registerAdminContextMiddleware(app, ctx, () => adminContext); } @@ -196,52 +267,14 @@ export async function mountDuneAdmin( // The admin _middleware enforces authentication; here we additionally // honour the plugin-declared permission (if any) so plugin authors can // restrict access to a subset of admin roles. - const pluginAdminPages = adminContext?.pluginPages; - if (pluginAdminPages && pluginAdminPages.length > 0 && adminContext) { - for (const page of pluginAdminPages) { - const fullPath = `${adminPrefix}${page.path}`; - app.get(fullPath, async (fc) => { - // deno-lint-ignore no-explicit-any - const authResult = (fc.state as any).auth; - if (!authResult?.authenticated) { - return new Response(null, { - status: 302, - headers: { Location: `${adminPrefix}/login` }, - }); - } - if (page.permission) { - // deno-lint-ignore no-explicit-any - const ok = await checkPermission( - fc as unknown as FreshContext, - page.permission as any, - ); - if (!ok) { - return new Response("Forbidden", { status: 403 }); - } - } - // deno-lint-ignore no-explicit-any - return page.handler(fc as any); - }); - } + if (adminContext?.pluginRoutes?.length) { + mountPluginAdminRoutes(app, adminPrefix, adminContext.pluginRoutes, adminContext.services.logger); } - } - - // ── Plugin public routes ──────────────────────────────────────────────────── - // @dune/core's own createDuneApp() registers these itself now (it collects - // BootstrapResult.pluginPublicRoutes in bootstrap.ts and wires them before - // its content catch-all) — this call only matters for the headless-mode - // path this file's own module docstring documents, where a developer calls - // bootstrap() + mountDuneAdmin() directly and never goes through - // createDuneApp() at all. Delegating to the same core function (rather than - // re-implementing the validation/shadowing logic here) keeps exactly one - // place that decides what a plugin route is allowed to do. - registerPluginPublicRoutes(app, ctx, { adminPrefix }); - // ── Public form / webhook API ─────────────────────────────────────────────── - // Served by core, with or without the admin panel. createDuneApp() already - // registers them, so this only matters for the headless path above, and it - // is a no-op when they are already there. - registerFormRoutes(app, ctx); + if (adminContext?.pluginPages?.length) { + mountPluginAdminPages(app, adminPrefix, adminContext.pluginPages, adminContext.services.logger); + } + } } /** @@ -284,7 +317,7 @@ export function getDuneAdminIslands(): string[] { * self-guard and pass through untouched on non-admin paths (they must, * because Fresh applies fs middleware/layouts globally too — this preserves * the exact fsRoutes() behavior). The auth middleware registration itself - * is a no-op here if `mountDuneAdminEarly()` already registered it (see + * is a no-op here if it was already registered (see * `registerAdminAuthMiddleware`) — the normal `createDuneApp()` + * `mountPlugins()` path. */ diff --git a/src/admin/page-events.ts b/src/admin/page-events.ts new file mode 100644 index 0000000..da4a41e --- /dev/null +++ b/src/admin/page-events.ts @@ -0,0 +1,40 @@ +/** + * Announce a page write to plugins and content webhooks. + * + * Whoever writes a page fires its event: core never does it for the writer, and + * search indexes, webhooks and audit plugins only learn of a write through it. + * Every admin route that creates, rewrites, deletes or moves a page calls this + * after the file is on disk. Fire-and-forget: a failing handler or an + * unreachable webhook never fails the request. + * + * @module + */ + +import type { FreshContext } from "fresh"; +import type { AdminState } from "./types.ts"; +import { fireContentWebhooks } from "./webhooks.ts"; +import { adminSettings } from "./settings.ts"; + +/** Paths are relative to the content directory. */ +export type PageEvent = + | { event: "onPageCreate"; payload: { sourcePath: string; title: string } } + | { event: "onPageUpdate"; payload: { sourcePath: string } } + | { event: "onPageDelete"; payload: { sourcePath: string } } + | { event: "onPageMove"; payload: { moves: Array<{ from: string; to: string }> } }; + +/** Fire a page event to hook handlers and to the configured content webhooks. */ +export function announcePageEvent(ctx: FreshContext, e: PageEvent): void { + const { hooks, config, services } = ctx.state.adminContext; + if (hooks) { + hooks.fire(e.event, e.payload).catch((err: unknown) => + console.warn(`[dune] ${e.event} hook failed: ${err instanceof Error ? err.message : err}`) + ); + } + fireContentWebhooks( + adminSettings(config).webhooks, + e.event, + e.payload, + services.paths.runtimeDir, + services.security, + ); +} diff --git a/src/admin/routes/_middleware.ts b/src/admin/routes/_middleware.ts index 395ed2e..595d1a5 100644 --- a/src/admin/routes/_middleware.ts +++ b/src/admin/routes/_middleware.ts @@ -136,7 +136,7 @@ export function toAdminRelative(pathname: string, normalizedPrefix: string): str * in the union — it only ever existed on the removed `ROLE_PERMISSIONS` * table and has no corresponding action in `@dune/core`'s authz schema * (`actionToRelations` never defined it). Panel access is `canThey: "access"` - * on `{ type: "app", id: "admin" }`, enforced by this middleware's gate. + * on `{ type: "app", id: "site" }`, enforced by this middleware's gate. */ const NAV_PERMISSIONS: readonly AdminPermission[] = [ "pages.create", @@ -177,7 +177,7 @@ export async function computeNavPermissions( who: { type: "user", id: userId }, // deno-lint-ignore no-explicit-any canThey: permission as any, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }); return allowed ? permission : null; }), @@ -229,7 +229,7 @@ export async function handler( const canAccess = await adminCtx.authz.check({ who: { type: "user", id: authResult.user.id }, canThey: "access", - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }); if (!canAccess) { return withSecurityHeaders(new Response("Forbidden", { status: 403 }), frameable); diff --git a/src/admin/routes/api/_utils.ts b/src/admin/routes/api/_utils.ts index 56236ba..919be47 100644 --- a/src/admin/routes/api/_utils.ts +++ b/src/admin/routes/api/_utils.ts @@ -148,7 +148,7 @@ export async function checkPermission( return await authz.check({ who: { type: "user", id: authResult.user.id }, canThey: permission as any, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }); } @@ -304,7 +304,7 @@ export function actorFromAuth( authResult: { user?: { id: string; username?: string; name?: string } | null; }, -): import("@dune/core/audit").AuditActor | null { +): import("../../../audit/mod.ts").AuditActor | null { if (!authResult.user) return null; const username = authResult.user.username ?? authResult.user.id; return { diff --git a/src/admin/routes/api/audit.ts b/src/admin/routes/api/audit.ts index c7f2cd1..f90c23f 100644 --- a/src/admin/routes/api/audit.ts +++ b/src/admin/routes/api/audit.ts @@ -2,7 +2,7 @@ import type { AdminState } from "../../types.ts"; import { json } from "./_utils.ts"; -import type { AuditQuery, AuditEventType } from "@dune/core/audit"; +import type { AuditQuery, AuditEventType } from "../../../audit/mod.ts"; import type { FreshContext } from "fresh"; export const handler = { diff --git a/src/admin/routes/api/dev/apply.ts b/src/admin/routes/api/dev/apply.ts index 61455a2..777db57 100644 --- a/src/admin/routes/api/dev/apply.ts +++ b/src/admin/routes/api/dev/apply.ts @@ -41,6 +41,7 @@ import type { FreshContext } from "fresh"; import type { AdminState } from "../../../types.ts"; import { json, requirePermission, requireTsxWrite, requireOwnedPage, csrfCheck, validatePagePath } from "../_utils.ts"; import { ValidationError } from "../../../http.ts"; +import { announcePageEvent } from "../../../page-events.ts"; import { join } from "@std/path"; import { parse as parseYaml, stringify as stringifyYaml } from "@std/yaml"; @@ -482,10 +483,19 @@ export const handler = { try { if (change.op === "delete") { await Deno.remove(absPath); + announcePageEvent(ctx, { event: "onPageDelete", payload: { sourcePath: change.path ?? "" } }); results.push({ op: change.op, path: change.path, status: "deleted", errors: [] }); } else { const existed = await Deno.stat(absPath).then(() => true).catch(() => false); await applyWrite(contentDir, change as Change & { op: "write" | "frontmatter"; path: string }); + const sourcePath = change.path ?? ""; + const title = change.op === "frontmatter" ? change.patch?.title : undefined; + announcePageEvent( + ctx, + existed + ? { event: "onPageUpdate", payload: { sourcePath } } + : { event: "onPageCreate", payload: { sourcePath, title: typeof title === "string" ? title : "" } }, + ); results.push({ op: change.op, path: change.path, diff --git a/src/admin/routes/api/email-preview.ts b/src/admin/routes/api/email-preview.ts index 51e4fc4..aa836a7 100644 --- a/src/admin/routes/api/email-preview.ts +++ b/src/admin/routes/api/email-preview.ts @@ -14,7 +14,7 @@ export const handler = { return json({ error: "Email preview is only available in development mode" }, 404); } - const runtimeDir = ctx.state.adminContext.config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; const devEmailDir = join(runtimeDir, "dev-email"); const entries: Record[] = []; diff --git a/src/admin/routes/api/email-preview/[id].ts b/src/admin/routes/api/email-preview/[id].ts index baef1a0..1852be3 100644 --- a/src/admin/routes/api/email-preview/[id].ts +++ b/src/admin/routes/api/email-preview/[id].ts @@ -19,7 +19,7 @@ export const handler = { return json({ error: "Invalid id" }, 400); } - const runtimeDir = ctx.state.adminContext.config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; const filePath = join(runtimeDir, "dev-email", `${id}.json`); try { diff --git a/src/admin/routes/api/history/[...rest].ts b/src/admin/routes/api/history/[...rest].ts index d2810f6..44508b7 100644 --- a/src/admin/routes/api/history/[...rest].ts +++ b/src/admin/routes/api/history/[...rest].ts @@ -13,6 +13,7 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, requireTsxWrite, requireOwnedPage, json, serverError, csrfCheck, validatePagePath } from "../_utils.ts"; import { stringify as stringifyYaml } from "@std/yaml"; +import { announcePageEvent } from "../../../page-events.ts"; import type { FreshContext } from "fresh"; export const handler = { @@ -108,6 +109,7 @@ export const handler = { await storage.write(filePath, new TextEncoder().encode(fullContent)); await engine.rebuild(); + announcePageEvent(ctx, { event: "onPageUpdate", payload: { sourcePath: pageIndex.sourcePath } }); return json({ restored: true, revision: revNum }); } catch (err) { diff --git a/src/admin/routes/api/i18n/translate-page.ts b/src/admin/routes/api/i18n/translate-page.ts index f46bb96..8c5ad2d 100644 --- a/src/admin/routes/api/i18n/translate-page.ts +++ b/src/admin/routes/api/i18n/translate-page.ts @@ -2,6 +2,7 @@ import type { AdminState } from "../../../types.ts"; import { json, serverError, csrfCheck, requirePermission, requireTsxWrite, requireOwnedPage, validatePagePath } from "../_utils.ts"; +import { announcePageEvent } from "../../../page-events.ts"; import type { FreshContext } from "fresh"; function splitFrontmatter(content: string): { fm: string; body: string } { @@ -95,6 +96,7 @@ export const handler = { } await storage.write(`${contentDir}/${targetPath}`, new TextEncoder().encode(translatedFm + translatedBody)); + announcePageEvent(ctx, { event: "onPageCreate", payload: { sourcePath: targetPath, title: pageIndex.title ?? "" } }); engine.rebuild().catch((err: unknown) => console.error("[dune] MT translate-page rebuild error:", err)); return json({ ok: true, targetPath }); diff --git a/src/admin/routes/api/introspect.ts b/src/admin/routes/api/introspect.ts index 7bc9fb3..e1a554d 100644 --- a/src/admin/routes/api/introspect.ts +++ b/src/admin/routes/api/introspect.ts @@ -13,6 +13,7 @@ import type { FreshContext } from "fresh"; import type { AdminState } from "../../types.ts"; import { json, requirePermission } from "./_utils.ts"; import { PLUGIN_API_VERSION } from "@dune/core/plugins"; +import { adminSettings } from "../../settings.ts"; /** Derive Dune package version from the module URL (same logic as CLI). */ function getDuneVersion(): string { @@ -102,20 +103,19 @@ export const handler = { const configSummary = { siteTitle: config.site.title, siteUrl: config.site.url, - adminPath: config.admin?.path ?? "/admin", + adminPath: adminSettings(config).path, themeName: config.theme.name, taxonomies: config.site.taxonomies, languages: { supported: config.system.languages?.supported ?? [], default: config.system.languages?.default ?? "en", }, - cacheEnabled: config.system.cache?.enabled ?? false, - cacheDriver: config.system.cache?.driver ?? "memory", + pageCacheEnabled: config.system.page_cache?.enabled ?? false, debugMode: config.system.debug ?? false, authMode: ((config as unknown as Record).auth as Record | undefined)?.mode ?? null, feedEnabled: config.site.feed?.enabled !== false, workflowEnabled: !!config.site.workflow, - auditEnabled: config.admin?.audit?.enabled !== false, + auditEnabled: adminSettings(config).audit?.enabled !== false, }; // ── Response ─────────────────────────────────────────────────────────── diff --git a/src/admin/routes/api/media/upload.ts b/src/admin/routes/api/media/upload.ts index 3116639..809f019 100644 --- a/src/admin/routes/api/media/upload.ts +++ b/src/admin/routes/api/media/upload.ts @@ -7,6 +7,7 @@ import { isMediaFile, dirPathToRoute } from "@dune/core/content/path-utils"; import { getMimeType } from "@dune/core/content/page-loader"; import { isDuneError } from "@dune/core/plugin-api"; import type { FreshContext } from "fresh"; +import { adminSettings } from "../../../settings.ts"; export const handler = { async POST(ctx: FreshContext) { @@ -17,7 +18,7 @@ export const handler = { const { storage, config, auditLogger, services } = ctx.state.adminContext; const MAX_FILE_SIZE = 50 * 1024 * 1024; - const maxBodyBytes = (config.admin?.max_upload_mb ?? 100) * 1024 * 1024; + const maxBodyBytes = adminSettings(config).max_upload_mb * 1024 * 1024; try { const tooLarge = services.security.checkBodySize(ctx.req, maxBodyBytes); diff --git a/src/admin/routes/api/pages/[...path]/index.ts b/src/admin/routes/api/pages/[...path]/index.ts index 638de1c..2c60120 100644 --- a/src/admin/routes/api/pages/[...path]/index.ts +++ b/src/admin/routes/api/pages/[...path]/index.ts @@ -15,12 +15,13 @@ import { stringify as stringifyYaml } from "@std/yaml"; import { fireContentWebhooks } from "../../../../../admin/webhooks.ts"; import type { PageFrontmatter } from "@dune/core/content/types"; import type { FreshContext } from "fresh"; +import { adminSettings } from "../../../../settings.ts"; async function maybeGitCommit( filePath: string, sourcePath: string, author: string | undefined, config: import("../../../../context.ts").AdminContext["config"], ): Promise { - if (!config.admin?.git_commit) return; + if (!adminSettings(config).git_commit) return; try { const msg = author ? `Admin: update ${sourcePath} (by ${author})` : `Admin: update ${sourcePath}`; const add = new Deno.Command("git", { args: ["add", filePath], stderr: "inherit" }); @@ -129,8 +130,8 @@ export const handler = { await engine.rebuild(); await maybeGitCommit(filePath, pagePath, undefined, config); - const webhookEndpoints = config.admin?.webhooks ?? []; - const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; + const webhookEndpoints = adminSettings(config).webhooks; + const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; if (hooks) hooks.fire("onPageUpdate", { sourcePath: page.sourcePath }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onPageUpdate", { sourcePath: page.sourcePath }, runtimeDir, ctx.state.adminContext.services.security); @@ -171,8 +172,8 @@ export const handler = { await storage.delete(`${contentDir}/${page.sourcePath}`); await engine.rebuild(); - const webhookEndpoints = config.admin?.webhooks ?? []; - const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; + const webhookEndpoints = adminSettings(config).webhooks; + const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; if (hooks) hooks.fire("onPageDelete", { sourcePath: page.sourcePath }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onPageDelete", { sourcePath: page.sourcePath }, runtimeDir, ctx.state.adminContext.services.security); diff --git a/src/admin/routes/api/pages/index.ts b/src/admin/routes/api/pages/index.ts index 291490a..b8f9230 100644 --- a/src/admin/routes/api/pages/index.ts +++ b/src/admin/routes/api/pages/index.ts @@ -5,6 +5,7 @@ import { requirePermission, requireTsxWrite, json, serverError, actorFromAuth, g import { fireContentWebhooks } from "../../../../admin/webhooks.ts"; import { stringify as stringifyYaml } from "@std/yaml"; import type { FreshContext } from "fresh"; +import { adminSettings } from "../../../settings.ts"; export const handler = { async GET(ctx: FreshContext) { @@ -67,8 +68,8 @@ export const handler = { await storage.write(filePath, new TextEncoder().encode(fm + "\n" + defaultContent)); await engine.rebuild(); - const webhookEndpoints = config.admin?.webhooks ?? []; - const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; + const webhookEndpoints = adminSettings(config).webhooks; + const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; if (hooks) hooks.fire("onPageCreate", { sourcePath: `${pagePath}/default${ext}`, title }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onPageCreate", { sourcePath: `${pagePath}/default${ext}`, title }, runtimeDir, ctx.state.adminContext.services.security); diff --git a/src/admin/routes/api/pages/reorder.ts b/src/admin/routes/api/pages/reorder.ts index fbb8ad8..a2a3107 100644 --- a/src/admin/routes/api/pages/reorder.ts +++ b/src/admin/routes/api/pages/reorder.ts @@ -3,6 +3,7 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, requireEditorOrAdmin, json, serverError, validatePagePath, csrfCheck } from "../_utils.ts"; import { dirname, basename } from "@std/path"; +import { announcePageEvent } from "../../../page-events.ts"; import type { FreshContext } from "fresh"; export const handler = { @@ -83,6 +84,12 @@ export const handler = { } await engine.rebuild(); + // Content-relative folders that were renamed; every page below `from` moved below `to`. + const rel = (p: string) => p.startsWith(`${contentDir}/`) ? p.slice(contentDir.length + 1) : p; + announcePageEvent(ctx, { + event: "onPageMove", + payload: { moves: renames.map((r) => ({ from: rel(r.oldDir), to: rel(r.newDir) })) }, + }); return json({ reordered: true }); } catch (err) { return serverError(err); diff --git a/src/admin/routes/api/pages/translate.ts b/src/admin/routes/api/pages/translate.ts index 6b6d7fd..b1c6d93 100644 --- a/src/admin/routes/api/pages/translate.ts +++ b/src/admin/routes/api/pages/translate.ts @@ -4,6 +4,7 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, requireTsxWrite, requireOwnedPage, json, serverError, csrfCheck, validatePagePath } from "../_utils.ts"; import { dirname, basename } from "@std/path"; import { parseContentFilename } from "@dune/core/content/path-utils"; +import { announcePageEvent } from "../../../page-events.ts"; import type { FreshContext } from "fresh"; export const handler = { @@ -48,6 +49,7 @@ export const handler = { const sourceBytes = await storage.read(`${contentDir}/${pageIndex.sourcePath}`); await storage.write(`${contentDir}/${targetPath}`, sourceBytes); + announcePageEvent(ctx, { event: "onPageCreate", payload: { sourcePath: targetPath, title: pageIndex.title ?? "" } }); await engine.rebuild(); return json({ created: true, path: targetPath }); diff --git a/src/admin/routes/api/staging/[...path]/publish.ts b/src/admin/routes/api/staging/[...path]/publish.ts index 1d55baa..0ba9134 100644 --- a/src/admin/routes/api/staging/[...path]/publish.ts +++ b/src/admin/routes/api/staging/[...path]/publish.ts @@ -7,7 +7,9 @@ import type { AdminState } from "../../../../types.ts"; import { requirePermission, requireTsxWrite, requireOwnedPage, json, serverError, csrfCheck, validatePagePath } from "../../_utils.ts"; import { stringify as stringifyYaml } from "@std/yaml"; +import { announcePageEvent } from "../../../../page-events.ts"; import type { FreshContext } from "fresh"; +import { adminSettings } from "../../../../settings.ts"; async function maybeGitCommit( filePath: string, sourcePath: string, author: string | undefined, @@ -65,9 +67,10 @@ export const handler = { }); } - await maybeGitCommit(filePath, pagePath, authResult.user?.name, config.admin?.git_commit); + await maybeGitCommit(filePath, pagePath, authResult.user?.name, adminSettings(config).git_commit); await staging.discard(pagePath); await engine.rebuild(); + announcePageEvent(ctx, { event: "onPageUpdate", payload: { sourcePath: pageIndex.sourcePath } }); return json({ published: true, sourcePath: pagePath }); } catch (err) { diff --git a/src/admin/routes/api/users/[id]/index.ts b/src/admin/routes/api/users/[id]/index.ts index 81ab1b5..ea470f4 100644 --- a/src/admin/routes/api/users/[id]/index.ts +++ b/src/admin/routes/api/users/[id]/index.ts @@ -69,7 +69,7 @@ export const handler = { await authz.disallowAllMatching({ who: { type: "user", id: userId }, was: existingRole, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }).catch((err) => { console.warn(`[dune/authz] disallowAllMatching failed on role change for user ${userId}:`, err); }); @@ -77,7 +77,7 @@ export const handler = { await authz.allow({ who: { type: "user", id: userId }, toBe: role, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }); } if (enabled !== undefined) { @@ -96,7 +96,7 @@ export const handler = { await authz.allow({ who: { type: "user", id: userId }, toBe: effectiveRole, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }).catch((err) => { console.warn(`[dune/authz] allow failed on re-enable for user ${userId}:`, err); }); diff --git a/src/admin/routes/api/users/[id]/password.ts b/src/admin/routes/api/users/[id]/password.ts index 764f236..c0cb66f 100644 --- a/src/admin/routes/api/users/[id]/password.ts +++ b/src/admin/routes/api/users/[id]/password.ts @@ -3,6 +3,7 @@ import type { AdminState } from "../../../../types.ts"; import { requirePermission, json, serverError, actorFromAuth, getClientIp, csrfCheck } from "../../_utils.ts"; import type { FreshContext } from "fresh"; +import { adminSettings } from "../../../../settings.ts"; export const handler = { async POST(ctx: FreshContext) { @@ -29,7 +30,7 @@ export const handler = { const session = await sessions.create(userId); res.headers.set( "Set-Cookie", - auth.createSessionCookie(session.id, config.admin?.session_lifetime ?? 86400), + auth.createSessionCookie(session.id, adminSettings(config).session_lifetime), ); } diff --git a/src/admin/routes/api/users/index.ts b/src/admin/routes/api/users/index.ts index 55f4e3d..955a1ed 100644 --- a/src/admin/routes/api/users/index.ts +++ b/src/admin/routes/api/users/index.ts @@ -79,7 +79,7 @@ export const handler = { await authz.allow({ who: { type: "user", id: user.id }, toBe: role, - onWhat: { type: "app", id: "admin" }, + onWhat: { type: "app", id: "site" }, }).catch(() => {}); } diff --git a/src/admin/routes/api/webhooks/deliveries.ts b/src/admin/routes/api/webhooks/deliveries.ts index 504e54e..1100bb0 100644 --- a/src/admin/routes/api/webhooks/deliveries.ts +++ b/src/admin/routes/api/webhooks/deliveries.ts @@ -32,9 +32,8 @@ export const handler = { return json({ error: "invalid path" }, 400); } - const { config } = ctx.state.adminContext; try { - const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; const logs = await listDeliveryLogs(runtimeDir, { sourcePath: path }); const items = view === "workflow" ? logs.map(toWorkflowDelivery) : logs; return json({ items, total: items.length, view }); diff --git a/src/admin/routes/audit/index.tsx b/src/admin/routes/audit/index.tsx index 639a363..3a85be9 100644 --- a/src/admin/routes/audit/index.tsx +++ b/src/admin/routes/audit/index.tsx @@ -17,9 +17,9 @@ export const handler = { ); } const eventFilter = ctx.url.searchParams.get("event") ?? ""; - const q: import("@dune/core/audit").AuditQuery = { limit: 50 }; + const q: import("../../../audit/mod.ts").AuditQuery = { limit: 50 }; if (eventFilter) { - q.event = eventFilter as import("@dune/core/audit").AuditEventType; + q.event = eventFilter as import("../../../audit/mod.ts").AuditEventType; } const result = await auditLogger.query(q); return ctx.render( diff --git a/src/admin/routes/email-preview/index.tsx b/src/admin/routes/email-preview/index.tsx index c25e7c5..7492b4d 100644 --- a/src/admin/routes/email-preview/index.tsx +++ b/src/admin/routes/email-preview/index.tsx @@ -116,7 +116,7 @@ export default function EmailPreviewPage({ data }: { data: PageData }) { export const handler = { async GET(ctx: FreshContext): Promise { const { prefix } = ctx.state.adminContext; - const runtimeDir = ctx.state.adminContext.config.admin?.runtime_dir ?? ".dune/admin"; + const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; if (Deno.env.get("DUNE_ENV") !== "dev") { return ctx.render( diff --git a/src/admin/routes/login.tsx b/src/admin/routes/login.tsx index f8ca0d3..7cfa79c 100644 --- a/src/admin/routes/login.tsx +++ b/src/admin/routes/login.tsx @@ -11,6 +11,7 @@ import { findOrProvisionUser } from "../auth/provisioner.ts"; import type { PluginServices } from "@dune/core/plugin-api"; import type { RateLimiter } from "@dune/core/security"; import { actorFromAuth, csrfCheck } from "./api/_utils.ts"; +import { adminSettings } from "../settings.ts"; // Module-level fallback limiter — used when no rateLimitStore is injected via // AdminContext (single-process deployments, tests, etc.). @@ -110,7 +111,7 @@ export const handler = { if (csrfDenied) return csrfDenied; const { auth, users, sessions, prefix, auditLogger, authProvider, config, rateLimitStore, services } = ctx.state.adminContext; - const adminConfig = config.admin!; + const adminConfig = adminSettings(config); const url = ctx.url; // Logout sub-action diff --git a/src/admin/settings.ts b/src/admin/settings.ts new file mode 100644 index 0000000..ae13a6a --- /dev/null +++ b/src/admin/settings.ts @@ -0,0 +1,106 @@ +/** + * The admin's own settings. + * + * They are the plugin's config: `config:` under its entry in site.yaml's + * `plugins:`, which core merges into `config.plugins.admin`. Core has no + * `admin` section. Everything is read through {@link adminSettings}, which + * applies the defaults and refuses a value that would desync the admin. + * + * @module + */ + +/** Content events that can trigger an outbound webhook. */ +export type WebhookContentEvent = + | "onPageCreate" + | "onPageUpdate" + | "onPageDelete" + | "onPageMove" + | "onWorkflowChange"; + +/** One outbound webhook endpoint. */ +export interface WebhookEndpointConfig { + url: string; + /** HMAC-SHA256 signing secret; `$ENV_VAR` is expanded. */ + secret?: string; + /** Which events trigger this endpoint. */ + events: WebhookContentEvent[]; + /** Disable without removing (default true). */ + enabled?: boolean; + /** Label used in delivery logs. */ + label?: string; + /** Allow delivery to private/loopback addresses (default false). */ + allow_private?: boolean; +} + +/** Sign-in provider configuration: only the built-in local passwords are configurable. */ +export type AuthProviderConfig = { type: "local" }; + +import type { MachineTranslationConfig } from "../mt/types.ts"; + +/** The admin plugin's settings, defaults applied. */ +export interface AdminSettings { + /** URL prefix the admin is mounted at (default `/admin`). */ + path: string; + /** Admin session lifetime in seconds (default 86400). */ + session_lifetime: number; + /** Maximum admin upload body in MB (default 100). */ + max_upload_mb: number; + /** Commit to git after every page save (default false). */ + git_commit: boolean; + /** Outbound webhooks. */ + webhooks: WebhookEndpointConfig[]; + /** Sign-in provider; omit for the local passwords. */ + auth_provider?: AuthProviderConfig; + /** Audit trail: `enabled` (default true) and `log_file` (under the site root). */ + audit?: { enabled?: boolean; log_file?: string }; + /** Machine translation provider; absent when none is configured. */ + machine_translation?: MachineTranslationConfig; +} + +const PATH_RE = /^\/[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)*$/; + +/** The part of the site config the admin reads settings from. */ +export interface SettingsSource { + plugins?: Record | undefined>; +} + +const cache = new WeakMap(); +const EMPTY = {}; + +/** + * The admin's settings with defaults applied. Throws on an unusable `path` or + * `auth_provider`, so a bad value stops the site at boot instead of leaving the + * admin mounted somewhere core does not protect. + */ +export function adminSettings(config: SettingsSource): AdminSettings { + const raw = (config.plugins?.admin ?? EMPTY) as Record; + const hit = cache.get(raw); + if (hit) return hit; + + const path = raw.path ?? "/admin"; + if (typeof path !== "string" || !PATH_RE.test(path)) { + throw new Error( + `[dune] admin plugin config: path must start with "/", contain only [a-zA-Z0-9_-] segments and have no ` + + `trailing slash (e.g. "/admin", "/cms"), got ${JSON.stringify(path)}`, + ); + } + const provider = raw.auth_provider as { type?: unknown } | undefined; + if (provider !== undefined && provider.type !== "local") { + throw new Error( + `[dune] admin plugin config: auth_provider.type must be "local" (got ${JSON.stringify(provider.type)}); ` + + `LDAP and SAML were never implemented. Pass a custom provider to createAdminPlugin({ authProvider }) instead.`, + ); + } + const settings: AdminSettings = { + path, + session_lifetime: typeof raw.session_lifetime === "number" ? raw.session_lifetime : 86400, + max_upload_mb: typeof raw.max_upload_mb === "number" ? raw.max_upload_mb : 100, + git_commit: raw.git_commit === true, + webhooks: Array.isArray(raw.webhooks) ? raw.webhooks as WebhookEndpointConfig[] : [], + auth_provider: provider as AuthProviderConfig | undefined, + audit: raw.audit as AdminSettings["audit"], + machine_translation: raw.machine_translation as MachineTranslationConfig | undefined, + }; + cache.set(raw, settings); + return settings; +} diff --git a/src/admin/submissions.ts b/src/admin/submissions.ts index b8ff188..f03e185 100644 --- a/src/admin/submissions.ts +++ b/src/admin/submissions.ts @@ -3,10 +3,10 @@ * * The store and its types live in `@dune/core/forms` — core accepts public * form submissions itself, so a site works without this plugin. This module - * re-exports them for the submissions UI and for existing importers. + * re-exports the types for the submissions UI. The manager itself is + * `PluginRuntime.submissions`; there is no factory to import. */ -export { createSubmissionManager } from "@dune/core/forms"; export type { Submission, SubmissionFile, diff --git a/src/admin/types.ts b/src/admin/types.ts index f52efbf..1cdb749 100644 --- a/src/admin/types.ts +++ b/src/admin/types.ts @@ -66,8 +66,6 @@ export interface AdminConfig { * (default: ".dune/admin") */ runtimeDir: string; - /** Whether admin panel is enabled (default: true) */ - enabled: boolean; } /** Result of an auth check */ diff --git a/src/admin/webhooks.ts b/src/admin/webhooks.ts index ab36545..8904e3a 100644 --- a/src/admin/webhooks.ts +++ b/src/admin/webhooks.ts @@ -22,7 +22,7 @@ import type { SecurityService } from "@dune/core/plugin-api"; /** The slice of the security service webhook delivery needs. */ export type WebhookSecurity = Pick; -import type { WebhookContentEvent, WebhookEndpointConfig } from "@dune/core/config"; +import type { WebhookContentEvent, WebhookEndpointConfig } from "./settings.ts"; // === Types === @@ -268,7 +268,7 @@ async function deliverWithRetry( * @param endpoints - Configured webhook endpoints (from admin.webhooks) * @param event - The content event that occurred * @param payload - Event-specific data included in the POST body - * @param logDir - Base directory for delivery logs (admin.runtime_dir) + * @param logDir - Base directory for delivery logs (system.runtime_dir) * @param security - Core's security service (SSRF-guarded fetch) */ export function fireContentWebhooks( @@ -308,7 +308,7 @@ export interface ListDeliveryLogsOptions { * Reads logs from the last 7 days of log directories and returns up to `limit` * records. Silently returns an empty array if the log directory doesn't exist. * - * @param logDir - Base directory (admin.runtime_dir) + * @param logDir - Base directory (system.runtime_dir) * @param limitOrOpts - Maximum records, or `{ limit, sourcePath }` */ export async function listDeliveryLogs( diff --git a/src/admin/workflow-actions.ts b/src/admin/workflow-actions.ts index 1ee9606..1260aa7 100644 --- a/src/admin/workflow-actions.ts +++ b/src/admin/workflow-actions.ts @@ -19,6 +19,7 @@ import type { DuneConfig } from "@dune/core/config"; import type { HookRegistry } from "@dune/core/hooks"; import type { WorkflowEngine } from "@dune/core/workflow"; import { fireContentWebhooks } from "./webhooks.ts"; +import { adminSettings } from "./settings.ts"; export interface WorkflowActionContext { engine: DuneEngine; @@ -75,8 +76,8 @@ export async function applyWorkflowTransition( await storage.write(filePath, new TextEncoder().encode(updated)); await engine.rebuild(); - const webhookEndpoints = config.admin?.webhooks ?? []; - const runtimeDir = config.admin?.runtime_dir ?? ".dune/admin"; + const webhookEndpoints = adminSettings(config).webhooks; + const runtimeDir = services.paths.runtimeDir; if (hooks) hooks.fire("onWorkflowChange", { sourcePath, from: currentStatus, to: newStatus }).catch(() => {}); fireContentWebhooks(webhookEndpoints, "onWorkflowChange", { sourcePath, from: currentStatus, to: newStatus }, runtimeDir, services.security); diff --git a/src/audit/logger.ts b/src/audit/logger.ts new file mode 100644 index 0000000..d35d72c --- /dev/null +++ b/src/audit/logger.ts @@ -0,0 +1,157 @@ +import { dirname, basename, extname, join } from "@std/path"; +import { ensureDir } from "@std/fs"; +import type { + AuditEntry, + AuditLogOptions, + AuditQuery, + AuditQueryResult, +} from "./types.ts"; + +const SHARD_RE = /^(\d{4}-\d{2}-\d{2})\.jsonl$/; + +/** + * AuditLogger — appends entries to daily-rotated JSONL files. + * + * Shards are written to `{dir}/{basename}/YYYY-MM-DD.jsonl`, where `{dir}` and + * `{basename}` are derived from `logFile`. Queries enumerate only the shards + * whose date falls within `from`/`to` (or all shards when the range is open). + * The legacy single-file layout (just `logFile`) is read as a fallback so an + * upgrade from pre-sharding installs doesn't lose history. + */ +export class AuditLogger { + private readonly logFile: string; // legacy path (fallback on read) + private readonly shardDir: string; // directory holding daily shards + private readonly maxQueryEntries: number; + + constructor(opts: AuditLogOptions) { + this.logFile = opts.logFile; + const ext = extname(this.logFile) || ".log"; + const stem = basename(this.logFile, ext); + this.shardDir = join(dirname(this.logFile), stem); + this.maxQueryEntries = opts.maxQueryEntries ?? 1000; + } + + private ready: Promise | null = null; + + /** Initialize — ensure shard directory exists. Idempotent; `log()` calls it on first use. */ + init(): Promise { + return this.ready ??= ensureDir(this.shardDir).then(() => undefined, (err) => { + this.ready = null; + throw err; + }); + } + + /** Write an audit entry to today's shard */ + async log(entry: Omit): Promise { + await this.init(); + const ts = new Date(); + const full: AuditEntry = { + id: crypto.randomUUID(), + ts: ts.toISOString(), + ...entry, + }; + const line = JSON.stringify(full) + "\n"; + const shardPath = join(this.shardDir, `${ts.toISOString().slice(0, 10)}.jsonl`); + await Deno.writeTextFile(shardPath, line, { append: true }); + } + + /** Query entries — reads only shards within the requested date range */ + async query(q: AuditQuery = {}): Promise { + const shards = await this.listShards(q.from, q.to); + + const fromMs = q.from ? new Date(q.from).getTime() : -Infinity; + const toMs = q.to ? new Date(q.to).getTime() : Infinity; + + // Accumulate newest-first across shards; stop once we have enough to + // satisfy maxQueryEntries after all filters (conservative cap: read + // enough candidate lines before other filters narrow further). + const collected: AuditEntry[] = []; + const entryMatches = (e: AuditEntry): boolean => { + if (q.event && e.event !== q.event) return false; + if (q.actorId && e.actor?.userId !== q.actorId) return false; + if (q.outcome && e.outcome !== q.outcome) return false; + const ems = new Date(e.ts).getTime(); + if (ems < fromMs || ems > toMs) return false; + return true; + }; + + // Walk newest shard first. + for (const shard of shards) { + const text = await readTextIfExists(shard); + if (!text) continue; + const lines = text.split("\n"); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (!line) continue; + let entry: AuditEntry; + try { + entry = JSON.parse(line) as AuditEntry; + } catch { + continue; // tolerate a partial write at end-of-file + } + if (!entryMatches(entry)) continue; + collected.push(entry); + if (collected.length >= this.maxQueryEntries) break; + } + if (collected.length >= this.maxQueryEntries) break; + } + + const total = collected.length; + const limit = Math.min(q.limit ?? 50, 200); + const offset = q.offset ?? 0; + const entries = collected.slice(offset, offset + limit); + return { entries, total }; + } + + /** + * Enumerate shard paths that fall within [from, to], newest first. + * Always includes the legacy single-file path at the end of the list when + * it exists, so migrations don't drop old entries. + */ + private async listShards(from?: string, to?: string): Promise { + const fromDate = from ? from.slice(0, 10) : null; + const toDate = to ? to.slice(0, 10) : null; + + const dates: string[] = []; + try { + for await (const entry of Deno.readDir(this.shardDir)) { + if (!entry.isFile) continue; + const m = entry.name.match(SHARD_RE); + if (!m) continue; + const date = m[1]; + if (fromDate && date < fromDate) continue; + if (toDate && date > toDate) continue; + dates.push(date); + } + } catch { + // Shard dir missing — fall through to legacy file only. + } + + dates.sort((a, b) => (a < b ? 1 : a > b ? -1 : 0)); // newest first + const shardPaths = dates.map((d) => join(this.shardDir, `${d}.jsonl`)); + + // Include legacy single-file log if still present — oldest-seeming, so + // it's queried last and its entries land at the tail. + if (await exists(this.logFile)) { + shardPaths.push(this.logFile); + } + return shardPaths; + } +} + +async function readTextIfExists(path: string): Promise { + try { + return await Deno.readTextFile(path); + } catch { + return null; + } +} + +async function exists(path: string): Promise { + try { + await Deno.stat(path); + return true; + } catch { + return false; + } +} diff --git a/src/audit/mod.ts b/src/audit/mod.ts new file mode 100644 index 0000000..dfa8d1b --- /dev/null +++ b/src/audit/mod.ts @@ -0,0 +1,24 @@ +/** + * Audit logging module. + * Re-exports all public types and the AuditLogger class. + * + * @module + */ + +export type { + AuditActor, + AuditTarget, + AuditEventType, + AuditEntry, + AuditLogOptions, + AuditQuery, + AuditQueryResult, +} from "./types.ts"; + +export type { AuditTrailOptions } from "./trail.ts"; +export { createAuditTrail } from "./trail.ts"; + +export { + /** Appends entries to daily-rotated JSONL files, sharded by date. */ + AuditLogger, +} from "./logger.ts"; diff --git a/src/audit/trail.ts b/src/audit/trail.ts new file mode 100644 index 0000000..3735130 --- /dev/null +++ b/src/audit/trail.ts @@ -0,0 +1,38 @@ +/** + * Build the audit trail for a site, or null when auditing is off. + * + * @module + */ + +import { join } from "@std/path"; +import type { DuneConfig } from "@dune/core/config"; +import { AuditLogger } from "./logger.ts"; +import { adminSettings } from "../admin/settings.ts"; + +export interface AuditTrailOptions { + config: DuneConfig; + /** Absolute site root. Without it there is no place to put the log, so no trail. */ + root: string | undefined; + /** Site-relative runtime directory (`services.paths.runtimeDir`). */ + runtimeDir: string; +} + +/** + * The trail is on unless `admin.audit.enabled` is false. The + * log file must resolve under the site root: a configured `log_file` that + * escapes it is refused. + */ +export function createAuditTrail(opts: AuditTrailOptions): AuditLogger | null { + const { config, root, runtimeDir } = opts; + const audit = adminSettings(config).audit; + if (audit?.enabled === false || !root) return null; + const logFile = audit?.log_file ? join(root, audit.log_file) : join(root, runtimeDir, "audit.log"); + const containmentRoot = root.endsWith("/") || root.endsWith("\\") ? root : root + "/"; + if (!logFile.startsWith(containmentRoot)) { + throw new Error( + `[dune] admin.audit.log_file must resolve under the site root. ` + + `Got: ${audit?.log_file} -> ${logFile}`, + ); + } + return new AuditLogger({ logFile }); +} diff --git a/src/audit/types.ts b/src/audit/types.ts new file mode 100644 index 0000000..3da1282 --- /dev/null +++ b/src/audit/types.ts @@ -0,0 +1,91 @@ +/** + * Audit logging type definitions. + */ + +/** Actor performing the action */ +export interface AuditActor { + userId: string; + username: string; + name: string; +} + +/** Target resource */ +export interface AuditTarget { + type: + | "page" + | "user" + | "config" + | "plugin" + | "theme" + | "media" + | "flex" + | "form" + | "system" + | "comment" + /** + * Used by `logAuthzDenial()` (MED-4) when the denial is keyed to an + * HTTP route rather than a content target. `id` carries the request + * pathname. + */ + | "route"; + /** Human-readable identifier (sourcePath for pages, userId for users, etc.) */ + id?: string; +} + +/** All audit event types */ +export type AuditEventType = + // Auth + | "auth.login" | "auth.logout" | "auth.login_failed" + | "auth.csrf_denied" | "auth.permission_denied" + // Pages + | "page.create" | "page.update" | "page.delete" | "page.publish" | "page.workflow" + // Config + | "config.update" + // Users + | "user.create" | "user.update" | "user.delete" | "user.password" + // Media + | "media.upload" | "media.delete" + // Plugins + | "plugin.config_update" + // Flex + | "flex.create" | "flex.update" | "flex.delete" + // System + | "system.rebuild" | "system.cache_purge"; + +/** A single audit log entry */ +export interface AuditEntry { + id: string; // crypto.randomUUID() + ts: string; // ISO 8601 + event: AuditEventType; + actor: AuditActor | null; // null for system/unauthenticated actions + ip: string | null; + userAgent: string | null; + target: AuditTarget | null; + detail: Record; // event-specific extra info + outcome: "success" | "failure"; +} + +/** Options for creating an AuditLogger */ +export interface AuditLogOptions { + /** Path to the JSONL log file (e.g. ".dune/admin/audit.log") */ + logFile: string; + /** Maximum entries returned by query() (default: 1000) */ + maxQueryEntries?: number; +} + +/** Query parameters for AuditLogger.query() */ +export interface AuditQuery { + limit?: number; + offset?: number; + event?: AuditEventType; + actorId?: string; + from?: string; // ISO date string + to?: string; // ISO date string + outcome?: "success" | "failure"; +} + +/** Result from AuditLogger.query() */ +export interface AuditQueryResult { + entries: AuditEntry[]; + total: number; +} diff --git a/src/collab/manager.ts b/src/collab/manager.ts index b454a63..0e309dc 100644 --- a/src/collab/manager.ts +++ b/src/collab/manager.ts @@ -111,7 +111,7 @@ function takeToken(rate: ConnectionRate): boolean { } export function createCollabManager(options: CollabManagerOptions): CollabManager { - const { storage, engine, history, contentDir, security } = options; + const { storage, engine, history, contentDir, security, hooks } = options; // docId → CollabSessionState const sessions = new Map(); @@ -194,6 +194,13 @@ export function createCollabManager(options: CollabManagerOptions): CollabManage return; } + // Whoever writes a page fires its event. Skip a save that changed nothing. + if (hooks && updated !== raw) { + hooks.fire("onPageUpdate", { sourcePath: session.docId }).catch((err: unknown) => + console.warn(`[collab] onPageUpdate hook failed for ${session.docId}:`, err) + ); + } + if (history && session.lastEditor) { const fm = parseFrontmatter(raw, security.parseUserYaml); history.record({ diff --git a/src/collab/types.ts b/src/collab/types.ts index f6fe8a2..ceef965 100644 --- a/src/collab/types.ts +++ b/src/collab/types.ts @@ -110,6 +110,11 @@ export interface CollabManagerOptions { contentDir: string; /** Core's security service (safe YAML parsing of frontmatter). */ security: Pick; + /** + * Hook registry. An auto-save that changes the file fires `onPageUpdate` + * (the save is already debounced, so this is not per keystroke). + */ + hooks?: Pick; } /** The CollabManager public interface exposed to the admin server. */ diff --git a/src/mt/deepl.ts b/src/mt/deepl.ts new file mode 100644 index 0000000..5ad2fa1 --- /dev/null +++ b/src/mt/deepl.ts @@ -0,0 +1,72 @@ +/** + * DeepL machine translation provider. + * Supports both Free (keys ending in ":fx") and Pro API tiers. + */ + +import type { MachineTranslator, SafeFetch } from "./types.ts"; + +/** DeepL target language special cases — map generic codes to preferred variants. */ +const TARGET_LANG_MAP: Record = { + en: "EN-US", + pt: "PT-BR", +}; + +function normalizeSourceLang(lang: string): string { + return lang.toUpperCase(); +} + +function normalizeTargetLang(lang: string): string { + const upper = lang.toUpperCase(); + return TARGET_LANG_MAP[lang.toLowerCase()] ?? upper; +} + +/** + * DeepL machine translation provider. + * Supports both Free (keys ending in ":fx") and Pro API tiers. + */ +export class DeepLTranslator implements MachineTranslator { + readonly provider = "deepl" as const; + + private readonly endpoint: string; + + constructor(private apiKey: string, private safeFetch: SafeFetch) { + // Free keys end with ":fx"; they use the api-free subdomain + this.endpoint = apiKey.endsWith(":fx") + ? "https://api-free.deepl.com/v2/translate" + : "https://api.deepl.com/v2/translate"; + } + + async translate(text: string, sourceLang: string, targetLang: string): Promise { + const results = await this.translateBatch([text], sourceLang, targetLang); + return results[0]; + } + + async translateBatch(texts: string[], sourceLang: string, targetLang: string): Promise { + let res: Response; + try { + res = await this.safeFetch(this.endpoint, { + method: "POST", + headers: { + "Authorization": `DeepL-Auth-Key ${this.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + text: texts, + source_lang: normalizeSourceLang(sourceLang), + target_lang: normalizeTargetLang(targetLang), + tag_handling: "markdown", + }), + }); + } catch (err) { + throw new Error(`DeepL network error: ${err}`); + } + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`DeepL API error ${res.status}: ${body}`); + } + + const data = await res.json() as { translations: Array<{ text: string }> }; + return data.translations.map((t) => t.text); + } +} diff --git a/src/mt/factory.ts b/src/mt/factory.ts new file mode 100644 index 0000000..6a371c6 --- /dev/null +++ b/src/mt/factory.ts @@ -0,0 +1,41 @@ +/** + * Factory for creating a MachineTranslator from config. + */ + +import type { MachineTranslationConfig, MachineTranslator, SafeFetch } from "./types.ts"; +import { DeepLTranslator } from "./deepl.ts"; +import { GoogleTranslator } from "./google.ts"; +import { LibreTranslateTranslator } from "./libretranslate.ts"; + +/** + * Create a MachineTranslator from config. + * Returns null if MT is disabled or provider is unknown. + * API keys starting with "$" are expanded from the environment. + * Every provider request goes through `safeFetch` (core's SSRF policy). + */ +export function createMachineTranslator( + config: MachineTranslationConfig, + safeFetch: SafeFetch, +): MachineTranslator | null { + if (config.enabled === false) return null; + + const rawKey = config.api_key ?? ""; + const key = rawKey.startsWith("$") + ? (Deno.env.get(rawKey.slice(1)) ?? rawKey) + : rawKey; + + switch (config.provider) { + case "deepl": + return new DeepLTranslator(key, safeFetch); + case "google": + return new GoogleTranslator(key, safeFetch); + case "libretranslate": + return new LibreTranslateTranslator( + config.base_url ?? "https://libretranslate.com", + key || undefined, + safeFetch, + ); + default: + return null; + } +} diff --git a/src/mt/google.ts b/src/mt/google.ts new file mode 100644 index 0000000..4032f8b --- /dev/null +++ b/src/mt/google.ts @@ -0,0 +1,47 @@ +/** + * Google Cloud Translation API v2 (Basic) provider. + */ + +import type { MachineTranslator, SafeFetch } from "./types.ts"; + +/** Google Cloud Translation API v2 (Basic) provider. */ +export class GoogleTranslator implements MachineTranslator { + readonly provider = "google" as const; + + constructor(private apiKey: string, private safeFetch: SafeFetch) {} + + async translate(text: string, sourceLang: string, targetLang: string): Promise { + const results = await this.translateBatch([text], sourceLang, targetLang); + return results[0]; + } + + async translateBatch(texts: string[], sourceLang: string, targetLang: string): Promise { + const endpoint = `https://translation.googleapis.com/language/translate/v2?key=${encodeURIComponent(this.apiKey)}`; + + let res: Response; + try { + res = await this.safeFetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + q: texts, + source: sourceLang, + target: targetLang, + format: "text", + }), + }); + } catch (err) { + throw new Error(`Google Translate network error: ${err}`); + } + + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Google Translate API error ${res.status}: ${body}`); + } + + const data = await res.json() as { data: { translations: Array<{ translatedText: string }> } }; + return data.data.translations.map((t) => t.translatedText); + } +} diff --git a/src/mt/libretranslate.ts b/src/mt/libretranslate.ts new file mode 100644 index 0000000..2727eb3 --- /dev/null +++ b/src/mt/libretranslate.ts @@ -0,0 +1,69 @@ +/** + * LibreTranslate provider — supports self-hosted instances. + * LibreTranslate does not support batch requests, so batch is implemented + * as sequential individual requests. + */ + +import type { MachineTranslator, SafeFetch } from "./types.ts"; + +/** + * LibreTranslate provider — supports self-hosted instances. + * LibreTranslate does not support batch requests, so batch is implemented + * as sequential individual requests. + */ +export class LibreTranslateTranslator implements MachineTranslator { + readonly provider = "libretranslate" as const; + + constructor( + private baseUrl: string, + private apiKey: string | undefined, + private safeFetch: SafeFetch, + ) { + // Strip trailing slash for consistent URL construction + this.baseUrl = baseUrl.replace(/\/$/, ""); + } + + async translate(text: string, sourceLang: string, targetLang: string): Promise { + const endpoint = `${this.baseUrl}/translate`; + + const body: Record = { + q: text, + source: sourceLang, + target: targetLang, + format: "text", + }; + if (this.apiKey) { + body.api_key = this.apiKey; + } + + let res: Response; + try { + res = await this.safeFetch(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + } catch (err) { + throw new Error(`LibreTranslate network error: ${err}`); + } + + if (!res.ok) { + const respBody = await res.text().catch(() => ""); + throw new Error(`LibreTranslate API error ${res.status}: ${respBody}`); + } + + const data = await res.json() as { translatedText: string }; + return data.translatedText; + } + + async translateBatch(texts: string[], sourceLang: string, targetLang: string): Promise { + // LibreTranslate has no batch endpoint — execute sequentially + const results: string[] = []; + for (const text of texts) { + results.push(await this.translate(text, sourceLang, targetLang)); + } + return results; + } +} diff --git a/src/mt/mod.ts b/src/mt/mod.ts new file mode 100644 index 0000000..bebddde --- /dev/null +++ b/src/mt/mod.ts @@ -0,0 +1,11 @@ +/** + * Machine translation module — re-exports all public types and classes. + * + * @module + */ + +export type { MachineTranslationConfig, MachineTranslator, SafeFetch } from "./types.ts"; +export { DeepLTranslator } from "./deepl.ts"; +export { GoogleTranslator } from "./google.ts"; +export { LibreTranslateTranslator } from "./libretranslate.ts"; +export { createMachineTranslator } from "./factory.ts"; diff --git a/src/mt/types.ts b/src/mt/types.ts new file mode 100644 index 0000000..6806ca7 --- /dev/null +++ b/src/mt/types.ts @@ -0,0 +1,38 @@ +/** + * Machine translation type definitions. + */ + +/** + * Fetch used for every provider request. The admin passes + * `services.security.safeFetch`, so private and metadata destinations are + * refused by core's SSRF policy. + */ +export type SafeFetch = (url: string, init?: RequestInit) => Promise; + +/** A machine translation provider */ +export interface MachineTranslator { + /** Translate a plain-text or Markdown string */ + translate(text: string, sourceLang: string, targetLang: string): Promise; + /** Translate multiple texts in a single API call (batched) */ + translateBatch(texts: string[], sourceLang: string, targetLang: string): Promise; + /** Provider identifier */ + readonly provider: "deepl" | "google" | "libretranslate"; +} + +/** Machine translation configuration */ +export interface MachineTranslationConfig { + /** Translation provider */ + provider: "deepl" | "google" | "libretranslate"; + /** + * API key for the provider. + * Supports "$ENV_VAR" expansion. + */ + api_key?: string; + /** + * For LibreTranslate: base URL of the self-hosted instance. + * Default: "https://libretranslate.com" + */ + base_url?: string; + /** Whether machine translation is enabled (default: true when this block is present) */ + enabled?: boolean; +} diff --git a/tests/admin/capabilities_e2e_test.ts b/tests/admin/capabilities_e2e_test.ts new file mode 100644 index 0000000..4c22822 --- /dev/null +++ b/tests/admin/capabilities_e2e_test.ts @@ -0,0 +1,165 @@ +/** + * End to end against a real core: the admin plugin provides `editor.session` and + * `scheduler.executor`, consumes `admin.pages` contributions, and no longer + * writes core's removed `adminContext` bag. + */ + +import { assertEquals, assertExists } from "@std/assert"; +import { join } from "@std/path"; +import { bootstrap } from "@dune/core/bootstrap"; +import { createDuneApp } from "@dune/core/fresh-app"; +import type { DunePlugin } from "@dune/core/hooks"; +import { createAdminPlugin } from "../../mod.ts"; + +async function withSite( + plugins: DunePlugin[], + fn: (ctx: Awaited>, handler: (req: Request) => Promise) => Promise, +): Promise { + const root = await Deno.makeTempDir({ prefix: "dune_admin_caps_" }); + try { + await Deno.mkdir(join(root, "content", "01.home"), { recursive: true }); + await Deno.writeTextFile(join(root, "content", "01.home", "default.md"), "---\ntitle: Home\n---\n\nHello\n"); + const ctx = await bootstrap(root, { plugins: [createAdminPlugin({ root, dev: false }), ...plugins] }); + const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); + await fn(ctx, app.handler()); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +} + +Deno.test( + "admin provides editor.session and scheduler.executor", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + await withSite([], async (ctx) => { + const auth = ctx.capabilities.get("editor.session"); + assertExists(auth); + assertExists(ctx.capabilities.get("scheduler.executor")); + const anonymous = await auth.authenticate(new Request("http://localhost/")); + assertEquals(anonymous.authenticated, false); + assertEquals(auth.sessionCookies, ["dune_session"]); + const providers = Object.fromEntries(ctx.capabilities.providers().map((p) => [p.name, p.provider])); + assertEquals(providers["editor.session"], "admin"); + assertEquals(providers["scheduler.executor"], "admin"); + }); + }, +); + +Deno.test( + "admin provides admin.prefix: /admin by default, admin.path when set", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + await withSite([], (ctx) => { + assertEquals(ctx.capabilities.get("admin.prefix"), { prefix: "/admin" }); + const providers = Object.fromEntries(ctx.capabilities.providers().map((p) => [p.name, p.provider])); + assertEquals(providers["admin.prefix"], "admin"); + return Promise.resolve(); + }); + + const root = await Deno.makeTempDir({ prefix: "dune_admin_prefix_" }); + try { + await Deno.mkdir(join(root, "content", "01.home"), { recursive: true }); + await Deno.writeTextFile(join(root, "content", "01.home", "default.md"), "---\ntitle: Home\n---\n"); + // The admin's settings are its plugin config: `config:` under its entry in site.yaml. + const ctx = await bootstrap(root, { + plugins: [createAdminPlugin({ root, dev: false })], + // deno-lint-ignore no-explicit-any + configOverrides: { plugins: { admin: { path: "/cms" } } } as any, + }); + const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); + assertEquals(ctx.capabilities.get("admin.prefix"), { prefix: "/cms" }); + // The panel answers at the provided prefix, and /admin is just a missing content page. + const handler = app.handler(); + assertEquals((await handler(new Request("http://localhost/admin/"))).status, 404); + assertEquals((await handler(new Request("http://localhost/cms/login"))).status !== 404, true); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } + }, +); + +Deno.test( + "admin mounts admin.pages contributed by another plugin, behind its login", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + const contributor: DunePlugin = { + name: "test-page-contributor", + version: "1.0.0", + hooks: {}, + setup(api) { + api.capabilities.contribute("admin.pages", { + path: "/my-plugin", + label: "My plugin", + permission: "pages.read", + handler: () => new Response("secret page"), + }); + }, + }; + await withSite([contributor], async (_ctx, handler) => { + const res = await handler(new Request("http://localhost/admin/my-plugin")); + assertEquals(res.status, 302); + assertEquals(res.headers.get("location")?.startsWith("/admin/login"), true); + }); + }, +); + +Deno.test( + "admin uses the editor.content slot another plugin provides", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + const editor: DunePlugin = { + name: "test-editor", + version: "1.0.0", + hooks: {}, + setup(api) { + api.capabilities.provide("editor.content", () => ({ + pageEditorHandler: () => new Response("custom editor"), + })); + }, + }; + await withSite([editor], async (ctx) => { + assertExists(ctx.capabilities.get("editor.content")); + assertEquals( + ctx.capabilities.providers().find((p) => p.name === "editor.content")?.provider, + "test-editor", + ); + }); + }, +); + +Deno.test( + "admin mounts admin.routes behind its login: an anonymous POST is sent to the login page and the handler never runs", + { sanitizeOps: false, sanitizeResources: false }, + async () => { + let ran = 0; + const contributor: DunePlugin = { + name: "test-route-contributor", + version: "1.0.0", + hooks: {}, + setup(api) { + api.capabilities.contribute("admin.routes", { + method: "POST", + path: "/api/my-plugin/save", + permission: "config.update", + handler: () => { + ran++; + return new Response("saved"); + }, + }); + }, + }; + await withSite([contributor], async (_ctx, handler) => { + const res = await handler( + new Request("http://localhost/admin/api/my-plugin/save", { + method: "POST", + headers: { origin: "http://localhost" }, + }), + ); + // Like every admin route, the admin middleware sends an anonymous request to + // the login page before the contributed handler (or its own 401) is reached. + assertEquals(res.status, 302); + assertEquals(res.headers.get("location")?.startsWith("/admin/login"), true); + assertEquals(ran, 0); + }); + }, +); diff --git a/tests/admin/conformance_test.ts b/tests/admin/conformance_test.ts new file mode 100644 index 0000000..bdc3676 --- /dev/null +++ b/tests/admin/conformance_test.ts @@ -0,0 +1,119 @@ +/** + * plugin-admin against the admin conformance suite from `@dune/testing`: the + * same checks the stub admin passes, run on the real thing over a real temp + * site. If this passes, plugin-admin meets the contract core relies on. + */ + +import { join } from "@std/path"; +import { runAdminConformance } from "@dune/testing/conformance"; +import type { ConformanceApp } from "@dune/testing/conformance"; +import { bootstrap } from "@dune/core/bootstrap"; +import { createAdminPlugin } from "../../mod.ts"; +import { createDuneApp } from "@dune/core/fresh-app"; +import { createStorage } from "@dune/core/storage"; +import { createLocalUserStore } from "@dune/core/auth/user-store"; +import { hashPassword } from "@dune/core/auth/passwords"; + +const PASSWORD = "correct horse battery staple"; +const ORIGIN = "http://localhost:8000"; + +async function removeWithRetry(root: string): Promise { + let last: unknown; + for (let i = 0; i < 5; i++) { + try { + await Deno.remove(root, { recursive: true }); + return; + } catch (err) { + last = err; + await new Promise((r) => setTimeout(r, 50)); + } + } + throw last; +} + +async function login(app: ConformanceApp, username: string): Promise { + const res = await app.request("/admin/login", { + method: "POST", + headers: { origin: ORIGIN, "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ username, password: PASSWORD }).toString(), + }); + const set = res.headers.get("set-cookie") ?? ""; + const m = set.match(/dune_session=[^;]+/); + if (!m) throw new Error(`plugin-admin login failed for ${username}: ${res.status}`); + return m[0]; +} + +runAdminConformance({ + name: "plugin-admin", + // The prefix is read from the admin.prefix slot; this admin hosts admin.pages / admin.routes contributions. + hostsContributions: true, + + async createApp({ content, extraPlugins, withAdmin }) { + const root = await Deno.makeTempDir({ prefix: "dune_admin_conformance_" }); + await Deno.mkdir(join(root, "config"), { recursive: true }); + await Deno.writeTextFile(join(root, "config", "site.yaml"), 'title: "Conformance Site"\nurl: "http://localhost:8000"\n'); + for (const [path, body] of Object.entries(content)) { + await Deno.mkdir(join(root, "content", path, ".."), { recursive: true }); + await Deno.writeTextFile(join(root, "content", path), body); + } + if (withAdmin) { + const users = createLocalUserStore({ storage: createStorage({ rootDir: root }), usersDir: "data/users" }); + await users.create({ + email: "ed@example.test", + username: "ed", + name: "Ed", + provider: "local", + passwordHash: await hashPassword(PASSWORD), + roles: ["admin"], + }); + } + // Plugins register in this order and mount in it, so the extras come before the admin. + const ctx = await bootstrap(root, { + plugins: [...extraPlugins, ...(withAdmin ? [createAdminPlugin({ root, dev: false })] : [])], + }); + const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); + const handler = app.handler(); + return { + request: (path, init) => handler(new Request(new URL(path, ORIGIN), init)), + bootstrap: ctx, + readText: (path) => Deno.readTextFile(join(root, path)), + dispose: () => removeWithRetry(root), + }; + }, + + signInEditor: (app) => login(app, "ed"), + // No signInViewer: every admin-tier role (admin, editor, author) holds pages.update, + // and a non-admin account cannot sign in to the admin at all. + + async editPage(app, cookie, { sourcePath, content }) { + // plugin-admin's route takes the path relative to the content dir and the body after the frontmatter. + const relative = sourcePath.replace(/^content\//, ""); + const body = content.replace(/^---[\s\S]*?---\n*/, ""); + const res = await app.request(`/admin/api/pages/${relative}`, { + method: "PUT", + headers: { cookie, origin: ORIGIN, "content-type": "application/json" }, + body: JSON.stringify({ content: body }), + }); + if (res.status !== 200) throw new Error(`plugin-admin edit failed: ${res.status} ${await res.text()}`); + }, + + async createPage(app, cookie, { sourcePath, title }) { + // The route takes the page folder and adds default.md itself. + const folder = sourcePath.replace(/^content\//, "").replace(/\/default\.md$/, ""); + const res = await app.request("/admin/api/pages", { + method: "POST", + headers: { cookie, origin: ORIGIN, "content-type": "application/json" }, + body: JSON.stringify({ path: folder, title }), + }); + if (!res.ok) throw new Error(`plugin-admin create failed: ${res.status} ${await res.text()}`); + }, + + async deletePage(app, cookie, { sourcePath }) { + const relative = sourcePath.replace(/^content\//, ""); + const res = await app.request(`/admin/api/pages/${relative}`, { + method: "DELETE", + headers: { cookie, origin: ORIGIN }, + }); + if (res.status !== 200) throw new Error(`plugin-admin delete failed: ${res.status} ${await res.text()}`); + }, +}); diff --git a/tests/admin/mount_early_test.ts b/tests/admin/mount_early_test.ts deleted file mode 100644 index 4849d13..0000000 --- a/tests/admin/mount_early_test.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Regression test for the third-party-plugin-guarded-route bug: a plugin's - * own `mount()`-registered route (registered under the admin prefix, e.g. - * `/admin/my-plugin/…` — see `admin/guards.ts`'s own doc comment for why it - * has to be under the prefix) previously read both `ctx.state.adminContext` - * and `ctx.state.auth` as `undefined`, no matter how the request was - * authenticated, whenever that route was registered before `mountDuneAdmin()` - * ran — which is the normal case: `@dune/core`'s `mountPlugins()` calls - * `mount()` in plugin registration order, and the built-in admin plugin - * always registers last. - * - * `mountDuneAdminEarly()` (this plugin's `mountEarly()` implementation, see - * `@dune/core`'s `hooks/types.ts` for why that hook exists) registers both - * pieces of middleware — `ctx.state.adminContext` and the admin auth - * middleware that sets `ctx.state.auth` — before any plugin's `mount()` - * runs. This test registers a fake plugin's route first, then calls - * `mountDuneAdminEarly()` and `mountDuneAdmin()` in the same relative order - * `mountPlugins()` uses (early hooks before any mount(), admin's own - * mount()-equivalent last), and confirms both are visible. - */ - -import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; -import { App } from "fresh"; -import type { BootstrapResult } from "@dune/core/bootstrap"; -import { mountDuneAdmin, mountDuneAdminEarly } from "../../src/admin/mount.ts"; -import type { AdminContext } from "../../src/admin/context.ts"; - -Deno.test( - "mountDuneAdminEarly: a plugin's route registered before mountDuneAdmin() still sees adminContext and auth", - { sanitizeOps: false, sanitizeResources: false }, - async () => { - // deno-lint-ignore no-explicit-any - const app = new App(); - - // Minimal fake AdminContext — enough for routes/_middleware.ts's handler - // to authenticate the request and call ctx.next() rather than - // redirecting/403ing before it reaches our probe route. - const adminContext = { - prefix: "/admin", - auth: { - authenticate: () => - Promise.resolve({ - authenticated: true, - user: { id: "u1", roles: ["admin"] }, - }), - }, - authz: { - check: () => Promise.resolve(true), - }, - // deno-lint-ignore no-explicit-any - } as any as AdminContext; - - // deno-lint-ignore no-explicit-any - const ctx = { config: { admin: { enabled: true, path: "/admin" } } } as any as BootstrapResult; - - // Phase 1 (mountEarly, admin first) — the fix under test. - mountDuneAdminEarly(app, ctx, () => adminContext); - - // A fake plugin's own mount()-registered route, added here to mirror - // Phase 2 registration order (this plugin runs before admin's own - // mount()-equivalent below, exactly as it would in the real - // mountPlugins() loop). - app.get("/admin/my-plugin/probe", (fc) => { - return Response.json({ - // deno-lint-ignore no-explicit-any - sawAdminContext: (fc.state as any).adminContext != null, - // deno-lint-ignore no-explicit-any - sawAuth: (fc.state as any).auth != null, - }); - }); - - // Phase 2, admin last — mirrors mountPlugins()'s own ordering. - await mountDuneAdmin(app, ctx, adminContext); - - const handler = app.handler(); - const res = await handler( - new Request("http://localhost:8000/admin/my-plugin/probe"), - ); - assertEquals(res.status, 200); - assertEquals(await res.json(), { sawAdminContext: true, sawAuth: true }); - }, -); diff --git a/tests/admin/page_events_test.ts b/tests/admin/page_events_test.ts new file mode 100644 index 0000000..5b7f25a --- /dev/null +++ b/tests/admin/page_events_test.ts @@ -0,0 +1,144 @@ +/** + * Every admin route that writes a page announces it: `onPageCreate`, + * `onPageUpdate`, `onPageDelete` and `onPageMove` reach plugins, on a real site + * through the real routes. Whoever writes a page fires its event; core does not + * do it for them, so a route that forgets is invisible to search indexes, + * webhooks and audit plugins. + */ + +import { assertEquals } from "@std/assert"; +import { join } from "@std/path"; +import type { DunePlugin } from "@dune/core/hooks"; +import { bootstrap } from "@dune/core/bootstrap"; +import { createDuneApp } from "@dune/core/fresh-app"; +import { createStorage } from "@dune/core/storage"; +import { createLocalUserStore } from "@dune/core/auth/user-store"; +import { hashPassword } from "@dune/core/auth/passwords"; +import { createAdminPlugin } from "../../mod.ts"; + +const PASSWORD = "correct horse battery staple"; +const ORIGIN = "http://localhost:8000"; + +type Seen = Array<{ event: string; data: unknown }>; + +async function withSite( + run: (api: { + seen: Seen; + history: Awaited>["history"]; + call: (method: string, path: string, body?: unknown) => Promise; + }) => Promise, +): Promise { + const root = await Deno.makeTempDir({ prefix: "dune_admin_events_" }); + try { + await Deno.mkdir(join(root, "config"), { recursive: true }); + await Deno.writeTextFile(join(root, "config", "site.yaml"), 'title: "Events"\nurl: "http://localhost:8000"\n'); + for (const dir of ["01.alpha", "02.beta"]) { + await Deno.mkdir(join(root, "content", dir), { recursive: true }); + await Deno.writeTextFile(join(root, "content", dir, "default.md"), `---\ntitle: ${dir}\n---\n\nbody\n`); + } + const users = createLocalUserStore({ storage: createStorage({ rootDir: root }), usersDir: "data/users" }); + await users.create({ + email: "ed@example.test", + username: "ed", + name: "Ed", + provider: "local", + passwordHash: await hashPassword(PASSWORD), + roles: ["admin"], + }); + + const seen: Seen = []; + const record = (event: string) => ({ data }: { data: unknown }) => { + seen.push({ event, data }); + }; + const recorder: DunePlugin = { + name: "recorder", + version: "1.0.0", + hooks: { + onPageCreate: record("onPageCreate"), + onPageUpdate: record("onPageUpdate"), + onPageDelete: record("onPageDelete"), + onPageMove: record("onPageMove"), + } as DunePlugin["hooks"], + }; + const ctx = await bootstrap(root, { plugins: [createAdminPlugin({ root, dev: false }), recorder] }); + const { app } = await createDuneApp(ctx, { root, port: 0, dev: false }); + const handler = app.handler(); + const req = (path: string, init?: RequestInit) => handler(new Request(new URL(path, ORIGIN), init)); + + const login = await req("/admin/login", { + method: "POST", + headers: { origin: ORIGIN, "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ username: "ed", password: PASSWORD }).toString(), + }); + const cookie = (login.headers.get("set-cookie") ?? "").match(/dune_session=[^;]+/)?.[0]; + if (!cookie) throw new Error(`login failed: ${login.status}`); + + await run({ + seen, + history: ctx.history, + call: async (method, path, body) => { + const res = await req(path, { + method, + headers: { cookie, origin: ORIGIN, "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + // Hooks are fire-and-forget; let them settle. + await new Promise((r) => setTimeout(r, 25)); + return res; + }, + }); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +} + +Deno.test("admin page routes announce create, update, move and delete", async () => { + await withSite(async ({ seen, call }) => { + let res = await call("POST", "/admin/api/pages", { path: "blog", title: "Hello" }); + assertEquals(res.ok, true); + assertEquals(seen.splice(0), [{ event: "onPageCreate", data: { sourcePath: "blog/default.md", title: "Hello" } }]); + + res = await call("PUT", "/admin/api/pages/blog/default.md", { content: "changed" }); + assertEquals(res.status, 200); + assertEquals(seen.splice(0), [{ event: "onPageUpdate", data: { sourcePath: "blog/default.md" } }]); + + // Move 02.beta before 01.alpha: both folders are renumbered. + res = await call("POST", "/admin/api/pages/reorder", { + sourcePath: "02.beta/default.md", + targetPath: "01.alpha/default.md", + position: "before", + }); + assertEquals(res.status, 200); + assertEquals(seen.splice(0), [{ + event: "onPageMove", + data: { + moves: [ + { from: "02.beta", to: "01.beta" }, + { from: "01.alpha", to: "02.alpha" }, + ], + }, + }]); + + res = await call("DELETE", "/admin/api/pages/blog/default.md"); + assertEquals(res.status, 200); + assertEquals(seen.splice(0), [{ event: "onPageDelete", data: { sourcePath: "blog/default.md" } }]); + }); +}); + +Deno.test("history restore announces onPageUpdate", async () => { + await withSite(async ({ seen, history, call }) => { + await history.record({ + sourcePath: "01.alpha/default.md", + content: "an earlier version", + frontmatter: { title: "01.alpha" }, + author: "ed", + message: "seed", + }); + const revisions = await history.getHistory("01.alpha/default.md"); + assertEquals(revisions.length, 1); + + const res = await call("POST", `/admin/api/history/01.alpha/default.md/${revisions[0].number}/restore`); + assertEquals(res.status, 200); + assertEquals(seen.splice(0), [{ event: "onPageUpdate", data: { sourcePath: "01.alpha/default.md" } }]); + }); +}); diff --git a/tests/admin/pages_edit_blueprint_test.ts b/tests/admin/pages_edit_blueprint_test.ts index d32ceda..08db562 100644 --- a/tests/admin/pages_edit_blueprint_test.ts +++ b/tests/admin/pages_edit_blueprint_test.ts @@ -64,7 +64,7 @@ function makeCtx(opts: { services: makeServices(null, { getBlueprints: () => engine.blueprints as never }), engine, storage: opts.storage, - config: { system: { content: { dir: "content" } }, admin: {} }, + config: { system: { content: { dir: "content" } }, plugins: {} }, // authz.check() always allows — this test exercises page-edit // behavior, not permission logic (ROLE_PERMISSIONS/hasPermission() // were removed in 3.0.0; authz.check() is the sole authority now). diff --git a/tests/admin/plugin_admin_routes_test.ts b/tests/admin/plugin_admin_routes_test.ts new file mode 100644 index 0000000..c6328bb --- /dev/null +++ b/tests/admin/plugin_admin_routes_test.ts @@ -0,0 +1,206 @@ +/** + * `admin.routes` contributions are mounted behind the admin's authentication, + * CSRF check and permission check; the contributed handler only runs for a + * request that passed all three. + */ + +import { assertEquals } from "@std/assert"; +import { mountPluginAdminPages, mountPluginAdminRoutes } from "../../src/admin/mount.ts"; + +type Handler = (fc: unknown) => Response | Promise; + +function fakeApp() { + const routes = new Map(); + const app: Record = {}; + for (const m of ["get", "post", "put", "patch", "delete"]) { + app[m] = (path: string, h: Handler) => routes.set(`${m.toUpperCase()} ${path}`, h); + } + return { app: app as never, routes }; +} + +const logs: { event: string; fields?: Record }[] = []; +const logger = { warn: (event: string, fields?: Record) => logs.push({ event, fields }) }; + +function fc(opts: { + method: string; + path: string; + headers?: Record; + authenticated?: boolean; + allow?: boolean; +}) { + const url = new URL(`https://cms.example.com${opts.path}`); + return { + req: new Request(url, { method: opts.method, headers: opts.headers }), + url, + params: {}, + state: { + auth: opts.authenticated === false + ? { authenticated: false } + : { authenticated: true, user: { id: "u1", username: "alice" }, session: { id: "s1" } }, + adminContext: { + authz: { check: () => Promise.resolve(opts.allow !== false) }, + csrfSecret: "test-secret", + config: { plugins: {} }, + }, + }, + }; +} + +function setup(route: Partial[2][number]> = {}) { + const { app, routes } = fakeApp(); + let ran = 0; + mountPluginAdminRoutes( + app, + "/admin", + [{ + method: "POST", + path: "/api/my-plugin/save", + permission: "config.update", + handler: () => { + ran++; + return new Response("done"); + }, + ...route, + }], + logger, + ); + return { routes, ran: () => ran }; +} + +const SAME_ORIGIN = { origin: "https://cms.example.com" }; + +Deno.test("admin.routes: mounted under the admin prefix with the contributed method", () => { + const { routes } = setup(); + assertEquals([...routes.keys()], ["POST /admin/api/my-plugin/save"]); +}); + +Deno.test("admin.routes: an unauthenticated request gets 401 and the handler does not run", async () => { + const { routes, ran } = setup(); + const res = await routes.get("POST /admin/api/my-plugin/save")!( + fc({ method: "POST", path: "/admin/api/my-plugin/save", headers: SAME_ORIGIN, authenticated: false }), + ); + assertEquals(res.status, 401); + assertEquals(ran(), 0); +}); + +Deno.test("admin.routes: a cross-origin POST is refused by the CSRF check", async () => { + const { routes, ran } = setup(); + const res = await routes.get("POST /admin/api/my-plugin/save")!( + fc({ method: "POST", path: "/admin/api/my-plugin/save", headers: { origin: "https://evil.example" } }), + ); + assertEquals(res.status, 403); + assertEquals(ran(), 0); +}); + +Deno.test("admin.routes: a request without the permission gets 403", async () => { + const { routes, ran } = setup(); + const res = await routes.get("POST /admin/api/my-plugin/save")!( + fc({ method: "POST", path: "/admin/api/my-plugin/save", headers: SAME_ORIGIN, allow: false }), + ); + assertEquals(res.status, 403); + assertEquals(ran(), 0); +}); + +Deno.test("admin.routes: an authenticated same-origin request with the permission reaches the handler", async () => { + const { routes, ran } = setup(); + const res = await routes.get("POST /admin/api/my-plugin/save")!( + fc({ method: "POST", path: "/admin/api/my-plugin/save", headers: SAME_ORIGIN }), + ); + assertEquals(res.status, 200); + assertEquals(await res.text(), "done"); + assertEquals(ran(), 1); +}); + +Deno.test("admin.routes: a route without a permission is refused at mount and logged, not exposed to every admin", () => { + logs.length = 0; + for (const permission of [undefined, ""]) { + const { routes } = setup({ permission: permission as never }); + assertEquals(routes.size, 0); + } + assertEquals(logs.map((l) => [l.event, l.fields?.reason]), [ + ["admin.routes.invalid", "permission is required"], + ["admin.routes.invalid", "permission is required"], + ]); +}); + +Deno.test("admin.routes: a GET route needs no CSRF token", async () => { + const { routes } = setup({ method: "GET", path: "/api/my-plugin/list" }); + const res = await routes.get("GET /admin/api/my-plugin/list")!( + fc({ method: "GET", path: "/admin/api/my-plugin/list" }), + ); + assertEquals(res.status, 200); +}); + +Deno.test("admin.routes: an invalid method or a path without a leading slash is skipped and logged", () => { + logs.length = 0; + const { routes } = setup({ path: "api/no-slash" }); + assertEquals(routes.size, 0); + const bad = setup({ method: "TRACE" as never }); + assertEquals(bad.routes.size, 0); + assertEquals(logs.map((l) => l.event), ["admin.routes.invalid", "admin.routes.invalid"]); + assertEquals(logs.map((l) => l.fields?.reason), [ + "unknown method or path without leading /", + "unknown method or path without leading /", + ]); +}); + +// ── admin.pages ────────────────────────────────────────────────────────────── + +function setupPages(page: Record = {}) { + const { app, routes } = fakeApp(); + let ran = 0; + mountPluginAdminPages( + app, + "/admin", + [{ + path: "/my-plugin", + label: "My plugin", + permission: "pages.read", + handler: () => { + ran++; + return new Response("page"); + }, + ...page, + } as never], + logger, + ); + return { routes, ran: () => ran }; +} + +Deno.test("admin.pages: an anonymous request is sent to the login page and the handler does not run", async () => { + const { routes, ran } = setupPages(); + const res = await routes.get("GET /admin/my-plugin")!( + fc({ method: "GET", path: "/admin/my-plugin", authenticated: false }), + ) as Response; + assertEquals(res.status, 302); + assertEquals(res.headers.get("location"), "/admin/login"); + assertEquals(ran(), 0); +}); + +Deno.test("admin.pages: the permission is enforced, and the handler runs when it is held", async () => { + const { routes, ran } = setupPages(); + const handler = routes.get("GET /admin/my-plugin")!; + assertEquals((await handler(fc({ method: "GET", path: "/admin/my-plugin", allow: false }))).status, 403); + assertEquals(ran(), 0); + assertEquals((await handler(fc({ method: "GET", path: "/admin/my-plugin" }))).status, 200); + assertEquals(ran(), 1); +}); + +Deno.test("admin.pages: a page without a permission is refused at mount and logged", () => { + logs.length = 0; + for (const permission of [undefined, ""]) { + const { routes } = setupPages({ permission }); + assertEquals(routes.size, 0); + } + assertEquals(logs.map((l) => [l.event, l.fields?.reason]), [ + ["admin.pages.invalid", "permission is required"], + ["admin.pages.invalid", "permission is required"], + ]); +}); + +Deno.test("admin.pages: a path without a leading slash is skipped and logged", () => { + logs.length = 0; + const { routes } = setupPages({ path: "no-slash" }); + assertEquals(routes.size, 0); + assertEquals(logs.map((l) => l.event), ["admin.pages.invalid"]); +}); diff --git a/tests/admin/plugin_version_test.ts b/tests/admin/plugin_version_test.ts index 65455f3..b1ab6ff 100644 --- a/tests/admin/plugin_version_test.ts +++ b/tests/admin/plugin_version_test.ts @@ -7,8 +7,6 @@ import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; import { createAdminPlugin } from "../../mod.ts"; -import type { DuneConfig } from "@dune/core/config"; -import type { StorageAdapter } from "@dune/core/storage"; Deno.test("admin plugin version matches deno.json", async () => { const denoJsonText = await Deno.readTextFile( @@ -16,11 +14,7 @@ Deno.test("admin plugin version matches deno.json", async () => { ); const { version } = JSON.parse(denoJsonText); - const plugin = createAdminPlugin( - {} as DuneConfig, - {} as StorageAdapter, - { root: "/tmp", dev: true }, - ); + const plugin = createAdminPlugin({ root: "/tmp", dev: true }); assertEquals(plugin.version, version); }); diff --git a/tests/admin/settings_test.ts b/tests/admin/settings_test.ts new file mode 100644 index 0000000..4cf937d --- /dev/null +++ b/tests/admin/settings_test.ts @@ -0,0 +1,55 @@ +import { assertEquals, assertThrows } from "@std/assert"; +import { adminSettings } from "../../src/admin/settings.ts"; + +Deno.test("adminSettings: defaults when the plugin has no config", () => { + for (const config of [{}, { plugins: {} }, { plugins: { admin: {} } }]) { + assertEquals(adminSettings(config), { + path: "/admin", + session_lifetime: 86400, + max_upload_mb: 100, + git_commit: false, + webhooks: [], + auth_provider: undefined, + audit: undefined, + machine_translation: undefined, + }); + } +}); + +Deno.test("adminSettings: reads the plugin's own config", () => { + const s = adminSettings({ + plugins: { + admin: { + path: "/cms", + session_lifetime: 3600, + max_upload_mb: 5, + git_commit: true, + webhooks: [{ url: "https://hooks.example.test", events: ["onPageUpdate"] }], + auth_provider: { type: "local" }, + audit: { enabled: false }, + }, + }, + }); + assertEquals(s.path, "/cms"); + assertEquals(s.session_lifetime, 3600); + assertEquals(s.max_upload_mb, 5); + assertEquals(s.git_commit, true); + assertEquals(s.webhooks.length, 1); + assertEquals(s.audit, { enabled: false }); +}); + +Deno.test("adminSettings: a path that would desync the admin stops the boot", () => { + for (const path of ["admin", "/", "/admin/", "/a b", "/admin?x", 7]) { + assertThrows(() => adminSettings({ plugins: { admin: { path } } }), Error, "path must start with"); + } +}); + +Deno.test("adminSettings: auth_provider other than local is refused", () => { + for (const type of ["ldap", "saml", undefined]) { + assertThrows(() => adminSettings({ plugins: { admin: { auth_provider: { type } } } }), Error, "auth_provider.type"); + } +}); + +Deno.test("adminSettings: another plugin's config is not the admin's", () => { + assertEquals(adminSettings({ plugins: { "dune-admin": { path: "/old" }, seo: { path: "/x" } } }).path, "/admin"); +}); diff --git a/tests/admin/theme_config_test.ts b/tests/admin/theme_config_test.ts index 5b206a2..a630dc3 100644 --- a/tests/admin/theme_config_test.ts +++ b/tests/admin/theme_config_test.ts @@ -52,7 +52,7 @@ function makeCtx(opts: { services: makeServices(opts.storage), engine, storage: opts.storage, - config: { admin: { data_dir: "data" } }, + config: { plugins: {} }, // authz.check() always allows — this test exercises theme-config // behavior, not permission logic (ROLE_PERMISSIONS/hasPermission() // were removed in 3.0.0; authz.check() is the sole authority now). diff --git a/tests/admin/webhook_deliveries_test.ts b/tests/admin/webhook_deliveries_test.ts index aa924c6..ef891d0 100644 --- a/tests/admin/webhook_deliveries_test.ts +++ b/tests/admin/webhook_deliveries_test.ts @@ -49,7 +49,8 @@ function makeCtx( state: { adminContext: { auditLogger: null, - config: { admin: { runtime_dir: opts.runtimeDir ?? ".dune/admin" } }, + config: {}, + services: { paths: { runtimeDir: opts.runtimeDir ?? ".dune/admin" } }, // Distinguishes which specific permission the mock holds — same // role this fixture's ROLE_PERMISSIONS-backed hasPermission() used // to play, sourced from authz.check() instead (removed in 3.0.0; diff --git a/tests/admin/workflow_actions_test.ts b/tests/admin/workflow_actions_test.ts index c6340ab..f30c3df 100644 --- a/tests/admin/workflow_actions_test.ts +++ b/tests/admin/workflow_actions_test.ts @@ -48,7 +48,7 @@ function makePageIndex(overrides: Partial = {}): PageIndex { const CONFIG = { system: { content: { dir: "content" } }, - admin: { webhooks: [], runtime_dir: ".dune/admin" }, + plugins: { admin: { webhooks: [] } }, } as unknown as DuneConfig; function makeEngine(pages: PageIndex[]) { diff --git a/tests/audit_trail_test.ts b/tests/audit_trail_test.ts new file mode 100644 index 0000000..f111bf3 --- /dev/null +++ b/tests/audit_trail_test.ts @@ -0,0 +1,45 @@ +import { assertEquals, assertExists } from "@std/assert"; +import type { DuneConfig } from "@dune/core/config"; +import { createAuditTrail } from "../src/audit/mod.ts"; + +const config = (admin?: Record) => ({ system: {}, site: {}, plugins: { admin } } as unknown as DuneConfig); +const entry = { event: "page.update", actor: null, ip: null, userAgent: null, target: null, detail: {} } as never; + +Deno.test("createAuditTrail: no site root, no trail", () => { + assertEquals(createAuditTrail({ config: config(), root: undefined, runtimeDir: ".dune/admin" }), null); +}); + +Deno.test("createAuditTrail: logs without an explicit init, and audit.enabled: false turns it off", async () => { + const root = await Deno.makeTempDir(); + try { + const trail = createAuditTrail({ config: config(), root, runtimeDir: ".dune/admin" }); + assertExists(trail); + await trail.log(entry); + assertEquals((await trail.query({ event: "page.update" })).entries.length, 1); + + assertEquals(createAuditTrail({ config: config({ audit: { enabled: false } }), root, runtimeDir: ".dune/admin" }), null); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("createAuditTrail: the log goes under the configured runtime dir", async () => { + const root = await Deno.makeTempDir(); + try { + const trail = createAuditTrail({ config: config(), root, runtimeDir: "var/rt" })!; + await trail.log(entry); + assertEquals((await Deno.stat(`${root}/var/rt/audit`)).isDirectory, true); + } finally { + await Deno.remove(root, { recursive: true }); + } +}); + +Deno.test("createAuditTrail: a log_file outside the site root is refused", () => { + let message = ""; + try { + createAuditTrail({ config: config({ audit: { log_file: "../elsewhere/audit.log" } }), root: "/srv/site", runtimeDir: ".dune/admin" }); + } catch (err) { + message = (err as Error).message; + } + assertEquals(message.includes("must resolve under the site root"), true); +}); diff --git a/tests/mt/ssrf_test.ts b/tests/mt/ssrf_test.ts new file mode 100644 index 0000000..e68666c --- /dev/null +++ b/tests/mt/ssrf_test.ts @@ -0,0 +1,37 @@ +import { assertEquals, assertRejects } from "@std/assert"; +import { createMachineTranslator, LibreTranslateTranslator } from "../../src/mt/mod.ts"; +import { createPluginServices } from "@dune/core/plugin-api"; +import type { DuneConfig } from "@dune/core/config"; +import { MemoryStorageAdapter } from "@dune/core/storage"; + +// The translators fetch through whatever they are given: the admin passes +// services.security.safeFetch, so core's SSRF policy applies. +const { security } = createPluginServices({ + config: { system: {}, site: {} } as unknown as DuneConfig, + storage: new MemoryStorageAdapter(), +}); + +Deno.test("LibreTranslateTranslator: refuses a private destination via the services' safeFetch", async () => { + const mt = new LibreTranslateTranslator("http://169.254.169.254", undefined, security.safeFetch); + await assertRejects(() => mt.translate("hello", "en", "es"), Error, "LibreTranslate"); +}); + +Deno.test("createMachineTranslator: sends provider requests through the given fetch", async () => { + const seen: string[] = []; + const fake = (url: string) => { + seen.push(url); + return Promise.resolve(new Response(JSON.stringify({ translatedText: "hola" }))); + }; + const mt = createMachineTranslator( + { provider: "libretranslate", base_url: "https://mt.example" }, + fake, + ); + assertEquals(await mt!.translate("hello", "en", "es"), "hola"); + assertEquals(seen, ["https://mt.example/translate"]); +}); + +Deno.test("createMachineTranslator: disabled or unknown providers give none", () => { + const fake = () => Promise.resolve(new Response()); + assertEquals(createMachineTranslator({ provider: "deepl", enabled: false }, fake), null); + assertEquals(createMachineTranslator({ provider: "nope" as never }, fake), null); +});