diff --git a/.tlc/adoption.json b/.tlc/adoption.json deleted file mode 100644 index 5e8c183..0000000 --- a/.tlc/adoption.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "adapter": { - "id": "none", - "pin": null - }, - "repository": "transpara-ai/work", - "requested_mode": "report_only", - "schema_version": "1", - "tlc": { - "binding_commit": "d025e0f1b7795c54c98af88103c3f3446acf9436", - "policy_blob": "d9d4345e683bc6a71216e1054a08bf2a411f79cd", - "release_manifest_sha256": "256c7ff038b5b95edcfb03425047646533ccb3311e3c01dcbdc03a0aca59139a", - "tag": "v5.0.0", - "version": "5.0.0" - } -} \ No newline at end of file diff --git a/cmd/work-server/events.go b/cmd/work-server/events.go index 7d0c014..033bb11 100644 --- a/cmd/work-server/events.go +++ b/cmd/work-server/events.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "fmt" - "log" "net/http" "strings" "sync" @@ -123,25 +122,14 @@ func eventMatchesFilters(evType string, filters []string) bool { return false } -// --- SSE auth with query-string fallback --- +// --- SSE auth --- -// authSSE is the auth middleware for SSE endpoints. It accepts three sources, -// in order: Authorization: Bearer header, ws_key cookie, and `?key=` query -// parameter. The query-string arm exists because browser `EventSource` cannot -// set custom headers; callers that use it get a warning log (once per -// connection) recommending cookie auth. +// authSSE keeps credentials server-side by accepting only Authorization: +// Bearer. Browser EventSource clients connect to Site, whose proxy attaches the +// Work credential to its upstream request. func (sv *server) authSSE(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if token, found := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); found && token == sv.apiKey { - next(w, r) - return - } - if c, err := r.Cookie("ws_key"); err == nil && c.Value == sv.apiKey { - next(w, r) - return - } - if k := r.URL.Query().Get("key"); k != "" && k == sv.apiKey { - log.Printf("sse auth via query string from %s — key will appear in access logs, prefer cookie auth", r.RemoteAddr) + if token, found := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer "); found && secureTokenEqual(token, sv.apiKey) { next(w, r) return } @@ -152,8 +140,7 @@ func (sv *server) authSSE(next http.HandlerFunc) http.HandlerFunc { // --- /events/subscribe handler --- // eventsSubscribe handles GET /events/subscribe[?types=,] — -// a raw, filtered SSE feed with no debounce. Accepts the same auth sources as -// /telemetry/sse. +// a raw, filtered SSE feed with no debounce. func (sv *server) eventsSubscribe(w http.ResponseWriter, r *http.Request) { flusher, ok := w.(http.Flusher) if !ok { diff --git a/cmd/work-server/events_test.go b/cmd/work-server/events_test.go index da914f4..0c9b62a 100644 --- a/cmd/work-server/events_test.go +++ b/cmd/work-server/events_test.go @@ -41,6 +41,7 @@ func sseClient(t *testing.T, ts *httptest.Server, path string) (<-chan string, c if err != nil { t.Fatalf("build request: %v", err) } + req.Header.Set("Authorization", "Bearer test-key") resp, err := ts.Client().Do(req) if err != nil { cancel() @@ -136,7 +137,7 @@ func TestSSEKeepaliveComment(t *testing.T) { // Verifying we get a 200 + correct Content-Type is enough: the keepalive // ticker fires at 30s, longer than any reasonable unit-test budget. ts, sv := newTestServer(t) - frames, cancel, resp := sseClient(t, ts, "/telemetry/sse?key=test-key") + frames, cancel, resp := sseClient(t, ts, "/telemetry/sse") defer cancel() if resp.StatusCode != 200 { t.Fatalf("status: want 200, got %d", resp.StatusCode) @@ -150,7 +151,7 @@ func TestSSEKeepaliveComment(t *testing.T) { func TestSSEEventDelivery(t *testing.T) { ts, sv := newTestServer(t) - frames, cancel, _ := sseClient(t, ts, "/telemetry/sse?key=test-key") + frames, cancel, _ := sseClient(t, ts, "/telemetry/sse") defer cancel() withSubscribers(t, sv, 1) @@ -169,7 +170,7 @@ func TestSSEEventDelivery(t *testing.T) { func TestSSEDebounce(t *testing.T) { ts, sv := newTestServer(t) - frames, cancel, _ := sseClient(t, ts, "/telemetry/sse?key=test-key") + frames, cancel, _ := sseClient(t, ts, "/telemetry/sse") defer cancel() withSubscribers(t, sv, 1) @@ -190,7 +191,7 @@ func TestSSEDebounce(t *testing.T) { func TestSSEDisconnectCleanup(t *testing.T) { ts, sv := newTestServer(t) - frames, cancel, _ := sseClient(t, ts, "/telemetry/sse?key=test-key") + frames, cancel, _ := sseClient(t, ts, "/telemetry/sse") withSubscribers(t, sv, 1) sv.fanout.Publish(mkEvent("hive.gap.detected", "cto", "hello")) @@ -210,38 +211,17 @@ func TestSSEDisconnectCleanup(t *testing.T) { t.Fatalf("fanout still has %d subscribers after disconnect", sv.fanout.NumSubscribers()) } -func TestSSEAuthQueryString(t *testing.T) { +func TestSSERejectsURLCredentials(t *testing.T) { ts, _ := newTestServer(t) - // Valid key via ?key= — expect 200. - ok, err := ts.Client().Get(ts.URL + "/telemetry/sse?key=test-key") - if err != nil { - t.Fatalf("request: %v", err) - } - if ok.StatusCode != 200 { - t.Fatalf("valid key: want 200, got %d", ok.StatusCode) - } - ok.Body.Close() - - // Invalid key — expect 401. - bad, err := ts.Client().Get(ts.URL + "/telemetry/sse?key=wrong") + resp, err := ts.Client().Get(ts.URL + "/telemetry/sse?key=test-key") if err != nil { t.Fatalf("request: %v", err) } - if bad.StatusCode != 401 { - t.Fatalf("invalid key: want 401, got %d", bad.StatusCode) - } - bad.Body.Close() - - // No auth at all — expect 401. - none, err := ts.Client().Get(ts.URL + "/telemetry/sse") - if err != nil { - t.Fatalf("request: %v", err) - } - if none.StatusCode != 401 { - t.Fatalf("no auth: want 401, got %d", none.StatusCode) + defer resp.Body.Close() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("query credential: want %d, got %d", http.StatusUnauthorized, resp.StatusCode) } - none.Body.Close() } func TestSSEAuthBearer(t *testing.T) { @@ -262,7 +242,7 @@ func TestSSEAuthBearer(t *testing.T) { func TestEventsSubscribeNoFilter(t *testing.T) { ts, sv := newTestServer(t) - frames, cancel, _ := sseClient(t, ts, "/events/subscribe?key=test-key") + frames, cancel, _ := sseClient(t, ts, "/events/subscribe") defer cancel() withSubscribers(t, sv, 1) @@ -284,7 +264,7 @@ func TestEventsSubscribeNoFilter(t *testing.T) { func TestEventsSubscribeMultiPrefix(t *testing.T) { ts, sv := newTestServer(t) - frames, cancel, _ := sseClient(t, ts, "/events/subscribe?key=test-key&types=hive.*,site.op.*") + frames, cancel, _ := sseClient(t, ts, "/events/subscribe?types=hive.*,site.op.*") defer cancel() withSubscribers(t, sv, 1) @@ -308,7 +288,7 @@ func TestEventsSubscribeMultiPrefix(t *testing.T) { func TestEventsSubscribeEmptyTypes(t *testing.T) { ts, sv := newTestServer(t) // Empty ?types= value should behave exactly like no filter. - frames, cancel, _ := sseClient(t, ts, "/events/subscribe?key=test-key&types=") + frames, cancel, _ := sseClient(t, ts, "/events/subscribe?types=") defer cancel() withSubscribers(t, sv, 1) diff --git a/cmd/work-server/legacy_ui_test.go b/cmd/work-server/legacy_ui_test.go index 41bf8c5..a57b1d9 100644 --- a/cmd/work-server/legacy_ui_test.go +++ b/cmd/work-server/legacy_ui_test.go @@ -7,7 +7,7 @@ import ( "testing" ) -func TestLegacyBrowserUIHeadersAndNotice(t *testing.T) { +func TestLegacyBrowserUICannotBeReenabledOrExposeCredentials(t *testing.T) { t.Setenv("WORK_LEGACY_BROWSER_UI", "1") sv := &server{apiKey: "test-key", apiToken: "test-token"} cases := []struct { @@ -40,18 +40,17 @@ func TestLegacyBrowserUIHeadersAndNotice(t *testing.T) { rec := httptest.NewRecorder() tc.handler(rec, tc.req) resp := rec.Result() - if got := resp.Header.Get(legacyUIStatusHeader); got != "legacy" { - t.Fatalf("%s = %q, want legacy", legacyUIStatusHeader, got) + if resp.StatusCode != http.StatusFound { + t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusFound) + } + if got := resp.Header.Get(legacyUIStatusHeader); got != "disabled" { + t.Fatalf("%s = %q, want disabled", legacyUIStatusHeader, got) } if got := resp.Header.Get(legacyUIReplacementHeader); got != tc.wantReplacement { t.Fatalf("%s = %q, want %q", legacyUIReplacementHeader, got, tc.wantReplacement) } - body := rec.Body.String() - if !strings.Contains(body, "Legacy Work browser UI") { - t.Fatalf("body missing legacy notice") - } - if !strings.Contains(body, tc.wantReplacement) { - t.Fatalf("body missing replacement URL %q", tc.wantReplacement) + if strings.Contains(rec.Body.String(), "test-key") || strings.Contains(rec.Body.String(), "test-token") || len(resp.Cookies()) != 0 { + t.Fatal("retired UI exposed a credential") } }) } @@ -119,19 +118,17 @@ func TestAPIRoutesDoNotCarryLegacyUIHeaders(t *testing.T) { } } -func TestLegacyBrowserUIEnabled(t *testing.T) { - for _, value := range []string{"1", "true", "TRUE", "yes", "on"} { - t.Run(value, func(t *testing.T) { - t.Setenv("WORK_LEGACY_BROWSER_UI", value) - if !legacyBrowserUIEnabled() { - t.Fatal("legacyBrowserUIEnabled() = false, want true") - } - }) - } - - t.Setenv("WORK_LEGACY_BROWSER_UI", "") - if legacyBrowserUIEnabled() { - t.Fatal("legacyBrowserUIEnabled() = true, want false") +func TestAPIRejectsRetiredCookieCredential(t *testing.T) { + sv := &server{apiKey: "test-key"} + handler := sv.auth(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }) + req := httptest.NewRequest(http.MethodGet, "http://nucbuntu:8080/tasks", nil) + req.AddCookie(&http.Cookie{Name: "ws_key", Value: "test-key"}) + rec := httptest.NewRecorder() + handler(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("cookie credential status = %d, want %d", rec.Code, http.StatusUnauthorized) } } diff --git a/cmd/work-server/main.go b/cmd/work-server/main.go index d7bcc80..c076a84 100644 --- a/cmd/work-server/main.go +++ b/cmd/work-server/main.go @@ -7,10 +7,10 @@ // WORK_API_KEY — API key for auth; callers pass Authorization: Bearer (required) // WORK_API_TOKEN — bearer token for workspace-scoped external API; falls back to WORK_API_KEY if unset // DATABASE_URL — Postgres DSN (optional; defaults to in-memory) +// WORK_SIGNING_KEY_FILE — owner-only base64 Ed25519 seed/private key (required with Postgres) // PORT — HTTP port to listen on (optional; defaults to 8080) // WORK_BIND_HOST — optional listen host; set 127.0.0.1 for loopback-only operation // SITE_UI_BASE_URL — canonical Site UI base URL for legacy UI notices (optional; derived from request host) -// TELEMETRY_DASHBOARD_PATH — path to dashboard.html on disk (optional; overrides the embedded copy for local dev) // // Endpoints: // @@ -48,10 +48,10 @@ import ( "context" "crypto/ed25519" "crypto/sha256" + "crypto/subtle" "encoding/json" "errors" "fmt" - "html" "net" "net/http" "net/netip" @@ -73,7 +73,7 @@ import ( "github.com/transpara-ai/eventgraph/go/pkg/types" "github.com/transpara-ai/work" - "github.com/transpara-ai/work/dashboard" + "github.com/transpara-ai/work/runtimeidentity" ) const ( @@ -81,523 +81,6 @@ const ( legacyUIReplacementHeader = "X-Transpara-Replacement-UI" ) -// dashboardHTML is the read-only monitoring dashboard served at GET /. -// The placeholder {{API_KEY}} is replaced at serve time with the actual key so -// the browser's fetch() calls can authenticate against GET /tasks. -const dashboardHTML = ` - - - - -Work Graph — Live Dashboard - - - -

