nandi/frqpublic Fork 0
4aa71e7
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Remember an OAuth sign-in across restarts

The durable broker token now lives in $XDG_CONFIG_HOME/frq/session.edn beside
the handle and nick it belongs to, so a restart opens on the Bluesky tab and
Connect resumes without a browser. It is a credential — it mints session
tokens for the account — so the file is created with mode 600 before anything
is written to it, and the single-use web-token is deliberately not saved.

Saving happens on every sign-in rather than only the first: /session can hand
back a rotated broker token, and the one it replaces may stop working the
moment it does.

A token the broker no longer honours is worth exactly one attempt. It is
dropped from disk and memory and the browser flow runs again in the same
Connect — the alternative was a saved credential that failed identically
forever and could only be cleared by hand. A SASL refusal clears it too.

While here, `describe` stopped prefixing every message with its exception
class; the class is now shown only for a raw host condition, which prints as
#object[:object] and would otherwise say nothing at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-08-30T00:55:46-07:00 Browse files
4aa71e7 parent: 3d47bd9
modified README.md +9 -2
@@ -14,6 +14,7 @@ of an `AppState` struct.
1414 ```
1515 src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payloads
1616 src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session
17+src/frq/store.jolt the saved sign-in, mode 600 in the config directory
1718 src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG
1819 src/frq/state.jolt the ratoms every screen reads, and `apply-msg!`
1920 src/frq/app.jolt the screens
@@ -50,6 +51,12 @@ job: POST the fragment back to itself. What comes back is a single-use SASL
5051 `web-token` and a durable `broker_token`; later connections mint a fresh token
5152 from the durable one at `/session` and skip the browser.
5253
54+The durable token is saved to `$XDG_CONFIG_HOME/frq/session.edn` (mode 600) so
55+a restart resumes without one, along with the handle and nick it belongs to.
56+The web-token beside it is single-use and deliberately not saved. A token the
57+broker no longer honours is dropped — from disk and memory — and the browser
58+flow runs once more, rather than failing the same way on every Connect.
59+
5360 **App password** signs in without a browser, straight to the user's own PDS:
5461 `resolveHandle` → DID → PDS from the DID document → `createSession`. The
5562 password goes to that PDS and nowhere else, is never written to disk, and is
@@ -98,7 +105,7 @@ surface — that surface does not work on Android either, while the syscalls do.
98105 * **No `did:key` signing, no credential gates, no E2EE.** Sign-in of either
99106 kind needs TLS, so it is desktop-only — the Android build connects as a
100107 guest.
101-* **The broker token lives in memory.** Nothing is persisted, so a restart
102- means another trip through the browser.
108+* **Only the broker token is persisted**, and only for OAuth. An app-password
109+ sign-in is not remembered.
103110 * **No scrollback trimming, avatars, reactions, threads, or calls.**
104111 * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet.
@@ -14,6 +14,7 @@ of an `AppState` struct.
14 ```14 ```
15 src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payloads15 src/frq/atproto.jolt handle → DID → PDS → session, and the SASL payloads
16 src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session16 src/frq/oauth.jolt the broker flow: login URL, loopback capture, /session
17+src/frq/store.jolt the saved sign-in, mode 600 in the config directory
17 src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG18 src/frq/irc.jolt IRC over TLS or TCP: parser, reader thread, SASL, PRIVMSG
18 src/frq/state.jolt the ratoms every screen reads, and `apply-msg!`19 src/frq/state.jolt the ratoms every screen reads, and `apply-msg!`
19 src/frq/app.jolt the screens20 src/frq/app.jolt the screens
@@ -50,6 +51,12 @@ job: POST the fragment back to itself. What comes back is a single-use SASL
50 `web-token` and a durable `broker_token`; later connections mint a fresh token51 `web-token` and a durable `broker_token`; later connections mint a fresh token
51 from the durable one at `/session` and skip the browser.52 from the durable one at `/session` and skip the browser.
52 53
54+The durable token is saved to `$XDG_CONFIG_HOME/frq/session.edn` (mode 600) so
55+a restart resumes without one, along with the handle and nick it belongs to.
56+The web-token beside it is single-use and deliberately not saved. A token the
57+broker no longer honours is dropped — from disk and memory — and the browser
58+flow runs once more, rather than failing the same way on every Connect.
59+
53 **App password** signs in without a browser, straight to the user's own PDS:60 **App password** signs in without a browser, straight to the user's own PDS:
54 `resolveHandle` → DID → PDS from the DID document → `createSession`. The61 `resolveHandle` → DID → PDS from the DID document → `createSession`. The
55 password goes to that PDS and nowhere else, is never written to disk, and is62 password goes to that PDS and nowhere else, is never written to disk, and is
@@ -98,7 +105,7 @@ surface — that surface does not work on Android either, while the syscalls do.
98 * **No `did:key` signing, no credential gates, no E2EE.** Sign-in of either105 * **No `did:key` signing, no credential gates, no E2EE.** Sign-in of either
99 kind needs TLS, so it is desktop-only — the Android build connects as a106 kind needs TLS, so it is desktop-only — the Android build connects as a
100 guest.107 guest.
101-* **The broker token lives in memory.** Nothing is persisted, so a restart108+* **Only the broker token is persisted**, and only for OAuth. An app-password
102- means another trip through the browser.109+ sign-in is not remembered.
103 * **No scrollback trimming, avatars, reactions, threads, or calls.**110 * **No scrollback trimming, avatars, reactions, threads, or calls.**
104 * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet.111 * Message lists are keyed vboxes; glimmer-vidya has no `:listbox` yet.
modified src/frq/app.jolt +8 -3
@@ -87,9 +87,11 @@
8787 :width-request 320
8888 :placeholder "alice.bsky.social"
8989 :on-change #(reset! s/form-handle %)}]
90- [:vbox {:key :remembered}
90+ [:vbox {:key :remembered :spacing 4}
9191 (when @s/broker-token
92- [:dim-label {:label "Session remembered — Connect will not need the browser."}])]
92+ [:vbox {:spacing 4}
93+ [:dim-label {:label "Session remembered — Connect will not need the browser."}]
94+ [:button {:label "Forget saved session" :on-click s/forget-session!}]])]
9395 [:vbox {:key :login-url :spacing 4}
9496 (when-let [url @s/login-url]
9597 [:vbox {:spacing 4}
@@ -238,7 +240,7 @@
238240 (when @s/broker-token
239241 [:button {:label "Forget Bluesky session"
240242 :kind :destructive
241- :on-click #(do (reset! s/broker-token nil) (reset! s/session nil))}])]]
243+ :on-click s/forget-session!}])]]
242244 [:dim-label {:label "Guest — not signed in."}])
243245 [:separator {}]
244246 [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]]
@@ -260,4 +262,7 @@
260262 [chats-screen]))
261263
262264 (defn -main [& _]
265+ ;; Before the window: a saved sign-in decides which mode the connect screen
266+ ;; opens in, and what it says.
267+ (s/restore-session!)
263268 (ui/run app :title "frq" :width 520 :height 860))
@@ -87,9 +87,11 @@
87 :width-request 32087 :width-request 320
88 :placeholder "alice.bsky.social"88 :placeholder "alice.bsky.social"
89 :on-change #(reset! s/form-handle %)}]89 :on-change #(reset! s/form-handle %)}]
90- [:vbox {:key :remembered}90+ [:vbox {:key :remembered :spacing 4}
91 (when @s/broker-token91 (when @s/broker-token
92- [:dim-label {:label "Session remembered — Connect will not need the browser."}])]92+ [:vbox {:spacing 4}
93+ [:dim-label {:label "Session remembered — Connect will not need the browser."}]
94+ [:button {:label "Forget saved session" :on-click s/forget-session!}]])]
93 [:vbox {:key :login-url :spacing 4}95 [:vbox {:key :login-url :spacing 4}
94 (when-let [url @s/login-url]96 (when-let [url @s/login-url]
95 [:vbox {:spacing 4}97 [:vbox {:spacing 4}
@@ -238,7 +240,7 @@
238 (when @s/broker-token240 (when @s/broker-token
239 [:button {:label "Forget Bluesky session"241 [:button {:label "Forget Bluesky session"
240 :kind :destructive242 :kind :destructive
241- :on-click #(do (reset! s/broker-token nil) (reset! s/session nil))}])]]243+ :on-click s/forget-session!}])]]
242 [:dim-label {:label "Guest — not signed in."}])244 [:dim-label {:label "Guest — not signed in."}])
243 [:separator {}]245 [:separator {}]
244 [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]]246 [:button {:label "Disconnect" :kind :destructive :on-click s/disconnect!}]]
@@ -260,4 +262,7 @@
260 [chats-screen]))262 [chats-screen]))
261 263
262 (defn -main [& _]264 (defn -main [& _]
265+ ;; Before the window: a saved sign-in decides which mode the connect screen
266+ ;; opens in, and what it says.
267+ (s/restore-session!)
263 (ui/run app :title "frq" :width 520 :height 860))268 (ui/run app :title "frq" :width 520 :height 860))
modified src/frq/state.jolt +56 -14
@@ -8,7 +8,8 @@
88 [glimmer.ratom :as r :refer [atom]]
99 [frq.atproto :as atproto]
1010 [frq.irc :as irc]
11- [frq.oauth :as oauth]))
11+ [frq.oauth :as oauth]
12+ [frq.store :as store]))
1213
1314 (def default-host "irc.freeq.at")
1415 (def default-port "6697")
@@ -150,8 +151,10 @@
150151 ("904" "905" "906") (do (reset! session nil)
151152 ;; The broker token may still be good but a
152153 ;; refusal is as likely to mean it is not, and a
153- ;; stale one would fail the same way every time.
154+ ;; stale one would fail the same way every time,
155+ ;; including across restarts if it were kept.
154156 (reset! broker-token nil)
157+ (store/clear-session!)
155158 (reset! error (str "Bluesky sign-in refused: "
156159 (or (last params) "no reason given"))))
157160 "*DISCONNECTED*" (do (reset! conn nil)
@@ -171,9 +174,12 @@
171174 "What went wrong, in words. A jolt condition prints as #object[:object], so
172175 the message and the ex-data are what has to be dug out by hand."
173176 [e]
174- (let [msg (or (ex-message e) (str e))
175- data (ex-data e)]
176- (str (type e) ": " msg (when data (str " " (pr-str data))))))
177+ (let [msg (ex-message e)]
178+ (if (seq msg)
179+ msg
180+ ;; A raw host condition prints as #object[:object] and says nothing, so
181+ ;; its type is the only thing left worth showing.
182+ (str (type e) ": " (str e)))))
177183
178184 (defn- dial! [host port nick tls? sess]
179185 ;; stderr is the only console on Android this line lands in logcat.
@@ -196,19 +202,34 @@
196202 ;; broker token in hand skips the browser entirely.
197203 :bluesky
198204 (let [handle (str/trim @form-handle)
205+ browser! (fn []
206+ (reset! status "Opening your browser to sign in…")
207+ (oauth/await-callback!
208+ oauth/default-broker handle
209+ (fn [url]
210+ (reset! login-url url)
211+ (oauth/open-browser! url)
212+ (reset! status "Waiting for the browser…"))))
199213 tokens (if-let [bt @broker-token]
200- (do (reset! status "Refreshing your session…")
201- (oauth/refresh-session oauth/default-broker bt))
202- (do (reset! status "Opening your browser to sign in…")
203- (oauth/await-callback!
204- oauth/default-broker handle
205- (fn [url]
206- (reset! login-url url)
207- (oauth/open-browser! url)
208- (reset! status "Waiting for the browser…")))))
214+ ;; A saved token that the broker no longer honours
215+ ;; is worth exactly one attempt: drop it and go
216+ ;; through the browser, rather than failing the
217+ ;; same way on every future Connect.
218+ (try (reset! status "Resuming your session…")
219+ (oauth/refresh-session oauth/default-broker bt)
220+ (catch Exception _
221+ (reset! broker-token nil)
222+ (store/clear-session!)
223+ (reset! status "Saved session expired — signing in again…")
224+ (browser!)))
225+ (browser!))
209226 s (assoc tokens :kind :web-token)]
210227 (reset! login-url nil)
211228 (reset! broker-token (:broker-token tokens))
229+ ;; Saved on every sign-in, not only the first: /session can
230+ ;; hand back a rotated broker token, and the old one may stop
231+ ;; working the moment it does.
232+ (store/save-session! tokens)
212233 (reset! session s)
213234 (when (seq (:handle tokens)) (reset! form-handle (:handle tokens)))
214235 s)
@@ -275,6 +296,27 @@
275296 (reset! status "Not connected")
276297 (reset! error (str "Could not connect: " (describe e))))))))
277298
299+(defn restore-session!
300+ "Pick up a saved sign-in at startup. Only the durable broker token comes
301+ back; the connection still mints a fresh web-token from it."
302+ []
303+ (when-let [saved (store/load-session)]
304+ (reset! broker-token (:broker-token saved))
305+ (when (seq (:handle saved)) (reset! form-handle (:handle saved)))
306+ (when (seq (:nick saved)) (reset! form-nick (:nick saved)))
307+ (reset! auth-mode :bluesky)
308+ (reset! status (str "Signed in as " (:handle saved) " — Connect to resume"))
309+ saved))
310+
311+(defn forget-session!
312+ "Drop the saved sign-in, on disk and in memory."
313+ []
314+ (store/clear-session!)
315+ (reset! broker-token nil)
316+ (reset! session nil)
317+ (reset! auth-mode :guest)
318+ (reset! status "Not connected"))
319+
278320 (defn disconnect! []
279321 (when-let [c @conn] (irc/close! c))
280322 (reset! conn nil)
@@ -8,7 +8,8 @@
8 [glimmer.ratom :as r :refer [atom]]8 [glimmer.ratom :as r :refer [atom]]
9 [frq.atproto :as atproto]9 [frq.atproto :as atproto]
10 [frq.irc :as irc]10 [frq.irc :as irc]
11- [frq.oauth :as oauth]))11+ [frq.oauth :as oauth]
12+ [frq.store :as store]))
12 13
13 (def default-host "irc.freeq.at")14 (def default-host "irc.freeq.at")
14 (def default-port "6697")15 (def default-port "6697")
@@ -150,8 +151,10 @@
150 ("904" "905" "906") (do (reset! session nil)151 ("904" "905" "906") (do (reset! session nil)
151 ;; The broker token may still be good but a152 ;; The broker token may still be good but a
152 ;; refusal is as likely to mean it is not, and a153 ;; refusal is as likely to mean it is not, and a
153- ;; stale one would fail the same way every time.154+ ;; stale one would fail the same way every time,
155+ ;; including across restarts if it were kept.
154 (reset! broker-token nil)156 (reset! broker-token nil)
157+ (store/clear-session!)
155 (reset! error (str "Bluesky sign-in refused: "158 (reset! error (str "Bluesky sign-in refused: "
156 (or (last params) "no reason given"))))159 (or (last params) "no reason given"))))
157 "*DISCONNECTED*" (do (reset! conn nil)160 "*DISCONNECTED*" (do (reset! conn nil)
@@ -171,9 +174,12 @@
171 "What went wrong, in words. A jolt condition prints as #object[:object], so174 "What went wrong, in words. A jolt condition prints as #object[:object], so
172 the message and the ex-data are what has to be dug out by hand."175 the message and the ex-data are what has to be dug out by hand."
173 [e]176 [e]
174- (let [msg (or (ex-message e) (str e))177+ (let [msg (ex-message e)]
175- data (ex-data e)]178+ (if (seq msg)
176- (str (type e) ": " msg (when data (str " " (pr-str data))))))179+ msg
180+ ;; A raw host condition prints as #object[:object] and says nothing, so
181+ ;; its type is the only thing left worth showing.
182+ (str (type e) ": " (str e)))))
177 183
178 (defn- dial! [host port nick tls? sess]184 (defn- dial! [host port nick tls? sess]
179 ;; stderr is the only console on Android this line lands in logcat.185 ;; stderr is the only console on Android this line lands in logcat.
@@ -196,19 +202,34 @@
196 ;; broker token in hand skips the browser entirely.202 ;; broker token in hand skips the browser entirely.
197 :bluesky203 :bluesky
198 (let [handle (str/trim @form-handle)204 (let [handle (str/trim @form-handle)
205+ browser! (fn []
206+ (reset! status "Opening your browser to sign in…")
207+ (oauth/await-callback!
208+ oauth/default-broker handle
209+ (fn [url]
210+ (reset! login-url url)
211+ (oauth/open-browser! url)
212+ (reset! status "Waiting for the browser…"))))
199 tokens (if-let [bt @broker-token]213 tokens (if-let [bt @broker-token]
200- (do (reset! status "Refreshing your session…")214+ ;; A saved token that the broker no longer honours
201- (oauth/refresh-session oauth/default-broker bt))215+ ;; is worth exactly one attempt: drop it and go
202- (do (reset! status "Opening your browser to sign in…")216+ ;; through the browser, rather than failing the
203- (oauth/await-callback!217+ ;; same way on every future Connect.
204- oauth/default-broker handle218+ (try (reset! status "Resuming your session…")
205- (fn [url]219+ (oauth/refresh-session oauth/default-broker bt)
206- (reset! login-url url)220+ (catch Exception _
207- (oauth/open-browser! url)221+ (reset! broker-token nil)
208- (reset! status "Waiting for the browser…")))))222+ (store/clear-session!)
223+ (reset! status "Saved session expired — signing in again…")
224+ (browser!)))
225+ (browser!))
209 s (assoc tokens :kind :web-token)]226 s (assoc tokens :kind :web-token)]
210 (reset! login-url nil)227 (reset! login-url nil)
211 (reset! broker-token (:broker-token tokens))228 (reset! broker-token (:broker-token tokens))
229+ ;; Saved on every sign-in, not only the first: /session can
230+ ;; hand back a rotated broker token, and the old one may stop
231+ ;; working the moment it does.
232+ (store/save-session! tokens)
212 (reset! session s)233 (reset! session s)
213 (when (seq (:handle tokens)) (reset! form-handle (:handle tokens)))234 (when (seq (:handle tokens)) (reset! form-handle (:handle tokens)))
214 s)235 s)
@@ -275,6 +296,27 @@
275 (reset! status "Not connected")296 (reset! status "Not connected")
276 (reset! error (str "Could not connect: " (describe e))))))))297 (reset! error (str "Could not connect: " (describe e))))))))
277 298
299+(defn restore-session!
300+ "Pick up a saved sign-in at startup. Only the durable broker token comes
301+ back; the connection still mints a fresh web-token from it."
302+ []
303+ (when-let [saved (store/load-session)]
304+ (reset! broker-token (:broker-token saved))
305+ (when (seq (:handle saved)) (reset! form-handle (:handle saved)))
306+ (when (seq (:nick saved)) (reset! form-nick (:nick saved)))
307+ (reset! auth-mode :bluesky)
308+ (reset! status (str "Signed in as " (:handle saved) " — Connect to resume"))
309+ saved))
310+
311+(defn forget-session!
312+ "Drop the saved sign-in, on disk and in memory."
313+ []
314+ (store/clear-session!)
315+ (reset! broker-token nil)
316+ (reset! session nil)
317+ (reset! auth-mode :guest)
318+ (reset! status "Not connected"))
319+
278 (defn disconnect! []320 (defn disconnect! []
279 (when-let [c @conn] (irc/close! c))321 (when-let [c @conn] (irc/close! c))
280 (reset! conn nil)322 (reset! conn nil)
added src/frq/store.jolt +50 -0
new file mode 100644
@@ -0,0 +1,50 @@
1+(ns frq.store
2+ "The one thing worth keeping between runs: the durable broker token.
3+
4+ It is a credential anyone holding it can mint session tokens for the
5+ account so it lives in the user's config directory with the permissions of
6+ an ssh key, and never anywhere else. The web-token beside it is single-use
7+ and deliberately not saved."
8+ (:require [clojure.edn :as edn]
9+ [clojure.string :as str]
10+ [jolt.host :as host]))
11+
12+(defn config-dir []
13+ (let [xdg (host/getenv "XDG_CONFIG_HOME")
14+ home (host/getenv "HOME")]
15+ (str (if (seq xdg) xdg (str home "/.config")) "/frq")))
16+
17+(defn session-file [] (str (config-dir) "/session.edn"))
18+
19+(defn load-session
20+ "The saved session, or nil. A file that will not parse is treated as absent —
21+ a stale credential is not worth an error at startup."
22+ []
23+ (let [path (session-file)]
24+ (when (host/file-exists? path)
25+ (try
26+ (let [m (edn/read-string (slurp path))]
27+ (when (and (map? m) (seq (:broker-token m))) m))
28+ (catch Exception _ nil)))))
29+
30+(defn save-session!
31+ "Write {:broker-token :handle :did :nick}, readable by nobody else."
32+ [session]
33+ (let [dir (config-dir)
34+ path (session-file)]
35+ (try
36+ (host/mkdirs! dir)
37+ ;; Created before it is written, so the token is never on disk
38+ ;; world-readable even for an instant.
39+ (host/sh (str "install -m 600 /dev/null '" path "'"))
40+ (spit path (pr-str (select-keys session [:broker-token :handle :did :nick])))
41+ (host/sh (str "chmod 600 '" path "'"))
42+ true
43+ (catch Exception _ false))))
44+
45+(defn clear-session! []
46+ (try
47+ (when (host/file-exists? (session-file))
48+ (host/delete-file! (session-file)))
49+ true
50+ (catch Exception _ false)))
new file mode 100644
@@ -0,0 +1,50 @@
1+(ns frq.store
2+ "The one thing worth keeping between runs: the durable broker token.
3+
4+ It is a credential anyone holding it can mint session tokens for the
5+ account so it lives in the user's config directory with the permissions of
6+ an ssh key, and never anywhere else. The web-token beside it is single-use
7+ and deliberately not saved."
8+ (:require [clojure.edn :as edn]
9+ [clojure.string :as str]
10+ [jolt.host :as host]))
11+
12+(defn config-dir []
13+ (let [xdg (host/getenv "XDG_CONFIG_HOME")
14+ home (host/getenv "HOME")]
15+ (str (if (seq xdg) xdg (str home "/.config")) "/frq")))
16+
17+(defn session-file [] (str (config-dir) "/session.edn"))
18+
19+(defn load-session
20+ "The saved session, or nil. A file that will not parse is treated as absent —
21+ a stale credential is not worth an error at startup."
22+ []
23+ (let [path (session-file)]
24+ (when (host/file-exists? path)
25+ (try
26+ (let [m (edn/read-string (slurp path))]
27+ (when (and (map? m) (seq (:broker-token m))) m))
28+ (catch Exception _ nil)))))
29+
30+(defn save-session!
31+ "Write {:broker-token :handle :did :nick}, readable by nobody else."
32+ [session]
33+ (let [dir (config-dir)
34+ path (session-file)]
35+ (try
36+ (host/mkdirs! dir)
37+ ;; Created before it is written, so the token is never on disk
38+ ;; world-readable even for an instant.
39+ (host/sh (str "install -m 600 /dev/null '" path "'"))
40+ (spit path (pr-str (select-keys session [:broker-token :handle :did :nick])))
41+ (host/sh (str "chmod 600 '" path "'"))
42+ true
43+ (catch Exception _ false))))
44+
45+(defn clear-session! []
46+ (try
47+ (when (host/file-exists? (session-file))
48+ (host/delete-file! (session-file)))
49+ true
50+ (catch Exception _ false)))