[]previous and next page

Guards and request locals

Guards and request locals in the Security guide.

For agentsUpdated

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.

src/auth.tsts
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.

src/routes/api/_middleware.tsts
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();
  },
];
src/routes/api/me.tsts
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.