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 4bfdb91..7faa295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,225 @@ follows [Semantic Versioning](https://semver.org). ## [Unreleased] +## [4.0.0] — 2026-11-10 + +Pairs with `@dune/core` 0.36 and needs it. The admin is a plugin you list under `plugins:` in `site.yaml` +(`jsr:@dune/plugin-admin@^4`), and its settings are that entry's `config:`; run `dune migrate:config` on the site +to move them. It declares its own extension points (typed keys), owns the audit trail and machine translation, +and no longer carries a default admin or its own copy of what moved into core. The sections below list every +breaking change. + +### Fixed + +- **Staging publish and the page-comment routes were unreachable.** The manifest generator registered a bare + wildcard (`/api/staging/:path*`) before routes that extend it (`/api/staging/:path*/publish`), so the + wildcard swallowed them: publishing a staged draft ran the upsert handler and 500ed on an empty body. The + same shadowing hit `/api/pages/:path*/comments`, `.../comments/:id` and `.../comments/:id/resolve`. The + generator now registers the route with more segments after a shared wildcard first. + `tests/admin/staging_publish_test.ts` publishes a staged draft over HTTP and checks the page file. + +- **Every route that writes a page now announces it (#97).** Whoever writes a page fires its event; core + does not. Six admin paths wrote pages and fired nothing, so search indexes, webhooks and audit plugins + missed them: restoring a revision (`onPageUpdate`), staging publish (`onPageUpdate`), the two translate + 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 declares its own extension points, #127) + +- **The inline-edit content routes are gone; the admin's buttons on the bar are the admin's own (#127).** + `/admin/api/content/*` (source, commit, fields) moved to `@dune/plugin-inline-edit` + (`/api/inline-edit/content/*`), served for any signed-in editor with or without this plugin. The + admin contributes "Edit source" (an overlay with its page editor) and "Open in admin" to the bar's + `bar.actions` point, so the bar needs no knowledge of the admin's URLs. +- **The admin seeds direct editing tuples only for its own accounts (duneorg/dune#139).** Accounts that can sign + in to it (a local password) keep a direct tuple, so the panel works even when a site sets + `role_grants: {}`. A site user who holds `editor` or `author` (OAuth, magic link, `external-jwt`) + is governed by core's `system.role_grants` like anyone else; the direct tuple an earlier version + seeded for such an account is revoked at start (logged as `admin.authz.stale_direct_grants_revoked`). + On the default grants nothing changes for them: their group membership still grants the relation. +- **Page writes that no admin route announced reach the audit trail (duneorg/dune#127).** The inline-edit bar, + a plugin or any other writer fires the page event with the person's name as `actor`; the admin now writes a + `page.create` / `page.update` / `page.delete` entry for it (no IP or user agent: the event does not carry + them). Events the admin's own routes announce are marked and skipped, so nothing is logged twice. +- **Content webhooks are a listener on the page events**, not a call in each admin route: a write + from the inline-edit bar, MCP or the CLI now reaches them too. `announcePageEvent()` names the + signed-in user as `actor`, which the webhook payload carries. + +- **`AdminPageRegistration`, `AdminRouteRegistration` and `ContentEditorPlugin` live here now**, exported + from `@dune/plugin-admin` (and `@dune/plugin-admin/contributions`). Core's contract never read them. The + keys are exported from here as typed tokens, `ADMIN_PAGES`, `ADMIN_ROUTES` and `ADMIN_EDITOR` + (`@dune/plugin-admin/contributions`): a plugin writes `contribute(ADMIN_PAGES, ...)`. The `editor.content` + slot is renamed **`admin.editor`**: a key carries the name of the plugin that reads it. A plugin that + provides a replacement page editor changes the key and imports `ContentEditorPlugin` from here. + +### 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`, + `bind_dn`, `bind_password`, `username_attr`, `email_attr`, `name_attr`, + `role_map`, `default_role`, `entity_id`, `acs_url`, `idp_metadata`, + `role_attr`). `content.allowTsxFormat` is `allow_tsx_format`. + Run `dune migrate:config` (core) to rewrite `config/system.yaml`. A config that + still carries an old key is refused by core at load time, so this cannot be + silently ignored. Requires `@dune/core` 0.36 or newer. +- **The workflow, scheduler, staging, machine-translation and audit engines come + from core's `services.engines`**, and admin tuples are seeded through + `services.authz.seedAdminTuples()` instead of importing `bootstrapAdminTuples`. + Core builds them once per site under `admin.runtime_dir`; the admin no longer + calls their factories. Requires `@dune/core` 0.36 or newer. +- **Blueprints and data paths come from core's `services`**: `services.blueprints` + (`has` / `resolve` / `validate`, read from the content engine at call time) + replaces importing `resolveBlueprint` / `validateFrontmatter`, and + `services.paths.dataDir` replaces `resolveDataDir()`. Requires `@dune/core` + 0.36 or newer. +- **`GET /admin/api/search/analytics` reads core's recorder.** Core now records + `GET /api/search` queries itself when `system.search.analytics: true` (off by + default: the log holds what visitors typed) and the route returns + `services.search.analytics.summarize()` plus an `enabled` flag (the summary also + carries `since`, the time of the first counted search). Nothing wrote + the log after the admin extraction, so the route used to return an empty + summary. Requires `@dune/core` 0.36 or newer. +- **Security policy, sessions, users and passwords come from core's `services`** + (`MountApi.services`, `@dune/core/plugin-api`) instead of imports from + `@dune/core`: HTML sanitising, SSRF-guarded fetch, body limits, client IP, the + YAML parser, password hashing/verification, the user store, rate limiting and + the logger. A core patch release now fixes them for the admin without an admin + release, and session storage (local, KV, Redis) follows `system.session_store` + including its per-site `prefix`. Errors are matched by `isDuneError(err, code)`, + not `instanceof`. Removed: the `hashPassword` / `verifyPassword` / `DUMMY_HASH` + re-exports from `@dune/plugin-admin/admin` (import them from + `@dune/core/auth/passwords`), and the old `createSessionManager` signature + (now async, takes `services`). Requires `@dune/core` with `plugin-api` (0.36). +- **The data directory is `system.data_dir` only.** `admin.data_dir` no longer + exists (core removed the deprecated form aliases); the plugin resolves the + directory with `resolveDataDir()` from `@dune/core/forms`. + ## [3.3.0] — 2026-11-03 ### Changed diff --git a/README.md b/README.md index 4924321..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 - sessionLifetime: 86400 # Session duration in seconds (default: 24h) - dataDir: data # Directory for users, submissions, etc. - runtimeDir: .dune/admin # Directory for sessions, audit log, etc. - - auth_provider: - type: local # Only "local" is supported today - - audit: - enabled: true - logFile: .dune/admin/audit.log # Override the audit log path - - 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..41469fd 100644 --- a/deno.json +++ b/deno.json @@ -1,11 +1,12 @@ { "name": "@dune/plugin-admin", - "version": "3.3.0", + "version": "4.0.0", "license": "MIT", "minimumDependencyAge": { "age": "P1D", "exclude": [ - "jsr:@dune/core" + "jsr:@dune/core", + "jsr:@dune/testing" ] }, "exports": { @@ -13,12 +14,16 @@ "./admin/mount": "./src/admin/mount.ts", "./admin/context": "./src/admin/context.ts", "./admin/types": "./src/admin/types.ts", + "./contributions": "./src/admin/contributions.ts", "./admin/guards": "./src/admin/guards.ts", "./admin/auth/middleware": "./src/admin/auth/middleware.ts", - "./admin/auth/provider": "./src/admin/auth/provider.ts" + "./admin/auth/provider": "./src/admin/auth/provider.ts", + "./audit": "./src/audit/mod.ts", + "./mt": "./src/mt/mod.ts" }, "imports": { - "@dune/core": "jsr:@dune/core@^0.35.3", + "@dune/core": "jsr:@dune/core@0.36", + "@dune/testing": "jsr:@dune/testing@^2", "fresh": "jsr:@fresh/core@^2", "preact": "npm:preact@^10", "preact/hooks": "npm:preact@^10/hooks", @@ -44,7 +49,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 a0d9f90..65d1144 100644 --- a/mod.ts +++ b/mod.ts @@ -14,42 +14,33 @@ * @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"; import { createUserManager } from "./src/admin/auth/users.ts"; -import { highestValidRole } from "./src/admin/auth/role-utils.ts"; import { createSessionManager } from "./src/admin/auth/sessions.ts"; -import { createSessionStore } from "@dune/core/session"; import { createAuthMiddleware } from "./src/admin/auth/middleware.ts"; -import { LocalRateLimitStore } from "@dune/core/security"; import { LocalAuthProvider } from "./src/admin/auth/local-provider.ts"; import type { AuthProvider } from "./src/admin/auth/provider.ts"; -import { createWorkflowEngine } from "@dune/core/workflow"; -import { createScheduler } from "@dune/core/workflow"; import type { ScheduledAction } from "@dune/core/workflow"; import { applyWorkflowTransition, SCHEDULED_ACTION_STATUS } from "./src/admin/workflow-actions.ts"; -import { resolveFormsConfig } from "@dune/core/forms"; -import { createStagingEngine } from "@dune/core/staging"; import { createCommentManager } from "./src/admin/comments.ts"; import { createCollabManager } from "./src/collab/mod.ts"; -import { createMachineTranslator } from "@dune/core/mt"; -import type { MachineTranslator } from "@dune/core/mt"; -import { AuditLogger } from "@dune/core/audit"; -import { bootstrapAdminTuples } from "@dune/core/auth/authz"; -import type { 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 { logger } from "@dune/core/logger"; -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"; +import { adminBarActions, BAR_ACTIONS } from "./src/admin/bar-actions.ts"; +import { reconcileAdminTuples } from "./src/admin/admin-tuples.ts"; +import { listenForPageAudit } from "./src/admin/page-audit.ts"; +import { ADMIN_EDITOR, ADMIN_PAGES, ADMIN_ROUTES } from "./src/admin/contributions.ts"; +import { listenForContentWebhooks } from "./src/admin/webhooks.ts"; // ── Core-instance handshake ────────────────────────────────────────────────── // Re-export the CORE_INSTANCE sentinel and CORE_VERSION that THIS package's @@ -65,17 +56,25 @@ const _core = corePlugins as unknown as { /** The `@dune/core` instance sentinel this package resolved. See core's `plugins/mod.ts`. */ export const resolvedCoreSentinel: unknown = _core.CORE_INSTANCE; +/** The admin's extension points: the typed keys (`ADMIN_PAGES`, `ADMIN_ROUTES`, `ADMIN_EDITOR`) and their item types. */ +export { ADMIN_EDITOR, ADMIN_PAGES, ADMIN_ROUTES } from "./src/admin/contributions.ts"; +export type { + AdminPageRegistration, + AdminRouteRegistration, + ContentEditorPlugin, +} from "./src/admin/contributions.ts"; + /** The `@dune/core` version this package resolved. Undefined on cores ≤0.30. */ export const resolvedCoreVersion: string | undefined = _core.CORE_VERSION; -/** 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(). */ @@ -91,101 +90,91 @@ 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) { + config = api.config; + storage = api.storage; + const adminCfg = adminSettings(config); // throws on a bad path / auth_provider: the site does not boot - async setup(_api) { - const adminCfg = config.admin ?? { - path: "/admin", - sessionLifetime: 86400, - dataDir: "data", - runtimeDir: ".dune/admin", - enabled: true, - }; - if (adminCfg.enabled === false) { - setupState = null; - return; - } - - // Audit logger — created in setup() so it can init() its file handle early. - let auditLogger: AuditLogger | null = null; - if (adminCfg.audit?.enabled !== false) { - const configuredPath = adminCfg.audit?.logFile; - const runtimeDir = adminCfg.runtimeDir ?? ".dune/admin"; - const auditLogFile = configuredPath - ? join(opts.root, configuredPath) - : join(opts.root, runtimeDir, "audit.log"); - const containmentRoot = opts.root.endsWith("/") || opts.root.endsWith("\\") - ? opts.root - : opts.root + "/"; - if (!auditLogFile.startsWith(containmentRoot)) { - throw new Error( - `[dune] admin.audit.logFile must resolve under the site root. ` + - `Got: ${configuredPath} -> ${auditLogFile}`, - ); - } - auditLogger = new AuditLogger({ logFile: auditLogFile }); - await auditLogger.init(); - } - - // Machine translation provider. - const mt: MachineTranslator | null = config.site.machine_translation - ? createMachineTranslator(config.site.machine_translation) - : null; + // 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); + }, + })); + + // The admin's own buttons on the inline-edit bar. `bar.actions` is that + // plugin's extension point; contributing through a key of the same name + // (see bar-actions.ts) keeps it an optional neighbour, not a dependency + // (a site without it simply never reads this). + api.capabilities.contribute(BAR_ACTIONS, (ctx) => adminBarActions(adminCfg.path, ctx)); }, - async mount({ app, bootstrap, adminServices }: 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", - sessionLifetime: 86400, - dataDir: "data", - runtimeDir: ".dune/admin", - enabled: true, - }; - const runtimeDir = adminCfg.runtimeDir ?? ".dune/admin"; - // `system.data_dir`, falling back to the deprecated `admin.dataDir` — - // the same resolution core uses for the submissions it stores. - const dataDir = resolveFormsConfig(config).dataDir; + const 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; // ── Admin services ──────────────────────────────────────────────────────── const users = createUserManager({ storage, usersDir: `${dataDir}/users`, + services, }); // Migration warning: detect users left in the old .dune/admin/users location @@ -203,42 +192,26 @@ export function createAdminPlugin( } catch { /* ignore */ } } - const sessionStoreCfg = config.system?.session_store; - const resolvedSessionStore = await createSessionStore({ - type: sessionStoreCfg?.type ?? "local", - redisUrl: sessionStoreCfg?.url - ? (sessionStoreCfg.url.startsWith("$") - ? Deno.env.get(sessionStoreCfg.url.slice(1)) - : sessionStoreCfg.url) - : undefined, - storage, + const sessions = await createSessionManager({ + services, sessionsDir: `${runtimeDir}/sessions`, - lifetimeMs: adminCfg.sessionLifetime * 1000, - }); - - const sessions = createSessionManager({ - store: resolvedSessionStore, - lifetime: adminCfg.sessionLifetime, + lifetime: adminCfg.session_lifetime, }); - const rateLimitStore = new LocalRateLimitStore(); + const rateLimitStore = services.rateLimit.createLocalStore(); // Auth provider: injection from bootstrap options > config > local default. let authProvider: AuthProvider; 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); - } 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.`, - ); + authProvider = new LocalAuthProvider(users, services.passwords); } 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.`, ); } } @@ -249,14 +222,10 @@ export function createAdminPlugin( users, secure: secureCookies, trustForwardedFor: config.system?.trusted_proxies === true, + security: services.security, }); - const workflow = createWorkflowEngine( - { storage, dataDir: runtimeDir }, - config.site.workflow ?? undefined, - ); - - const scheduler = createScheduler({ storage, dataDir: runtimeDir }); + const { workflow, scheduler, staging: stagingEngine } = services.engines; // scheduler on its own only supports CRUD (schedule/cancel/list) — it // never executes anything by itself. Whichever long-running process @@ -273,7 +242,7 @@ export function createAdminPlugin( return; } try { - await applyWorkflowTransition({ engine: bootstrap.engine, storage, config, hooks: bootstrap.hooks, workflow }, action.sourcePath, newStatus); + await applyWorkflowTransition({ engine: runtime.engine, storage, config, hooks: runtime.hooks, workflow }, action.sourcePath, newStatus); } catch (err) { logger.error("scheduler.action-failed", { actionId: action.id, @@ -284,20 +253,20 @@ export function createAdminPlugin( } }; - const stagingEngine = createStagingEngine({ storage, runtimeDir }); - const commentManager = createCommentManager({ dataDir, runtimeDir }); 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 " + @@ -306,7 +275,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({ @@ -320,68 +289,74 @@ export function createAdminPlugin( }).catch(() => {}); }); + // Outbound content webhooks follow the page events, whoever wrote the + // page (an admin route, the inline-edit bar, MCP, the CLI): the rule is + // that the writer fires the event, and this is a listener like any other. + listenForContentWebhooks({ + hooks: runtime.hooks, + endpoints: () => adminSettings(config).webhooks, + runtimeDir, + security: services.security, + }); + + // Writes that did not come through an admin route (the inline-edit bar, a + // plugin) still reach the audit trail, named by the event's `actor`. + listenForPageAudit({ hooks: runtime.hooks, audit: () => setupState?.auditLogger }); + // ── Default content editor ──────────────────────────────────────────────── - if (!adminServices.contentEditor) { - adminServices.contentEditor = createBlockEditorPlugin(); - } + const inlineEdit = capabilities.get("editor.inline"); + const contentEditor = capabilities.get(ADMIN_EDITOR) ?? 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 = { - engine: bootstrap.engine, - search: bootstrap.search, + services, + 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 @@ -399,18 +374,28 @@ 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 - // visitors share the same store (Phase 5b). Only bootstrap tuples - // for accounts that actually carry an admin-tier role. - const allUsers = await users.list(); - const enabledAdminUsers = allUsers - .filter((u) => u.enabled !== false) - .map((u) => ({ id: u.id, role: highestValidRole(u.roles) })) - .filter((u) => u.role !== undefined) as { id: string; role: string }[]; - await bootstrapAdminTuples(bootstrap.authz, bootstrap.authzAdapter, enabledAdminUsers); + // users.list() returns every account in the unified store (data/users/), + // site visitors included. Only the admin's own accounts, the ones that + // can sign in to it, get a direct tuple; the rest are governed by + // core's `system.role_grants` (see admin-tuples.ts). + const revoked = await reconcileAdminTuples(await users.list(), { + seedAdminTuples: (accounts) => services.authz.seedAdminTuples(accounts), + revoke: async (userId, relation) => { + await runtime.authz!.disallowAllMatching({ + who: { type: "user", id: userId }, + was: relation as "admin" | "editor" | "author", + onWhat: { type: "app", id: "site" }, + }); + }, + }); + if (revoked > 0) { + logger.info("admin.authz.stale_direct_grants_revoked", { + count: revoked, + reason: "accounts that cannot sign in to the admin are governed by system.role_grants", + }); + } } catch (err) { console.warn( "[dune/authz] Admin tuple bootstrap failed — authz is up but tuples were not seeded; admin access will deny (403) until bootstrap succeeds:", @@ -420,7 +405,27 @@ 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 }`. + */ +function adminPluginFactory( + _pluginConfig: Record, + ctx: { root: string; dev: boolean }, +): DunePlugin { + return createAdminPlugin({ root: ctx.root, dev: ctx.dev }); +} +adminPluginFactory.pluginName = "admin"; + +// A separate default export: declaring it inline (`export default function`) +// next to the property assignment above is a merged declaration, which JSR's +// type output rejects (TS2652) and so blocks `deno publish`. +export default adminPluginFactory; diff --git a/scripts/generate-admin-manifest.ts b/scripts/generate-admin-manifest.ts index 61fda6f..73cde8d 100644 --- a/scripts/generate-admin-manifest.ts +++ b/scripts/generate-admin-manifest.ts @@ -57,7 +57,13 @@ function comparePatterns(a: string, b: string): number { const y = sb[i]; if (x === undefined) return -1; if (y === undefined) return 1; - if (x === y) continue; + if (x === y) { + // A shared wildcard swallows everything after it, so the pattern with + // more segments following it (`/:path*/publish`) is the narrower one + // and must be registered first, or the bare `/:path*` shadows it. + if (x.endsWith("*") && sa.length !== sb.length) return sb.length - sa.length; + continue; + } const rank = (s: string) => s.endsWith("*") ? 2 : s.startsWith(":") ? 1 : 0; const rx = rank(x); const ry = rank(y); diff --git a/scripts/generate-core-imports.ts b/scripts/generate-core-imports.ts index 95d6f9a..db577cd 100644 --- a/scripts/generate-core-imports.ts +++ b/scripts/generate-core-imports.ts @@ -39,24 +39,18 @@ * deno task check:core-imports — fail (exit 1) if deno.json is stale */ -/** Bump at every core minor this package needs. Bare-minor form (e.g. "0.31") +/** Bump at every core minor this package needs. Bare-minor form (e.g. "0.36") * auto-tracks patch releases within that minor — confirmed via `deno cache`: * "0.28" resolved to the latest 0.28.x, not just 0.28.0. * - * Floored at 0.35.3: password hashing (`@dune/core/auth/passwords`) and the - * `DUNE_ADMIN_*` first-admin bootstrap moved into core there, and this package - * no longer carries its own copy or a default admin. (0.35.2 before that: the - * public form routes and the submission store moved into core, with - * `BootstrapResult.submissions`, `registerFormRoutes()` and the `onSystemAction` - * hook from `@dune/core/forms`.) A bare "0.35" would floor JSR's publish-time check at - * 0.35.0 (reproduced 2026-08-31 with "0.34": - * "invalid 'jsr:' dependency subpath: '@dune/core@0.34/auth/authz-schema', - * resolved to 0.34.0, has no export") and let a site pair this package with a - * core that cannot serve those routes. Back to a bare minor at the next core - * minor. This constant and deno.json are only kept in step by - * `check:core-imports`, so run it before merging any pin change. + * "0.36": this package (4.x) is a plugin core lists under `plugins:` and reads + * its settings from `config.plugins.admin`, which needs core 0.36.0 (core no + * longer loads the admin or has an `admin` config block). The floor is 0.36.0, + * so JSR's publish-time check (oldest version satisfying the range) sees every + * subpath this package uses. This constant and deno.json are only kept in step + * by `check:core-imports`, so run it before merging any pin change. */ -const CORE_RANGE = "^0.35.3"; +const CORE_RANGE = "0.36"; const DENO_JSON = "deno.json"; const SCAN_ROOTS = ["mod.ts", "src", "tests"]; diff --git a/src/admin/admin-tuples.ts b/src/admin/admin-tuples.ts new file mode 100644 index 0000000..87d5dc7 --- /dev/null +++ b/src/admin/admin-tuples.ts @@ -0,0 +1,61 @@ +/** + * Which accounts the admin gives direct editing rights. + * + * `admin`, `editor` and `author` in a user's `roles[]` already carry editing + * rights through core's group grants (`system.role_grants`), for any signed-in + * user. What the admin adds is a direct tuple for **its own accounts**, so they + * can use the panel even when a site turns the group grants off with + * `role_grants: {}`. Its own accounts are the ones that can sign in to it: the + * built-in sign-in is by password, so those with a local password. + * + * A site user (OAuth, magic link, `external-jwt`) who happens to hold an + * admin-tier role gets no direct tuple here: they are governed by + * `role_grants` like everyone else, and a direct tuple would keep them editing + * after the site switched that off. + * + * @module + */ + +import { highestValidRole } from "./auth/role-utils.ts"; + +/** The part of a user record this needs. */ +export interface StoredAccount { + id: string; + roles: string[]; + enabled?: boolean; + provider?: string; + passwordHash?: string; +} + +/** The part of core's authz this needs. */ +export interface TupleAuthz { + seedAdminTuples(users: Array<{ id: string; role: string }>): Promise; + revoke(userId: string, relation: string): Promise; +} + +/** An account that can sign in to the built-in admin: it has a local password. */ +export function isAdminAccount(user: StoredAccount): boolean { + return user.provider === "local" || !!user.passwordHash; +} + +/** + * Seed the direct tuple of every enabled admin account that holds an + * admin-tier role, and revoke the one an earlier version seeded for an + * account that cannot sign in to the admin. Returns how many it revoked. + */ +export async function reconcileAdminTuples(users: StoredAccount[], authz: TupleAuthz): Promise { + const seed: Array<{ id: string; role: string }> = []; + const stale: Array<{ id: string; role: string }> = []; + for (const user of users) { + const role = highestValidRole(user.roles); + if (role === undefined) continue; + if (isAdminAccount(user)) { + if (user.enabled !== false) seed.push({ id: user.id, role }); + } else { + stale.push({ id: user.id, role }); + } + } + await authz.seedAdminTuples(seed); + for (const { id, role } of stale) await authz.revoke(id, role); + return stale.length; +} diff --git a/src/admin/auth/csrf.ts b/src/admin/auth/csrf.ts index cb40c42..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?.runtimeDir; + 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 21c3701..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.usernameAttr ?? "uid"; - * const emailAttr = this.config.emailAttr ?? "mail"; - * const nameAttr = this.config.nameAttr ?? "cn"; - * const client = new Client({ url: this.config.url }); - * try { - * if (this.config.bindDn) { - * // Service account bind, then search for the user entry - * const bindPw = expandEnv(this.config.bindPassword ?? ""); - * await client.bind(this.config.bindDn, bindPw); - * const { searchEntries } = await client.search(this.config.baseDn, { - * // 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.roleMap) - * ?? this.config.defaultRole - * ?? "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.baseDn}`; - * await client.bind(userDn, creds.password); - * return { - * externalId: userDn, - * username: creds.username, - * roles: [this.config.defaultRole ?? "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/local-provider.ts b/src/admin/auth/local-provider.ts index 8e9d4f0..06f5922 100644 --- a/src/admin/auth/local-provider.ts +++ b/src/admin/auth/local-provider.ts @@ -7,19 +7,19 @@ import type { AuthProvider, AuthCredentials, AuthProviderUser } from "./provider.ts"; import type { UserManager } from "./users.ts"; -import { verifyPassword, DUMMY_HASH } from "@dune/core/auth/passwords"; +import type { PasswordService } from "@dune/core/plugin-api"; export class LocalAuthProvider implements AuthProvider { readonly type = "local" as const; - constructor(private users: UserManager) {} + constructor(private users: UserManager, private passwords: Pick) {} async authenticate(creds: AuthCredentials): Promise { const user = await this.users.getByUsername(creds.username); // Always run verifyPassword even when the user is not found to prevent // username enumeration via response-time differences (timing oracle). - const hash = user?.passwordHash ?? DUMMY_HASH; - const valid = await verifyPassword(creds.password, hash); + const hash = user?.passwordHash ?? this.passwords.dummyHash; + const valid = await this.passwords.verify(creds.password, hash); if (!valid || !user || !user.enabled) return null; return { externalId: user.id, diff --git a/src/admin/auth/middleware.ts b/src/admin/auth/middleware.ts index 13da8b0..ed28b3f 100644 --- a/src/admin/auth/middleware.ts +++ b/src/admin/auth/middleware.ts @@ -7,7 +7,7 @@ import type { SessionManager } from "./sessions.ts"; import type { UserManager } from "./users.ts"; import type { AuthResult } from "../types.ts"; -import { clientIp } from "@dune/core/security"; +import type { SecurityService } from "@dune/core/plugin-api"; /** Options for {@link createAuthMiddleware}. */ export interface AuthMiddlewareConfig { @@ -29,6 +29,8 @@ export interface AuthMiddlewareConfig { * a cookie thief can spoof the original IP via a forged header. */ trustForwardedFor?: boolean; + /** Core's security service (client IP resolution). */ + security: Pick; } /** Validates session cookies. Obtain via {@link createAuthMiddleware}. */ @@ -74,7 +76,7 @@ export function createAuthMiddleware( // also send a forged forwarded header that matches the original session // IP, and the binding check is meaningless. if (session.ip) { - const requestIp = clientIp(req, { trustForwardedFor }); + const requestIp = config.security.clientIp(req, { trustForwardedFor }); // "unknown" is a missing peer (no socket stamp, no trusted XFF). // A stolen cookie that omits forwarded headers used to skip this // check; treat a missing IP as a mismatch when the session is bound. diff --git a/src/admin/auth/provider.ts b/src/admin/auth/provider.ts index 87865df..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" - */ - baseDn: string; - /** - * LDAP attribute containing the login name. - * Default: "sAMAccountName" (Active Directory) or "uid" (OpenLDAP). - */ - usernameAttr?: string; - /** - * Optional bind DN for a service account used to search the directory. - * If omitted, Dune attempts a direct bind using the user's constructed DN. - */ - bindDn?: string; - /** - * Service account password. Supports "$ENV_VAR" expansion. - * @example "$LDAP_BIND_PASSWORD" - */ - bindPassword?: string; - /** LDAP attribute to use as email. Default: "mail" */ - emailAttr?: string; - /** LDAP attribute to use as display name. Default: "cn" */ - nameAttr?: 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" - * ``` - */ - roleMap?: Array<{ group: string; role: Role }>; - /** Default role for authenticated users not in any mapped group. Default: "author" */ - defaultRole?: Role; -} - -/** SAML 2.0 authentication provider configuration. */ -export interface SamlProviderConfig { - type: "saml"; - /** Your application's Entity ID (SP entity ID). */ - entityId: string; - /** - * Assertion Consumer Service URL — where the IdP POSTs the assertion after authentication. - * @example "https://example.com/admin/saml/acs" - */ - acsUrl: string; - /** Identity provider metadata as a URL (fetched at startup) or inline XML. */ - idpMetadata: string; - /** SAML attribute containing the username. Default: "NameID" */ - usernameAttr?: string; - /** SAML attribute containing the email. Default: "email" or "mail" */ - emailAttr?: string; - /** SAML attribute containing the display name. Default: "displayName" */ - nameAttr?: string; - /** - * Map IdP group/role attribute values to Dune roles. First match wins. - */ - roleMap?: Array<{ value: string; role: Role }>; - /** SAML attribute carrying the role or group value. Default: "role" or "groups" */ - roleAttr?: string; - /** Default role for users with no matching roleMap entry. Default: "author" */ - defaultRole?: Role; -} diff --git a/src/admin/auth/saml-provider.ts b/src/admin/auth/saml-provider.ts deleted file mode 100644 index 840f6d2..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.entityId, - * assertionConsumerService: [ - * { Binding: saml.Constants.namespace.binding.post, Location: this.config.acsUrl }, - * ], - * }); - * } - * - * private buildIdp() { - * return saml.IdentityProvider({ metadata: this.config.idpMetadata }); - * } - * - * 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.usernameAttr ?? "NameID"; - * const emailAttr = this.config.emailAttr ?? "email"; - * const nameAttr = this.config.nameAttr ?? "displayName"; - * const roleAttr = this.config.roleAttr ?? "role"; - * const role = mapRole(attrs[roleAttr], this.config.roleMap) - * ?? this.config.defaultRole - * ?? "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/auth/sessions.ts b/src/admin/auth/sessions.ts index afe39d8..aec5b23 100644 --- a/src/admin/auth/sessions.ts +++ b/src/admin/auth/sessions.ts @@ -6,31 +6,18 @@ * over whichever SessionStore backend (local/KV/Redis) is configured. This * file exists to keep the original SessionManager interface stable for * existing call sites (create() has no embeddedUser param — admin sessions - * never need one) and to preserve the storage/sessionsDir convenience - * construction path. + * never need one). */ -import type { StorageAdapter } from "@dune/core/storage"; import type { Session } from "../types.ts"; -import type { SessionStore } from "@dune/core/session"; -import { createLocalSessionStore, createSessionManager as createCoreSessionManager } from "@dune/core/session"; - -export type { SessionStore }; +import type { PluginServices } from "@dune/core/plugin-api"; /** Options for {@link createSessionManager}. */ export interface SessionManagerConfig { + /** Core's services; the session backend is chosen by core from `system.session_store`. */ + services: Pick; /** - * Pre-constructed session store. When provided, `storage` and `sessionsDir` - * are ignored. - */ - store?: SessionStore; - /** - * StorageAdapter for the local (file-backed) store. - * Required when `store` is not supplied. - */ - storage?: StorageAdapter; - /** - * Directory for session files when using the local backend. + * Directory for session files when the local backend is configured. * E.g. ".dune/admin/sessions" */ sessionsDir?: string; @@ -53,29 +40,14 @@ export interface SessionManager { } /** - * Create a session manager backed by the given store. - * - * When `config.store` is not supplied, falls back to constructing a - * LocalSessionStore from `config.storage` + `config.sessionsDir`, preserving - * the original file-backed behaviour for existing callers. + * Create the admin session manager. Core builds the underlying manager over + * the configured backend (local files, Deno KV or Redis, scoped per site). */ -export function createSessionManager(config: SessionManagerConfig): SessionManager { - const { lifetime } = config; - - const store: SessionStore = config.store ?? (() => { - if (!config.storage) { - throw new Error( - "[dune] createSessionManager: either 'store' or 'storage' must be provided.", - ); - } - return createLocalSessionStore({ - storage: config.storage, - sessionsDir: config.sessionsDir ?? ".dune/admin/sessions", - lifetimeMs: lifetime * 1000, - }); - })(); - - const core = createCoreSessionManager(store, lifetime * 1000); +export async function createSessionManager(config: SessionManagerConfig): Promise { + const core = await config.services.sessions.createManager({ + sessionsDir: config.sessionsDir ?? ".dune/admin/sessions", + lifetimeMs: config.lifetime * 1000, + }); return { // Admin sessions never carry an embeddedUser — the underlying factory's diff --git a/src/admin/auth/users.ts b/src/admin/auth/users.ts index e1f4e73..0cb861f 100644 --- a/src/admin/auth/users.ts +++ b/src/admin/auth/users.ts @@ -16,10 +16,9 @@ */ import type { StorageAdapter } from "@dune/core/storage"; -import { createLocalUserStore } from "@dune/core/auth/user-store"; import type { UserStore } from "@dune/core/auth/user-store"; import type { User, Role } from "../types.ts"; -import { hashPassword } from "@dune/core/auth/passwords"; +import type { PluginServices } from "@dune/core/plugin-api"; import { withRole } from "./role-utils.ts"; /** Options for {@link createUserManager}. */ @@ -27,6 +26,8 @@ export interface UserManagerConfig { storage: StorageAdapter; /** Directory for user files (e.g. "data/users") */ usersDir: string; + /** Core's services: the user store and password hashing. */ + services: Pick; } /** Input for {@link UserManager.create}. */ @@ -63,8 +64,9 @@ export interface UserManager { * Create a user manager backed by @dune/core's UserStore. */ export function createUserManager(config: UserManagerConfig): UserManager { - const { storage, usersDir } = config; - const store: UserStore = createLocalUserStore({ storage, usersDir }); + const { usersDir, services } = config; + const store: UserStore = services.users.createLocalStore({ usersDir }); + const hashPassword = services.passwords.hash; async function create(input: CreateUserInput): Promise { return store.create({ diff --git a/src/admin/bar-actions.ts b/src/admin/bar-actions.ts new file mode 100644 index 0000000..0251883 --- /dev/null +++ b/src/admin/bar-actions.ts @@ -0,0 +1,71 @@ +/** + * What the admin adds to the inline-edit bar. + * + * The bar belongs to `@dune/plugin-inline-edit`, which knows nothing about an + * admin; this plugin contributes its own buttons to the bar's `bar.actions` + * point: a link to the page's editor here, and a button that opens that editor + * in an overlay on the page. + * + * The contribution is made without importing the bar plugin's package (that would + * make an optional neighbour a dependency), so the shapes and the key below are + * this plugin's own restatement of the `BarAction` contract. + * + * @module + */ + +import { type CapabilityKey, capabilityKey } from "@dune/core/plugin-api"; + +/** A bar button, as `@dune/plugin-inline-edit` renders it. */ +export interface AdminBarAction { + id: string; + label: string; + /** Renders as a link. */ + href?: string; + /** Renders as a button running this script. */ + onClick?: string; +} + +/** The part of the bar's per-response context this plugin reads. */ +export interface AdminBarContext { + page: { sourcePath: string } | null; +} + +/** + * The bar's `bar.actions` contribution point, recreated from its documented name + * so this plugin needs no dependency on the bar's package: two keys with one + * name are one capability. + */ +export const BAR_ACTIONS: CapabilityKey<(ctx: AdminBarContext) => AdminBarAction[]> = capabilityKey("bar.actions"); + +/** + * Script for the "Edit source" button: show the admin's page editor + * (`embedded=1` drops its sidebar and topbar, redundant over a content page) in + * a full-screen overlay. Built on first click so nothing loads until asked; + * Escape or Close hides it. + */ +function overlayScript(embeddedUrl: string): string { + return `(function(){var id='dune-source-overlay',o=document.getElementById(id);` + + `if(o){o.style.display='flex';return;}` + + `var s=document.createElement('style');s.textContent='@media print{#'+id+'{display:none!important}}';document.head.appendChild(s);` + + `o=document.createElement('div');o.id=id;` + + `o.style.cssText='position:fixed;inset:0;z-index:100000;background:rgba(0,0,0,.5);display:flex;flex-direction:column';` + + `var b=document.createElement('div');b.style.cssText='flex:none;display:flex;justify-content:flex-end;padding:8px 16px;background:#1a1a2e';` + + `var c=document.createElement('button');c.type='button';c.textContent='\\u2715 Close';` + + `c.style.cssText='background:rgba(255,255,255,.15);color:#fff;border:none;border-radius:4px;padding:4px 12px;font-size:12px;cursor:pointer;font-family:inherit';` + + `function hide(){o.style.display='none';}c.onclick=hide;b.appendChild(c);` + + `var f=document.createElement('iframe');f.title='Edit source';f.style.cssText='flex:1;width:100%;border:none;background:#fff';` + + `f.src=${JSON.stringify(embeddedUrl)};o.appendChild(b);o.appendChild(f);document.body.appendChild(o);` + + `document.addEventListener('keydown',function(e){if(e.key==='Escape')hide();});})()`; +} + +/** The admin's bar contribution for a page, or nothing off content pages. */ +export function adminBarActions(prefix: string, ctx: AdminBarContext): AdminBarAction[] { + if (!ctx.page) return []; + // The edit route is `${prefix}/pages/edit?path=` (there is no + // `/pages/` route). + const editUrl = `${prefix}/pages/edit?path=${encodeURIComponent(ctx.page.sourcePath)}`; + return [ + { id: "admin:edit-source", label: "✎ Edit source", onClick: overlayScript(`${editUrl}&embedded=1`) }, + { id: "admin:open", label: "Open in admin →", href: editUrl }, + ]; +} diff --git a/src/admin/block-editor-plugin.tsx b/src/admin/block-editor-plugin.tsx index fc5aa7e..62b959d 100644 --- a/src/admin/block-editor-plugin.tsx +++ b/src/admin/block-editor-plugin.tsx @@ -1,14 +1,15 @@ /** @jsxImportSource preact */ /** * Built-in block editor — the default ContentEditorPlugin loaded by bootstrap - * when no plugin overrides adminServices.contentEditor. + * when no plugin provides the `admin.editor` capability. * * Wraps the PageEditor island in the same way the old hardwired route did, but * now as a proper ContentEditorPlugin so it participates in the same slot that * third-party editors use. */ -import type { ContentEditorPlugin } from "@dune/core/hooks"; +import type { ContentEditorPlugin } from "./contributions.ts"; +import type { FreshContext } from "fresh"; import PageEditor from "./islands/PageEditor.tsx"; 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 0d0d2c9..332ac58 100644 --- a/src/admin/comments.ts +++ b/src/admin/comments.ts @@ -5,11 +5,11 @@ * notifications, and a resolve/unresolve workflow suitable for editorial * sign-off processes. * - * Storage layout (inside admin.dataDir — git-tracked): + * Storage layout (inside admin.data_dir — git-tracked): * data/comments/{encodedPath}.json → { comments: PageComment[] } * encodedPath: sourcePath with "/" → "__" and "." → "-dot-" * - * Mention read-state (inside admin.runtimeDir — gitignored, machine-local): + * Mention read-state (inside 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. @@ -63,9 +63,9 @@ interface MentionReadState { /** Options for createCommentManager */ export interface CommentManagerOptions { - /** admin.dataDir — git-tracked persistent storage */ + /** admin.data_dir — git-tracked persistent storage */ dataDir: string; - /** admin.runtimeDir — ephemeral machine-local storage */ + /** system.runtime_dir — ephemeral machine-local storage */ runtimeDir: string; } diff --git a/src/admin/context.ts b/src/admin/context.ts index e7958bd..90c2178 100644 --- a/src/admin/context.ts +++ b/src/admin/context.ts @@ -17,25 +17,27 @@ 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 { ContentEditorPlugin } from "@dune/core/hooks"; +import type { HookRegistry } from "@dune/core/hooks"; +import type { AdminPageRegistration, AdminRouteRegistration, ContentEditorPlugin } from "./contributions.ts"; import type { StagingEngine } from "@dune/core/staging"; import type { CommentManager } from "./comments.ts"; import type { CollabManager } from "../collab/mod.ts"; 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 { + /** Services core hands plugins (security policy, sessions, passwords, ...). */ + services: PluginServices; engine: DuneEngine; search: SearchManager; storage: StorageAdapter; @@ -64,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 `admin.editor` capability. Replaces the built-in block editor. */ contentEditor?: ContentEditorPlugin; - imageCache?: ImageCache; auditLogger?: AuditLogger; metrics?: MetricsCollector; mt?: MachineTranslator | null; @@ -105,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/contributions.ts b/src/admin/contributions.ts new file mode 100644 index 0000000..955f1ad --- /dev/null +++ b/src/admin/contributions.ts @@ -0,0 +1,139 @@ +/** + * The admin's own extension points. + * + * Core's contract names none of these: it never reads them. The admin is their + * only consumer, so it declares them here, as typed keys under its own `admin.` + * prefix. A plugin that contributes an admin page, an admin route or a + * replacement page editor imports the key (which carries the type) from + * `@dune/plugin-admin`: + * + * ```ts + * import { ADMIN_PAGES } from "@dune/plugin-admin"; + * api.capabilities.contribute(ADMIN_PAGES, { path: "/billing", label: "Billing", permission: "billing.manage", handler }); + * ``` + * + * @module + */ + +import { type CapabilityKey, capabilityKey, type PluginRouteContext } from "@dune/core/plugin-api"; + +/** + * A custom admin page contributed by a plugin. + * + * Plugins contribute these to the `admin.pages` point: + * `api.capabilities.contribute(ADMIN_PAGES, { path, label, permission, handler })`. + * Unlike `publicRoutes`, this is **not** wired by `@dune/core`: the admin reads the list + * (`capabilities.list(ADMIN_PAGES)`) and mounts each page as a programmatic route, + * enforcing its declared `permission`. A site without the admin gets no admin pages. + * + * @since 4.0.0 + */ +// deno-lint-ignore no-explicit-any +export interface AdminPageRegistration { + /** + * URL path relative to the admin prefix (must start with `/`). + * Example: `/my-plugin` registers at `/admin/my-plugin`. + */ + path: string; + /** Human-readable nav label shown in the admin sidebar */ + label: string; + /** Optional icon — emoji or inline SVG */ + icon?: string; + /** + * Admin permission required to view this page. Required, like + * {@link AdminRouteRegistration.permission}: every admin contribution says + * who may use it, so a page cannot be exposed to every authenticated admin + * by leaving a field out. + */ + permission: string; + /** GET handler for the page (a method, so a full Fresh context is accepted: see {@link PluginRouteContext}). */ + handler(ctx: PluginRouteContext): Promise | Response; +} + +/** + * A guarded admin route contributed by a plugin: the way to add a `POST`, `PUT`, + * `PATCH` or `DELETE` endpoint (or a `GET` API route that is not a sidebar page) + * under the admin prefix. + * + * Plugins contribute these to the `admin.routes` point: + * `api.capabilities.contribute(ADMIN_ROUTES, { method, path, permission, handler })`. + * The admin reads the list + * (`capabilities.list(ADMIN_ROUTES)`) and mounts each route behind its own + * authentication, with the CSRF check applied to every non-safe method and the + * declared `permission` enforced, so the handler only runs for a request that + * passed all three. A site without the admin mounts nothing. + * + * A route registered directly in a plugin's `mount()` is *not* behind the + * admin's middleware (mount order is registration order, and the admin mounts + * last), so it cannot use the admin's guards: contribute it here instead. + * + * @since 4.0.0 + */ +// deno-lint-ignore no-explicit-any +export interface AdminRouteRegistration { + /** HTTP method. Anything other than `GET` is subject to the CSRF check. */ + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + /** + * URL path relative to the admin prefix (must start with `/`). + * Example: `/my-plugin/save` registers at `/admin/my-plugin/save`. + */ + path: string; + /** + * Admin permission required (including ones the plugin declares through + * `authzActions`). Required, not optional: a mutation endpoint must say who + * may call it, so it cannot be mounted open to every authenticated admin by + * leaving a field out. + */ + permission: string; + /** Handler; runs only after authentication, CSRF and permission checks pass (a method, so a full Fresh context is accepted). */ + handler(ctx: PluginRouteContext): Promise | Response; +} + +/** + * Plugin-provided replacement for the built-in block editor. + * + * Provide it as the `admin.editor` capability: `api.capabilities.provide(ADMIN_EDITOR, () => impl)`. + * The edit route delegates to `pageEditorHandler` instead of the default block editor. + * + * Declared by the admin plugin, which is the only reader of the `admin.editor` slot; a plugin + * that provides one imports this type from `@dune/plugin-admin`. + * + * @since 4.0.0 + */ +export interface ContentEditorPlugin { + /** + * Handle `GET /admin/pages/edit?path=...`. + * + * Core passes the full Fresh context: annotate `ctx` as `FreshContext` and call + * `ctx.render(component)` to render within the admin layout, or return any + * `Response` directly. An unannotated `ctx` is a {@link PluginRouteContext}. + * Access `ctx.state.adminContext` for engine, config, and other admin services. + * The context state type is `AdminState` from `@dune/plugin-admin`; typed as + * `any` here so core does not depend on admin-internal types. + */ + pageEditorHandler(ctx: PluginRouteContext): Response | Promise; + + /** + * Optional WebSocket upgrade handler for real-time collaboration. + * + * When present, `GET /admin/api/content-editor/ws?path=...` delegates here + * after auth and path validation. Return a `101 Switching Protocols` response + * via `Deno.upgradeWebSocket`. When absent the WS endpoint responds 501. + */ + wsHandler?: ( + req: Request, + user: { id: string; name: string }, + ) => Response; +} + +/** A replacement page editor (slot). Consumer: the admin. */ +export const ADMIN_EDITOR: CapabilityKey = capabilityKey("admin.editor"); + +/** An extra admin page (contribution point). Consumer: the admin. */ +export const ADMIN_PAGES: CapabilityKey = capabilityKey("admin.pages"); + +/** + * A guarded admin route, any method, under the admin prefix (contribution point). + * Consumer: the admin, which applies authentication, CSRF and permission checks. + */ +export const ADMIN_ROUTES: CapabilityKey = capabilityKey("admin.routes"); diff --git a/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/manifest.gen.ts b/src/admin/manifest.gen.ts index 5c81817..8ed48e6 100644 --- a/src/admin/manifest.gen.ts +++ b/src/admin/manifest.gen.ts @@ -11,93 +11,92 @@ import * as r4 from "./routes/api/config/index.ts"; import * as r5 from "./routes/api/config/theme.ts"; import * as r6 from "./routes/api/config/theme-config.ts"; import * as r7 from "./routes/api/config/themes.ts"; -import * as r8 from "./routes/api/content/[...rest].ts"; -import * as r9 from "./routes/api/content-editor/ws.ts"; -import * as r10 from "./routes/api/csrf.ts"; -import * as r11 from "./routes/api/dashboard.ts"; -import * as r12 from "./routes/api/dev/apply.ts"; -import * as r13 from "./routes/api/editor/parse.ts"; -import * as r14 from "./routes/api/editor/serialize.ts"; -import * as r15 from "./routes/api/email-preview.ts"; -import * as r16 from "./routes/api/email-preview/[id].ts"; -import * as r17 from "./routes/api/flex/[type]/index.ts"; -import * as r18 from "./routes/api/flex/[type]/[id]/index.ts"; -import * as r19 from "./routes/api/history/[...rest].ts"; -import * as r20 from "./routes/api/i18n/memory/index.ts"; -import * as r21 from "./routes/api/i18n/memory/rebuild.ts"; -import * as r22 from "./routes/api/i18n/mt-status.ts"; -import * as r23 from "./routes/api/i18n/translate-page.ts"; -import * as r24 from "./routes/api/i18n/translate-segment.ts"; -import * as r25 from "./routes/api/inline-edit/presence.ts"; -import * as r26 from "./routes/api/introspect.ts"; -import * as r27 from "./routes/api/jobs.ts"; -import * as r28 from "./routes/api/jobs/[name]/run.ts"; -import * as r29 from "./routes/api/media/index.ts"; -import * as r30 from "./routes/api/media/meta.ts"; -import * as r31 from "./routes/api/media/upload.ts"; -import * as r32 from "./routes/api/metrics.ts"; -import * as r33 from "./routes/api/page-source.ts"; -import * as r34 from "./routes/api/pages/index.ts"; -import * as r35 from "./routes/api/pages/reorder.ts"; -import * as r36 from "./routes/api/pages/translate.ts"; -import * as r37 from "./routes/api/pages/[...path]/index.ts"; +import * as r8 from "./routes/api/content-editor/ws.ts"; +import * as r9 from "./routes/api/csrf.ts"; +import * as r10 from "./routes/api/dashboard.ts"; +import * as r11 from "./routes/api/dev/apply.ts"; +import * as r12 from "./routes/api/editor/parse.ts"; +import * as r13 from "./routes/api/editor/serialize.ts"; +import * as r14 from "./routes/api/email-preview.ts"; +import * as r15 from "./routes/api/email-preview/[id].ts"; +import * as r16 from "./routes/api/flex/[type]/index.ts"; +import * as r17 from "./routes/api/flex/[type]/[id]/index.ts"; +import * as r18 from "./routes/api/history/[...rest].ts"; +import * as r19 from "./routes/api/i18n/memory/index.ts"; +import * as r20 from "./routes/api/i18n/memory/rebuild.ts"; +import * as r21 from "./routes/api/i18n/mt-status.ts"; +import * as r22 from "./routes/api/i18n/translate-page.ts"; +import * as r23 from "./routes/api/i18n/translate-segment.ts"; +import * as r24 from "./routes/api/inline-edit/presence.ts"; +import * as r25 from "./routes/api/introspect.ts"; +import * as r26 from "./routes/api/jobs.ts"; +import * as r27 from "./routes/api/jobs/[name]/run.ts"; +import * as r28 from "./routes/api/media/index.ts"; +import * as r29 from "./routes/api/media/meta.ts"; +import * as r30 from "./routes/api/media/upload.ts"; +import * as r31 from "./routes/api/metrics.ts"; +import * as r32 from "./routes/api/page-source.ts"; +import * as r33 from "./routes/api/pages/index.ts"; +import * as r34 from "./routes/api/pages/reorder.ts"; +import * as r35 from "./routes/api/pages/translate.ts"; +import * as r36 from "./routes/api/pages/[...path]/comments/[id]/resolve.ts"; +import * as r37 from "./routes/api/pages/[...path]/comments/[id]/index.ts"; import * as r38 from "./routes/api/pages/[...path]/comments/index.ts"; -import * as r39 from "./routes/api/pages/[...path]/comments/[id]/index.ts"; -import * as r40 from "./routes/api/pages/[...path]/comments/[id]/resolve.ts"; -import * as r41 from "./routes/api/plugins/index.ts"; -import * as r42 from "./routes/api/plugins/install.ts"; -import * as r43 from "./routes/api/plugins/[name]/config.ts"; -import * as r44 from "./routes/api/preview.ts"; -import * as r45 from "./routes/api/registry/plugins.ts"; -import * as r46 from "./routes/api/registry/themes.ts"; -import * as r47 from "./routes/api/render-markdown.ts"; -import * as r48 from "./routes/api/search/analytics.ts"; -import * as r49 from "./routes/api/search/engines.ts"; -import * as r50 from "./routes/api/sections.ts"; +import * as r39 from "./routes/api/pages/[...path]/index.ts"; +import * as r40 from "./routes/api/plugins/index.ts"; +import * as r41 from "./routes/api/plugins/install.ts"; +import * as r42 from "./routes/api/plugins/[name]/config.ts"; +import * as r43 from "./routes/api/preview.ts"; +import * as r44 from "./routes/api/registry/plugins.ts"; +import * as r45 from "./routes/api/registry/themes.ts"; +import * as r46 from "./routes/api/render-markdown.ts"; +import * as r47 from "./routes/api/search/analytics.ts"; +import * as r48 from "./routes/api/search/engines.ts"; +import * as r49 from "./routes/api/sections.ts"; +import * as r50 from "./routes/api/staging/[...path]/publish.ts"; import * as r51 from "./routes/api/staging/[...path]/index.ts"; -import * as r52 from "./routes/api/staging/[...path]/publish.ts"; -import * as r53 from "./routes/api/theme-preview.ts"; -import * as r54 from "./routes/api/themes/install.ts"; -import * as r55 from "./routes/api/users/index.ts"; -import * as r56 from "./routes/api/users/[id]/index.ts"; -import * as r57 from "./routes/api/users/[id]/password.ts"; -import * as r58 from "./routes/api/webhooks/deliveries.ts"; -import * as r59 from "./routes/api/workflow/schedule/index.ts"; -import * as r60 from "./routes/api/workflow/schedule/[id].ts"; -import * as r61 from "./routes/api/workflow/scheduled/[...path].ts"; -import * as r62 from "./routes/api/workflow/stages.ts"; -import * as r63 from "./routes/api/workflow/status/[...path].ts"; -import * as r64 from "./routes/api/workflow/transition.ts"; -import * as r65 from "./routes/audit/index.tsx"; -import * as r66 from "./routes/collab/edit-ws.ts"; -import * as r67 from "./routes/collab/ws.ts"; -import * as r68 from "./routes/config/index.tsx"; -import * as r69 from "./routes/email-preview/index.tsx"; -import * as r70 from "./routes/flex/index.tsx"; -import * as r71 from "./routes/flex/[type]/index.tsx"; -import * as r72 from "./routes/flex/[type]/[id].tsx"; -import * as r73 from "./routes/i18n/index.tsx"; -import * as r74 from "./routes/i18n/memory.tsx"; -import * as r75 from "./routes/jobs/index.tsx"; -import * as r76 from "./routes/login.tsx"; -import * as r77 from "./routes/login/logout.ts"; -import * as r78 from "./routes/marketplace/index.tsx"; -import * as r79 from "./routes/media/index.tsx"; -import * as r80 from "./routes/metrics/index.tsx"; -import * as r81 from "./routes/pages/index.tsx"; -import * as r82 from "./routes/pages/builder.tsx"; -import * as r83 from "./routes/pages/edit.tsx"; -import * as r84 from "./routes/pages/history.tsx"; -import * as r85 from "./routes/plugins/index.tsx"; -import * as r86 from "./routes/search/index.tsx"; -import * as r87 from "./routes/submissions/index.tsx"; -import * as r88 from "./routes/submissions/[form]/index.tsx"; -import * as r89 from "./routes/submissions/[form]/[id]/index.tsx"; -import * as r90 from "./routes/submissions/[form]/[id]/delete.ts"; -import * as r91 from "./routes/submissions/[form]/[id]/files/[filename].ts"; -import * as r92 from "./routes/submissions/[form]/[id]/status.ts"; -import * as r93 from "./routes/themes/index.tsx"; -import * as r94 from "./routes/users/index.tsx"; +import * as r52 from "./routes/api/theme-preview.ts"; +import * as r53 from "./routes/api/themes/install.ts"; +import * as r54 from "./routes/api/users/index.ts"; +import * as r55 from "./routes/api/users/[id]/index.ts"; +import * as r56 from "./routes/api/users/[id]/password.ts"; +import * as r57 from "./routes/api/webhooks/deliveries.ts"; +import * as r58 from "./routes/api/workflow/schedule/index.ts"; +import * as r59 from "./routes/api/workflow/schedule/[id].ts"; +import * as r60 from "./routes/api/workflow/scheduled/[...path].ts"; +import * as r61 from "./routes/api/workflow/stages.ts"; +import * as r62 from "./routes/api/workflow/status/[...path].ts"; +import * as r63 from "./routes/api/workflow/transition.ts"; +import * as r64 from "./routes/audit/index.tsx"; +import * as r65 from "./routes/collab/edit-ws.ts"; +import * as r66 from "./routes/collab/ws.ts"; +import * as r67 from "./routes/config/index.tsx"; +import * as r68 from "./routes/email-preview/index.tsx"; +import * as r69 from "./routes/flex/index.tsx"; +import * as r70 from "./routes/flex/[type]/index.tsx"; +import * as r71 from "./routes/flex/[type]/[id].tsx"; +import * as r72 from "./routes/i18n/index.tsx"; +import * as r73 from "./routes/i18n/memory.tsx"; +import * as r74 from "./routes/jobs/index.tsx"; +import * as r75 from "./routes/login.tsx"; +import * as r76 from "./routes/login/logout.ts"; +import * as r77 from "./routes/marketplace/index.tsx"; +import * as r78 from "./routes/media/index.tsx"; +import * as r79 from "./routes/metrics/index.tsx"; +import * as r80 from "./routes/pages/index.tsx"; +import * as r81 from "./routes/pages/builder.tsx"; +import * as r82 from "./routes/pages/edit.tsx"; +import * as r83 from "./routes/pages/history.tsx"; +import * as r84 from "./routes/plugins/index.tsx"; +import * as r85 from "./routes/search/index.tsx"; +import * as r86 from "./routes/submissions/index.tsx"; +import * as r87 from "./routes/submissions/[form]/index.tsx"; +import * as r88 from "./routes/submissions/[form]/[id]/index.tsx"; +import * as r89 from "./routes/submissions/[form]/[id]/delete.ts"; +import * as r90 from "./routes/submissions/[form]/[id]/files/[filename].ts"; +import * as r91 from "./routes/submissions/[form]/[id]/status.ts"; +import * as r92 from "./routes/themes/index.tsx"; +import * as r93 from "./routes/users/index.tsx"; /** Shape of an fs-convention route module (subset Fresh cares about). */ export interface AdminRouteModule { @@ -125,93 +124,92 @@ export const adminRoutes: AdminRouteEntry[] = [ { pattern: "/api/config/theme", mod: r5 }, { pattern: "/api/config/theme-config", mod: r6 }, { pattern: "/api/config/themes", mod: r7 }, - { pattern: "/api/content/:rest*", mod: r8 }, - { pattern: "/api/content-editor/ws", mod: r9 }, - { pattern: "/api/csrf", mod: r10 }, - { pattern: "/api/dashboard", mod: r11 }, - { pattern: "/api/dev/apply", mod: r12 }, - { pattern: "/api/editor/parse", mod: r13 }, - { pattern: "/api/editor/serialize", mod: r14 }, - { pattern: "/api/email-preview", mod: r15 }, - { pattern: "/api/email-preview/:id", mod: r16 }, - { pattern: "/api/flex/:type", mod: r17 }, - { pattern: "/api/flex/:type/:id", mod: r18 }, - { pattern: "/api/history/:rest*", mod: r19 }, - { pattern: "/api/i18n/memory", mod: r20 }, - { pattern: "/api/i18n/memory/rebuild", mod: r21 }, - { pattern: "/api/i18n/mt-status", mod: r22 }, - { pattern: "/api/i18n/translate-page", mod: r23 }, - { pattern: "/api/i18n/translate-segment", mod: r24 }, - { pattern: "/api/inline-edit/presence", mod: r25 }, - { pattern: "/api/introspect", mod: r26 }, - { pattern: "/api/jobs", mod: r27 }, - { pattern: "/api/jobs/:name/run", mod: r28 }, - { pattern: "/api/media", mod: r29 }, - { pattern: "/api/media/meta", mod: r30 }, - { pattern: "/api/media/upload", mod: r31 }, - { pattern: "/api/metrics", mod: r32 }, - { pattern: "/api/page-source", mod: r33 }, - { pattern: "/api/pages", mod: r34 }, - { pattern: "/api/pages/reorder", mod: r35 }, - { pattern: "/api/pages/translate", mod: r36 }, - { pattern: "/api/pages/:path*", mod: r37 }, + { pattern: "/api/content-editor/ws", mod: r8 }, + { pattern: "/api/csrf", mod: r9 }, + { pattern: "/api/dashboard", mod: r10 }, + { pattern: "/api/dev/apply", mod: r11 }, + { pattern: "/api/editor/parse", mod: r12 }, + { pattern: "/api/editor/serialize", mod: r13 }, + { pattern: "/api/email-preview", mod: r14 }, + { pattern: "/api/email-preview/:id", mod: r15 }, + { pattern: "/api/flex/:type", mod: r16 }, + { pattern: "/api/flex/:type/:id", mod: r17 }, + { pattern: "/api/history/:rest*", mod: r18 }, + { pattern: "/api/i18n/memory", mod: r19 }, + { pattern: "/api/i18n/memory/rebuild", mod: r20 }, + { pattern: "/api/i18n/mt-status", mod: r21 }, + { pattern: "/api/i18n/translate-page", mod: r22 }, + { pattern: "/api/i18n/translate-segment", mod: r23 }, + { pattern: "/api/inline-edit/presence", mod: r24 }, + { pattern: "/api/introspect", mod: r25 }, + { pattern: "/api/jobs", mod: r26 }, + { pattern: "/api/jobs/:name/run", mod: r27 }, + { pattern: "/api/media", mod: r28 }, + { pattern: "/api/media/meta", mod: r29 }, + { pattern: "/api/media/upload", mod: r30 }, + { pattern: "/api/metrics", mod: r31 }, + { pattern: "/api/page-source", mod: r32 }, + { pattern: "/api/pages", mod: r33 }, + { pattern: "/api/pages/reorder", mod: r34 }, + { pattern: "/api/pages/translate", mod: r35 }, + { pattern: "/api/pages/:path*/comments/:id/resolve", mod: r36 }, + { pattern: "/api/pages/:path*/comments/:id", mod: r37 }, { pattern: "/api/pages/:path*/comments", mod: r38 }, - { pattern: "/api/pages/:path*/comments/:id", mod: r39 }, - { pattern: "/api/pages/:path*/comments/:id/resolve", mod: r40 }, - { pattern: "/api/plugins", mod: r41 }, - { pattern: "/api/plugins/install", mod: r42 }, - { pattern: "/api/plugins/:name/config", mod: r43 }, - { pattern: "/api/preview", mod: r44 }, - { pattern: "/api/registry/plugins", mod: r45 }, - { pattern: "/api/registry/themes", mod: r46 }, - { pattern: "/api/render-markdown", mod: r47 }, - { pattern: "/api/search/analytics", mod: r48 }, - { pattern: "/api/search/engines", mod: r49 }, - { pattern: "/api/sections", mod: r50 }, + { pattern: "/api/pages/:path*", mod: r39 }, + { pattern: "/api/plugins", mod: r40 }, + { pattern: "/api/plugins/install", mod: r41 }, + { pattern: "/api/plugins/:name/config", mod: r42 }, + { pattern: "/api/preview", mod: r43 }, + { pattern: "/api/registry/plugins", mod: r44 }, + { pattern: "/api/registry/themes", mod: r45 }, + { pattern: "/api/render-markdown", mod: r46 }, + { pattern: "/api/search/analytics", mod: r47 }, + { pattern: "/api/search/engines", mod: r48 }, + { pattern: "/api/sections", mod: r49 }, + { pattern: "/api/staging/:path*/publish", mod: r50 }, { pattern: "/api/staging/:path*", mod: r51 }, - { pattern: "/api/staging/:path*/publish", mod: r52 }, - { pattern: "/api/theme-preview", mod: r53 }, - { pattern: "/api/themes/install", mod: r54 }, - { pattern: "/api/users", mod: r55 }, - { pattern: "/api/users/:id", mod: r56 }, - { pattern: "/api/users/:id/password", mod: r57 }, - { pattern: "/api/webhooks/deliveries", mod: r58 }, - { pattern: "/api/workflow/schedule", mod: r59 }, - { pattern: "/api/workflow/schedule/:id", mod: r60 }, - { pattern: "/api/workflow/scheduled/:path*", mod: r61 }, - { pattern: "/api/workflow/stages", mod: r62 }, - { pattern: "/api/workflow/status/:path*", mod: r63 }, - { pattern: "/api/workflow/transition", mod: r64 }, - { pattern: "/audit", mod: r65 }, - { pattern: "/collab/edit-ws", mod: r66 }, - { pattern: "/collab/ws", mod: r67 }, - { pattern: "/config", mod: r68 }, - { pattern: "/email-preview", mod: r69 }, - { pattern: "/flex", mod: r70 }, - { pattern: "/flex/:type", mod: r71 }, - { pattern: "/flex/:type/:id", mod: r72 }, - { pattern: "/i18n", mod: r73 }, - { pattern: "/i18n/memory", mod: r74 }, - { pattern: "/jobs", mod: r75 }, - { pattern: "/login", mod: r76 }, - { pattern: "/login/logout", mod: r77 }, - { pattern: "/marketplace", mod: r78 }, - { pattern: "/media", mod: r79 }, - { pattern: "/metrics", mod: r80 }, - { pattern: "/pages", mod: r81 }, - { pattern: "/pages/builder", mod: r82 }, - { pattern: "/pages/edit", mod: r83 }, - { pattern: "/pages/history", mod: r84 }, - { pattern: "/plugins", mod: r85 }, - { pattern: "/search", mod: r86 }, - { pattern: "/submissions", mod: r87 }, - { pattern: "/submissions/:form", mod: r88 }, - { pattern: "/submissions/:form/:id", mod: r89 }, - { pattern: "/submissions/:form/:id/delete", mod: r90 }, - { pattern: "/submissions/:form/:id/files/:filename", mod: r91 }, - { pattern: "/submissions/:form/:id/status", mod: r92 }, - { pattern: "/themes", mod: r93 }, - { pattern: "/users", mod: r94 }, + { pattern: "/api/theme-preview", mod: r52 }, + { pattern: "/api/themes/install", mod: r53 }, + { pattern: "/api/users", mod: r54 }, + { pattern: "/api/users/:id", mod: r55 }, + { pattern: "/api/users/:id/password", mod: r56 }, + { pattern: "/api/webhooks/deliveries", mod: r57 }, + { pattern: "/api/workflow/schedule", mod: r58 }, + { pattern: "/api/workflow/schedule/:id", mod: r59 }, + { pattern: "/api/workflow/scheduled/:path*", mod: r60 }, + { pattern: "/api/workflow/stages", mod: r61 }, + { pattern: "/api/workflow/status/:path*", mod: r62 }, + { pattern: "/api/workflow/transition", mod: r63 }, + { pattern: "/audit", mod: r64 }, + { pattern: "/collab/edit-ws", mod: r65 }, + { pattern: "/collab/ws", mod: r66 }, + { pattern: "/config", mod: r67 }, + { pattern: "/email-preview", mod: r68 }, + { pattern: "/flex", mod: r69 }, + { pattern: "/flex/:type", mod: r70 }, + { pattern: "/flex/:type/:id", mod: r71 }, + { pattern: "/i18n", mod: r72 }, + { pattern: "/i18n/memory", mod: r73 }, + { pattern: "/jobs", mod: r74 }, + { pattern: "/login", mod: r75 }, + { pattern: "/login/logout", mod: r76 }, + { pattern: "/marketplace", mod: r77 }, + { pattern: "/media", mod: r78 }, + { pattern: "/metrics", mod: r79 }, + { pattern: "/pages", mod: r80 }, + { pattern: "/pages/builder", mod: r81 }, + { pattern: "/pages/edit", mod: r82 }, + { pattern: "/pages/history", mod: r83 }, + { pattern: "/plugins", mod: r84 }, + { pattern: "/search", mod: r85 }, + { pattern: "/submissions", mod: r86 }, + { pattern: "/submissions/:form", mod: r87 }, + { pattern: "/submissions/:form/:id", mod: r88 }, + { pattern: "/submissions/:form/:id/delete", mod: r89 }, + { pattern: "/submissions/:form/:id/files/:filename", mod: r90 }, + { pattern: "/submissions/:form/:id/status", mod: r91 }, + { pattern: "/themes", mod: r92 }, + { pattern: "/users", mod: r93 }, ]; /** Island files under src/admin/islands/, for Builder.registerIsland(). */ diff --git a/src/admin/mod.ts b/src/admin/mod.ts index 4ababa4..876d911 100644 --- a/src/admin/mod.ts +++ b/src/admin/mod.ts @@ -14,19 +14,12 @@ export type { UserManager, UserManagerConfig, CreateUserInput } from "./auth/use export { createAuthMiddleware } from "./auth/middleware.ts"; export type { AuthMiddleware, AuthMiddlewareConfig } from "./auth/middleware.ts"; -export { hashPassword, verifyPassword, DUMMY_HASH } from "@dune/core/auth/passwords"; - export type { AuthProvider, AuthProviderUser, 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 { @@ -41,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-audit.ts b/src/admin/page-audit.ts new file mode 100644 index 0000000..74959ef --- /dev/null +++ b/src/admin/page-audit.ts @@ -0,0 +1,55 @@ +/** + * Audit page writes that did not come through an admin route. + * + * The admin's own routes (create, update, delete) write their audit entry + * themselves, with the request's IP and user agent. Whoever else writes a page + * (the inline-edit bar, a plugin, MCP) fires the same page event with the + * person's name as `actor`, so this listener gives those writes an entry too. + * Events the admin's routes announced carry the {@link FROM_ADMIN_ROUTE} mark + * and are skipped, so nothing is logged twice. Entries from here have no IP or + * user agent: the event does not carry them. + * + * @module + */ + +import type { HookRegistry } from "@dune/core/hooks"; +import type { AuditEventType, AuditLogger } from "../audit/mod.ts"; + +/** + * Set (non-enumerably, so it never reaches a webhook body) on the payload of + * an event an admin route announced, which that route has already audited. + */ +export const FROM_ADMIN_ROUTE: unique symbol = Symbol("dune.admin.fromRoute"); + +const EVENTS: Array<[event: "onPageCreate" | "onPageUpdate" | "onPageDelete", audit: AuditEventType]> = [ + ["onPageCreate", "page.create"], + ["onPageUpdate", "page.update"], + ["onPageDelete", "page.delete"], +]; + +/** Audit page create, update and delete events that no admin route announced. */ +export function listenForPageAudit(opts: { + hooks: Pick; + audit: () => Pick | null | undefined; +}): void { + for (const [event, auditEvent] of EVENTS) { + opts.hooks.on(event, ({ data }) => { + const payload = data as { sourcePath?: string; actor?: string; [FROM_ADMIN_ROUTE]?: true }; + if (payload[FROM_ADMIN_ROUTE]) return; + const logger = opts.audit(); + if (!logger || typeof payload.sourcePath !== "string") return; + const actor = typeof payload.actor === "string" && payload.actor !== "" + ? { userId: payload.actor, username: payload.actor, name: payload.actor } + : null; + void logger.log({ + event: auditEvent, + actor, + ip: null, + userAgent: null, + target: { type: "page", id: payload.sourcePath }, + detail: { via: "page event" }, + outcome: "success", + }).catch(() => {}); + }); + } +} diff --git a/src/admin/page-events.ts b/src/admin/page-events.ts new file mode 100644 index 0000000..56ea470 --- /dev/null +++ b/src/admin/page-events.ts @@ -0,0 +1,38 @@ +/** + * Announce a page write to plugins. + * + * Whoever writes a page fires its event: core never does it for the writer, and + * search indexes, webhooks and audit plugins only learn of a write through it. + * Every admin route that creates, rewrites, deletes or moves a page calls this + * after the file is on disk, naming the signed-in user as `actor`. The admin's + * own content webhooks are one of the listeners (registered in `mount()`), so + * they also see writes made elsewhere. Fire-and-forget: a failing handler never + * fails the request. + * + * @module + */ + +import type { FreshContext } from "fresh"; +import type { AdminState } from "./types.ts"; +import { FROM_ADMIN_ROUTE } from "./page-audit.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 the hook handlers, with the signed-in user as `actor`. */ +export function announcePageEvent(ctx: FreshContext, e: PageEvent): void { + const { hooks } = ctx.state.adminContext; + const user = ctx.state.auth?.user; + const actor = user ? (user.username ?? user.name ?? user.id) : undefined; + // The route that announces this writes its own audit entry (with IP and user agent); + // the mark stops the page-audit listener from adding a second one. + const payload = actor ? { ...e.payload, actor } : { ...e.payload }; + Object.defineProperty(payload, FROM_ADMIN_ROUTE, { value: true, enumerable: false }); + hooks?.fire(e.event, payload).catch((err: unknown) => + console.warn(`[dune] ${e.event} hook failed: ${err instanceof Error ? err.message : err}`) + ); +} diff --git a/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 f0a34cd..919be47 100644 --- a/src/admin/routes/api/_utils.ts +++ b/src/admin/routes/api/_utils.ts @@ -6,7 +6,6 @@ import type { FreshContext } from "fresh"; import type { AdminPermission, AdminState } from "../../types.ts"; import { highestValidRole } from "../../auth/role-utils.ts"; import { csrfTokenMatches, resolveCsrfSecret } from "../../auth/csrf.ts"; -import { clientIp } from "@dune/core/security"; // json()/serverError()/typed error classes live in ../../http.ts so the // public API (public-api.ts) and admin routes share one implementation. @@ -29,7 +28,7 @@ function logAuthzDenial( void auditLogger.log({ event, actor: actorFromAuth(ctx.state.auth ?? {}), - ip: getClientIp(ctx.req), + ip: getClientIp(ctx), userAgent: ctx.req.headers.get("user-agent") ?? null, target: { type: "route", id: ctx.url.pathname }, detail, @@ -129,7 +128,7 @@ export function csrfCheck(ctx: FreshContext): Response | null { * flat table" dec-auth-storage said should be fully replaced, not coexist * with). * - * `admin.authzStore` defaults to `"local"` and `authz` is created whenever + * `admin.authz_store` defaults to `"local"` and `authz` is created whenever * the admin panel is enabled, regardless of `site.auth`'s mode — see its * doc comment in `src/config/admin-config.ts`. `authz` being undefined * here means its creation itself failed at startup (already logged loudly @@ -149,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" }, }); } @@ -183,7 +182,7 @@ export function requireTsxWrite( ): Response | null { if (!isTsxSource(formatOrPath)) return null; const allowedRoles: string[] = - ctx.state.adminContext.config.system.content.allowTsxFormat ?? ["admin"]; + ctx.state.adminContext.config.system.content.allow_tsx_format ?? ["admin"]; const userRoles = ctx.state.auth?.user?.roles ?? []; if (allowedRoles.length === 0 || !userRoles.some((r) => allowedRoles.includes(r))) { return json({ @@ -296,8 +295,8 @@ export function validatePagePath(p: string): boolean { * attribution. Callers that need the raw claimed IP should read the headers * themselves and record them as untrusted metadata. */ -export function getClientIp(req: Request): string | null { - const ip = clientIp(req, { trustForwardedFor: false }); +export function getClientIp(ctx: FreshContext): string | null { + const ip = ctx.state.adminContext.services.security.clientIp(ctx.req, { trustForwardedFor: false }); return ip === "unknown" ? null : ip; } @@ -305,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/config/index.ts b/src/admin/routes/api/config/index.ts index bd6dba0..285df8f 100644 --- a/src/admin/routes/api/config/index.ts +++ b/src/admin/routes/api/config/index.ts @@ -3,7 +3,6 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, json, serverError, csrfCheck } from "../_utils.ts"; import { stringify as stringifyYaml } from "@std/yaml"; -import { parseUserYaml as parseYaml } from "@dune/core/security"; import type { FreshContext } from "fresh"; export const handler = { @@ -21,7 +20,7 @@ export const handler = { const denied = await requirePermission(ctx, "config.update"); if (denied) return denied; - const { engine, storage } = ctx.state.adminContext; + const { engine, storage, services } = ctx.state.adminContext; try { const body = await ctx.req.json() as { title?: string; description?: string; url?: string; @@ -32,7 +31,7 @@ export const handler = { // Read-modify-write config/site.yaml (same approach as switchTheme) const existingRaw = await storage.readText("config/site.yaml").catch(() => ""); - const existing = ((parseYaml(existingRaw || "") ?? {}) as Record); + const existing = ((services.security.parseUserYaml(existingRaw || "") ?? {}) as Record); if (body.title !== undefined) existing.title = body.title; if (body.description !== undefined) existing.description = body.description; diff --git a/src/admin/routes/api/config/theme-config.ts b/src/admin/routes/api/config/theme-config.ts index 20b9efb..6902ec0 100644 --- a/src/admin/routes/api/config/theme-config.ts +++ b/src/admin/routes/api/config/theme-config.ts @@ -24,7 +24,7 @@ export const handler = { const denied = await requirePermission(ctx, "config.update"); if (denied) return denied; - const { engine, storage, config, hooks } = ctx.state.adminContext; + const { engine, storage, hooks } = ctx.state.adminContext; try { const body = await ctx.req.json() as Record; const manifest = engine.themes.theme.manifest; @@ -58,7 +58,7 @@ export const handler = { } } - const dataDir = config.admin?.dataDir ?? "data"; + const dataDir = ctx.state.adminContext.services.paths.dataDir; const themeConfigPath = `${dataDir}/theme-config.json`; const themeName = engine.config.theme.name; diff --git a/src/admin/routes/api/content/[...rest].ts b/src/admin/routes/api/content/[...rest].ts deleted file mode 100644 index 5a1285c..0000000 --- a/src/admin/routes/api/content/[...rest].ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Inline editing content API (v0.16+). - * - * GET /admin/api/content/:encodedSourcePath/source - * Return the raw Markdown source of the content file (body only, no - * frontmatter). Used by the auto-overlay textarea editor to pre-populate - * the edit area with the current content. - * Response: `{ body: "# Heading\n..." }` - * - * POST /admin/api/content/:encodedSourcePath/commit - * Flush the live editing state to a history revision and write committed - * Markdown back to the `.md` file. This is the "Save" action. - * - * PATCH /admin/api/content/:encodedSourcePath/fields - * Update individual frontmatter fields without touching the Markdown body. - * Body: `{ fields: { title: "New title", date: "2026-06-08", ... } }` - * Special key `__body` replaces the Markdown body (used by auto-overlay). - * - * In all routes `:encodedSourcePath` is the URL-encoded page source path, - * e.g. `pages%2Fabout%2Fdefault.md`. - */ - -import type { AdminState } from "../../../types.ts"; -import { requirePermission, requireTsxWrite, requireOwnedPage, json, serverError, csrfCheck, validatePagePath } from "../_utils.ts"; -import { splitFile } from "../../../../collab/manager.ts"; -import type { FreshContext } from "fresh"; - -export const handler = { - // ── GET /admin/api/content/:path/source ───────────────────────────────────── - async GET(ctx: FreshContext) { - const denied = await requirePermission(ctx, "pages.read"); - if (denied) return denied; - - const rest = ctx.params.rest; - const parts = rest.split("/"); - - if (parts.length !== 2 || parts[1] !== "source") { - return json({ error: "Invalid route" }, 400); - } - - const sourcePath = decodeURIComponent(parts[0]); - if (!validatePagePath(sourcePath)) return json({ error: "Invalid path" }, 400); - - const { storage, config } = ctx.state.adminContext; - const contentDir = config.system.content.dir; - const filePath = `${contentDir}/${sourcePath}`; - - try { - const raw = await storage.readText(filePath); - const { body } = splitFile(raw); - return json({ body }); - } catch (err) { - return serverError(err); - } - }, - - // ── POST /admin/api/content/:path/commit ──────────────────────────────────── - async POST(ctx: FreshContext) { - const denied = await requirePermission(ctx, "pages.update"); - if (denied) return denied; - - const csrfErr = await csrfCheck(ctx); - if (csrfErr) return csrfErr; - - const { inlineEdit } = ctx.state.adminContext; - if (!inlineEdit) return json({ error: "Inline editing not enabled" }, 501); - - // rest = "encodedPath/commit" or "encodedPath/fields" - const rest = ctx.params.rest; - const parts = rest.split("/"); - - if (parts.length !== 2) { - return json({ error: "Invalid route" }, 400); - } - - const sourcePath = decodeURIComponent(parts[0]); - if (!validatePagePath(sourcePath)) return json({ error: "Invalid path" }, 400); - const tsxDenied = requireTsxWrite(ctx, sourcePath); - if (tsxDenied) return tsxDenied; - const ownerDenied = await requireOwnedPage(ctx, sourcePath); - if (ownerDenied) return ownerDenied; - - const action = parts[1]; - if (action !== "commit") { - return json({ error: "Use PATCH for field updates" }, 405); - } - - const authResult = ctx.state.auth; - const author = authResult?.user?.username ?? "unknown"; - - try { - await inlineEdit.commit(sourcePath, author); - return json({ ok: true }); - } catch (err) { - return serverError(err); - } - }, - - // ── PATCH /admin/api/content/:path/fields ─────────────────────────────────── - async PATCH(ctx: FreshContext) { - const denied = await requirePermission(ctx, "pages.update"); - if (denied) return denied; - - const csrfErr = await csrfCheck(ctx); - if (csrfErr) return csrfErr; - - const { inlineEdit } = ctx.state.adminContext; - if (!inlineEdit) return json({ error: "Inline editing not enabled" }, 501); - - const rest = ctx.params.rest; - const parts = rest.split("/"); - - if (parts.length !== 2) { - return json({ error: "Invalid route" }, 400); - } - - const sourcePath = decodeURIComponent(parts[0]); - if (!validatePagePath(sourcePath)) return json({ error: "Invalid path" }, 400); - const tsxDenied = requireTsxWrite(ctx, sourcePath); - if (tsxDenied) return tsxDenied; - const ownerDenied = await requireOwnedPage(ctx, sourcePath); - if (ownerDenied) return ownerDenied; - - const action = parts[1]; - if (action !== "fields") { - return json({ error: "Use POST for commit" }, 405); - } - - let body: unknown; - try { - body = await ctx.req.json(); - } catch { - return json({ error: "Invalid JSON body" }, 400); - } - - if ( - !body || - typeof body !== "object" || - !("fields" in body) || - typeof (body as { fields: unknown }).fields !== "object" - ) { - return json({ error: "Body must be { fields: { ... } }" }, 400); - } - - const fields = (body as { fields: Record }).fields; - - const authResult = ctx.state.auth; - const author = authResult?.user?.username ?? "unknown"; - - try { - await inlineEdit.patchFields(sourcePath, fields, author); - return json({ ok: true }); - } catch (err) { - return serverError(err); - } - }, -}; diff --git a/src/admin/routes/api/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 306a36f..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?.runtimeDir ?? ".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 ef74c88..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?.runtimeDir ?? ".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/index.ts b/src/admin/routes/api/media/index.ts index 6808eb1..9e5949b 100644 --- a/src/admin/routes/api/media/index.ts +++ b/src/admin/routes/api/media/index.ts @@ -86,7 +86,7 @@ export const handler = { void auditLogger?.log({ event: "media.delete", actor: actorFromAuth(ctx.state.auth), - ip: getClientIp(ctx.req), + ip: getClientIp(ctx), userAgent: ctx.req.headers.get("user-agent") ?? null, target: { type: "media", id: name }, detail: {}, diff --git a/src/admin/routes/api/media/meta.ts b/src/admin/routes/api/media/meta.ts index a25cf2c..d6aaff4 100644 --- a/src/admin/routes/api/media/meta.ts +++ b/src/admin/routes/api/media/meta.ts @@ -4,7 +4,6 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, json, serverError, csrfCheck } from "../_utils.ts"; import { dirname } from "@std/path"; import { stringify as stringifyYaml } from "@std/yaml"; -import { parseUserYaml as parseYaml } from "@dune/core/security"; import type { FreshContext } from "fresh"; export const handler = { @@ -14,7 +13,7 @@ export const handler = { const denied = await requirePermission(ctx, "media.upload"); if (denied) return denied; - const { storage, config } = ctx.state.adminContext; + const { storage, config, services } = ctx.state.adminContext; try { const body = await ctx.req.json(); const { pagePath, name, focal } = body; @@ -48,7 +47,7 @@ export const handler = { let existing: Record = {}; try { const raw = await storage.read(sidecarPath); - existing = (parseYaml(new TextDecoder().decode(raw)) as Record) ?? {}; + existing = (services.security.parseUserYaml(new TextDecoder().decode(raw)) as Record) ?? {}; } catch { return json({ ok: true }); } delete existing.focal; if (Object.keys(existing).length === 0) { @@ -60,7 +59,7 @@ export const handler = { let existing: Record = {}; try { const raw = await storage.read(sidecarPath); - existing = (parseYaml(new TextDecoder().decode(raw)) as Record) ?? {}; + existing = (services.security.parseUserYaml(new TextDecoder().decode(raw)) as Record) ?? {}; } catch { /* new sidecar */ } existing.focal = focal; await storage.write(sidecarPath, new TextEncoder().encode(stringifyYaml(existing))); diff --git a/src/admin/routes/api/media/upload.ts b/src/admin/routes/api/media/upload.ts index 7c1d0d1..809f019 100644 --- a/src/admin/routes/api/media/upload.ts +++ b/src/admin/routes/api/media/upload.ts @@ -5,8 +5,9 @@ import { requirePermission, requireOwnedPage, json, serverError, actorFromAuth, import { dirname } from "@std/path"; import { isMediaFile, dirPathToRoute } from "@dune/core/content/path-utils"; import { getMimeType } from "@dune/core/content/page-loader"; -import { checkBodySize, limitedBody, BodyTooLargeError } from "@dune/core/security"; +import { isDuneError } from "@dune/core/plugin-api"; import type { FreshContext } from "fresh"; +import { adminSettings } from "../../../settings.ts"; export const handler = { async POST(ctx: FreshContext) { @@ -15,17 +16,17 @@ export const handler = { const denied = await requirePermission(ctx, "media.upload"); if (denied) return denied; - const { storage, config, auditLogger } = ctx.state.adminContext; + const { storage, config, auditLogger, services } = ctx.state.adminContext; const MAX_FILE_SIZE = 50 * 1024 * 1024; - const maxBodyBytes = (config.admin?.maxUploadMb ?? 100) * 1024 * 1024; + const maxBodyBytes = adminSettings(config).max_upload_mb * 1024 * 1024; try { - const tooLarge = checkBodySize(ctx.req, maxBodyBytes); + const tooLarge = services.security.checkBodySize(ctx.req, maxBodyBytes); if (tooLarge) return tooLarge; // Also enforce via streaming counter — catches chunked uploads that // omit Content-Length and would otherwise bypass the pre-check above. - const formData = await new Response(limitedBody(ctx.req.body, maxBodyBytes), { + const formData = await new Response(services.security.limitedBody(ctx.req.body, maxBodyBytes), { headers: { "content-type": ctx.req.headers.get("content-type") ?? "" }, }).formData(); const file = formData.get("file"); @@ -60,7 +61,7 @@ export const handler = { void auditLogger?.log({ event: "media.upload", actor: actorFromAuth(ctx.state.auth), - ip: getClientIp(ctx.req), + ip: getClientIp(ctx), userAgent: ctx.req.headers.get("user-agent") ?? null, target: { type: "media", id: safeName }, detail: {}, @@ -69,7 +70,7 @@ export const handler = { return json({ ok: true, item: { name: safeName, url, type: mimeType, size: bytes.length, pagePath } }); } catch (err) { - if (err instanceof BodyTooLargeError) { + if (isDuneError(err, "body_too_large")) { return json({ error: "Request too large" }, 413); } return serverError(err); diff --git a/src/admin/routes/api/pages/[...path]/index.ts b/src/admin/routes/api/pages/[...path]/index.ts index 1cf84d2..e9121c5 100644 --- a/src/admin/routes/api/pages/[...path]/index.ts +++ b/src/admin/routes/api/pages/[...path]/index.ts @@ -12,17 +12,16 @@ import type { AdminState } from "../../../../types.ts"; import { requirePermission, requireTsxWrite, requirePageOwner, requireOwnedPage, pageOwnerId, json, serverError, actorFromAuth, getClientIp, csrfCheck, validatePagePath } from "../../_utils.ts"; import { stringify as stringifyYaml } from "@std/yaml"; -import { parseUserYaml as parseYaml } from "@dune/core/security"; -import { resolveBlueprint, validateFrontmatter } from "@dune/core/blueprints"; -import { fireContentWebhooks } from "../../../../../admin/webhooks.ts"; +import { announcePageEvent } from "../../../../page-events.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" }); @@ -52,10 +51,7 @@ export const handler = { // nothing for every site with blueprints configured. Resolve // inheritance (`extends`) the same way PUT's validateFrontmatter() // does, rather than sending the raw unresolved definition. - const rawBlueprint = engine.blueprints[page.template]; - const blueprint = rawBlueprint - ? resolveBlueprint(page.template, rawBlueprint, engine.blueprints, 0) - : null; + const blueprint = ctx.state.adminContext.services.blueprints.resolve(page.template); return json({ sourcePath: page.sourcePath, route: page.route, format: page.format, template: page.template, frontmatter: page.frontmatter, rawContent: page.rawContent, @@ -73,7 +69,7 @@ export const handler = { const denied = await requirePermission(ctx, "pages.update"); if (denied) return denied; - const { engine, storage, config, hooks, auditLogger } = ctx.state.adminContext; + const { engine, storage, config, auditLogger, services } = ctx.state.adminContext; const authResult = ctx.state.auth; const pagePath = decodeURIComponent(ctx.params.path); if (!validatePagePath(pagePath)) return json({ error: "Invalid path" }, 400); @@ -93,7 +89,7 @@ export const handler = { let raw = new TextDecoder().decode(existing); const existingFmMatch = raw.match(/^---\n([\s\S]*?)\n---/); const existingFm = existingFmMatch - ? (parseYaml(existingFmMatch[1]) as Record ?? {}) + ? (services.security.parseUserYaml(existingFmMatch[1]) as Record ?? {}) : {}; const ownerDenied = requirePageOwner(ctx, pageOwnerId(existingFm)); if (ownerDenied) return ownerDenied; @@ -114,8 +110,9 @@ export const handler = { else delete mergedFm.createdBy; const template = (mergedFm.template as string) ?? page.template; - if (engine.blueprints[template]) { - const errors = validateFrontmatter(mergedFm as PageFrontmatter, template, engine.blueprints); + const { blueprints } = ctx.state.adminContext.services; + if (blueprints.has(template)) { + const errors = blueprints.validate(mergedFm as PageFrontmatter, template); if (errors.length > 0) { return json({ error: "Blueprint validation failed", validationErrors: errors }, 422); } @@ -133,15 +130,12 @@ export const handler = { await engine.rebuild(); await maybeGitCommit(filePath, pagePath, undefined, config); - const webhookEndpoints = config.admin?.webhooks ?? []; - const runtimeDir = config.admin?.runtimeDir ?? ".dune/admin"; - if (hooks) hooks.fire("onPageUpdate", { sourcePath: page.sourcePath }).catch(() => {}); - fireContentWebhooks(webhookEndpoints, "onPageUpdate", { sourcePath: page.sourcePath }, runtimeDir); + announcePageEvent(ctx, { event: "onPageUpdate", payload: { sourcePath: page.sourcePath } }); void auditLogger?.log({ event: "page.update", actor: actorFromAuth(authResult), - ip: getClientIp(ctx.req), + ip: getClientIp(ctx), userAgent: ctx.req.headers.get("user-agent") ?? null, target: { type: "page", id: pagePath }, detail: {}, @@ -160,7 +154,7 @@ export const handler = { const denied = await requirePermission(ctx, "pages.delete"); if (denied) return denied; - const { engine, storage, config, hooks, auditLogger } = ctx.state.adminContext; + const { engine, storage, config, auditLogger } = ctx.state.adminContext; const authResult = ctx.state.auth; const pagePath = decodeURIComponent(ctx.params.path); if (!validatePagePath(pagePath)) return json({ error: "Invalid path" }, 400); @@ -175,15 +169,12 @@ export const handler = { await storage.delete(`${contentDir}/${page.sourcePath}`); await engine.rebuild(); - const webhookEndpoints = config.admin?.webhooks ?? []; - const runtimeDir = config.admin?.runtimeDir ?? ".dune/admin"; - if (hooks) hooks.fire("onPageDelete", { sourcePath: page.sourcePath }).catch(() => {}); - fireContentWebhooks(webhookEndpoints, "onPageDelete", { sourcePath: page.sourcePath }, runtimeDir); + announcePageEvent(ctx, { event: "onPageDelete", payload: { sourcePath: page.sourcePath } }); void auditLogger?.log({ event: "page.delete", actor: actorFromAuth(authResult), - ip: getClientIp(ctx.req), + ip: getClientIp(ctx), userAgent: ctx.req.headers.get("user-agent") ?? null, target: { type: "page", id: pagePath }, detail: {}, diff --git a/src/admin/routes/api/pages/index.ts b/src/admin/routes/api/pages/index.ts index 675bda5..9b08eab 100644 --- a/src/admin/routes/api/pages/index.ts +++ b/src/admin/routes/api/pages/index.ts @@ -2,7 +2,7 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, requireTsxWrite, json, serverError, actorFromAuth, getClientIp, validatePagePath, csrfCheck } from "../_utils.ts"; -import { fireContentWebhooks } from "../../../../admin/webhooks.ts"; +import { announcePageEvent } from "../../../page-events.ts"; import { stringify as stringifyYaml } from "@std/yaml"; import type { FreshContext } from "fresh"; @@ -28,7 +28,7 @@ export const handler = { const denied = await requirePermission(ctx, "pages.create"); if (denied) return denied; - const { engine, storage, config, hooks, auditLogger } = ctx.state.adminContext; + const { engine, storage, config, auditLogger } = ctx.state.adminContext; const authResult = ctx.state.auth; try { const body = await ctx.req.json(); @@ -67,15 +67,12 @@ 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?.runtimeDir ?? ".dune/admin"; - if (hooks) hooks.fire("onPageCreate", { sourcePath: `${pagePath}/default${ext}`, title }).catch(() => {}); - fireContentWebhooks(webhookEndpoints, "onPageCreate", { sourcePath: `${pagePath}/default${ext}`, title }, runtimeDir); + announcePageEvent(ctx, { event: "onPageCreate", payload: { sourcePath: `${pagePath}/default${ext}`, title } }); void auditLogger?.log({ event: "page.create", actor: actorFromAuth(authResult), - ip: getClientIp(ctx.req), + ip: getClientIp(ctx), userAgent: ctx.req.headers.get("user-agent") ?? null, target: { type: "page", id: filePath }, detail: {}, diff --git a/src/admin/routes/api/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/plugins/[name]/config.ts b/src/admin/routes/api/plugins/[name]/config.ts index 8abfcb8..6b83b1a 100644 --- a/src/admin/routes/api/plugins/[name]/config.ts +++ b/src/admin/routes/api/plugins/[name]/config.ts @@ -74,7 +74,7 @@ export const handler = { if (errors.length > 0) return json({ error: `Missing required fields: ${errors.join(", ")}` }, 422); } - const dataDir = config.admin?.dataDir ?? "data"; + const dataDir = ctx.state.adminContext.services.paths.dataDir; const filePath = `${dataDir}/plugins/${pluginName}.json`; await storage.write(filePath, new TextEncoder().encode(JSON.stringify(body, null, 2))); diff --git a/src/admin/routes/api/preview.ts b/src/admin/routes/api/preview.ts index 8fd32e6..44a04f7 100644 --- a/src/admin/routes/api/preview.ts +++ b/src/admin/routes/api/preview.ts @@ -20,7 +20,6 @@ import type { AdminState } from "../../types.ts"; import { requirePermission, serverError, csrfCheck } from "./_utils.ts"; import type { FreshContext } from "fresh"; import type { DuneEngine } from "@dune/core/engine"; -import { sanitizeHtml } from "@dune/core/security"; const PREVIEW_CSP = "sandbox; script-src 'none'; object-src 'none'"; @@ -38,7 +37,11 @@ const PREVIEW_STYLE = "body{font-family:system-ui;padding:2rem;max-width:800px;margin:0 auto;line-height:1.6}img{max-width:100%}pre{background:#f5f5f5;padding:1rem;border-radius:4px;overflow-x:auto}code{background:#f0f0f0;padding:0.1em 0.3em;border-radius:2px}blockquote{border-left:3px solid #ccc;padding-left:1rem;color:#666;margin:1rem 0}table{border-collapse:collapse;width:100%}th,td{border:1px solid #ddd;padding:0.5rem}"; /** Render a real page's saved HTML (or a 404-ish fallback) wrapped in preview CSS. */ -async function renderSavedPage(engine: DuneEngine, sourcePath: string): Promise { +async function renderSavedPage( + engine: DuneEngine, + sourcePath: string, + sanitizeHtml: (html: string) => string, +): Promise { // Exact match only — substring lookup is an IDOR (a fragment like "blog" // would match an arbitrary page the caller did not intend). const pageIndex = engine.pages.find((p) => p.sourcePath === sourcePath); @@ -58,11 +61,11 @@ export const handler = { async GET(ctx: FreshContext) { const denied = await requirePermission(ctx, "pages.read"); if (denied) return denied; - const { engine } = ctx.state.adminContext; + const { engine, services } = ctx.state.adminContext; const sourcePath = ctx.url.searchParams.get("path"); if (!sourcePath) return htmlResponse(``); try { - return await renderSavedPage(engine, sourcePath); + return await renderSavedPage(engine, sourcePath, services.security.sanitizeHtml); } catch (err) { return serverError(err); } @@ -74,7 +77,7 @@ export const handler = { const denied = await requirePermission(ctx, "pages.read"); if (denied) return denied; - const { engine } = ctx.state.adminContext; + const { engine, services } = ctx.state.adminContext; try { const body = await ctx.req.json(); const { sourcePath, content } = body; @@ -82,11 +85,11 @@ export const handler = { if (!sourcePath) { // Sanitize caller-supplied content before embedding in a text/html // response — even authenticated editors must not inject arbitrary HTML. - const safeContent = content ? sanitizeHtml(String(content)) : ""; + const safeContent = content ? services.security.sanitizeHtml(String(content)) : ""; return htmlResponse(`${safeContent}`); } - return await renderSavedPage(engine, sourcePath); + return await renderSavedPage(engine, sourcePath, services.security.sanitizeHtml); } catch (err) { return serverError(err); } diff --git a/src/admin/routes/api/render-markdown.ts b/src/admin/routes/api/render-markdown.ts index 0989d5f..5e6dc09 100644 --- a/src/admin/routes/api/render-markdown.ts +++ b/src/admin/routes/api/render-markdown.ts @@ -25,7 +25,6 @@ import { requirePermission, serverError, csrfCheck } from "./_utils.ts"; import type { FreshContext } from "fresh"; import { parse as parseYaml } from "@std/yaml"; import { Marked } from "marked"; -import { sanitizeHtml } from "@dune/core/security"; /** Maximum content size: 500 KB */ const MAX_BYTES = 500 * 1024; @@ -101,7 +100,7 @@ export const handler = { // Sanitize unless trusted if (!trusted) { - html = sanitizeHtml(html); + html = ctx.state.adminContext.services.security.sanitizeHtml(html); } // Add lazy loading to images diff --git a/src/admin/routes/api/search/analytics.ts b/src/admin/routes/api/search/analytics.ts index 04005f9..b749c94 100644 --- a/src/admin/routes/api/search/analytics.ts +++ b/src/admin/routes/api/search/analytics.ts @@ -2,20 +2,16 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, json, serverError } from "../_utils.ts"; -import { createSearchAnalytics } from "@dune/core/search"; -import { join } from "@std/path"; import type { FreshContext } from "fresh"; export const handler = { async GET(ctx: FreshContext) { const denied = await requirePermission(ctx, "pages.read"); if (denied) return denied; - const { config } = ctx.state.adminContext; + const { analytics } = ctx.state.adminContext.services.search; try { - const runtimeDir = config.admin?.runtimeDir ?? ".dune/admin"; - const analyticsPath = join(runtimeDir, "search-analytics.jsonl"); - const summary = await createSearchAnalytics(analyticsPath).summarize(); - return json(summary); + // Core records the queries (`system.search.analytics: true`); this only reads them. + return json({ enabled: analytics.enabled, ...await analytics.summarize() }); } catch (err) { return serverError(err); } diff --git a/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/theme-preview.ts b/src/admin/routes/api/theme-preview.ts index dbd5d7e..3f24563 100644 --- a/src/admin/routes/api/theme-preview.ts +++ b/src/admin/routes/api/theme-preview.ts @@ -2,7 +2,6 @@ import type { AdminState } from "../../types.ts"; import { requirePermission, serverError } from "./_utils.ts"; -import { sanitizeHtml } from "@dune/core/security"; import { h, type ComponentType } from "preact"; import { render as renderJsxToString } from "preact-render-to-string"; import { buildPageTitle } from "@dune/core/content/types"; @@ -27,7 +26,7 @@ export const handler = { const denied = await requirePermission(ctx, "config.read"); if (denied) return denied; - const { engine } = ctx.state.adminContext; + const { engine, services } = ctx.state.adminContext; const themeName = ctx.url.searchParams.get("theme") ?? ""; const route = ctx.url.searchParams.get("route") || "/"; @@ -59,7 +58,7 @@ export const handler = { engine.createPreviewTheme(themeName), ]); - const html = sanitizeHtml(await page.html()); + const html = services.security.sanitizeHtml(await page.html()); const templateName = previewLoader.resolveTemplateName(page) ?? "default"; const template = await previewLoader.loadTemplate(templateName); diff --git a/src/admin/routes/api/themes/install.ts b/src/admin/routes/api/themes/install.ts index 0007d59..81f3f42 100644 --- a/src/admin/routes/api/themes/install.ts +++ b/src/admin/routes/api/themes/install.ts @@ -2,7 +2,7 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, json, serverError, csrfCheck } from "../_utils.ts"; -import { safeFetch } from "@dune/core/security"; +import type { SecurityService } from "@dune/core/plugin-api"; import type { StorageAdapter } from "@dune/core/storage"; import { parse as parseYaml, stringify as stringifyYaml } from "@std/yaml"; import { fetchThemeRegistrySafe, isThemeSha256, type RegistryTheme } from "../../../theme-registry.ts"; @@ -78,6 +78,7 @@ async function installJsrTheme( async function installZipTheme( storage: StorageAdapter, + safeFetch: SecurityService["safeFetch"], slug: string, entry: RegistryTheme, ): Promise<{ filesWritten: number }> { @@ -137,7 +138,7 @@ export const handler = { const denied = await requirePermission(ctx, "config.update"); if (denied) return denied; - const { storage } = ctx.state.adminContext; + const { storage, services } = ctx.state.adminContext; try { const { slug } = await ctx.req.json() as { slug?: string }; @@ -179,7 +180,7 @@ export const handler = { error: `Registry entry for "${slug}" is missing a sha256 integrity hash`, }, 502); } - const { filesWritten } = await installZipTheme(storage, slug, entry); + const { filesWritten } = await installZipTheme(storage, services.security.safeFetch, slug, entry); console.log(`[dune/themes] 📦 Installed theme "${slug}" (${filesWritten} files)`); return json({ success: true, slug, filesWritten, method: "zip" }); } diff --git a/src/admin/routes/api/users/[id]/index.ts b/src/admin/routes/api/users/[id]/index.ts index a1d78ae..ea470f4 100644 --- a/src/admin/routes/api/users/[id]/index.ts +++ b/src/admin/routes/api/users/[id]/index.ts @@ -4,7 +4,7 @@ import type { AdminState } from "../../../../types.ts"; import { requirePermission, json, serverError, actorFromAuth, getClientIp, csrfCheck } from "../../_utils.ts"; import { toUserInfo } from "../../../../types.ts"; import { highestValidRole } from "../../../../auth/role-utils.ts"; -import { DuplicateEmailError } from "@dune/core/auth/user-store"; +import { isDuneError } from "@dune/core/plugin-api"; import type { FreshContext } from "fresh"; export const handler = { @@ -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); }); @@ -110,7 +110,7 @@ export const handler = { try { updated = await users.update(userId, updates); } catch (err) { - if (err instanceof DuplicateEmailError) { + if (isDuneError(err, "duplicate_email")) { return json({ error: "Email already in use" }, 409); } throw err; @@ -120,7 +120,7 @@ export const handler = { void auditLogger?.log({ event: "user.update", actor: actorFromAuth(authResult), - ip: getClientIp(ctx.req), + ip: getClientIp(ctx), userAgent: ctx.req.headers.get("user-agent") ?? null, target: { type: "user", id: userId }, detail: {}, @@ -176,7 +176,7 @@ export const handler = { void auditLogger?.log({ event: "user.delete", actor: actorFromAuth(authResult), - ip: getClientIp(ctx.req), + ip: getClientIp(ctx), userAgent: ctx.req.headers.get("user-agent") ?? null, target: { type: "user", id: userId }, detail: {}, diff --git a/src/admin/routes/api/users/[id]/password.ts b/src/admin/routes/api/users/[id]/password.ts index f0892ba..c0cb66f 100644 --- a/src/admin/routes/api/users/[id]/password.ts +++ b/src/admin/routes/api/users/[id]/password.ts @@ -2,8 +2,8 @@ import type { AdminState } from "../../../../types.ts"; import { requirePermission, json, serverError, actorFromAuth, getClientIp, csrfCheck } from "../../_utils.ts"; -import { checkPasswordStrength } from "@dune/core/security"; import type { FreshContext } from "fresh"; +import { adminSettings } from "../../../../settings.ts"; export const handler = { async POST(ctx: FreshContext) { @@ -12,13 +12,13 @@ export const handler = { const denied = await requirePermission(ctx, "users.update"); if (denied) return denied; - const { users, sessions, auth, config, auditLogger } = ctx.state.adminContext; + const { users, sessions, auth, config, auditLogger, services } = ctx.state.adminContext; const authResult = ctx.state.auth; const userId = ctx.params.id; try { const { password } = await ctx.req.json(); - const strength = checkPasswordStrength(password); + const strength = services.security.checkPasswordStrength(password); if (!strength.ok) return json({ error: strength.reason }, 400); const changed = await users.changePassword(userId, password); @@ -30,14 +30,14 @@ export const handler = { const session = await sessions.create(userId); res.headers.set( "Set-Cookie", - auth.createSessionCookie(session.id, config.admin?.sessionLifetime ?? 86400), + auth.createSessionCookie(session.id, adminSettings(config).session_lifetime), ); } void auditLogger?.log({ event: "user.password", actor: actorFromAuth(authResult), - ip: getClientIp(ctx.req), + ip: getClientIp(ctx), userAgent: ctx.req.headers.get("user-agent") ?? null, target: { type: "user", id: userId }, detail: {}, diff --git a/src/admin/routes/api/users/index.ts b/src/admin/routes/api/users/index.ts index 5de538d..955a1ed 100644 --- a/src/admin/routes/api/users/index.ts +++ b/src/admin/routes/api/users/index.ts @@ -3,8 +3,7 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, json, serverError, actorFromAuth, getClientIp, csrfCheck } from "../_utils.ts"; import { toUserInfo } from "../../../types.ts"; -import { checkPasswordStrength } from "@dune/core/security"; -import { DuplicateEmailError } from "@dune/core/auth/user-store"; +import { isDuneError } from "@dune/core/plugin-api"; import type { FreshContext } from "fresh"; export const handler = { @@ -29,7 +28,7 @@ export const handler = { const denied = await requirePermission(ctx, "users.create"); if (denied) return denied; - const { users, auditLogger } = ctx.state.adminContext; + const { users, auditLogger, services } = ctx.state.adminContext; const authResult = ctx.state.auth; try { const body = await ctx.req.json(); @@ -39,7 +38,7 @@ export const handler = { return json({ error: "username, password, and role are required" }, 400); } - const strength = checkPasswordStrength(password); + const strength = services.security.checkPasswordStrength(password); if (!strength.ok) return json({ error: strength.reason }, 400); const VALID_ROLES = ["admin", "editor", "author"] as const; @@ -67,7 +66,7 @@ export const handler = { name: name ?? username, }); } catch (err) { - if (err instanceof DuplicateEmailError) { + if (isDuneError(err, "duplicate_email")) { return json({ error: "Email already in use" }, 409); } throw err; @@ -80,14 +79,14 @@ 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(() => {}); } void auditLogger?.log({ event: "user.create", actor: actorFromAuth(authResult), - ip: getClientIp(ctx.req), + ip: getClientIp(ctx), userAgent: ctx.req.headers.get("user-agent") ?? null, target: { type: "user", id: user.id }, detail: {}, diff --git a/src/admin/routes/api/webhooks/deliveries.ts b/src/admin/routes/api/webhooks/deliveries.ts index 46497ba..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?.runtimeDir ?? ".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 fa3aa36..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?.runtimeDir ?? ".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 9b7b09f..7cfa79c 100644 --- a/src/admin/routes/login.tsx +++ b/src/admin/routes/login.tsx @@ -7,14 +7,23 @@ import type { FreshContext } from "fresh"; import type { AdminState } from "../types.ts"; -import { verifyPassword, DUMMY_HASH, needsRehash } from "@dune/core/auth/passwords"; import { findOrProvisionUser } from "../auth/provisioner.ts"; -import { RateLimiter, clientIp } from "@dune/core/security"; +import type { PluginServices } from "@dune/core/plugin-api"; +import type { RateLimiter } from "@dune/core/security"; import { actorFromAuth, csrfCheck } from "./api/_utils.ts"; +import { adminSettings } from "../settings.ts"; // Module-level fallback limiter — used when no rateLimitStore is injected via // AdminContext (single-process deployments, tests, etc.). -const loginRateLimiter = new RateLimiter(5, 15 * 60 * 1000); +const fallbackLimiters = new WeakMap(); +function fallbackLoginLimiter(services: PluginServices): RateLimiter { + let limiter = fallbackLimiters.get(services); + if (!limiter) { + limiter = services.rateLimit.createLimiter(5, 15 * 60 * 1000); + fallbackLimiters.set(services, limiter); + } + return limiter; +} // Per-account lockout fallback (in-process Map, single-process only). // When a RateLimitStore is present on AdminContext, store-backed methods are @@ -101,8 +110,8 @@ export const handler = { const csrfDenied = csrfCheck(ctx); if (csrfDenied) return csrfDenied; - const { auth, users, sessions, prefix, auditLogger, authProvider, config, rateLimitStore } = ctx.state.adminContext; - const adminConfig = config.admin!; + const { auth, users, sessions, prefix, auditLogger, authProvider, config, rateLimitStore, services } = ctx.state.adminContext; + const adminConfig = adminSettings(config); const url = ctx.url; // Logout sub-action @@ -134,7 +143,7 @@ export const handler = { // Login. Rate-limit / lockout key is IP-based — only honor forwarded // headers when the operator explicitly opts in via system.trusted_proxies. const trustForwardedFor = config.system?.trusted_proxies === true; - const ip = clientIp(ctx.req, { trustForwardedFor }); + const ip = services.security.clientIp(ctx.req, { trustForwardedFor }); // Use the store-backed rate limiter when available; fall back to the // module-level in-process limiter for single-process deployments. @@ -151,6 +160,7 @@ export const handler = { ); } } else { + const loginRateLimiter = fallbackLoginLimiter(services); if (!loginRateLimiter.check(ip)) { const retryAfter = loginRateLimiter.retryAfter(ip); return ctx.render( @@ -204,8 +214,8 @@ export const handler = { user = await findOrProvisionUser(providerUser, users); } else { const found = await users.getByUsername(username); - const hashToVerify = found?.passwordHash ?? DUMMY_HASH; - const valid = await verifyPassword(password, hashToVerify); + const hashToVerify = found?.passwordHash ?? services.passwords.dummyHash; + const valid = await services.passwords.verify(password, hashToVerify); if (!found || !found.enabled || !valid) { if (rateLimitStore) { await rateLimitStore.recordFailure(accountKey, LOGIN_LOCKOUT_WINDOW_MS); @@ -218,9 +228,9 @@ export const handler = { user = found; // Transparently upgrade legacy (low-iteration) hashes to current cost. // found.passwordHash is guaranteed set here: `valid` above can only be - // true if verifyPassword() matched a real hash, not the DUMMY_HASH + // true if verifyPassword() matched a real hash, not the dummy hash // fallback used when passwordHash is absent. - if (found.passwordHash && needsRehash(found.passwordHash)) { + if (found.passwordHash && services.passwords.needsRehash(found.passwordHash)) { try { await users.changePassword(found.id, password); } catch { @@ -246,7 +256,7 @@ export const handler = { status: 302, headers: { Location: safeNext, - "Set-Cookie": auth.createSessionCookie(session.id, adminConfig.sessionLifetime), + "Set-Cookie": auth.createSessionCookie(session.id, adminConfig.session_lifetime), }, }); }, diff --git a/src/admin/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..6890004 100644 --- a/src/admin/types.ts +++ b/src/admin/types.ts @@ -4,7 +4,7 @@ * @module */ -export type { ContentEditorPlugin } from "@dune/core/hooks"; +export type { AdminPageRegistration, AdminRouteRegistration, ContentEditorPlugin } from "./contributions.ts"; // Session and Role live in core to break circular deps; import for // local use AND re-export so callers importing from admin/types get them. // Session was formerly AdminSession — renamed in @@ -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 3aa4e51..b81f106 100644 --- a/src/admin/webhooks.ts +++ b/src/admin/webhooks.ts @@ -17,8 +17,13 @@ import { join } from "@std/path"; import { encodeHex } from "@std/encoding/hex"; -import { safeFetch, SsrfBlockedError } from "@dune/core/security"; -import type { WebhookContentEvent, WebhookEndpointConfig } from "@dune/core/config"; +import { isDuneError } from "@dune/core/plugin-api"; +import type { SecurityService } from "@dune/core/plugin-api"; +import type { HookRegistry } from "@dune/core/hooks"; + +/** The slice of the security service webhook delivery needs. */ +export type WebhookSecurity = Pick; +import type { WebhookContentEvent, WebhookEndpointConfig } from "./settings.ts"; // === Types === @@ -149,6 +154,7 @@ async function attemptDelivery( endpoint: WebhookEndpointConfig, body: string, attemptNumber: number, + security: WebhookSecurity, ): Promise { const timestamp = Date.now(); try { @@ -170,13 +176,13 @@ async function attemptDelivery( // bots — must be explicit). Treat any redirect as a failed attempt. let resp: Response; try { - resp = await safeFetch( + resp = await security.safeFetch( endpoint.url, { method: "POST", headers, body }, { allowPrivateDestinations: endpoint.allow_private === true }, ); } catch (err) { - const msg = err instanceof SsrfBlockedError ? err.message : String(err); + const msg = isDuneError(err, "ssrf_blocked") ? (err as Error).message : String(err); return { attemptNumber, timestamp, errorMessage: `SSRF guard: ${msg}`, success: false }; } if (resp.ok) { @@ -211,6 +217,7 @@ async function deliverWithRetry( event: WebhookContentEvent, payload: unknown, logDir: string, + security: WebhookSecurity, ): Promise { const id = encodeHex(crypto.getRandomValues(new Uint8Array(8))); const body = JSON.stringify({ event, payload, sentAt: new Date().toISOString() }); @@ -228,7 +235,7 @@ async function deliverWithRetry( }; for (let i = 0; i < 3; i++) { - const attempt = await attemptDelivery(endpoint, body, i + 1); + const attempt = await attemptDelivery(endpoint, body, i + 1, security); record.attempts.push(attempt); if (attempt.success) { @@ -262,13 +269,15 @@ async function deliverWithRetry( * @param endpoints - Configured webhook endpoints (from admin.webhooks) * @param event - The content event that occurred * @param payload - Event-specific data included in the POST body - * @param logDir - Base directory for delivery logs (admin.runtimeDir) + * @param logDir - Base directory for delivery logs (system.runtime_dir) + * @param security - Core's security service (SSRF-guarded fetch) */ export function fireContentWebhooks( endpoints: WebhookEndpointConfig[], event: WebhookContentEvent, payload: unknown, logDir: string, + security: WebhookSecurity, ): void { const active = endpoints.filter( (e) => (e.enabled ?? true) && e.events.includes(event), @@ -278,7 +287,7 @@ export function fireContentWebhooks( // Fire all matching endpoints in parallel — do not await Promise.all( active.map((endpoint) => - deliverWithRetry(endpoint, event, payload, logDir).catch((err) => { + deliverWithRetry(endpoint, event, payload, logDir, security).catch((err) => { console.error(`[dune] webhook: unexpected error for ${endpoint.url}: ${err}`); }) ), @@ -287,6 +296,33 @@ export function fireContentWebhooks( }); } +/** The content events an endpoint can subscribe to. */ +const CONTENT_EVENTS: WebhookContentEvent[] = [ + "onPageCreate", + "onPageUpdate", + "onPageDelete", + "onPageMove", + "onWorkflowChange", +]; + +/** + * Deliver content webhooks for every page event that is fired, whoever fires + * it. `endpoints` is read per event, so a settings change applies without a + * restart. Delivery is fire-and-forget (see {@link fireContentWebhooks}). + */ +export function listenForContentWebhooks(opts: { + hooks: Pick; + endpoints: () => WebhookEndpointConfig[]; + runtimeDir: string; + security: WebhookSecurity; +}): void { + for (const event of CONTENT_EVENTS) { + opts.hooks.on(event, ({ data }) => { + fireContentWebhooks(opts.endpoints(), event, data, opts.runtimeDir, opts.security); + }); + } +} + export interface ListDeliveryLogsOptions { /** Maximum records to return (default: 50) */ limit?: number; @@ -300,7 +336,7 @@ export interface ListDeliveryLogsOptions { * Reads logs from the last 7 days of log directories and returns up to `limit` * records. Silently returns an empty array if the log directory doesn't exist. * - * @param logDir - Base directory (admin.runtimeDir) + * @param logDir - Base directory (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 bc7e108..9f094b2 100644 --- a/src/admin/workflow-actions.ts +++ b/src/admin/workflow-actions.ts @@ -17,7 +17,6 @@ import type { StorageAdapter } from "@dune/core/storage"; import type { DuneConfig } from "@dune/core/config"; import type { HookRegistry } from "@dune/core/hooks"; import type { WorkflowEngine } from "@dune/core/workflow"; -import { fireContentWebhooks } from "./webhooks.ts"; export interface WorkflowActionContext { engine: DuneEngine; @@ -72,10 +71,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?.runtimeDir ?? ".dune/admin"; + // The admin's content webhooks listen to this event (see mount()). if (hooks) hooks.fire("onWorkflowChange", { sourcePath, from: currentStatus, to: newStatus }).catch(() => {}); - fireContentWebhooks(webhookEndpoints, "onWorkflowChange", { sourcePath, from: currentStatus, to: newStatus }, runtimeDir); return { from: currentStatus, to: newStatus }; } 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 da6f9ba..0e309dc 100644 --- a/src/collab/manager.ts +++ b/src/collab/manager.ts @@ -11,7 +11,6 @@ */ import { join } from "@std/path"; -import { parseUserYaml as parseYaml } from "@dune/core/security"; import type { CollabManager, CollabManagerOptions, @@ -58,7 +57,10 @@ function spliceBody(raw: string, newBody: string): string { * Parse frontmatter YAML from a raw file string. * Returns an empty object on failure. */ -function parseFrontmatter(raw: string): Record { +function parseFrontmatter( + raw: string, + parseYaml: (text: string) => unknown, +): Record { const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (!match) return {}; try { @@ -109,7 +111,7 @@ function takeToken(rate: ConnectionRate): boolean { } export function createCollabManager(options: CollabManagerOptions): CollabManager { - const { storage, engine, history, contentDir } = options; + const { storage, engine, history, contentDir, security, hooks } = options; // docId → CollabSessionState const sessions = new Map(); @@ -192,8 +194,15 @@ 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); + const fm = parseFrontmatter(raw, security.parseUserYaml); history.record({ sourcePath: session.docId, content: session.content, diff --git a/src/collab/types.ts b/src/collab/types.ts index b60afa0..ceef965 100644 --- a/src/collab/types.ts +++ b/src/collab/types.ts @@ -108,6 +108,13 @@ export interface CollabManagerOptions { history?: HistoryEngine; /** Value of config.system.content.dir — used to build storage paths. */ contentDir: string; + /** Core's security service (safe YAML parsing of frontmatter). */ + security: Pick; + /** + * 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/admin_tuples_test.ts b/tests/admin/admin_tuples_test.ts new file mode 100644 index 0000000..457c18d --- /dev/null +++ b/tests/admin/admin_tuples_test.ts @@ -0,0 +1,52 @@ +/** + * The admin seeds direct editing tuples for its own accounts only; a site user + * who holds an admin-tier role is governed by core's system.role_grants. + */ + +import { assertEquals } from "@std/assert"; +import { isAdminAccount, reconcileAdminTuples, type StoredAccount } from "../../src/admin/admin-tuples.ts"; + +function fakeAuthz() { + const seeded: Array<{ id: string; role: string }> = []; + const revoked: Array<[string, string]> = []; + return { + seeded, + revoked, + seedAdminTuples: (users: Array<{ id: string; role: string }>) => (seeded.push(...users), Promise.resolve()), + revoke: (id: string, relation: string) => (revoked.push([id, relation]), Promise.resolve()), + }; +} + +const account = (over: Partial & { id: string }): StoredAccount => ({ roles: [], ...over }); + +Deno.test("a password account is an admin account; OAuth, magic-link and invited ones are not", () => { + assertEquals(isAdminAccount(account({ id: "a", provider: "local" })), true); + assertEquals(isAdminAccount(account({ id: "a", provider: "github", passwordHash: "pbkdf2:..." })), true); + for (const provider of ["github", "google", "magic", "invited", "external-jwt"]) { + assertEquals(isAdminAccount(account({ id: "a", provider })), false, provider); + } +}); + +Deno.test("only admin accounts with an admin-tier role are seeded", async () => { + const authz = fakeAuthz(); + await reconcileAdminTuples([ + account({ id: "admin1", provider: "local", roles: ["admin"] }), + account({ id: "ed1", provider: "local", roles: ["member", "editor"] }), + account({ id: "plain", provider: "local", roles: ["member"] }), + account({ id: "off", provider: "local", roles: ["admin"], enabled: false }), + account({ id: "visitor", provider: "github", roles: ["editor"] }), + ], authz); + assertEquals(authz.seeded, [{ id: "admin1", role: "admin" }, { id: "ed1", role: "editor" }]); +}); + +Deno.test("a tuple an earlier version seeded for an account that cannot sign in is revoked, and counted", async () => { + const authz = fakeAuthz(); + const n = await reconcileAdminTuples([ + account({ id: "visitor", provider: "github", roles: ["author", "member"] }), + account({ id: "buyer", provider: "magic", roles: ["editor"] }), + account({ id: "real", provider: "local", roles: ["editor"] }), + account({ id: "tagged", provider: "github", roles: ["member"] }), + ], authz); + assertEquals(n, 2); + assertEquals(authz.revoked, [["visitor", "author"], ["buyer", "editor"]]); +}); diff --git a/tests/admin/auth_test.ts b/tests/admin/auth_test.ts index 9be4e71..4eb8c70 100644 --- a/tests/admin/auth_test.ts +++ b/tests/admin/auth_test.ts @@ -2,6 +2,7 @@ * Tests for admin auth subsystem: passwords, sessions, users, middleware. */ +import { makeServices } from "../helpers/services.ts"; import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; import { hashPassword, verifyPassword } from "@dune/core/auth/passwords"; import { createSessionManager } from "../../src/admin/auth/sessions.ts"; @@ -74,8 +75,8 @@ Deno.test("verifyPassword: invalid hash format returns false", async () => { Deno.test("SessionManager: create returns session with ID", async () => { const storage = createMemoryStorage(); - const mgr = createSessionManager({ - storage, + const mgr = await createSessionManager({ + services: makeServices(storage), sessionsDir: ".sessions", lifetime: 3600, }); @@ -88,8 +89,8 @@ Deno.test("SessionManager: create returns session with ID", async () => { Deno.test("SessionManager: get returns valid session", async () => { const storage = createMemoryStorage(); - const mgr = createSessionManager({ - storage, + const mgr = await createSessionManager({ + services: makeServices(storage), sessionsDir: ".sessions", lifetime: 3600, }); @@ -103,8 +104,8 @@ Deno.test("SessionManager: get returns valid session", async () => { Deno.test("SessionManager: get returns null for missing session", async () => { const storage = createMemoryStorage(); - const mgr = createSessionManager({ - storage, + const mgr = await createSessionManager({ + services: makeServices(storage), sessionsDir: ".sessions", lifetime: 3600, }); @@ -114,8 +115,8 @@ Deno.test("SessionManager: get returns null for missing session", async () => { Deno.test("SessionManager: revoke deletes session", async () => { const storage = createMemoryStorage(); - const mgr = createSessionManager({ - storage, + const mgr = await createSessionManager({ + services: makeServices(storage), sessionsDir: ".sessions", lifetime: 3600, }); @@ -128,8 +129,8 @@ Deno.test("SessionManager: revoke deletes session", async () => { Deno.test("SessionManager: revokeAll removes user sessions", async () => { const storage = createMemoryStorage(); - const mgr = createSessionManager({ - storage, + const mgr = await createSessionManager({ + services: makeServices(storage), sessionsDir: ".sessions", lifetime: 3600, }); @@ -147,8 +148,8 @@ Deno.test("SessionManager: revokeAll removes user sessions", async () => { Deno.test("SessionManager: expired session returns null", async () => { const storage = createMemoryStorage(); - const mgr = createSessionManager({ - storage, + const mgr = await createSessionManager({ + services: makeServices(storage), sessionsDir: ".sessions", lifetime: 0, // Expire immediately }); @@ -163,7 +164,7 @@ Deno.test("SessionManager: expired session returns null", async () => { Deno.test("UserManager: create stores user", async () => { const storage = createMemoryStorage(); - const mgr = createUserManager({ storage, usersDir: ".users" }); + const mgr = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); const user = await mgr.create({ username: "alice", @@ -182,7 +183,7 @@ Deno.test("UserManager: create stores user", async () => { Deno.test("UserManager: getByUsername finds user", async () => { const storage = createMemoryStorage(); - const mgr = createUserManager({ storage, usersDir: ".users" }); + const mgr = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); await mgr.create({ username: "bob", @@ -199,14 +200,14 @@ Deno.test("UserManager: getByUsername finds user", async () => { Deno.test("UserManager: getByUsername returns null for missing", async () => { const storage = createMemoryStorage(); - const mgr = createUserManager({ storage, usersDir: ".users" }); + const mgr = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); assertEquals(await mgr.getByUsername("nobody"), null); }); Deno.test("UserManager: list returns all users", async () => { const storage = createMemoryStorage(); - const mgr = createUserManager({ storage, usersDir: ".users" }); + const mgr = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); await mgr.create({ username: "u1", email: "u1@test.com", password: "p", role: "admin", name: "U1" }); await mgr.create({ username: "u2", email: "u2@test.com", password: "p", role: "editor", name: "U2" }); @@ -217,7 +218,7 @@ Deno.test("UserManager: list returns all users", async () => { Deno.test("UserManager: update modifies user fields", async () => { const storage = createMemoryStorage(); - const mgr = createUserManager({ storage, usersDir: ".users" }); + const mgr = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); const user = await mgr.create({ username: "charlie", @@ -236,7 +237,7 @@ Deno.test("UserManager: update modifies user fields", async () => { Deno.test("UserManager: changePassword works", async () => { const storage = createMemoryStorage(); - const mgr = createUserManager({ storage, usersDir: ".users" }); + const mgr = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); const user = await mgr.create({ username: "dave", @@ -258,7 +259,7 @@ Deno.test("UserManager: changePassword works", async () => { Deno.test("UserManager: delete removes user", async () => { const storage = createMemoryStorage(); - const mgr = createUserManager({ storage, usersDir: ".users" }); + const mgr = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); const user = await mgr.create({ username: "ephemeral", @@ -274,7 +275,7 @@ Deno.test("UserManager: delete removes user", async () => { Deno.test("UserManager: has no default-admin bootstrap (no admin is created implicitly)", async () => { const storage = createMemoryStorage(); - const mgr = createUserManager({ storage, usersDir: ".users" }); + const mgr = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); assertEquals("ensureDefaultAdmin" in mgr, false); assertEquals(await mgr.list(), []); @@ -285,9 +286,9 @@ Deno.test("UserManager: has no default-admin bootstrap (no admin is created impl Deno.test("AuthMiddleware: authenticate fails without cookie", async () => { const storage = createMemoryStorage(); - const sessions = createSessionManager({ storage, sessionsDir: ".sess", lifetime: 3600 }); - const users = createUserManager({ storage, usersDir: ".users" }); - const auth = createAuthMiddleware({ sessions, users }); + const sessions = await createSessionManager({ services: makeServices(storage), sessionsDir: ".sess", lifetime: 3600 }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); + const auth = createAuthMiddleware({ sessions, users, security: makeServices(null).security }); const req = new Request("http://localhost/admin/"); const result = await auth.authenticate(req); @@ -297,9 +298,9 @@ Deno.test("AuthMiddleware: authenticate fails without cookie", async () => { Deno.test("AuthMiddleware: authenticate succeeds with valid session", async () => { const storage = createMemoryStorage(); - const sessions = createSessionManager({ storage, sessionsDir: ".sess", lifetime: 3600 }); - const userMgr = createUserManager({ storage, usersDir: ".users" }); - const auth = createAuthMiddleware({ sessions, users: userMgr }); + const sessions = await createSessionManager({ services: makeServices(storage), sessionsDir: ".sess", lifetime: 3600 }); + const userMgr = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); + const auth = createAuthMiddleware({ sessions, users: userMgr, security: makeServices(null).security }); const user = await userMgr.create({ username: "admin", @@ -322,12 +323,13 @@ Deno.test("AuthMiddleware: authenticate succeeds with valid session", async () = Deno.test("AuthMiddleware: bound session fails when forwarded IP is omitted", async () => { const storage = createMemoryStorage(); - const sessions = createSessionManager({ storage, sessionsDir: ".sess", lifetime: 3600 }); - const userMgr = createUserManager({ storage, usersDir: ".users" }); + const sessions = await createSessionManager({ services: makeServices(storage), sessionsDir: ".sess", lifetime: 3600 }); + const userMgr = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); const auth = createAuthMiddleware({ sessions, users: userMgr, trustForwardedFor: true, + security: makeServices(null).security, }); const user = await userMgr.create({ @@ -357,11 +359,11 @@ Deno.test("AuthMiddleware: bound session fails when forwarded IP is omitted", as assertEquals(matching.authenticated, true); }); -Deno.test("AuthMiddleware: createSessionCookie formats correctly", () => { +Deno.test("AuthMiddleware: createSessionCookie formats correctly", async () => { const storage = createMemoryStorage(); - const sessions = createSessionManager({ storage, sessionsDir: ".sess", lifetime: 3600 }); - const users = createUserManager({ storage, usersDir: ".users" }); - const auth = createAuthMiddleware({ sessions, users }); + const sessions = await createSessionManager({ services: makeServices(storage), sessionsDir: ".sess", lifetime: 3600 }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); + const auth = createAuthMiddleware({ sessions, users, security: makeServices(null).security }); const cookie = auth.createSessionCookie("abc123", 86400); assertEquals(cookie.includes("dune_session=abc123"), true); diff --git a/tests/admin/bar_actions_test.ts b/tests/admin/bar_actions_test.ts new file mode 100644 index 0000000..68840c8 --- /dev/null +++ b/tests/admin/bar_actions_test.ts @@ -0,0 +1,25 @@ +/** What the admin contributes to the inline-edit bar. */ + +import { assertEquals, assertStringIncludes } from "@std/assert"; +import { adminBarActions } from "../../src/admin/bar-actions.ts"; + +Deno.test("no bar buttons off content pages", () => { + assertEquals(adminBarActions("/admin", { page: null }), []); +}); + +Deno.test("an edit-source button and an open-in-admin link, at the admin's own prefix", () => { + const actions = adminBarActions("/panel", { page: { sourcePath: "03.work/01.test.mdx" } }); + assertEquals(actions.map((a) => a.id), ["admin:edit-source", "admin:open"]); + const expected = "/panel/pages/edit?path=03.work%2F01.test.mdx"; + assertEquals(actions[1].href, expected); + // The overlay shows the same page embedded; the link is a full-page navigation. + assertStringIncludes(actions[0].onClick ?? "", JSON.stringify(`${expected}&embedded=1`)); + assertEquals(actions[0].href, undefined); +}); + +Deno.test("the overlay script is valid JavaScript that builds an iframe lazily", () => { + const script = adminBarActions("/admin", { page: { sourcePath: "a.md" } })[0].onClick ?? ""; + new Function(script); // throws on a syntax error + assertStringIncludes(script, "createElement('iframe')"); + assertStringIncludes(script, "dune-source-overlay"); +}); diff --git a/tests/admin/capabilities_e2e_test.ts b/tests/admin/capabilities_e2e_test.ts new file mode 100644 index 0000000..eee5d4a --- /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 { ADMIN_EDITOR, ADMIN_PAGES, ADMIN_ROUTES, 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 admin.editor 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(ADMIN_EDITOR, () => ({ + pageEditorHandler: () => new Response("custom editor"), + })); + }, + }; + await withSite([editor], async (ctx) => { + assertExists(ctx.capabilities.get(ADMIN_EDITOR)); + assertEquals( + ctx.capabilities.providers().find((p) => p.name === "admin.editor")?.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/contributions_types_test.ts b/tests/admin/contributions_types_test.ts new file mode 100644 index 0000000..0f95112 --- /dev/null +++ b/tests/admin/contributions_types_test.ts @@ -0,0 +1,53 @@ +/** + * The admin declares its own extension points as typed keys. These checks fail to compile if + * the key typing, the required `permission`, or the handler typing + * regresses; the runtime assertions only keep the tests honest. + */ + +import { assertEquals } from "@std/assert"; +import type { FreshContext } from "fresh"; +import { createCapabilityRegistry } from "@dune/core/plugin-api"; +import { + ADMIN_EDITOR, + ADMIN_PAGES, + ADMIN_ROUTES, + type AdminPageRegistration, + type AdminRouteRegistration, + type ContentEditorPlugin, +} from "../../mod.ts"; + +Deno.test("the admin's keys type what is contributed and read", () => { + const reg = createCapabilityRegistry(); + const page: AdminPageRegistration = { path: "/p", label: "P", permission: "pages.read", handler: () => new Response("") }; + reg.registrar("x").contribute(ADMIN_PAGES, page); + assertEquals(reg.reader.list(ADMIN_PAGES).map((p) => p.path), ["/p"]); +}); + +Deno.test("an admin.pages contribution must name a permission (compile-time)", () => { + const reg = createCapabilityRegistry(); + // @ts-expect-error `permission` is required on admin.pages + reg.registrar("a").contribute(ADMIN_PAGES, { path: "/x", label: "X", handler: () => new Response("") }); + assertEquals(reg.reader.list(ADMIN_PAGES).length, 1); +}); + +Deno.test("an admin.routes contribution must name a permission (compile-time)", () => { + const reg = createCapabilityRegistry(); + // @ts-expect-error `permission` is required on admin.routes + reg.registrar("a").contribute(ADMIN_ROUTES, { method: "POST", path: "/x", handler: () => new Response("") }); + assertEquals(reg.reader.list(ADMIN_ROUTES).length, 1); +}); + +Deno.test("contribution handlers may be written against a full Fresh context", () => { + // `handler` is declared as a method, so the parameter is checked bivariantly. + const full = (ctx: FreshContext<{ n: number }>) => new Response(String(ctx.state.n)); + const page: AdminPageRegistration<{ n: number }> = { path: "/p", label: "P", permission: "pages.read", handler: full }; + const route: AdminRouteRegistration<{ n: number }> = { method: "POST", path: "/r", permission: "pages.update", handler: full }; + assertEquals([page.path, route.path], ["/p", "/r"]); +}); + +Deno.test("admin.editor takes a ContentEditorPlugin", () => { + const reg = createCapabilityRegistry(); + const editor: ContentEditorPlugin = { pageEditorHandler: () => new Response("") }; + reg.registrar("alt-editor").provide(ADMIN_EDITOR, () => editor); + assertEquals(reg.reader.providers()[0].name, "admin.editor"); +}); diff --git a/tests/admin/csp_test.ts b/tests/admin/csp_test.ts index 3937837..5a849e1 100644 --- a/tests/admin/csp_test.ts +++ b/tests/admin/csp_test.ts @@ -12,6 +12,7 @@ * into the CSP's script-src/style-src directives. */ +import { makeServices } from "../helpers/services.ts"; import { assertEquals, assertMatch } from "https://deno.land/std@0.224.0/assert/mod.ts"; import { handler } from "../../src/admin/routes/_middleware.ts"; import { createAuthMiddleware } from "../../src/admin/auth/middleware.ts"; @@ -54,9 +55,9 @@ function createMemoryStorage() { async function buildAdminContext() { const storage = createMemoryStorage(); - const sessions = createSessionManager({ storage, sessionsDir: ".sess", lifetime: 3600 }); - const users = createUserManager({ storage, usersDir: ".users" }); - const auth = createAuthMiddleware({ sessions, users }); + const sessions = await createSessionManager({ services: makeServices(storage), sessionsDir: ".sess", lifetime: 3600 }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); + const auth = createAuthMiddleware({ sessions, users, security: makeServices(null).security }); const user = await users.create({ username: "admin", diff --git a/tests/admin/manifest_test.ts b/tests/admin/manifest_test.ts index 833044c..7f2d49c 100644 --- a/tests/admin/manifest_test.ts +++ b/tests/admin/manifest_test.ts @@ -64,6 +64,21 @@ Deno.test("staging and workflow status/scheduled routes are wildcard, not single } }); +Deno.test("admin manifest registers a wildcard's longer sub-routes before the bare wildcard", () => { + // `/x/:path*` also matches `/x/a/b/publish`, so it has to come after + // `/x/:path*/publish` or the sub-route is unreachable. + const patterns = adminRoutes.map((r) => r.pattern); + const order: Array<[string, string]> = [ + ["/api/staging/:path*/publish", "/api/staging/:path*"], + ["/api/pages/:path*/comments", "/api/pages/:path*"], + ["/api/pages/:path*/comments/:id", "/api/pages/:path*/comments"], + ["/api/pages/:path*/comments/:id/resolve", "/api/pages/:path*/comments/:id"], + ]; + for (const [first, second] of order) { + assertEquals(patterns.indexOf(first) < patterns.indexOf(second), true, `${first} must precede ${second}`); + } +}); + Deno.test("admin manifest orders static segments before dynamic ones", () => { const patterns = adminRoutes.map((r) => r.pattern); // /api/pages/reorder (static) must be registered before /api/pages/:path* diff --git a/tests/admin/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_audit_test.ts b/tests/admin/page_audit_test.ts new file mode 100644 index 0000000..fd1ac06 --- /dev/null +++ b/tests/admin/page_audit_test.ts @@ -0,0 +1,59 @@ +/** Page writes that no admin route announced reach the audit trail, once. */ + +import { assertEquals } from "@std/assert"; +import { FROM_ADMIN_ROUTE, listenForPageAudit } from "../../src/admin/page-audit.ts"; + +type Handler = (ctx: { data: unknown }) => void; + +function setup() { + const handlers = new Map(); + const logged: Array> = []; + listenForPageAudit({ + // deno-lint-ignore no-explicit-any + hooks: { on: (event: string, h: Handler) => handlers.set(event, [...(handlers.get(event) ?? []), h]) } as any, + audit: () => ({ log: (entry: Record) => (logged.push(entry), Promise.resolve()) }) as never, + }); + return { logged, fire: (event: string, data: unknown) => (handlers.get(event) ?? []).forEach((h) => h({ data })) }; +} + +Deno.test("an update by the inline-edit bar is audited under the editor's name", () => { + const { logged, fire } = setup(); + fire("onPageUpdate", { sourcePath: "about/default.md", actor: "alice" }); + assertEquals(logged.length, 1); + assertEquals(logged[0].event, "page.update"); + assertEquals(logged[0].actor, { userId: "alice", username: "alice", name: "alice" }); + assertEquals(logged[0].target, { type: "page", id: "about/default.md" }); + assertEquals(logged[0].ip, null); +}); + +Deno.test("create and delete are audited too; a write with no actor is audited as the system", () => { + const { logged, fire } = setup(); + fire("onPageCreate", { sourcePath: "new/default.md", title: "New" }); + fire("onPageDelete", { sourcePath: "old/default.md", actor: "bob" }); + assertEquals(logged.map((l) => l.event), ["page.create", "page.delete"]); + assertEquals(logged[0].actor, null); +}); + +Deno.test("an event an admin route announced is not audited again", () => { + const { logged, fire } = setup(); + const payload = { sourcePath: "about/default.md", actor: "ada" }; + Object.defineProperty(payload, FROM_ADMIN_ROUTE, { value: true, enumerable: false }); + fire("onPageUpdate", payload); + assertEquals(logged, []); + // The mark never shows up when the payload is serialised (webhook bodies). + assertEquals(JSON.stringify(payload), '{"sourcePath":"about/default.md","actor":"ada"}'); +}); + +Deno.test("no audit trail configured, or a payload without a path, logs nothing and does not throw", () => { + const off = [] as unknown[]; + const handlers: Array<(c: { data: unknown }) => void> = []; + listenForPageAudit({ + // deno-lint-ignore no-explicit-any + hooks: { on: (_e: string, h: (c: { data: unknown }) => void) => handlers.push(h) } as any, + audit: () => null, + }); + for (const h of handlers) h({ data: { sourcePath: "a.md" } }); + const { logged, fire } = setup(); + fire("onPageUpdate", { actor: "x" }); + assertEquals([off, logged], [[], []]); +}); diff --git a/tests/admin/page_events_test.ts b/tests/admin/page_events_test.ts new file mode 100644 index 0000000..512eff3 --- /dev/null +++ b/tests/admin/page_events_test.ts @@ -0,0 +1,145 @@ +/** + * 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", actor: "ed" } }]); + + res = await call("PUT", "/admin/api/pages/blog/default.md", { content: "changed" }); + assertEquals(res.status, 200); + assertEquals(seen.splice(0), [{ event: "onPageUpdate", data: { sourcePath: "blog/default.md", actor: "ed" } }]); + + // 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" }, + ], + actor: "ed", + }, + }]); + + res = await call("DELETE", "/admin/api/pages/blog/default.md"); + assertEquals(res.status, 200); + assertEquals(seen.splice(0), [{ event: "onPageDelete", data: { sourcePath: "blog/default.md", actor: "ed" } }]); + }); +}); + +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", actor: "ed" } }]); + }); +}); diff --git a/tests/admin/pages_edit_blueprint_test.ts b/tests/admin/pages_edit_blueprint_test.ts index 23ee66b..08db562 100644 --- a/tests/admin/pages_edit_blueprint_test.ts +++ b/tests/admin/pages_edit_blueprint_test.ts @@ -12,6 +12,7 @@ * leaving the old value in place. */ +import { makeServices } from "../helpers/services.ts"; import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; import { handler } from "../../src/admin/routes/api/pages/[...path]/index.ts"; @@ -60,9 +61,10 @@ function makeCtx(opts: { state: { auth: { authenticated: true, user: { id: "1", username: "admin", roles: ["admin"] } }, adminContext: { + 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/provisioner_test.ts b/tests/admin/provisioner_test.ts index c9e6c55..f6d9c37 100644 --- a/tests/admin/provisioner_test.ts +++ b/tests/admin/provisioner_test.ts @@ -7,6 +7,7 @@ * is an array. */ +import { makeServices } from "../helpers/services.ts"; import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; import { findOrProvisionUser } from "../../src/admin/auth/provisioner.ts"; import { LocalAuthProvider } from "../../src/admin/auth/local-provider.ts"; @@ -67,7 +68,7 @@ function providerUser(overrides: Partial = {}): AuthProviderUs Deno.test("findOrProvisionUser: creates a new user at defaultRole when provider reports no roles", async () => { const storage = createMemoryStorage(); - const users = createUserManager({ storage, usersDir: ".users" }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); const created = await findOrProvisionUser(providerUser(), users, { defaultRole: "editor" }); @@ -78,7 +79,7 @@ Deno.test("findOrProvisionUser: creates a new user at defaultRole when provider Deno.test("findOrProvisionUser: accepts a provider-reported role at or below defaultRole", async () => { const storage = createMemoryStorage(); - const users = createUserManager({ storage, usersDir: ".users" }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); const created = await findOrProvisionUser( providerUser({ roles: ["author"] }), @@ -91,7 +92,7 @@ Deno.test("findOrProvisionUser: accepts a provider-reported role at or below def Deno.test("findOrProvisionUser: caps a new user's role at defaultRole even if the provider reports higher", async () => { const storage = createMemoryStorage(); - const users = createUserManager({ storage, usersDir: ".users" }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); const created = await findOrProvisionUser( providerUser({ roles: ["admin"] }), @@ -104,7 +105,7 @@ Deno.test("findOrProvisionUser: caps a new user's role at defaultRole even if th Deno.test("findOrProvisionUser: ignores unknown role strings in the roles array, falls back to defaultRole", async () => { const storage = createMemoryStorage(); - const users = createUserManager({ storage, usersDir: ".users" }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); const created = await findOrProvisionUser( providerUser({ roles: ["superadmin", "not-a-role"] }), @@ -117,7 +118,7 @@ Deno.test("findOrProvisionUser: ignores unknown role strings in the roles array, Deno.test("findOrProvisionUser: picks the highest-ranked valid role when roles has multiple entries", async () => { const storage = createMemoryStorage(); - const users = createUserManager({ storage, usersDir: ".users" }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); const created = await findOrProvisionUser( providerUser({ roles: ["author", "editor"] }), @@ -132,7 +133,7 @@ Deno.test("findOrProvisionUser: picks the highest-ranked valid role when roles h Deno.test("findOrProvisionUser: refuses to elevate an existing user's role from provider attributes", async () => { const storage = createMemoryStorage(); - const users = createUserManager({ storage, usersDir: ".users" }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); await users.create({ username: "bob", email: "bob@example.com", @@ -152,7 +153,7 @@ Deno.test("findOrProvisionUser: refuses to elevate an existing user's role from Deno.test("findOrProvisionUser: permits demoting an existing user's role from provider attributes", async () => { const storage = createMemoryStorage(); - const users = createUserManager({ storage, usersDir: ".users" }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); await users.create({ username: "bob", email: "bob@example.com", @@ -172,7 +173,7 @@ Deno.test("findOrProvisionUser: permits demoting an existing user's role from pr Deno.test("findOrProvisionUser: keeps existing user's role unchanged when provider reports no roles", async () => { const storage = createMemoryStorage(); - const users = createUserManager({ storage, usersDir: ".users" }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); await users.create({ username: "bob", email: "bob@example.com", @@ -188,7 +189,7 @@ Deno.test("findOrProvisionUser: keeps existing user's role unchanged when provid Deno.test("findOrProvisionUser: syncs display name from the provider on each login", async () => { const storage = createMemoryStorage(); - const users = createUserManager({ storage, usersDir: ".users" }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); await users.create({ username: "bob", email: "bob@example.com", @@ -210,7 +211,7 @@ Deno.test("findOrProvisionUser: syncs display name from the provider on each log Deno.test("LocalAuthProvider: authenticate returns the user's role wrapped in a roles array", async () => { const storage = createMemoryStorage(); - const users = createUserManager({ storage, usersDir: ".users" }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); await users.create({ username: "alice", email: "alice@example.com", @@ -219,7 +220,7 @@ Deno.test("LocalAuthProvider: authenticate returns the user's role wrapped in a name: "Alice", }); - const provider = new LocalAuthProvider(users); + const provider = new LocalAuthProvider(users, makeServices(null).passwords); const result = await provider.authenticate({ username: "alice", password: "password123" }); assertEquals(result?.roles, ["admin"]); @@ -227,7 +228,7 @@ Deno.test("LocalAuthProvider: authenticate returns the user's role wrapped in a Deno.test("LocalAuthProvider: authenticate returns null for wrong password", async () => { const storage = createMemoryStorage(); - const users = createUserManager({ storage, usersDir: ".users" }); + const users = createUserManager({ storage, usersDir: ".users", services: makeServices(storage) }); await users.create({ username: "alice", email: "alice@example.com", @@ -236,7 +237,7 @@ Deno.test("LocalAuthProvider: authenticate returns null for wrong password", asy name: "Alice", }); - const provider = new LocalAuthProvider(users); + const provider = new LocalAuthProvider(users, makeServices(null).passwords); const result = await provider.authenticate({ username: "alice", password: "wrong" }); assertEquals(result, null); diff --git a/tests/admin/render_markdown_test.ts b/tests/admin/render_markdown_test.ts index 8d64d2b..6405c2a 100644 --- a/tests/admin/render_markdown_test.ts +++ b/tests/admin/render_markdown_test.ts @@ -5,6 +5,7 @@ * No filesystem access — all tests operate on in-memory content strings. */ +import { makeServices } from "../helpers/services.ts"; import { assertEquals, assertExists, @@ -32,6 +33,7 @@ async function callRenderMarkdown(body: unknown): Promise { state: { auth: { authenticated: true, user: { id: "1", roles: ["admin"] } }, adminContext: { + services: makeServices(null), authz: { check: () => Promise.resolve(true) }, auditLogger: null, config: { system: { debug: false } }, diff --git a/tests/admin/role_grants_e2e_test.ts b/tests/admin/role_grants_e2e_test.ts new file mode 100644 index 0000000..6198d90 --- /dev/null +++ b/tests/admin/role_grants_e2e_test.ts @@ -0,0 +1,62 @@ +/** + * With the admin installed, `system.role_grants: {}` leaves a site user who + * holds `editor` without editing rights, while the admin's own account keeps + * working. A tuple an earlier version seeded for the site user is revoked. + */ + +import { assertEquals } from "@std/assert"; +import { join } from "@std/path"; +import { bootstrap } from "@dune/core/bootstrap"; +import { createDuneApp } from "@dune/core/fresh-app"; +import { createStorage } from "@dune/core/storage"; +import { createLocalUserStore } from "@dune/core/auth/user-store"; +import { hashPassword } from "@dune/core/auth/passwords"; +import { SITE_RESOURCE } from "@dune/core/auth/authz-schema"; +import { createAdminPlugin } from "../../mod.ts"; + +async function run(systemYaml: string, preseedVisitor: boolean) { + const root = await Deno.makeTempDir({ prefix: "dune_admin_role_grants_" }); + try { + await Deno.mkdir(join(root, "config"), { recursive: true }); + await Deno.writeTextFile(join(root, "config", "site.yaml"), 'title: "T"\nurl: "http://localhost:8000"\nauth:\n mode: dune\n'); + await Deno.writeTextFile(join(root, "config", "system.yaml"), systemYaml); + await Deno.mkdir(join(root, "content", "01.home"), { recursive: true }); + await Deno.writeTextFile(join(root, "content", "01.home", "default.md"), "---\ntitle: Home\n---\n\nHi\n"); + const users = createLocalUserStore({ storage: createStorage({ rootDir: root }), usersDir: "data/users" }); + const admin = await users.create({ + email: "a@example.test", + username: "ada", + provider: "local", + passwordHash: await hashPassword("correct horse battery staple"), + roles: ["admin"], + }); + const visitor = await users.create({ email: "v@example.test", provider: "github", roles: ["editor"] }); + + const ctx = await bootstrap(root, { plugins: [createAdminPlugin({ root, dev: false })] }); + if (preseedVisitor) { + // What 0.35 and the first 0.36 builds did: a direct tuple for any user with the role. + await ctx.authz!.allow({ who: { type: "user", id: visitor.id }, toBe: "editor", onWhat: SITE_RESOURCE }); + } + await createDuneApp(ctx, { root, port: 0, dev: false }); + const can = (id: string) => + // deno-lint-ignore no-explicit-any + ctx.authz!.check({ who: { type: "user", id }, canThey: "pages.update" as any, onWhat: SITE_RESOURCE }); + return { admin: await can(admin.id), visitor: await can(visitor.id) }; + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +} + +const opts = { sanitizeOps: false, sanitizeResources: false }; + +Deno.test("default grants: the admin account and the site user holding editor both edit", opts, async () => { + assertEquals(await run("debug: false\n", false), { admin: true, visitor: true }); +}); + +Deno.test("role_grants {}: the admin account keeps its rights, the site user does not", opts, async () => { + assertEquals(await run("role_grants: {}\n", false), { admin: true, visitor: false }); +}); + +Deno.test("role_grants {}: a direct tuple an earlier version seeded for the site user is revoked at start", opts, async () => { + assertEquals(await run("role_grants: {}\n", true), { admin: true, visitor: false }); +}); diff --git a/tests/admin/search_analytics_test.ts b/tests/admin/search_analytics_test.ts new file mode 100644 index 0000000..5accb82 --- /dev/null +++ b/tests/admin/search_analytics_test.ts @@ -0,0 +1,32 @@ +/** GET /admin/api/search/analytics reads the summary core's recorder keeps. */ + +import { assertEquals } from "@std/assert"; +import { handler } from "../../src/admin/routes/api/search/analytics.ts"; +import { makeServices } from "../helpers/services.ts"; + +// deno-lint-ignore no-explicit-any +function ctxWith(searchAnalytics: any): any { + return { + req: new Request("https://cms.example.com/admin/api/search/analytics"), + url: new URL("https://cms.example.com/admin/api/search/analytics"), + state: { + auth: { authenticated: true, user: { id: "1", username: "admin" } }, + adminContext: { + services: makeServices(null, { searchAnalytics }), + authz: { check: () => Promise.resolve(true) }, + }, + }, + }; +} + +Deno.test("search analytics route: disabled reports enabled:false and an empty summary", async () => { + const res = await handler.GET(ctxWith(null)); + assertEquals(res.status, 200); + assertEquals(await res.json(), { enabled: false, totalSearches: 0, since: null, topQueries: [], zeroResultQueries: [] }); +}); + +Deno.test("search analytics route: enabled returns core's summary", async () => { + const summary = { totalSearches: 3, since: 1, topQueries: [{ query: "deno", count: 3, avgResults: 2 }], zeroResultQueries: [] }; + const res = await handler.GET(ctxWith({ record: () => Promise.resolve(), summarize: () => Promise.resolve(summary) })); + assertEquals(await res.json(), { enabled: true, ...summary }); +}); 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/staging_publish_test.ts b/tests/admin/staging_publish_test.ts new file mode 100644 index 0000000..40b19f9 --- /dev/null +++ b/tests/admin/staging_publish_test.ts @@ -0,0 +1,90 @@ +/** + * Publishing a staged draft goes through HTTP: the staging publish route has to + * win over the bare staging wildcard (upsert) route, whose greedy wildcard also + * matches paths ending in /publish. It writes the draft over the page file and + * announces `onPageUpdate`. + */ + +import { assertEquals, assertStringIncludes } from "@std/assert"; +import { join } from "@std/path"; +import type { DunePlugin } from "@dune/core/hooks"; +import { bootstrap } from "@dune/core/bootstrap"; +import { createDuneApp } from "@dune/core/fresh-app"; +import { createStorage } from "@dune/core/storage"; +import { createLocalUserStore } from "@dune/core/auth/user-store"; +import { hashPassword } from "@dune/core/auth/passwords"; +import { createAdminPlugin } from "../../mod.ts"; + +const PASSWORD = "correct horse battery staple"; +const ORIGIN = "http://localhost:8000"; + +Deno.test("a staged draft is published through POST /api/staging/:path*/publish", async () => { + const root = await Deno.makeTempDir({ prefix: "dune_admin_staging_" }); + try { + await Deno.mkdir(join(root, "config"), { recursive: true }); + await Deno.writeTextFile(join(root, "config", "site.yaml"), 'title: "Staging"\nurl: "http://localhost:8000"\n'); + const pageFile = join(root, "content", "01.alpha", "default.md"); + await Deno.mkdir(join(root, "content", "01.alpha"), { recursive: true }); + await Deno.writeTextFile(pageFile, "---\ntitle: Live\n---\n\nlive body\n"); + const users = createLocalUserStore({ storage: createStorage({ rootDir: root }), usersDir: "data/users" }); + await users.create({ + email: "ed@example.test", + username: "ed", + name: "Ed", + provider: "local", + passwordHash: await hashPassword(PASSWORD), + roles: ["admin"], + }); + + const updates: unknown[] = []; + const recorder: DunePlugin = { + name: "recorder", + version: "1.0.0", + hooks: { onPageUpdate: ({ data }: { data: unknown }) => void updates.push(data) } as DunePlugin["hooks"], + }; + const ctx = await bootstrap(root, { plugins: [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}`); + const call = (method: string, path: string, body?: unknown) => + req(path, { + method, + headers: { cookie, origin: ORIGIN, "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + + const staged = await call("POST", "/admin/api/staging/01.alpha/default.md", { + content: "staged body", + frontmatter: { title: "Staged" }, + }); + assertEquals(staged.status, 200); + await staged.body?.cancel(); + // Staging a draft does not touch the live page. + assertStringIncludes(await Deno.readTextFile(pageFile), "live body"); + + const published = await call("POST", "/admin/api/staging/01.alpha/default.md/publish"); + assertEquals(published.status, 200); + assertEquals(await published.json(), { published: true, sourcePath: "01.alpha/default.md" }); + + const written = await Deno.readTextFile(pageFile); + assertStringIncludes(written, "title: Staged"); + assertStringIncludes(written, "staged body"); + + await new Promise((r) => setTimeout(r, 25)); + assertEquals(updates, [{ sourcePath: "01.alpha/default.md", actor: "ed" }]); + + // The draft is consumed. + const after = await call("GET", "/admin/api/staging/01.alpha/default.md"); + assertEquals(await after.json(), { draft: null }); + } finally { + await Deno.remove(root, { recursive: true }).catch(() => {}); + } +}); diff --git a/tests/admin/theme_config_test.ts b/tests/admin/theme_config_test.ts index f9406f4..a630dc3 100644 --- a/tests/admin/theme_config_test.ts +++ b/tests/admin/theme_config_test.ts @@ -7,6 +7,7 @@ import { assertEquals } from "https://deno.land/std@0.224.0/assert/mod.ts"; import { handler } from "../../src/admin/routes/api/config/theme-config.ts"; +import { makeServices } from "../helpers/services.ts"; // deno-lint-ignore no-explicit-any function makeStorage(initial: Record = {}): any { @@ -48,9 +49,10 @@ function makeCtx(opts: { state: { auth: { authenticated: true, user: { id: "1", username: "admin" } }, adminContext: { + services: makeServices(opts.storage), engine, storage: opts.storage, - config: { admin: { dataDir: "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/tsx_write_test.ts b/tests/admin/tsx_write_test.ts index ae0d231..f9ab51f 100644 --- a/tests/admin/tsx_write_test.ts +++ b/tests/admin/tsx_write_test.ts @@ -13,7 +13,7 @@ function ctx(roles: string[], allowTsxFormat?: string[]): any { adminContext: { config: { system: { - content: allowTsxFormat ? { allowTsxFormat } : {}, + content: allowTsxFormat ? { allow_tsx_format: allowTsxFormat } : {}, }, }, }, diff --git a/tests/admin/webhook_deliveries_test.ts b/tests/admin/webhook_deliveries_test.ts index ea88b7a..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: { runtimeDir: 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/webhook_listener_test.ts b/tests/admin/webhook_listener_test.ts new file mode 100644 index 0000000..3e768bb --- /dev/null +++ b/tests/admin/webhook_listener_test.ts @@ -0,0 +1,101 @@ +/** + * Content webhooks follow the page events, whoever fires them: a write from the + * inline-edit bar, MCP or the CLI reaches the admin's configured endpoints just + * like one from an admin route. + */ + +import { assertEquals } from "@std/assert"; +import { join } from "@std/path"; +import { listenForContentWebhooks } from "../../src/admin/webhooks.ts"; +import type { WebhookEndpointConfig } from "../../src/admin/settings.ts"; + +type Handler = (ctx: { data: unknown }) => void; + +function fakeHooks() { + const handlers = new Map(); + return { + on: (event: string, handler: Handler) => { + handlers.set(event, [...(handlers.get(event) ?? []), handler]); + }, + fire: (event: string, data: unknown) => (handlers.get(event) ?? []).forEach((h) => h({ data })), + events: () => [...handlers.keys()].sort(), + }; +} + +async function withReceiver(run: (url: string, received: unknown[]) => Promise) { + const received: unknown[] = []; + const server = Deno.serve({ port: 0, onListen: () => {} }, async (req) => { + received.push(await req.json()); + return new Response("ok"); + }); + try { + await run(`http://127.0.0.1:${server.addr.port}/`, received); + } finally { + await server.shutdown(); + } +} + +const security = { safeFetch: (url: string, init?: RequestInit) => fetch(url, init) }; + +async function settle(received: unknown[], count: number) { + for (let i = 0; i < 100 && received.length < count; i++) await new Promise((r) => setTimeout(r, 20)); +} + +Deno.test("a page event fired by anyone is delivered, with its actor", async () => { + const runtimeDir = await Deno.makeTempDir({ prefix: "dune_webhook_listener_" }); + try { + await withReceiver(async (url, received) => { + const hooks = fakeHooks(); + const endpoints: WebhookEndpointConfig[] = [{ url, events: ["onPageUpdate"], allow_private: true }]; + listenForContentWebhooks({ + // deno-lint-ignore no-explicit-any + hooks: hooks as any, + endpoints: () => endpoints, + runtimeDir, + security, + }); + assertEquals(hooks.events(), ["onPageCreate", "onPageDelete", "onPageMove", "onPageUpdate", "onWorkflowChange"]); + + // What plugin-inline-edit fires after a save. + hooks.fire("onPageUpdate", { sourcePath: "about/default.md", actor: "alice" }); + // Not subscribed: never delivered. + hooks.fire("onPageDelete", { sourcePath: "about/default.md" }); + await settle(received, 1); + await new Promise((r) => setTimeout(r, 100)); + + assertEquals(received.length, 1); + const body = received[0] as { event: string; payload: unknown }; + assertEquals(body.event, "onPageUpdate"); + assertEquals(body.payload, { sourcePath: "about/default.md", actor: "alice" }); + }); + } finally { + await Deno.remove(runtimeDir, { recursive: true }).catch(() => {}); + } +}); + +Deno.test("the endpoint list is read per event, so a settings change applies at once", async () => { + const runtimeDir = await Deno.makeTempDir({ prefix: "dune_webhook_listener_" }); + try { + await withReceiver(async (url, received) => { + const hooks = fakeHooks(); + let endpoints: WebhookEndpointConfig[] = []; + listenForContentWebhooks({ + // deno-lint-ignore no-explicit-any + hooks: hooks as any, + endpoints: () => endpoints, + runtimeDir, + security, + }); + hooks.fire("onPageCreate", { sourcePath: "a/default.md", title: "A" }); + await new Promise((r) => setTimeout(r, 100)); + assertEquals(received.length, 0); + + endpoints = [{ url, events: ["onPageCreate"], allow_private: true }]; + hooks.fire("onPageCreate", { sourcePath: "b/default.md", title: "B" }); + await settle(received, 1); + assertEquals(received.length, 1); + }); + } finally { + await Deno.remove(runtimeDir, { recursive: true }).catch(() => {}); + } +}); diff --git a/tests/admin/workflow_actions_test.ts b/tests/admin/workflow_actions_test.ts index 8ec70c9..012bb38 100644 --- a/tests/admin/workflow_actions_test.ts +++ b/tests/admin/workflow_actions_test.ts @@ -47,7 +47,7 @@ function makePageIndex(overrides: Partial = {}): PageIndex { const CONFIG = { system: { content: { dir: "content" } }, - admin: { webhooks: [], runtimeDir: ".dune/admin" }, + 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/helpers/services.ts b/tests/helpers/services.ts new file mode 100644 index 0000000..03f4408 --- /dev/null +++ b/tests/helpers/services.ts @@ -0,0 +1,13 @@ +import { createPluginServices } from "@dune/core/plugin-api"; +import type { PluginServices, PluginServicesOptions } from "@dune/core/plugin-api"; +import type { DuneConfig } from "@dune/core/config"; +import type { StorageAdapter } from "@dune/core/storage"; + +/** Core's plugin services over a test storage (default config: local sessions). */ +export function makeServices(storage: unknown, extra: Partial = {}): PluginServices { + return createPluginServices({ + config: { system: {}, site: {} } as unknown as DuneConfig, + storage: storage as StorageAdapter, + ...extra, + }); +} 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); +});