Skip to content

Commit d356df9

Browse files
aksOpsclaude
andcommitted
feat(ui): migrate to @ossrandom/design-system 0.2.1
Replace the in-repo radix-ui + shadcn shim layer with the published design system (@ossrandom/design-system@^0.2.1). This also removes class-variance-authority and the @fontsource Inter / JetBrains Mono / Playfair Display packages — typography now ships with the design system as self-hosted variable woff2 (Bricolage Grotesque, Plus Jakarta Sans, Geist Mono — all OFL-1.1). Component swaps: - Tabs: shadcn TabsList/TabsTrigger/TabsContent → DS Tabs items[] API - Drawer: radix Sheet → DS Drawer (DiffSheet, RevertSheet, SettingsDrawer) - Modal: radix Dialog → DS Modal (NewSessionModal — uses rcs-pop-in) - Skeleton: shadcn → DS Skeleton (7 consumers) - Button: shadcn → DS Button / IconButton (Dashboard chrome, etc.) - PageHeader: custom <header> → DS PageHeader with size="xs", inlineSubtitle, backInline. Slim ~34px detail header on session pages; "ctm · claude tmux manager" inline-subtitle on dashboard. Layout robustness: - 100dvh root + flex-shrink chrome so the input bar stays pinned and the tabs content claims height correctly (fixes mobile overlap). - iOS auto-zoom guard: SessionInputBar text-base on mobile (<sm), tiny on desktop. The DS Input/Textarea also pin font-size 16px under pointer:coarse, so future migrations off raw <input> are zoom-safe. Bundle delta: −46 KB JS (radix gone), −9 KB CSS (shadcn gone). Net diff: +2955 / −4713 lines (−1758 net), 46 files. 110/110 vitest tests pass; 0 audit findings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c387df0 commit d356df9

46 files changed

Lines changed: 2955 additions & 4713 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

internal/serve/api/auth.go

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@ import (
88
"errors"
99
"io/fs"
1010
"log/slog"
11+
"math"
12+
"net"
1113
"net/http"
1214
"regexp"
15+
"strconv"
1316
"strings"
1417

1518
"github.com/RandomCodeSpace/ctm/internal/serve/auth"
@@ -103,14 +106,29 @@ func AuthSignup(store *auth.Store) http.HandlerFunc {
103106
}
104107
}
105108

106-
// AuthLogin returns POST /api/auth/login.
107-
func AuthLogin(store *auth.Store) http.HandlerFunc {
109+
// AuthLogin returns POST /api/auth/login. The limiter protects the
110+
// argon2id verify path from brute-force/DoS; a successful login
111+
// resets the IP's window so legitimate users aren't locked out
112+
// after a typo.
113+
func AuthLogin(store *auth.Store, limiter *auth.Limiter) http.HandlerFunc {
108114
return func(w http.ResponseWriter, r *http.Request) {
109115
if r.Method != http.MethodPost {
110116
w.Header().Set("Allow", http.MethodPost)
111117
writeInputErr(w, http.StatusMethodNotAllowed, "method_not_allowed", "POST only")
112118
return
113119
}
120+
ip := clientIP(r)
121+
if ok, retryAfter := limiter.Allow(ip); !ok {
122+
secs := int(math.Ceil(retryAfter.Seconds()))
123+
if secs < 1 {
124+
secs = 1
125+
}
126+
w.Header().Set("Retry-After", strconv.Itoa(secs))
127+
slog.Info("auth login reject", "reason", "rate_limited", "ip", ip)
128+
writeInputErr(w, http.StatusTooManyRequests, "rate_limited",
129+
"too many login attempts; try again later")
130+
return
131+
}
114132
var body authCredsBody
115133
if err := decodeAuthBody(r, w, &body); err != nil {
116134
return
@@ -133,6 +151,7 @@ func AuthLogin(store *auth.Store) http.HandlerFunc {
133151
"username or password does not match")
134152
return
135153
}
154+
limiter.Reset(ip)
136155
tok, err := store.Create(u.Username)
137156
if err != nil {
138157
writeInputErr(w, http.StatusInternalServerError, "session_failed", err.Error())
@@ -147,6 +166,18 @@ func AuthLogin(store *auth.Store) http.HandlerFunc {
147166
}
148167
}
149168

169+
// clientIP returns the host portion of r.RemoteAddr. We deliberately
170+
// do NOT honour X-Forwarded-For: behind the reverse proxy the real
171+
// source IP should reach us via RemoteAddr, and trusting XFF blindly
172+
// would let any client spoof the rate-limit key.
173+
func clientIP(r *http.Request) string {
174+
host, _, err := net.SplitHostPort(r.RemoteAddr)
175+
if err != nil {
176+
return r.RemoteAddr
177+
}
178+
return host
179+
}
180+
150181
// AuthLogout returns POST /api/auth/logout.
151182
func AuthLogout(store *auth.Store) http.HandlerFunc {
152183
return func(w http.ResponseWriter, r *http.Request) {

internal/serve/api/auth_test.go

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,18 @@ import (
99
"path/filepath"
1010
"strings"
1111
"testing"
12+
"time"
1213

1314
"github.com/RandomCodeSpace/ctm/internal/serve/api"
1415
"github.com/RandomCodeSpace/ctm/internal/serve/auth"
1516
)
1617

18+
// testLimiter returns a generously-sized limiter so tests that aren't
19+
// specifically exercising rate-limiting are never throttled.
20+
func testLimiter() *auth.Limiter {
21+
return auth.NewLimiter(1000, time.Second)
22+
}
23+
1724
// ---------- helpers --------------------------------------------------------
1825

1926
func authTempHome(t *testing.T) string {
@@ -156,7 +163,7 @@ func TestLogin_HappyPath(t *testing.T) {
156163
enc, _ := auth.Hash("password123")
157164
_ = auth.Save(auth.User{Username: "alice@example.com", Password: enc})
158165
store := auth.NewStore()
159-
h := api.AuthLogin(store)
166+
h := api.AuthLogin(store, testLimiter())
160167
rec := httptest.NewRecorder()
161168
h(rec, authJSONReq(t, http.MethodPost, "/api/auth/login",
162169
map[string]string{"username": "alice@example.com", "password": "password123"}))
@@ -175,7 +182,7 @@ func TestLogin_BadPassword(t *testing.T) {
175182
enc, _ := auth.Hash("password123")
176183
_ = auth.Save(auth.User{Username: "alice@example.com", Password: enc})
177184
store := auth.NewStore()
178-
h := api.AuthLogin(store)
185+
h := api.AuthLogin(store, testLimiter())
179186
rec := httptest.NewRecorder()
180187
h(rec, authJSONReq(t, http.MethodPost, "/api/auth/login",
181188
map[string]string{"username": "alice@example.com", "password": "wrong"}))
@@ -192,7 +199,7 @@ func TestLogin_UnknownUsername(t *testing.T) {
192199
enc, _ := auth.Hash("password123")
193200
_ = auth.Save(auth.User{Username: "alice@example.com", Password: enc})
194201
store := auth.NewStore()
195-
h := api.AuthLogin(store)
202+
h := api.AuthLogin(store, testLimiter())
196203
rec := httptest.NewRecorder()
197204
h(rec, authJSONReq(t, http.MethodPost, "/api/auth/login",
198205
map[string]string{"username": "mallory@example.com", "password": "password123"}))
@@ -204,7 +211,7 @@ func TestLogin_UnknownUsername(t *testing.T) {
204211
func TestLogin_NotRegistered(t *testing.T) {
205212
authTempHome(t)
206213
store := auth.NewStore()
207-
h := api.AuthLogin(store)
214+
h := api.AuthLogin(store, testLimiter())
208215
rec := httptest.NewRecorder()
209216
h(rec, authJSONReq(t, http.MethodPost, "/api/auth/login",
210217
map[string]string{"username": "alice@example.com", "password": "password123"}))
@@ -216,6 +223,42 @@ func TestLogin_NotRegistered(t *testing.T) {
216223
}
217224
}
218225

226+
func TestLogin_RateLimited(t *testing.T) {
227+
authTempHome(t)
228+
enc, _ := auth.Hash("password123")
229+
_ = auth.Save(auth.User{Username: "alice@example.com", Password: enc})
230+
store := auth.NewStore()
231+
232+
// Injected clock stays fixed so all 6 attempts fall in the window.
233+
clock := func() time.Time { return time.Unix(1_700_000_000, 0) }
234+
lim := auth.NewLimiterWithClock(5, 60*time.Second, clock)
235+
h := api.AuthLogin(store, lim)
236+
237+
// 5 bad attempts — all should reach the handler and return 401.
238+
for i := 0; i < 5; i++ {
239+
rec := httptest.NewRecorder()
240+
h(rec, authJSONReq(t, http.MethodPost, "/api/auth/login",
241+
map[string]string{"username": "alice@example.com", "password": "wrong"}))
242+
if rec.Code != http.StatusUnauthorized {
243+
t.Fatalf("attempt %d status = %d, want 401 (%s)", i+1, rec.Code, rec.Body.String())
244+
}
245+
}
246+
247+
// 6th attempt must be rate-limited.
248+
rec := httptest.NewRecorder()
249+
h(rec, authJSONReq(t, http.MethodPost, "/api/auth/login",
250+
map[string]string{"username": "alice@example.com", "password": "wrong"}))
251+
if rec.Code != http.StatusTooManyRequests {
252+
t.Fatalf("6th attempt status = %d, want 429", rec.Code)
253+
}
254+
if ra := rec.Result().Header.Get("Retry-After"); ra == "" {
255+
t.Fatal("Retry-After header missing on 429")
256+
}
257+
if !strings.Contains(rec.Body.String(), "rate_limited") {
258+
t.Fatalf("body = %q, want rate_limited code", rec.Body.String())
259+
}
260+
}
261+
219262
// ---------- logout ---------------------------------------------------------
220263

221264
func TestLogout_RevokesToken(t *testing.T) {

internal/serve/api/feed_history.go

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,13 +166,38 @@ func FeedHistory(logDir string, resolver UUIDNameResolver) http.HandlerFunc {
166166
}
167167
}
168168

169-
// resolveNameToUUID scans logDir for *.jsonl files and asks the
170-
// UUID→name resolver for each until one matches `name`. Returns the
171-
// matching UUID or ("", false).
169+
// nameToUUIDResolver is the optional direct name→uuid lookup. When a
170+
// UUIDNameResolver also implements this, resolveNameToUUID consults it
171+
// first so the authoritative sessions.json mapping wins over the log-
172+
// directory scan. Without this, sessions that had an older claude
173+
// session_id (a dead log file still sitting in logDir) would race with
174+
// the live one and could shadow it when filenames sort before the live
175+
// UUID. See resolveNameToUUID below.
176+
type nameToUUIDResolver interface {
177+
ResolveName(name string) (uuid string, ok bool)
178+
}
179+
180+
// resolveNameToUUID returns the log UUID for a human session name.
181+
//
182+
// Order of resolution:
183+
// 1. If resolver implements nameToUUIDResolver (production: the
184+
// projection-backed logsUUIDResolver), use that directly. This is
185+
// the authoritative path and handles the multi-historical-log-
186+
// file case where a session has cycled through several claude
187+
// session_ids.
188+
// 2. Fallback: scan logDir for *.jsonl files and reverse-map each
189+
// via ResolveUUID. Preserves behaviour for orphan UUIDs whose
190+
// session isn't in the projection (tests, migration, manual
191+
// overrides).
172192
func resolveNameToUUID(resolver UUIDNameResolver, logDir, name string) (string, bool) {
173193
if resolver == nil {
174194
return "", false
175195
}
196+
if nr, ok := resolver.(nameToUUIDResolver); ok {
197+
if uuid, ok := nr.ResolveName(name); ok {
198+
return uuid, true
199+
}
200+
}
176201
entries, err := os.ReadDir(logDir)
177202
if err != nil {
178203
return "", false

internal/serve/api/feed_history_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,69 @@ func (h historyResolver) ResolveUUID(u string) (string, bool) {
7171
return "", false
7272
}
7373

74+
// projectionResolver implements both ResolveUUID (workdir-fallback
75+
// semantics: every uuid reverse-maps to `name`) and ResolveName (the
76+
// authoritative direct lookup). Used by TestResolveNameToUUID_Prefers
77+
// ProjectionOverLexicalScan to reproduce the codeiq-style bug where
78+
// a lexically-earlier dead log file shadowed the live one.
79+
type projectionResolver struct {
80+
liveUUID string
81+
name string
82+
}
83+
84+
func (p projectionResolver) ResolveUUID(u string) (string, bool) { return p.name, true }
85+
func (p projectionResolver) ResolveName(n string) (string, bool) {
86+
if n == p.name {
87+
return p.liveUUID, true
88+
}
89+
return "", false
90+
}
91+
92+
func TestResolveNameToUUID_PrefersProjectionOverLexicalScan(t *testing.T) {
93+
// Two log files under logDir. deadUUID sorts lexically before
94+
// liveUUID; both reverse-map to "codeiq" via the workdir fallback
95+
// (projectionResolver.ResolveUUID returns "codeiq" for any input).
96+
// Without the direct-name lookup, resolveNameToUUID would return
97+
// deadUUID and callers (Subagents, Teams, FeedHistory) would open
98+
// the wrong file.
99+
const (
100+
deadUUID = "11111111-0000-0000-0000-000000000000"
101+
liveUUID = "99999999-0000-0000-0000-000000000000"
102+
)
103+
dir := t.TempDir()
104+
for _, u := range []string{deadUUID, liveUUID} {
105+
if err := os.WriteFile(filepath.Join(dir, u+".jsonl"), []byte{}, 0o600); err != nil {
106+
t.Fatalf("create %s: %v", u, err)
107+
}
108+
}
109+
110+
got, ok := resolveNameToUUID(projectionResolver{liveUUID: liveUUID, name: "codeiq"}, dir, "codeiq")
111+
if !ok {
112+
t.Fatalf("resolveNameToUUID: ok=false, want true")
113+
}
114+
if got != liveUUID {
115+
t.Errorf("resolveNameToUUID = %q, want %q (projection/live uuid, not the lexically-earlier dead file)", got, liveUUID)
116+
}
117+
}
118+
119+
func TestResolveNameToUUID_FallsBackToScanWhenNoDirectLookup(t *testing.T) {
120+
// historyResolver only implements ResolveUUID — no direct name
121+
// lookup — so the scan path must still work for orphan UUIDs /
122+
// legacy callers.
123+
const uuid = "aaaaaaaa-0000-0000-0000-000000000001"
124+
dir := t.TempDir()
125+
if err := os.WriteFile(filepath.Join(dir, uuid+".jsonl"), []byte{}, 0o600); err != nil {
126+
t.Fatalf("create: %v", err)
127+
}
128+
got, ok := resolveNameToUUID(historyResolver{uuid: uuid, name: "alpha"}, dir, "alpha")
129+
if !ok {
130+
t.Fatalf("resolveNameToUUID: ok=false, want true")
131+
}
132+
if got != uuid {
133+
t.Errorf("resolveNameToUUID = %q, want %q", got, uuid)
134+
}
135+
}
136+
74137
func TestFeedHistory_BeforeInMiddleReturnsOlder(t *testing.T) {
75138
dir := t.TempDir()
76139
const uuid = "aaaaaaaa-0000-0000-0000-000000000001"

internal/serve/api/pane.go

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,35 @@ import (
99
"encoding/json"
1010
"io"
1111
"net/http"
12+
"strconv"
1213
"time"
1314
)
1415

1516
// paneTick is the capture cadence. 1 Hz matches the design brief —
1617
// a shell prompt feels live without hammering tmux / the browser.
1718
const paneTick = 1 * time.Second
1819

20+
// Default and upper bound for the scrollback window captured above
21+
// the visible pane area. Detached tmux sessions often collapse to a
22+
// small geometry (e.g. 55×28); without scrollback the viewer shows
23+
// only the last ~28 rows no matter how much output has been
24+
// produced. 500 lines is a generous debugging window; 10 000 caps a
25+
// pathological `?history=` query from shipping megabytes per tick.
26+
const (
27+
defaultPaneScrollback = 500
28+
maxPaneScrollback = 10_000
29+
)
30+
1931
// TmuxPaneCapturer is the narrow slice of *tmux.Client this handler
2032
// needs. A package-local interface keeps the api package decoupled
2133
// from internal/tmux (which would otherwise pull os/exec into every
2234
// api test binary) and makes the handler trivially faked.
2335
type TmuxPaneCapturer interface {
24-
// CapturePane returns the raw output of
25-
// tmux capture-pane -e -p -t <name>
26-
// -e preserves SGR escape sequences (colour); -p prints to stdout.
27-
CapturePane(name string) (string, error)
36+
// CapturePaneHistory returns the raw output of
37+
// tmux capture-pane -e -p -J -t <name> -S -<scrollback>
38+
// scrollback lines above the visible pane, with -e preserving
39+
// SGR, -p writing to stdout, and -J joining wrapped lines.
40+
CapturePaneHistory(name string, scrollback int) (string, error)
2841
}
2942

3043
// PaneStream returns a GET /events/session/{name}/pane handler that
@@ -40,8 +53,10 @@ type TmuxPaneCapturer interface {
4053
// - Emits a single initial frame on connect so the UI has something
4154
// to render immediately (no 1s blank state).
4255
// - Exits cleanly when the client disconnects (r.Context().Done())
43-
// or when the pane disappears (CapturePane returns an error twice
44-
// in a row — we tolerate one transient miss).
56+
// or when the pane disappears (CapturePaneHistory returns an
57+
// error twice in a row — we tolerate one transient miss).
58+
// - `?history=<N>` query param overrides the default scrollback
59+
// window. Clamped to [0, maxPaneScrollback]. 0 = visible-only.
4560
func PaneStream(tmux TmuxPaneCapturer) http.HandlerFunc {
4661
return func(w http.ResponseWriter, r *http.Request) {
4762
name := r.PathValue("name")
@@ -50,6 +65,16 @@ func PaneStream(tmux TmuxPaneCapturer) http.HandlerFunc {
5065
return
5166
}
5267

68+
scrollback := defaultPaneScrollback
69+
if raw := r.URL.Query().Get("history"); raw != "" {
70+
if n, err := strconv.Atoi(raw); err == nil && n >= 0 {
71+
if n > maxPaneScrollback {
72+
n = maxPaneScrollback
73+
}
74+
scrollback = n
75+
}
76+
}
77+
5378
flusher, ok := w.(http.Flusher)
5479
if !ok {
5580
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
@@ -103,7 +128,7 @@ func PaneStream(tmux TmuxPaneCapturer) http.HandlerFunc {
103128

104129
// Initial capture + emission — so the UI has a first frame
105130
// without waiting 1s.
106-
if out, err := tmux.CapturePane(name); err == nil {
131+
if out, err := tmux.CapturePaneHistory(name, scrollback); err == nil {
107132
if !emit(out) {
108133
return
109134
}
@@ -116,7 +141,7 @@ func PaneStream(tmux TmuxPaneCapturer) http.HandlerFunc {
116141
case <-ctx.Done():
117142
return
118143
case <-ticker.C:
119-
out, err := tmux.CapturePane(name)
144+
out, err := tmux.CapturePaneHistory(name, scrollback)
120145
if err != nil {
121146
consecutiveErrs++
122147
if consecutiveErrs >= 2 {

0 commit comments

Comments
 (0)