Skip to main content

Entry rules

An entry rule is a small function that answers one question: may this person open this page?

  • It takes the session and the location, and returns null to allow or a path to redirect to.
  • It reads nothing from the network and changes nothing. That makes it easy to test and easy to read.
  • This is the lookup page. The tables below are the whole answer, and the code must match them.
  • This page covers seven things, in this order.
    • What an entry rule is, in one picture and in code.
    • The facts a rule may read.
    • What a rule does with a state the code does not know.
    • Why a guest rule sends a signed-in person to the area's default page.
    • The tables: user, talent, employer and admin rules, and what the access-unavailable page shows.
    • How redirect_to is checked before any rule uses it.
    • How to write a new rule, and how to test one.

The words this page uses​

WordMeaning
Entry ruleA function in app/auth/auth-entry-rules.js, or app/auth/auth-admin-entry-rules.js for admins. It takes two inputs, the session and the location, and returns null to say "allow" or a path such as /login to say "send the person there". Example: authEntryRules.employerRequired
The sessionidentitiesUserSession, the current-session JSON that Node fetched from Rails, or null when nobody is signed in. Reading the session explains how it is read. For admin rules it is identitiesAdminSession
The locationWhere the person is going: an object with pathname, such as /employers/dashboard, and search, the query string such as ?redirect_to=/talent/profile
Access stateOne word in the session that says what a person may do in an area. talent_access.state is one of missing, setup_required, ready. employer_access.state is one of missing, active, company_disabled, revoked. Rails computes the word, and a rule only reads it
GuardauthUserGuard.protect(rule). It gives the rule its two inputs and throws replace(path) when the rule returns a path. The policy routes page shows it
Policy routeThe route that exports the guard as its middleware. One policy route, one rule. The policy routes page lists all ten
Guest policyA policy route that guards a page only signed-out people should see, such as /login or /employers/login
Holding pageA page that keeps a person who cannot enter a private area yet: /talent/setup and /employers/access-unavailable. Its policy allows every state it does not know, so the person never bounces between two policies
redirect_toA query value on a login page, such as /login?redirect_to=/talent/profile. After login the person lands there. A stranger can write it into a link, so it is checked before any rule uses it
Unknown stateA state word that the web app's code does not know. It happens when Rails adds a state before the web app learns it, around a deploy, after a rename, or in a stale tab

An entry rule answers with null or a path​

The rule is the only part that decides. The guard around it does the redirect.

Legend: Orange is the entry rule, this page's subject. Lavender is what the guard gives it: the session and the location. Grey is what comes out. The rule returns a value and does nothing else. The guard, on the policy route, turns a path into a redirect.

The employer rule, from the real file:

// app/auth/auth-entry-rules.js
import { authSafeRedirect } from '@/auth/auth-safe-redirect'
import { ORG_MEMBERSHIP_ACCESS_STATE } from '@/domains/org-membership/org-membership-constants'

const employerRequired = (identitiesUserSession, location) => {
if (!identitiesUserSession) return authSafeRedirect.loginRedirectPath('/employers/login', location)

const employerState = identitiesUserSession.employer_access.state

if (employerState === ORG_MEMBERSHIP_ACCESS_STATE.MISSING) return '/employers/create-account'
if (employerState !== ORG_MEMBERSHIP_ACCESS_STATE.ACTIVE) return '/employers/access-unavailable'

return null
}

// The other seven user-side rules follow the same shape. The file exports one object.
export const authEntryRules = {
USER_GUEST_PATHS : USER_GUEST_PATHS,
userGuest : userGuest,
userRequired : userRequired,
talentOnboarding : talentOnboarding,
talentRequired : talentRequired,
employerGuest : employerGuest,
employerOnboarding : employerOnboarding,
employerInactive : employerInactive,
employerRequired : employerRequired
}

The answer is the redirect target. There is nothing to translate and no verdict object to read.

An entry rule must never do any of these:

  • Call Rails. The session middleware already fetched the facts.
  • Read a cookie. The middleware already read it.
  • Import React, or use a hook.
  • Navigate by itself. It returns a path and lets the guard act.
  • Change anything outside itself, including the session object it was given.

We keep rules this small for three practical reasons.

  • They are easy to test. Pass a session and a path, then check the answer. No browser, no Node, no network, no mocks.
  • You can read one and know the whole rule. Nothing hidden happens somewhere else.
  • They can move. A function that depends on nothing around it runs anywhere.

