Skip to content

Overview

Every request into a Plumix site resolves to a principal: a loaded user, or the anonymous absence of one. One authenticator makes that call for the whole site, and the admin, the RPC layer, plugin routes and the theme all read the same answer off the request context.

Four nouns carry the access model.

  • A principal is the actor a request resolved to. Handlers read it as ctx.user, which is an authenticated user or null.
  • A session is a server-issued credential a browser carries in a cookie. One row in sessions, one plumix_session cookie, one user.
  • An authenticator decides who a request is. It reads credentials and returns a user or null. It never mints a session and never sets a cookie.
  • A capability is a permission string a role grants. Code asks ctx.auth.can(entryCapability("recipe", "publish")) instead of comparing roles by hand.

Capabilities come in two shapes. A per-type capability carries three segments, <entity>:<type>:<action>, as in entry:recipe:publish or term:cuisine:manage. An entity-level capability carries two, <entity>:<action>, with no type segment at all, as in user:list, user:promote and settings:manage. Core ships eighteen. Nine are the entry:post:* set, baked into core whether or not you install the blog plugin that registers the post entry type; the other nine are flat.

ctx.user is a projection of the user row rather than the row itself, carrying id, email, name, avatarUrl, role and the stored meta bag, the same on every surface. name and avatarUrl are null when the user never set them. Nothing on it is a credential. It is still personal data, and core’s own documentation says so, so keep the whole object out of third-party logging and analytics. email, name and whatever a plugin wrote into meta all ride along.

A role is a named set of capabilities, and nothing ranks one role above another. A user holds one role, so there is no per-user permission list to maintain. Core ships four default roles, member, author, editor and admin, and admin holds every capability there is, one a plugin registers included. Registering the recipe entry type mints nine capabilities for it, from entry:recipe:read up to entry:recipe:restore_revision, and registering the cuisine taxonomy mints five. Each goes to the default roles core’s action table names for it, so an author publishes recipes and an editor also deletes them. A plugin that registers a capability of its own names the default roles that receive it with grantTo, and one it registers without grantTo is admin’s alone.

A site declares its own roles in plumix({ roles }), keyed by role name. can groups the capabilities a role grants by what they guard: entry and term are keyed by entry type and taxonomy, and every other capability sits under its prefix. extends adds another role’s capabilities, and label is what the admin shows:

import { plumix } from "plumix";
export default plumix({
// …runtime, database, auth, plugins
roles: {
designer: {
label: "Designer",
extends: "author",
can: {
admin: ["access"],
entry: { page: ["read", "edit_any"] },
term: { category: ["assign"] },
},
},
},
});

A role you add holds only what its can and extends grant. Declaring a default role’s name replaces that default’s grants, so an editor entry that leaves out delete stops editors deleting. admin cannot be declared, which keeps someone able to fix the site. An action that doesn’t exist fails to compile, and a grant, entry type, taxonomy or extends target that nothing registers fails plumix build and plumix dev with a message naming it. A running site ignores such a grant rather than going down, and a user whose stored role the config no longer declares holds no capabilities.

Code names an entry or term capability by what it guards, with entryCapability(type, action) or termCapability(taxonomy, action) from plumix/plugin, and never spells the string. A type registered with capabilityType pools its permissions under another type’s namespace, and the reference resolves to that namespace wherever it is checked: auth.can, requireCapability, a route’s auth, a lookup adapter, a field or admin page. What reaches the admin or a FORBIDDEN payload is still the resolved string. Flat capabilities such as settings:manage stay strings.

