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
nullto 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_tois checked before any rule uses it. - How to write a new rule, and how to test one.
The words this page uses
| Word | Meaning |
|---|---|
| Entry rule | A 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 session | identitiesUserSession, 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 location | Where the person is going: an object with pathname, such as /employers/dashboard, and search, the query string such as ?redirect_to=/talent/profile |
| Access state | One 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 |
| Guard | authUserGuard.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 route | The route that exports the guard as its middleware. One policy route, one rule. The policy routes page lists all ten |
| Guest policy | A policy route that guards a page only signed-out people should see, such as /login or /employers/login |
| Holding page | A 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_to | A 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 state | A 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.
| Fact | Where it lives | Values |
|---|---|---|
| Signed in or out | identitiesUserSession | null, or an object with identities_user and the two access answers |
| Talent state | identitiesUserSession.talent_access.state | missing, setup_required, ready |
| Employer state | identitiesUserSession.employer_access.state | missing, 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. Itsloaderreads it fromGET /identities/users/current/org_memberships: the entry withis_selected: true. - No rule reads it. Rails already turned every membership into the one
stateword, 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 group | Takes |
|---|---|
| User, talent and employer rules | identitiesUserSession, location |
| Admin rules | identitiesAdminSession, 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/setupand/employers/access-unavailablekeep 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
| Policy | Session | Result |
|---|---|---|
| User guest | Signed out | Allow |
| User guest | Signed in | Safe redirect_to, or / |
| User required | Signed out | /login with redirect_to |
| User required | Signed in | Allow |
The talent rules
| Policy | Session | Talent state | Result |
|---|---|---|---|
| Talent onboarding | Signed out | Not read | /login with redirect_to |
| Talent onboarding | Signed in | missing | Allow |
| Talent onboarding | Signed in | setup_required | Allow |
| Talent onboarding | Signed in | ready | /talent/profile |
| Talent onboarding | Signed in | any other state | Allow |
| Talent required | Signed out | Not read | /login with redirect_to |
| Talent required | Signed in | missing | /talent/setup with redirect_to |
| Talent required | Signed in | setup_required | /talent/setup with redirect_to |
| Talent required | Signed in | ready | Allow |
| Talent required | Signed in | any 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
| Policy | Session | Employer state | Result |
|---|---|---|---|
| Employer guest | Signed out | Not read | Allow |
| Employer guest | Signed in | Not read | Safe redirect_to, or /employers/dashboard |
| Employer onboarding | Signed out | Not read | /employers/login with redirect_to |
| Employer onboarding | Signed in | missing | Allow |
| Employer onboarding | Signed in | active | /employers/dashboard |
| Employer onboarding | Signed in | company_disabled or revoked | /employers/access-unavailable |
| Employer onboarding | Signed in | any other state | /employers/access-unavailable |
| Employer inactive | Signed out | Not read | /employers/login |
| Employer inactive | Signed in | missing | /employers/create-account |
| Employer inactive | Signed in | active | /employers/dashboard |
| Employer inactive | Signed in | company_disabled or revoked | Allow |
| Employer inactive | Signed in | any other state | Allow |
| Employer required | Signed out | Not read | /employers/login with redirect_to |
| Employer required | Signed in | missing | /employers/create-account |
| Employer required | Signed in | active | Allow |
| Employer required | Signed in | company_disabled or revoked | /employers/access-unavailable |
| Employer required | Signed in | any other state | /employers/access-unavailable |
Two things to hold on to.
- A person with
company_disabledorrevokedis 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.
| State | The 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.
| Rule | Why |
|---|---|
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 page | One 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 own | That layout's navbar already shows the person's name and a logout button |
| The page makes no employer data request | The session already carries the one word the page needs, and Rails would refuse the request with 403 |
The admin rules
| Policy | Admin session | Result |
|---|---|---|
| Admin guest | Signed out | Allow |
| Admin guest | Signed in | Safe redirect_to, or /team |
| Admin required | Signed out | /team/login with redirect_to |
| Admin required | Signed in | Allow |
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
}
| Value | Result | Why |
|---|---|---|
/talent/profile | Allow | One leading slash, then a normal path |
/employers/dashboard?tab=jobs | Allow | A query string is fine |
//outside.example | Reject | A browser reads // as "same scheme, another site" |
/\outside.example | Reject | Some browsers treat /\ the same way |
https://outside.example | Reject | Does not start with a slash |
A path in the caller's blockedPaths | Reject | It would loop, see below |
| Missing or empty | Use the default | Nothing 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
| Rule | Why |
|---|---|
| Take the session and the location, nothing else | One session carries every fact. A rule that wants more data is asking the wrong question |
| Return a path, never a verdict object | The answer is the redirect target. Nothing should have to read it |
| Test for the states that allow | Everything else, including unknown states, then falls through to a redirect |
| Cover every state of every fact in the table | The table is the contract. The function is its translation |
| Never call Rails, read a cookie, or navigate | Those belong to the middleware and the guard |
| Keep one rule per policy | A 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_topointing at the guest page itself,/employers/login.redirect_topointing 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-helpmust 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.