The guard throws replace(), not redirect(). A policy redirect is a correction, not a destination. It must not stay in browser history, or the Back button bounces the person forward again.

The facts a rule may read​

Every fact lives in one place: the session. A rule reads three of its fields and nothing else.

FactWhere it livesValues
Signed in or outidentitiesUserSessionnull, or an object with identities_user and the two access answers
Talent stateidentitiesUserSession.talent_access.statemissing, setup_required, ready
Employer stateidentitiesUserSession.employer_access.statemissing, active, company_disabled, revoked

The session holds no other fact about memberships. Identities D6 — The current-session answer holds the person and one access word per area says why.

  • Which company the person acts as is display data for app/layouts/org-dashboard/org-dashboard-layout.jsx. Its loader reads it from GET /identities/users/current/org_memberships: the entry with is_selected: true.
  • No rule reads it. Rails already turned every membership into the one state word, and that rule must live in one place only.

Admin rules take the admin session instead. It has no access states. An admin is signed in or not.

Rule groupTakes
User, talent and employer rulesidentitiesUserSession, location
Admin rulesidentitiesAdminSession, location

One signature for every user-side rule. One session carries every fact, so no rule ever forces an extra fetch.

A state the code does not know must not open a private page​

Rails may add a state before the web app learns the word. A rule must treat any state it does not recognise as "do not enter".

The employer rule above does this with its last check. employerState !== ACTIVE catches company_disabled, revoked and every future state alike. Write every rule for a private area the same way: test for the states that allow, and let everything else fall through to a redirect.

The dangerous shape is the opposite one: testing for the states that deny, and ending with return null. A state you forgot then silently allows.

The two holding pages are the exception, and they must be.

  • /talent/setup and /employers/access-unavailable keep a person who cannot enter a private area yet.
  • The policy on a holding page allows any state it does not know, because the private policy beside it already sends unknown states to that page.
  • A redirect from the holding page would bounce the person between the two forever:
/employers/dashboard              employer-required-policy: unknown state, go to access-unavailable
/employers/access-unavailable employer-inactive-policy: unknown state, go to dashboard <- never do this

Two guarantees still hold. No private route opens on an unknown state. And every unknown state lands on a page that keeps the person.

A guest rule sends a signed-in person to the area's default page​

A guest policy guards a page that only signed-out people should see, such as /employers/login. When a signed-in person opens it, the rule has to send them somewhere.

The session already carries their employer state, so it is tempting to pick the exact right page here. Do not. Send them to the area's default page and let that page's policy decide.

// app/auth/auth-entry-rules.js
const EMPLOYER_GUEST_PATHS = ['/employers/login', '/employers/sign-up']

const employerGuest = (identitiesUserSession, location) => {
if (!identitiesUserSession) return null

return authSafeRedirect.internalPathOrDefault(
authSafeRedirect.redirectTarget(location),
'/employers/dashboard',
EMPLOYER_GUEST_PATHS
)
}

The third argument is the loop protection. Each guest rule blocks its own guest paths, so a redirect_to can never send a signed-in person back into the same guest policy. The section on redirect_to below shows the check.

A signed-in person with no membership then takes two hops.

/employers/login
| employer-guest-policy: you are signed in
/employers/dashboard
| employer-required-policy: you have no membership
/employers/create-account

One reason: the answer is written once. Only employer-required-policy knows where each employer state belongs. If the guest rule also knew, the two tables would drift apart the first time somebody edits only one of them.

The cost is one extra redirect, on a page almost nobody opens while signed in. Both redirects use replace(), so neither temporary path stays in history. This flow does not skip the employer check. It delays that check until the destination request, where it always runs.

The user rules​

PolicySessionResult
User guestSigned outAllow
User guestSigned inSafe redirect_to, or /
User requiredSigned out/login with redirect_to
User requiredSigned inAllow

The talent rules​

PolicySessionTalent stateResult
Talent onboardingSigned outNot read/login with redirect_to
Talent onboardingSigned inmissingAllow
Talent onboardingSigned insetup_requiredAllow
Talent onboardingSigned inready/talent/profile
Talent onboardingSigned inany other stateAllow
Talent requiredSigned outNot read/login with redirect_to
Talent requiredSigned inmissing/talent/setup with redirect_to
Talent requiredSigned insetup_required/talent/setup with redirect_to
Talent requiredSigned inreadyAllow
Talent requiredSigned inany other state/talent/setup with redirect_to