Work Graph

-

Live pipeline dashboard

-
- - Connecting... - -
- - - - - - - - - - - - - - - - - -` - -// workspaceDashboardHTML is the interactive task dashboard served at GET /w/{workspace}. -// Placeholders {{WORKSPACE}} and {{API_TOKEN}} are replaced at serve time. -const workspaceDashboardHTML = ` - - - - -{{WORKSPACE}} — Work Graph - - - -

{{WORKSPACE}}

-

Workspace task board

-
- - Connecting... - -
- -
- -
- - - - - - - - - - - - - - - - - - - - - - - - -` - func main() { if err := run(); err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) @@ -629,7 +112,7 @@ func run() error { // Open shared pool for Postgres, or nil for in-memory. var pool *pgxpool.Pool if dsn != "" { - fmt.Fprintf(os.Stderr, "Postgres: %s\n", dsn) + fmt.Fprintln(os.Stderr, "Postgres: configured") poolCfg, err := pgxpool.ParseConfig(dsn) if err != nil { return fmt.Errorf("postgres config: %w", err) @@ -683,15 +166,11 @@ func run() error { return fmt.Errorf("actor store: %w", err) } - // Bootstrap human actor — same key-derivation pattern as cmd/hive. - if pool != nil { - fmt.Fprintln(os.Stderr, "WARNING: CLI key derivation is insecure for persistent Postgres stores.") - fmt.Fprintln(os.Stderr, " Production should use Google auth. Proceeding for development.") - } - humanID, err := registerHuman(actors, humanName) + identity, err := runtimeidentity.Resolve(actors, humanName, os.Getenv("WORK_SIGNING_KEY_FILE"), pool != nil) if err != nil { - return fmt.Errorf("register human: %w", err) + return fmt.Errorf("load runtime identity: %w", err) } + humanID := identity.ActorID // Register work event type unmarshalers before any store reads — // Head() deserializes the latest event which may be a work type. @@ -701,7 +180,7 @@ func run() error { work.RegisterEventTypes() // Bootstrap the event graph if it has no genesis event. - if err := bootstrapGraph(s, humanID); err != nil { + if err := bootstrapGraphWithSigner(s, humanID, identity.Signer); err != nil { return fmt.Errorf("bootstrap graph: %w", err) } @@ -709,7 +188,7 @@ func run() error { registry := event.DefaultRegistry() work.RegisterWithRegistry(registry) factory := event.NewEventFactory(registry) - signer := deriveSignerFromID(humanID) + signer := identity.Signer ts := work.NewTaskStore(s, factory, signer) phaseGates := work.NewPhaseGateStore(s, factory, signer) @@ -762,9 +241,9 @@ func run() error { mux.HandleFunc("GET /telemetry/pipeline/report", srv.auth(srv.telemetryPipelineReport)) mux.HandleFunc("POST /telemetry/phases/{phase}", srv.auth(srv.updatePhase)) mux.HandleFunc("GET /telemetry/health", srv.auth(srv.telemetryHealth)) - // SSE endpoints accept Authorization header, ws_key cookie, OR ?key= query - // param — the last is an EventSource fallback for browsers that cannot set - // custom headers. See authSSE in events.go. + // SSE endpoints are consumed through Site's server-side proxy. Keeping + // authentication header-only prevents credentials from entering URLs, + // browser cookies, access logs, and referrer metadata. mux.HandleFunc("GET /telemetry/sse", srv.authSSE(srv.telemetrySSE)) mux.HandleFunc("GET /events/subscribe", srv.authSSE(srv.eventsSubscribe)) mux.HandleFunc("GET /telemetry/roles", srv.auth(srv.telemetryRoles)) @@ -791,7 +270,7 @@ func run() error { return fmt.Errorf("listen address: %w", err) } fmt.Fprintf(os.Stderr, "work-server listening on %s\n", addr) - httpSrv := &http.Server{Addr: addr, Handler: corsMiddleware(mux)} + httpSrv := &http.Server{Addr: addr, Handler: mux} go func() { <-ctx.Done() httpSrv.Shutdown(context.Background()) //nolint:errcheck @@ -837,57 +316,20 @@ type server struct { fanout *eventFanout } -// telemetryDashboard handles GET /telemetry/ — serves the embedded dashboard HTML. -// TELEMETRY_DASHBOARD_PATH overrides the embedded copy for local dev (read per request). +// telemetryDashboard permanently redirects the retired browser surface to Site. func (sv *server) telemetryDashboard(w http.ResponseWriter, r *http.Request) { replacement := siteUIURL(r, "/ops/telemetry") - if !legacyBrowserUIEnabled() { - redirectLegacyBrowserUI(w, r, replacement) - return - } - markLegacyBrowserUI(w, replacement) - // Set a session cookie so the dashboard can poll without an Authorization - // header, avoiding Chrome Private-Network-Access preflight blocks. - http.SetCookie(w, &http.Cookie{ - Name: "ws_key", - Value: sv.apiKey, - Path: "/", - HttpOnly: true, - SameSite: http.SameSiteStrictMode, - }) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") - - if path := os.Getenv("TELEMETRY_DASHBOARD_PATH"); path != "" { - html, err := os.ReadFile(path) - if err != nil { - fmt.Fprintf(os.Stderr, "warning: TELEMETRY_DASHBOARD_PATH=%s: %v\n", path, err) - } else { - w.Write([]byte(injectLegacyUINotice(string(html), replacement))) - return - } - } - w.Write([]byte(injectLegacyUINotice(string(dashboard.HTML), replacement))) + redirectLegacyBrowserUI(w, r, replacement) } -// dashboard handles GET / — serves the legacy read-only HTML monitoring dashboard. -// No auth required; the API key is injected into the page so the browser's -// fetch() calls can authenticate against GET /tasks. +// dashboard permanently redirects the retired browser surface to Site. Work +// credentials are never placed in HTML or cookies. func (sv *server) dashboard(w http.ResponseWriter, r *http.Request) { replacement := siteUIURL(r, "/ops/work") - if !legacyBrowserUIEnabled() { - redirectLegacyBrowserUI(w, r, replacement) - return - } - markLegacyBrowserUI(w, replacement) - html := strings.ReplaceAll(dashboardHTML, "{{API_KEY}}", jsEscapeKey(sv.apiKey)) - html = injectLegacyUINotice(html, replacement) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, html) + redirectLegacyBrowserUI(w, r, replacement) } -// workspaceDashboard handles GET /w/{workspace} — serves the legacy interactive workspace task dashboard. -// No auth required on the GET; the API token is injected into the page for browser fetch() calls. +// workspaceDashboard permanently redirects the retired workspace UI to Site. func (sv *server) workspaceDashboard(w http.ResponseWriter, r *http.Request) { workspace := r.PathValue("workspace") if workspace == "" { @@ -895,21 +337,7 @@ func (sv *server) workspaceDashboard(w http.ResponseWriter, r *http.Request) { return } replacement := siteUIURL(r, "/ops/work?workspace="+url.QueryEscape(workspace)) - if !legacyBrowserUIEnabled() { - redirectLegacyBrowserUI(w, r, replacement) - return - } - markLegacyBrowserUI(w, replacement) - html := strings.ReplaceAll(workspaceDashboardHTML, "{{WORKSPACE}}", jsEscapeKey(workspace)) - html = strings.ReplaceAll(html, "{{API_TOKEN}}", jsEscapeKey(sv.apiToken)) - html = injectLegacyUINotice(html, replacement) - w.Header().Set("Content-Type", "text/html; charset=utf-8") - fmt.Fprint(w, html) -} - -func markLegacyBrowserUI(w http.ResponseWriter, replacement string) { - w.Header().Set(legacyUIStatusHeader, "legacy") - w.Header().Set(legacyUIReplacementHeader, replacement) + redirectLegacyBrowserUI(w, r, replacement) } func redirectLegacyBrowserUI(w http.ResponseWriter, r *http.Request, replacement string) { @@ -918,28 +346,6 @@ func redirectLegacyBrowserUI(w http.ResponseWriter, r *http.Request, replacement http.Redirect(w, r, replacement, http.StatusFound) } -func legacyBrowserUIEnabled() bool { - switch strings.ToLower(strings.TrimSpace(os.Getenv("WORK_LEGACY_BROWSER_UI"))) { - case "1", "true", "yes", "on": - return true - default: - return false - } -} - -func injectLegacyUINotice(page, replacement string) string { - notice := legacyUINotice(replacement) - if strings.Contains(page, "") { - return strings.Replace(page, "", ""+notice, 1) - } - return notice + page -} - -func legacyUINotice(replacement string) string { - escaped := html.EscapeString(replacement) - return `
Legacy Work browser UI. Canonical operator UI lives in Site: ` + escaped + `.
` -} - func siteUIURL(r *http.Request, path string) string { if base := strings.TrimSpace(os.Getenv("SITE_UI_BASE_URL")); base != "" { u, err := url.Parse(strings.TrimRight(base, "/")) @@ -976,47 +382,11 @@ func (sv *server) health(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } -// jsEscapeKey returns s with characters that are dangerous inside a