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/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/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/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/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( 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..9a59fdf 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" @@ -52,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/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..8574c37 --- /dev/null +++ b/src/harness_test.go @@ -0,0 +1,116 @@ +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) +} + +// 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 new file mode 100644 index 0000000..a7bf409 --- /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, prose(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) { + id := endpointID(t) + + 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 + // 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 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 @@ + +