Skip to content

Password lock UI #13

Description

@ableinc

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".

Activity

  1. ableinc commented on Aug 26, 2026

    @ableinc
    OwnerAuthor

    Plan

    Password lock for the web console (issue #13)

    Add an optional server.password that gates the control API and puts a login screen in front of the web UI, with the unlocked state held in sessionStorage so closing the tab or browser logs the operator out.

    Context

    internal/server is documented as "loopback-only, no authentication" (internal/server/server.go:1-7), and since #11 it also serves an embedded browser console at /ui that can pause the loop, cancel runs, and read full Claude transcripts. Issue #13 asks for a required password prompt on first visit, configured at server.password in config.json, with session-only persistence. The result should be backwards compatible: an existing config.json with no password key 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 not localStorage. 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.Load uses DisallowUnknownFields, so the field must exist before config.example.json can carry it.
    • No new rule in Validate(); a blank password simply means "disabled" (compare with strings.TrimSpace, so " " is treated as unset rather than as a password nobody can type).

    config.example.json — add "password": "" under "server". The Makefile's embed-ready target copies this file to the gitignored config.json that embedded.go compiles 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.Time of token → expiry, a TTL constant (12h), plus a failed-attempt counter and window start for login throttling. Methods: issue() string (32 random bytes from crypto/rand, hex/base64url encoded), valid(token string) bool (prunes expired entries and slides the expiry forward on a hit), revoke(token string), and throttled() bool / recordFailure() (e.g. 10 failures per minute → 429). Constants live in the package so tests can drive them.
    • Server gains a sessions *sessionStore field, created in New (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 with crypto/subtle.ConstantTimeCompare. Accepting the raw password keeps the documented curl localhost:8787/status workflow usable (curl -H "Authorization: Bearer $PASSWORD" ...) — see Decisions below.

    Server: middleware and routes

    internal/server/server.go

    • In routes(), register s.app.Use(s.requireAuth) immediately after the existing s.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(); else s.fail(c, http.StatusUnauthorized, errors.New("authentication required")), reusing the existing fail helper so the body shape matches every other error and apiFetch surfaces 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": "..."} with c.Bind().Body, returns 429 when throttled, 401 + recordFailure() on mismatch, and {"token": ..., "expires_in_seconds": ...} on success. Log failures via s.log.Warn (no password in the log line).
      • POST /logout → revokes the presented token, always {"ok": true}.
    • getConfig must redact the password exactly like the Discord webhook: blank cfg.Server.Password on the copy and add "password_set": <bool> alongside discord_webhook_set. This matters even though /config is 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>, a type="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.js

    • const TOKEN_KEY = "cal.token" held in sessionStorage (deliberately not localStorage, unlike the existing cal.theme / cal.pollInterval keys — that difference is what makes "tab closed = logged out" true, and deserves a comment).
    • authHeaders() helper; apiFetch merges it into options.headers for every request. The one raw fetch — the transcript loader at app.js:659 — gets the same headers.
    • In apiFetch, treat res.status === 401 specially: clear the stored token, call lock(), and skip the error banner (the lock screen is the message).
    • lock() clears the token, clearInterval(pollTimer), un-hides #lock-screen, hides the chrome (a locked class on <body>), focuses the input. unlock() reverses it and runs the current boot sequence.
    • Restructure the four boot statements at the bottom of app.js into async function boot(): GET /auth first; if required && !authenticated → lock(); otherwise show/hide the Lock button per required and run the existing schedulePoll() / refreshStatus() / parseHash() / renderRoute().
    • Lock form submit → api.post("/login", {password}), store token in sessionStorage, unlock(); on failure show the message in #lock-error (401 → "Incorrect password", 429 → "Too many attempts, wait a minute").
    • Lock button → api.post("/logout") then lock().

    internal/web/assets/app.css — .lock (fixed full-viewport flex centering over --bg), .lock-card (--surface, --border, --radius, --shadow), .lock-error (--danger), and body.locked .topbar, body.locked #app { display: none }. Existing .btn/.btn-primary cover the button; the theme toggle already runs before boot so the lock screen inherits the right theme.

    Tests

    • internal/server/server_test.go (reuse testServerWithConfig with cfg.Server.Password = "hunter2"): unauthenticated GET /status → 401; /healthz, /, /ui/, /ui/app.js still 200; wrong password → 401; correct password → token; that token as Authorization: Bearer → 200; the raw password as bearer → 200; POST /logout then reuse of the token → 401; GET /auth shape in both configured and unconfigured cases; repeated failures → 429; GET /config blanks server.password and sets password_set (mirror TestGetConfigRedactsWebhook). Keep an explicit regression test that with Password: "" every existing route stays open.
    • internal/config/config_test.go: {"server":{"password":"s3cret"}} loads (guards DisallowUnknownFields) and Default().Server.Password == "".
    • internal/web/web_test.go: extend the existing asset-reference checks to assert index.html contains lock-screen and app.js contains sessionStorage, 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 keep config.json readable 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

    1. Raw password accepted as a bearer credential. Keeps curl usable 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 README curl example needing a POST /login first.
    2. 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.
    3. /ui assets 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.
    4. /healthz stays open so systemd/uptime probes don't need a credential.

    Verification

    1. make embed-ready && go test ./... (plus make vet lint if available) — new server/config/web tests pass, and TestUIServedAndRootRedirects / TestSameOriginGuard still pass unchanged.
    2. 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.
    3. 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.
    4. Set "password": "" and restart → no lock screen, all curl calls 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 implement to 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, model claude-opus-5, cost $1.4762

  2. ableinc commented on Aug 26, 2026

    @ableinc
    OwnerAuthor

    implement

  3. ableinc commented on Aug 26, 2026

    @ableinc
    OwnerAuthor

    Opened a draft pull request for this issue: #16

    Tests failed (make test) — see the PR for output.

    Comment implement again if you want another attempt at this issue.

    coding-agent-loop run 13d5a39a-e5c7-43d3-b133-c5307d79f4fa

  4. added a commit that references this issue on Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions