Rails is the boundary
Rails decides whether a request may read or change data. Nothing in JodApp Web does.
- A policy route improves the journey: the right person lands on the right page.
- Rails decides access: whether a request may read or change data.
- A visitor can skip everything that runs in their own browser, so only Rails can be the boundary.
- This page covers six things, in this order.
- Why no code in the browser can be the boundary.
- On a click inside the app, Rails is the only thing refusing a raced fetch.
- Where page data loads, and when to move it.
- Every response that carries a session must not be cached.
- What Rails must enforce on its own: employer, talent, and the session endpoint.
- Why a stale copy of the session in the browser is safe.
The words this page uses
| Word | Meaning |
|---|---|
| Boundary | The one place where a request is allowed or refused, and that a visitor cannot get around. For JodApp Web that place is Rails, on every request |
| Journey | Which page a person lands on, and in what order. Policy routes shape it. A wrong journey is a bad experience, never a leak |
| Access | Whether a request may read or change data. Rails decides it, by reading the database at the moment of the request |
| Raced fetch | On a click inside the app, a call that a page's clientLoader sends to Rails before the policy on Node has answered. The policy loader page shows why it happens |
| Referee | What Rails is for a raced fetch. Not a second check behind the policy, but the only check, because the policy has not answered yet |
| Shared cache | Anything between the browser and Node that stores responses and serves them by URL: a CDN, a company proxy. It must never store a page built for one signed-in person |
Employers::BaseController | The Rails controller every employer endpoint inherits from. It refuses a request unless the person has an active membership in an active company |
CurrentRequest | The Rails object that holds the signed-in person, their session, and their membership for the rest of one request. Code below the check reads it |
No code in the browser can be the boundary
A person controls their own browser. They can open the developer tools and call Rails directly, edit the JavaScript running on their page, or replay a request with any body they like.
Legend: Orange is Rails, this page's subject. Grey is what runs in the browser or on Node. Lavender is the data. Both paths end at Rails, and only Rails stands in front of the data.
| Layer | Decides | Can the visitor skip it? |
|---|---|---|
| Policy route | Which page opens | Yes. It runs in code the visitor can skip |
| Rails controller | Whether data moves | No. It runs on our machines, on every request |
The two jobs must never be confused. A policy that thinks it protects data is wrong, and a Rails endpoint that trusts the policy to have checked is a leak.
On a click inside the app, Rails is the only thing refusing a raced fetch
Picture a person whose session just ended, clicking from the employer home page into the dashboard. That click enters employer-required-policy.
Legend: The two requests leave the browser together. Rails refuses the page's own fetch on its own, with no help from the policy, because the policy has not answered yet. Then the policy's redirect wins the navigation and Mei never sees the page.
Nothing leaked, and the person never saw the page. But look at the 401. On every denied click inside the app, Rails is not a second line of defence. It is the only thing refusing that request.
That is why this page exists as a first-class part of the design. Every Rails endpoint must enforce its own rules completely, as if JodApp Web did not exist. For a raced fetch, it effectively does not.
A click between two pages already inside the dashboard sends no .data request at all, so there the policy does not run. The page's own fetch is refused, the caller answers with revalidate(), and that reload runs the policy. The policy loader page lists when the policy runs.
Two duties follow for the API client in the browser.
| Duty | Why |
|---|---|
| Never navigate or clear state on a refused request | The refusal may be a raced fetch losing to a policy redirect that is already in flight. Only policies navigate |
Treat only 401 as signed out | Every other failure is an error, not a logout. The refused-response table on the changing-a-session page gives the meaning of each code |
Where page data loads, and when to move it
The referee role raises a fair question: should protected pages move their fetches to a server loader, so the policy runs before them inside one request on Node?
The answer is a standard per kind of page, not one rule for everything.
| Page | Data loads in | Why |
|---|---|---|
| Public page | A server loader | Search engines must see the content, and the first paint should carry data |
| Protected page | A clientLoader, plus a HydrateFallback on the page | No search engine ever sees it. It is interaction-heavy, and the browser path to Rails exists anyway for writes. Rails referees the raced fetch |
| Protected entry page: the dashboard home, a bookmarked detail page | A server loader beside the clientLoader, added after a measurement | The first paint should carry data, and the person often arrives by a document request |
| Policy route | Server middleware and a loader that returns null | The check itself always runs on Node. The policy loader page explains why |
A protected page may adopt a server loader when it has a concrete reason: it needs request context, its data must not be fetched before the check, or its first paint should carry data. That is a small, per-route change, and never a requirement. app/routes/jobs/job-list-page.jsx is the working example to copy.
Whichever kind a page uses, the security position is identical, because Rails checks every request either way. JodApp Web D3 — Node reads, the browser writes, Rails decides records the standard and the measurements behind it.
Move more protected pages to a server loader when one of these becomes true.
| Condition | Why it changes the answer |
|---|---|
| We take the Rails API off the public internet | Then every request must flow through Node, including writes |
| A page needs a secret that must not reach the browser | Only a server loader can hold it |
| A protected area gains search-engine needs | Server rendering with data becomes required |
| Measurements show the browser path is slow | Real numbers can justify the move |
Node holds a session for one request at a time and then forgets it. It must never cache a session across requests. The reading-the-session page states the rule and why it keeps Node safe to scale.
Every response that carries a session must not be cached
Session data crosses two hops, and each hop needs its own protection.
| Hop | Response | Protected by |
|---|---|---|
| Rails to Node | The current-session JSON | Rails sends Cache-Control: no-store |
| Node to the browser | The HTML document and the .data responses | Node sends Cache-Control: private, no-store |
The second hop is the one that is easy to forget.
- The root loader writes the session, with the person's name and access states, into the HTML document and into every
.dataresponse. - Rails's header protects only the first hop. The browser never sees that response.
- React Router adds no cache header to a document or
.dataresponse on its own. - So without this rule, a personalised page leaves Node with no caching instructions at all. A shared cache is then allowed to store it and serve it by URL. That is how one person's page could reach another person's screen.
The rule: every response Node builds while a session cookie is present carries Cache-Control: private, no-store. This includes redirects. Test it on the outer HTTP response, not on the Rails hop. The root middleware sets it, as the reading-the-session page shows.
Employer endpoints require an active membership
Employers::BaseController binds every employer request to one membership, then requires that membership to be usable. A membership record that merely exists is not enough.
| State | Protected employer endpoint answers |
|---|---|
| No membership | 403 with code org_membership_required |
active membership, active company | Allowed |
revoked membership | 403 with code org_membership_inactive |
active membership, company switched off | 403 with code org_company_disabled |
| No session at all | 401. That is a different fact |
Why the check reads both columns:
- A revoked membership is still a record. A check that asks only "does a membership exist?" lets a revoked person into every employer endpoint.
- A check that reads only the membership status lets in a member of a disabled company.
- So the check reads both, through the same resolver the session uses,
Org::Memberships::CurrentResolveService. The session and the API can never disagree about who may enter.
Rails sets CurrentRequest.org_membership and CurrentRequest.org_company only after the check passes. Code below the check can trust them.
One controller keeps its deliberate exception. Employers::Org::MembershipsController skips the membership requirement for create, because that is how a signed-in user makes their first company. create stays the only exception.
Those three 403 codes are the ones the browser treats as "access facts changed" and reloads the session for. The CSRF codes share the status and mean something else. The refused-response table tells them apart.
Talent endpoints check their own permission
The same principle, smaller shape. Each talent endpoint checks the permission it needs. A policy route allowing /talent/profile does not replace the API's own check on GET /talent/profiles/current.
The session endpoint reports and never grants
GET /identities/user_sessions/current tells a person whose company is disabled that their state is company_disabled. That is one word about their situation. It exposes no company data, and it opens no employer endpoint. The strict rules above apply to every endpoint that carries real data.
Only 401 means signed out, and error boundaries never navigate
Do not turn every failure into a logout. Each failure means one thing, and the app reacts to that thing only.
401means the session is gone. The session middleware answersnull, and the policy on the page sends the person to login.- A
403with an access code means the access facts changed. The session is reloaded, and the policies read the new state. Nobody is signed out. - A
403with a CSRF code, a5xx, or a network failure is an error. The error boundary shows it and reports it. Nobody is signed out. - The refused-response table holds every code with what the caller does.
The dangerous shortcut is a catch that treats everything as "signed out". During an API outage, that shortcut tells every signed-in person they are logged out, all at once. The session middleware throws everything except 401 for exactly this reason.
Error boundaries display errors. They never perform auth navigation from a useEffect. Policies own entry redirects. Boundaries own broken states.
A stale copy of the session in the browser is safe
The browser's route data can be out of date. This is the sequence that proves it does not matter for access.
Legend: The stale "active" in the browser granted nothing, because Rails read the database at the moment of the request. The browser's copy is a navigation hint. The database is the truth. Every part of this design is allowed to be stale except Rails.