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 mapped to a minimum role. Code asks ctx.auth.can("entry: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, plugin:manage and settings:manage. Core ships seventeen. Eight 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, role and the stored meta bag. Nothing on it is a credential. It is still personal data, and core’s own hook 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.

Roles are one ordered ladder, subscriber, contributor, author, editor, admin. A capability names the lowest role that holds it, and every role above that holds it too, so there is no per-user permission list to maintain. Registering the recipe entry type mints eight capabilities for it, from entry:recipe:read up to entry:recipe:restore_revision; registering the cuisine taxonomy mints five.

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";
    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 } 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("entry:recipe:publish"),
    }),
    });
    },
    });

    The route serves at /_plumix/recipes/whoami. Signed out it answers 401 and your handler never runs; auth: { capability: "entry:recipe:publish" } instead of "authenticated" turns the shortfall into a 403.

    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.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 authenticate returning null. 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), resolving to a user or to null. 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 may come with a tokenScopes list, which 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.

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. On a plugin route that opted into caching it reads the shared entry as everyone does, but its render is never stored into one. A policied route is keyed by audience segment instead, and an entitled member reads that audience’s entry rather than bypassing.

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";
import { apiTokenAuthenticator, chainAuthenticators } from "plumix";
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 } : null;
},
// Without this, a public render never loads the proxied user.
hasSession(request) {
return request.headers.has(IDENTITY_HEADER);
},
signOutUrl() {
return "https://identity.example.com/logout";
},
};
}
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. 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. It resolves the capability’s minimum role from core’s table plus every capability plugins registered, compares it against the principal’s role, and intersects the result 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.

Two role checks sit outside it, and token scopes do not reach either. canAccessAdmin(role) compares the role’s level against STAFF_MIN_ROLE to decide who gets the admin shell. rolePolicy(required) makes the same comparison for an access policy. Both ask about a tier on the ladder rather than about a named permission, so a scoped credential whose owner holds the tier still passes them.

Not every signed-in principal is staff. STAFF_MIN_ROLE is contributor, so Plumix redirects a signed-in subscriber to the site root rather than hand over a shell whose every call would answer 403. That is the tier open signup hands out, and it is the tier a theme’s own membership area is built on.

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 is the exhaustive listing of the five roles 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.