From b2c7279b04f89c2bc813b9f3acc030ec3ce505e4 Mon Sep 17 00:00:00 2001 From: botre Date: Sun, 9 Aug 2026 11:30:59 +0200 Subject: [PATCH 1/3] Give every Go test file one subject routes_test.go held the tests for six subjects at once, so the tests for a handler lived nowhere near it and the file had no source counterpart. Each suite now sits beside the code it covers and is named after what it exercises, and the shared request harness has a file of its own. Claude-Session: https://claude.ai/code/session_017NsCXwruqBye5cySvFEFiB --- src/api_test.go | 285 ++++++++++++++++++++++ src/application_test.go | 18 ++ src/capture.go | 7 +- src/capture_test.go | 105 ++++++++ src/endpoint_test.go | 22 ++ src/harness_test.go | 108 ++++++++ src/pages_test.go | 93 +++++++ src/requestlog_test.go | 36 +++ src/routes_test.go | 528 ---------------------------------------- src/security_test.go | 19 ++ 10 files changed, 692 insertions(+), 529 deletions(-) create mode 100644 src/api_test.go create mode 100644 src/harness_test.go create mode 100644 src/pages_test.go delete mode 100644 src/routes_test.go diff --git a/src/api_test.go b/src/api_test.go new file mode 100644 index 0000000..7582ff7 --- /dev/null +++ b/src/api_test.go @@ -0,0 +1,285 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/url" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "httphq/src/database" +) + +type requestListing struct { + Requests []database.Request `json:"requests"` + Total int64 `json:"total"` + Cursor string `json:"cursor"` + HasMore bool `json:"hasMore"` +} + +// listRequests reads back what an endpoint has captured, through the same API +// the page uses. An empty `since` is the browser's call, with no cursor. +func listRequests(t *testing.T, id, search, since string) requestListing { + t.Helper() + + response := get(t, "/api/endpoints/"+id+"/requests?search="+search+ + "&since="+url.QueryEscape(since)) + require.Equal(t, http.StatusOK, response.StatusCode) + + var payload requestListing + require.NoError(t, json.Unmarshal([]byte(bodyOf(t, response)), &payload)) + return payload +} + +func capturedRequests(t *testing.T, id, search string) []database.Request { + t.Helper() + return listRequests(t, id, search, "").Requests +} + +func listedTotal(t *testing.T, id, search string) int64 { + t.Helper() + return listRequests(t, id, search, "").Total +} + +func TestHandleHealth(t *testing.T) { + t.Run("answers 200 so a platform probe can reach it", func(t *testing.T) { + assert.Equal(t, http.StatusOK, get(t, "/api/health").StatusCode) + }) +} + +func TestHandleDebug(t *testing.T) { + t.Run("reports process state and no captured data", func(t *testing.T) { + response := get(t, "/api/debug") + require.Equal(t, http.StatusOK, response.StatusCode) + + var payload map[string]any + require.NoError(t, json.Unmarshal([]byte(bodyOf(t, response)), &payload)) + + assert.Contains(t, payload, "host") + assert.Contains(t, payload, "requests") + assert.Equal(t, false, payload["isProduction"]) + assert.Equal(t, float64(0), payload["sockets"]) + }) +} + +// The listing orders newest-first or oldest-first depending on whether it was +// given a cursor, so neither end of the page is reliably the newest. +func TestNewestCreatedAt(t *testing.T) { + base := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) + at := func(offset time.Duration) database.Request { + return database.Request{CreatedAt: base.Add(offset)} + } + + t.Run("finds the newest wherever it sits in the page", func(t *testing.T) { + newest := base.Add(3 * time.Minute) + + for name, page := range map[string][]database.Request{ + "first": {at(3 * time.Minute), at(time.Minute), at(2 * time.Minute)}, + "last": {at(time.Minute), at(2 * time.Minute), at(3 * time.Minute)}, + "middle": {at(time.Minute), at(3 * time.Minute), at(2 * time.Minute)}, + } { + t.Run(name, func(t *testing.T) { + assert.Equal(t, newest, newestCreatedAt(page)) + }) + } + }) + + t.Run("a single capture is its own newest", func(t *testing.T) { + assert.Equal(t, base, newestCreatedAt([]database.Request{at(0)})) + }) +} + +func TestHandleListRequests(t *testing.T) { + t.Run("lists an endpoint's captures, newest first", func(t *testing.T) { + id := endpointID(t) + post(t, "/to/"+id, "older") + post(t, "/to/"+id, "newer") + + captured := capturedRequests(t, id, "") + + require.Len(t, captured, 2) + assert.Equal(t, "newer", captured[0].Body) + assert.Equal(t, "older", captured[1].Body) + }) + + t.Run("the search parameter narrows the list", func(t *testing.T) { + id := endpointID(t) + post(t, "/to/"+id, "alpha") + post(t, "/to/"+id, "beta") + + assert.Len(t, capturedRequests(t, id, "alpha"), 1) + assert.Empty(t, capturedRequests(t, id, "no-such-term")) + }) + + // The listing is both filtered and windowed, so its length says nothing + // about the endpoint. A control that clears the whole endpoint has to + // report what it will actually delete. + t.Run("the total is the endpoint's, not the filtered view's", func(t *testing.T) { + id := endpointID(t) + post(t, "/to/"+id, "alpha") + post(t, "/to/"+id, "beta") + + assert.Equal(t, int64(2), listedTotal(t, id, "")) + assert.Equal(t, int64(2), listedTotal(t, id, "alpha")) + assert.Equal(t, int64(2), listedTotal(t, id, "no-such-term")) + }) + + t.Run("an endpoint with no traffic totals zero", func(t *testing.T) { + assert.Equal(t, int64(0), listedTotal(t, endpointID(t), "")) + }) + + t.Run("an endpoint with no traffic lists nothing", func(t *testing.T) { + assert.Empty(t, capturedRequests(t, endpointID(t), "")) + }) +} + +// The cursor is what makes the listing pollable. Its promise is that echoing it +// back returns every capture exactly once, so these cover the round trip rather +// than the field's presence. +func TestHandleListRequestsCursor(t *testing.T) { + t.Run("an endpoint with no traffic still carries a cursor", func(t *testing.T) { + listing := listRequests(t, endpointID(t), "", "") + + assert.NotEmpty(t, listing.Cursor) + assert.False(t, listing.HasMore) + }) + + // Without this the caller has nothing to advance from and would re-ask for + // the same empty window forever. + t.Run("the cursor from an empty endpoint is usable", func(t *testing.T) { + id := endpointID(t) + cursor := listRequests(t, id, "", "").Cursor + + post(t, "/to/"+id, "after") + + captured := listRequests(t, id, "", cursor).Requests + require.Len(t, captured, 1) + assert.Equal(t, "after", captured[0].Body) + }) + + t.Run("round-tripping the cursor returns only what is new", func(t *testing.T) { + id := endpointID(t) + post(t, "/to/"+id, "first") + + first := listRequests(t, id, "", "") + require.Len(t, first.Requests, 1) + + post(t, "/to/"+id, "second") + + second := listRequests(t, id, "", first.Cursor) + require.Len(t, second.Requests, 1) + assert.Equal(t, "second", second.Requests[0].Body) + }) + + t.Run("a cursor with nothing behind it returns nothing but still advances", func(t *testing.T) { + id := endpointID(t) + post(t, "/to/"+id, "only") + + first := listRequests(t, id, "", "") + second := listRequests(t, id, "", first.Cursor) + + assert.Empty(t, second.Requests) + assert.NotEmpty(t, second.Cursor) + }) + + // A cursor that moved backwards would hand the caller captures it has + // already processed, which is the one thing echoing it back promises not to + // do. An empty page reports where the query ran, so a cursor ahead of that + // has to survive the round trip unchanged. + t.Run("a cursor is never handed back older than it was sent", func(t *testing.T) { + ahead := time.Now().UTC().Add(time.Hour).Format(time.RFC3339Nano) + + listing := listRequests(t, endpointID(t), "", ahead) + + assert.Empty(t, listing.Requests) + assert.Equal(t, ahead, listing.Cursor) + }) + + // total is what a delete-all will affect, so it stays endpoint-wide however + // the listing is narrowed. + t.Run("the total ignores the cursor", func(t *testing.T) { + id := endpointID(t) + post(t, "/to/"+id, "first") + + first := listRequests(t, id, "", "") + + assert.Equal(t, int64(1), listRequests(t, id, "", first.Cursor).Total) + }) + + // Ignoring an unparseable cursor would hand back the whole window, which a + // caller cannot tell from a legitimate reply and would reprocess in full. + t.Run("a malformed since is rejected rather than ignored", func(t *testing.T) { + response := get(t, "/api/endpoints/"+endpointID(t)+"/requests?since=not-a-timestamp") + + assert.Equal(t, http.StatusBadRequest, response.StatusCode) + assert.Contains(t, bodyOf(t, response), "RFC 3339") + }) + + t.Run("a plain RFC 3339 second-precision cursor is accepted", func(t *testing.T) { + id := endpointID(t) + post(t, "/to/"+id, "only") + + listing := listRequests(t, id, "", time.Now().UTC().Add(-time.Hour).Format(time.RFC3339)) + + require.Len(t, listing.Requests, 1) + assert.Equal(t, "only", listing.Requests[0].Body) + }) + + // hasMore is the signal a poller throttles on: it sleeps between calls + // unless a page came back full, and a full page means the backlog is still + // draining. Reporting it wrongly either stalls the drain or turns the poll + // into a hot loop. + t.Run("a full page reports more to come, and draining clears it", func(t *testing.T) { + id := endpointID(t) + for i := range requestPageSize + 1 { + post(t, "/to/"+id, "burst-"+strconv.Itoa(i)) + } + + first := listRequests(t, id, "", "") + require.Len(t, first.Requests, requestPageSize) + assert.True(t, first.HasMore) + + second := listRequests(t, id, "", first.Cursor) + assert.False(t, second.HasMore, "the tail of a burst is not a full page") + }) +} + +func TestHandleDeleteRequest(t *testing.T) { + t.Run("deleting one capture leaves the rest", func(t *testing.T) { + id := endpointID(t) + post(t, "/to/"+id, "keep") + doomed := post(t, "/to/"+id, "delete") + uuid := doomed.Header.Get(captureUUIDHeader) + + response := do(t, testRequest{ + method: http.MethodDelete, + path: "/api/endpoints/" + id + "/requests/" + uuid, + }) + + assert.Equal(t, http.StatusOK, response.StatusCode) + captured := capturedRequests(t, id, "") + require.Len(t, captured, 1) + assert.Equal(t, "keep", captured[0].Body) + }) +} + +func TestHandleDeleteRequests(t *testing.T) { + t.Run("deleting an endpoint's captures clears only that endpoint", func(t *testing.T) { + id, other := endpointID(t), endpointID(t)+"-other" + post(t, "/to/"+id, "x") + post(t, "/to/"+other, "y") + + response := do(t, testRequest{ + method: http.MethodDelete, + path: "/api/endpoints/" + id + "/requests", + }) + + assert.Equal(t, http.StatusOK, response.StatusCode) + assert.Empty(t, capturedRequests(t, id, "")) + assert.Len(t, capturedRequests(t, other, ""), 1) + }) +} diff --git a/src/application_test.go b/src/application_test.go index 9c11a2b..36287b0 100644 --- a/src/application_test.go +++ b/src/application_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "net/http" "testing" "time" @@ -31,6 +32,23 @@ func uuidsFor(ctx context.Context, endpointID string) []string { return uuids } +// newApplication builds the whole routing surface, so these cover what the +// wiring itself decides rather than what any one handler does. +func TestNewApplication(t *testing.T) { + t.Run("serves static files from the public directory", func(t *testing.T) { + response := get(t, "/robots.txt") + + assert.Equal(t, http.StatusOK, response.StatusCode) + assert.Contains(t, bodyOf(t, response), "User-agent") + }) + + // The fallthrough runs after every route, including the prefix-matched + // capture surface, so a path that matches nothing must not be captured. + t.Run("an unmatched path is a 404", func(t *testing.T) { + assert.Equal(t, http.StatusNotFound, get(t, "/no/such/page").StatusCode) + }) +} + func TestSweepRetention(t *testing.T) { t.Run("drops captures older than the retention window", func(t *testing.T) { endpointID := "sweep-old" diff --git a/src/capture.go b/src/capture.go index fa653be..fbd7e8f 100644 --- a/src/capture.go +++ b/src/capture.go @@ -19,6 +19,11 @@ import ( // not the browser's fingerprint around it. const spoofCurlHeader = "Httphq-Spoof-Curl" +// captureUUIDHeader carries the stored capture's UUID back on the response. It +// is how a caller finds its own request in a stream it shares with everything +// else pointed at the endpoint. +const captureUUIDHeader = "Httphq-Request-Uuid" + // browserOnlyHeaders are the headers a browser adds that no command-line client // sends. Dropped together with the opt-in header itself when curl is spoofed. var browserOnlyHeaders = []string{ @@ -114,7 +119,7 @@ func captureRequest(registry *socketRegistry) fiber.Handler { } } - c.Set("Httphq-Request-Uuid", request.UUID) + c.Set(captureUUIDHeader, request.UUID) return c.SendStatus(http.StatusOK) } } diff --git a/src/capture_test.go b/src/capture_test.go index c002738..3644c54 100644 --- a/src/capture_test.go +++ b/src/capture_test.go @@ -1,11 +1,116 @@ package main import ( + "net/http" + "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +func TestCaptureRequest(t *testing.T) { + t.Run("records the method, path, query string, body and headers", func(t *testing.T) { + id := endpointID(t) + + response := do(t, testRequest{ + method: http.MethodPut, + path: "/to/" + id + "/orders/8821?event=charge.succeeded", + body: `{"hello":"world"}`, + headers: map[string]string{"Content-Type": "application/json", "X-Sample": "value"}, + }) + + assert.Equal(t, http.StatusOK, response.StatusCode) + assert.NotEmpty(t, response.Header.Get(captureUUIDHeader), + "the capture UUID is how a caller finds its own request in the stream") + + captured := capturedRequests(t, id, "") + require.Len(t, captured, 1) + assert.Equal(t, http.MethodPut, captured[0].Method) + assert.Equal(t, "/to/"+id+"/orders/8821", captured[0].Path) + assert.Equal(t, "event=charge.succeeded", captured[0].QueryString) + assert.Equal(t, `{"hello":"world"}`, captured[0].Body) + assert.Contains(t, string(captured[0].Headers), "X-Sample") + assert.Equal(t, response.Header.Get(captureUUIDHeader), captured[0].UUID) + }) + + t.Run("captures every method", func(t *testing.T) { + id := endpointID(t) + + methods := []string{ + http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, + http.MethodDelete, http.MethodHead, http.MethodOptions, + } + for _, method := range methods { + assert.Equalf(t, http.StatusOK, + do(t, testRequest{method: method, path: "/to/" + id}).StatusCode, + "%s is a request a user may want to inspect", method) + } + + assert.Len(t, capturedRequests(t, id, ""), len(methods)) + }) + + t.Run("hides infrastructure headers from the capture", func(t *testing.T) { + id := endpointID(t) + + do(t, testRequest{ + method: http.MethodPost, + path: "/to/" + id, + body: "x", + headers: map[string]string{ + "X-Forwarded-For": "198.51.100.1", + "X-Forwarded-Host": "elsewhere.example", + "Via": "1.1 proxy", + "X-Sample": "value", + }, + }) + + captured := capturedRequests(t, id, "") + require.Len(t, captured, 1) + headers := string(captured[0].Headers) + assert.Contains(t, headers, "X-Sample") + assert.NotContains(t, headers, "X-Forwarded-For") + assert.NotContains(t, headers, "X-Forwarded-Host") + assert.NotContains(t, headers, "Via") + }) + + t.Run("an empty body is captured as an empty body", func(t *testing.T) { + id := endpointID(t) + + get(t, "/to/"+id) + + captured := capturedRequests(t, id, "") + require.Len(t, captured, 1) + assert.Empty(t, captured[0].Body) + }) + + t.Run("a body at the limit is captured whole", func(t *testing.T) { + id := endpointID(t) + + response := post(t, "/to/"+id, strings.Repeat("a", bodyLimit)) + + assert.Equal(t, http.StatusOK, response.StatusCode) + captured := capturedRequests(t, id, "") + require.Len(t, captured, 1) + assert.Len(t, captured[0].Body, bodyLimit) + }) + + // The limit is what keeps one caller from filling the disk a whole shared + // instance writes to. It is enforced by the server rather than the handler, + // so the transport refuses the payload and the caller never gets an answer + // that would read as a successful capture. The status a real client sees is + // covered end to end, where there is a socket to see it on. + t.Run("a body over the limit is refused by the transport", func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/to/"+endpointID(t), + strings.NewReader(strings.Repeat("a", bodyLimit+1))) + + _, err := application(t).Test(request) + + require.Error(t, err) + }) +} + func TestCaptureHeaders(t *testing.T) { t.Run("leaves a caller's own headers alone", func(t *testing.T) { withPlatform(t, "direct") diff --git a/src/endpoint_test.go b/src/endpoint_test.go index 5a2fd3d..2ccd40a 100644 --- a/src/endpoint_test.go +++ b/src/endpoint_test.go @@ -1,6 +1,7 @@ package main import ( + "net/http" "strings" "testing" @@ -80,3 +81,24 @@ func TestValidEndpointID(t *testing.T) { } }) } + +// The guard runs before the handler, so no route can reach the database, a +// template or a log line with an unvalidated ID. Every route carrying an +// :endpoint parameter has to be behind it. +func TestRequireValidEndpoint(t *testing.T) { + malformed := "Not_A_Valid_ID" + + routes := []testRequest{ + {method: http.MethodGet, path: "/" + malformed}, + {method: http.MethodGet, path: "/api/endpoints/" + malformed + "/requests"}, + {method: http.MethodDelete, path: "/api/endpoints/" + malformed + "/requests"}, + {method: http.MethodDelete, path: "/api/endpoints/" + malformed + "/requests/some-uuid"}, + {method: http.MethodPost, path: "/to/" + malformed}, + } + + for _, route := range routes { + t.Run(route.method+" "+route.path, func(t *testing.T) { + assert.Equal(t, http.StatusNotFound, do(t, route).StatusCode) + }) + } +} diff --git a/src/harness_test.go b/src/harness_test.go new file mode 100644 index 0000000..b98658f --- /dev/null +++ b/src/harness_test.go @@ -0,0 +1,108 @@ +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/require" + + "httphq/src/database" +) + +// Shared fixtures for the tests that drive real requests through the wired +// application. Everything here is used from more than one subject's test file; +// anything specific to one subject belongs beside that subject instead. + +// The whole package shares one database. Captures are keyed by endpoint ID and +// every test mints its own, so no test can see another's traffic. +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "httphq-test") + if err != nil { + panic(err) + } + database.Connect("file:" + filepath.Join(dir, "test.db")) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} + +var ( + testApplicationOnce sync.Once + testApplication *fiber.App +) + +// application returns the fully wired app, built against the repository's real +// templates and static files. It is built once: socket lifecycle handlers are +// registered process-wide, so a per-test app would stack them up. +func application(t *testing.T) *fiber.App { + t.Helper() + testApplicationOnce.Do(func() { + testApplication = newApplication(applicationConfig{ + viewsDir: "./views", + publicDir: "../public", + registry: newSocketRegistry(), + }) + }) + return testApplication +} + +var endpointCounter atomic.Uint64 + +// endpointID mints an ID unique to one test, so no test can see another's +// captures. Counted rather than derived from the test name, which carries +// characters the endpoint pattern rejects. +func endpointID(t *testing.T) string { + t.Helper() + return "test-" + strconv.FormatUint(endpointCounter.Add(1), 10) +} + +type testRequest struct { + method string + path string + body string + headers map[string]string +} + +func do(t *testing.T, spec testRequest) *http.Response { + t.Helper() + + var body io.Reader + if spec.body != "" { + body = strings.NewReader(spec.body) + } + req := httptest.NewRequest(spec.method, spec.path, body) + for name, value := range spec.headers { + req.Header.Set(name, value) + } + + response, err := application(t).Test(req) + require.NoError(t, err) + t.Cleanup(func() { _ = response.Body.Close() }) + return response +} + +func get(t *testing.T, path string) *http.Response { + t.Helper() + return do(t, testRequest{method: http.MethodGet, path: path}) +} + +func post(t *testing.T, path, body string) *http.Response { + t.Helper() + return do(t, testRequest{method: http.MethodPost, path: path, body: body}) +} + +func bodyOf(t *testing.T, response *http.Response) string { + t.Helper() + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + return string(body) +} diff --git a/src/pages_test.go b/src/pages_test.go new file mode 100644 index 0000000..9e349fe --- /dev/null +++ b/src/pages_test.go @@ -0,0 +1,93 @@ +package main + +import ( + "net/http" + "strings" + "testing" + + "github.com/gofiber/fiber/v3" + "github.com/stretchr/testify/assert" +) + +func TestRenderIndex(t *testing.T) { + t.Run("renders with its canonical and social tags", func(t *testing.T) { + response := get(t, "/") + body := bodyOf(t, response) + + assert.Equal(t, http.StatusOK, response.StatusCode) + assert.Contains(t, body, "httphq: inspect HTTP requests in real time") + assert.Contains(t, body, ``) + assert.Contains(t, body, ``) + }) + + // The window is a promise the landing page makes before a visitor points + // traffic at a public URL, so it is rendered from the constant the sweep + // uses rather than typed into the copy. + t.Run("states the retention window", func(t *testing.T) { + assert.Contains(t, bodyOf(t, get(t, "/")), "deleted after 4 hours") + }) +} + +func TestRenderContact(t *testing.T) { + t.Run("renders with its canonical tag", func(t *testing.T) { + response := get(t, "/contact") + body := bodyOf(t, response) + + assert.Equal(t, http.StatusOK, response.StatusCode) + assert.Contains(t, body, "Contact | httphq") + assert.Contains(t, body, ``) + }) +} + +func TestRenderEndpoint(t *testing.T) { + t.Run("advertises its capture and socket URLs", func(t *testing.T) { + id := endpointID(t) + body := bodyOf(t, get(t, "/"+id)) + + assert.Contains(t, body, "http://example.com/to/"+id) + assert.Contains(t, body, `data-endpoint-id="`+id+`"`) + assert.Contains(t, body, "endpoint.js?v=") + }) + + // The page expires captures out of its own list, so it needs the window as + // a number. Rendering it from the same constant the sweep uses is what keeps + // the two from drifting. + t.Run("carries the retention window as a number and as prose", func(t *testing.T) { + body := bodyOf(t, get(t, "/"+endpointID(t))) + + assert.Contains(t, body, `data-retention-seconds="14400"`) + assert.Contains(t, body, "deleted after 4 hours") + }) + + // The prompt is built from the request, so a self-hosted deployment hands + // out its own URLs rather than httphq.com's. + t.Run("carries an agent prompt for this host", func(t *testing.T) { + id := endpointID(t) + body := bodyOf(t, get(t, "/"+id)) + + assert.Contains(t, body, "http://example.com/api/endpoints/"+id+"/requests") + assert.Contains(t, body, "150 requests per minute") + assert.NotContains(t, body, "httphq.com") + }) + + // robots.txt excludes endpoint pages, so a canonical URL pointing them at a + // shared address would be a claim nothing else in the site makes. + t.Run("carries no canonical URL", func(t *testing.T) { + body := bodyOf(t, get(t, "/"+endpointID(t))) + + assert.NotContains(t, body, `rel="canonical"`) + }) +} + +func TestCreateEndpoint(t *testing.T) { + t.Run("redirects to a valid endpoint page", func(t *testing.T) { + response := do(t, testRequest{method: http.MethodPost, path: "/endpoint"}) + + // See Other, so the browser follows with a GET rather than replaying + // the POST at the new location. + assert.Equal(t, http.StatusSeeOther, response.StatusCode) + location := response.Header.Get(fiber.HeaderLocation) + assert.True(t, validEndpointID(strings.TrimPrefix(location, "/")), + "generated endpoint %q must satisfy the validator every route applies", location) + }) +} diff --git a/src/requestlog_test.go b/src/requestlog_test.go index f4cd788..fa8ae03 100644 --- a/src/requestlog_test.go +++ b/src/requestlog_test.go @@ -1,6 +1,7 @@ package main import ( + "net/http" "strings" "testing" @@ -41,6 +42,41 @@ func TestRequestIDPattern(t *testing.T) { }) } +// The ID a caller reads back is the one stamped on every line logged while its +// request was handled, so the echo is what makes a log line findable from +// outside the process. +func TestRequestLogger(t *testing.T) { + t.Run("a valid inbound request ID is echoed back", func(t *testing.T) { + response := do(t, testRequest{ + method: http.MethodGet, + path: "/api/health", + headers: map[string]string{"X-Request-Id": "abcd-1234-efgh"}, + }) + + assert.Equal(t, "abcd-1234-efgh", response.Header.Get("X-Request-Id")) + }) + + t.Run("a request with no ID is given one", func(t *testing.T) { + assert.NotEmpty(t, get(t, "/api/health").Header.Get("X-Request-Id")) + }) + + // A caller's ID reaches every log line it produces, so one shaped like a log + // record of its own is replaced rather than reused. + t.Run("a malformed inbound request ID is replaced rather than echoed", func(t *testing.T) { + forged := `{"level":"error"}` + + response := do(t, testRequest{ + method: http.MethodGet, + path: "/api/health", + headers: map[string]string{"X-Request-Id": forged}, + }) + + echoed := response.Header.Get("X-Request-Id") + assert.NotEmpty(t, echoed) + assert.NotEqual(t, forged, echoed) + }) +} + func TestProbePaths(t *testing.T) { // Probe traffic is constant and says nothing, so it logs at debug and stays // out of production logs. Everything else has to remain visible. diff --git a/src/routes_test.go b/src/routes_test.go deleted file mode 100644 index 7f7799a..0000000 --- a/src/routes_test.go +++ /dev/null @@ -1,528 +0,0 @@ -package main - -import ( - "encoding/json" - "io" - "net/http" - "net/http/httptest" - "net/url" - "os" - "path/filepath" - "strconv" - "strings" - "sync" - "sync/atomic" - "testing" - "time" - - "github.com/gofiber/fiber/v3" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "httphq/src/database" -) - -// The whole package shares one database. Captures are keyed by endpoint ID and -// every test mints its own, so no test can see another's traffic. -func TestMain(m *testing.M) { - dir, err := os.MkdirTemp("", "httphq-test") - if err != nil { - panic(err) - } - database.Connect("file:" + filepath.Join(dir, "test.db")) - code := m.Run() - _ = os.RemoveAll(dir) - os.Exit(code) -} - -var ( - testApplicationOnce sync.Once - testApplication *fiber.App -) - -// application returns the fully wired app, built against the repository's real -// templates and static files. It is built once: socket lifecycle handlers are -// registered process-wide, so a per-test app would stack them up. -func application(t *testing.T) *fiber.App { - t.Helper() - testApplicationOnce.Do(func() { - testApplication = newApplication(applicationConfig{ - viewsDir: "./views", - publicDir: "../public", - registry: newSocketRegistry(), - }) - }) - return testApplication -} - -var endpointCounter atomic.Uint64 - -// endpointID mints an ID unique to one test, so no test can see another's -// captures. Counted rather than derived from the test name, which carries -// characters the endpoint pattern rejects. -func endpointID(t *testing.T) string { - t.Helper() - return "test-" + strconv.FormatUint(endpointCounter.Add(1), 10) -} - -type testRequest struct { - method string - path string - body string - headers map[string]string -} - -func do(t *testing.T, spec testRequest) *http.Response { - t.Helper() - - var body io.Reader - if spec.body != "" { - body = strings.NewReader(spec.body) - } - req := httptest.NewRequest(spec.method, spec.path, body) - for name, value := range spec.headers { - req.Header.Set(name, value) - } - - response, err := application(t).Test(req) - require.NoError(t, err) - t.Cleanup(func() { _ = response.Body.Close() }) - return response -} - -func get(t *testing.T, path string) *http.Response { - t.Helper() - return do(t, testRequest{method: http.MethodGet, path: path}) -} - -func bodyOf(t *testing.T, response *http.Response) string { - t.Helper() - body, err := io.ReadAll(response.Body) - require.NoError(t, err) - return string(body) -} - -type requestListing struct { - Requests []database.Request `json:"requests"` - Total int64 `json:"total"` - Cursor string `json:"cursor"` - HasMore bool `json:"hasMore"` -} - -// listRequests reads back what an endpoint has captured, through the same API -// the page uses. An empty `since` is the browser's call, with no cursor. -func listRequests(t *testing.T, id, search, since string) requestListing { - t.Helper() - - response := get(t, "/api/endpoints/"+id+"/requests?search="+search+ - "&since="+url.QueryEscape(since)) - require.Equal(t, http.StatusOK, response.StatusCode) - - var payload requestListing - require.NoError(t, json.Unmarshal([]byte(bodyOf(t, response)), &payload)) - return payload -} - -func capturedRequests(t *testing.T, id, search string) []database.Request { - t.Helper() - return listRequests(t, id, search, "").Requests -} - -func listedTotal(t *testing.T, id, search string) int64 { - t.Helper() - return listRequests(t, id, search, "").Total -} - -func TestHealthRoute(t *testing.T) { - t.Run("answers 200 so a platform probe can reach it", func(t *testing.T) { - assert.Equal(t, http.StatusOK, get(t, "/api/health").StatusCode) - }) -} - -func TestDebugRoute(t *testing.T) { - t.Run("reports process state and no captured data", func(t *testing.T) { - response := get(t, "/api/debug") - require.Equal(t, http.StatusOK, response.StatusCode) - - var payload map[string]any - require.NoError(t, json.Unmarshal([]byte(bodyOf(t, response)), &payload)) - - assert.Contains(t, payload, "host") - assert.Contains(t, payload, "requests") - assert.Equal(t, false, payload["isProduction"]) - assert.Equal(t, float64(0), payload["sockets"]) - }) -} - -func TestPageRoutes(t *testing.T) { - t.Run("the landing page renders with its canonical and social tags", func(t *testing.T) { - response := get(t, "/") - body := bodyOf(t, response) - - assert.Equal(t, http.StatusOK, response.StatusCode) - assert.Contains(t, body, "httphq: inspect HTTP requests in real time") - assert.Contains(t, body, ``) - assert.Contains(t, body, ``) - }) - - t.Run("the contact page renders", func(t *testing.T) { - response := get(t, "/contact") - body := bodyOf(t, response) - - assert.Equal(t, http.StatusOK, response.StatusCode) - assert.Contains(t, body, "Contact | httphq") - assert.Contains(t, body, ``) - }) - - t.Run("an endpoint page advertises its capture and socket URLs", func(t *testing.T) { - id := endpointID(t) - body := bodyOf(t, get(t, "/"+id)) - - assert.Contains(t, body, "http://example.com/to/"+id) - assert.Contains(t, body, `data-endpoint-id="`+id+`"`) - assert.Contains(t, body, "endpoint.js?v=") - }) - - // The page expires captures out of its own list, so it needs the window as - // a number. Rendering it from the same constant the sweep uses is what keeps - // the two from drifting. - t.Run("an endpoint page carries the retention window", func(t *testing.T) { - body := bodyOf(t, get(t, "/"+endpointID(t))) - - assert.Contains(t, body, `data-retention-seconds="14400"`) - }) - - // The prompt is built from the request, so a self-hosted deployment hands - // out its own URLs rather than httphq.com's. - t.Run("an endpoint page carries an agent prompt for this host", func(t *testing.T) { - id := endpointID(t) - body := bodyOf(t, get(t, "/"+id)) - - assert.Contains(t, body, "http://example.com/api/endpoints/"+id+"/requests") - assert.Contains(t, body, "150 requests per minute") - assert.NotContains(t, body, "httphq.com") - }) - - // robots.txt excludes endpoint pages, so a canonical URL pointing them at a - // shared address would be a claim nothing else in the site makes. - t.Run("an endpoint page carries no canonical URL", func(t *testing.T) { - body := bodyOf(t, get(t, "/"+endpointID(t))) - - assert.NotContains(t, body, `rel="canonical"`) - }) - - t.Run("creating an endpoint redirects to a valid endpoint page", func(t *testing.T) { - response := do(t, testRequest{method: http.MethodPost, path: "/endpoint"}) - - // See Other, so the browser follows with a GET rather than replaying - // the POST at the new location. - assert.Equal(t, http.StatusSeeOther, response.StatusCode) - location := response.Header.Get(fiber.HeaderLocation) - assert.True(t, validEndpointID(strings.TrimPrefix(location, "/")), - "generated endpoint %q must satisfy the validator every route applies", location) - }) - - t.Run("an unknown path is a 404", func(t *testing.T) { - assert.Equal(t, http.StatusNotFound, get(t, "/no/such/page").StatusCode) - }) - - t.Run("static files are served from the public directory", func(t *testing.T) { - response := get(t, "/robots.txt") - - assert.Equal(t, http.StatusOK, response.StatusCode) - assert.Contains(t, bodyOf(t, response), "User-agent") - }) -} - -// An endpoint ID reaches the templates, the database and the logs, so every -// route that takes one rejects a malformed value before the handler runs. -func TestEndpointIDRejection(t *testing.T) { - malformed := "Not_A_Valid_ID" - - routes := []testRequest{ - {method: http.MethodGet, path: "/" + malformed}, - {method: http.MethodGet, path: "/api/endpoints/" + malformed + "/requests"}, - {method: http.MethodDelete, path: "/api/endpoints/" + malformed + "/requests"}, - {method: http.MethodDelete, path: "/api/endpoints/" + malformed + "/requests/some-uuid"}, - {method: http.MethodPost, path: "/to/" + malformed}, - } - - for _, route := range routes { - t.Run(route.method+" "+route.path, func(t *testing.T) { - assert.Equal(t, http.StatusNotFound, do(t, route).StatusCode) - }) - } -} - -func TestCaptureRoute(t *testing.T) { - t.Run("records the method, path, query string, body and headers", func(t *testing.T) { - id := endpointID(t) - - response := do(t, testRequest{ - method: http.MethodPut, - path: "/to/" + id + "/orders/8821?event=charge.succeeded", - body: `{"hello":"world"}`, - headers: map[string]string{"Content-Type": "application/json", "X-Sample": "value"}, - }) - - assert.Equal(t, http.StatusOK, response.StatusCode) - assert.NotEmpty(t, response.Header.Get("Httphq-Request-Uuid"), - "the capture UUID is how a caller finds its own request in the stream") - - captured := capturedRequests(t, id, "") - require.Len(t, captured, 1) - assert.Equal(t, http.MethodPut, captured[0].Method) - assert.Equal(t, "/to/"+id+"/orders/8821", captured[0].Path) - assert.Equal(t, "event=charge.succeeded", captured[0].QueryString) - assert.Equal(t, `{"hello":"world"}`, captured[0].Body) - assert.Contains(t, string(captured[0].Headers), "X-Sample") - assert.Equal(t, response.Header.Get("Httphq-Request-Uuid"), captured[0].UUID) - }) - - t.Run("captures every method", func(t *testing.T) { - id := endpointID(t) - - methods := []string{ - http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, - http.MethodDelete, http.MethodHead, http.MethodOptions, - } - for _, method := range methods { - assert.Equalf(t, http.StatusOK, - do(t, testRequest{method: method, path: "/to/" + id}).StatusCode, - "%s is a request a user may want to inspect", method) - } - - assert.Len(t, capturedRequests(t, id, ""), len(methods)) - }) - - t.Run("hides infrastructure headers from the capture", func(t *testing.T) { - id := endpointID(t) - - do(t, testRequest{ - method: http.MethodPost, - path: "/to/" + id, - body: "x", - headers: map[string]string{ - "X-Forwarded-For": "198.51.100.1", - "X-Forwarded-Host": "elsewhere.example", - "Via": "1.1 proxy", - "X-Sample": "value", - }, - }) - - captured := capturedRequests(t, id, "") - require.Len(t, captured, 1) - headers := string(captured[0].Headers) - assert.Contains(t, headers, "X-Sample") - assert.NotContains(t, headers, "X-Forwarded-For") - assert.NotContains(t, headers, "X-Forwarded-Host") - assert.NotContains(t, headers, "Via") - }) - - t.Run("an empty body is captured as an empty body", func(t *testing.T) { - id := endpointID(t) - - do(t, testRequest{method: http.MethodGet, path: "/to/" + id}) - - captured := capturedRequests(t, id, "") - require.Len(t, captured, 1) - assert.Empty(t, captured[0].Body) - }) - - t.Run("a body at the limit is captured whole", func(t *testing.T) { - id := endpointID(t) - body := strings.Repeat("a", bodyLimit) - - response := do(t, testRequest{method: http.MethodPost, path: "/to/" + id, body: body}) - - assert.Equal(t, http.StatusOK, response.StatusCode) - captured := capturedRequests(t, id, "") - require.Len(t, captured, 1) - assert.Len(t, captured[0].Body, bodyLimit) - }) -} - -func TestRequestsAPI(t *testing.T) { - t.Run("lists an endpoint's captures, newest first", func(t *testing.T) { - id := endpointID(t) - do(t, testRequest{method: http.MethodPost, path: "/to/" + id, body: "older"}) - do(t, testRequest{method: http.MethodPost, path: "/to/" + id, body: "newer"}) - - captured := capturedRequests(t, id, "") - - require.Len(t, captured, 2) - assert.Equal(t, "newer", captured[0].Body) - assert.Equal(t, "older", captured[1].Body) - }) - - t.Run("the search parameter narrows the list", func(t *testing.T) { - id := endpointID(t) - do(t, testRequest{method: http.MethodPost, path: "/to/" + id, body: "alpha"}) - do(t, testRequest{method: http.MethodPost, path: "/to/" + id, body: "beta"}) - - assert.Len(t, capturedRequests(t, id, "alpha"), 1) - assert.Empty(t, capturedRequests(t, id, "no-such-term")) - }) - - // The listing is both filtered and windowed, so its length says nothing - // about the endpoint. A control that clears the whole endpoint has to - // report what it will actually delete. - t.Run("the total is the endpoint's, not the filtered view's", func(t *testing.T) { - id := endpointID(t) - do(t, testRequest{method: http.MethodPost, path: "/to/" + id, body: "alpha"}) - do(t, testRequest{method: http.MethodPost, path: "/to/" + id, body: "beta"}) - - assert.Equal(t, int64(2), listedTotal(t, id, "")) - assert.Equal(t, int64(2), listedTotal(t, id, "alpha")) - assert.Equal(t, int64(2), listedTotal(t, id, "no-such-term")) - }) - - t.Run("an endpoint with no traffic totals zero", func(t *testing.T) { - assert.Equal(t, int64(0), listedTotal(t, endpointID(t), "")) - }) - - t.Run("an endpoint with no traffic lists nothing", func(t *testing.T) { - assert.Empty(t, capturedRequests(t, endpointID(t), "")) - }) - - t.Run("deleting one capture leaves the rest", func(t *testing.T) { - id := endpointID(t) - do(t, testRequest{method: http.MethodPost, path: "/to/" + id, body: "keep"}) - doomed := do(t, testRequest{method: http.MethodPost, path: "/to/" + id, body: "delete"}) - uuid := doomed.Header.Get("Httphq-Request-Uuid") - - response := do(t, testRequest{ - method: http.MethodDelete, - path: "/api/endpoints/" + id + "/requests/" + uuid, - }) - - assert.Equal(t, http.StatusOK, response.StatusCode) - captured := capturedRequests(t, id, "") - require.Len(t, captured, 1) - assert.Equal(t, "keep", captured[0].Body) - }) - - t.Run("deleting an endpoint's captures clears only that endpoint", func(t *testing.T) { - id, other := endpointID(t), endpointID(t)+"-other" - do(t, testRequest{method: http.MethodPost, path: "/to/" + id, body: "x"}) - do(t, testRequest{method: http.MethodPost, path: "/to/" + other, body: "y"}) - - response := do(t, testRequest{ - method: http.MethodDelete, - path: "/api/endpoints/" + id + "/requests", - }) - - assert.Equal(t, http.StatusOK, response.StatusCode) - assert.Empty(t, capturedRequests(t, id, "")) - assert.Len(t, capturedRequests(t, other, ""), 1) - }) -} - -// The cursor is what makes the listing pollable. Its promise is that echoing it -// back returns every capture exactly once, so these cover the round trip rather -// than the field's presence. -func TestRequestsAPICursor(t *testing.T) { - t.Run("an endpoint with no traffic still carries a cursor", func(t *testing.T) { - listing := listRequests(t, endpointID(t), "", "") - - assert.NotEmpty(t, listing.Cursor) - assert.False(t, listing.HasMore) - }) - - // Without this the caller has nothing to advance from and would re-ask for - // the same empty window forever. - t.Run("the cursor from an empty endpoint is usable", func(t *testing.T) { - id := endpointID(t) - cursor := listRequests(t, id, "", "").Cursor - - do(t, testRequest{method: http.MethodPost, path: "/to/" + id, body: "after"}) - - captured := listRequests(t, id, "", cursor).Requests - require.Len(t, captured, 1) - assert.Equal(t, "after", captured[0].Body) - }) - - t.Run("round-tripping the cursor returns only what is new", func(t *testing.T) { - id := endpointID(t) - do(t, testRequest{method: http.MethodPost, path: "/to/" + id, body: "first"}) - - first := listRequests(t, id, "", "") - require.Len(t, first.Requests, 1) - - do(t, testRequest{method: http.MethodPost, path: "/to/" + id, body: "second"}) - - second := listRequests(t, id, "", first.Cursor) - require.Len(t, second.Requests, 1) - assert.Equal(t, "second", second.Requests[0].Body) - }) - - t.Run("a cursor with nothing behind it returns nothing but still advances", func(t *testing.T) { - id := endpointID(t) - do(t, testRequest{method: http.MethodPost, path: "/to/" + id, body: "only"}) - - first := listRequests(t, id, "", "") - second := listRequests(t, id, "", first.Cursor) - - assert.Empty(t, second.Requests) - assert.NotEmpty(t, second.Cursor) - }) - - // total is what a delete-all will affect, so it stays endpoint-wide however - // the listing is narrowed. - t.Run("the total ignores the cursor", func(t *testing.T) { - id := endpointID(t) - do(t, testRequest{method: http.MethodPost, path: "/to/" + id, body: "first"}) - - first := listRequests(t, id, "", "") - - assert.Equal(t, int64(1), listRequests(t, id, "", first.Cursor).Total) - }) - - // Ignoring an unparseable cursor would hand back the whole window, which a - // caller cannot tell from a legitimate reply and would reprocess in full. - t.Run("a malformed since is rejected rather than ignored", func(t *testing.T) { - response := get(t, "/api/endpoints/"+endpointID(t)+"/requests?since=not-a-timestamp") - - assert.Equal(t, http.StatusBadRequest, response.StatusCode) - assert.Contains(t, bodyOf(t, response), "RFC 3339") - }) - - t.Run("a plain RFC 3339 second-precision cursor is accepted", func(t *testing.T) { - id := endpointID(t) - do(t, testRequest{method: http.MethodPost, path: "/to/" + id, body: "only"}) - - listing := listRequests(t, id, "", time.Now().UTC().Add(-time.Hour).Format(time.RFC3339)) - - require.Len(t, listing.Requests, 1) - assert.Equal(t, "only", listing.Requests[0].Body) - }) -} - -func TestResponseHeaders(t *testing.T) { - t.Run("every response carries the security headers", func(t *testing.T) { - for _, path := range []string{"/", "/contact", "/api/health", "/no/such/page"} { - t.Run(path, func(t *testing.T) { - response := get(t, path) - - assert.Equal(t, "nosniff", response.Header.Get("X-Content-Type-Options")) - assert.Equal(t, "no-referrer", response.Header.Get("Referrer-Policy")) - assert.Equal(t, "DENY", response.Header.Get("X-Frame-Options")) - assert.Contains(t, response.Header.Get(fiber.HeaderContentSecurityPolicy), "frame-ancestors 'none'") - }) - } - }) - - t.Run("a valid inbound request ID is echoed back", func(t *testing.T) { - response := do(t, testRequest{ - method: http.MethodGet, - path: "/api/health", - headers: map[string]string{"X-Request-Id": "abcd-1234-efgh"}, - }) - - assert.Equal(t, "abcd-1234-efgh", response.Header.Get("X-Request-Id")) - }) - - t.Run("a request with no ID is given one", func(t *testing.T) { - assert.NotEmpty(t, get(t, "/api/health").Header.Get("X-Request-Id")) - }) -} diff --git a/src/security_test.go b/src/security_test.go index 08292ce..2187a21 100644 --- a/src/security_test.go +++ b/src/security_test.go @@ -4,9 +4,28 @@ import ( "strings" "testing" + "github.com/gofiber/fiber/v3" "github.com/stretchr/testify/assert" ) +// The headers are set by middleware rather than per route, so the assertion +// that matters is that no surface can be reached without them: a page, an API +// route, a probe and the fallthrough 404 all have to carry them. +func TestSecurityHeaders(t *testing.T) { + t.Run("every response carries them", func(t *testing.T) { + for _, path := range []string{"/", "/contact", "/api/health", "/no/such/page"} { + t.Run(path, func(t *testing.T) { + response := get(t, path) + + assert.Equal(t, "nosniff", response.Header.Get("X-Content-Type-Options")) + assert.Equal(t, "no-referrer", response.Header.Get("Referrer-Policy")) + assert.Equal(t, "DENY", response.Header.Get("X-Frame-Options")) + assert.Contains(t, response.Header.Get(fiber.HeaderContentSecurityPolicy), "frame-ancestors 'none'") + }) + } + }) +} + func TestContentSecurityPolicy(t *testing.T) { // The design tooling opens a socket back to a local origin. Shipping that // origin to production would widen the policy for every visitor, so the From cd2e8e54b333cbad7da0a8dfa15bab85ab621211 Mon Sep 17 00:00:00 2001 From: botre Date: Sun, 9 Aug 2026 11:39:34 +0200 Subject: [PATCH 2/3] Spell the disclosure panel and its icons once Both panels repeated the same summary class string and the same four SVGs, which is how two nominally identical controls drift apart. The summary and its body are now component classes, the shared glyphs are partials, and the pages render the retention window from the constant the sweep uses instead of typing a figure that outlives it. Claude-Session: https://claude.ai/code/session_017NsCXwruqBye5cySvFEFiB --- DESIGN.md | 13 +- e2e/tests/capture-api.spec.ts | 43 ++++++ e2e/tests/endpoint-screen.spec.ts | 43 ++++++ e2e/tests/support/harness.ts | 21 +++ public/app.css | 2 +- public/endpoint.js | 28 ++-- public/index.js | 8 ++ src/application_test.go | 16 +++ src/harness_test.go | 8 ++ src/pages.go | 18 ++- src/pages_test.go | 8 +- src/styles/components.css | 18 +++ src/views/endpoint.html | 122 +++--------------- src/views/index.html | 4 +- src/views/partials/icons/clipboard.html | 15 +++ src/views/partials/icons/copy.html | 13 ++ .../partials/icons/disclosure-chevron.html | 14 ++ src/views/partials/icons/send.html | 13 ++ 18 files changed, 272 insertions(+), 135 deletions(-) create mode 100644 e2e/tests/capture-api.spec.ts create mode 100644 src/views/partials/icons/clipboard.html create mode 100644 src/views/partials/icons/copy.html create mode 100644 src/views/partials/icons/disclosure-chevron.html create mode 100644 src/views/partials/icons/send.html diff --git a/DESIGN.md b/DESIGN.md index 5a55b56..441d3c7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -456,8 +456,9 @@ must never be borrowed for a disabled, errored, or drop-target surface. The system is implemented, not only described. `src/styles/components.css` carries the classes these entries specify (`.btn` and its variants, `.field`, -`.field-label`, `.region-label`, `.panel`, `.kv-row`, `.icon`, `.badge`, -`.code-block`, `.empty-value`, `.btn-lg`), and templates compose them rather +`.field-label`, `.region-label`, `.panel`, `.panel-summary`, `.panel-body`, +`.kv-row`, `.icon`, `.badge`, `.code-block`, `.empty-value`, `.btn-lg`), and +templates compose them rather than repeating utility strings. Every page uses them: a template that re-spells a component as a utility string is the bug, not a shortcut. Utilities stay the default for one-off composition; anything whose tokens must not drift between call sites belongs in that file. Before adding a @@ -470,6 +471,14 @@ no shadow. A second, quieter panel would be the One Edge Rule broken by another name, so a surface that needs to read as nested takes its distinction from spacing or tone rather than from a class of its own. +A disclosure panel is that surface with two more parts: `.panel-summary` is the +row that opens it, and `.panel-body` is what appears below the seam. They are +one component in two spellings, so a panel that opens is composed from them +rather than from a utility string repeated at each panel. The summary drops the +native marker and squares its bottom corners when open, so the seam meets the +panel edge instead of crossing a radius; the chevron that replaces the marker is +a partial, and it reports state rather than competing with the label. + `.field-label` and `.region-label` carry one type token between them. The field label owns the spacing above its input; the region label takes spacing from the call site, because a heading inside a flex row must not carry a bottom margin. diff --git a/e2e/tests/capture-api.spec.ts b/e2e/tests/capture-api.spec.ts new file mode 100644 index 0000000..ff73f9c --- /dev/null +++ b/e2e/tests/capture-api.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from "@playwright/test"; +import { captureUrl, newEndpointId, requestsUrl } from "./support/harness"; + +/** + * What only a real socket can show. The Go suite drives the same routes through + * the router in memory, which is enough for everything a handler decides; the + * limits enforced by the server around the handler need a client on the wire to + * observe. + */ +test.describe("Capture API", () => { + test.describe("Body limit", () => { + // A shared instance writes to one disk, so an unbounded body is one + // caller's ability to fill it for everyone. + test("a body over the limit is rejected", async ({ request }) => { + const overOneMebibyte = "a".repeat(1024 * 1024 + 1); + + const response = await request.post(captureUrl(newEndpointId()), { + data: overOneMebibyte, + headers: { "Content-Type": "text/plain" }, + }); + + expect(response.status()).toBe(413); + }); + + test("a body at the limit is accepted", async ({ request }) => { + const endpointId = newEndpointId(); + const oneMebibyte = "a".repeat(1024 * 1024); + + const response = await request.post(captureUrl(endpointId), { + data: oneMebibyte, + headers: { "Content-Type": "text/plain" }, + }); + + expect(response.status()).toBe(200); + + const listing = await request.get(requestsUrl(endpointId)); + const payload = (await listing.json()) as { + requests: { body: string }[]; + }; + expect(payload.requests[0].body).toHaveLength(oneMebibyte.length); + }); + }); +}); diff --git a/e2e/tests/endpoint-screen.spec.ts b/e2e/tests/endpoint-screen.spec.ts index e0dd763..ca86708 100644 --- a/e2e/tests/endpoint-screen.spec.ts +++ b/e2e/tests/endpoint-screen.spec.ts @@ -2,8 +2,10 @@ import { test, expect } from "@playwright/test"; import { captureUrl, newEndpointId, + pruneExpiredCaptures, readClipboard, readClipboardJson, + requestsUrl, send, type HarDocument, } from "./support/harness"; @@ -185,6 +187,30 @@ test.describe("Endpoint screen", () => { ); }); + // Nothing tells the page that the server swept a capture out from under it, + // so a list left open long enough would go on rendering requests that no + // longer exist, beside the promise that they were deleted. The page runs + // this on an interval; the test drives the same pass directly rather than + // holding the suite open for it. + test("a capture the server no longer holds stops being rendered", async ({ + page, + request, + }) => { + const response = await send(request, endpointUrl, { data: "swept" }); + const uuid = response.headers()["httphq-request-uuid"]; + await expect(page.locator(`#request-${uuid}`)).toBeAttached(); + + // Deleted behind the page's back, which is what the retention sweep is + // from the page's point of view. + await request.delete(requestsUrl(endpointId)); + + await pruneExpiredCaptures(page); + + await expect(page.locator('[data-test="requests"]')).toContainText( + "Waiting for requests", + ); + }); + // Rendering every capture at once is a five-figure node count and a visible // stall, so the rest stay in the store until asked for. test("only a page of cards is rendered until more are asked for", async ({ @@ -662,6 +688,15 @@ test.describe("Endpoint screen", () => { await expect(page.locator('[data-test="agent-prompt"]')).toBeVisible(); }); + // Both panels sit above the stream. Opening one to read a prompt must not + // push the other open on top of it. + test("opening it leaves the send panel closed", async ({ page }) => { + await page.locator('[data-test="agent-toggle"]').click(); + + await expect(page.locator('[data-test="agent-prompt"]')).toBeVisible(); + await expect(page.locator('[data-test="send-submit"]')).toBeHidden(); + }); + // The prompt is built from the request, so it has to name the host the // page was actually served from rather than a hardcoded one. test("the prompt carries this endpoint's own URLs", async ({ page }) => { @@ -714,6 +749,14 @@ test.describe("Endpoint screen", () => { }); test.describe("Sending a test request", () => { + test("the panel is collapsed until it is opened", async ({ page }) => { + await expect(page.locator('[data-test="send-submit"]')).toBeHidden(); + + await page.locator('[data-test="send-toggle"]').click(); + + await expect(page.locator('[data-test="send-submit"]')).toBeVisible(); + }); + test("submitting the panel produces a captured request", async ({ page, }) => { diff --git a/e2e/tests/support/harness.ts b/e2e/tests/support/harness.ts index aa772aa..aecb836 100644 --- a/e2e/tests/support/harness.ts +++ b/e2e/tests/support/harness.ts @@ -15,6 +15,27 @@ export const newEndpointId = () => export const captureUrl = (endpointId: string) => `${BASE_URL}/to/${endpointId}`; +/** The JSON listing for an endpoint, which is also its delete-all target. */ +export const requestsUrl = (endpointId: string) => + `${BASE_URL}/api/endpoints/${endpointId}/requests`; + +/** + * The page drops captures that have aged past the retention window and resyncs + * with the server, on an interval measured in tens of seconds. This runs that + * pass on demand with a window short enough to expire everything, so a test can + * assert what the page does about a swept capture without waiting for a tick. + */ +export const pruneExpiredCaptures = (page: Page) => + page.evaluate(() => window.Alpine.store("main").pruneExpired(1)); + +declare global { + interface Window { + Alpine: { + store(name: "main"): { pruneExpired(retentionMs: number): unknown }; + }; + } +} + export type SendOptions = { method?: string; data?: string | object; diff --git a/public/app.css b/public/app.css index c1199f1..290bdd6 100644 --- a/public/app.css +++ b/public/app.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.2.4 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--container-md:28rem;--container-xl:36rem;--container-2xl:42rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-white:#fff;--color-neutral-50:oklch(98.4% .003 274);--color-neutral-100:oklch(96.6% .006 274);--color-neutral-200:oklch(92.8% .011 274);--color-neutral-300:oklch(86.6% .018 274);--color-neutral-400:oklch(70.2% .032 274);--color-neutral-500:oklch(55.2% .038 274);--color-neutral-600:oklch(44.4% .036 274);--color-neutral-700:oklch(37% .034 274);--color-neutral-800:oklch(27.8% .032 274);--color-neutral-900:oklch(20.6% .03 274);--color-brand-50:oklch(96.5% .022 275.25);--color-brand-400:oklch(70% .14 275.25);--color-brand-500:oklch(57.5% .157 275.25);--color-brand-600:oklch(52% .157 275.25);--color-brand-700:oklch(46% .15 275.25);--color-get-ink:oklch(50% .155 255);--color-get-wash:oklch(97% .025 255);--color-post-ink:oklch(50% .115 157);--color-post-wash:oklch(97% .018 157);--color-put-ink:oklch(50% .135 62);--color-put-wash:oklch(97% .022 62);--color-patch-ink:oklch(50% .165 308);--color-patch-wash:oklch(97% .026 308);--color-delete-ink:oklch(50% .17 19);--color-delete-wash:oklch(97% .027 19);--color-options-ink:oklch(50% .13 213);--color-options-wash:oklch(97% .021 213);--color-head-ink:oklch(37% .034 274);--color-head-wash:oklch(96.6% .006 274);--color-syntax-key:#005cc5;--color-syntax-string:#032f62;--color-danger-50:oklch(97% .02 19);--color-danger-200:oklch(89% .07 19);--color-danger-500:oklch(62% .19 19);--color-danger-600:oklch(55% .185 19);--color-danger-700:oklch(48% .165 19);--color-live:oklch(62% .145 157);--color-pending:oklch(68% .145 62)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])),[x-cloak]{display:none!important}button:not(:disabled),summary,[role=button]:not(:disabled){cursor:pointer}::selection{background-color:var(--color-indigo-100);color:var(--color-indigo-900)}:root{accent-color:var(--color-brand-600);color-scheme:light}}@layer components{.app-select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%2364748b'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 011.06.02L10 11.083l3.71-3.853a.75.75 0 111.08 1.04l-4.25 4.41a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z' clip-rule='evenodd'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.1em;padding-right:2rem}.loading-dots:after{content:"";text-align:left;width:1.5ch;animation:1.2s steps(4,end) infinite httphq-dots;display:inline-block}.focus-ring:focus{--tw-outline-style:none;outline-style:none}.focus-ring:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-brand-500)}.btn{justify-content:center;align-items:center;gap:calc(var(--spacing) * 1.5);border-radius:var(--radius-md);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);display:inline-flex}.btn:focus{--tw-outline-style:none;outline-style:none}.btn:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-brand-500)}.btn:disabled{cursor:not-allowed;opacity:.5}.btn-secondary{border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-neutral-300);background-color:var(--color-white);color:var(--color-neutral-700)}@media (hover:hover){.btn-secondary:hover{border-color:var(--color-neutral-400);background-color:var(--color-neutral-100)}}.btn-danger{border-style:var(--tw-border-style);background-color:var(--color-danger-600);color:var(--color-white);border-width:1px;border-color:#0000}@media (hover:hover){.btn-danger:hover{background-color:var(--color-danger-700)}}.btn-danger:focus-visible{--tw-ring-color:var(--color-danger-500)}.btn-primary{background-color:var(--color-brand-600);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);color:var(--color-white)}@media (hover:hover){.btn-primary:hover{background-color:var(--color-brand-500)}}.btn-primary:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.btn-lg{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 3);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.btn-inline{align-items:center;gap:calc(var(--spacing) * 1);border-radius:var(--radius-md);padding-inline:calc(var(--spacing) * 1);padding-block:calc(var(--spacing) * 1);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--color-neutral-500);display:inline-flex}@media (hover:hover){.btn-inline:hover{color:var(--color-brand-600)}}.btn-inline:focus{--tw-outline-style:none;outline-style:none}.btn-inline:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-brand-500)}@media (hover:hover){.btn-inline-danger:hover{color:var(--color-danger-600)}}.btn-inline-danger:focus-visible{--tw-ring-color:var(--color-danger-500)}.field{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-neutral-300);background-color:var(--color-white);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));display:block}.field:focus{--tw-outline-style:none;outline-style:none}.field:focus-visible{border-color:var(--color-brand-500);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-brand-400)}.field-mono{font-family:var(--font-mono)}.field-label,.region-label{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide);color:var(--color-neutral-500);text-transform:uppercase}.field-label{margin-bottom:calc(var(--spacing) * 2);display:block}.panel{border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-neutral-200);background-color:var(--color-white)}.kv-row{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--color-neutral-100);padding-block:calc(var(--spacing) * 1.5);flex-direction:column;display:flex}.kv-row:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media (min-width:40rem){.kv-row{gap:calc(var(--spacing) * 3);flex-direction:row}}.kv-row-tight{padding-block:calc(var(--spacing) * 1)}.kv-key{color:var(--color-neutral-500)}@media (min-width:40rem){.kv-key{width:calc(var(--spacing) * 40);flex-shrink:0}}.kv-value{min-width:calc(var(--spacing) * 0)}.icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);flex-shrink:0}.badge{padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide);text-transform:uppercase;border-radius:.25rem;flex-shrink:0;justify-content:center;align-items:center;display:inline-flex}.code-block{border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-neutral-200);background-color:var(--color-neutral-50);padding:calc(var(--spacing) * 3);font-family:var(--font-mono);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));border-radius:.25rem;overflow:auto}.empty-value{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--color-neutral-500);font-style:italic}}@layer utilities{.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-0{top:calc(var(--spacing) * 0)}.z-10{z-index:10}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.-mx-4{margin-inline:calc(var(--spacing) * -4)}.mx-auto{margin-inline:auto}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-auto{margin-top:auto}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.h-2{height:calc(var(--spacing) * 2)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-9{height:calc(var(--spacing) * 9)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-96{max-height:calc(var(--spacing) * 96)}.min-h-11{min-height:calc(var(--spacing) * 11)}.min-h-screen{min-height:100vh}.w-2{width:calc(var(--spacing) * 2)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-9{width:calc(var(--spacing) * 9)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-md{max-width:var(--container-md)}.max-w-xl{max-width:var(--container-xl)}.min-w-0{min-width:calc(var(--spacing) * 0)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.scroll-mt-24{scroll-margin-top:calc(var(--spacing) * 24)}.list-none{list-style-type:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-danger-200{border-color:var(--color-danger-200)}.border-neutral-100{border-color:var(--color-neutral-100)}.border-neutral-200{border-color:var(--color-neutral-200)}.border-neutral-300{border-color:var(--color-neutral-300)}.bg-brand-50{background-color:var(--color-brand-50)}.bg-danger-50{background-color:var(--color-danger-50)}.bg-danger-500{background-color:var(--color-danger-500)}.bg-delete-wash{background-color:var(--color-delete-wash)}.bg-get-wash{background-color:var(--color-get-wash)}.bg-head-wash{background-color:var(--color-head-wash)}.bg-live{background-color:var(--color-live)}.bg-neutral-50{background-color:var(--color-neutral-50)}.bg-neutral-50\/95{background-color:#f9fafcf2}@supports (color:color-mix(in lab, red, red)){.bg-neutral-50\/95{background-color:color-mix(in oklab, var(--color-neutral-50) 95%, transparent)}}.bg-options-wash{background-color:var(--color-options-wash)}.bg-patch-wash{background-color:var(--color-patch-wash)}.bg-pending{background-color:var(--color-pending)}.bg-post-wash{background-color:var(--color-post-wash)}.bg-put-wash{background-color:var(--color-put-wash)}.bg-white{background-color:var(--color-white)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-10{padding-top:calc(var(--spacing) * 10)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.pb-10{padding-bottom:calc(var(--spacing) * 10)}.pb-12{padding-bottom:calc(var(--spacing) * 12)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.text-pretty{text-wrap:pretty}.break-all{word-break:break-all}.text-brand-600{color:var(--color-brand-600)}.text-brand-700{color:var(--color-brand-700)}.text-danger-700{color:var(--color-danger-700)}.text-delete-ink{color:var(--color-delete-ink)}.text-get-ink{color:var(--color-get-ink)}.text-head-ink{color:var(--color-head-ink)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-600{color:var(--color-neutral-600)}.text-neutral-700{color:var(--color-neutral-700)}.text-neutral-800{color:var(--color-neutral-800)}.text-neutral-900{color:var(--color-neutral-900)}.text-options-ink{color:var(--color-options-ink)}.text-patch-ink{color:var(--color-patch-ink)}.text-post-ink{color:var(--color-post-ink)}.text-put-ink{color:var(--color-put-ink)}.text-syntax-key{color:var(--color-syntax-key)}.text-syntax-string{color:var(--color-syntax-string)}.normal-case{text-transform:none}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.underline-offset-2{text-underline-offset:2px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.select-all{-webkit-user-select:all;user-select:all}.group-open\:rotate-180:is(:where(.group):is([open],:popover-open,:open) *){rotate:180deg}.group-open\:rounded-b-none:is(:where(.group):is([open],:popover-open,:open) *){border-bottom-right-radius:0;border-bottom-left-radius:0}@media (hover:hover){.hover\:bg-neutral-100:hover{background-color:var(--color-neutral-100)}.hover\:text-brand-600:hover{color:var(--color-brand-600)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-brand-500:focus-visible{--tw-ring-color:var(--color-brand-500)}.focus-visible\:ring-inset:focus-visible{--tw-ring-inset:inset}@media (min-width:40rem){.sm\:-mx-6{margin-inline:calc(var(--spacing) * -6)}.sm\:inline{display:inline}.sm\:w-40{width:calc(var(--spacing) * 40)}.sm\:w-auto{width:auto}.sm\:max-w-\[10rem\]{max-width:10rem}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-8{padding:calc(var(--spacing) * 8)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-8{padding-block:calc(var(--spacing) * 8)}.sm\:py-12{padding-block:calc(var(--spacing) * 12)}.sm\:pt-16{padding-top:calc(var(--spacing) * 16)}.sm\:pb-5{padding-bottom:calc(var(--spacing) * 5)}.sm\:pb-8{padding-bottom:calc(var(--spacing) * 8)}.sm\:pb-12{padding-bottom:calc(var(--spacing) * 12)}.sm\:pb-14{padding-bottom:calc(var(--spacing) * 14)}.sm\:text-right{text-align:right}.sm\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.sm\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.sm\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}}}@keyframes httphq-dots{0%{content:""}25%{content:"."}50%{content:".."}75%,to{content:"..."}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}.loading-dots:after{content:"...";animation:none}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--spacing:.25rem;--container-md:28rem;--container-xl:36rem;--container-2xl:42rem;--container-4xl:56rem;--container-5xl:64rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--text-3xl:1.875rem;--text-3xl--line-height:calc(2.25 / 1.875);--text-5xl:3rem;--text-5xl--line-height:1;--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-wide:.025em;--radius-sm:.25rem;--radius-md:.375rem;--radius-lg:.5rem;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono);--color-white:#fff;--color-neutral-50:oklch(98.4% .003 274);--color-neutral-100:oklch(96.6% .006 274);--color-neutral-200:oklch(92.8% .011 274);--color-neutral-300:oklch(86.6% .018 274);--color-neutral-400:oklch(70.2% .032 274);--color-neutral-500:oklch(55.2% .038 274);--color-neutral-600:oklch(44.4% .036 274);--color-neutral-700:oklch(37% .034 274);--color-neutral-800:oklch(27.8% .032 274);--color-neutral-900:oklch(20.6% .03 274);--color-brand-50:oklch(96.5% .022 275.25);--color-brand-400:oklch(70% .14 275.25);--color-brand-500:oklch(57.5% .157 275.25);--color-brand-600:oklch(52% .157 275.25);--color-brand-700:oklch(46% .15 275.25);--color-get-ink:oklch(50% .155 255);--color-get-wash:oklch(97% .025 255);--color-post-ink:oklch(50% .115 157);--color-post-wash:oklch(97% .018 157);--color-put-ink:oklch(50% .135 62);--color-put-wash:oklch(97% .022 62);--color-patch-ink:oklch(50% .165 308);--color-patch-wash:oklch(97% .026 308);--color-delete-ink:oklch(50% .17 19);--color-delete-wash:oklch(97% .027 19);--color-options-ink:oklch(50% .13 213);--color-options-wash:oklch(97% .021 213);--color-head-ink:oklch(37% .034 274);--color-head-wash:oklch(96.6% .006 274);--color-syntax-key:#005cc5;--color-syntax-string:#032f62;--color-danger-50:oklch(97% .02 19);--color-danger-200:oklch(89% .07 19);--color-danger-500:oklch(62% .19 19);--color-danger-600:oklch(55% .185 19);--color-danger-700:oklch(48% .165 19);--color-live:oklch(62% .145 157);--color-pending:oklch(68% .145 62)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])),[x-cloak]{display:none!important}button:not(:disabled),summary,[role=button]:not(:disabled){cursor:pointer}::selection{background-color:var(--color-indigo-100);color:var(--color-indigo-900)}:root{accent-color:var(--color-brand-600);color-scheme:light}}@layer components{.app-select{appearance:none;background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='%2364748b'%3E%3Cpath fill-rule='evenodd' d='M5.23 7.21a.75.75 0 011.06.02L10 11.083l3.71-3.853a.75.75 0 111.08 1.04l-4.25 4.41a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z' clip-rule='evenodd'/%3E%3C/svg%3E");background-position:right .5rem center;background-repeat:no-repeat;background-size:1.1em;padding-right:2rem}.loading-dots:after{content:"";text-align:left;width:1.5ch;animation:1.2s steps(4,end) infinite httphq-dots;display:inline-block}.focus-ring:focus{--tw-outline-style:none;outline-style:none}.focus-ring:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-brand-500)}.btn{justify-content:center;align-items:center;gap:calc(var(--spacing) * 1.5);border-radius:var(--radius-md);padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 1.5);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);display:inline-flex}.btn:focus{--tw-outline-style:none;outline-style:none}.btn:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-brand-500)}.btn:disabled{cursor:not-allowed;opacity:.5}.btn-secondary{border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-neutral-300);background-color:var(--color-white);color:var(--color-neutral-700)}@media (hover:hover){.btn-secondary:hover{border-color:var(--color-neutral-400);background-color:var(--color-neutral-100)}}.btn-danger{border-style:var(--tw-border-style);background-color:var(--color-danger-600);color:var(--color-white);border-width:1px;border-color:#0000}@media (hover:hover){.btn-danger:hover{background-color:var(--color-danger-700)}}.btn-danger:focus-visible{--tw-ring-color:var(--color-danger-500)}.btn-primary{background-color:var(--color-brand-600);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 2);color:var(--color-white)}@media (hover:hover){.btn-primary:hover{background-color:var(--color-brand-500)}}.btn-primary:focus-visible{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.btn-lg{padding-inline:calc(var(--spacing) * 6);padding-block:calc(var(--spacing) * 3);font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.btn-inline{align-items:center;gap:calc(var(--spacing) * 1);border-radius:var(--radius-md);padding-inline:calc(var(--spacing) * 1);padding-block:calc(var(--spacing) * 1);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));color:var(--color-neutral-500);display:inline-flex}@media (hover:hover){.btn-inline:hover{color:var(--color-brand-600)}}.btn-inline:focus{--tw-outline-style:none;outline-style:none}.btn-inline:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-brand-500)}@media (hover:hover){.btn-inline-danger:hover{color:var(--color-danger-600)}}.btn-inline-danger:focus-visible{--tw-ring-color:var(--color-danger-500)}.field{border-radius:var(--radius-md);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-neutral-300);background-color:var(--color-white);width:100%;padding-inline:calc(var(--spacing) * 3);padding-block:calc(var(--spacing) * 2);font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height));display:block}.field:focus{--tw-outline-style:none;outline-style:none}.field:focus-visible{border-color:var(--color-brand-500);--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-brand-400)}.field-mono{font-family:var(--font-mono)}.field-label,.region-label{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide);color:var(--color-neutral-500);text-transform:uppercase}.field-label{margin-bottom:calc(var(--spacing) * 2);display:block}.panel{border-radius:var(--radius-lg);border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-neutral-200);background-color:var(--color-white)}.panel-summary{align-items:center;gap:calc(var(--spacing) * 2);padding-inline:calc(var(--spacing) * 4);padding-block:calc(var(--spacing) * 3);list-style-type:none;display:flex}@media (min-width:40rem){.panel-summary{padding-inline:calc(var(--spacing) * 5)}}.panel-summary{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium);color:var(--color-neutral-700)}@media (hover:hover){.panel-summary:hover{background-color:var(--color-neutral-100)}}.panel-summary{border-radius:var(--radius-lg)}.panel-summary:is(:where(.group):is([open],:popover-open,:open) *){border-bottom-right-radius:0;border-bottom-left-radius:0}.panel-summary:focus{--tw-outline-style:none;outline-style:none}.panel-summary:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);--tw-ring-color:var(--color-brand-500);--tw-ring-inset:inset}.panel-body{border-top-style:var(--tw-border-style);border-top-width:1px;border-color:var(--color-neutral-200);padding:calc(var(--spacing) * 4)}@media (min-width:40rem){.panel-body{padding:calc(var(--spacing) * 5)}}.kv-row{border-bottom-style:var(--tw-border-style);border-bottom-width:1px;border-color:var(--color-neutral-100);padding-block:calc(var(--spacing) * 1.5);flex-direction:column;display:flex}.kv-row:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media (min-width:40rem){.kv-row{gap:calc(var(--spacing) * 3);flex-direction:row}}.kv-row-tight{padding-block:calc(var(--spacing) * 1)}.kv-key{color:var(--color-neutral-500)}@media (min-width:40rem){.kv-key{width:calc(var(--spacing) * 40);flex-shrink:0}}.kv-value{min-width:calc(var(--spacing) * 0)}.icon{height:calc(var(--spacing) * 4);width:calc(var(--spacing) * 4);flex-shrink:0}.badge{padding-inline:calc(var(--spacing) * 2);padding-block:calc(var(--spacing) * .5);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold);--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide);text-transform:uppercase;border-radius:.25rem;flex-shrink:0;justify-content:center;align-items:center;display:inline-flex}.code-block{border-style:var(--tw-border-style);border-width:1px;border-color:var(--color-neutral-200);background-color:var(--color-neutral-50);padding:calc(var(--spacing) * 3);font-family:var(--font-mono);font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));border-radius:.25rem;overflow:auto}.empty-value{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height));color:var(--color-neutral-500);font-style:italic}}@layer utilities{.visible{visibility:visible}.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-0{top:calc(var(--spacing) * 0)}.z-10{z-index:10}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.-mx-4{margin-inline:calc(var(--spacing) * -4)}.mx-auto{margin-inline:auto}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mt-auto{margin-top:auto}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.h-2{height:calc(var(--spacing) * 2)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-9{height:calc(var(--spacing) * 9)}.max-h-64{max-height:calc(var(--spacing) * 64)}.max-h-96{max-height:calc(var(--spacing) * 96)}.min-h-11{min-height:calc(var(--spacing) * 11)}.min-h-screen{min-height:100vh}.w-2{width:calc(var(--spacing) * 2)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-9{width:calc(var(--spacing) * 9)}.w-full{width:100%}.max-w-2xl{max-width:var(--container-2xl)}.max-w-4xl{max-width:var(--container-4xl)}.max-w-5xl{max-width:var(--container-5xl)}.max-w-md{max-width:var(--container-md)}.max-w-xl{max-width:var(--container-xl)}.min-w-0{min-width:calc(var(--spacing) * 0)}.flex-1{flex:1}.shrink-0{flex-shrink:0}.scroll-mt-24{scroll-margin-top:calc(var(--spacing) * 24)}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-5>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 5) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 5) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-sm{border-radius:var(--radius-sm)}.border{border-style:var(--tw-border-style);border-width:1px}.border-y{border-block-style:var(--tw-border-style);border-block-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-dashed{--tw-border-style:dashed;border-style:dashed}.border-danger-200{border-color:var(--color-danger-200)}.border-neutral-100{border-color:var(--color-neutral-100)}.border-neutral-200{border-color:var(--color-neutral-200)}.border-neutral-300{border-color:var(--color-neutral-300)}.bg-brand-50{background-color:var(--color-brand-50)}.bg-danger-50{background-color:var(--color-danger-50)}.bg-danger-500{background-color:var(--color-danger-500)}.bg-delete-wash{background-color:var(--color-delete-wash)}.bg-get-wash{background-color:var(--color-get-wash)}.bg-head-wash{background-color:var(--color-head-wash)}.bg-live{background-color:var(--color-live)}.bg-neutral-50{background-color:var(--color-neutral-50)}.bg-neutral-50\/95{background-color:#f9fafcf2}@supports (color:color-mix(in lab, red, red)){.bg-neutral-50\/95{background-color:color-mix(in oklab, var(--color-neutral-50) 95%, transparent)}}.bg-options-wash{background-color:var(--color-options-wash)}.bg-patch-wash{background-color:var(--color-patch-wash)}.bg-pending{background-color:var(--color-pending)}.bg-post-wash{background-color:var(--color-post-wash)}.bg-put-wash{background-color:var(--color-put-wash)}.bg-white{background-color:var(--color-white)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.p-6{padding:calc(var(--spacing) * 6)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-8{padding-block:calc(var(--spacing) * 8)}.py-12{padding-block:calc(var(--spacing) * 12)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-4{padding-top:calc(var(--spacing) * 4)}.pt-10{padding-top:calc(var(--spacing) * 10)}.pt-12{padding-top:calc(var(--spacing) * 12)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-4{padding-bottom:calc(var(--spacing) * 4)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pb-8{padding-bottom:calc(var(--spacing) * 8)}.pb-10{padding-bottom:calc(var(--spacing) * 10)}.pb-12{padding-bottom:calc(var(--spacing) * 12)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.text-pretty{text-wrap:pretty}.break-all{word-break:break-all}.text-brand-600{color:var(--color-brand-600)}.text-brand-700{color:var(--color-brand-700)}.text-danger-700{color:var(--color-danger-700)}.text-delete-ink{color:var(--color-delete-ink)}.text-get-ink{color:var(--color-get-ink)}.text-head-ink{color:var(--color-head-ink)}.text-neutral-400{color:var(--color-neutral-400)}.text-neutral-500{color:var(--color-neutral-500)}.text-neutral-600{color:var(--color-neutral-600)}.text-neutral-700{color:var(--color-neutral-700)}.text-neutral-800{color:var(--color-neutral-800)}.text-neutral-900{color:var(--color-neutral-900)}.text-options-ink{color:var(--color-options-ink)}.text-patch-ink{color:var(--color-patch-ink)}.text-post-ink{color:var(--color-post-ink)}.text-put-ink{color:var(--color-put-ink)}.text-syntax-key{color:var(--color-syntax-key)}.text-syntax-string{color:var(--color-syntax-string)}.normal-case{text-transform:none}.no-underline{text-decoration-line:none}.underline{text-decoration-line:underline}.underline-offset-2{text-underline-offset:2px}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur{--tw-backdrop-blur:blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.select-all{-webkit-user-select:all;user-select:all}.group-open\:rotate-180:is(:where(.group):is([open],:popover-open,:open) *){rotate:180deg}@media (hover:hover){.hover\:text-brand-600:hover{color:var(--color-brand-600)}.hover\:underline:hover{text-decoration-line:underline}}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.focus-visible\:ring-2:focus-visible{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus-visible\:ring-brand-500:focus-visible{--tw-ring-color:var(--color-brand-500)}@media (min-width:40rem){.sm\:-mx-6{margin-inline:calc(var(--spacing) * -6)}.sm\:inline{display:inline}.sm\:w-40{width:calc(var(--spacing) * 40)}.sm\:w-auto{width:auto}.sm\:max-w-\[10rem\]{max-width:10rem}.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-8{padding:calc(var(--spacing) * 8)}.sm\:px-5{padding-inline:calc(var(--spacing) * 5)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-8{padding-block:calc(var(--spacing) * 8)}.sm\:py-12{padding-block:calc(var(--spacing) * 12)}.sm\:pt-16{padding-top:calc(var(--spacing) * 16)}.sm\:pb-5{padding-bottom:calc(var(--spacing) * 5)}.sm\:pb-8{padding-bottom:calc(var(--spacing) * 8)}.sm\:pb-12{padding-bottom:calc(var(--spacing) * 12)}.sm\:pb-14{padding-bottom:calc(var(--spacing) * 14)}.sm\:text-right{text-align:right}.sm\:text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading,var(--text-3xl--line-height))}.sm\:text-5xl{font-size:var(--text-5xl);line-height:var(--tw-leading,var(--text-5xl--line-height))}.sm\:text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}}}@keyframes httphq-dots{0%{content:""}25%{content:"."}50%{content:".."}75%,to{content:"..."}}@media (prefers-reduced-motion:reduce){*,:before,:after{scroll-behavior:auto!important;transition-duration:.01ms!important;animation-duration:.01ms!important;animation-iteration-count:1!important}.loading-dots:after{content:"...";animation:none}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000} \ No newline at end of file diff --git a/public/endpoint.js b/public/endpoint.js index 6ef21c4..dcec574 100644 --- a/public/endpoint.js +++ b/public/endpoint.js @@ -77,11 +77,17 @@ this.search = ""; }, + // Every call is scoped to the endpoint the page is open on, so the route + // is spelled once rather than at each call site. + requestsUrl(suffix = "") { + return `/api/endpoints/${this.endpointId}/requests${suffix}`; + }, + fetchRequests() { if (!this.endpointId) return; - const url = - `/api/endpoints/${this.endpointId}/requests?search=` + - encodeURIComponent(this.search); + const url = this.requestsUrl( + `?search=${encodeURIComponent(this.search)}`, + ); return fetch(url) .then((r) => r.json()) .then((d) => { @@ -97,9 +103,7 @@ }, deleteRequests() { - return fetch(`/api/endpoints/${this.endpointId}/requests`, { - method: "DELETE", - }) + return fetch(this.requestsUrl(), { method: "DELETE" }) .then(() => { this.requests = []; this.total = 0; @@ -108,9 +112,7 @@ }, deleteRequest(uuid) { - return fetch(`/api/endpoints/${this.endpointId}/requests/${uuid}`, { - method: "DELETE", - }) + return fetch(this.requestsUrl(`/${uuid}`), { method: "DELETE" }) .then(() => { this.requests = this.requests.filter((r) => r.uuid !== uuid); this.total = Math.max(0, this.total - 1); @@ -294,6 +296,7 @@ formatTimeAgo: window.formatTimeAgo, formatClock: window.formatClock, formatBytes: window.formatBytes, + pluralize: window.pluralize, renderBody: window.renderBody, // Screen readers get no navigation on this page, so every change that a @@ -325,11 +328,10 @@ // Copies requests as a HAR-shaped document. copyHar(requests, key) { - const count = requests.length; return this._copyAndFlash( window.buildHarExport(requests), key, - `Copied ${count} ${count === 1 ? "request" : "requests"} to clipboard`, + `Copied ${window.pluralize(requests.length, "request")} to clipboard`, ); }, @@ -341,9 +343,7 @@ const count = Alpine.store("main").requests.length; this.pendingDeleteAll = false; await Alpine.store("main").deleteRequests(); - this.announce( - `Deleted ${count} ${count === 1 ? "request" : "requests"}`, - ); + this.announce(`Deleted ${window.pluralize(count, "request")}`); }, async sendCustom() { diff --git a/public/index.js b/public/index.js index f670ace..e35d324 100644 --- a/public/index.js +++ b/public/index.js @@ -60,6 +60,14 @@ window.formatClock = function (date) { return clockFormatter.format(date); }; +/* A count with its noun, pluralised by adding an s. Several surfaces state a + count in prose, and one that says "1 requests" reads as a defect in the thing + being counted rather than in the sentence. */ + +window.pluralize = function (count, noun) { + return `${count} ${count === 1 ? noun : `${noun}s`}`; +}; + /* Byte sizes for captured bodies. */ window.formatBytes = function (bytes) { diff --git a/src/application_test.go b/src/application_test.go index 36287b0..9a59fdf 100644 --- a/src/application_test.go +++ b/src/application_test.go @@ -70,3 +70,19 @@ func TestSweepRetention(t *testing.T) { assert.Equal(t, []string{"sweep-recent-live"}, uuidsFor(t.Context(), endpointID)) }) } + +// Cron's first tick is a full interval away, so a process restarting more often +// than the interval would never sweep and captures would outlive the window for +// as long as the database file does. +func TestStartRetentionSweep(t *testing.T) { + t.Run("sweeps at startup rather than waiting for the first tick", func(t *testing.T) { + endpointID := "sweep-boot" + storeCapture(t, endpointID, "sweep-boot-expired", + time.Now().Add(-retentionWindow).Add(-time.Minute)) + + scheduler := startRetentionSweep() + t.Cleanup(func() { scheduler.Stop() }) + + assert.Empty(t, uuidsFor(t.Context(), endpointID)) + }) +} diff --git a/src/harness_test.go b/src/harness_test.go index b98658f..8574c37 100644 --- a/src/harness_test.go +++ b/src/harness_test.go @@ -106,3 +106,11 @@ func bodyOf(t *testing.T, response *http.Response) string { require.NoError(t, err) return string(body) } + +// prose collapses the whitespace a template's own line breaks introduce, so an +// assertion about a rendered sentence does not depend on where the formatter +// happened to wrap it. Use it for copy; markup is matched on bodyOf. +func prose(t *testing.T, response *http.Response) string { + t.Helper() + return strings.Join(strings.Fields(bodyOf(t, response)), " ") +} diff --git a/src/pages.go b/src/pages.go index 6c5f911..6e84b55 100644 --- a/src/pages.go +++ b/src/pages.go @@ -11,6 +11,11 @@ import ( // whole site: an unfurled link is a link to httphq, whichever page it points at. const socialImagePath = "/social-card.png" +// Every surface that states how long a capture lives renders RetentionPhrase +// rather than typing a figure, so moving retentionWindow moves the promise +// wherever the pages make it. Prose that quoted its own number would go on +// quoting the old one, and a reader has no way to tell. + // pageBaseURL is the scheme+host a rendered page is being served from, used to // build the absolute URLs that canonical and Open Graph tags require. It tracks // the request rather than a configured hostname so a self-hosted deployment @@ -25,10 +30,11 @@ func pageBaseURL(c fiber.Ctx) string { func pageMeta(c fiber.Ctx, title, description, path string) fiber.Map { base := pageBaseURL(c) return fiber.Map{ - "Title": title, - "Description": description, - "Canonical": base + path, - "SocialImage": base + socialImagePath, + "Title": title, + "Description": description, + "Canonical": base + path, + "SocialImage": base + socialImagePath, + "RetentionPhrase": retentionPhrase(retentionWindow), } } @@ -53,13 +59,15 @@ func renderEndpoint(c fiber.Ctx) error { endpointID := c.Params("endpoint") endpointURL, websocketURL, apiURL := endpointURLs( c.Scheme(), string(c.Request().Host()), endpointID) + retention := retentionPhrase(retentionWindow) return c.Render("endpoint", fiber.Map{ "Title": endpointID + " | httphq", - "Description": "Live capture stream for " + endpointID + ". Requests sent to this endpoint appear here in real time and are deleted after 4 hours.", + "Description": "Live capture stream for " + endpointID + ". Requests sent to this endpoint appear here in real time and are deleted after " + retention + ".", "AppScripts": true, "EndpointID": endpointID, "EndpointURL": endpointURL, "EndpointWebSocketURL": websocketURL, + "RetentionPhrase": retention, // The page drops captures from its own list once they age out, so it // needs the window as a number rather than as the prose it renders. "RetentionSeconds": int(retentionWindow.Seconds()), diff --git a/src/pages_test.go b/src/pages_test.go index 9e349fe..a7bf409 100644 --- a/src/pages_test.go +++ b/src/pages_test.go @@ -24,7 +24,7 @@ func TestRenderIndex(t *testing.T) { // traffic at a public URL, so it is rendered from the constant the sweep // uses rather than typed into the copy. t.Run("states the retention window", func(t *testing.T) { - assert.Contains(t, bodyOf(t, get(t, "/")), "deleted after 4 hours") + assert.Contains(t, prose(t, get(t, "/")), "deleted after 4 hours") }) } @@ -53,10 +53,10 @@ func TestRenderEndpoint(t *testing.T) { // a number. Rendering it from the same constant the sweep uses is what keeps // the two from drifting. t.Run("carries the retention window as a number and as prose", func(t *testing.T) { - body := bodyOf(t, get(t, "/"+endpointID(t))) + id := endpointID(t) - assert.Contains(t, body, `data-retention-seconds="14400"`) - assert.Contains(t, body, "deleted after 4 hours") + assert.Contains(t, bodyOf(t, get(t, "/"+id)), `data-retention-seconds="14400"`) + assert.Contains(t, prose(t, get(t, "/"+id)), "deleted after 4 hours") }) // The prompt is built from the request, so a self-hosted deployment hands diff --git a/src/styles/components.css b/src/styles/components.css index 6f062dc..69ac569 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -109,6 +109,24 @@ @apply rounded-lg border border-neutral-200 bg-white; } + /* Disclosure panel: a `details` wearing `.panel`, whose `summary` is the row + that opens it and whose body appears below a seam. The two halves are one + component, so they are spelled once here rather than at each call site. + + `list-none` removes the native marker, which the panel draws its own + chevron in place of. The open state squares off the bottom corners so the + seam below meets the panel edge rather than crossing a radius. */ + .panel-summary { + @apply list-none flex items-center gap-2 px-4 sm:px-5 py-3; + @apply font-medium text-neutral-700 hover:bg-neutral-100; + @apply rounded-lg group-open:rounded-b-none; + @apply focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-inset; + } + + .panel-body { + @apply border-t border-neutral-200 p-4 sm:p-5; + } + /* Key/value row. Stacks below the breakpoint so a value is never squeezed into what is left of a fixed label column on a phone. */ .kv-row { diff --git a/src/views/endpoint.html b/src/views/endpoint.html index a43ba3d..13e1e93 100644 --- a/src/views/endpoint.html +++ b/src/views/endpoint.html @@ -35,17 +35,7 @@