The auth slot is not optional. A config without one does not type-check, and auth() validates its input as the config module loads, throwing PlumixConfigError with the offending path named. Its one required block is passkey, so every site has a working sign-in method from its first deploy.

  1. Declare the auth slot. The auth() call lives in plumix.config.ts, which is where the scaffolder writes it:

    import { auth } from "plumix/auth";
    export const recipeAuth = auth({
    passkey: {
    rpName: "Recipes",
    rpId: "localhost",
    origin: "http://localhost:5173",
    },
    });
  2. Wire it in. Pass recipeAuth as the auth slot of plumix({ … }), beside the runtime and database slots. The scaffolder inlines the whole auth({ … }) call into that literal rather than naming it first. Both shapes produce the same slot value.

  3. Enrol the first user.

    Terminal window
    pnpm dev

    Open /_plumix/admin. With an empty users table the admin routes to its bootstrap screen instead of the login screen. Enter an email and approve the browser’s passkey prompt. The insert that creates the row elects the first user admin inside the statement itself, so two concurrent bootstraps cannot both win.

  4. Read the principal. Any plugin can mount a raw route and let the dispatcher gate it:

    import { definePlugin, entryCapability } from "plumix/plugin";
    export const recipes = definePlugin("recipes", {
    setup: (ctx) => {
    ctx.registerRoute({
    method: "GET",
    path: "/whoami",
    auth: "authenticated",
    handler: (_request, appCtx) =>
    Response.json({
    email: appCtx.user?.email ?? null,
    role: appCtx.user?.role ?? null,
    canPublish: appCtx.auth.can(entryCapability("recipe", "publish")),
    }),
    });
    },
    });

    The route serves at /_plumix/recipes/whoami. Signed out it answers 401 and your handler never runs; auth: { capability: entryCapability("recipe", "publish") } instead of "authenticated" turns the shortfall into a 403. An auth: "public" route runs for everyone, and its ctx.user is the signed-in visitor when the request carries a session and null otherwise.

    A write method on that route also passes the dispatcher’s CSRF gate, which requires the X-Plumix-Request header — one a plain HTML <form method="post"> cannot set, so a no-JavaScript submit cannot reach a plugin route at all. A route that has to accept one declares formPost: true, dropping the header requirement and leaving the Origin check as the whole control: the submit has to carry an Origin (or Referer) matching the site, where an ordinary request is only rejected for contradicting one. It exempts the POST and nothing else, so a route registered as method: "*" still gates every other write method. Only an auth: "public" route may take it, and declaring it on a gated route throws at registration.

    The reasoning is what the header gate is for: stopping a cross-origin POST that rides on the visitor’s ambient session authority. A public submit carries none, so an attacker forging one has merely submitted a form they could have submitted directly. That holds only while the handler never derives privilege from a session, so the dispatcher takes the session away rather than trust the handler to ignore it: on the request that took the exemption, ctx.user is null and ctx.authenticator resolves nobody — authenticate returns null and hasSession is false.

    The exemption is per request, not per route. A JS-enhanced form posting to the same endpoint sets the header, passes the ordinary gate and reaches the handler with its session intact, so attributing a submission to a signed-in visitor still works wherever it is safe to. Your handler needs no branch for the difference: a public route already has to cope with a null ctx.user, which it gets whenever the request carries no session. Note that this swaps the authenticator, not the request. The session cookie is still on ctx.request, and defaultAuthenticator() is one import away, so a handler that goes looking recovers the user regardless — what is gone is the reading that looks like ordinary code.

    A fourth gate, auth: "development", is not about a principal at all: the route exists only while plumix dev is running and only for requests that reached it over loopback, and answers 404 otherwise. The Dev Server covers what belongs behind it.

A session token is 192 bits of randomness. Plumix hashes it with SHA-256 and stores the row under the hash, so the raw value exists in the cookie and nowhere else, and a dump of the sessions table yields nothing anyone could present.

The cookie is plumix_session, set HttpOnly and SameSite=Lax, scoped to the site’s base path, and marked Secure whenever the request arrived over HTTPS. Plumix reads it from the Cookie header only, never from a query string or a form field.

Lifetime is a sliding 30 days under a hard ceiling of 90. Validation extends the expiry once half the window has elapsed, and it measures that fraction from the row’s createdAt, which never moves. A session validates without writing for its first 15 days. After that every validation issues an UPDATE, until the sliding expiry reaches the 90-day ceiling and stops changing. Set sessions in the auth config to change the window, the ceiling and the 0.5 threshold.

Finding the user disabled deletes the row on the spot, so disabling an account stops its sessions on their next use rather than at expiry. Validation deletes on the ceiling too, but a row cannot pass the ceiling before it has passed its own expiry, so that branch is cleanup rather than revocation.

Signing out at /_plumix/auth/signout deletes the row and returns a cookie that clears itself. The admin lists the sessions on your own account with their recorded IP and user agent, and revokes any one of them or all the others at once. It refuses to revoke the session making the request, since signing out is what that is for. Both recorded values come off request headers, and whoever sent the request chose them, so read them as a memory aid for the “is this me?” question rather than as evidence.