missing and setup_required reach the same page today. They stay apart in the contract, because the setup page needs to know whether it is creating a profile or continuing one, and because the setup page must not fetch a profile that does not exist.

The two talent rules end differently on purpose. The onboarding rule keeps the person on the holding page, and the required rule sends them to it.

// app/auth/auth-entry-rules.js
const talentOnboarding = (identitiesUserSession, location) => {
if (!identitiesUserSession) return authSafeRedirect.loginRedirectPath('/login', location)

const talentState = identitiesUserSession.talent_access.state

if (talentState === TALENT_PROFILE_ACCESS_STATE.MISSING) return null
if (talentState === TALENT_PROFILE_ACCESS_STATE.SETUP_REQUIRED) return null
if (talentState === TALENT_PROFILE_ACCESS_STATE.READY) return '/talent/profile'

// An unknown state stays on the setup page. talentRequired sends every
// state that is not 'ready' here, so a redirect from here would loop.
return null
}

const talentRequired = (identitiesUserSession, location) => {
if (!identitiesUserSession) return authSafeRedirect.loginRedirectPath('/login', location)

const talentState = identitiesUserSession.talent_access.state

if (talentState === TALENT_PROFILE_ACCESS_STATE.READY) return null

return authSafeRedirect.loginRedirectPath('/talent/setup', location)
}

The employer rules​

PolicySessionEmployer stateResult
Employer guestSigned outNot readAllow
Employer guestSigned inNot readSafe redirect_to, or /employers/dashboard
Employer onboardingSigned outNot read/employers/login with redirect_to
Employer onboardingSigned inmissingAllow
Employer onboardingSigned inactive/employers/dashboard
Employer onboardingSigned incompany_disabled or revoked/employers/access-unavailable
Employer onboardingSigned inany other state/employers/access-unavailable
Employer inactiveSigned outNot read/employers/login
Employer inactiveSigned inmissing/employers/create-account
Employer inactiveSigned inactive/employers/dashboard
Employer inactiveSigned incompany_disabled or revokedAllow
Employer inactiveSigned inany other stateAllow
Employer requiredSigned outNot read/employers/login with redirect_to
Employer requiredSigned inmissing/employers/create-account
Employer requiredSigned inactiveAllow
Employer requiredSigned incompany_disabled or revoked/employers/access-unavailable
Employer requiredSigned inany other state/employers/access-unavailable

Two things to hold on to.

  • A person with company_disabled or revoked is never sent to account creation. Their account already exists. Telling them to make another one is the defect this design exists to prevent.
  • The access-unavailable page shows no private company data. It explains the state and offers a way to get help.

What the access-unavailable page shows​

/employers/access-unavailable is the page a signed-in person lands on when employer_access.state is company_disabled, revoked, or a state the code does not know. It shows one short text per state.

Rails answers company_disabled or revoked only when none of the person's memberships is usable. So the text first tells the person they have no company they can use, then gives the reason. Org D1 — is_selected always marks the membership Rails acts as says what Rails does to is_selected for this person.

StateThe text the page shows, exactly
company_disabled"Your account does not belong to a company right now. The company account is switched off, so no one can access the company. Contact the Jod team to switch it back on."
revoked"Your account does not belong to any company right now, so employer pages are closed. Contact the Jod team to find out why, and how to get access back."
any other state"Your employer account cannot be used right now. Contact the Jod team."

Three rules shape the page.

RuleWhy
The way to get help is a WhatsApp link. The page reads the number from the WhatsApp entry of DEFAULT_COUNTRY.social in app/utils/constants.js, never from a value written into the pageOne place holds the number. A change there reaches every page that uses it
The page sits under app/layouts/org/org-public-layout.jsx and adds no sign-out of its ownThat layout's navbar already shows the person's name and a logout button
The page makes no employer data requestThe session already carries the one word the page needs, and Rails would refuse the request with 403

The admin rules​

PolicyAdmin sessionResult
Admin guestSigned outAllow
Admin guestSigned inSafe redirect_to, or /team
Admin requiredSigned out/team/login with redirect_to
Admin requiredSigned inAllow

A user session must never satisfy an admin rule. An admin session must never satisfy a user rule. The two sides keep separate rule files for that reason.

redirect_to is checked before any rule uses it​