@click="copy($refs.endpointUrl.textContent, 'url')" class="btn btn-secondary shrink-0" > - + {{template "partials/icons/copy" .}}

Anyone with this URL can read every request sent to it. Requests are - deleted after 4 hours. + deleted after {{.RetentionPhrase}}.

@@ -63,40 +53,15 @@

- - + + {{template "partials/icons/send" .}}

Send a test request

- + {{template "partials/icons/disclosure-chevron" .}}
-
+

title="Copy every request shown as HAR-shaped JSON" class="btn btn-secondary" > - + {{template "partials/icons/clipboard" .}} @@ -365,7 +285,7 @@

Connect an agent

>

@@ -501,19 +421,7 @@

Connect an agent

title="Copy the full request (method, URL, headers, query string, body) as HAR-shaped JSON" class="btn-inline" > - + {{template "partials/icons/clipboard" .}}

- No account, no email, free forever. Requests are deleted after 4 hours, - and anyone with the URL can read them. + No account, no email, free forever. Requests are deleted after + {{.RetentionPhrase}}, and anyone with the URL can read them.

diff --git a/src/views/partials/icons/clipboard.html b/src/views/partials/icons/clipboard.html new file mode 100644 index 0000000..15eded4 --- /dev/null +++ b/src/views/partials/icons/clipboard.html @@ -0,0 +1,15 @@ + + diff --git a/src/views/partials/icons/copy.html b/src/views/partials/icons/copy.html new file mode 100644 index 0000000..a5c9429 --- /dev/null +++ b/src/views/partials/icons/copy.html @@ -0,0 +1,13 @@ + + diff --git a/src/views/partials/icons/disclosure-chevron.html b/src/views/partials/icons/disclosure-chevron.html new file mode 100644 index 0000000..ffe8735 --- /dev/null +++ b/src/views/partials/icons/disclosure-chevron.html @@ -0,0 +1,14 @@ + + diff --git a/src/views/partials/icons/send.html b/src/views/partials/icons/send.html new file mode 100644 index 0000000..246737c --- /dev/null +++ b/src/views/partials/icons/send.html @@ -0,0 +1,13 @@ + + From 000e8dc937b09ff336ff75d93c945f2261be0bf2 Mon Sep 17 00:00:00 2001 From: botre Date: Sun, 9 Aug 2026 11:43:41 +0200 Subject: [PATCH 3/3] Correct the docs the last changes outran The API doc still promised an unconditional 200, PRODUCT.md still described a listing that only ever ordered newest first, AGENTS.md's file inventory had no entry for the agent prompt, and a comment pointed at the file the header flattening used to live in. Claude-Session: https://claude.ai/code/session_017NsCXwruqBye5cySvFEFiB --- AGENTS.md | 12 +++++++++--- PRODUCT.md | 8 +++++++- docs/api.md | 6 +++--- public/render-body.js | 6 +++--- 4 files changed, 22 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 45d6cbb..08d8022 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,9 +8,15 @@ Guidance for agents and contributors working in this repository. tests beside it: `application.go` (wiring and entry point), `platform.go` (client IP and header stripping), `endpoint.go` (endpoint IDs and URLs), `capture.go` (the capture handler), `api.go` (the JSON API), `pages.go` (page -rendering), `assets.go` (content-hashed asset URLs), `security.go` (CSP and -security headers), `sockets.go` (the live feed), `requestlog.go` (correlation -IDs and the access log). +rendering), `agent.go` (the prompt an endpoint page hands to a coding agent), +`assets.go` (content-hashed asset URLs), `security.go` (CSP and security +headers), `sockets.go` (the live feed), `requestlog.go` (correlation IDs and +the access log). + +A test file covers one subject, is named after it, and names each suite after +what it exercises, so a handler's tests are found beside the handler. The one +exception is `harness_test.go`, which is not a subject: it holds `TestMain` and +the fixtures shared by every test that drives a real request. `newApplication` builds the entire routing surface from arguments, so tests drive real requests through it without a listening socket. Anything that pulls diff --git a/PRODUCT.md b/PRODUCT.md index bc1cfdb..592bc2c 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -87,6 +87,10 @@ Confirmed functionality: body). - Content-type-aware body rendering: pretty-printed and highlighted JSON, multipart/form-data part list, XML highlighting, escaped raw text otherwise. +- Poll the JSON listing with a cursor: echo the response's `cursor` back as + `?since=` and each capture is handed over exactly once. The endpoint page + carries a ready-made prompt that hands a coding agent this endpoint's URLs + and that loop. - Pages: home (`/`), endpoint (`/`), contact (`/contact`). `/api/health` and `/api/debug` exist for operations, not for users. @@ -97,7 +101,9 @@ Technical constraints: - Storage is SQLite on the container's writable layer. Capture history is lost on restart, by design. No durable store, no migration path. - Request body limit is 1 MiB. -- The request list returns at most 128 requests, newest first. +- The request list returns at most 128 requests: newest first when asked + without a cursor, oldest first when asked with one, so a poller drains a + burst in order. - Rate limit is 150 requests per minute per client IP in production, bucketed on the platform-resolved IP. - Client IP resolution is a trust decision driven by the `PLATFORM` env var; diff --git a/docs/api.md b/docs/api.md index 1447753..c2691e8 100644 --- a/docs/api.md +++ b/docs/api.md @@ -21,9 +21,9 @@ characters. Anything else is a 404. ## Capturing -Send anything at all to `/to/:endpoint`. Every method is accepted, the response -is always `200`, and the capture's UUID comes back on the -`Httphq-Request-Uuid` header. +Send anything at all to `/to/:endpoint`. Every method is accepted, anything +within the body limit below answers `200`, and the capture's UUID comes back on +the `Httphq-Request-Uuid` header. ```bash curl -X POST -d '{"hello":"world"}' https://httphq.com/to/purple-frog-0691 diff --git a/public/render-body.js b/public/render-body.js index 7cd82f7..c378d50 100644 --- a/public/render-body.js +++ b/public/render-body.js @@ -32,9 +32,9 @@ function highlightPrettyJSON(value) { } /* Case-insensitive lookup into a headers object whose values are either a - scalar string or a string[] (see the flattening in application.go). Exposed - as window.headerValue because that scalar-or-array contract is shared by - every consumer of a captured request, not just body rendering. */ + scalar string or a string[] (see flattenHeaders in capture.go). Exposed as + window.headerValue because that scalar-or-array contract is shared by every + consumer of a captured request, not just body rendering. */ function headerValue(headers, name) { if (!headers) return undefined; const key = Object.keys(headers).find(