An authenticator has one required method, authenticate(request, db, scope), resolving to a user or to null. scope.startingUserMeta is the meta a new user starts with: an authenticator that creates the user it resolves stores it as that user’s meta, so the user fields’ defaults apply however the user was created. One that only looks users up can ignore it. Returning null means “no credential on this request”, and the caller decides whether that is a 401 or an anonymous render. Throwing is reserved for a credential that was present and malformed, a bad signature or a replay. Nothing turns that throw into a typed error today. No call site wraps the authenticator, so on a plugin route or the admin shell the error reaches the dispatcher’s one top-level catch, which logs dispatch_failed and answers { "error": "internal_error" } at 500; on an RPC procedure it lands as an internal server error the same way. Return null for a malformed credential if you want the caller’s 401 instead. A returned user comes with a required credential naming what resolved them. "session" is a credential binding a browser to the user, Plumix’s own cookie or an identity provider’s assertion. "api-token" is a credential that carries its own scopes, and it comes with a tokenScopes list, or null for an unscoped token, and with apiToken: { id, name }, which a revision made with that token records. A list narrows what that one request may do. ctx.auth.can() intersects the scopes with the role’s capabilities, so a scoped credential holds fewer capabilities than its owner and never more. A surface that creates a credential for the caller, such as adding a passkey, accepts only a "session", so an API token can never be turned into a sign-in.

Out of the box Plumix chains two of them. The first reads the plumix_session cookie. The second reads Authorization: Bearer pl_pat_… and resolves it against the hashed api_tokens table. First non-null wins, so browser traffic and API clients both work with no configuration. Set authenticator in the auth config to replace the chain, and wrap your own with the API-token one if you want bearer credentials alongside it.

A public page render skips authentication for traffic that carries no session. Loading a user on every anonymous hit would cost a database read per request and make the render depend on who is asking. The predicate that decides is hasSession(request), the optional, synchronous method a custom authenticator owes the public render path. Omit it and the default applies, which is that the plumix_session cookie is present.

That default is right for anything cookie-shaped and wrong for everything else. An authenticator carrying its credential in a header sees every public request as anonymous, so a signed-in principal gets the signed-out page and every capability-gated render decision takes the anonymous branch. Returning false opts out on purpose. The API-token authenticator does exactly that, because an API token belongs to an API client rather than to a browser session, and a cross-site navigation carrying an Authorization header should not touch the token’s lastUsedAt.

The shared cache asks the same predicate, so what you answer here decides more than whether a user loads. On a page carrying no access policy, a request you call signed in bypasses the cache entirely, because its render may differ from the anonymous document every other visitor receives. A registered public route with no policy treats it the same way: it renders live with its principal and is never stored. A policied route is keyed by audience segment instead, and an entitled member reads that audience’s entry rather than bypassing. A signed-in request that a policy grants anonymous still bypasses the cache, as it would with no policy. A render that reads the principal — ctx.user, ctx.auth.can(), useUser() — in a shared segment is personal: it is served to that member and never stored for the next one, which CDN Caching describes.

Declaring no session opens a narrow but real hole. Core then treats the request as anonymous throughout, so a route handler or theme that calls authenticate itself and personalizes what it renders has that render stored under the public URL and served on to the next visitor.

import type { RequestAuthenticator } from "plumix/auth";
import { apiTokenAuthenticator, chainAuthenticators } from "plumix/auth";
import { eq } from "plumix/db";
import { users } from "plumix/schema";
const IDENTITY_HEADER = "x-forwarded-email";
function proxyIdentity(): RequestAuthenticator {
return {
async authenticate(request, db) {
const email = request.headers.get(IDENTITY_HEADER);
if (!email) return null;
const user = await db.query.users.findFirst({
where: eq(users.email, email.toLowerCase()),
});
return user ? { user, credential: "session" } : null;
},
// Without this, a public render never loads the proxied user.
hasSession(request) {
return request.headers.has(IDENTITY_HEADER);
},
signOutUrl(request) {
return request.headers.has(IDENTITY_HEADER)
? "https://identity.example.com/logout"
: null;
},
};
}
export const authenticator = chainAuthenticators(
proxyIdentity(),
apiTokenAuthenticator(),
);

signOutUrl matters for any identity provider that keeps its own session. Clearing the local cookie is not enough there. The next request still carries the provider’s credential, so the provider signs the same person straight back in. signOutUrl receives the sign-out request, so an authenticator chained beside others can return its URL only for a request that carries its own credential and null for the rest. Plumix passes the URL to the admin client after sign-out and drops anything that is not an https:// URL or a same-origin path.

