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. +

+