Repository navigation
Password lock UI #13
Description
Activity
Plan
Password lock for the web console (issue #13)
Add an optional
server.passwordthat gates the control API and puts a login screen in front of the web UI, with the unlocked state held insessionStorageso closing the tab or browser logs the operator out.Context
internal/serveris documented as "loopback-only, no authentication" (internal/server/server.go:1-7), and since #11 it also serves an embedded browser console at/uithat can pause the loop, cancel runs, and read full Claude transcripts. Issue #13 asks for a required password prompt on first visit, configured atserver.passwordinconfig.json, with session-only persistence. The result should be backwards compatible: an existingconfig.jsonwith nopasswordkey keeps today's open behaviour.Reading chosen where the issue is ambiguous: "session persisted only" means the browser holds a short-lived session token in
sessionStorage(per-tab, dropped on tab/browser close), not a cookie and notlocalStorage. The token is issued by the server and kept in memory, so a daemon restart also logs everyone out.Config
internal/config/config.go- Add to
ServerConfig:Password string \json:"password"`with a comment saying empty = no authentication (today's behaviour) and that it is stored in plaintext inconfig.json`. Default()leaves it empty —config.LoadusesDisallowUnknownFields, so the field must exist beforeconfig.example.jsoncan carry it.- No new rule in
Validate(); a blank password simply means "disabled" (compare withstrings.TrimSpace, so" "is treated as unset rather than as a password nobody can type).
config.example.json— add"password": ""under"server". The Makefile'sembed-readytarget copies this file to the gitignoredconfig.jsonthatembedded.gocompiles in, so nothing else needs touching for the embedded fallback.Server: auth primitives
New file
internal/server/auth.go:type sessionStore struct— mutex,map[string]time.Timeof token → expiry, a TTL constant (12h), plus a failed-attempt counter and window start for login throttling. Methods:issue() string(32 random bytes fromcrypto/rand, hex/base64url encoded),valid(token string) bool(prunes expired entries and slides the expiry forward on a hit),revoke(token string), andthrottled() bool/recordFailure()(e.g. 10 failures per minute → 429). Constants live in the package so tests can drive them.Servergains asessions *sessionStorefield, created inNew(always; it is inert when no password is configured).func (s *Server) authEnabled() bool—strings.TrimSpace(s.cfg.Server.Password) != "".- Credential extraction:
Authorization: Bearer <value>, accepted if it is either a live session token or the configured password itself, compared withcrypto/subtle.ConstantTimeCompare. Accepting the raw password keeps the documentedcurl localhost:8787/statusworkflow usable (curl -H "Authorization: Bearer $PASSWORD" ...) — see Decisions below.
Server: middleware and routes
internal/server/server.go- In
routes(), registers.app.Use(s.requireAuth)immediately after the existings.app.Use(s.sameOrigin), so the origin check still runs first and one middleware covers every route including the static mount. requireAuth(c fiber.Ctx) error:!s.authEnabled()→c.Next().- Unauthenticated allow-list, by
c.Path():/healthz(liveness probes / systemd),/auth,/login,/(the redirect), and anything under/ui— the console's own HTML/CSS/JS must load in order to render the lock screen, and those assets contain no run data. - Otherwise, valid credential →
c.Next(); elses.fail(c, http.StatusUnauthorized, errors.New("authentication required")), reusing the existingfailhelper so the body shape matches every other error andapiFetchsurfaces it unchanged.
- New handlers, registered unconditionally so the front end has one code path:
GET /auth→{"required": <bool>, "authenticated": <bool>}. Called at boot; because the client sends its stored token with it, this doubles as "is my stored token still good?".POST /login→ binds{"password": "..."}withc.Bind().Body, returns 429 when throttled, 401 +recordFailure()on mismatch, and{"token": ..., "expires_in_seconds": ...}on success. Log failures vias.log.Warn(no password in the log line).POST /logout→ revokes the presented token, always{"ok": true}.
getConfigmust redact the password exactly like the Discord webhook: blankcfg.Server.Passwordon the copy and add"password_set": <bool>alongsidediscord_webhook_set. This matters even though/configis itself behind auth — the console renders the whole config blob into the page.
Web console
internal/web/assets/index.html- Add a
<div id="lock-screen" class="lock" hidden>overlay before<header>, containing a<form id="lock-form">with an<h1>, atype="password"input (autocomplete="current-password",autofocus), a submit button reusing.btn .btn-primary, and a<p id="lock-error" role="alert" hidden>. - Add a
<button id="lock-btn">to.topbar-right, hidden unless auth is required.
internal/web/assets/app.jsconst TOKEN_KEY = "cal.token"held insessionStorage(deliberately notlocalStorage, unlike the existingcal.theme/cal.pollIntervalkeys — that difference is what makes "tab closed = logged out" true, and deserves a comment).authHeaders()helper;apiFetchmerges it intooptions.headersfor every request. The one rawfetch— the transcript loader atapp.js:659— gets the same headers.- In
apiFetch, treatres.status === 401specially: clear the stored token, calllock(), and skip the error banner (the lock screen is the message). lock()clears the token,clearInterval(pollTimer), un-hides#lock-screen, hides the chrome (alockedclass on<body>), focuses the input.unlock()reverses it and runs the current boot sequence.- Restructure the four boot statements at the bottom of
app.jsintoasync function boot():GET /authfirst; ifrequired && !authenticated→lock(); otherwise show/hide the Lock button perrequiredand run the existingschedulePoll() / refreshStatus() / parseHash() / renderRoute(). - Lock form submit →
api.post("/login", {password}), storetokeninsessionStorage,unlock(); on failure show the message in#lock-error(401 → "Incorrect password", 429 → "Too many attempts, wait a minute"). - Lock button →
api.post("/logout")thenlock().
internal/web/assets/app.css—.lock(fixed full-viewport flex centering over--bg),.lock-card(--surface,--border,--radius,--shadow),.lock-error(--danger), andbody.locked .topbar, body.locked #app { display: none }. Existing.btn/.btn-primarycover the button; the theme toggle already runs before boot so the lock screen inherits the right theme.Tests
internal/server/server_test.go(reusetestServerWithConfigwithcfg.Server.Password = "hunter2"): unauthenticatedGET /status→ 401;/healthz,/,/ui/,/ui/app.jsstill 200; wrong password → 401; correct password → token; that token asAuthorization: Bearer→ 200; the raw password as bearer → 200;POST /logoutthen reuse of the token → 401;GET /authshape in both configured and unconfigured cases; repeated failures → 429;GET /configblanksserver.passwordand setspassword_set(mirrorTestGetConfigRedactsWebhook). Keep an explicit regression test that withPassword: ""every existing route stays open.internal/config/config_test.go:{"server":{"password":"s3cret"}}loads (guardsDisallowUnknownFields) andDefault().Server.Password == "".internal/web/web_test.go: extend the existing asset-reference checks to assertindex.htmlcontainslock-screenandapp.jscontainssessionStorage, matching the file's stated purpose of catching silent renames.
Docs (
README.md)- Config table (~line 431): add
server.password— "optional password for the web console and control API; empty disables authentication; stored in plaintext, so keepconfig.jsonreadable only by the daemon user". - Sample config block (~line 378): add
"password": "". - API route table (~line 559): add
GET /auth,POST /login,POST /logout, plus a paragraph on how scripts authenticate (-H "Authorization: Bearer $PASSWORD") and which routes stay open (/healthz,/,/ui/*). - "Web interface" section (~line 571-580): replace "loopback-only and unauthenticated by default" with the new nuance — still loopback-first, now optionally password-locked; session-only, so closing the tab logs you out; a daemon restart invalidates all sessions; and the password is not a substitute for binding to loopback, since there is no TLS.
Decisions the reviewer may want to overrule
- Raw password accepted as a bearer credential. Keeps
curlusable without a login round-trip, but means the password crosses the wire on every scripted call. Drop it if the lock should be browser-only, at the cost of every READMEcurlexample needing aPOST /loginfirst. - Plaintext password in
config.json. What the issue asks for; no hashing, no env-var override. The mitigation is documentation plus the existing loopback default. /uiassets served unauthenticated. Required for the login screen to render; they expose no run data. The alternative (a separate embedded login page) is more code for no real gain./healthzstays open so systemd/uptime probes don't need a credential.
Verification
make embed-ready && go test ./...(plusmake vet lintif available) — new server/config/web tests pass, andTestUIServedAndRootRedirects/TestSameOriginGuardstill pass unchanged.make config, set"password": "hunter2",make run, then:curl -i localhost:8787/status→ 401;curl -i localhost:8787/healthz→ 200.curl -s -XPOST localhost:8787/login -H 'Content-Type: application/json' -d '{"password":"hunter2"}'→ a token; reusing it as a bearer on/status→ 200.curl -s localhost:8787/config -H "Authorization: Bearer hunter2" | jq .config.server.password→"",.password_set→true.
- Browser:
http://127.0.0.1:8787/→ lock screen; wrong password shows an inline error; correct password loads the dashboard and polling resumes; close the tab and reopen → locked again; the Lock button returns to the lock screen immediately. - Set
"password": ""and restart → no lock screen, allcurlcalls work without a header (backwards compatibility).
Plan file written to
/home/node1/.claude/plans/write-a-plan-for-peaceful-clarke.md. No repository files were modified.
Reply with exactly
implementto approve this plan and start the change. Reply with anything else and the plan will be revised to address it.coding-agent-loop run
d6f561dc-0dac-4fc8-9096-c5997d06a2f7, modelclaude-opus-5, cost $1.4762- Add to
- addedagent-plannedManaged by coding-agent-loopManaged by coding-agent-loopand removed
on Aug 26, 2026 implement
Opened a draft pull request for this issue: #16
Tests failed (
make test) — see the PR for output.Comment
implementagain if you want another attempt at this issue.coding-agent-loop run
13d5a39a-e5c7-43d3-b133-c5307d79f4fa- added and removedagent-plannedManaged by coding-agent-loopManaged by coding-agent-loop
on Aug 26, 2026 - added a commit that references this issue
on Aug 28, 2026
Add a required password input when initially visiting the web UI. set the password in config.json at server.password. Password is session persisted only, so if tab/browser closes the user is "logged out".