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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ follows [Semantic Versioning](https://semver.org).

---

## [Unreleased]
## [3.1.0] — 2026-09-02

### Fixed

Expand All @@ -32,9 +32,25 @@ follows [Semantic Versioning](https://semver.org).
`admin/guards.ts`'s own example and in the plugin-authoring guide
(duneorg/dune-docs#4).

### Added

- **`AdminPermission` widened to accept any string, not just the built-in
admin actions.** `@dune/core@^0.34.4`'s `DunePlugin.authzActions` lets a
plugin declare its own admin-permission action (e.g. `"billing.manage"`)
and gate a route behind it via `withGuards()`/`requirePermission()` the
identical way as a built-in one — `authz.check()` is the real authority
either way, so this package's own closed union was the only thing
actually stopping a plugin author from passing a custom action through
without a `permission: "..." as never` type-level workaround.
`guards.ts`'s own doc example now shows a plugin declaring and using
its own action. No runtime change — `checkPermission()`/
`requirePermission()`/`withGuards()` were already just forwarding
whatever string they were given to `authz.check()`.

### Requires

- `@dune/core@^0.34.4` or later (for `DunePlugin.mountEarly()`).
- `@dune/core@^0.34.4` or later (for `DunePlugin.mountEarly()` and
`DunePlugin.authzActions`) — already published.

## [3.0.0] — 2026-08-29

Expand Down
33 changes: 27 additions & 6 deletions src/admin/guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,38 @@
* Use `csrfCheck`/`requirePermission`/`validatePagePath` directly only when
* `withGuards`' shape doesn't fit.
*
* `permission` isn't limited to the built-in admin actions — a plugin can
* declare its own via `DunePlugin.authzActions` (`@dune/core`) and gate a
* route behind it the identical way, instead of reusing an existing,
* semantically-mismatched permission or hand-rolling a check outside the
* authz system entirely. `AdminPermission` accepts any string for exactly
* this reason (it isn't a fully closed union) — see that type's own doc
* comment.
*
* @example
* ```ts
* import type { DunePlugin } from "@dune/core/hooks";
* import { withGuards } from "@dune/plugin-admin/admin/guards";
*
* app.post("/admin/my-plugin/rotate-key", withGuards(
* { permission: "settings.update" },
* async (ctx) => {
* // csrfCheck() and requirePermission() have already run and passed.
* return Response.json({ ok: true });
* export default {
* name: "my-billing-plugin",
* version: "1.0.0",
* // Registers a new admin permission this plugin's own routes gate on —
* // merged into the site's authz schema at bootstrap.
* authzActions: {
* "billing.manage": ["admin"],
* },
* async mount({ app }) {
* app.post("/admin/my-plugin/rotate-key", withGuards(
* { permission: "billing.manage" },
* async (ctx) => {
* // csrfCheck() and requirePermission() have already run and passed.
* return Response.json({ ok: true });
* },
* ));
* },
* ));
* hooks: {},
* } satisfies DunePlugin;
* ```
*
* @module
Expand Down
16 changes: 14 additions & 2 deletions src/admin/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,25 @@ export type { Role };
import type { User } from "@dune/core/auth/types";
export type { User };

/** All possible admin permissions */
/**
* Every built-in admin permission, plus any action a plugin registered via
* `DunePlugin.authzActions` (`@dune/core`) — the `(string & {})` half
* keeps this from being a fully closed union (which could never include a
* plugin's own action, unknown to this package at its own compile time)
* while still giving IDE autocomplete for the built-ins here. Not
* type-checked against what's actually registered — `authz.check()` (or,
* for the one synchronous path, `roleHasPermission()`) is the real
* authority on whether a given string names a real action; passing one
* that doesn't exist on the site's schema just always denies.
*/
export type AdminPermission =
| "pages.create" | "pages.read" | "pages.update" | "pages.delete"
| "media.upload" | "media.read" | "media.delete"
| "users.create" | "users.read" | "users.update" | "users.delete"
| "config.read" | "config.update"
| "submissions.read" | "submissions.delete";
| "submissions.read" | "submissions.delete"
// deno-lint-ignore ban-types
| (string & {});

/** Admin configuration (added to DuneConfig) */
export interface AdminConfig {
Expand Down
25 changes: 25 additions & 0 deletions tests/admin/public_guards_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,31 @@ Deno.test("withGuards: all guards passing reaches the handler", async () => {
assertEquals(await res.text(), "ran with path=my-plugin/settings");
});

Deno.test("withGuards: a plugin-declared permission (not a built-in AdminPermission) type-checks and forwards to authz.check() with no cast", async () => {
// AdminPermission widened to accept any string (`@dune/core`'s
// DunePlugin.authzActions lets a plugin declare its own action) — unlike
// the tests above, this one needs no `as never`/`as any` escape hatch to
// pass a permission string this package's own closed built-in union
// never listed. checkPermission()/requirePermission()/withGuards() don't
// themselves validate the string against a schema — that's authz.check()'s
// job (see @dune/core's authz_plugin_actions_test.ts for the real
// end-to-end resolution through a bootstrapped authz system) — this just
// proves the plumbing here accepts and forwards it correctly.
const guarded = withGuards(
{ permission: "billing.manage" },
() => new Response("ran"),
);
const allowed = await guarded(
makeCtx("POST", { authzAllows: true }),
);
assertEquals(allowed.status, 200);

const denied = await guarded(
makeCtx("POST", { authzAllows: false }),
);
assertEquals(denied.status, 403);
});

Deno.test("withGuards: csrf: false opts out of the CSRF check", async () => {
const guarded = withGuards(
{ csrf: false },
Expand Down