-
Notifications
You must be signed in to change notification settings - Fork 1
Pipeline Design 15
The pipeline artifacts directory is protected. Let me output the ADR directly.
Shipwright's Fleet Command dashboard (dashboard/server.ts, ~3500 lines) already implements a functional GitHub OAuth flow including OAuth config (lines 33-41), session management via in-memory Map<string, Session> + file-backed persistence (lines 173-254), auth mode detection for OAuth/PAT/None (lines 356-367), a styled login page (lines 387-510), route-level auth gating (lines 2188-2202), and WebSocket auth rejection (line 2194). The frontend already has user-menu HTML in dashboard/public/index.html (lines 83-91), CSS in styles.css (lines 165-234), and JS functions fetchUser() + setupUserMenu() in app.js (lines 567-615).
The implementation is structurally complete but has six gaps that prevent production readiness:
-
No CSRF protection —
/auth/github(line 1922) omits the OAuthstateparameter entirely, violating RFC 6749 Section 10.12. -
No OAuth error handling —
handleAuthCallback()(line 1939) only checks forcode, returning a bare 400 when GitHub sendserror=access_denied(user denies consent). -
No developer auto-registration — Neither
handleAuthCallback()norhandlePatLogin()writes todeveloperRegistry, so dashboard users are invisible in the team presence system. -
No
Securecookie flag —sessionCookie()(line 214) setsHttpOnly; SameSite=Laxbut neverSecure, allowing cookie leakage over HTTP when the dashboard runs behind TLS termination. -
Frontend auth never initializes —
fetchUser()andsetupUserMenu()exist inapp.jsbut are never called from the startup path. The/api/meendpoint (line 2468) already works correctly. -
Heartbeat identity gap —
/api/connect/heartbeat(line 3764) authenticates via invite tokens for CLI clients but ignoresfleet_sessioncookies, preventing browser-based identity enrichment.
Constraints: Bun runtime, vanilla JS frontend, no external dependencies, in-memory session Map with atomic file persistence, three auth modes coexist (OAuth / PAT / None), Bash 3.2 compatibility for test scripts.
Harden the existing OAuth implementation in-place. No new dependencies, frameworks, or session stores. Six targeted modifications:
Add const oauthStates = new Map<string, number>() near the existing session store. In handleAuthGitHub(): generate crypto.randomUUID() state, store with 10-min TTL (Date.now() + 600_000), include as &state= in the GitHub authorize URL. In handleAuthCallback(): validate state exists and hasn't expired, delete after use. Reject mismatches with redirect to /login?error=invalid_state. Purge expired states in the existing setInterval health-check loop.
States are not file-persisted — they're ephemeral (10-min TTL). Server restart simply requires re-initiating the OAuth flow.
In handleAuthCallback(), check url.searchParams.get("error") before checking for code. If present, redirect to /login?error=<encoded error_description>. The login page already accepts an error parameter (line 388 — loginPageHTML(error?: string)).
After createSession() in both handleAuthCallback() and handlePatLogin(), call:
developerRegistry.set(`${username}@dashboard`, {
developer_id: username,
machine_name: "dashboard",
hostname: "dashboard",
platform: "web",
last_heartbeat: Date.now(),
daemon_running: false, daemon_pid: null,
active_jobs: [], queued: [], events_since: 0,
});
saveDeveloperRegistry();Key format <user>@dashboard avoids collision with CLI entries (<user>@<machine-name>). Re-login is idempotent (overwrites same key).
Add const SECURE_COOKIES = process.env.DASHBOARD_SECURE === "true" near auth config. Modify sessionCookie() and clearSessionCookie() to append ; Secure when true. Explicit opt-in via environment variable — no auto-detection heuristics.
Add fetchUser(); setupUserMenu(); to the existing initialization block in app.js (near connectWebSocket() call). No new endpoints or HTML/CSS needed — everything already exists.
In the /api/connect/heartbeat handler (line 3764), after invite-token validation, check for fleet_session cookie via getSessionFromCookie(). If present and valid, use session.githubUser as fallback developer_id when not specified in the request body. CLI heartbeats (no cookies) continue unchanged.
GET /auth/github
→ generate state, store in oauthStates
→ 302 to github.com/login/oauth/authorize?client_id=...&state=<uuid>&scope=read:org+repo
GET /auth/callback?code=<code>&state=<uuid>
→ validate state (delete from Map)
→ POST github.com/login/oauth/access_token (exchange code)
→ GET api.github.com/user (get login + avatar)
→ GET api.github.com/repos/:owner/:repo/collaborators/:user/permission
→ if admin/write: createSession(), register developer, Set-Cookie, 302 to /
→ else: 403 accessDeniedHTML
GET / (authenticated)
→ auth gate passes, serve index.html
→ app.js: fetch /api/me → populate #user-menu
→ app.js: WebSocket /ws → authenticated push
GET /auth/callback?error=access_denied&error_description=The+user+has+denied...
→ 302 to /login?error=The+user+has+denied...
GET /auth/callback?code=<code>&state=<expired-or-missing>
→ 302 to /login?error=invalid_state
GET /auth/callback?code=<code>&state=<valid> but GitHub rejects code
→ 302 to /login?error=token_exchange_failed
-
JWT-based stateless sessions — Pros: No server-side store, horizontally scalable. / Cons: Can't revoke sessions without a blocklist (defeating the purpose), adds a JWT dependency, the existing Map+file store works well for a single-server dev-team dashboard.
-
External session store (Redis/SQLite) — Pros: Survives crashes, enables multi-instance. / Cons: Adds operational dependency to a currently zero-dependency Bun server, overkill for <10 concurrent users.
-
Middleware framework rewrite (Hono/Express) — Pros: Structured middleware pipeline, community auth plugins. / Cons: Major refactor of 3500-line server, new dependency chain, existing flat handler is well-organized and auth gating already works.
-
Double-submit cookie CSRF — Pros: No server-side state needed. / Cons: More complex implementation, OAuth
stateparameter is the standard approach per RFC 6749 Section 10.12 and GitHub's own recommendation.
-
Files to create:
-
scripts/sw-dashboard-test.sh— Bash test suite (project harness conventions: mock binaries, PASS/FAIL counters, ERR trap). Tests: CSRF state lifecycle, session creation/expiry, developer auto-registration, OAuth error redirect, public vs protected route classification.
-
-
Files to modify:
-
dashboard/server.ts— CSRF state Map + generation/validation inhandleAuthGitHub()/handleAuthCallback(), OAuth error check, developer auto-registration in both login handlers,Securecookie flag, heartbeat identity enrichment -
dashboard/public/app.js— AddfetchUser()andsetupUserMenu()calls to initialization -
package.json— Registersw-dashboard-test.shin test scripts array
-
-
Dependencies: None. Zero new packages.
-
Risk areas:
- OAuth state Map memory — Mitigated by 10-min TTL + periodic cleanup. Each entry is UUID + timestamp (~80 bytes). Even 10,000 pending states = <1 MB.
-
Developer registry key collision —
<user>@dashboardis distinct from CLI keys<user>@<machine>. Multiple browser sessions for same user overwrite idempotently. -
No-auth mode regression — All changes gated behind
isAuthEnabled()./api/mealready returns{ username: "local" }in no-auth mode.fetchUser()handles this gracefully. -
Login page error rendering —
loginPageHTML(error)renders the error parameter directly into HTML. The error string comes from GitHub'serror_description— must HTML-escape it to prevent reflected XSS. The existingerrorHtmltemplate on line 391 renders raw — needs escaping.
-
/auth/github302 Location includes&state=parameter (non-empty UUID) -
/auth/callbackwith valid state + code creates session and redirects to/ -
/auth/callbackwith missing/expired state redirects to/login?error=invalid_state -
/auth/callback?error=access_deniedredirects to/loginwith human-readable message - Error messages in login page are HTML-escaped (no reflected XSS)
- Successful OAuth login registers user in
developerRegistryas<user>@dashboard - Successful PAT login registers user in
developerRegistryas<user>@dashboard -
sessionCookie()includes; SecurewhenDASHBOARD_SECURE=true -
sessionCookie()omitsSecurewhenDASHBOARD_SECUREis unset - Frontend calls
fetchUser()on page load and populates user-menu -
/api/mereturns session data for authenticated requests -
/api/mereturns{ username: "local" }when auth disabled - WebSocket upgrade without valid session returns 401
- CLI heartbeats without cookies still work via invite-token auth
- Browser heartbeats with
fleet_sessioncookie get identity-enriched -
sw-dashboard-test.shpasses (PASS > 0, FAIL = 0) - All 22 existing test suites pass (
npm test) - No new runtime dependencies