diff --git a/skills/dune-auth.md b/skills/dune-auth.md index 95f8d2a2..1a599e6f 100644 --- a/skills/dune-auth.md +++ b/skills/dune-auth.md @@ -31,31 +31,31 @@ Dune owns the session lifecycle. Credential verification is delegated to OAuth p # site.yaml auth: mode: dune # default — can be omitted - userStore: session # identity embedded in the session record — no data/users/ files - sessionLifetime: 604800 # seconds (7 days) — flat field, default 2592000 (30 days) + user_store: session # identity embedded in the session record — no data/users/ files + session_lifetime: 604800 # seconds (7 days) — flat field, default 2592000 (30 days) providers: github: - clientId: "${GITHUB_CLIENT_ID}" - clientSecret: "${GITHUB_CLIENT_SECRET}" + client_id: "${GITHUB_CLIENT_ID}" + client_secret: "${GITHUB_CLIENT_SECRET}" ``` -**There is no `session.secret`/`SESSION_SECRET` config anywhere in the real auth system.** Sessions are opaque server-tracked IDs, not signed/encrypted cookies — nothing reads a signing secret. By default they're stored under `{admin.runtimeDir}/site-sessions/`, using the same `SessionStore` mechanism (local/KV/Redis) admin sessions use — set `system.session_store` to switch backends; see `src/session/mod.ts`. See Gotchas. +**There is no `session.secret`/`SESSION_SECRET` config anywhere in the real auth system.** Sessions are opaque server-tracked IDs, not signed/encrypted cookies — nothing reads a signing secret. By default they're stored under `{runtime_dir}/site-sessions/` (`system.runtime_dir`, default `.dune/admin`), using the same `SessionStore` mechanism (local/KV/Redis) admin sessions use — set `system.session_store` to switch backends; see `src/session/mod.ts`. See Gotchas. ### With persistent user records + magic link ```yaml auth: mode: dune - userStore: local # JSON files at data/users/ + by-email/ index — the same store @dune/plugin-admin's admin accounts live in + user_store: local # JSON files at data/users/ + by-email/ index — the same store @dune/plugin-admin's admin accounts live in providers: github: - clientId: "${GITHUB_CLIENT_ID}" - clientSecret: "${GITHUB_CLIENT_SECRET}" - magicLink: + client_id: "${GITHUB_CLIENT_ID}" + client_secret: "${GITHUB_CLIENT_SECRET}" + magic_link: enabled: true # passwordless — requires email config (see dune-email skill) ``` -**There is no `providers.localUsers` config, at any userStore tier** — self-service signup is still only OAuth or magic link. But an admin *can* now pre-provision an account ahead of the person's first login: `dune users:create [--role x[,y]] [--name "..."]` creates a `User` record directly (`provider: "invited"`). The invited person's first login via magic link matches it by email and just works; a first login via OAuth 409s instead (no linked provider yet on a record nobody's ever actually authenticated as) — see "Linking an additional provider" below for the fix. +**There is no `providers.localUsers` config, at any user_store tier** — self-service signup is still only OAuth or magic link. But an admin *can* now pre-provision an account ahead of the person's first login: `dune users:create [--role x[,y]] [--name "..."]` creates a `User` record directly (`provider: "invited"`). The invited person's first login via magic link matches it by email and just works; a first login via OAuth 409s instead (no linked provider yet on a record nobody's ever actually authenticated as) — see "Linking an additional provider" below for the fix. ### Routes generated by mode 1 @@ -100,15 +100,15 @@ auth: jwt: issuer: "https://your-app.clerk.accounts.dev" audience: "your-app" - jwksUrl: "https://your-app.clerk.accounts.dev/.well-known/jwks.json" - userIdClaim: sub # default "sub" — flat fields, not a nested userClaims: {} map - emailClaim: email # default "email" - rolesClaim: roles # default "roles" + jwks_url: "https://your-app.clerk.accounts.dev/.well-known/jwks.json" + user_id_claim: sub # default "sub" — flat fields, not a nested userClaims: {} map + email_claim: email # default "email" + roles_claim: roles # default "roles" ``` -There is no `userClaims: { id, email, name }` nested map — `userIdClaim`/`emailClaim`/`rolesClaim` are flat fields on `jwt:`, all optional with the defaults shown, and there is no name-claim option at all (name isn't extracted from the JWT in this mode). `issuer` and `audience` are required — `mountDuneAuth()` refuses to start without both. Without them, any token signed by the same JWKS endpoint is accepted, including tokens minted for a different tenant on a shared IdP. +There is no `userClaims: { id, email, name }` nested map — `user_id_claim`/`email_claim`/`roles_claim` are flat fields on `jwt:`, all optional with the defaults shown, and there is no name-claim option at all (name isn't extracted from the JWT in this mode). `issuer` and `audience` are required — `mountDuneAuth()` refuses to start without both. Without them, any token signed by the same JWKS endpoint is accepted, including tokens minted for a different tenant on a shared IdP. -No routes generated. No sessions. No user records in Dune. Clients pass `Authorization: Bearer {token}`. Optionally, `auth.webhook` (only active with `mode: external-jwt` + `authzStore: local`) registers `POST /auth/webhook` to handle `user.deleted` events and revoke authz tuples — role changes don't need a webhook, they're reconciled per-request from the JWT claims automatically. +No routes generated. No sessions. No user records in Dune. Clients pass `Authorization: Bearer {token}`. Optionally, `auth.webhook` (only active with `mode: external-jwt` + `authz_store: local`) registers `POST /auth/webhook` to handle `user.deleted` events and revoke authz tuples — role changes don't need a webhook, they're reconciled per-request from the JWT claims automatically. --- @@ -118,7 +118,7 @@ No routes generated. No sessions. No user records in Dune. Clients pass `Authori ```ts interface User { - id: string; // UUID (dune mode) or the JWT's userIdClaim value (external-jwt) + id: string; // UUID (dune mode) or the JWT's user_id_claim value (external-jwt) email: string; name?: string; avatarUrl?: string; @@ -137,11 +137,17 @@ interface User { // ctx.state.siteUser is null on unauthenticated requests — never undefined ``` -(`src/auth/types.ts`.) In `external-jwt` mode, `roles` comes from the JWT's `rolesClaim` on every request — there's no persistent record. In `dune` mode, `roles` is a field stored directly on the `User` record. +(`src/auth/types.ts`.) In `external-jwt` mode, `roles` comes from the JWT's `roles_claim` on every request — there's no persistent record. In `dune` mode, `roles` is a field stored directly on the `User` record. ### Reading it outside a Fresh handler -Generated CRUD route handlers (from `dune codegen`) and other code that only has a raw `Request` (not a Fresh context) can't read `ctx.state` directly. The auth middleware also serializes the resolved user into an internal `x-dune-site-user` request header (JSON, stripped at the edge — never externally settable) for exactly this case: `requireAuth(req, mode)` from `@dune/core/auth/api-guard` reads it. **Hand-written TSX content pages have no automatic `page.siteUser` prop** — if you need the user there, read the header yourself the same way `api-guard.ts` does. +Generated CRUD route handlers (from `dune codegen`) and other code that only has a raw `Request` (not a Fresh context) can't read `ctx.state` directly. The auth middleware also serializes the resolved user into an internal `x-dune-user` request header (JSON, stripped at the edge — never externally settable) for exactly this case: `requireAuth(req, mode)` from `@dune/core/auth/api-guard` reads it. **Hand-written TSX content pages have no automatic `page.siteUser` prop** — if you need the user there, read the header yourself the same way `api-guard.ts` does. + +--- + +## Site users can edit + +A signed-in site user whose `roles` include `admin`, `editor` or `author` may edit pages in place (the editing bar and `/api/inline-edit/*` from `@dune/plugin-inline-edit`), with no admin plugin installed: core checks `pages.update` for them like for an admin account. Their role is also a group membership, kept in step with `roles[]` live, so changing a user's roles takes effect on their next request. Content-gating tags (`member`, `subscriber`) never grant editing. An `author` edits only the pages it created (`createdBy`); `admin` and `editor` edit any page. **Do not use `editor` or `author` as a content tier** (in `roles:` frontmatter, a payment product's `role`, a sign-up default): holding the name is what grants editing. Granting them on purpose, for example to paid contributors, is fine; core logs `auth.automatic_admin_tier_grant` at start and `dune doctor --boot` lists where the config does it. Responses for any signed-in user bypass the shared page cache. --- @@ -165,28 +171,22 @@ export async function handler(req: Request, ctx: FreshContext) { ### Require a specific role -**There is no `ctx.state.authz`.** `mountDuneAuth(app, ctx)` returns `{ resolveUser, authz }`, but Dune doesn't inject `authz` into request state — how you reach it differs by mode: +**There is no `ctx.state.authz`.** `mountDuneAuth(app, ctx)` returns `{ resolveUser, authz }`; `createDuneApp()` keeps the `authz` for core's own checks (the inline-edit gate, the bar) but does not put it in request state. In your own code: -**Default/full mode** (the normal `dune serve` path — `createDuneApp()` calls `mountDuneAuth()` for you and discards the return value): you never call `mountDuneAuth()` yourself, so there's no `authCtx` to stash. Use the project-local `getAuthz()` pattern from the **dune-authz** skill instead (`dune add polizy` scaffolds `src/auth/authz.ts`) — it's a separate `AuthSystem` instance, but reads/writes the same underlying tuple files in `data/permissions/`, so checks stay consistent with what `mountDuneAuth()`'s own instance sees. - -**Headless mode** (site calls `mountDuneAuth()` itself): stash the returned context when you call it: - -```ts -// main.ts -export const authCtx = await mountDuneAuth(app, ctx); // authCtx.authz, authCtx.resolveUser -``` +- **A plugin**: use `services.authz` / `runtime.authz` (see the plugin-authoring skill). +- **A site's own route or middleware**: use the project-local `getAuthz()` pattern from the **dune-authz** skill (`dune add polizy` scaffolds `src/auth/authz.ts`). It is a separate `AuthSystem` instance, but reads/writes the same tuple files in `data/permissions/`, so checks stay consistent. +- **Headless mode** (the site calls `mountDuneAuth()` itself): stash the returned context. ```ts // routes/_middleware.ts import type { FreshContext } from "fresh"; -import { authCtx } from "../main.ts"; // however you exposed it +import { getAuthz } from "../src/auth/authz.ts"; export async function handler(req: Request, ctx: FreshContext) { if (!ctx.state.siteUser) { return Response.redirect(new URL("/auth/login", req.url)); } - if (!authCtx.authz) return new Response(null, { status: 403 }); // null in external-jwt mode - const allowed = await authCtx.authz.check({ + const allowed = await getAuthz().check({ who: { type: "user", id: ctx.state.siteUser.id }, canThey: "access", onWhat: { type: "group", id: "member" }, @@ -196,13 +196,13 @@ export async function handler(req: Request, ctx: FreshContext) { } ``` -`authz` is `null` in `external-jwt` mode — check `ctx.state.siteUser.roles` (from the JWT) directly instead; there's no polizy authz system to call there. +`authz` is `null` in `external-jwt` mode unless `authz_store: local` is set — check `ctx.state.siteUser.roles` (from the JWT) directly instead. For content gating via frontmatter `roles:`, this check runs automatically in the page middleware — no manual wiring needed. See **dune-authz** skill. --- -## `userStore` tiers (mode: dune only) +## `user_store` tiers (mode: dune only) | Value | User records | When to use | |-------|-------------|-------------| @@ -216,7 +216,7 @@ For content gating via frontmatter `roles:`, this check runs automatically in th **No passwords, still no `providers.localUsers`.** Password auth is out of scope for public-site accounts. Self-service signup is still only OAuth or magic link — but `dune users:create` (CLI) *can* pre-provision an account ahead of someone's first login; see "With persistent user records + magic link" above. -**Account linking only works for `userStore: local`/`db`, not `userStore: session`.** `GET /auth/{provider}/link` 400s under `userStore: session` — there's no persistent record to attach a linked identity to (identity there is synthesized fresh from OAuth claims every login). +**Account linking only works for `user_store: local`/`db`, not `user_store: session`.** `GET /auth/{provider}/link` 400s under `user_store: session` — there's no persistent record to attach a linked identity to (identity there is synthesized fresh from OAuth claims every login). **`ctx.state.siteUser`, not `ctx.state.user`.** This is the single most common way to break every example in this file — the field is `siteUser`. @@ -226,10 +226,10 @@ For content gating via frontmatter `roles:`, this check runs automatically in th **There is no session-signing secret to configure.** No `SESSION_SECRET`, no `session.secret` YAML key — sessions are opaque IDs looked up server-side, not self-contained signed cookies. -**`external-jwt` eliminates user storage entirely.** `ctx.state.siteUser.roles` comes from the JWT's `rolesClaim` on every request, not from authz tuples — `authz` is `null` in this mode. +**`external-jwt` eliminates user storage entirely.** `ctx.state.siteUser.roles` comes from the JWT's `roles_claim` on every request, not from authz tuples — `authz` is `null` in this mode. **Magic link routes are `/auth/magic/send` and `/auth/magic`** — not `/auth/magic-link/send`/`/auth/magic-link/verify`. `POST /auth/magic/send` has a fixed-window rate limit owned by Dune (`src/auth/routes.ts`); if you build a custom magic-link flow that calls it directly (or bypasses it entirely), you own your own rate limiting. **`/auth/logout` is `GET`, not `POST`.** A plain link/redirect works. -**TSX content pages don't get `page.siteUser` for free.** Read the `x-dune-site-user` header yourself if you need the user in a hand-written TSX page — see "Reading it outside a Fresh handler" above. +**TSX content pages don't get `page.siteUser` for free.** Read the `x-dune-user` header yourself if you need the user in a hand-written TSX page — see "Reading it outside a Fresh handler" above. diff --git a/skills/dune-authz.md b/skills/dune-authz.md index 3420ef38..a939b393 100644 --- a/skills/dune-authz.md +++ b/skills/dune-authz.md @@ -1,8 +1,8 @@ # Skill: Dune Authorization (Polizy) -Authorization in Dune uses [polizy](https://github.com/bratsos/polizy) — a Zanzibar-inspired relationship-based model. **The schema is fixed and internal to `@dune/core`** — there is no site-authored `defineSchema()` step. `authzStore` just picks where tuples are persisted (flat files or a DB); it does not let you redefine what actions/relations exist. +Authorization in Dune uses [polizy](https://github.com/bratsos/polizy) — a Zanzibar-inspired relationship-based model. **The schema is fixed and internal to `@dune/core`** — there is no site-authored `defineSchema()` step. `authz_store` just picks where tuples are persisted (flat files or a DB); it does not let you redefine what actions/relations exist. A plugin can add actions with `authzActions`, nothing more. -**Read `dune-auth` first.** Everything here about admin-panel checks, route middleware, and resource grants going through a live `authz` instance depends on `mountDuneAuth()` being called — and as documented in that skill, `mountDuneAuth()` currently has no public export from `@dune/core` and is never auto-called. Content gating (`roles:` frontmatter) still works without it, via a documented fallback — see "Content gating" below — but the wired-`authz.check()` path for everything else is presently unreachable the same way public auth is. +**Read `dune-auth` first.** Core creates the authz system whenever the site needs one: a plugin provides `editor.session` (the admin does), a plugin declares `authzActions`, or `site.yaml` has an `auth:` block. A site with none of those has no authz system, and every check below fails closed (deny) except content gating, which falls back to a direct `roles[]` check (see "Content gating"). --- @@ -10,9 +10,10 @@ Authorization in Dune uses [polizy](https://github.com/bratsos/polizy) — a Zan `src/auth/authz-schema.ts` (internal to `@dune/core`) defines everything: -- **Relations**: `member` (group membership, used for content gating), `admin`/`editor`/`author` (direct admin-tier roles), `owner` (per-resource grant). +- **Relations**: `member` (group membership, used for content gating), `admin`/`editor`/`author` (admin-tier roles), `owner` (per-resource grant). - **Actions → relations**: `access` (satisfied by `member`/`admin`/`editor`/`author` — deliberately excludes `owner`, so owning one page doesn't grant general gated-content access), `edit` (satisfied by `owner`/`admin`/`editor`), and the full `AdminPermission` set mirrored 1:1 — `pages.create/read/update/delete`, `media.upload/read/delete`, `users.create/read/update/delete`, `config.read/update`, `submissions.read/delete`. -- **Subject type**: `user` only. **Object types**: `group`, `app`, `resource`. +- **Subject types**: `user` and `group`. **Object types**: `group`, `app`, `resource`. +- **The site resource**: every site-wide permission is checked on `{ type: "app", id: "site" }` (`SITE_RESOURCE`, exported from `@dune/core`). It names what is protected, not who administers it, so it is the same with or without an admin plugin. (It was `app:admin` before 0.36.) There is no `"pages.view"` — real is `"pages.read"`. @@ -40,7 +41,7 @@ export function getAuthz() { } ``` -`initAuthz(storage)` must be called once, yourself, before `getAuthz()` works anywhere — there's no automatic initialization of this specific file. Its own generated header comment says as much: "In most cases you don't need this file — Dune wires authz automatically when `authzStore: local` is set" — referring to the *separate* internal instance `mountDuneAuth()` creates for the admin panel and content gating, not this file. This file exists for reaching authz from your own custom code (e.g. a hand-written API route) outside anything Dune already wires. As long as `authzStore`/`dataDir` match, both instances read and write the same underlying flat files in `data/permissions/` — so grants are consistent in practice, but they are two separate `AuthSystem` objects, not one shared singleton. +`initAuthz(storage)` must be called once, yourself, before `getAuthz()` works anywhere — there's no automatic initialization of this specific file. Its own generated header comment says as much: "In most cases you don't need this file — Dune wires authz automatically." Dune's own instance (the one core, the admin and the public login share) is separate from this one. This file exists for reaching authz from your own custom code (e.g. a hand-written API route). As long as `authz_store`/`dataDir` match, both instances read and write the same underlying flat files in `data/permissions/` — so grants are consistent in practice, but they are two separate `AuthSystem` objects, not one shared singleton. A plugin should not scaffold this file: it receives `services.authz` and `runtime.authz` instead. There is no `import { authz } from "@/auth/authz.ts";` bare-constant pattern — use `getAuthz()` (after `initAuthz()` has run). @@ -51,11 +52,11 @@ There is no `import { authz } from "@/auth/authz.ts";` bare-constant pattern — ```yaml # site.yaml auth: - authzStore: local # default — data/permissions/*.json, rebuilt into an in-memory Map on startup (no Deno KV involved) - # authzStore: db # opt-in — requires a real database adapter (DUNE_DB_URL/DUNE_DB_PATH), not a separate "schema layer" feature + authz_store: local # default — data/permissions/*.json, rebuilt into an in-memory Map on startup (no Deno KV involved) + # authz_store: db # opt-in — requires a real database adapter (DUNE_DB_URL/DUNE_DB_PATH), not a separate "schema layer" feature ``` -`authzStore` is independent of `userStore` (from `dune-auth`) — you can have `userStore: session` and `authzStore: local`. +`authz_store` is independent of `user_store` (from `dune-auth`) — you can have `user_store: session` and `authz_store: local`. --- @@ -63,17 +64,19 @@ auth: All checks are async. Always `await`. **Mind which user type you're checking** — admin-panel actions use the admin user's id (from plugin-admin's own `AdminState.auth`, a completely separate identity from public site users); group membership / content gating / resource grants use the public `ctx.state.siteUser.id` (not `ctx.state.user` — see `dune-auth`). -### Admin panel access +### Site-wide permissions (editing, admin panel) ```ts -const canAdmin = await getAuthz().check({ - who: { type: "user", id: adminUserId }, // the ADMIN user's id, not ctx.state.siteUser - canThey: "access", - onWhat: { type: "app", id: "admin" }, +const canEdit = await getAuthz().check({ + who: { type: "user", id: userId }, // an admin account, or a site user: both are User records + canThey: "pages.update", // or "access", "config.update", ... + onWhat: { type: "app", id: "site" }, // SITE_RESOURCE }); ``` -In practice, `plugin-admin`'s own route guards don't call this directly for you — `requirePermission(ctx, perm)` (internal to `@dune/plugin-admin`) already does: it calls `authz.check()` and denies if `authz` is undefined. There is no role-table fallback. You don't need to duplicate this in an admin route. +Who holds what: a **site user** gets their admin-tier role (`admin`/`editor`/`author` in `roles[]`) through the role's group: Dune writes `(group:, , app:site)` once at startup (`bootstrapRoleGrants`) and the user is a `member` of `group:`. The **admin plugin's own accounts** get direct tuples (`services.authz.seedAdminTuples([{ id, role }])` from a plugin). Either way `authz.check()` answers the same, so a signed-in site user with the `editor` role may edit inline with no admin plugin installed. + +You rarely call this yourself: the admin's route guards (`requirePermission`) and core's inline-edit gate already do, and deny when `authz` is undefined. There is no role-table fallback. ### Group membership (content gating, membership sites) @@ -145,7 +148,7 @@ roles: [] # authenticated-only — any logged-in site user # roles absent # public — no check performed ``` -When a polizy `AuthSystem` has been wired into the gating layer via `setGatingAuthz()` (called from inside `mountDuneAuth()` — see the top of this file for why that's currently unreachable), checks go through `authz.check()` for full group-hierarchy support. **Without one wired — the current default state of every site — gating falls back to a direct `siteUser.roles[]` array check, same semantics, no polizy dependency.** This fallback is a deliberate, documented feature (`src/auth/gating.ts`), not a degraded mode to route around — but it means the hierarchy/inheritance behavior polizy would add isn't active until `mountDuneAuth()` is reachable and called. +Core wires its authz system into the gating layer (`setGatingAuthz()`, from `mountDuneAuth()`) whenever the site has an `auth:` block, so checks go through `authz.check()` with full group support. **When none is wired, gating falls back to a direct `siteUser.roles[]` array check — same semantics, no polizy dependency.** That fallback is deliberate (`src/auth/gating.ts`), not a degraded mode to route around. Unauthenticated users are redirected to `/auth/login`. Authenticated users without the required role get a 403. @@ -155,18 +158,21 @@ Unauthenticated users are redirected to `/auth/login`. Authenticated users witho ## Bootstrap path (existing users → tuples) -Two separate, real bootstrap functions (`src/auth/authz.ts`) — don't conflate them: +Tuples are derived data: core rebuilds them from the user records at every start, idempotently. Three functions in `src/auth/authz.ts` (don't conflate them): + +- **`bootstrapRoleGrants(authz, adapter)`** — once per start: grants each admin-tier role to its group on the site resource, `(group:admin|editor|author, , app:site)`. This is what lets a site user's role carry `pages.update` with no per-user tuple. +- **`bootstrapRoleTuples(authz, adapter, siteUsers)`** — makes every user a `member` of `group:` for each entry of their `roles[]` (plural array). Runs at start for existing site users. +- **`bootstrapAdminTuples(authz, adapter, adminUsers)`** — direct `(user, , app:site)` tuples for the admin plugin's own accounts, reached through `services.authz.seedAdminTuples()`. Idempotent. A bootstrap that never runs `mountPlugins()` (`dune mcp:serve`'s lightweight one, for instance) does not run it. -- **`bootstrapAdminTuples(authz, adapter, adminUsers)`** — for admin users (`data/users/*.json`, single `role: "admin" | "editor" | "author"` field). Grants a direct tuple: `authz.allow({ who: user, toBe: user.role, onWhat: { type: "app", id: "admin" } })`. Idempotent, called from `@dune/plugin-admin`'s own `mount()` (`plugin-admin/mod.ts`) — **not** literally inside core's `bootstrap()` function, despite running invisibly in the normal `dune serve`/`dune dev` path once `mountPlugins()` runs. Anything that only calls `bootstrap()` without also running `mountPlugins()`/`mountDuneAdmin()` — `dune mcp:serve`'s lightweight bootstrap, for instance — never triggers this step, same caveat as `publicRoutes`/`adminPages` in `dune-plugin-authoring`. -- **`bootstrapRoleTuples(authz, adapter, siteUsers)`** — for public site users (`SiteUser.roles: string[]`, plural array, not a single field). Grants group memberships via `authz.addMember()` for each role in the array. This one is only reachable through `mountDuneAuth()`, same caveat as everything else public-auth-related in this file. +**Role edits apply live, no restart.** A user store obtained from the site login or from `services.users.createLocalStore()` is wrapped (`withRoleSync`, `syncUserRoles`) so create, update of `roles` and delete keep the group memberships in step with `roles[]`; `dune users:create` and `dune users:grant-role` do the same. If you write user JSON files by hand, the change is picked up at the next start. -From then on, tuples are the authority for whichever system was bootstrapped — admin `role`/site `roles[]` become legacy hints once tuples exist. Use `authz.allow()`/`addMember()` to manage permissions going forward, not by editing the role fields directly. +Because tuples are rebuilt from `roles[]`, `roles[]` is the thing to edit for a role: a tuple you add by hand for an admin-tier role is not a durable grant. Use `authz.allow()`/`addMember()` for extra grants (a payment, a resource owner) that `roles[]` does not express. --- ## Permission tuple storage -`authzStore: local` stores tuples one-per-file under `data/permissions/`: +`authz_store: local` stores tuples one-per-file under `data/permissions/`: ``` data/permissions/{uuid}.json → { id, subject: {type, id}, relation, object: {type, id}, condition?, hmac? } @@ -182,12 +188,12 @@ Rebuilt into an **in-memory `Map`** on startup (`AuthzLocalAdapter` — no Deno **`allow()` uses `toBe`, not `canThey`.** See "Grant a direct permission" above — this is a real, easy copy-paste mistake. -**Admin-panel checks use the admin user's id, not `ctx.state.siteUser.id`.** These are two completely separate identity systems (see `dune-auth`) — don't mix them into one "the current user" concept when calling `authz.check()`. +**Check the id of whoever is acting.** Admin accounts and site users are the same `User` record type with different sessions: an admin-panel request is identified by the admin session, a public-site request by `ctx.state.siteUser`. Use the id of the one you are authorising. **There is no site-authored `defineSchema()` step.** The schema is fixed inside `@dune/core` (`src/auth/authz-schema.ts`). You cannot add new relations/actions to this system the way the polizy package's own generic docs might suggest for a from-scratch integration. -**Content gating's fallback is not a bug to work around.** Without `mountDuneAuth()` wired (the current default), `roles:` frontmatter gating still works via a direct array check — it just doesn't get polizy's hierarchy/inheritance behavior. This is documented, deliberate behavior in `src/auth/gating.ts`, not something broken that needs a manual `authz.check()` bolted on. +**Content gating's fallback is not a bug to work around.** With no authz system wired (a site with no `auth:` block and no admin), `roles:` frontmatter gating still works via a direct array check — it just doesn't get polizy's group behaviour. This is documented, deliberate behaviour in `src/auth/gating.ts`, not something broken that needs a manual `authz.check()` bolted on. -**`external-jwt` mode's relationship to authz tuples depends on `authzStore`.** With `authzStore` omitted (the default in this mode), roles come purely from the JWT's `roles` claim on every request — no local tuple store involved. Setting `authzStore: local` explicitly opts into a hybrid: Dune seeds group-membership tuples from the JWT on first appearance, then re-seeds whenever a per-request fingerprint of the JWT's roles changes (wiping and rebuilding via `disallowAllMatching`) — this lets you layer `authz.addMember()`/`allow()` grants (e.g. after a payment) on top of JWT-derived roles, since only the fingerprinted portion gets wiped on rotation. See `dune-docs`' authorization page for the full mechanism. +**`external-jwt` mode's relationship to authz tuples depends on `authz_store`.** With `authz_store` omitted (the default in this mode), roles come purely from the JWT's `roles` claim on every request — no local tuple store involved. Setting `authz_store: local` explicitly opts into a hybrid: Dune seeds group-membership tuples from the JWT on first appearance, then re-seeds whenever a per-request fingerprint of the JWT's roles changes (wiping and rebuilding via `disallowAllMatching`) — this lets you layer `authz.addMember()`/`allow()` grants (e.g. after a payment) on top of JWT-derived roles, since only the fingerprinted portion gets wiped on rotation. See `dune-docs`' authorization page for the full mechanism. -**HMAC signing of tuple files is opt-in, not automatic.** Set `DUNE_AUTHZ_HMAC_SECRET` (read via `loadHmacKeyFromEnv()`) to enable it. Without it, `dune serve` logs `authz.hmac.disabled` and tuple files are written unsigned — anyone with filesystem access could hand-edit or drop in a tuple file undetected. Decide deliberately, don't assume it's on by default. +**HMAC signing of tuple files is opt-in, not automatic.** Set `DUNE_AUTHZ_HMAC_SECRET` (read via `loadHmacKeyFromEnv()`) to enable it. Without it, `dune serve` logs `authz.hmac.disabled` and tuple files are written unsigned — anyone with filesystem access could hand-edit or drop in a tuple file undetected. Decide deliberately, don't assume it's on by default. With a secret set, tuple files that were changed outside Dune need `dune authz:sign` before the next start accepts them. diff --git a/skills/dune-content.md b/skills/dune-content.md index c6076c12..5ae2ad6d 100644 --- a/skills/dune-content.md +++ b/skills/dune-content.md @@ -294,6 +294,8 @@ The command detects existing numeric-prefix folders (e.g. `02.blog/`) and places ### The HTTP admin API uses session cookies, not Bearer tokens +The admin API exists only when `@dune/plugin-admin` is listed under `plugins:` in `site.yaml` (it is not loaded by default). Without it these URLs 404; write content through the CLI, MCP or the files instead. + **There is no `Authorization: Bearer` support anywhere in the admin API.** `plugin-admin`'s auth middleware (`src/admin/auth/middleware.ts`) reads a session ID from the `Cookie` header only — it never looks at `Authorization` at all. A curl request with a Bearer token gets exactly the same response as one with no auth header: `401 { error: "No session cookie" }`. Log in first and reuse the cookie: ```sh diff --git a/skills/dune-email.md b/skills/dune-email.md index 044cee81..dc9c32ff 100644 --- a/skills/dune-email.md +++ b/skills/dune-email.md @@ -131,7 +131,7 @@ ls .dune/admin/dev-email/ cat .dune/admin/dev-email/1715598000000-ab12cd.json # JSON, not HTML ``` -Or via the admin panel at `/admin/email-preview` (requires `config.read`; both `/admin/api/email-preview` and `/admin/api/email-preview/{id}` 404 outside `DUNE_ENV=dev`). +Or, with `@dune/plugin-admin` listed under `plugins:`, via the admin panel at `/admin/email-preview` (requires `config.read`; both `/admin/api/email-preview` and `/admin/api/email-preview/{id}` 404 outside `DUNE_ENV=dev`). --- diff --git a/skills/dune-jobs.md b/skills/dune-jobs.md index d5f35b92..47d80744 100644 --- a/skills/dune-jobs.md +++ b/skills/dune-jobs.md @@ -89,6 +89,8 @@ Every file you want to actually run must also be listed in `site.yaml`'s `jobs:` ## Admin panel +Only on a site that lists `@dune/plugin-admin` under `plugins:`; jobs run the same without it. + `/admin/jobs` (UI) and `GET /admin/api/jobs` (API, requires `config.read`) list all registered jobs with: - `lastRun` — timestamp of most recent execution, or `null` if never run - `nextRun` — best-estimate next scheduled execution time, or `null` diff --git a/skills/dune-mcp.md b/skills/dune-mcp.md index aac15b90..cda48fc5 100644 --- a/skills/dune-mcp.md +++ b/skills/dune-mcp.md @@ -117,7 +117,7 @@ Get taxonomy values with page counts. ### `get_config` Get site configuration summary (no secrets). -Returns: `site` (title, url, author, taxonomies, feedEnabled, workflowEnabled), `theme` (name, templates, layouts), `system` (contentDir, languages, cacheEnabled, debugMode), `admin` (path, auditEnabled), `plugins` (list of specs). +Returns: `site` (title, url, author, taxonomies, feedEnabled, workflowEnabled), `theme` (name, templates, layouts), `system` (contentDir, contentSrc, languages, debugMode), `plugins` (list of specs). There is no `admin` entry: the admin is a plugin now, and its settings are its plugin config. ### `get_runtime_info` Live snapshot: page counts (total/published/draft), formats breakdown, top-level route sections, taxonomy summaries (with top values), theme name/version/templates/layouts, `generatedAt`. @@ -191,13 +191,12 @@ None of the 9 write/scaffold tools are exported from `@dune/core/mcp`'s public m | `dune://content/taxonomy` | All taxonomy values with counts | | `dune://content/blueprints` | All frontmatter blueprint definitions, inheritance resolved | | `dune://content/forms` | All form definitions under `forms/` — read this before `scaffold_form` | -| `dune://site/audit` | Last 50 admin audit-log entries (empty array if audit logging isn't enabled) | -The last three aren't in the original version of this doc. `dune mcp:serve` registers all 7. A programmatic embedding only gets `forms`/`audit` if it passes `storage` and `root` to `buildResources()` (both optional — omit either and you get 5 resources, not 7, with no error). +`dune mcp:serve` registers all 6. A programmatic embedding only gets `forms` if it passes `storage` and `root` to `buildResources()` (both optional — omit either and you get 5 resources, with no error). The admin audit trail is the admin plugin's own and is not exposed over MCP. ## HTTP API — Content Read/Write -These REST endpoints complement the MCP tools (largely superseded by `get_page_source`/`write_page`/etc. above when you're inside an MCP session, but still relevant for non-MCP tooling or CI). +These REST endpoints belong to `@dune/plugin-admin`, so they exist only on a site that lists it under `plugins:` in `site.yaml`; without it they 404. They complement the MCP tools (largely superseded by `get_page_source`/`write_page`/etc. above when you're inside an MCP session, but still relevant for non-MCP tooling or CI). All require authentication; `dev/apply` additionally requires `DUNE_ENV=dev` or `system.debug: true`. ### `GET /admin/api/page-source?route=/blog/post` diff --git a/skills/dune-plugin-authoring.md b/skills/dune-plugin-authoring.md index d4918b54..29a551bf 100644 --- a/skills/dune-plugin-authoring.md +++ b/skills/dune-plugin-authoring.md @@ -88,7 +88,7 @@ There is **no** `email` or `db` field on this context — nothing is injected fo **`ctx.content` is the same `ContentApi` — `.pages()`, `.page()`, `.search()`, `.taxonomy()` — that `bootstrap.contentApi` exposes**, injected via `hooks.setContentApi()`. It's `undefined`, not a query-capable stub, for the handful of hooks that fire before `bootstrap()` finishes building it: `onConfigLoaded`, `onStorageReady`, `onContentIndexReady`, `onSearchRecordsCollect`, `onSearchEngineCreate`. Present for every other live hook (`onPageCreate`/`onPageUpdate`/`onPageDelete`/`onWorkflowChange`, `onRequest`, `onCacheInvalidate`, `onRebuild`, `onThemeSwitch`). **Also `undefined` on the lightweight, standalone `HookRegistry` instances `content:create` and `migrate:*` (with `--fire-hooks`) build outside a full `bootstrap()`** — those never call `setContentApi()` at all. `content:delete` is not in that group; it runs through a real `bootstrap()`, so its `onPageDelete` gets a working `ctx.content`. Always guard with `ctx.content?.` unless you've confirmed your specific hook only ever fires post-bootstrap. `onContentIndexReady`'s `data` (the raw `PageIndex[]`) is still the only thing available during the earliest bootstrap hooks — filter/map that array directly there instead. -`data`'s shape is different per event — some examples: `onConfigLoaded` → `DuneConfig`; `onContentIndexReady` → `PageIndex[]`; `onRequest` → `Request` directly (not wrapped); `onPageCreate`/`onPageUpdate` → `{ sourcePath: string, title: string }`; `onPageDelete` → `{ sourcePath: string }`; `onWorkflowChange` → `{ sourcePath, from, to }` (`WorkflowStatus`). Check `src/hooks/types.ts`'s `HookEvent` union and the actual `hooks.fire()` call site for a given event (not just the docs) for its real shape — see the note below on hooks that are declared but never fired. +`data`'s shape is different per event — some examples: `onConfigLoaded` → `DuneConfig`; `onContentIndexReady` → `PageIndex[]`; `onRequest` → `Request` directly (not wrapped); `onPageCreate` → `{ sourcePath, title, actor? }`; `onPageUpdate`/`onPageDelete` → `{ sourcePath, actor? }`; `onPageMove` → `{ moves: [{ from, to }], actor? }`; `onWorkflowChange` → `{ sourcePath, from, to }` (`WorkflowStatus`). `actor` is the name of the person who made the write, when a person did (an admin, the inline-edit bar); it is absent for CLI and automated writes. Check `src/hooks/types.ts`'s `HookEvent` union and the actual `hooks.fire()` call site for a given event (not just the docs) for its real shape — see the note below on hooks that are declared but never fired. --- @@ -213,30 +213,33 @@ export default { `hooks: {}` on the plugin object is still required (`DunePlugin.hooks` isn't optional) even if you register everything dynamically through `setup()` instead. -**Every hook in `HookEvent` is fired.** `onRouteResolved`, `onPageLoaded`, `onAfterRender` and `onResponse` used to be declared and never fired; they were removed in 0.36, so registering one is a type error. For the rendered page use `transformResponse` (it sees the finished `Response`); for the template props use `onBeforeRender`. Page-mutation events (`onPageCreate`/`onPageUpdate`/`onPageDelete`/`onPageMove`/`onWorkflowChange`) are fired by whoever writes the page, so a plugin that writes pages must fire them itself. +**Every hook in `HookEvent` is fired.** `onRouteResolved`, `onPageLoaded`, `onAfterRender` and `onResponse` used to be declared and never fired; they were removed in 0.36, so registering one is a type error. For the rendered page use `transformResponse` (it sees the finished `Response`); for the template props use `onBeforeRender`. Page-mutation events (`onPageCreate`/`onPageUpdate`/`onPageDelete`/`onPageMove`/`onWorkflowChange`) are fired by whoever writes the page, so a plugin that writes pages must fire them itself, naming the person in `actor` when there is one. --- ## Admin routes -There is no `onAdminRoutes` hook, and `@dune/core` itself does not export `requirePermission`/`csrfCheck`. You do not need them: a plugin **contributes** its admin routes in `setup()`, and the admin package mounts them behind its own authentication, CSRF check and permission check. Your handler only runs for a request that passed all three. (Contributions are read by whichever admin package is installed; with none, nothing is mounted.) +There is no `onAdminRoutes` hook, and `@dune/core` itself does not export `requirePermission`/`csrfCheck`. You do not need them: a plugin **contributes** its admin routes in `setup()`, and the admin package mounts them behind its own authentication, CSRF check and permission check. Your handler only runs for a request that passed all three. (Contributions are read by the admin package; with none installed, nothing is mounted.) The admin is not loaded by default: a site runs it only if `jsr:@dune/plugin-admin` is listed under `plugins:`. -There are two supported ways to add admin routes, both through `api.capabilities.contribute`: +A plugin owns its extension points as **typed keys** (`CapabilityKey`, made with `capabilityKey(name)`), exported next to its types. The admin's are `ADMIN_PAGES`, `ADMIN_ROUTES` and `ADMIN_EDITOR`, imported from `@dune/plugin-admin`; the inline-edit bar's is `BAR_ACTIONS`, from `@dune/plugin-inline-edit`. There is no module augmentation (`declare module`): JSR refuses it in a published package. Key names are prefixed with the plugin that reads them (`admin.*`, `bar.*`); a plugin that does not want the dependency may recreate a key from its name, `capabilityKey<…>("bar.actions")`, since one name is one capability. -### `admin.pages` — authenticated GET pages with a sidebar entry (the normal case) +There are two supported ways to add admin routes, both through `api.capabilities.contribute` with the admin's keys: + +### `ADMIN_PAGES` (`admin.pages`) — authenticated GET pages with a sidebar entry (the normal case) Each entry adds a route under the admin prefix that's rendered inside the admin shell (sidebar, header) automatically. Auth and the `permission` check are both enforced by the mount code — the handler itself does not need to check anything. ```ts // plugins/my-plugin/mod.ts import type { DunePlugin } from "@dune/core"; +import { ADMIN_PAGES } from "@dune/plugin-admin"; export default { name: "my-plugin", version: "1.0.0", hooks: {}, setup(api) { - api.capabilities.contribute("admin.pages", { + api.capabilities.contribute(ADMIN_PAGES, { path: "/my-plugin", // relative to the admin prefix → /admin/my-plugin label: "My Plugin", icon: "🧩", @@ -255,20 +258,21 @@ export default { `path` is relative to the admin prefix — do not include `/admin` yourself, or you'll register `/admin/admin/my-plugin`. GET only; for anything else use `admin.routes`. -### `admin.routes` — guarded API routes (`POST`/`PUT`/`PATCH`/`DELETE`, or a GET API) +### `ADMIN_ROUTES` (`admin.routes`) — guarded API routes (`POST`/`PUT`/`PATCH`/`DELETE`, or a GET API) For a mutation endpoint, or a GET route that is not a sidebar page: ```ts // plugins/my-plugin/mod.ts import type { DunePlugin } from "@dune/core"; +import { ADMIN_ROUTES } from "@dune/plugin-admin"; export default { name: "my-plugin", version: "1.0.0", hooks: {}, setup(api) { - api.capabilities.contribute("admin.routes", { + api.capabilities.contribute(ADMIN_ROUTES, { method: "POST", path: "/api/my-plugin/action", // relative to the admin prefix → /admin/api/my-plugin/action permission: "config.update", // required — who may call this route @@ -313,7 +317,7 @@ export default { Registered before Dune's content catch-all, so a `publicRoutes` entry takes priority over a content page at the same path. Handlers get a full Fresh context (`ctx.render()`, islands, middleware) — unlike `mount()`, you don't need the raw `app` reference. These routes are **not** admin-guarded (no permission check, no auth) — that's the tradeoff for being public in the first place; add your own check inside the handler if the route needs one. -**`publicRoutes` and the `admin.*` contributions are wired up differently — `publicRoutes` is `@dune/core`-owned, `admin.pages` and `admin.routes` are read by the admin package.** `bootstrap()` collects every plugin's `publicRoutes` onto `BootstrapResult.pluginPublicRoutes`, and `createDuneApp()` itself registers them as live Fresh routes (`registerPluginPublicRoutes()`, `src/runtime/register-plugin-routes.ts`) — this works in every `createDuneApp()` context: `admin.enabled: false`, no `@dune/plugin-admin` installed at all, `dune mcp:serve`'s lightweight bootstrap, all of it. The admin contributions are different: only the admin package reads them (`capabilities.list("admin.pages")` / `("admin.routes")`), because mounting them means enforcing each route's authentication, CSRF and declared `permission` through the admin's own auth system — something `@dune/core` has no reason to depend on. So they need `admin.enabled !== false` (the default) in the normal `dune serve`/`dune dev` path, or an explicit `mountDuneAdmin(app, ctx)` call in headless mode — skip both and they go dark with no error, while `publicRoutes` keeps working regardless. +**`publicRoutes` and the `admin.*` contributions are wired up differently — `publicRoutes` is `@dune/core`-owned, `admin.pages` and `admin.routes` are read by the admin package.** `bootstrap()` collects every plugin's `publicRoutes` onto `BootstrapResult.pluginPublicRoutes`, and `createDuneApp()` itself registers them as live Fresh routes (`registerPluginPublicRoutes()`, `src/runtime/register-plugin-routes.ts`) — this works in every `createDuneApp()` context: no `@dune/plugin-admin` installed, `dune mcp:serve`'s lightweight bootstrap, all of it. The admin contributions are different: only the admin package reads them (`capabilities.list(ADMIN_PAGES)` / `(ADMIN_ROUTES)`), because mounting them means enforcing each route's authentication, CSRF and declared `permission` through the admin's own auth system — something `@dune/core` has no reason to depend on. So they need `jsr:@dune/plugin-admin` listed under `plugins:` in the normal `dune serve`/`dune dev` path, or an explicit `mountDuneAdmin(app, ctx)` call in headless mode — skip both and they go dark with no error, while `publicRoutes` keeps working regardless. Headless-mode developers who call `mountDuneAdmin(app, ctx)` directly (never going through `createDuneApp()`) still get `publicRoutes` too — `mountDuneAdmin()` delegates to the same core `registerPluginPublicRoutes()` function rather than re-implementing it, and calling it twice for the same bootstrap (once from `createDuneApp()`, once from `mountDuneAdmin()` when both happen to run in the same process) is a safe no-op, not a double-registration. @@ -329,7 +333,7 @@ Real values of the `AdminPermission` union (`plugin-admin/src/admin/types.ts`) | `"config.read"` / `"config.update"` | Site config | | `"submissions.read"` / `"submissions.delete"` | Form submissions | -Panel access itself is `authz.check({ canThey: "access", onWhat: { type: "app", id: "admin" } })`, not a permission string. There is no `"admin.access"` — that name existed only on the removed `ROLE_PERMISSIONS` table and is now rejected as unknown. +Panel access itself is `authz.check({ canThey: "access", onWhat: { type: "app", id: "site" } })` (the site resource; it was `app:admin` before 0.36), not a permission string. There is no `"admin.access"` — that name existed only on the removed `ROLE_PERMISSIONS` table and is now rejected as unknown. There is no `"pages.view"`, `"users.manage"`, or `"plugins.manage"` — use the split create/read/update/delete permissions above. @@ -337,6 +341,16 @@ Use the most restrictive permission that still allows the action. --- +## Writing content from a plugin, and serving the inline editor + +**Who is editing.** Core decides, once per request: a signed-in site user, or the user of the `editor.session` provider, who holds `pages.update` by `authz.check()`. A plugin does not re-derive it. For a plugin route outside the admin, read a site user with `getUser(req)` (`@dune/core/auth/types`) or ask `capabilities.get("editor.session")?.authenticate(req)`; for the permission use `services.authz` (it can seed tuples for your own accounts with `seedAdminTuples`; the check itself is `runtime.authz?.check(...)` on `{ type: "app", id: "site" }`). + +**Serving the editor.** The `editor.inline` slot is how `@dune/plugin-inline-edit` plugs in. Core owns `/api/inline-edit/*`: it resolves the editor, checks `pages.update` and (for writes) that the request is same-origin, then calls your `InlineEditManager`: `handleUpgrade(req, user)` for the WebSocket and `handleRequest(req, { editor, path, pageFrontmatter })` for every other request below `/api/inline-edit/`. You choose the routes and apply page-specific rules (which file types, whose pages: `editor.roles`, `pageFrontmatter(sourcePath)`); return 404 for a path you do not serve. Fire `onPageUpdate` with `actor: editor.username` after a write. + +**Reacting to writes.** Webhooks, search indexing and audit are listeners on the page events, not calls inside a particular route: the admin's content webhooks are a hook handler, so a write from the bar, MCP or the CLI reaches them too. Register yours with `hooks.on("onPageUpdate", ...)` from `mount()` (`runtime.hooks`) and read `actor` from the payload. + +--- + ## Testing a plugin Use the plugin integration harness. It boots a real in-memory Dune instance and gives you `fetch()`/`render()` against the read-only content API (`/api/*`) — not arbitrary page routes or the admin API: @@ -363,7 +377,7 @@ Deno.test("my plugin", async () => { }); ``` -`h.render(path)` is shorthand for `(await h.fetch(path)).text()` — both only reach the content API, since the harness runs without the admin plugin by default (`disableAdmin: true`). For a full page route (`/home`) or an admin/mount()-registered route, use the Playwright E2E suite against a real server instead — the harness can't reach either. +`h.render(path)` is shorthand for `(await h.fetch(path)).text()` — both only reach the content API, since the harness loads only the plugins you pass, and the admin is not one unless you list it. For a full page route (`/home`) or an admin/mount()-registered route, use the Playwright E2E suite against a real server instead — the harness can't reach either. --- @@ -379,7 +393,7 @@ Deno.test("my plugin", async () => { **`mount()` runs once at startup, after the Fresh `App` is ready.** Register routes and middleware there; don't rely on it for per-request async work — that belongs in the route handlers themselves. -**Hook error handling is not uniform — check the caller before assuming.** `HookRegistry.fire()` itself (`src/hooks/registry.ts`) has no try/catch around handler calls. Whether a throwing hook is contained depends entirely on who calls `fire()`: the admin panel's page CRUD routes (`onPageCreate`/`onPageUpdate`/`onPageDelete`/`onWorkflowChange`) call it as `hooks.fire(...).catch(() => {})` — errors are silently swallowed, not even logged. Startup and engine-lifecycle hooks (`onConfigLoaded`, `onStorageReady`, `onContentIndexReady`, `onRebuild`, `onThemeSwitch`) are `await`ed directly with no catch — a throwing handler there propagates and can fail bootstrap or a rebuild. Don't assume either behavior; if a hook has a side effect that must succeed or must not silently vanish, handle its own errors explicitly and log them yourself. +**Hook error handling is not uniform — check the caller before assuming.** `HookRegistry.fire()` itself (`src/hooks/registry.ts`) has no try/catch around handler calls. Whether a throwing hook is contained depends entirely on who calls `fire()`: the admin panel's page CRUD routes (`onPageCreate`/`onPageUpdate`/`onPageDelete`/`onWorkflowChange`) call it through `announcePageEvent()`, which catches a failing handler and logs a console warning; the request never fails because of a hook. Startup and engine-lifecycle hooks (`onConfigLoaded`, `onStorageReady`, `onContentIndexReady`, `onRebuild`, `onThemeSwitch`) are `await`ed directly with no catch — a throwing handler there propagates and can fail bootstrap or a rebuild. Don't assume either behavior; if a hook has a side effect that must succeed or must not silently vanish, handle its own errors explicitly and log them yourself. **Pin JSR/npm plugin versions.** `src: jsr:@dune/blog` without a version pinned fails validation. Use `^major.minor.patch` at minimum. diff --git a/skills/dune-themes.md b/skills/dune-themes.md index 1538f051..cd7f35df 100644 --- a/skills/dune-themes.md +++ b/skills/dune-themes.md @@ -121,6 +121,21 @@ esbuild requires explicit subpath entries — a catch-all prefix entry alone is } ``` +### Types and helpers for a distributed theme + +A theme that lives in a site's `themes/` directory can import types from `@dune/core` (the site pins its own core, so it cannot drift). A theme you **publish as a package** should depend on `@dune/theme-api` instead, which has no dependency on core, so the theme does not pull a second copy of core into every site: + +```json +{ "imports": { "@dune/theme-api": "jsr:@dune/theme-api@^1" } } +``` + +```tsx +import type { TemplateProps } from "@dune/theme-api"; +import { formatDate, getSearchUrl, truncate } from "@dune/theme-api"; // paginate, sortPages, groupByYear, getCanonicalUrl, buildPageTitle too +``` + +`@dune/core/theme-helpers` no longer exists (removed in 0.36); its helpers are in `@dune/theme-api`. `@dune/core/content/types` is still there for site code. + --- ## Templates @@ -149,7 +164,7 @@ export default function PostTemplate({ page, site, nav, children }: TemplateProp For typed markers, `@dune/core/ui/editable` provides server-only components that render exactly these attributes — `…` is identical to a hand-written `data-dune-body` wrapper, and `` marks individual frontmatter fields. They ship no JS and imply no editor; editor plugins consume the rendered markers. -Markers never reach anonymous visitors: the response pipeline scrubs all `data-dune-*` attributes from HTML served without a validated editing session, so `data-dune-source` paths stay private. Consequence for themes: never use `data-dune-*` attributes as CSS/JS hooks for public styling — they are absent from public responses (verify markers while logged in as an admin, not in an incognito window). +Markers never reach anonymous visitors: the response pipeline scrubs all `data-dune-*` attributes from HTML served without a validated editing session, so `data-dune-source` paths stay private. Consequence for themes: never use `data-dune-*` attributes as CSS/JS hooks for public styling — they are absent from public responses (verify markers while signed in as an editor, not in an incognito window). | Prop | Contents | |------|----------|