Guards and request locals
Guards and request locals in the Security guide.
A guard decides whether a call may run. serverFn({ guard }) and apiHandler({ guard }, handler) run their guards before the
input is validated, before the middleware and before the handler. A guard that throws refuses the call with the error's status:
HttpError(401, ...) for "not signed in", HttpError(403, ...) for "not allowed". A guard that returns false refuses with
403. A guard that returns nothing lets the call through. A guard receives the request (event), not the input: the input is
not trusted until its schema has accepted it, so a guard must not read it.
import { all, defineGuard, HttpError } from "@shadow-js/dusk/server";
declare module "@shadow-js/dusk/server" {
interface DuskLocals {
user: { readonly id: string; readonly role: "reader" | "admin" } | undefined;
}
}
/** Refuses a call with no signed-in user: 401, and the handler does not run. */
export const requireUser = defineGuard((event) => {
if (event.locals.user === undefined) throw new HttpError(401, "Sign in first");
});
/** Refuses a call whose user is not an admin: 403. Runs `requireUser` first. */
export const requireAdmin = all(
requireUser,
defineGuard((event) => {
if (event.locals.user?.role !== "admin") throw new HttpError(403, "Admins only");
}),
);
Put the guard on the function that does the work, not on the page that calls it. A server function is an endpoint of its own:
anyone can post to /_dusk/fn/<id> without loading any page, so a check in a layout, in a route's beforeLoad or in a
folder's middleware does not protect it on its own. The code cannot show whether a function is meant to be public, so Dusk has no
lint rule that asks for a guard; review each function that writes data.
Guards compose. all(a, b) runs them in order and stops at the first refusal. any(a, b) passes when one of them passes; when
none does, the last refusal is the outcome (its error, or a 403 for a guard that returned false). any() with no guard is a
TypeError. An array in the option, guard: [requireUser, requireAdmin], runs its guards in order. defineGuard(fn) returns
fn typed as a Guard, so a reusable check has one name.
The API route below takes its user from a header that a real app would replace with a verified session. Middleware on the
folder sets event.locals.user; the guard reads it.
import type { Middleware } from "@shadow-js/dusk/server";
import "../../auth";
/** Demo identity: a real app sets `event.locals.user` from a verified session cookie. */
export const middleware: Middleware[] = [
async (event, next) => {
const id = event.request.headers.get("x-user-id");
const role = event.request.headers.get("x-user-role");
if (id !== null && (role === "reader" || role === "admin")) event.locals.user = { id, role };
return next();
},
];
import { apiHandler, apiRoute, json } from "@shadow-js/dusk/server";
import { requireUser } from "../../auth";
export default apiRoute({
GET: apiHandler({ guard: requireUser }, ({ event }) => json({ id: event.locals.user?.id })),
});
Request locals are the values of one request that middleware, guards, loaders, API routes and server functions share.
Middleware sets them on event.locals. Their keys and types come from the DuskLocals interface, which the app merges into
with declaration merging (shown in src/auth.ts above). defineLocals({ ... }) gives one module a typed view of the same
object and a middleware that sets the defaults of the keys the request does not have yet. getLocals() (also named
useRequestLocals()) returns the locals of the current request wherever getRequestEvent() works: a loader, a server
function body, a guard.
Locals are server data. The framework never serializes them: the page, its router payload, an API answer and a server
function's result carry only what the code returned. Return a value from a loader on purpose when the browser needs it, and
nothing else from event.locals leaves the server.