Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
5480f7e
feat(config)!: runtime_dir and max_revisions are core settings, not a…
zumbrunn Nov 6, 2026
0ea5229
feat(plugin-api)!: the admin declares where it is mounted (admin.pref…
zumbrunn Nov 6, 2026
439d7e4
chore: drop the deno.lock change that slipped into the step 2 commit
zumbrunn Nov 6, 2026
8ad89d5
feat(plugins)!: core no longer loads the admin, it is a plugin you li…
zumbrunn Nov 6, 2026
f72041b
feat(plugin-api)!: machine translation and the audit trail leave core…
zumbrunn Nov 6, 2026
3e609c4
feat(config)!: the admin's settings are the admin plugin's config, no…
zumbrunn Nov 7, 2026
145b7ab
feat(config)!: machine_translation moves into the admin plugin's conf…
zumbrunn Nov 7, 2026
c5de0fa
chore: drop the deno.lock change that slipped into the step 5 commits
zumbrunn Nov 7, 2026
b0d38c0
fix(authz)!: site users' roles grant editing, checked on app:site (#130)
zumbrunn Nov 7, 2026
2191190
fix(authz): a role granted or revoked in a running app takes effect a…
zumbrunn Nov 7, 2026
ca675bb
Merge origin/main into feat/explicit-admin-loading
zumbrunn Nov 7, 2026
bf41539
Merge feat/explicit-admin-loading into feat/mt-audit-to-admin
zumbrunn Nov 7, 2026
25067b4
Merge feat/mt-audit-to-admin into feat/admin-settings-in-plugin
zumbrunn Nov 7, 2026
87db9e4
Merge feat/admin-settings-in-plugin into fix/site-user-editing
zumbrunn Nov 7, 2026
d3e5c3a
feat(plugin-api)!: the contract keeps what core consumes; plugins dec…
zumbrunn Nov 7, 2026
292a3dd
feat(plugin-api)!: transforms read capabilities from ResponseTransfor…
zumbrunn Nov 7, 2026
3c1b2be
docs: the unprefixed capability namespaces are editor, scheduler and …
zumbrunn Nov 7, 2026
efdb350
feat(plugin-api)!: core gates /api/inline-edit/* and hands requests t…
zumbrunn Nov 7, 2026
929043b
docs: changelog for the inline-edit HTTP gate and page-event actor (#…
zumbrunn Nov 7, 2026
740da47
feat(plugin-api)!: typed capability keys instead of module augmentati…
zumbrunn Nov 8, 2026
e2a696e
docs: capability keys in the plugins module comment
zumbrunn Nov 8, 2026
f2cfb64
docs(skills): refresh for 0.36 (snake_case config keys, app:site, edi…
zumbrunn Nov 8, 2026
979fc52
fix: headless smoke boots without the admin entry; headless scaffold …
zumbrunn Nov 8, 2026
c5ed518
fix(auth): log a failed role-grant bootstrap, own-property override l…
zumbrunn Nov 8, 2026
5f85916
Merge branch 'feat/explicit-admin-loading' into feat/mt-audit-to-admin
zumbrunn Nov 8, 2026
0588f2b
Merge branch 'feat/mt-audit-to-admin' into feat/admin-settings-in-plugin
zumbrunn Nov 8, 2026
42f5f23
test: the overlay admin.enabled warning, with #131's manual-move exit
zumbrunn Nov 8, 2026
e648e84
Merge branch 'feat/admin-settings-in-plugin' into fix/site-user-editing
zumbrunn Nov 8, 2026
b204168
chore: keep deno.lock as on the base branch
zumbrunn Nov 8, 2026
2d41672
Merge branch 'fix/site-user-editing' into refactor/contract-split
zumbrunn Nov 8, 2026
2d57358
Merge branch 'refactor/contract-split' into feat/inline-edit-routes
zumbrunn Nov 8, 2026
423356c
Merge branch 'feat/inline-edit-routes' into docs/refresh-skills
zumbrunn Nov 8, 2026
caf77e6
feat(auth): live editing sessions respect page ownership; name where …
zumbrunn Nov 8, 2026
e9dce52
Merge branch 'feat/inline-edit-routes' into docs/refresh-skills
zumbrunn Nov 8, 2026
dfb9d5b
docs(skills): author ownership and the content-tier caveat for editin…
zumbrunn Nov 8, 2026
84547bd
fix(cli): headless scaffold registers plugin islands with registerIsland
zumbrunn Nov 8, 2026
83b8207
Merge remote-tracking branch 'origin/feat/explicit-admin-loading' int…
zumbrunn Nov 10, 2026
db082f9
Merge origin/main into feat/mt-audit-to-admin
zumbrunn Nov 10, 2026
7445cfb
fix(migrate:config): keep core settings of a disabled admin block; ne…
zumbrunn Nov 10, 2026
89a25ec
Merge remote-tracking branch 'origin/feat/mt-audit-to-admin' into fea…
zumbrunn Nov 10, 2026
5672444
Merge remote-tracking branch 'origin/main' into feat/admin-settings-i…
zumbrunn Nov 10, 2026
2b50403
Merge remote-tracking branch 'origin/feat/admin-settings-in-plugin' i…
zumbrunn Nov 10, 2026
70f485b
Merge remote-tracking branch 'origin/main' into fix/site-user-editing
zumbrunn Nov 10, 2026
2cd6e29
Merge remote-tracking branch 'origin/fix/site-user-editing' into refa…
zumbrunn Nov 10, 2026
42a1cff
Merge origin/main into refactor/contract-split
zumbrunn Nov 10, 2026
53ef3a7
Merge remote-tracking branch 'origin/refactor/contract-split' into fe…
zumbrunn Nov 10, 2026
6cbd371
Merge origin/main into feat/inline-edit-routes
zumbrunn Nov 10, 2026
2f65c3e
Merge remote-tracking branch 'origin/feat/inline-edit-routes' into do…
zumbrunn Nov 10, 2026
2205791
Merge remote-tracking branch 'origin/main' into docs/refresh-skills
zumbrunn Nov 10, 2026
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
72 changes: 36 additions & 36 deletions skills/dune-auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <email> [--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 <email> [--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

Expand Down Expand Up @@ -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.

---

Expand All @@ -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;
Expand All @@ -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.

---

Expand All @@ -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" },
Expand All @@ -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 |
|-------|-------------|-------------|
Expand All @@ -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`.

Expand All @@ -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.
Loading
Loading