Route Guards

A guard is a <guard> around the content it protects.

Put one in a layout.html and it covers every page in that folder, so the check is written once:

source
<!-- pages/admin/layout.html -->
<Layout>
  <guard access="admins" redirect="/login">
    <AdminSidebar />
    <slot />
  </guard>
</Layout>

Now nothing under /admin renders for a visitor without admins access.

What a denied visitor sees

A denied guard always navigates. It never renders a 403 page in place of the children, and the children are never mounted.

Where it navigates to is resolved in three steps, first hit wins:

  1. redirect="/login" on the tag. Explicit beats everything.
  2. guards.redirect in config/jaf.config.js - the project-wide default.
  3. /, the site index. There is always somewhere to go.
source
// config/jaf.config.js
export default {
  guards: { redirect: '/login' },
}

So a project with a login page names it once and writes <guard access={...}> everywhere else. The navigation replaces the current history entry, so Back does not bounce the visitor into the guarded page again.

Sending them back afterwards

Every denial appends the location the visitor was trying to reach as a return query parameter, so /account?tab=billing denied to /login becomes:

source
/login?return=%2Faccount%3Ftab%3Dbilling

It is a plain path with the base path already stripped, ready to hand to navigate(). Read it with a url() state variable:

source
<!-- pages/login.html -->
<script>
import { navigate, guards } from 'jafjs/router'

let returnTo = url('return')

async function signIn() {
  await api.login()
  guards.access = true
  navigate(returnTo || '/')
}
</script>

<button onclick="signIn()">Sign in</button>

A destination you write with a return of its own is left exactly as written - the author said where to come back to, and the guard is not better informed. The fragment is dropped: it addresses a position on the page, not the page.

Treat return as user input. It comes from the URL bar and anyone can edit it.

It never loops. A destination that is the page the visitor is already on would be denied again on arrival, so it is not navigated to at all: the guard renders nothing, and in dev it warns naming the guard and the destination. That is the shape a login page guarded by its own guard has.

Changed in 0.1. A guard with no redirect used to render pages/errors/403.html in place of its children. Denial is one thing now, and it is a navigation. If your app kept denial content in a 403 page, move it to the page you redirect to - see Error Pages.

redirect= takes a literal path. redirect={somePath} is a build error, and so is a value mixing text and an expression (access="team-{id}"): a destination is decided before anything renders, so it has to be knowable when the component compiles.

Who is allowed

access="admins" asks whether admins is in a global allow list. Seed that list from a config file:

source
// config/guards.config.ts
export default {
  access: true,                    // Global access flag
  allow: ['admins', 'premium'],    // Allow list
  file: '/api/access.json',        // Optional: load from backend
}

And change it at runtime when someone signs in or out:

source
<script>
import { guards } from 'jafjs/router'

function onSignIn(user) {
  guards.allow(user.permissions)  // ['admins', 'editors']
}

function onSignOut() {
  guards.clear()
}
</script>
Method What it does
guards.allow(list) Replace the allow list
guards.add(name) / guards.remove(name) Add or drop one permission
guards.has(name) Check one, in your own code
guards.clear() Empty the list
guards.access The flag a <guard> with no access uses
The allow list is not reactive. It is a plain set, so guards.add('admins') does not re-open a guard that is already on screen. A guard whose answer changes while the page is open should read your own state instead: <guard access={isAdmin}>.
Guards hide UI, they do not protect data. Anything the browser can fetch, a determined visitor can fetch. Authorise on the server as well.