ctx.auth.can(capability) is the one place a capability decision happens. At boot core resolves one table from each role to the capabilities it holds, from the default roles, the config’s roles and every capability plugins registered. can asks whether the principal’s role holds the capability in that table, and intersects the answer with the request’s token scopes. Every capability check in core and in plugins reads through it, which is why an authenticator that returns scopes narrows all of them at once.

One check sits outside it, and token scopes do not reach it. The admin shell and the admin bar ask whether the principal’s role holds admin:access, so a scoped credential whose owner’s role holds it still passes.

Gate content on a capability. capabilityPolicy(capability) from plumix/auth reads through ctx.auth.can, so token scopes narrow it too. It grants a viewer who holds the capability the capability:<name> segment, answers 403 to a signed-in viewer without it, and sends an anonymous visitor to sign-in. Everyone holding the capability shares one cached copy of the page, whatever role grants it.

Not every signed-in principal is staff. author, editor and admin hold admin:access, and a role you declare opens the admin only when its can grants admin: ["access"]. Plumix redirects a signed-in user without it, such as a member, to the site root rather than hand over a shell whose every call would answer 403. member is the role open signup hands out, and the one a theme’s own membership area is built on.

An entry type registered with access lets an editor pick a policy per entry, and that choice holds wherever the entry appears, not only on its own page. The front page, the entry-type, taxonomy, author and date archives, core’s search page, the REST API, the RPC reads and the reference lookups all list an entry only when its policy admits the principal asking. A signed-in member sees the members-only entries an anonymous visitor does not. Someone who may edit an entry (edit_any, or edit_own over their own) reads it in the admin and the API whatever its policy says.

A soft challenge admits a visitor to the listing but not the body. Such an entry is listed with its title, excerpt, URL and images, carries contentGated: true, and has content and contentBlocks set to null, so a theme renders a teaser. Every other entry carries contentGated: false. The same holds for any list resolved through resolveEntryList from plumix/plugin, a plugin’s own included, and for the REST API; only the entry’s own page receives the body, and its template serves the teaser:

import { fallback } from "plumix/theme";
export const listing = fallback(({ data }) =>
"entries" in data ? (
<ul>
{data.entries.map((entry) => (
<li key={entry.id}>
<a href={entry.url ?? "#"}>{entry.title}</a>
{entry.contentGated ? <p>{entry.excerpt}</p> : null}
</li>
))}
</ul>
) : null,
);

Each policy resolves once per request, however many entries a page lists, and the rows are matched in the one query that lists them. A listing that asks a policy about a signed-in member is personal to them, so the shared cache stores only the anonymous version of it.

Plugin code reads entries through the same rules: publicEntryRows(ctx) from plumix/db is the condition a public listing adds to its query, and it answers for the request’s own principal. When the output is public but the request is someone else’s, such as a card scraped later, a ping sent after an editor saves, or a cron’s artefact, ask through anonymousContext(ctx) from plumix/auth. It is the same context with no user, no token scopes and no capabilities, so a rule asked through it answers what anonymous visitors may see. entryPolicyAdmits(ctx, entry, "listing" | "content") asks one entry’s policy the same way, and policyAdmits(ctx, policy, "listing" | "content") asks a policy you hold, such as an archive’s.

The capabilities above are not written by hand. Content Modelling covers the registrations that mint them, so the names you choose for an entry type and a taxonomy decide the capability strings that gate them. The auth slot sits in plumix.config.ts alongside the rest of the site’s configuration, described in Configuration, and any value in it that is a secret takes an (env) => … resolver rather than a literal, covered in Secrets. A theme reads the same principal the admin does, which is how a signed-in principal gets a different page from an anonymous one.

Read Passkeys next. It is the sign-in method the config above turned on, the only one available on a fresh install, and the path the first admin takes.

Seven pages in this section are not written yet, though everything they cover already ships. Roles lists every capability each default role holds, and the staff boundary. Magic Links covers email sign-in and the mailer slot it needs; OAuth covers third-party providers and the oauth_accounts rows they create; both are optional blocks in the same auth() call. Capabilities lists every core capability and the per-type actions derived from your registrations. API Tokens covers the pl_pat_ bearer credentials, their scopes, and the device flow a CLI uses to obtain one. Access Policy covers definePolicy, the audience segments a policy may grant, and the three gate outcomes. Gating Content builds on it for teasers, paywalls and what a gated page tells a crawler.

After that, the Deployment section covers what changes when the site stops running on localhost, starting with the origins a passkey is anchored to.