Part 4: whose cache is it? Scopes, and guarding a route¶
Part 3 gave Conduit a session: a sign-in, a token on every request, and a restore on reload. That has two consequences. The server now answers for the reader, so Part 2's reads need a cache per reader. And some pages — settings, the editor — should refuse to open while nobody is signed in.
This part adds one namespace, src/conduit/scope.cljc, and grows resources.cljc, auth.cljc and core.cljs.
Whose cache is it? Scope reads by viewer¶
Part 3's bearer interceptor changed what Part 2's reads mean. Signed in, the server answers for you: each article carries favorited, and its author following, relative to the token. With both reads registered :rf.scope/global, the cache would hand the first reader's copy — flags and all — to the next reader asking for the same params.
The fix is a named scope resolver that answers "who is reading?" from app-db, with both registrations pointing at it:
;; src/conduit/scope.cljc
;; cf. examples/real-apps/realworld_resources/scope.cljs
(ns conduit.scope
(:require [clojure.string :as str]
[re-frame.core :as rf]
[re-frame.resources]))
(rf/reg-resource-scope :conduit/viewer
{:doc "Whose copy of a public read is this? The signed-in username, a
confirmed anonymous reader, or nil (fail-closed) while a saved
token has not been checked yet."
:inputs {:username [:db [:auth :user :username]]
:token [:db [:auth :token]]}}
(fn [{:keys [username token]} _ctx]
(cond
username [:conduit.scope/viewer {:username username}]
(str/blank? token) [:conduit.scope/viewer :anonymous]
:else nil)))
A signed-in reader gets a scope of their own; readers with no token share one anonymous copy. Between boot and the GET /user reply there's a token but no user yet — a request sent then isn't anonymous, but it doesn't belong to anyone the app can name. The resolver returns nil, and nil fails closed: a route plan refuses, and a subscription reads no entry and reports :status :unresolved, rather than guessing.
Now point Part 2's reads at it. In src/conduit/resources.cljc, add [conduit.scope] to the requires and change both :scope lines to:
Nothing else in Part 2 changes: routes and :rf/resource subscriptions inherit the registration's scope. Three places now have to respect the new identity.
The shell waits out a restore. While the resolver says nil, every read reports :unresolved. Rather than give each page an :unresolved arm, the shell shows one holding line while the viewer is unknown. Add a sub to auth.cljc that knows when the viewer is unknown:
(rf/reg-sub :auth/restoring?
{:doc "A saved token is in hand but GET /user hasn't answered yet: the viewer is unknown."}
(fn [db _]
(and (nil? (get-in db [:auth :user]))
(not (str/blank? (get-in db [:auth :token]))))))
and wrap the root view's case in it:
(reg-view root-view []
[:div.app
[header]
(if @(subscribe [:auth/restoring?])
[:div.container.page [:p "Restoring your session…"]]
(case @(subscribe [:rf.route/id])
:conduit/home [articles/home-page]
:conduit.article/show [articles/article-page]
:conduit.auth/login [auth/login-page]
:rf.route/not-found [not-found-page]
[not-found-page]))])
A restore replans the page it's on. The first URL's reads failed closed while the viewer was unknown. When the reply lands, the subscription re-keys to the new scope, but re-keying never fetches, and navigating to the current page is a no-op. A change of reader is one framework event, :rf.resource/change-principal: it clears the scopes the departing reader held, then reruns the current route's reads under the current identity without navigating. It reuses retained identities with usable data or live work; an unusable retained entry is ensured again. A restore has nothing to clear — no entry was ever keyed to a viewer nobody knew — so :from is empty. Replace the two restore handlers so both outcomes dispatch it:
(rf/reg-event :auth/session-restored
{:sensitive [[:value :user :token]]}
(fn [{:keys [db]} [_ {:keys [value correlation]}]]
(if (= (:auth-generation db) (second (:request-id correlation)))
{:db (-> db
(update :auth-generation inc)
(assoc-in [:auth :user] (dissoc (:user value) :token)))
:fx [[:dispatch [:rf.resource/change-principal {:from [] :cause [:session-restore]}]]]}
{})))
(rf/reg-event :auth/session-expired
{:sensitive [[:error :body] [:error :body-text]]}
(fn [{:keys [db]} [_ {:keys [correlation]}]]
(if (= (:auth-generation db) (second (:request-id correlation)))
{:db (-> db
(update :auth-generation inc)
(update :auth assoc :user nil :token nil))
:fx [[:auth.session/persist {:token nil}]
[:dispatch [:rf.resource/change-principal {:from [] :cause [:session-restore-failed]}]]]}
{})))
Signing in and out change the reader, and both say so with the same event. Signing in ends by navigating, and the new route plans its reads under the new reader, but navigating clears nothing: the anonymous reader's entries stay cached, and whatever still holds one keeps refetching it on focus, reconnect or an invalidation, now with the new token. Extend Part 3's :auth.login-form/submit-success in auth.cljc, and add [conduit.scope] to that file's requires, because sign-in now names the viewer resolver:
(rf/reg-event :auth.login-form/submit-success
{:sensitive [[:value :user :token]]}
(fn [{:keys [db]} [_ {:keys [value correlation]}]]
(if (not= (:auth-generation db) (second (:request-id correlation)))
{}
(let [user (:user value)
return-to (get-in db [:auth :return-to])]
{:db (-> db
(update :auth-generation inc)
(assoc-in [:auth :user] (dissoc user :token))
(assoc-in [:auth :token] (:token user))
(update :auth dissoc :return-to)
(update-in [:auth :login-form]
#(assoc % :status :submitted :submitted (:draft %))))
:fx [[:auth.session/persist {:token (:token user)}]
[:dispatch [:rf.resource/change-principal
{:from [(rf/resolve-resource-scope db :conduit/viewer)]
:cause [:login]}]]
[:dispatch (if return-to
[:rf.route/navigate (assoc return-to :replace? true)]
[:rf.route/navigate {:to :conduit/home :replace? true}])]]}))))
rf/resolve-resource-scope runs the viewer resolver against the handler's db, the value before this event, so it names the reader who is leaving: the anonymous one, or whoever was signed in before. The change of principal is queued ahead of the navigate, so that reader's entries are gone before the next page plans its reads. The generation guard and the classified token payload are Part 3's, unchanged.
Sign-out clears the reader's cache¶
Part 3's :auth/logout ends the session. Now it also leaves a reader's cache behind. Replace it in auth.cljc:
(rf/reg-event :auth/logout
(fn [{:keys [db]} [_ {:keys [generation]}]]
(if (and generation (not= generation (:auth-generation db)))
{}
{:db (-> db (update :auth-generation (fnil inc 0))
(assoc :auth {:user nil :token nil}))
:fx [[:auth.session/persist {:token nil}]
[:dispatch [:rf.route/navigate {:to :conduit/home}]]
[:dispatch [:rf.resource/change-principal
{:from [(rf/resolve-resource-scope db :conduit/viewer)]
:cause [:logout]}]]]})))
The new step is about the cache. rf/resolve-resource-scope runs the viewer resolver against the handler's db — the value before this event, which still knows who is leaving — and :rf.resource/change-principal evicts that reader's entries, aborts anything of theirs in flight, and replans the current page. It runs after the navigate: signed out on the home page, the navigate is a no-op, and the replan is what fetches the anonymous list.
Scope keeps readers apart on the read side: the next reader's entries are different cache keys. Clearing is the other half. An entry that is gone cannot be refetched, so no focus, reconnect, poll tick or invalidation can load the departing reader's key with the next reader's token.
The cell below runs the viewer resolver, the article list and the logout above against a stub transport. Buttons stand in for Part 3's login, and the table reads the list's entry in each reader's scope. Sign in as alice: her first read sends a request, and the anonymous entry is cleared, because she replaced the anonymous reader. Sign in as bob: alice's entry is cleared and bob's read is sent. Sign out, and bob's entry is cleared while the anonymous list loads again: every change of reader clears only the departing reader's scope.
(require '[clojure.string :as str]
'[re-frame.core :as rf]
'[re-frame.http.managed]
'[re-frame.resources]
'[re-frame.routing]
'[re-frame.http.stub-transport :as stub-transport]
'[reagent.core :as r])
(def api-base "https://api.realworld.show/api")
(stub-transport/install-transport! :conduit
(stub-transport/route-transport
{[:get (str api-base "/articles?limit=10")]
{:body {:articles [{:slug "welcome" :title "Welcome to Conduit"}] :articlesCount 1}}}))
;; Log each request that reaches the stubbed API. The frame below names it
;; in :http-interceptors.
(def requests (r/atom 0))
(rf/reg-http-interceptor :conduit/request-log
{:before (fn [ctx] (swap! requests inc) ctx)})
(rf/reg-resource-scope :conduit/viewer
{:inputs {:username [:db [:auth :user :username]]
:token [:db [:auth :token]]}}
(fn [{:keys [username token]} _ctx]
(cond
username [:conduit.scope/viewer {:username username}]
(str/blank? token) [:conduit.scope/viewer :anonymous]
:else nil)))
(rf/reg-resource :conduit/articles
{:params-schema [:map]
:scope {:from-db :conduit/viewer}
:stale-after-ms 60000}
(fn [_params _ctx]
{:request {:method :get :url (str api-base "/articles") :params {:limit 10}}
:decode :json}))
(rf/reg-route :conduit/home
{:resources [{:resource :conduit/articles :params (fn [_route] {}) :blocking? false}]}
"/")
;; Signing in here skips Part 3's login request and stores a user directly.
(rf/reg-event :auth/signed-in
(fn [{:keys [db]} [_ username]]
{:db (assoc db :auth {:user {:username username} :token (str username "-token")})
:fx [[:dispatch [:rf.resource/change-principal
{:from [(rf/resolve-resource-scope db :conduit/viewer)]
:cause [:sign-in]}]]]}))
;; Part 4's logout, without Part 3's token persistence.
(rf/reg-event :auth/logout
(fn [{:keys [db]} _]
{:db (assoc db :auth {:user nil :token nil})
:fx [[:dispatch [:rf.route/navigate {:to :conduit/home}]]
[:dispatch [:rf.resource/change-principal
{:from [(rf/resolve-resource-scope db :conduit/viewer)]
:cause [:logout]}]]]}))
(rf/reg-sub :auth/user (fn [db _] (get-in db [:auth :user])))
(rf/reg-view cache-row [{:keys [label scope]}]
(let [state @(subscribe [:rf/resource {:resource :conduit/articles :params {} :scope scope}])]
[:tr [:td label] [:td [:code (pr-str (:status state))]]]))
(rf/reg-view cache-demo []
(let [user (:username @(subscribe [:auth/user]))]
[:div
[:p "Reading as: " [:b (or user "anonymous")]]
[:button {:on-click #(dispatch [:auth/signed-in "alice"])} "Sign in as alice"]
[:button {:on-click #(dispatch [:auth/signed-in "bob"])} "Sign in as bob"]
[:button {:on-click #(dispatch [:auth/logout])} "Sign out"]
[:table
[:tbody
[cache-row {:label "anonymous" :scope [:conduit.scope/viewer :anonymous]}]
[cache-row {:label "alice" :scope [:conduit.scope/viewer {:username "alice"}]}]
[cache-row {:label "bob" :scope [:conduit.scope/viewer {:username "bob"}]}]]]
[:p "Requests sent: " @requests]]))
[rf/frame-root {:id :conduit
:http-interceptors [:conduit/request-log]
:initial-events [[:rf.route/navigate {:to :conduit/home}]]}
[cache-demo]]
The guard¶
Settings and the editor should refuse to open while signed out. Each protected route names a :can-enter guard in its metadata, and the runtime consults it for every navigation, however it started. (:tags is free-form classification the framework attaches no meaning to — handy when a navbar wants to ask "is this page protected?")
;; add to src/conduit/auth.cljc
(rf/reg-route :conduit.user/settings
{:tags #{:requires-auth}
:can-enter [:conduit/signed-in?]}
"/settings")
(rf/reg-sub :conduit/signed-in?
{:doc "The :can-enter auth guard: true when a user is signed in."
:inputs [[:auth/user]]}
(fn [[user] _] (some? user))) ;; true → OK to enter
That's the whole gate. Sign-out needs nothing extra for it: once :auth/user is nil, the guard refuses again.
Give the route a small page so you can exercise the guard. This part displays the
current account; editing it is a separate mutation, left to the full example.
Add this view in auth.cljc:
(rf/reg-view settings-page []
(let [user @(subscribe [:auth/user])]
[:div.container.page
[:h1 "Your account"]
[:p (:username user)]
[:p (:email user)]]))
In core.cljs, add :conduit.user/settings [auth/settings-page] to the root
view's case. Add a link beside the brand in header, visible whether signed in
or out so you can try both outcomes:
Why a route guard rather than an event interceptor
Navigations arrive by several doors — :rf.route/navigate (in several shapes, including {:url …} and in-place query edits that name no route id), link clicks, and the URL bar or Back button. An interceptor on one navigation event has to handle every shape itself, and the one it misses lets a signed-out visitor in. :can-enter runs in the single planning step all of them pass through, so it can't be bypassed. A frame interceptor is still right for policies that aren't about routes, such as a maintenance-mode lockout — see Require sign-in on a route.
What a refusal does, and the login bounce¶
A refusal is terminal. Nothing commits — no route slice, URL push or :on-match events — and nothing is parked waiting to resume.
The runtime dispatches :rf.route/entry-denied once. Its default handler does nothing, so a signed-out click on Settings currently has no visible effect. Your replacement sends the visitor to login, with one exception that comes from Part 3's boot:
Gotcha — the token lands in time, the identity does not
:initial-events settles synchronous work; it doesn't wait for requests. So when the first URL is resolved, [:auth :token] is set but [:auth :user] is still nil — GET /user hasn't answered. A protected deep link is therefore refused on that first resolution, and the handler below treats that refusal as "we don't know yet" rather than "you're not signed in". If your API lets you store the user alongside the token, restore both at boot and there's no window at all — Add authentication shows that simpler shape.
(rf/reg-event :rf.route/entry-denied
{:doc "Steer a signed-out visitor to login, remembering where they were headed.
While a cold-boot restore is still in flight, defer the bounce instead:
we don't yet know that they're signed out."}
(fn [{:keys [db]} [_ {:keys [destination]}]]
(let [restoring? (and (nil? (get-in db [:auth :user])) ;; identity unknown…
(some? (get-in db [:auth :token])))] ;; …but a token is in hand
(cond-> {:db (assoc-in db [:auth :return-to] destination)}
(not restoring?)
(assoc :fx [[:dispatch [:rf.route/navigate {:to :conduit.auth/login
:replace? true}]]])))))
;; Once the restore settles, resolve whatever was deferred:
;; signed in → navigate to the stash again; the guard now allows it
;; signed out → go to login, keeping the stash for after sign-in
;; no stash → it was a public deep link; nothing to do
(rf/reg-event :auth/settle-deferred-entry
(fn [{:keys [db]} _]
(let [return-to (get-in db [:auth :return-to])]
(cond
(nil? return-to) {}
(some? (get-in db [:auth :user])) {:db (update db :auth dissoc :return-to)
:fx [[:dispatch [:rf.route/navigate
(assoc return-to :replace? true)]]]}
:else {:fx [[:dispatch [:rf.route/navigate
{:to :conduit.auth/login :replace? true}]]]}))))
Add [:dispatch [:auth/settle-deferred-entry]] to the :fx of both restore handlers, :auth/session-restored and :auth/session-expired. It reads the settled slice rather than being told which outcome happened, so there's one code path.
Why branch in the handler rather than make the guard smarter? :can-enter returns a strict boolean, and mid-restore the true answer to "is this visitor signed in?" is false. What that refusal means is policy, and policy belongs in the handler. Waiting is safe because the refusal committed nothing, so no protected page is on screen meanwhile. A deferred entry commits no route either, so the shell's :auth/restoring? branch shows the holding line instead of the not-found page.
Three details:
:destinationis the resolved address the visitor was heading to — path params, query and fragment — and is itself a valid:rf.route/navigaterequest. Stashing it at[:auth :return-to], where Part 3'ssubmit-successalready looks, returns the user to the exact URL.:replace? trueon the hop to login keeps the refused URL off the back stack, so Back from/logindoesn't run into the guard again.- Register the handler without a
:sensitivemap. The framework's classification of the payload's URLs still applies to your replacement handler, and your handler sees the real values. (Require sign-in on a route covers the handler in full.)
The return trip is an ordinary navigation: the guard runs again and, now that a user is present, allows it.
Watch it fire. Signed out, click Settings. In Xray the navigation row is followed by :rf.route/entry-denied and your redirect to login, and /settings never commits. Sign in, and the ledger shows the bounce back to /settings.
Then reload directly on /settings while signed in. The ledger reads: :auth/initialise, the initial :rf.route/handle-url-change, one :rf.route/entry-denied with no redirect after it, the /user reply, :auth/settle-deferred-entry, and finally the navigate that commits /settings. A redirect-to-login row in the middle of that sequence means the deferral has broken.
A guard must return a boolean — strictly
true allows, false refuses, and any other value refuses and raises :rf.error/can-enter-non-boolean. A sub that returns nil because its slice isn't seeded yet doesn't quietly half-work; guard against an absent slice in the sub itself. (Part 5 adds the mirror-image guard, :can-leave, which stops you leaving a page with unsaved work.)