Login pages accept a redirect_to value in the URL so a person lands back where they were going. That value comes from the URL, so a stranger can set it.

Without a check, somebody could send a link that looks like a normal Jod login link and lands the visitor on another site straight after they sign in. One shared helper checks it before any rule uses it.

// app/auth/auth-safe-redirect.js

// One leading slash, then no second slash, backslash or whitespace.
const INTERNAL_PATH_PATTERN = /^\/(?!\/|\\)[^\s]*$/

const internalPathOrDefault = (urlPath, defaultPath, blockedPaths = []) => {
const isUrlPathPresent = !!urlPath
const isUrlPathInternal = isUrlPathPresent && INTERNAL_PATH_PATTERN.test(urlPath)

if (!isUrlPathInternal) return defaultPath

// Compare on the path only. A query string must not hide a blocked path.
const pathname = urlPath.split('?')[0]

const isBlocked = blockedPaths.some((blocked) => (
pathname === blocked || pathname.startsWith(`${blocked}/`)
))

return isBlocked ? defaultPath : urlPath
}

// redirectTarget(location) reads redirect_to from location.search.
// loginRedirectPath(basePath, location) builds basePath?redirect_to=<the path the person asked for>.
export const authSafeRedirect = {
internalPathOrDefault : internalPathOrDefault,
loginRedirectPath : loginRedirectPath,
redirectTarget : redirectTarget
}
ValueResultWhy
/talent/profileAllowOne leading slash, then a normal path
/employers/dashboard?tab=jobsAllowA query string is fine
//outside.exampleRejectA browser reads // as "same scheme, another site"
/\outside.exampleRejectSome browsers treat /\ the same way
https://outside.exampleRejectDoes not start with a slash
A path in the caller's blockedPathsRejectIt would loop, see below
Missing or emptyUse the defaultNothing to check

The blocked-paths check exists for one URL shape:

/employers/login?redirect_to=/employers/login

Without it, a signed-in person opening that link is sent to /employers/login, whose guest policy sends them to /employers/login again, forever. The browser stops the loop with an error page after about twenty redirects. So each guest rule passes its own guest paths as blockedPaths, and the helper falls back to the default instead.

The helper holds no user or admin data, so both identity systems may share it. It is the only auth file they share. The blockedPaths values are the caller's, which is what keeps it that way. The helper knows no routes of its own.

How to write a new rule​

RuleWhy
Take the session and the location, nothing elseOne session carries every fact. A rule that wants more data is asking the wrong question
Return a path, never a verdict objectThe answer is the redirect target. Nothing should have to read it
Test for the states that allowEverything else, including unknown states, then falls through to a redirect
Cover every state of every fact in the tableThe table is the contract. The function is its translation
Never call Rails, read a cookie, or navigateThose belong to the middleware and the guard
Keep one rule per policyA rule answering two questions should be two rules

Write the table first, then the function.

How to test a rule​

Because a rule reads nothing but its two inputs, a test is a table of inputs and expected answers. No browser, no Node, no mocks.

import { authEntryRules } from '@/auth/auth-entry-rules'

const withEmployerState = (state) => ({
identities_user : { id: 42 },
talent_access : { state: 'missing' },
employer_access : { state: state }
})

test.each([
[null, '/employers/login?redirect_to=%2Femployers%2Fdashboard'],
[withEmployerState('missing'), '/employers/create-account'],
[withEmployerState('active'), null],
[withEmployerState('company_disabled'), '/employers/access-unavailable'],
[withEmployerState('revoked'), '/employers/access-unavailable'],
[withEmployerState('some_future'), '/employers/access-unavailable']
])('authEntryRules.employerRequired', (identitiesUserSession, expected) => {
expect(
authEntryRules.employerRequired(identitiesUserSession, { pathname: '/employers/dashboard', search: '' })
).toBe(expected)
})

The last row is the unknown-state test. Every rule file needs one.

The safe-redirect helper needs its own loop rows, for every guest area:

  • redirect_to pointing at the guest page itself, /employers/login.
  • redirect_to pointing at another page in the same guest area, /employers/sign-up.
  • A blocked path followed by a query string, /employers/login?a=1.
  • A URL-encoded variant of a blocked path, as it arrives from the query string.
  • A blocked path as a prefix of a longer allowed path. /employers/login-help must not be blocked by /employers/login.

Every row of every table on this page should appear as a row in a test. That is how the page and the code stay in step.