From 645e3f7972f59d36a8bc8a16fa91ce60ab303593 Mon Sep 17 00:00:00 2001 From: botre Date: Sun, 9 Aug 2026 09:59:52 +0200 Subject: [PATCH] Let an agent poll an endpoint with a cursor The JSON API already returned everything an agent needs, but nothing pointed at it, every call returned the newest 128 captures, and the safe polling rate was unstated. Watching a webhook from a coding agent meant hand-writing the integration. The listing now takes an optional `since` and answers with a `cursor` and `hasMore`. Echoing the cursor back is a promise that no capture is handed over twice and none is skipped. Omitting it changes nothing, so the page is untouched. The ordering flips with the cursor, which is a correctness rule rather than a preference. Under DESC with a full page, a burst larger than the limit returns its newest captures, the caller's cursor advances past the rest, and the backlog is never served. ASC hands out the oldest unseen page and the next call resumes where it ended. CreatedAt is now stamped in UTC. SQLite has no date type and this column is compared and ordered as text, so a row carrying a +02:00 offset was ranked against a UTC cursor by its digits rather than its instant, off by the whole offset. Containers run UTC, so this was invisible in production and in CI while being wrong on any developer machine that is not. It also closes a latent misordering across a DST fall-back. The endpoint page carries a prompt teaching the loop, built from the request so a self-hosted instance points at itself, and quoting the rate limit and retention window from the constants that enforce them rather than restating them. The rate limit goes to 150 so a 2 second poll leaves four fifths of the budget for everything else. Claude-Session: https://claude.ai/code/session_01XkbFE6pgcxRfsMAvnwyuqS --- PRODUCT.md | 2 +- README.md | 2 +- docs/api.md | 96 ++++++++++++++++ e2e/tests/endpoint-screen.spec.ts | 60 ++++++++++ e2e/tests/home-screen.spec.ts | 1 + public/app.css | 2 +- src/agent.go | 64 +++++++++++ src/agent_test.go | 74 ++++++++++++ src/api.go | 68 ++++++++++- src/application.go | 4 +- src/application_test.go | 2 +- src/database/database.go | 46 +++++++- src/database/database_test.go | 180 +++++++++++++++++++++++++++--- src/endpoint.go | 18 ++- src/endpoint_test.go | 16 ++- src/pages.go | 7 +- src/routes_test.go | 106 +++++++++++++++++- src/views/endpoint.html | 80 +++++++++++++ src/views/index.html | 27 ++++- 19 files changed, 805 insertions(+), 50 deletions(-) create mode 100644 docs/api.md create mode 100644 src/agent.go create mode 100644 src/agent_test.go diff --git a/PRODUCT.md b/PRODUCT.md index 1755223..bc1cfdb 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -98,7 +98,7 @@ Technical constraints: 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. -- Rate limit is 125 requests per minute per client IP in production, bucketed +- 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; setting it trusts that platform's header unconditionally. diff --git a/README.md b/README.md index 9c3faff..9dede10 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ ## Docs -[Scripts](docs/scripts.md) +[API](docs/api.md) · [Scripts](docs/scripts.md) ## Configuration diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..1447753 --- /dev/null +++ b/docs/api.md @@ -0,0 +1,96 @@ +# API + +httphq's JSON API needs no account, no key and no create step. An endpoint +exists as soon as something addresses it, so the only thing a caller needs is an +endpoint ID. + +Every URL below is relative to the host serving the page. A self-hosted +deployment answers on its own host. + +## Routes + +| Method | Path | Purpose | +| -------- | -------------------------------------------- | ----------------------------- | +| `ANY` | `/to/:endpoint` | Capture a request | +| `GET` | `/api/endpoints/:endpoint/requests` | List captures | +| `DELETE` | `/api/endpoints/:endpoint/requests` | Delete an endpoint's captures | +| `DELETE` | `/api/endpoints/:endpoint/requests/:request` | Delete one capture by UUID | + +An endpoint ID is lowercase words and digits joined by hyphens, up to 64 +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. + +```bash +curl -X POST -d '{"hello":"world"}' https://httphq.com/to/purple-frog-0691 +``` + +## Listing + +``` +GET /api/endpoints/:endpoint/requests?search=&since= +``` + +| Parameter | Notes | +| --------- | ----------------------------------------------------------------- | +| `search` | Optional. Substring match across headers, query string and body. | +| `since` | Optional RFC 3339 timestamp. Returns only captures newer than it. | + + +```jsonc +{ + "requests": [ /* newest 128, or the oldest 128 newer than `since` */ ], + "total": 42, // the endpoint's, ignoring search and since + "cursor": "2026-08-09T07:20:05.559121660Z", + "hasMore": false +} +``` + +`total` is what the endpoint holds regardless of `search`, `since` or the page +size, so a caller can say what a delete-all would affect. + +A malformed `since` is a `400` rather than a silently ignored parameter: a +caller that mistyped one would otherwise be handed its whole history back and +have no way to tell. + +## Polling with the cursor + +Echo `cursor` back as `since` and you are handed each capture exactly once. + +1. Call the listing with no `since`. Read `requests` and `cursor`. +2. Call again with `?since=` from the previous response. +3. If `hasMore` is true, call again immediately: a burst is still draining. + Otherwise wait 2 seconds. + +```bash +curl -s "https://httphq.com/api/endpoints/purple-frog-0691/requests?since=2026-08-09T07:20:05Z" +``` + +Three things are worth knowing: + +- **The cursor is opaque.** It is a timestamp today. Echo it back rather than + parsing one or building your own, or a change of format will break you. +- **It is server time.** Substituting your own clock reintroduces the skew the + cursor exists to remove. +- **With `since`, captures come back oldest first**, and without it, newest + first. A cursored caller is draining a stream in order; an uncursored one is + looking at the latest activity. + +An endpoint with no traffic still returns a cursor, so a poller that starts +before the first request has something to advance from. + +## Limits + +- **128 captures** per listing response. +- **1 MiB** request body. Larger captures are rejected. +- **150 requests per minute per client IP** in production, across everything: + page loads, captures and API calls share one budget. The recommended + 2 second poll is 30 a minute, which leaves room for the traffic under test. +- **4 hour retention.** Captures are deleted after that, and on restart. + +Anyone holding an endpoint's URL can read everything sent to it. Nothing secret +should go through one. diff --git a/e2e/tests/endpoint-screen.spec.ts b/e2e/tests/endpoint-screen.spec.ts index 855a72c..e0dd763 100644 --- a/e2e/tests/endpoint-screen.spec.ts +++ b/e2e/tests/endpoint-screen.spec.ts @@ -653,6 +653,66 @@ test.describe("Endpoint screen", () => { }); }); + test.describe("Connecting an agent", () => { + test("the panel is collapsed until it is opened", async ({ page }) => { + await expect(page.locator('[data-test="agent-prompt"]')).toBeHidden(); + + await page.locator('[data-test="agent-toggle"]').click(); + + await expect(page.locator('[data-test="agent-prompt"]')).toBeVisible(); + }); + + // 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 }) => { + await page.locator('[data-test="agent-toggle"]').click(); + + const prompt = page.locator('[data-test="agent-prompt"]'); + await expect(prompt).toContainText(endpointUrl); + await expect(prompt).toContainText( + `/api/endpoints/${endpointId}/requests`, + ); + }); + + test("the prompt states the cursor loop and the poll interval", async ({ + page, + }) => { + await page.locator('[data-test="agent-toggle"]').click(); + + const prompt = page.locator('[data-test="agent-prompt"]'); + await expect(prompt).toContainText("?since="); + await expect(prompt).toContainText("hasMore"); + await expect(prompt).toContainText("2 seconds"); + }); + + // Copied verbatim into another tool, so stray edge whitespace from the + // template would travel with it. + test("the copy button writes the prompt with no stray whitespace", async ({ + page, + }) => { + await page.locator('[data-test="agent-toggle"]').click(); + const shown = await page + .locator('[data-test="agent-prompt"]') + .textContent(); + + await page.locator('[data-test="copy-agent-prompt"]').click(); + + const copied = await readClipboard(page); + expect(copied).toBe(shown); + expect(copied).toBe(copied.trim()); + expect(copied).toContain(endpointUrl); + }); + + test("the copy button label flips to Copied!", async ({ page }) => { + await page.locator('[data-test="agent-toggle"]').click(); + await page.locator('[data-test="copy-agent-prompt"]').click(); + + await expect( + page.locator('[data-test="copy-agent-prompt-label"]'), + ).toContainText("Copied!"); + }); + }); + test.describe("Sending a test request", () => { test("submitting the panel produces a captured request", async ({ page, diff --git a/e2e/tests/home-screen.spec.ts b/e2e/tests/home-screen.spec.ts index 14ed7b8..fbad809 100644 --- a/e2e/tests/home-screen.spec.ts +++ b/e2e/tests/home-screen.spec.ts @@ -60,6 +60,7 @@ test.describe("Home screen", () => { await expect(section).toBeVisible(); await expect(section).toContainText("Test webhooks"); await expect(section).toContainText("Inspect payloads"); + await expect(section).toContainText("Debug with an agent"); }); test("the example capture shows a rendered request", async ({ page }) => { diff --git a/public/app.css b/public/app.css index 64fbc9c..029503a 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}.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}.items-start{align-items:flex-start}.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-3{grid-template-columns:repeat(3,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)}.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}.items-start{align-items:flex-start}.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-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * 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 diff --git a/src/agent.go b/src/agent.go new file mode 100644 index 0000000..9d08c31 --- /dev/null +++ b/src/agent.go @@ -0,0 +1,64 @@ +package main + +import ( + "fmt" + "time" +) + +// agentPromptTemplate is the text an endpoint page offers for pasting into a +// coding agent. It is a prompt rather than documentation: it is read by +// something that will act on it, so it states the loop, the interval and the +// stopping condition rather than describing the API. +// +// Every figure in it is substituted, never typed. A prompt that restated the +// rate limit or the retention window in prose would keep quoting the old number +// after the constant behind it moved, and the reader has no way to tell. +const agentPromptTemplate = `You are debugging HTTP requests with httphq. + +Capture URL: %[1]s +Read captures: %[2]s + +Anything sent to the capture URL (a webhook provider, a form action, a script, +your own code) is stored and readable as JSON from the read URL. + +To watch requests as they arrive: + +1. Call GET %[2]s and read ` + "`requests`" + ` and ` + "`cursor`" + ` from the response. +2. On every call after the first, send the previous response's cursor back as + ?since=. You will only be given captures you have not seen. +3. If ` + "`hasMore`" + ` is true, call again immediately: a burst is still draining. + Otherwise wait 2 seconds before the next call. +4. Keep polling until you have what you need, then stop. + +Example: + curl -s "%[2]s?since=2026-08-09T07:20:05Z" + +Each capture carries method, path, query string, headers, body, client IP and +timestamp. With ?since they come back oldest first. + +Do not poll faster than every 2 seconds. httphq allows %[3]d requests per minute +per IP and that budget is shared with everything else you and this browser send +it. + +Captures are deleted after %[4]s and anyone with the URL can read them, so do not +send anything secret through this endpoint.` + +// agentPrompt builds that text for one endpoint. +func agentPrompt(endpointURL, apiURL string, requestsPerMinute int, retention time.Duration) string { + return fmt.Sprintf(agentPromptTemplate, + endpointURL, apiURL, requestsPerMinute, retentionPhrase(retention)) +} + +// retentionPhrase renders the window for prose, in whichever unit divides it +// evenly. It exists because Go's own duration formatting would offer a reader +// "4h0m0s". +func retentionPhrase(d time.Duration) string { + unit, count := "hour", int(d.Hours()) + if count < 1 || d%time.Hour != 0 { + unit, count = "minute", int(d.Minutes()) + } + if count == 1 { + return "1 " + unit + } + return fmt.Sprintf("%d %ss", count, unit) +} diff --git a/src/agent_test.go b/src/agent_test.go new file mode 100644 index 0000000..ce51a5d --- /dev/null +++ b/src/agent_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestAgentPrompt(t *testing.T) { + const ( + endpointURL = "https://example.com/to/purple-frog-0691" + apiURL = "https://example.com/api/endpoints/purple-frog-0691/requests" + ) + prompt := agentPrompt(endpointURL, apiURL, 150, 4*time.Hour) + + t.Run("carries both of the endpoint's URLs", func(t *testing.T) { + assert.Contains(t, prompt, endpointURL) + assert.Contains(t, prompt, apiURL) + }) + + // The prompt is read by something that will act on it, so the loop has to + // be stated rather than implied. + t.Run("states the cursor loop", func(t *testing.T) { + assert.Contains(t, prompt, "?since=") + assert.Contains(t, prompt, "cursor") + assert.Contains(t, prompt, "hasMore") + }) + + // A prompt that restated these in prose would keep quoting the old number + // after the constant behind it moved. + t.Run("quotes the limits it was given", func(t *testing.T) { + assert.Contains(t, prompt, "150 requests per minute") + assert.Contains(t, prompt, "deleted after 4 hours") + }) + + t.Run("tracks the figures it is given rather than restating fixed ones", func(t *testing.T) { + other := agentPrompt(endpointURL, apiURL, 60, 30*time.Minute) + + assert.Contains(t, other, "60 requests per minute") + assert.Contains(t, other, "deleted after 30 minutes") + assert.NotContains(t, other, "150") + assert.NotContains(t, other, "4 hours") + }) + + // A self-hosted deployment has to produce a prompt pointing at itself. + t.Run("names no hardcoded host", func(t *testing.T) { + assert.NotContains(t, prompt, "httphq.com") + }) + + // The whole block is copied verbatim into another tool, so stray edge + // whitespace travels with it. + t.Run("has no leading or trailing whitespace", func(t *testing.T) { + assert.Equal(t, strings.TrimSpace(prompt), prompt) + }) +} + +func TestRetentionPhrase(t *testing.T) { + for _, testCase := range []struct { + in time.Duration + want string + }{ + {4 * time.Hour, "4 hours"}, + {time.Hour, "1 hour"}, + {90 * time.Minute, "90 minutes"}, + {30 * time.Minute, "30 minutes"}, + {time.Minute, "1 minute"}, + } { + t.Run(testCase.want, func(t *testing.T) { + assert.Equal(t, testCase.want, retentionPhrase(testCase.in)) + }) + } +} diff --git a/src/api.go b/src/api.go index 268ee06..9143d50 100644 --- a/src/api.go +++ b/src/api.go @@ -2,6 +2,7 @@ package main import ( "net/http" + "time" "github.com/gofiber/fiber/v3" @@ -30,16 +31,71 @@ func handleDebug(registry *socketRegistry) fiber.Handler { } } +// newestCreatedAt is the latest capture time in a page. It scans rather than +// reading an end of the slice: the listing orders newest-first or oldest-first +// depending on whether it was given a cursor, so neither end is reliably the +// newest. +func newestCreatedAt(requests []database.Request) time.Time { + newest := requests[0].CreatedAt + for _, request := range requests[1:] { + if request.CreatedAt.After(newest) { + newest = request.CreatedAt + } + } + return newest +} + // handleListRequests returns a window of an endpoint's captures, narrowed by -// the search. `total` is what the endpoint holds regardless of search or -// window, so the page can say what a control acting on the whole endpoint will -// affect rather than reporting the size of the current view. +// the search and by an optional `since` cursor. `total` is what the endpoint +// holds regardless of search, cursor or window, so the page can say what a +// control acting on the whole endpoint will affect rather than reporting the +// size of the current view. +// +// `cursor` is what makes the listing usable by a poller: echoing it back is a +// promise that no capture is handed over twice and none is skipped. It is +// opaque on purpose. It happens to be a timestamp, but a caller that parses or +// synthesises one instead of echoing it can be broken by a change of format. func handleListRequests(c fiber.Ctx) error { endpointID := c.Params("endpoint") + + // Read before the query. A capture landing while the query runs then falls + // inside the next cursor window rather than into the gap between the two. + queryStart := time.Now().UTC() + + var since time.Time + if raw := c.Query("since"); raw != "" { + parsed, err := time.Parse(time.RFC3339Nano, raw) + if err != nil { + // Rejected rather than ignored. Falling back to the full window + // would make a poller reprocess its whole history while looking + // like a successful call. + return c.Status(http.StatusBadRequest). + JSON(fiber.Map{"error": "since must be an RFC 3339 timestamp"}) + } + since = parsed.UTC() + } + + requests := database.GetRequestsForEndpointID( + c.Context(), endpointID, c.Query("search"), since, requestPageSize) + + // An empty page still advances the cursor to the start of this query, so a + // poller that begins before any traffic arrives does not re-ask for the + // same empty window forever. + cursor := queryStart + if len(requests) > 0 { + cursor = newestCreatedAt(requests) + } + if cursor.Before(since) { + cursor = since + } + return c.JSON(fiber.Map{ - "requests": database.GetRequestsForEndpointID( - c.Context(), endpointID, c.Query("search"), requestPageSize), - "total": database.CountRequestsForEndpointID(c.Context(), endpointID), + "requests": requests, + "total": database.CountRequestsForEndpointID(c.Context(), endpointID), + "cursor": cursor.Format(time.RFC3339Nano), + // A full page means a burst is still draining, so the caller should + // come straight back rather than sleeping through the backlog. + "hasMore": len(requests) == requestPageSize, }) } diff --git a/src/application.go b/src/application.go index 3aa810e..e8a6415 100644 --- a/src/application.go +++ b/src/application.go @@ -31,8 +31,10 @@ const ( // Development runs effectively unlimited so a page under test is never // throttled; production bounds one client's share of a shared instance. + // The production figure is quoted to pollers in the agent prompt, so it is + // read from here rather than restated: see agentPrompt. developmentRequestsPerMinute = 9999 - productionRequestsPerMinute = 125 + productionRequestsPerMinute = 150 ) // applicationConfig locates the files the app serves and the socket registry it diff --git a/src/application_test.go b/src/application_test.go index 438a6ff..9c11a2b 100644 --- a/src/application_test.go +++ b/src/application_test.go @@ -25,7 +25,7 @@ func storeCapture(t *testing.T, endpointID, uuid string, createdAt time.Time) { func uuidsFor(ctx context.Context, endpointID string) []string { var uuids []string - for _, request := range database.GetRequestsForEndpointID(ctx, endpointID, "", 10) { + for _, request := range database.GetRequestsForEndpointID(ctx, endpointID, "", time.Time{}, 10) { uuids = append(uuids, request.UUID) } return uuids diff --git a/src/database/database.go b/src/database/database.go index 3700374..c8c90d1 100644 --- a/src/database/database.go +++ b/src/database/database.go @@ -72,22 +72,54 @@ func CountRequestsForEndpointID(ctx context.Context, endpointID string) int64 { return count } -func GetRequestsForEndpointID(ctx context.Context, endpointID string, search string, limit int) []Request { +// GetRequestsForEndpointID returns a window of an endpoint's captures. A zero +// `since` means no cursor. +// +// The ordering flips with the cursor, and that is a correctness rule rather +// than a preference. A cursored caller reads a stream it intends to consume +// whole, so it has to be handed the OLDEST unseen page: with DESC and a full +// page, a burst larger than the limit returns the newest captures, the caller's +// cursor advances past the rest, and nothing ever goes back for them. ASC hands +// out the backlog in order and the next call resumes where this page ended. +func GetRequestsForEndpointID( + ctx context.Context, endpointID string, search string, since time.Time, limit int, +) []Request { var items []Request - result := DB. + query := DB. Where(&Request{EndpointID: endpointID}). Where("(? = '' OR (headers LIKE ? OR query_string LIKE ? OR body LIKE ?))", search, "%"+search+"%", "%"+search+"%", "%"+search+"%"). - Limit(limit). - Order("created_at DESC"). - Find(&items) + Limit(limit) + + if since.IsZero() { + query = query.Order("created_at DESC") + } else { + // UTC to match how CreateRequest stores the column. A bound value keeps + // whatever zone it arrives in, and the comparison is textual. + query = query.Where("created_at > ?", since.UTC()).Order("created_at ASC") + } + + result := query.Find(&items) if result.Error != nil { slog.ErrorContext(ctx, "get requests failed", "err", result.Error, "endpoint_id", endpointID) } return items } +// CreateRequest stores a capture, stamping it if the caller did not. +// +// CreatedAt is forced to UTC because SQLite has no date type and this column is +// compared and ordered as text. A row written with a +02:00 offset is ranked +// against one written with +00:00 by their digits rather than their instants, +// so a single row in the wrong zone breaks both `created_at > ?` and +// `ORDER BY created_at` for every row around it. Normalising on the way in is +// what lets the rest of this file treat those clauses as chronological. func CreateRequest(ctx context.Context, request *Request) { + if request.CreatedAt.IsZero() { + request.CreatedAt = time.Now() + } + request.CreatedAt = request.CreatedAt.UTC() + result := DB.Create(&request) if result.Error != nil { slog.ErrorContext(ctx, "create request failed", "err", result.Error) @@ -109,7 +141,9 @@ func DeleteRequestForUUID(ctx context.Context, UUID string) { } func DeleteOldRequests(ctx context.Context, threshold time.Time) { - result := DB.Where("created_at < ?", threshold).Delete(&Request{}) + // UTC for the same reason as the cursor in GetRequestsForEndpointID: the + // comparison is textual, so both sides have to agree on a zone. + result := DB.Where("created_at < ?", threshold.UTC()).Delete(&Request{}) if result.Error != nil { slog.ErrorContext(ctx, "delete old requests failed", "err", result.Error) } diff --git a/src/database/database_test.go b/src/database/database_test.go index 6b052d2..13bbe96 100644 --- a/src/database/database_test.go +++ b/src/database/database_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "gorm.io/datatypes" "httphq/src/database" @@ -114,7 +115,7 @@ func TestGetRequestsForEndpointID(t *testing.T) { freshDB(t) assert.Equal(t, []database.Request{}, - database.GetRequestsForEndpointID(t.Context(), "test-id", "", 32)) + database.GetRequestsForEndpointID(t.Context(), "test-id", "", time.Time{}, 32)) }) t.Run("round-trips every stored field", func(t *testing.T) { @@ -125,7 +126,7 @@ func TestGetRequestsForEndpointID(t *testing.T) { withHeaders(`{ "Test": "Test-Header-1" }`), withQueryString("a=1")) - items := database.GetRequestsForEndpointID(t.Context(), stored.EndpointID, "", 1) + items := database.GetRequestsForEndpointID(t.Context(), stored.EndpointID, "", time.Time{}, 1) assert.Len(t, items, 1) assert.Equal(t, stored.UUID, items[0].UUID) @@ -136,7 +137,7 @@ func TestGetRequestsForEndpointID(t *testing.T) { assert.Equal(t, stored.QueryString, items[0].QueryString) assert.Equal(t, stored.Body, items[0].Body) assert.Equal(t, stored.Headers, items[0].Headers) - assert.Equal(t, time.Now().Format(time.ANSIC), items[0].CreatedAt.Format(time.ANSIC)) + assert.Equal(t, time.Now().UTC().Format(time.ANSIC), items[0].CreatedAt.UTC().Format(time.ANSIC)) }) t.Run("returns only the requested endpoint's requests", func(t *testing.T) { @@ -146,7 +147,7 @@ func TestGetRequestsForEndpointID(t *testing.T) { storeRequest(t.Context(), "uuid-2", withEndpointID("wanted")) storeRequest(t.Context(), "uuid-3", withEndpointID("other")) - assert.Len(t, database.GetRequestsForEndpointID(t.Context(), "wanted", "", 32), 2) + assert.Len(t, database.GetRequestsForEndpointID(t.Context(), "wanted", "", time.Time{}, 32), 2) }) t.Run("orders newest first", func(t *testing.T) { @@ -155,7 +156,7 @@ func TestGetRequestsForEndpointID(t *testing.T) { storeRequest(t.Context(), "uuid-1", withBody("older")) storeRequest(t.Context(), "uuid-2", withBody("newer")) - items := database.GetRequestsForEndpointID(t.Context(), "test-id", "", 32) + items := database.GetRequestsForEndpointID(t.Context(), "test-id", "", time.Time{}, 32) assert.Equal(t, "newer", items[0].Body) assert.Equal(t, "older", items[1].Body) @@ -167,7 +168,7 @@ func TestGetRequestsForEndpointID(t *testing.T) { storeRequest(t.Context(), "uuid-1") storeRequest(t.Context(), "uuid-2") - assert.Len(t, database.GetRequestsForEndpointID(t.Context(), "test-id", "", 1), 1) + assert.Len(t, database.GetRequestsForEndpointID(t.Context(), "test-id", "", time.Time{}, 1), 1) }) t.Run("an empty search applies no filtering", func(t *testing.T) { @@ -176,7 +177,7 @@ func TestGetRequestsForEndpointID(t *testing.T) { storeRequest(t.Context(), "uuid-1", withBody("alpha")) storeRequest(t.Context(), "uuid-2", withBody("beta")) - assert.Len(t, database.GetRequestsForEndpointID(t.Context(), "test-id", "", 32), 2) + assert.Len(t, database.GetRequestsForEndpointID(t.Context(), "test-id", "", time.Time{}, 32), 2) }) t.Run("searches the body", func(t *testing.T) { @@ -185,8 +186,8 @@ func TestGetRequestsForEndpointID(t *testing.T) { storeRequest(t.Context(), "uuid-1", withBody("test-body-1")) storeRequest(t.Context(), "uuid-2", withBody("test-body-2")) - assert.Len(t, database.GetRequestsForEndpointID(t.Context(), "test-id", "test-body", 32), 2) - assert.Len(t, database.GetRequestsForEndpointID(t.Context(), "test-id", "test-body-1", 32), 1) + assert.Len(t, database.GetRequestsForEndpointID(t.Context(), "test-id", "test-body", time.Time{}, 32), 2) + assert.Len(t, database.GetRequestsForEndpointID(t.Context(), "test-id", "test-body-1", time.Time{}, 32), 1) }) t.Run("searches the headers", func(t *testing.T) { @@ -195,8 +196,8 @@ func TestGetRequestsForEndpointID(t *testing.T) { storeRequest(t.Context(), "uuid-1", withHeaders(`{ "Test": "Test-Header-1" }`)) storeRequest(t.Context(), "uuid-2", withHeaders(`{ "Test": "Test-Header-2" }`)) - assert.Len(t, database.GetRequestsForEndpointID(t.Context(), "test-id", "Test-Header", 32), 2) - assert.Len(t, database.GetRequestsForEndpointID(t.Context(), "test-id", "Test-Header-1", 32), 1) + assert.Len(t, database.GetRequestsForEndpointID(t.Context(), "test-id", "Test-Header", time.Time{}, 32), 2) + assert.Len(t, database.GetRequestsForEndpointID(t.Context(), "test-id", "Test-Header-1", time.Time{}, 32), 1) }) t.Run("searches the query string", func(t *testing.T) { @@ -205,8 +206,8 @@ func TestGetRequestsForEndpointID(t *testing.T) { storeRequest(t.Context(), "uuid-1", withQueryString("event=charge.succeeded")) storeRequest(t.Context(), "uuid-2", withQueryString("event=charge.failed")) - assert.Len(t, database.GetRequestsForEndpointID(t.Context(), "test-id", "event=charge", 32), 2) - assert.Len(t, database.GetRequestsForEndpointID(t.Context(), "test-id", "charge.failed", 32), 1) + assert.Len(t, database.GetRequestsForEndpointID(t.Context(), "test-id", "event=charge", time.Time{}, 32), 2) + assert.Len(t, database.GetRequestsForEndpointID(t.Context(), "test-id", "charge.failed", time.Time{}, 32), 1) }) t.Run("a search that matches nothing returns an empty slice", func(t *testing.T) { @@ -214,7 +215,132 @@ func TestGetRequestsForEndpointID(t *testing.T) { storeRequest(t.Context(), "uuid-1", withBody("alpha")) - assert.Empty(t, database.GetRequestsForEndpointID(t.Context(), "test-id", "no-such-term", 32)) + assert.Empty(t, database.GetRequestsForEndpointID(t.Context(), "test-id", "no-such-term", time.Time{}, 32)) + }) +} + +// uuidsOf names the captures in a page, in the order they came back. Ordering +// is half of what the cursor guarantees, so the assertions have to see it. +func uuidsOf(requests []database.Request) []string { + uuids := make([]string, 0, len(requests)) + for _, request := range requests { + uuids = append(uuids, request.UUID) + } + return uuids +} + +func TestGetRequestsForEndpointIDSince(t *testing.T) { + // A fixed base keeps the ordering assertions readable and keeps the rows + // clear of time.Now(), so nothing depends on how fast the test runs. + base := time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC) + at := func(offset time.Duration) time.Time { return base.Add(offset) } + + t.Run("returns only captures strictly newer than the cursor", func(t *testing.T) { + freshDB(t) + + storeRequest(t.Context(), "before", withCreatedAt(at(-time.Minute))) + storeRequest(t.Context(), "on-the-cursor", withCreatedAt(base)) + storeRequest(t.Context(), "after", withCreatedAt(at(time.Minute))) + + items := database.GetRequestsForEndpointID(t.Context(), "test-id", "", base, 32) + + assert.Equal(t, []string{"after"}, uuidsOf(items)) + }) + + t.Run("returns them oldest first", func(t *testing.T) { + freshDB(t) + + storeRequest(t.Context(), "third", withCreatedAt(at(3*time.Minute))) + storeRequest(t.Context(), "first", withCreatedAt(at(time.Minute))) + storeRequest(t.Context(), "second", withCreatedAt(at(2*time.Minute))) + + items := database.GetRequestsForEndpointID(t.Context(), "test-id", "", base, 32) + + assert.Equal(t, []string{"first", "second", "third"}, uuidsOf(items)) + }) + + t.Run("a zero cursor still returns newest first", func(t *testing.T) { + freshDB(t) + + storeRequest(t.Context(), "older", withCreatedAt(at(time.Minute))) + storeRequest(t.Context(), "newer", withCreatedAt(at(2*time.Minute))) + + items := database.GetRequestsForEndpointID(t.Context(), "test-id", "", time.Time{}, 32) + + assert.Equal(t, []string{"newer", "older"}, uuidsOf(items)) + }) + + t.Run("a zero cursor is still limited", func(t *testing.T) { + freshDB(t) + + storeRequest(t.Context(), "older", withCreatedAt(at(time.Minute))) + storeRequest(t.Context(), "newer", withCreatedAt(at(2*time.Minute))) + + assert.Len(t, database.GetRequestsForEndpointID(t.Context(), "test-id", "", time.Time{}, 1), 1) + }) + + t.Run("the search and the cursor compose", func(t *testing.T) { + freshDB(t) + + storeRequest(t.Context(), "old-match", withBody("alpha"), withCreatedAt(at(-time.Minute))) + storeRequest(t.Context(), "new-miss", withBody("beta"), withCreatedAt(at(time.Minute))) + storeRequest(t.Context(), "new-match", withBody("alpha"), withCreatedAt(at(2*time.Minute))) + + items := database.GetRequestsForEndpointID(t.Context(), "test-id", "alpha", base, 32) + + assert.Equal(t, []string{"new-match"}, uuidsOf(items)) + }) + + t.Run("the cursor is scoped to its endpoint", func(t *testing.T) { + freshDB(t) + + storeRequest(t.Context(), "wanted", withCreatedAt(at(time.Minute))) + storeRequest(t.Context(), "other", withEndpointID("other-id"), withCreatedAt(at(time.Minute))) + + items := database.GetRequestsForEndpointID(t.Context(), "test-id", "", base, 32) + + assert.Equal(t, []string{"wanted"}, uuidsOf(items)) + }) + + // The one that matters. With DESC and a full page, a burst larger than the + // limit hands back its newest captures, the caller's cursor advances past + // everything older, and the backlog is never served. Draining has to reach + // every capture exactly once. + t.Run("a burst larger than the limit drains with nothing lost or repeated", func(t *testing.T) { + freshDB(t) + + const ( + burst = 25 + limit = 10 + ) + for i := range burst { + storeRequest(t.Context(), fmt.Sprintf("capture-%02d", i), + withCreatedAt(at(time.Duration(i)*time.Second))) + } + + var ( + seen []string + cursor = base.Add(-time.Second) + calls int + ) + for { + page := database.GetRequestsForEndpointID(t.Context(), "test-id", "", cursor, limit) + if len(page) == 0 { + break + } + calls++ + require.Less(t, calls, 10, "the drain is not converging") + + seen = append(seen, uuidsOf(page)...) + cursor = page[len(page)-1].CreatedAt + } + + want := make([]string, burst) + for i := range want { + want[i] = fmt.Sprintf("capture-%02d", i) + } + assert.Equal(t, want, seen, "every capture exactly once, in order") + assert.Equal(t, 3, calls, "25 captures at 10 a page is three pages") }) } @@ -224,10 +350,28 @@ func TestCreateRequest(t *testing.T) { storeRequest(t.Context(), "test-uuid") - items := database.GetRequestsForEndpointID(t.Context(), "test-id", "", 1) + items := database.GetRequestsForEndpointID(t.Context(), "test-id", "", time.Time{}, 1) assert.Equal(t, "test-uuid", items[0].UUID) - assert.Equal(t, time.Now().Format(time.ANSIC), items[0].CreatedAt.Format(time.ANSIC)) + assert.Equal(t, time.Now().UTC().Format(time.ANSIC), items[0].CreatedAt.UTC().Format(time.ANSIC)) + }) + + // created_at is compared and ordered as text, so a row carrying a zone + // offset would be ranked against the others by its digits rather than its + // instant. Every write normalises, whatever zone the caller was holding. + t.Run("stamps in UTC whatever zone the caller supplies", func(t *testing.T) { + freshDB(t) + + brussels, err := time.LoadLocation("Europe/Brussels") + require.NoError(t, err) + stamp := time.Date(2026, 8, 8, 19, 36, 13, 0, brussels) + + storeRequest(t.Context(), "zoned-uuid", withCreatedAt(stamp)) + + items := database.GetRequestsForEndpointID(t.Context(), "test-id", "", time.Time{}, 1) + + assert.Equal(t, time.UTC, items[0].CreatedAt.Location()) + assert.True(t, stamp.Equal(items[0].CreatedAt), "the instant must survive the conversion") }) } @@ -242,7 +386,7 @@ func TestDeleteRequestsForEndpointID(t *testing.T) { assert.Equal(t, int64(1), database.CountRequests(t.Context())) assert.Equal(t, "keep-id", - database.GetRequestsForEndpointID(t.Context(), "keep-id", "", 1)[0].EndpointID) + database.GetRequestsForEndpointID(t.Context(), "keep-id", "", time.Time{}, 1)[0].EndpointID) }) } @@ -257,7 +401,7 @@ func TestDeleteRequestForUUID(t *testing.T) { assert.Equal(t, int64(1), database.CountRequests(t.Context())) assert.Equal(t, "keep-uuid", - database.GetRequestsForEndpointID(t.Context(), "test-id", "", 1)[0].UUID) + database.GetRequestsForEndpointID(t.Context(), "test-id", "", time.Time{}, 1)[0].UUID) }) } diff --git a/src/endpoint.go b/src/endpoint.go index d0a4f85..e745ba6 100644 --- a/src/endpoint.go +++ b/src/endpoint.go @@ -27,15 +27,21 @@ func requireValidEndpoint(c fiber.Ctx) error { return c.Next() } -// endpointURLs builds the public capture and live-feed URLs shown on an -// endpoint page. The WebSocket scheme tracks the request scheme so an HTTPS -// page always advertises wss:// — a ws:// socket on an HTTPS page is blocked -// by browsers as mixed content. -func endpointURLs(scheme, host, endpointID string) (endpointURL, websocketURL string) { +// endpointURLs builds the three public URLs for an endpoint: where captures are +// sent, where the live feed is read, and where the JSON listing is polled. They +// are built together so a caller cannot advertise one host for the capture URL +// and another for the API. +// +// Every one of them tracks the request's scheme and host rather than a +// configured name, so a self-hosted deployment advertises itself. The WebSocket +// scheme tracks it too: a ws:// socket on an HTTPS page is blocked by browsers +// as mixed content. +func endpointURLs(scheme, host, endpointID string) (endpointURL, websocketURL, apiURL string) { websocketScheme := "ws" if scheme == "https" { websocketScheme = "wss" } return scheme + "://" + host + "/to/" + endpointID, - websocketScheme + "://" + host + "/ws/" + endpointID + websocketScheme + "://" + host + "/ws/" + endpointID, + scheme + "://" + host + "/api/endpoints/" + endpointID + "/requests" } diff --git a/src/endpoint_test.go b/src/endpoint_test.go index 1f6674a..5a2fd3d 100644 --- a/src/endpoint_test.go +++ b/src/endpoint_test.go @@ -9,17 +9,29 @@ import ( func TestEndpointURLs(t *testing.T) { t.Run("an https page advertises a wss socket", func(t *testing.T) { - endpointURL, websocketURL := endpointURLs("https", "httphq.com", "purple-frog-0691") + endpointURL, websocketURL, apiURL := endpointURLs("https", "httphq.com", "purple-frog-0691") assert.Equal(t, "https://httphq.com/to/purple-frog-0691", endpointURL) assert.Equal(t, "wss://httphq.com/ws/purple-frog-0691", websocketURL) + assert.Equal(t, "https://httphq.com/api/endpoints/purple-frog-0691/requests", apiURL) }) t.Run("an http page advertises a ws socket", func(t *testing.T) { - endpointURL, websocketURL := endpointURLs("http", "httphq.com", "purple-frog-0691") + endpointURL, websocketURL, apiURL := endpointURLs("http", "httphq.com", "purple-frog-0691") assert.Equal(t, "http://httphq.com/to/purple-frog-0691", endpointURL) assert.Equal(t, "ws://httphq.com/ws/purple-frog-0691", websocketURL) + assert.Equal(t, "http://httphq.com/api/endpoints/purple-frog-0691/requests", apiURL) + }) + + // The URLs track the request rather than a configured hostname, so a + // self-hosted deployment advertises itself instead of httphq.com. + t.Run("a self-hosted deployment advertises its own host", func(t *testing.T) { + endpointURL, websocketURL, apiURL := endpointURLs("http", "localhost:8080", "purple-frog-0691") + + assert.Equal(t, "http://localhost:8080/to/purple-frog-0691", endpointURL) + assert.Equal(t, "ws://localhost:8080/ws/purple-frog-0691", websocketURL) + assert.Equal(t, "http://localhost:8080/api/endpoints/purple-frog-0691/requests", apiURL) }) } diff --git a/src/pages.go b/src/pages.go index 68e9158..6c5f911 100644 --- a/src/pages.go +++ b/src/pages.go @@ -51,7 +51,8 @@ func renderContact(c fiber.Ctx) error { // and pointing them at a shared URL would be a lie. func renderEndpoint(c fiber.Ctx) error { endpointID := c.Params("endpoint") - endpointURL, websocketURL := endpointURLs(c.Scheme(), string(c.Request().Host()), endpointID) + endpointURL, websocketURL, apiURL := endpointURLs( + c.Scheme(), string(c.Request().Host()), endpointID) 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.", @@ -62,6 +63,10 @@ func renderEndpoint(c fiber.Ctx) error { // 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()), + // Rendered rather than written by hand so the prompt quotes this + // deployment's own URLs and the limits actually in force. + "AgentPrompt": agentPrompt( + endpointURL, apiURL, productionRequestsPerMinute, retentionWindow), }) } diff --git a/src/routes_test.go b/src/routes_test.go index 89b84fe..7f7799a 100644 --- a/src/routes_test.go +++ b/src/routes_test.go @@ -5,6 +5,7 @@ import ( "io" "net/http" "net/http/httptest" + "net/url" "os" "path/filepath" "strconv" @@ -12,6 +13,7 @@ import ( "sync" "sync/atomic" "testing" + "time" "github.com/gofiber/fiber/v3" "github.com/stretchr/testify/assert" @@ -103,14 +105,17 @@ func bodyOf(t *testing.T, response *http.Response) string { 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. -func listRequests(t *testing.T, id, search string) requestListing { +// 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) + response := get(t, "/api/endpoints/"+id+"/requests?search="+search+ + "&since="+url.QueryEscape(since)) require.Equal(t, http.StatusOK, response.StatusCode) var payload requestListing @@ -120,12 +125,12 @@ func listRequests(t *testing.T, id, search string) requestListing { func capturedRequests(t *testing.T, id, search string) []database.Request { t.Helper() - return listRequests(t, id, search).Requests + return listRequests(t, id, search, "").Requests } func listedTotal(t *testing.T, id, search string) int64 { t.Helper() - return listRequests(t, id, search).Total + return listRequests(t, id, search, "").Total } func TestHealthRoute(t *testing.T) { @@ -187,6 +192,17 @@ func TestPageRoutes(t *testing.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) { @@ -402,6 +418,86 @@ func TestRequestsAPI(t *testing.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 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"} { diff --git a/src/views/endpoint.html b/src/views/endpoint.html index cb24b8b..79c83b5 100644 --- a/src/views/endpoint.html +++ b/src/views/endpoint.html @@ -60,6 +60,86 @@

+ +
+
+ + +

Connect an agent

+ +
+ +
+

+ Paste this into Claude Code or any coding agent. It carries this + endpoint's URLs and tells the agent how to poll for new requests + without re-reading the ones it already has. +

+ +
+{{.AgentPrompt}}
+ +
+
+
+
diff --git a/src/views/index.html b/src/views/index.html index b089e9a..017e861 100644 --- a/src/views/index.html +++ b/src/views/index.html @@ -120,7 +120,7 @@

An example captured request

> Common use cases

-
+
from your side malformed, or mine?" conversations.

+ +
+
+ +

Debug with an agent

+
+

+ Point Claude Code or any coding agent at the JSON API and let it watch + requests arrive while it works on your handler. +

+