From 6ff03a45f78e33fedc24953f7e19a8e19af21b29 Mon Sep 17 00:00:00 2001 From: ajet Date: Sat, 19 Sep 2026 07:36:06 -0900 Subject: [PATCH] add nrepl GET --- README.md | 5 +++-- src/tbd/nrepl/routes.clj | 9 ++++++++- test/tbd/nrepl/routes_test.clj | 26 ++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 0ee09ce..3747fad 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ docker run --rm -p 3000:3000 tbd | `GET` | `/auth/callback` | OAuth redirect target: completes the login, sets the session cookie, redirects to `/` | | `POST` | `/auth/logout` | Ends the session | | `GET` | `/auth/me` | The logged-in user, `{:login "alice" :id 7 :admin? true}` (401 if not logged in) | +| `GET` | `/nrepl` | **Admin only.** Whether the nREPL debug server is on: `{:enabled false}`, or `{:enabled true :bind "127.0.0.1" :port 7888}` | | `POST` | `/nrepl` | **Admin only.** Body `{"enabled": true}` (JSON or EDN) starts the nREPL debug server, `false` stops it; returns `{:enabled true :bind "127.0.0.1" :port 7888}` | ```bash @@ -60,7 +61,7 @@ curl 'localhost:3000/api/add?x=2&y=3' # {:result 5} Login is OAuth2 (authorization-code flow) against a [Gitea](https://about.gitea.com/) instance. Anyone with a Gitea account can log in, but routes marked `:admin? true` (currently -`POST /nrepl`) also require the user to be on the `ADMIN_USERS` allowlist: **401** without a +`GET` and `POST /nrepl`) also require the user to be on the `ADMIN_USERS` allowlist: **401** without a login, **403** for a logged-in non-admin. With no allowlist, nobody is an admin. 1. In Gitea (Settings > Applications) create an OAuth2 application, a confidential client, with @@ -154,7 +155,7 @@ src/tbd/ /routes.clj Each route group (health, math, ...) owns its route subtree /core.clj Business logic for the group, with no HTTP concerns auth/ Gitea OAuth (core), session + admin middleware (middleware), /auth routes - nrepl/ On-demand nREPL server (core) and the admin-only POST /nrepl route + nrepl/ On-demand nREPL server (core) and the admin-only `/nrepl` routes ui/ ClojureScript re-frame app (shadow-cljs project); ui/src/tbd/ui/{db,events,subs,views,core}.cljs .env-template Settings the server reads from the environment; copy to .env (git-ignored) resources/public/ index.html (the shell served at /) and js/ (compiled UI output, git-ignored) diff --git a/src/tbd/nrepl/routes.clj b/src/tbd/nrepl/routes.clj index d0cdf6b..fdcaf53 100644 --- a/src/tbd/nrepl/routes.clj +++ b/src/tbd/nrepl/routes.clj @@ -15,9 +15,16 @@ (println "[tbd] nREPL toggle failed:" (.getMessage e)) (edn-response 500 {:error (str "could not change nREPL state: " (.getMessage e))}))))) +(defn- status-handler [_req] + (edn-response 200 (nrepl/status))) + (defn routes [config] ["/nrepl" - {:post {:tags ["admin"] + {:get {:tags ["admin"] + :summary "Read whether the nREPL debug server is on or off (admin only)" + :admin? true + :handler status-handler} + :post {:tags ["admin"] :summary "Enable or disable the nREPL debug server (admin only)" :admin? true :parameters {:body [:map [:enabled {:description "true starts the server, false stops it"} :boolean]]} diff --git a/test/tbd/nrepl/routes_test.clj b/test/tbd/nrepl/routes_test.clj index 54b4ef1..21b6d15 100644 --- a/test/tbd/nrepl/routes_test.clj +++ b/test/tbd/nrepl/routes_test.clj @@ -22,17 +22,24 @@ body (assoc :body (ByteArrayInputStream. (.getBytes ^String body))))))) (cond-> @resp (string? (:body @resp)) (update :body edn/read-string))))) +(defn get-nrepl [app cookie] + (let [resp (app {:request-method :get :uri "/nrepl" + :headers (cond-> {} cookie (assoc "cookie" cookie))})] + (cond-> resp (string? (:body resp)) (update :body edn/read-string)))) + (deftest anonymous-test (let [app (t/make-app (t/gitea-stub "alice"))] (doseq [body ["{\"enabled\":true}" "{\"enabled\":\"junk\"}" nil]] (let [resp (post-nrepl app nil body)] (is (= 401 (:status resp)) (str "body: " body)))) + (is (= 401 (:status (get-nrepl app nil)))) (is (= {:enabled false} (nrepl/status)) "nothing was started"))) (deftest non-admin-test (let [app (t/make-app (t/gitea-stub "mallory")) cookie (t/login! app)] (is (= 403 (:status (post-nrepl app cookie "{\"enabled\":true}")))) + (is (= 403 (:status (get-nrepl app cookie)))) (is (= {:enabled false} (nrepl/status)) "nothing was started"))) (deftest empty-allowlist-test @@ -60,6 +67,24 @@ (is (= 200 (:status resp))) (is (true? (get-in resp [:body :enabled]))))))) +(deftest status-test + (let [app (t/make-app (t/gitea-stub "alice")) + cookie (t/login! app)] + (testing "off" + (let [resp (get-nrepl app cookie)] + (is (= 200 (:status resp))) + (is (= "application/edn; charset=utf-8" (get-in resp [:headers "Content-Type"]))) + (is (= {:enabled false} (:body resp))))) + (testing "on" + (with-out-str (post-nrepl app cookie "{\"enabled\":true}")) + (let [resp (get-nrepl app cookie)] + (is (= 200 (:status resp))) + (is (= {:enabled true :bind "127.0.0.1"} (dissoc (:body resp) :port))) + (is (= (:body resp) (nrepl/status))))) + (testing "off again" + (with-out-str (post-nrepl app cookie "{\"enabled\":false}")) + (is (= {:enabled false} (:body (get-nrepl app cookie))))))) + (deftest invalid-body-test (let [app (t/make-app (t/gitea-stub "alice")) cookie (t/login! app)] @@ -89,4 +114,5 @@ (->> (m/decode "application/json"))) paths (into {} (map (fn [[k v]] [(str (namespace k) "/" (name k)) v])) (:paths spec))] (is (contains? (get-in paths ["/nrepl"]) :post) "documented") + (is (contains? (get-in paths ["/nrepl"]) :get) "documented") (is (contains? paths "/auth/login"))))