diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2a6df0f..c8e36c6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,9 @@ jobs: with: go-version-file: "go.mod" cache: false - - run: go vet ./... + # Scoped to src: node_modules ships a stray Go package that ./... would + # otherwise build. + - run: go vet ./src/... build: runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9bf4dd9..27b1e35 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,26 @@ jobs: with: go-version-file: "go.mod" cache: false - - run: go test ./... + # Scoped to src: node_modules ships a stray Go package that ./... would + # otherwise build. + - run: go vet ./src/... + - run: go test ./src/... + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 + with: + node-version-file: ".tool-versions" + - run: npm ci + # The Playwright suite is linted with type information, which needs its + # own dependencies resolvable. + - run: npm ci + working-directory: e2e + - run: npm run lint + - name: Type-check the Playwright suite + run: npx tsc --noEmit + working-directory: e2e styles: runs-on: ubuntu-latest steps: diff --git a/AGENTS.md b/AGENTS.md index 87cbd14..45d6cbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,20 @@ Guidance for agents and contributors working in this repository. +## Code layout + +`src` is one `main` package split by concern, one file per subject with its +tests beside it: `application.go` (wiring and entry point), `platform.go` +(client IP and header stripping), `endpoint.go` (endpoint IDs and URLs), +`capture.go` (the capture handler), `api.go` (the JSON API), `pages.go` (page +rendering), `assets.go` (content-hashed asset URLs), `security.go` (CSP and +security headers), `sockets.go` (the live feed), `requestlog.go` (correlation +IDs and the access log). + +`newApplication` builds the entire routing surface from arguments, so tests +drive real requests through it without a listening socket. Anything that pulls +configuration out of the environment belongs in `main`, not in a handler. + ## Comments Comments describe what the code does now and warn about non-obvious constraints diff --git a/DESIGN.md b/DESIGN.md index a7102d2..66c0fe2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -456,13 +456,23 @@ must never be borrowed for a disabled, errored, or drop-target surface. The system is implemented, not only described. `src/styles/components.css` carries the classes these entries specify (`.btn` and its variants, `.field`, -`.field-label`, `.panel`, `.kv-row`, `.icon`, `.badge`, `.code-block`, -`.empty-value`, `.btn-lg`), and templates compose them rather +`.field-label`, `.region-label`, `.panel`, `.kv-row`, `.icon`, `.badge`, +`.code-block`, `.empty-value`, `.btn-lg`), and templates compose them rather than repeating utility strings. Every page uses them: a template that re-spells a component as a utility string is the bug, not a shortcut. Utilities stay the default for one-off composition; anything whose tokens must not drift between call sites belongs in that file. Before adding a -variant, check whether an existing class should absorb it: eight spellings of one -button is how two nominally identical controls ended up 4px apart. +variant, check whether an existing class should absorb it. Several spellings of +one component is how two nominally identical controls come to sit a few pixels +apart. + +There is one surface class, `.panel`: a white fill, a 1px neutral-200 seam and +no shadow. A second, quieter panel would be the One Edge Rule broken by another +name, so a surface that needs to read as nested takes its distinction from +spacing or tone rather than from a class of its own. + +`.field-label` and `.region-label` carry one type token between them. The field +label owns the spacing above its input; the region label takes spacing from the +call site, because a heading inside a flex row must not carry a bottom margin. ### Buttons @@ -503,9 +513,9 @@ Every other route is reachable from the footer: feedback, the GitHub repository with an inline brand glyph, and the Formspark credit: set at 0.875rem neutral-500, centred, hovering to brand-600. -Every link carries its own padding and a 2.75rem minimum height. As bare inline -text their hit area collapses to the text box, which lands under the 24px target -minimum and makes the page's only exits hard to hit on a phone. Links also carry +Every link carries its own padding and a 2.75rem minimum height. Bare inline +text has a hit area no larger than its text box, which lands under the 24px +target minimum and makes the page's only exits hard to hit on a phone. Links also carry the same authored `focus-visible` ring as buttons rather than falling back to the browser's default outline, which is engine-specific and belongs to no design system. @@ -551,10 +561,10 @@ substitution, not a degradation. ### Render Window The stream renders 25 cards and reveals another 25 per press of a Show more -control, rather than rendering everything the store holds. Each card is roughly -104 DOM nodes, so a full page of them is a five-figure node count and a visible -stall on every filter change. The store still holds every captured request; only -the DOM is bounded. +control, rather than rendering everything the store holds. Each card is around a +hundred DOM nodes, so a full endpoint's worth is a five-figure node count and a +visible stall on every filter change. The store still holds every captured +request; only the DOM is bounded. ### Waiting State diff --git a/docs/scripts.md b/docs/scripts.md index 4068bbc..38ce415 100644 --- a/docs/scripts.md +++ b/docs/scripts.md @@ -32,12 +32,26 @@ Run project: go run ./src ``` +Lint the page scripts and the Playwright suite: + +```bash +npm run lint +npm run lint:fix +``` + +The Playwright suite is linted with type information, so `e2e` needs its own +dependencies installed. The Go application is checked by `go vet` instead. + Run unit tests: ```bash -go test ./... +go vet ./src/... +go test ./src/... ``` +Every Go package lives under `src`. `./...` also walks `node_modules`, which +ships a stray Go package once the npm tooling is installed. + Run E2E tests (Playwright auto-starts the binary; build it first): ```bash @@ -48,7 +62,7 @@ cd e2e && npx playwright test View test coverage: ```bash -go test ./... -coverprofile=coverage.out +go test ./src/... -coverprofile=coverage.out go tool cover -html=coverage.out ``` diff --git a/e2e/tests/contact-screen.spec.ts b/e2e/tests/contact-screen.spec.ts index 7fe864a..a21196b 100644 --- a/e2e/tests/contact-screen.spec.ts +++ b/e2e/tests/contact-screen.spec.ts @@ -5,14 +5,45 @@ test.describe("Contact screen", () => { await page.goto("/contact"); }); - test("title should be correct", async ({ page }) => { - await expect(page).toHaveTitle("Contact | httphq"); + test.describe("Page", () => { + test("the title names the page", async ({ page }) => { + await expect(page).toHaveTitle("Contact | httphq"); + }); + + test("the page ships no javascript", async ({ page }) => { + await expect(page.locator("script")).toHaveCount(0); + }); }); - test("form should be functional", async ({ page }) => { - await page.locator('input[name="name"]').fill("John Doe"); - await page.locator('input[name="email"]').fill("john@doe.test"); - await page.locator('textarea[name="message"]').fill("Hello, World!"); - await expect(page.locator('button[data-test="send-form"]')).toBeEnabled(); + test.describe("Form", () => { + test("a filled form is submittable", async ({ page }) => { + await page.locator('input[name="name"]').fill("John Doe"); + await page.locator('input[name="email"]').fill("john@doe.test"); + await page.locator('textarea[name="message"]').fill("Hello, World!"); + await expect(page.locator('button[data-test="send-form"]')).toBeEnabled(); + }); + + test("every field is labelled and required", async ({ page }) => { + for (const label of ["Name", "Email", "Message"]) { + const field = page.getByLabel(label, { exact: true }); + await expect(field).toBeVisible(); + await expect(field).toHaveAttribute("required", ""); + } + }); + + test("an empty form does not submit", async ({ page }) => { + await page.locator('button[data-test="send-form"]').click(); + await expect(page).toHaveURL(/\/contact$/); + }); + + // The hidden checkbox is a honeypot: a real visitor never sees it, so a + // submission that ticks it came from something filling fields blindly. + test("the honeypot field stays out of the visible form", async ({ + page, + }) => { + const honeypot = page.locator('input[name="accept"]'); + await expect(honeypot).toBeHidden(); + await expect(honeypot).toHaveAttribute("tabindex", "-1"); + }); }); }); diff --git a/e2e/tests/endpoint-screen.spec.ts b/e2e/tests/endpoint-screen.spec.ts index 6ad121b..855a72c 100644 --- a/e2e/tests/endpoint-screen.spec.ts +++ b/e2e/tests/endpoint-screen.spec.ts @@ -1,78 +1,64 @@ -import { test, expect, type APIRequestContext } from "@playwright/test"; - -const randomId = () => Math.random().toString(36).slice(2, 7); - -const post = async ( - request: APIRequestContext, - url: string, - init: { data?: string | object; headers?: Record } = {}, -) => request.post(url, init); +import { test, expect } from "@playwright/test"; +import { + captureUrl, + newEndpointId, + readClipboard, + readClipboardJson, + send, + type HarDocument, +} from "./support/harness"; test.describe("Endpoint screen", () => { - let testId: string; + let endpointId: string; let endpointPath: string; let endpointUrl: string; test.beforeEach(async ({ page }) => { - testId = randomId(); - endpointPath = `/to/${testId}`; - endpointUrl = `http://localhost:8080${endpointPath}`; - await page.goto(`/${testId}`); + endpointId = newEndpointId(); + endpointPath = `/to/${endpointId}`; + endpointUrl = captureUrl(endpointId); + await page.goto(`/${endpointId}`); // Wait for Alpine to mount and the empty state to render so subsequent // assertions don't race with initialization. await expect(page.locator('[data-test="endpoint-url"]')).toBeVisible(); }); - test("title is the endpoint id", async ({ page }) => { - await expect(page).toHaveTitle(`${testId} | httphq`); - }); - - test("endpoint URL is shown with copy button", async ({ page }) => { - await expect(page.locator('[data-test="endpoint-url"]')).toContainText( - endpointUrl, - ); - await expect(page.locator('[data-test="copy-url"]')).toBeVisible(); - }); - - test("copy-url button writes the URL to the clipboard", async ({ page }) => { - await page.locator('[data-test="copy-url"]').click(); - const clipboard = await page.evaluate(() => navigator.clipboard.readText()); - expect(clipboard).toBe(endpointUrl); - await expect(page.locator('[data-test="copy-url-label"]')).toContainText( - "Copied!", - ); - }); + test.describe("Page", () => { + test("the title is the endpoint id", async ({ page }) => { + await expect(page).toHaveTitle(`${endpointId} | httphq`); + }); - test("disclaimer about 4-hour retention is visible", async ({ page }) => { - await expect( - page.getByText("Requests are deleted after 4 hours"), - ).toBeVisible(); - }); + test("the endpoint URL is shown with a copy button", async ({ page }) => { + await expect(page.locator('[data-test="endpoint-url"]')).toContainText( + endpointUrl, + ); + await expect(page.locator('[data-test="copy-url"]')).toBeVisible(); + }); - test.describe("Send a test request panel", () => { - test("submitting the panel produces a captured request", async ({ + test("the copy-url button writes the URL to the clipboard", async ({ page, }) => { - await page.locator('[data-test="send-toggle"]').click(); - await page.locator('[data-test="send-method"]').selectOption("PUT"); - await page - .locator('[data-test="send-headers"]') - .fill("X-Source: panel\nContent-Type: application/json"); - await page.locator('[data-test="send-body"]').fill('{"hello":"panel"}'); - await page.locator('[data-test="send-submit"]').click(); - - const card = page.locator('[data-test="request"]').first(); - await expect(card).toContainText("PUT"); - await expect(card.locator('[data-test="request-headers"]')).toContainText( - "X-Source", - ); - await expect(card.locator('[data-test="request-body"]')).toContainText( - "panel", + await page.locator('[data-test="copy-url"]').click(); + expect(await readClipboard(page)).toBe(endpointUrl); + await expect(page.locator('[data-test="copy-url-label"]')).toContainText( + "Copied!", ); }); + + test("the retention and visibility terms are stated", async ({ page }) => { + await expect( + page.getByText("Requests are deleted after 4 hours"), + ).toBeVisible(); + }); + + test("the connection indicator settles on live", async ({ page }) => { + await expect( + page.locator('[data-test="connection-status"]'), + ).toContainText("Live"); + }); }); - test.describe("Requests list", () => { + test.describe("Capture stream", () => { test("shows the empty state when no requests exist", async ({ page }) => { await expect(page.locator('[data-test="requests"]')).toContainText( "Waiting for requests", @@ -83,7 +69,7 @@ test.describe("Endpoint screen", () => { page, request, }) => { - await post(request, endpointUrl, { data: "Hello, World!" }); + await send(request, endpointUrl, { data: "Hello, World!" }); await expect(page.locator('[data-test="requests"]')).not.toContainText( "Waiting for requests", ); @@ -93,7 +79,7 @@ test.describe("Endpoint screen", () => { page, request, }) => { - await post(request, endpointUrl, { data: "Real-time-payload" }); + await send(request, endpointUrl, { data: "Real-time-payload" }); await expect(page.locator('[data-test="requests"]')).toContainText( "Real-time-payload", ); @@ -103,7 +89,7 @@ test.describe("Endpoint screen", () => { page, request, }) => { - await post(request, endpointUrl, { + await send(request, endpointUrl, { data: "Hello, World!", headers: { "Content-Type": "text/plain" }, }); @@ -121,11 +107,116 @@ test.describe("Endpoint screen", () => { ); }); + test("renders the query string, and says so when there is none", async ({ + page, + request, + }) => { + await send(request, `${endpointUrl}?event=charge.succeeded`, { + data: "x", + }); + const withQuery = page.locator('[data-test="query-string"]').first(); + await expect(withQuery).toContainText("event=charge.succeeded"); + await expect( + page.locator('[data-test="request-path"]').first(), + ).toContainText("?event=charge.succeeded"); + + await send(request, endpointUrl, { data: "y" }); + await expect( + page.locator('[data-test="query-string"]').first(), + ).toContainText("None"); + }); + + test("a bodyless request reports no body rather than an empty panel", async ({ + page, + request, + }) => { + await send(request, endpointUrl, { method: "GET" }); + await expect( + page.locator('[data-test="request-body"]').first(), + ).toContainText("None"); + }); + + test("the header count matches the headers listed", async ({ + page, + request, + }) => { + await send(request, endpointUrl, { + data: "x", + headers: { "X-One": "1", "X-Two": "2" }, + }); + const headers = page.locator('[data-test="request-headers"]').first(); + await expect(headers).toContainText("X-One"); + await expect(headers).toContainText("X-Two"); + await expect(headers).toContainText(/Headers\s*\(\d+\)/); + }); + + test("delete-request removes a single card", async ({ page, request }) => { + await send(request, endpointUrl, { data: "first" }); + const response = await send(request, endpointUrl, { + data: { msg: "second" }, + }); + const uuid = response.headers()["httphq-request-uuid"]; + const card = page.locator(`#request-${uuid}`); + await expect(card).toBeAttached(); + await card.locator('[data-test="delete-request"]').click(); + await expect(card).not.toBeAttached(); + }); + + test("delete-all asks before clearing every request", async ({ + page, + request, + }) => { + await send(request, endpointUrl, { data: "x" }); + await send(request, endpointUrl, { data: "y" }); + + await page.locator('[data-test="delete-requests"]').click(); + // Destructive and unrecoverable, so it confirms first; the requests are + // still there until the confirmation is accepted. + await expect(page.locator('[data-test="delete-confirm"]')).toBeVisible(); + await expect(page.locator('[data-test="request"]')).toHaveCount(2); + + await page.locator('[data-test="delete-cancel"]').click(); + await expect(page.locator('[data-test="request"]')).toHaveCount(2); + + await page.locator('[data-test="delete-requests"]').click(); + await page.locator('[data-test="delete-confirm-button"]').click(); + await expect(page.locator('[data-test="requests"]')).toContainText( + "Waiting for requests", + ); + }); + + // Rendering every capture at once is a five-figure node count and a visible + // stall, so the rest stay in the store until asked for. + test("only a page of cards is rendered until more are asked for", async ({ + page, + request, + }) => { + const overOnePage = 26; + for (let i = 0; i < overOnePage; i++) { + await send(request, endpointUrl, { data: `payload-${i}` }); + } + await expect(page.locator('[data-test="search-results"]')).toContainText( + `${overOnePage} results`, + ); + + await expect(page.locator('[data-test="request"]')).toHaveCount(25); + const showMore = page.locator('[data-test="show-more"]'); + await expect(showMore).toContainText("Show 1 more"); + + await showMore.click(); + await expect(page.locator('[data-test="request"]')).toHaveCount( + overOnePage, + ); + await expect(showMore).toBeHidden(); + }); + }); + + test.describe("Body rendering", () => { test("pretty-prints JSON bodies via highlight.js", async ({ page, request, }) => { - await post(request, endpointUrl, { + await send(request, endpointUrl, { data: { hello: "world", arr: [1, 2, 3] }, headers: { "Content-Type": "application/json" }, }); @@ -140,7 +231,7 @@ test.describe("Endpoint screen", () => { }); test("highlights XML bodies", async ({ page, request }) => { - await post(request, endpointUrl, { + await send(request, endpointUrl, { data: "1", headers: { "Content-Type": "application/xml" }, }); @@ -149,6 +240,21 @@ test.describe("Endpoint screen", () => { expect(tagCount).toBeGreaterThan(0); }); + // A body is captured bytes, never markup: it is escaped on the way onto the + // page rather than rendered. + test("an HTML body is displayed as text, not rendered", async ({ + page, + request, + }) => { + await send(request, endpointUrl, { + data: "", + headers: { "Content-Type": "text/html" }, + }); + const body = page.locator('[data-test="request-body"]').first(); + await expect(body).toContainText("onerror=alert(1)"); + await expect(body.locator("img")).toHaveCount(0); + }); + test("renders multipart/form-data fields as a parsed JSON array", async ({ page, request, @@ -207,54 +313,226 @@ test.describe("Endpoint screen", () => { page, request, }) => { - await post(request, endpointUrl, { + await send(request, endpointUrl, { data: "not-actually-parseable-multipart", headers: { "Content-Type": "multipart/form-data" }, }); const body = page.locator('[data-test="request-body"]').first(); await expect(body).toContainText("not-actually-parseable-multipart"); }); + }); + + test.describe("Filtering", () => { + test("filters by request body via the search box", async ({ + page, + request, + }) => { + await send(request, endpointUrl, { data: "Hello, World!" }); + const requests = page.locator('[data-test="requests"]'); + const results = page.locator('[data-test="search-results"]'); + const search = page.locator('[data-test="search-input"]'); + + await expect(requests).toContainText("Hello, World!"); + + await search.fill("Hello"); + await expect(results).toContainText("1 result"); + await expect(requests).toContainText("Hello, World!"); + + await search.fill("Nothing-matches-this"); + await expect(results).toContainText("0 results"); + // A stream hidden by a filter is not a stream that never arrived, so it + // must not claim to be still waiting. + await expect(requests).toContainText("No requests match this filter"); + await expect(requests).not.toContainText("Waiting for requests"); + }); + + test("filters by header key/value via the search box", async ({ + page, + request, + }) => { + const key = "A-Test"; + const value = "Hello-Header"; + await send(request, endpointUrl, { + data: "x", + headers: { [key]: value }, + }); + const requests = page.locator('[data-test="requests"]'); + const results = page.locator('[data-test="search-results"]'); + const search = page.locator('[data-test="search-input"]'); + + await expect(requests).toContainText(key); + await expect(requests).toContainText(value); + + await search.fill("A-"); + await expect(results).toContainText("1 result"); + + await search.fill("Hello-Header"); + await expect(results).toContainText("1 result"); + + await search.fill("not-a-thing"); + await expect(results).toContainText("0 results"); + }); + + test("filters by query string via the search box", async ({ + page, + request, + }) => { + await send(request, `${endpointUrl}?event=charge.succeeded`, { + data: "x", + }); + const results = page.locator('[data-test="search-results"]'); + + await page.locator('[data-test="search-input"]').fill("charge.succeeded"); + await expect(results).toContainText("1 result"); + }); + + test("the method filter narrows the list to matching methods", async ({ + page, + request, + }) => { + await send(request, endpointUrl, { data: "p1" }); + await send(request, endpointUrl, { method: "PUT", data: "p2" }); + await send(request, endpointUrl, { method: "DELETE" }); + + await expect(page.locator('[data-test="search-results"]')).toContainText( + "3 results", + ); + + await page.locator('[data-test="method-filter"]').selectOption("POST"); + await expect(page.locator('[data-test="search-results"]')).toContainText( + "1 result", + ); + await expect(page.locator('[data-test="request"]')).toHaveCount(1); + + await page.locator('[data-test="method-filter"]').selectOption(""); + await expect(page.locator('[data-test="search-results"]')).toContainText( + "3 results", + ); + }); + + test("a filtered-empty stream is not reported as waiting", async ({ + page, + request, + }) => { + await send(request, endpointUrl, { data: "x" }); + await expect(page.locator('[data-test="request"]')).toHaveCount(1); + + await page + .locator('[data-test="search-input"]') + .fill("nothing-matches-this-term"); + await expect(page.locator('[data-test="empty-filtered"]')).toBeVisible(); + await expect(page.locator('[data-test="empty-waiting"]')).toHaveCount(0); + + await page.locator('[data-test="clear-filters"]').click(); + await expect(page.locator('[data-test="request"]')).toHaveCount(1); + }); - test("body copy button writes raw body to clipboard", async ({ + // Delete-all clears the endpoint, not the filtered view its neighbour + // copies, so its count stays the whole stream. + test("the delete-all count is the whole stream, not the filtered view", async ({ + page, + request, + }) => { + await send(request, endpointUrl, { data: "p" }); + await send(request, endpointUrl, { method: "PUT", data: "u" }); + await expect(page.locator('[data-test="request"]')).toHaveCount(2); + + await page.locator('[data-test="method-filter"]').selectOption("PUT"); + + await expect( + page.locator('[data-test="copy-all-har-label"]'), + ).toContainText("Copy shown (1)"); + await expect( + page.locator('[data-test="delete-requests-label"]'), + ).toContainText("Delete all (2)"); + }); + + // The search runs on the server, so the list it returns is not the + // endpoint. A destructive control must not take its count from it. + test("a search does not shrink the delete-all count", async ({ + page, + request, + }) => { + await send(request, endpointUrl, { data: "alpha" }); + await send(request, endpointUrl, { data: "beta" }); + await expect(page.locator('[data-test="request"]')).toHaveCount(2); + + await page.locator('[data-test="search-input"]').fill("alpha"); + await expect(page.locator('[data-test="search-results"]')).toContainText( + "1 result", + ); + await expect( + page.locator('[data-test="delete-requests-label"]'), + ).toContainText("Delete all (2)"); + + await page.locator('[data-test="delete-requests"]').click(); + await expect(page.locator('[data-test="delete-confirm"]')).toContainText( + "Delete all 2 captured requests?", + ); + }); + + test("a search that hides everything still reports the endpoint total", async ({ + page, + request, + }) => { + await send(request, endpointUrl, { data: "alpha" }); + await expect(page.locator('[data-test="request"]')).toHaveCount(1); + + await page + .locator('[data-test="search-input"]') + .fill("nothing-matches-this-term"); + + await expect(page.locator('[data-test="empty-filtered"]')).toContainText( + "1 captured on this endpoint", + ); + }); + }); + + test.describe("Copying", () => { + test("the body copy button writes the raw body to the clipboard", async ({ page, request, }) => { const raw = '{"copy":"me"}'; - await post(request, endpointUrl, { + await send(request, endpointUrl, { data: raw, headers: { "Content-Type": "application/json" }, }); const card = page.locator('[data-test="request"]').first(); await card.locator('[data-test="copy-body"]').click(); - const clipboard = await page.evaluate(() => - navigator.clipboard.readText(), - ); - expect(clipboard).toBe(raw); + expect(await readClipboard(page)).toBe(raw); }); - test("headers copy button writes the headers JSON to clipboard", async ({ + test("the headers copy button writes the headers JSON to the clipboard", async ({ page, request, }) => { - await post(request, endpointUrl, { + await send(request, endpointUrl, { data: "x", headers: { "X-Sample": "value" }, }); const card = page.locator('[data-test="request"]').first(); await card.locator('[data-test="copy-headers"]').click(); - const clipboard = await page.evaluate(() => - navigator.clipboard.readText(), - ); - const parsed = JSON.parse(clipboard); + const parsed = await readClipboardJson>(page); expect(parsed["X-Sample"]).toBe("value"); }); - test("request copy button writes a HAR-shaped document to the clipboard", async ({ + test("the query copy button writes the raw query string", async ({ + page, + request, + }) => { + await send(request, `${endpointUrl}?a=1&b=2`, { data: "x" }); + const card = page.locator('[data-test="request"]').first(); + await card.locator('[data-test="copy-query"]').click(); + expect(await readClipboard(page)).toBe("a=1&b=2"); + }); + + test("the request copy button writes a HAR-shaped document", async ({ page, request, }) => { const raw = '{"hello":"world"}'; - const response = await request.post(`${endpointUrl}?a=1&b=2`, { + const response = await send(request, `${endpointUrl}?a=1&b=2`, { data: raw, headers: { "Content-Type": "application/json", "X-Sample": "value" }, }); @@ -263,10 +541,7 @@ test.describe("Endpoint screen", () => { await expect(card).toBeAttached(); await card.locator('[data-test="copy-request-har"]').click(); - const clipboard = await page.evaluate(() => - navigator.clipboard.readText(), - ); - const har = JSON.parse(clipboard); + const har = await readClipboardJson(page); expect(har.creator.name).toBe("httphq"); expect(har.entries).toHaveLength(1); @@ -290,11 +565,37 @@ test.describe("Endpoint screen", () => { expect(entry.request.bodySize).toBe(raw.length); }); - test("request copy button label flips to Copied!", async ({ + // httphq never observes a response, so an entry that carried one would be + // reporting data it does not have. + test("a HAR entry claims nothing about the response", async ({ + page, + request, + }) => { + await send(request, endpointUrl, { data: "x" }); + const card = page.locator('[data-test="request"]').first(); + await card.locator('[data-test="copy-request-har"]').click(); + const har = await readClipboardJson(page); + + expect(har.entries[0]).not.toHaveProperty("response"); + expect(har.entries[0]).not.toHaveProperty("timings"); + expect(har.entries[0]).not.toHaveProperty("cache"); + }); + + test("a bodyless request omits postData", async ({ page, request }) => { + await send(request, endpointUrl, { method: "GET" }); + const card = page.locator('[data-test="request"]').first(); + await card.locator('[data-test="copy-request-har"]').click(); + const har = await readClipboardJson(page); + + expect(har.entries[0].request).not.toHaveProperty("postData"); + expect(har.entries[0].request.bodySize).toBe(0); + }); + + test("the request copy button label flips to Copied!", async ({ page, request, }) => { - await post(request, endpointUrl, { data: "x" }); + await send(request, endpointUrl, { data: "x" }); const card = page.locator('[data-test="request"]').first(); await card.locator('[data-test="copy-request-har"]').click(); await expect( @@ -306,32 +607,27 @@ test.describe("Endpoint screen", () => { page, request, }) => { - await post(request, endpointUrl, { data: "first" }); + await send(request, endpointUrl, { data: "first" }); await expect(page.locator('[data-test="request"]')).toHaveCount(1); - await post(request, endpointUrl, { data: "second" }); + await send(request, endpointUrl, { data: "second" }); await expect(page.locator('[data-test="request"]')).toHaveCount(2); await page.locator('[data-test="copy-all-har"]').click(); - const clipboard = await page.evaluate(() => - navigator.clipboard.readText(), - ); - const har = JSON.parse(clipboard); + const har = await readClipboardJson(page); expect(har.entries).toHaveLength(2); - expect( - har.entries.map( - (e: { request: { postData: { text: string } } }) => - e.request.postData.text, - ), - ).toEqual(["second", "first"]); + expect(har.entries.map((e) => e.request.postData?.text)).toEqual([ + "second", + "first", + ]); }); test("copy-all is scoped to the method filter", async ({ page, request, }) => { - await post(request, endpointUrl, { data: "p" }); - await request.fetch(endpointUrl, { method: "PUT", data: "u" }); + await send(request, endpointUrl, { data: "p" }); + await send(request, endpointUrl, { method: "PUT", data: "u" }); await expect(page.locator('[data-test="search-results"]')).toContainText( "2 results", ); @@ -342,135 +638,59 @@ test.describe("Endpoint screen", () => { ).toContainText("Copy shown (1)"); await page.locator('[data-test="copy-all-har"]').click(); - const clipboard = await page.evaluate(() => - navigator.clipboard.readText(), - ); - const har = JSON.parse(clipboard); + const har = await readClipboardJson(page); expect(har.entries).toHaveLength(1); expect(har.entries[0].request.method).toBe("PUT"); }); + // Copying nothing produces an empty document, which reads as a failed + // export rather than an empty stream. test("copy-all is disabled while nothing has been captured", async ({ page, }) => { await expect(page.locator('[data-test="copy-all-har"]')).toBeDisabled(); }); + }); - test("filters by request body via the search box", async ({ - page, - request, - }) => { - await post(request, endpointUrl, { data: "Hello, World!" }); - const requests = page.locator('[data-test="requests"]'); - const results = page.locator('[data-test="search-results"]'); - const search = page.locator('[data-test="search-input"]'); - - await expect(requests).toContainText("Hello, World!"); - - await search.fill("Hello"); - await expect(results).toContainText("1 result"); - await expect(requests).toContainText("Hello, World!"); - - await search.fill("Nothing-matches-this"); - await expect(results).toContainText("0 results"); - // A stream hidden by a filter is not a stream that never arrived, so it - // must not claim to be still waiting. - await expect(requests).toContainText("No requests match this filter"); - await expect(requests).not.toContainText("Waiting for requests"); - }); - - test("filters by header key/value via the search box", async ({ - page, - request, - }) => { - const key = "A-Test"; - const value = "Hello-Header"; - await post(request, endpointUrl, { - data: "x", - headers: { [key]: value }, - }); - const requests = page.locator('[data-test="requests"]'); - const results = page.locator('[data-test="search-results"]'); - const search = page.locator('[data-test="search-input"]'); - - await expect(requests).toContainText(key); - await expect(requests).toContainText(value); - - await search.fill("A-"); - await expect(results).toContainText("1 result"); - - await search.fill("Hello-Header"); - await expect(results).toContainText("1 result"); - - await search.fill("not-a-thing"); - await expect(results).toContainText("0 results"); - }); - - test("method filter narrows the list to matching methods", async ({ + test.describe("Sending a test request", () => { + test("submitting the panel produces a captured request", async ({ page, - request, }) => { - await post(request, endpointUrl, { data: "p1" }); - await request.fetch(endpointUrl, { method: "PUT", data: "p2" }); - await request.fetch(endpointUrl, { method: "DELETE" }); - - await expect(page.locator('[data-test="search-results"]')).toContainText( - "3 results", - ); - - await page.locator('[data-test="method-filter"]').selectOption("POST"); - await expect(page.locator('[data-test="search-results"]')).toContainText( - "1 result", - ); - await expect(page.locator('[data-test="request"]')).toHaveCount(1); + await page.locator('[data-test="send-toggle"]').click(); + await page.locator('[data-test="send-method"]').selectOption("PUT"); + await page + .locator('[data-test="send-headers"]') + .fill("X-Source: panel\nContent-Type: application/json"); + await page.locator('[data-test="send-body"]').fill('{"hello":"panel"}'); + await page.locator('[data-test="send-submit"]').click(); - await page.locator('[data-test="method-filter"]').selectOption(""); - await expect(page.locator('[data-test="search-results"]')).toContainText( - "3 results", + const card = page.locator('[data-test="request"]').first(); + await expect(card).toContainText("PUT"); + await expect(card.locator('[data-test="request-headers"]')).toContainText( + "X-Source", ); - }); - - test("delete-all asks before clearing every request", async ({ - page, - request, - }) => { - await post(request, endpointUrl, { data: "x" }); - await post(request, endpointUrl, { data: "y" }); - - await page.locator('[data-test="delete-requests"]').click(); - // Destructive and unrecoverable, so it confirms first; the requests are - // still there until the confirmation is accepted. - await expect(page.locator('[data-test="delete-confirm"]')).toBeVisible(); - await expect(page.locator('[data-test="request"]')).toHaveCount(2); - - await page.locator('[data-test="delete-cancel"]').click(); - await expect(page.locator('[data-test="request"]')).toHaveCount(2); - - await page.locator('[data-test="delete-requests"]').click(); - await page.locator('[data-test="delete-confirm-button"]').click(); - await expect(page.locator('[data-test="requests"]')).toContainText( - "Waiting for requests", + await expect(card.locator('[data-test="request-body"]')).toContainText( + "panel", ); }); - test("a filtered-empty stream is not reported as waiting", async ({ - page, - request, - }) => { - await post(request, endpointUrl, { data: "x" }); - await expect(page.locator('[data-test="request"]')).toHaveCount(1); - + test("the path and query field reaches the capture", async ({ page }) => { + await page.locator('[data-test="send-toggle"]').click(); await page - .locator('[data-test="search-input"]') - .fill("nothing-matches-this-term"); - await expect(page.locator('[data-test="empty-filtered"]')).toBeVisible(); - await expect(page.locator('[data-test="empty-waiting"]')).toHaveCount(0); + .locator('[data-test="send-path"]') + .fill("/orders/8821?event=charge.succeeded"); + await page.locator('[data-test="send-submit"]').click(); - await page.locator('[data-test="clear-filters"]').click(); - await expect(page.locator('[data-test="request"]')).toHaveCount(1); + const card = page.locator('[data-test="request"]').first(); + await expect(card.locator('[data-test="request-path"]')).toContainText( + `${endpointPath}/orders/8821?event=charge.succeeded`, + ); }); + // Silently discarding a line that is one typo away from an Authorization + // header, and then reporting success, sends the user chasing an auth bug + // that does not exist. test("a malformed header line is reported instead of dropped", async ({ page, }) => { @@ -484,22 +704,10 @@ test.describe("Endpoint screen", () => { ); await expect(page.locator('[data-test="request"]')).toHaveCount(0); }); - - test("delete-request removes a single card", async ({ page, request }) => { - await post(request, endpointUrl, { data: "first" }); - const response = await request.post(endpointUrl, { - data: { msg: "second" }, - }); - const uuid = response.headers()["httphq-request-uuid"]; - const card = page.locator(`#request-${uuid}`); - await expect(card).toBeAttached(); - await card.locator('[data-test="delete-request"]').click(); - await expect(card).not.toBeAttached(); - }); }); test.describe("Tab indicator", () => { - test("title shows unread counter when the tab is hidden and resets on focus", async ({ + test("the title counts unread arrivals while hidden and resets on focus", async ({ page, request, }) => { @@ -512,11 +720,11 @@ test.describe("Endpoint screen", () => { document.dispatchEvent(new Event("visibilitychange")); }); - await post(request, endpointUrl, { data: "background-1" }); - await expect.poll(async () => page.title()).toContain(`(1)`); + await send(request, endpointUrl, { data: "background-1" }); + await expect.poll(async () => page.title()).toContain("(1)"); - await post(request, endpointUrl, { data: "background-2" }); - await expect.poll(async () => page.title()).toContain(`(2)`); + await send(request, endpointUrl, { data: "background-2" }); + await expect.poll(async () => page.title()).toContain("(2)"); // Bring the tab back to foreground. await page.evaluate(() => { @@ -527,7 +735,9 @@ test.describe("Endpoint screen", () => { document.dispatchEvent(new Event("visibilitychange")); }); - await expect.poll(async () => page.title()).toBe(`${testId} | httphq`); + await expect + .poll(async () => page.title()) + .toBe(`${endpointId} | httphq`); }); }); }); diff --git a/e2e/tests/home-screen.spec.ts b/e2e/tests/home-screen.spec.ts index 5cc6383..14ed7b8 100644 --- a/e2e/tests/home-screen.spec.ts +++ b/e2e/tests/home-screen.spec.ts @@ -5,48 +5,80 @@ test.describe("Home screen", () => { await page.goto("/"); }); - test("title should be correct", async ({ page }) => { - await expect(page).toHaveTitle( - "httphq: inspect HTTP requests in real time", - ); - }); + test.describe("Page", () => { + test("the title states what the product does", async ({ page }) => { + await expect(page).toHaveTitle( + "httphq: inspect HTTP requests in real time", + ); + }); - test("page renders the hero copy", async ({ page }) => { - await expect( - page.getByRole("heading", { name: /Inspect HTTP requests/ }), - ).toBeVisible(); - }); + test("the hero copy renders", async ({ page }) => { + await expect( + page.getByRole("heading", { name: /Inspect HTTP requests/ }), + ).toBeVisible(); + }); - test("create endpoint button is visible", async ({ page }) => { - await expect( - page.locator('button[data-test="create-endpoint"]'), - ).toBeVisible(); + // The landing page is the one surface a visitor judges the product on + // before trusting it with traffic, so it stays a pure document. + test("the page ships no javascript", async ({ page }) => { + await expect(page.locator("script")).toHaveCount(0); + }); }); - test("create endpoint button redirects to the endpoint screen", async ({ - page, - }) => { - await page.locator('button[data-test="create-endpoint"]').click(); - await expect(page).toHaveURL(/\/[a-z0-9-]+$/); - await expect(page.locator('[data-test="endpoint-url"]')).toBeVisible(); - }); + test.describe("Creating an endpoint", () => { + test("the create button is visible", async ({ page }) => { + await expect( + page.locator('button[data-test="create-endpoint"]'), + ).toBeVisible(); + }); - test("common use cases section is visible", async ({ page }) => { - const section = page.locator('[data-test="use-cases"]'); - await expect(section).toBeVisible(); - await expect(section).toContainText("Test webhooks"); - await expect(section).toContainText("Inspect payloads"); - }); + test("the create button lands on a working endpoint screen", async ({ + page, + }) => { + await page.locator('button[data-test="create-endpoint"]').click(); + await expect(page).toHaveURL(/\/[a-z0-9-]+$/); + await expect(page.locator('[data-test="endpoint-url"]')).toBeVisible(); + }); - test("example capture shows a rendered request", async ({ page }) => { - const example = page.locator('[data-test="example-capture"]'); - await expect(example).toBeVisible(); - await expect(example).toContainText("POST"); - await expect(example).toContainText("content-type"); - await expect(example).toContainText("payment_intent.succeeded"); + // The facts a visitor needs before pointing live traffic at a public URL, + // stated where the decision is made rather than after it. + test("the retention and visibility terms are stated beside the button", async ({ + page, + }) => { + await expect( + page.getByText("Requests are deleted after 4 hours"), + ).toBeVisible(); + await expect( + page.getByText("anyone with the URL can read them"), + ).toBeVisible(); + }); }); - test("the page ships no javascript", async ({ page }) => { - await expect(page.locator("script")).toHaveCount(0); + test.describe("Supporting sections", () => { + test("the use cases section is visible", async ({ page }) => { + const section = page.locator('[data-test="use-cases"]'); + await expect(section).toBeVisible(); + await expect(section).toContainText("Test webhooks"); + await expect(section).toContainText("Inspect payloads"); + }); + + test("the example capture shows a rendered request", async ({ page }) => { + const example = page.locator('[data-test="example-capture"]'); + await expect(example).toBeVisible(); + await expect(example).toContainText("POST"); + await expect(example).toContainText("content-type"); + await expect(example).toContainText("payment_intent.succeeded"); + }); + + test("the footer links to contact and to the repository", async ({ + page, + }) => { + await expect( + page.getByRole("link", { name: "Send us feedback" }), + ).toHaveAttribute("href", "/contact"); + await expect( + page.getByRole("link", { name: "formspark/httphq" }), + ).toHaveAttribute("href", "https://github.com/formspark/httphq"); + }); }); }); diff --git a/e2e/tests/support/harness.ts b/e2e/tests/support/harness.ts new file mode 100644 index 0000000..aa772aa --- /dev/null +++ b/e2e/tests/support/harness.ts @@ -0,0 +1,70 @@ +import type { APIRequestContext, Page } from "@playwright/test"; + +/** Where the server under test is reachable, matching the Playwright baseURL. */ +export const BASE_URL = "http://localhost:8080"; + +/** + * A fresh endpoint ID for one test. Endpoints are implicit — a page exists for + * any well-formed ID — so a unique ID per test is all the isolation needed to + * keep one test's captures out of another's stream. + */ +export const newEndpointId = () => + `e2e-${Math.random().toString(36).slice(2, 8)}`; + +/** The public capture URL for an endpoint, as printed on its page. */ +export const captureUrl = (endpointId: string) => + `${BASE_URL}/to/${endpointId}`; + +export type SendOptions = { + method?: string; + data?: string | object; + headers?: Record; +}; + +/** + * Sends a request straight to the capture URL, bypassing the page. Traffic + * under test comes from outside the browser, which is how a real user's + * webhook arrives. + */ +export const send = ( + request: APIRequestContext, + url: string, + { method = "POST", ...init }: SendOptions = {}, +) => request.fetch(url, { method, ...init }); + +export const readClipboard = (page: Page) => + page.evaluate(() => navigator.clipboard.readText()); + +/** + * Reads the clipboard as JSON of an expected shape. The assertion is the one + * place a shape is declared rather than proven — `JSON.parse` cannot know it — + * and it is deliberately confined here so no test has to make its own. + */ +export const readClipboardJson = async (page: Page): Promise => + JSON.parse(await readClipboard(page)) as T; + +/** + * The clipboard export shape. HAR 1.2 field names, but entries carry only a + * `request`: httphq never observes a response. + */ +export type HarNameValue = { name: string; value: string }; + +export type HarEntry = { + id: string; + startedDateTime: string; + clientIPAddress: string; + request: { + method: string; + url: string; + httpVersion: string; + headers: HarNameValue[]; + queryString: HarNameValue[]; + postData?: { mimeType: string; text: string }; + bodySize: number; + }; +}; + +export type HarDocument = { + creator: { name: string; version: string }; + entries: HarEntry[]; +}; diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..94aed2f --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,59 @@ +// Lint configuration for the two hand-written JavaScript surfaces: the page +// scripts in public/, which the browser loads as plain scripts with no build +// step, and the Playwright suite in e2e/, which is type-checked TypeScript. +// +// The Go application is linted by `go vet`; nothing here touches it. + +import js from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; + +export default tseslint.config( + { + ignores: [ + "**/node_modules/**", + "bin/**", + // Generated by `npm run css`, and minified onto one line. + "public/app.css", + "e2e/playwright-report/**", + "e2e/test-results/**", + ], + }, + + // Page scripts. No bundler and no module system: each file is a classic + // script that publishes what the next one needs on `window`, so the globals + // it defines and the ones it reads are both declared here. + { + files: ["public/**/*.js"], + extends: [js.configs.recommended], + languageOptions: { + ecmaVersion: 2022, + sourceType: "script", + globals: { + ...globals.browser, + // Loaded from a CDN on the endpoint page only. + Alpine: "readonly", + hljs: "readonly", + }, + }, + }, + + // Playwright suite. Type-aware linting, so an implicit `any` reaching an + // assertion is an error rather than a silently weaker test. + { + files: ["e2e/**/*.ts"], + extends: [js.configs.recommended, tseslint.configs.recommendedTypeChecked], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname + "/e2e", + }, + }, + }, + + // This file is the only Node-side script in the repository. + { + files: ["eslint.config.mjs"], + languageOptions: { globals: globals.node }, + }, +); diff --git a/package-lock.json b/package-lock.json index 1eed89d..68bd3fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,8 +6,207 @@ "": { "name": "httphq-assets", "devDependencies": { + "@eslint/js": "^10.0.1", "@tailwindcss/cli": "4.2.4", - "tailwindcss": "4.2.4" + "eslint": "^10.8.1", + "globals": "^17.9.0", + "tailwindcss": "4.2.4", + "typescript": "^6.0.3", + "typescript-eslint": "^8.66.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, "node_modules/@jridgewell/gen-mapping": { @@ -623,191 +822,901 @@ "node": ">= 20" } }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } + "license": "MIT" }, - "node_modules/enhanced-resolve": { - "version": "5.24.5", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", - "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.66.0.tgz", + "integrity": "sha512-p088eaGrzYz1s+7cov0aMOCkNGTJlVxF4jgubf28c8L0Cv9Rloj8YBHnv4hXLq6IIEE1AsjNWavO+k+8kP2Y0A==", "dev": true, "license": "MIT", "dependencies": { - "graceful-fs": "^4.2.4", - "tapable": "^2.3.3" + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/type-utils": "8.66.0", + "@typescript-eslint/utils": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" }, "engines": { - "node": ">=10.13.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.66.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", "dev": true, "license": "MIT", "engines": { - "node": ">=0.10.0" + "node": ">= 4" } }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "node_modules/@typescript-eslint/parser": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.66.0.tgz", + "integrity": "sha512-X6ypGChaWYk6PBtUg2BwuTZEFFcHJAtGTVJ9/lCTOufhZ4i9fNolQNnktq+kkMCwMj7V8Svsq7+TxSDslmhE0g==", "dev": true, "license": "MIT", "dependencies": { - "is-extglob": "^2.1.1" + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "debug": "^4.4.3" }, "engines": { - "node": ">=0.10.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/jiti": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", - "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.66.0.tgz", + "integrity": "sha512-7MthGPTt4BP69lSryqpqq8HQqxuzynssckL/jyDyk3+TNMQ3y2jFWkptCrktWvBrP+EH787Nl5N5Qpw7WZg+5g==", "dev": true, "license": "MIT", - "bin": { - "jiti": "lib/jiti-cli.mjs" + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.66.0", + "@typescript-eslint/types": "^8.66.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/lightningcss": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", - "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.66.0.tgz", + "integrity": "sha512-8TGcH25j9zqJ/IULB/ppyhRvxA8QYfFEZ7nfbg6/BN9spDgb8fPWQXlE5l8TWBL50EtUx007uZ1o9VOwrq2/9g==", "dev": true, - "license": "MPL-2.0", + "license": "MIT", "dependencies": { - "detect-libc": "^2.0.3" + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0" }, "engines": { - "node": ">= 12.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.32.0", - "lightningcss-darwin-arm64": "1.32.0", - "lightningcss-darwin-x64": "1.32.0", - "lightningcss-freebsd-x64": "1.32.0", - "lightningcss-linux-arm-gnueabihf": "1.32.0", - "lightningcss-linux-arm64-gnu": "1.32.0", - "lightningcss-linux-arm64-musl": "1.32.0", - "lightningcss-linux-x64-gnu": "1.32.0", - "lightningcss-linux-x64-musl": "1.32.0", - "lightningcss-win32-arm64-msvc": "1.32.0", - "lightningcss-win32-x64-msvc": "1.32.0" + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/lightningcss-android-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", - "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", - "cpu": [ - "arm64" - ], + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.66.0.tgz", + "integrity": "sha512-9D5gLYZG4rOjcoag8MQ/fWI8WqA9wcPDyOGyWtWFhvM1lHRbliqUSPIY5J3zqCU1tvSwzXxnnjhQhz5Ne7mJ4g==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], + "license": "MIT", "engines": { - "node": ">= 12.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", - "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", - "cpu": [ - "arm64" - ], + "node_modules/@typescript-eslint/type-utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.66.0.tgz", + "integrity": "sha512-LG2dWfjZQQp0ADtAu/EWJVayefGL2UEZ3CDeI44D9v3rXB/WYUqE/jpO28KrEKul5AySrmI+Zh1v6v+xW2U9+g==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/utils": "8.66.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, "engines": { - "node": ">= 12.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", - "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", - "cpu": [ - "x64" - ], + "node_modules/@typescript-eslint/types": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.66.0.tgz", + "integrity": "sha512-H6gcYaSDOyvL3AD/jHUtUFo2jqGgn/F6nuyuZSu0QTesxL+cP4dQoIMrODRofuJC09g64+WgZ6tE19Y1N2YIFQ==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], + "license": "MIT", "engines": { - "node": ">= 12.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://opencollective.com/typescript-eslint" } }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", - "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", - "cpu": [ - "x64" - ], + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.66.0.tgz", + "integrity": "sha512-8/x4INiiQb10jGgXYD7116/zQ+OL84ZIFn0za68wwFHCanT/VLbBEroWht8RV8fn0/ZCAoazHLQgwUC0UQcDfg==", "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.66.0", + "@typescript-eslint/tsconfig-utils": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/visitor-keys": "8.66.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, "engines": { - "node": ">= 12.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/parcel" + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" } }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.32.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", - "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", - "cpu": [ - "arm" - ], + "node_modules/@typescript-eslint/utils": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.66.0.tgz", + "integrity": "sha512-jasearZPolBw5NJNYGMwxzHMF83niVWmMU1VdHzG1CyfI2VS7f7nZltnKtHcg20hW+7Uo5GfK4MeDPoU3qI8EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.66.0", + "@typescript-eslint/types": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.66.0.tgz", + "integrity": "sha512-dkKR8q+lKciskj1Y3vthHktl+3cMLWGyVUP23bRiPZ5O9BRT++4EqDDV+TVeIKBL1VXVEqrJlz8MYbcnvJcAlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.66.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.8.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.8.1.tgz", + "integrity": "sha512-wqA7W2jbsC/BnV9Iv1UZpKVFkO1AdNoSmYW8NWG4HNOBbkAMvIqDZ27pI2f07dqn583NcIC44ckjAcOXDL1QbQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.9.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.9.0.tgz", + "integrity": "sha512-m/MvAW61QVU5VDNF1Vj8axt016h8w7L5TU1e9zlab7XIttAT2YAlCwl75K1fOqvMM9apmD7lbCIRhpfkhmxhCg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], "dev": true, "license": "MPL-2.0", "optional": true, @@ -948,6 +1857,22 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -958,6 +1883,22 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -968,6 +1909,20 @@ "node": ">=4" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -975,6 +1930,76 @@ "dev": true, "license": "MIT" }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -995,6 +2020,62 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1025,6 +2106,136 @@ "type": "opencollective", "url": "https://opencollective.com/webpack" } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.66.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.66.0.tgz", + "integrity": "sha512-QlEbBPz/RuJ1XUHj29nm3t0F/O/cSlEnntozqPOYHnnTGAXFamnMBu5i9Vn6vhUPHGAjR+Vl+5J8vPN/BMUrJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.66.0", + "@typescript-eslint/parser": "8.66.0", + "@typescript-eslint/typescript-estree": "8.66.0", + "@typescript-eslint/utils": "8.66.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/package.json b/package.json index ac93bf8..9f857a8 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,20 @@ { "name": "httphq-assets", "private": true, - "description": "Build-time stylesheet generation. The Go application does not depend on npm at runtime; public/app.css is committed and served from the binary's static directory.", + "description": "Build-time tooling: stylesheet generation and linting. The Go application does not depend on npm at runtime; public/app.css is committed and served from the binary's static directory.", "scripts": { "css": "tailwindcss --input ./src/styles/app.css --output ./public/app.css --minify", - "css:watch": "tailwindcss --input ./src/styles/app.css --output ./public/app.css --watch" + "css:watch": "tailwindcss --input ./src/styles/app.css --output ./public/app.css --watch", + "lint": "eslint .", + "lint:fix": "eslint . --fix" }, "devDependencies": { + "@eslint/js": "^10.0.1", "@tailwindcss/cli": "4.2.4", - "tailwindcss": "4.2.4" + "eslint": "^10.8.1", + "globals": "^17.9.0", + "tailwindcss": "4.2.4", + "typescript": "^6.0.3", + "typescript-eslint": "^8.66.0" } } diff --git a/public/app.css b/public/app.css index 5bf5867..64fbc9c 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{margin-bottom:calc(var(--spacing) * 2);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;display:block}.region-label{margin-bottom:calc(var(--spacing) * 2);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}.panel,.panel-flat{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)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.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}.uppercase{text-transform:uppercase}.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}.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 diff --git a/public/endpoint.js b/public/endpoint.js index a9c255e..97593ee 100644 --- a/public/endpoint.js +++ b/public/endpoint.js @@ -28,6 +28,9 @@ const RECONNECT_MIN_MS = 1_000; const RECONNECT_MAX_MS = 30_000; + // How long a copy button reads "Copied!" before returning to its label. + const COPIED_MS = 1_500; + function transformRequest(r) { return { ...r, createdAt: new Date(r.createdAt) }; } @@ -43,7 +46,11 @@ Alpine.store("main", { endpointId: null, + // `requests` is the current view: the search runs on the server and the + // response is windowed, so it is not the endpoint. `total` is, and it is + // what any control acting on the whole endpoint has to report. requests: [], + total: 0, search: "", methodFilter: "", @@ -79,19 +86,24 @@ .then((r) => r.json()) .then((d) => { this.requests = (d.requests || []).map(transformRequest); + this.total = d.total ?? this.requests.length; }) .catch((err) => console.error(err)); }, addRequest(request) { this.requests = [transformRequest(request), ...this.requests]; + this.total += 1; }, deleteRequests() { return fetch(`/api/endpoints/${this.endpointId}/requests`, { method: "DELETE", }) - .then(() => (this.requests = [])) + .then(() => { + this.requests = []; + this.total = 0; + }) .catch((err) => console.error(err)); }, @@ -101,6 +113,7 @@ }) .then(() => { this.requests = this.requests.filter((r) => r.uuid !== uuid); + this.total = Math.max(0, this.total - 1); }) .catch((err) => console.error(err)); }, @@ -153,10 +166,10 @@ }); }, - // Which of the three empty states applies. They are distinct on purpose: - // "nothing has arrived", "your filter hides everything" and "the - // connection is gone" are different facts and previously shared one panel - // that asserted the first regardless of which was true. + // Which empty state applies. They are distinct on purpose: "nothing has + // arrived" and "your filter hides everything" are different facts, and a + // panel that asserts the first while the second is true tells the user + // their traffic never landed. get streamState() { if (Alpine.store("main").visibleRequests.length > 0) return "list"; if (Alpine.store("main").filtered) return "filtered"; @@ -197,7 +210,7 @@ let request; try { request = JSON.parse(payload.data); - } catch (_) { + } catch { return; } Alpine.store("main").addRequest(request); @@ -262,35 +275,35 @@ this.announcement = message; }, - async copy(text, key) { + // Writes text to the clipboard and flashes the button that asked for it. + // `key` names that button: several share this one component instance, so + // a single label field can't tell them apart. + async _copyAndFlash(text, key, announcement) { try { await window.copyToClipboard(text); this.copiedKey = key; - this.announce("Copied to clipboard"); + this.announce(announcement); setTimeout(() => { if (this.copiedKey === key) this.copiedKey = null; - }, 1500); + }, COPIED_MS); } catch (err) { console.error(err); this.announce("Copy failed"); } }, - // Copies requests as a HAR-shaped document. `key` names the button that - // should read "Copied!" — several buttons share this one component - // instance, so a single label field can't tell them apart. - async copyHar(requests, key) { - try { - await window.copyToClipboard(window.buildHarExport(requests)); - this.copiedKey = key; - this.announce(`Copied ${requests.length} requests to clipboard`); - setTimeout(() => { - if (this.copiedKey === key) this.copiedKey = null; - }, 1500); - } catch (err) { - console.error(err); - this.announce("Copy failed"); - } + copy(text, key) { + return this._copyAndFlash(text, key, "Copied to clipboard"); + }, + + // Copies requests as a HAR-shaped document. + copyHar(requests, key) { + const count = requests.length; + return this._copyAndFlash( + window.buildHarExport(requests), + key, + `Copied ${count} ${count === 1 ? "request" : "requests"} to clipboard`, + ); }, confirmDeleteAll() { @@ -301,7 +314,9 @@ const count = Alpine.store("main").requests.length; this.pendingDeleteAll = false; await Alpine.store("main").deleteRequests(); - this.announce(`Deleted ${count} requests`); + this.announce( + `Deleted ${count} ${count === 1 ? "request" : "requests"}`, + ); }, async sendCustom() { diff --git a/public/har.js b/public/har.js index 79d757f..08cba33 100644 --- a/public/har.js +++ b/public/har.js @@ -3,7 +3,8 @@ The field names and value shapes follow HAR 1.2 so the output is familiar to HAR tooling, but entries carry only a `request`: httphq never observes a response, so `response`, `timings` and `cache` are omitted rather than - emitted as stubs that would read as captured data. Loaded on every page. */ + emitted as stubs that would read as captured data. Loaded on the endpoint + page only. */ (function () { const CREATOR = { name: "httphq", version: "1" }; diff --git a/public/render-body.js b/public/render-body.js index 7571037..7cd82f7 100644 --- a/public/render-body.js +++ b/public/render-body.js @@ -141,7 +141,7 @@ window.renderBody = function (body, headers) { // Best-effort JSON pretty + highlight. try { return highlightPrettyJSON(JSON.parse(body)); - } catch (_) { + } catch { // not JSON } // Heuristic: looks like XML/HTML if it starts with '<' diff --git a/src/api.go b/src/api.go new file mode 100644 index 0000000..268ee06 --- /dev/null +++ b/src/api.go @@ -0,0 +1,57 @@ +package main + +import ( + "net/http" + + "github.com/gofiber/fiber/v3" + + "httphq/src/database" +) + +// requestPageSize bounds one listing response. The page renders a window of the +// stream rather than its history, and an endpoint under load would otherwise +// return a payload no reader can use. +const requestPageSize = 128 + +func handleHealth(c fiber.Ctx) error { + return c.SendStatus(http.StatusOK) +} + +// handleDebug reports coarse process state. It carries no captured data: the +// counts say how much traffic the process is holding, never what was in it. +func handleDebug(registry *socketRegistry) fiber.Handler { + return func(c fiber.Ctx) error { + return c.JSON(fiber.Map{ + "host": string(c.Request().Host()), + "isProduction": isProduction, + "requests": database.CountRequests(c.Context()), + "sockets": registry.count(), + }) + } +} + +// 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. +func handleListRequests(c fiber.Ctx) error { + endpointID := c.Params("endpoint") + return c.JSON(fiber.Map{ + "requests": database.GetRequestsForEndpointID( + c.Context(), endpointID, c.Query("search"), requestPageSize), + "total": database.CountRequestsForEndpointID(c.Context(), endpointID), + }) +} + +func handleDeleteRequests(c fiber.Ctx) error { + database.DeleteRequestsForEndpointID(c.Context(), c.Params("endpoint")) + return c.SendStatus(http.StatusOK) +} + +// handleDeleteRequest deletes one capture by UUID. The endpoint ID is validated +// upstream but the UUID is not scoped to it, so this is a delete-by-key on a +// value the caller already had to read from that endpoint's stream. +func handleDeleteRequest(c fiber.Ctx) error { + database.DeleteRequestForUUID(c.Context(), c.Params("request")) + return c.SendStatus(http.StatusOK) +} diff --git a/src/application.go b/src/application.go index bd2b964..2feea8c 100644 --- a/src/application.go +++ b/src/application.go @@ -2,31 +2,19 @@ package main import ( "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "io" "log/slog" - "net" "net/http" "os" - "regexp" "strconv" - "strings" - "sync" "time" "github.com/atrox/haikunatorgo/v2" - "github.com/gofiber/contrib/v3/socketio" - "github.com/gofiber/contrib/v3/websocket" "github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3/middleware/compress" "github.com/gofiber/fiber/v3/middleware/limiter" "github.com/gofiber/fiber/v3/middleware/static" "github.com/gofiber/template/html/v2" - "github.com/google/uuid" "github.com/robfig/cron/v3" - "gorm.io/datatypes" "httphq/src/database" "httphq/src/logging" @@ -35,363 +23,33 @@ import ( const ( port = 8080 bodyLimit = 1 << 20 // 1 MiB -) - -var isProduction = os.Getenv("APPLICATION_ENV") == "production" - -// Generic forwarding headers stripped from every captured request so users -// see their original payload, not infrastructure-added headers. Vendor headers -// specific to a hosting platform are stripped separately, see platformConfig. -var omittedHeaders = [...]string{ - "Cdn-Loop", - "Trace", - "Traceparent", - "Tracestate", - "Via", - "X-Forwarded-For", - "X-Forwarded-Host", - "X-Forwarded-Port", - "X-Forwarded-Proto", - "X-Forwarded-Server", - "X-Forwarded-Ssl", - "X-Real-Ip", - "X-Request-Start", -} - -// platformConfig describes how a hosting platform exposes request metadata: -// which header carries the real client IP, and which vendor headers it adds -// that should be hidden from captured requests — users inspect their own -// traffic and shouldn't have to care which provider sits in front of httphq. -type platformConfig struct { - ipHeader string // header with the real client IP; "" = TCP peer - ipList bool // ipHeader is a comma-separated list; take the leftmost - stripPrefix []string // captured-request header prefixes to drop as vendor noise -} - -// platforms maps the PLATFORM env var to its config. Each platform overwrites -// (or reliably sets) its own headers; the operator is responsible for ensuring -// traffic cannot reach the app bypassing the platform. -var platforms = map[string]platformConfig{ - "direct": {}, - "cloudflare": {ipHeader: "Cf-Connecting-Ip", stripPrefix: []string{"Cf-"}}, - "fly": {ipHeader: "Fly-Client-Ip", stripPrefix: []string{"Fly-"}}, - "heroku": {ipHeader: "X-Forwarded-For", ipList: true}, - "render": {ipHeader: "X-Forwarded-For", ipList: true}, - "proxy": {ipHeader: "X-Forwarded-For", ipList: true}, -} - -// currentPlatform is the config resolved once from PLATFORM at startup. -var currentPlatform platformConfig - -// resolvePlatform maps a PLATFORM value to its config. An empty value means -// "direct"; an unrecognised value fails safe to "direct" so a typo never -// causes a spoofable header to be trusted. -func resolvePlatform(name string) platformConfig { - name = strings.ToLower(strings.TrimSpace(name)) - if name == "" { - name = "direct" - } - if p, ok := platforms[name]; ok { - return p - } - slog.Warn("unknown PLATFORM, falling back to direct", "platform", name) - return platforms["direct"] -} - -// contentSecurityPolicy is the CSP sent on every response, assembled once at -// startup because its only variable part is fixed for the process lifetime. -// -// - script-src needs 'unsafe-eval' for Alpine, which compiles directive -// expressions via the Function constructor, and the two CDN origins that -// serve Alpine and the syntax highlighter to the endpoint page. All page -// scripts are external so script-src does NOT need 'unsafe-inline'. -// - style-src needs the CDN origin for the highlighter's theme, and -// 'unsafe-inline' because Alpine's x-show toggles visibility through an -// inline display style. The application's own stylesheet is first-party -// and covered by 'self'. -// - The local design-tooling origin is allowed in development only: it serves -// an injected picker script and opens a socket back to itself. Production -// must never carry it, so it is gated on the environment rather than on a -// request-time condition a client could influence. -var contentSecurityPolicy = buildContentSecurityPolicy() - -func buildContentSecurityPolicy() string { - designTooling := "" - if !isProduction { - designTooling = " http://localhost:8400" - } - return "default-src 'self'; " + - "script-src 'self' 'unsafe-eval' https://unpkg.com https://cdn.jsdelivr.net" + designTooling + "; " + - "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; " + - "img-src 'self' data:; " + - "connect-src 'self' ws: wss:" + designTooling + "; " + - "frame-ancestors 'none'" -} - -// assetVersions maps a served static path to a short hash of its contents, -// computed once at startup. The stylesheet and the page scripts live at fixed -// paths, so without a version in the URL a CDN or a browser can pair a cached -// copy of one deploy with the HTML of the next: every class name the new markup -// asks for resolves to nothing and the page renders unstyled. Versioning the URL -// makes a changed asset a different URL, which no cache can confuse for the old -// one. -var assetVersions = map[string]string{} - -// versionedAssets are the first-party files referenced from the templates whose -// contents change between deploys. Images are excluded: they are replaced rarely -// and never in a way that breaks a page that fetched the previous copy. -var versionedAssets = []string{ - "/app.css", - "/index.js", - "/render-body.js", - "/har.js", - "/endpoint.js", -} - -func hashAsset(path string) string { - f, err := os.Open("./public" + path) - if err != nil { - slog.Warn("asset missing, serving unversioned", "path", path, "err", err) - return "" - } - defer f.Close() - h := sha256.New() - if _, err := io.Copy(h, f); err != nil { - slog.Warn("asset unreadable, serving unversioned", "path", path, "err", err) - return "" - } - return hex.EncodeToString(h.Sum(nil))[:10] -} - -// assetURL is exposed to templates as `asset`. Production reads the map computed -// at startup. Development re-hashes, but only when the file's modification time -// has moved, so an edited stylesheet is picked up without a restart and without -// re-reading every asset on every render. -var assetMu sync.Mutex -var assetStamps = map[string]time.Time{} - -func assetURL(path string) string { - if isProduction { - if version := assetVersions[path]; version != "" { - return path + "?v=" + version - } - return path - } - - assetMu.Lock() - defer assetMu.Unlock() - info, err := os.Stat("./public" + path) - if err == nil && !info.ModTime().Equal(assetStamps[path]) { - assetStamps[path] = info.ModTime() - assetVersions[path] = hashAsset(path) - } - if version := assetVersions[path]; version != "" { - return path + "?v=" + version - } - return path -} - -// trustedProxyConfig lists the peers whose X-Forwarded-* headers Fiber may -// honour once TrustProxy is enabled. httphq is only ever fronted by a reverse -// proxy that reaches it from a private, loopback or link-local address; no -// legitimate direct client connects from those ranges. Bounding trust to them -// means a public client that reaches the app directly still can't spoof the -// scheme or client IP. -func trustedProxyConfig() fiber.TrustProxyConfig { - return fiber.TrustProxyConfig{ - Private: true, - Loopback: true, - LinkLocal: true, - } -} - -// 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) { - websocketScheme := "ws" - if scheme == "https" { - websocketScheme = "wss" - } - return scheme + "://" + host + "/to/" + endpointID, - websocketScheme + "://" + host + "/ws/" + endpointID -} - -// pageBaseURL is the scheme+host a rendered page is being served from, used to -// build the absolute URLs that canonical and Open Graph tags require. It tracks -// the request rather than a configured hostname so a self-hosted deployment -// advertises itself, not httphq.com. -func pageBaseURL(c fiber.Ctx) string { - return c.Scheme() + "://" + string(c.Request().Host()) -} - -// omitHeader reports whether a captured-request header is infrastructure noise -// — a generic forwarding header or a vendor header added by the configured -// PLATFORM — and so should be hidden from the user. -func omitHeader(name string) bool { - for _, h := range omittedHeaders { - if strings.EqualFold(name, h) { - return true - } - } - for _, prefix := range currentPlatform.stripPrefix { - if len(name) >= len(prefix) && strings.EqualFold(name[:len(prefix)], prefix) { - return true - } - } - return false -} - -// endpointIDPattern bounds an endpoint ID to the shape haikunator emits -// (lowercase words and digits joined by hyphens). Rejecting anything else -// keeps attacker-controlled characters — quotes, angle brackets, parens — -// out of the rendered pages, the database, and the logs. -var endpointIDPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) - -func validEndpointID(id string) bool { - return len(id) <= 64 && endpointIDPattern.MatchString(id) -} - -// socketRegistry tracks WS UUIDs subscribed to each endpoint, so the capture -// hot path can fan-out without a DB round-trip. -type socketRegistry struct { - mu sync.RWMutex - byEnd map[string]map[string]struct{} -} - -func newSocketRegistry() *socketRegistry { - return &socketRegistry{byEnd: make(map[string]map[string]struct{})} -} - -func (r *socketRegistry) add(endpointID, uuid string) { - r.mu.Lock() - defer r.mu.Unlock() - set, ok := r.byEnd[endpointID] - if !ok { - set = make(map[string]struct{}) - r.byEnd[endpointID] = set - } - set[uuid] = struct{}{} -} - -func (r *socketRegistry) remove(uuid string) { - r.mu.Lock() - defer r.mu.Unlock() - for endpointID, set := range r.byEnd { - if _, ok := set[uuid]; ok { - delete(set, uuid) - if len(set) == 0 { - delete(r.byEnd, endpointID) - } - return - } - } -} - -func (r *socketRegistry) uuidsFor(endpointID string) []string { - r.mu.RLock() - defer r.mu.RUnlock() - set := r.byEnd[endpointID] - if len(set) == 0 { - return nil - } - out := make([]string, 0, len(set)) - for u := range set { - out = append(out, u) - } - return out -} - -func (r *socketRegistry) count() int { - r.mu.RLock() - defer r.mu.RUnlock() - n := 0 - for _, set := range r.byEnd { - n += len(set) - } - return n -} - -// resolveClientIP returns the real client IP per the configured PLATFORM -// strategy: it reads the platform's client-IP header (leftmost entry when the -// header is a list) and validates it parses. With no platform configured, or -// when the header is missing or malformed, it falls back to the TCP peer. -func resolveClientIP(c fiber.Ctx) string { - if currentPlatform.ipHeader != "" { - v := c.Get(currentPlatform.ipHeader) - if currentPlatform.ipList { - if i := strings.IndexByte(v, ','); i >= 0 { - v = v[:i] - } - } - if ip := net.ParseIP(trimSpace(v)); ip != nil { - return ip.String() - } - } - if ip := net.ParseIP(c.IP()); ip != nil { - return ip.String() - } - return "" -} - -func trimSpace(s string) string { - for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') { - s = s[1:] - } - for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\t') { - s = s[:len(s)-1] - } - return s -} - -func main() { - /* Logging */ - - env := os.Getenv("APPLICATION_ENV") - if env == "" { - env = "development" - } - logging.Init("httphq", env) - - /* Client IP strategy */ - - currentPlatform = resolvePlatform(os.Getenv("PLATFORM")) - slog.Info("platform resolved", - "platform", os.Getenv("PLATFORM"), "ip_header", currentPlatform.ipHeader) - - /* Database */ - - database.Connect("file:local.db?_journal_mode=WAL&_busy_timeout=5000&_synchronous=NORMAL") - - /* Haiku maker */ - - haikuMaker := haikunator.New() - - /* Cron */ - - c := cron.New() - - everyFiveMinutes := "*/5 * * * *" - if _, err := c.AddFunc(everyFiveMinutes, func() { - threshold := time.Now().Add(-1 * 4 * time.Hour) - database.DeleteOldRequests(context.Background(), threshold) - }); err != nil { - slog.Error("cron job registration failed", "err", err) - os.Exit(1) - } - c.Start() + // Captured data is ephemeral by design. The window is stated on the landing + // page and on every endpoint page; changing it here changes a promise. + retentionWindow = 4 * time.Hour + retentionSweep = "*/5 * * * *" - /* Server */ - - engine := html.New("./src/views", ".html") - - for _, path := range versionedAssets { - assetVersions[path] = hashAsset(path) - } - engine.AddFunc("asset", assetURL) + // Development runs effectively unlimited so a page under test is never + // throttled; production bounds one client's share of a shared instance. + developmentRequestsPerMinute = 9999 + productionRequestsPerMinute = 125 +) +// applicationConfig locates the files the app serves and the socket registry it +// fans captures out to. The paths are arguments rather than constants so the +// process can be driven from a directory other than the repository root. +type applicationConfig struct { + viewsDir string + publicDir string + registry *socketRegistry +} + +// newApplication builds the fully wired HTTP application. Everything it needs +// beyond the database is passed in, so the whole routing surface can be +// exercised without a listening socket. +func newApplication(config applicationConfig) *fiber.App { + engine := html.New(config.viewsDir, ".html") + engine.AddFunc("asset", newAssetIndex(config.publicDir, !isProduction).url) if !isProduction { engine.Reload(true) engine.Debug(true) @@ -422,9 +80,9 @@ func main() { // correlation ID and a structured access-log line. application.Use(requestLogger) - maxRequestsPerMinute := 9999 + maxRequestsPerMinute := developmentRequestsPerMinute if isProduction { - maxRequestsPerMinute = 125 + maxRequestsPerMinute = productionRequestsPerMinute } application.Use(limiter.New(limiter.Config{ Max: maxRequestsPerMinute, @@ -436,229 +94,72 @@ func main() { })) application.Use(compress.New()) + application.Use(securityHeaders(contentSecurityPolicy(!isProduction))) - // Security headers - application.Use(func(c fiber.Ctx) error { - c.Set("X-Content-Type-Options", "nosniff") - c.Set("Referrer-Policy", "no-referrer") - c.Set("X-Frame-Options", "DENY") - c.Set("Content-Security-Policy", contentSecurityPolicy) - return c.Next() - }) - - // Static handling - application.Get("/*", static.New("./public")) - - // WS handling - registry := newSocketRegistry() - - application.Use("/ws", func(c fiber.Ctx) error { - if websocket.IsWebSocketUpgrade(c) { - c.Locals("allowed", true) - return c.Next() - } - return fiber.ErrUpgradeRequired - }) - - application.Get("/ws/:endpoint", func(c fiber.Ctx) error { - if !validEndpointID(c.Params("endpoint")) { - return c.SendStatus(http.StatusNotFound) - } - return c.Next() - }, socketio.New(func(kws *socketio.Websocket) { - endpointID := kws.Params("endpoint") - kws.SetAttribute("endpointID", endpointID) - registry.add(endpointID, kws.UUID) - slog.Info("websocket connected", "endpoint_id", endpointID) - })) - - socketio.On(socketio.EventDisconnect, func(ep *socketio.EventPayload) { - registry.remove(ep.Kws.UUID) - }) - - socketio.On(socketio.EventClose, func(ep *socketio.EventPayload) { - registry.remove(ep.Kws.UUID) - }) - - // HTTP handling - - application.Get("/api/health", func(c fiber.Ctx) error { - return c.SendStatus(http.StatusOK) - }) - - application.Get("/api/debug", func(c fiber.Ctx) error { - return c.JSON(fiber.Map{ - "host": string(c.Request().Host()), - "isProduction": isProduction, - "requests": database.CountRequests(c.Context()), - "sockets": registry.count(), - }) - }) - - application.Get("/api/endpoints/:endpoint/requests", func(c fiber.Ctx) error { - endpointID := c.Params("endpoint") - if !validEndpointID(endpointID) { - return c.SendStatus(http.StatusNotFound) - } - search := c.Query("search") - requests := database.GetRequestsForEndpointID(c.Context(), endpointID, search, 128) - return c.JSON(fiber.Map{ - "requests": requests, - }) - }) - - application.Delete("/api/endpoints/:endpoint/requests", func(c fiber.Ctx) error { - endpointID := c.Params("endpoint") - if !validEndpointID(endpointID) { - return c.SendStatus(http.StatusNotFound) - } - database.DeleteRequestsForEndpointID(c.Context(), endpointID) - return c.SendStatus(http.StatusOK) - }) + application.Get("/*", static.New(config.publicDir)) - application.Delete("/api/endpoints/:endpoint/requests/:request", func(c fiber.Ctx) error { - if !validEndpointID(c.Params("endpoint")) { - return c.SendStatus(http.StatusNotFound) - } - requestUUID := c.Params("request") - database.DeleteRequestForUUID(c.Context(), requestUUID) - return c.SendStatus(http.StatusOK) - }) + registerWebSockets(application, config.registry) - application.Get("/", func(c fiber.Ctx) error { - base := pageBaseURL(c) - return c.Render("index", fiber.Map{ - "Title": "httphq: inspect HTTP requests in real time", - "Description": "Generate a unique URL, point any client at it, and watch every request arrive: method, headers, body, query string, client IP. No account, free forever.", - "Canonical": base + "/", - "SocialImage": base + "/social-card.png", - }) - }) + application.Get("/api/health", handleHealth) + application.Get("/api/debug", handleDebug(config.registry)) + application.Get("/api/endpoints/:endpoint/requests", requireValidEndpoint, handleListRequests) + application.Delete("/api/endpoints/:endpoint/requests", requireValidEndpoint, handleDeleteRequests) + application.Delete("/api/endpoints/:endpoint/requests/:request", requireValidEndpoint, handleDeleteRequest) - application.Get("/contact", func(c fiber.Ctx) error { - base := pageBaseURL(c) - return c.Render("contact", fiber.Map{ - "Title": "Contact | httphq", - "Description": "Found a bug, have an idea, or want to say hi? Get in touch with the people who build httphq.", - "Canonical": base + "/contact", - "SocialImage": base + "/social-card.png", - }) - }) + application.Get("/", renderIndex) + application.Get("/contact", renderContact) + application.Get("/:endpoint", requireValidEndpoint, renderEndpoint) + application.Post("/endpoint", createEndpoint(haikunator.New())) - application.Get("/:endpoint", func(c fiber.Ctx) error { - endpointID := c.Params("endpoint") - if !validEndpointID(endpointID) { - return c.SendStatus(http.StatusNotFound) - } - host := string(c.Request().Host()) - endpointURL, websocketURL := endpointURLs(c.Scheme(), 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.", - // No canonical: endpoint pages are per-user surfaces excluded by - // robots.txt, and pointing them at a shared URL would be a lie. - "AppScripts": true, - "EndpointID": endpointID, - "EndpointURL": endpointURL, - "EndpointWebSocketURL": websocketURL, - }) - }) + // Prefix-matched so everything after the endpoint ID is captured as the + // request path, and method-agnostic because any method is a valid capture. + application.Use("/to/:endpoint", requireValidEndpoint, captureRequest(config.registry)) - application.Post("/endpoint", func(c fiber.Ctx) error { - endpointID := haikuMaker.Haikunate() - slog.InfoContext(c.Context(), "endpoint created", "endpoint_id", endpointID) - return c.Redirect().To("/" + endpointID) + application.Use(func(c fiber.Ctx) error { + return c.SendStatus(http.StatusNotFound) }) - application.Use("/to/:endpoint", func(c fiber.Ctx) error { - endpointID := c.Params("endpoint") - if !validEndpointID(endpointID) { - return c.SendStatus(http.StatusNotFound) - } - - requestUUID := uuid.NewString() - - ip := resolveClientIP(c) - - method := c.Method() - - path := c.Path() - - queryString := string(c.Request().URI().QueryString()) - - body := c.Body() - - headers := c.GetReqHeaders() - - if spoofCurl, ok := headers["Httphq-Spoof-Curl"]; ok && len(spoofCurl) > 0 && spoofCurl[0] == "true" { - delete(headers, "Accept-Encoding") - delete(headers, "Accept-Language") - delete(headers, "Connection") - delete(headers, "Httphq-Spoof-Curl") - delete(headers, "Origin") - delete(headers, "Referer") - delete(headers, "Sec-Fetch-Dest") - delete(headers, "Sec-Fetch-Mode") - delete(headers, "Sec-Fetch-Site") - delete(headers, "Sec-Ch-Ua") - delete(headers, "Sec-Ch-Ua-Mobile") - delete(headers, "Sec-Ch-Ua-Platform") + return application +} - headers["Content-Type"] = []string{"application/x-www-form-urlencoded"} - headers["User-Agent"] = []string{"curl/7.79.1"} - } - for k := range headers { - if omitHeader(k) { - delete(headers, k) - } - } +// startRetentionSweep drops captures older than the retention window on a +// schedule. It returns the stopped-on-exit scheduler so the caller owns its +// lifetime. +func startRetentionSweep() *cron.Cron { + scheduler := cron.New() + if _, err := scheduler.AddFunc(retentionSweep, func() { + database.DeleteOldRequests(context.Background(), time.Now().Add(-retentionWindow)) + }); err != nil { + slog.Error("cron job registration failed", "err", err) + os.Exit(1) + } + scheduler.Start() + return scheduler +} - // Flatten []string headers to a {key: scalar-or-array} JSON; the UI - // expects either string or string[] per RFC 7230 ambiguity. - flatHeaders := make(map[string]any, len(headers)) - for k, vs := range headers { - if len(vs) == 1 { - flatHeaders[k] = vs[0] - } else { - flatHeaders[k] = vs - } - } - jsonHeaders, err := json.Marshal(flatHeaders) - if err != nil { - slog.ErrorContext(c.Context(), "request header marshal failed", "err", err) - } +func main() { + env := os.Getenv("APPLICATION_ENV") + if env == "" { + env = "development" + } + logging.Init("httphq", env) - request := database.Request{ - UUID: requestUUID, - EndpointID: endpointID, - IP: ip, - Method: method, - Path: path, - QueryString: queryString, - Body: string(body), - Headers: datatypes.JSON(jsonHeaders), - } - database.CreateRequest(c.Context(), &request) + currentPlatform = resolvePlatform(os.Getenv("PLATFORM")) + slog.Info("platform resolved", + "platform", os.Getenv("PLATFORM"), "ip_header", currentPlatform.ipHeader) - // Fan-out to subscribed WS clients. Marshal once, then dispatch - // asynchronously so a slow client doesn't stall the capture handler. - if uuids := registry.uuidsFor(endpointID); len(uuids) > 0 { - marshalled, marshalErr := json.Marshal(request) - if marshalErr != nil { - slog.ErrorContext(c.Context(), "websocket payload marshal failed", "err", marshalErr) - } else { - go socketio.EmitToList(uuids, marshalled) - } - } + database.Connect("file:local.db?_journal_mode=WAL&_busy_timeout=5000&_synchronous=NORMAL") - c.Set("Httphq-Request-Uuid", requestUUID) - return c.SendStatus(http.StatusOK) - }) + startRetentionSweep() - application.Use(func(c fiber.Ctx) error { - return c.SendStatus(http.StatusNotFound) + application := newApplication(applicationConfig{ + viewsDir: "./src/views", + publicDir: "./public", + registry: newSocketRegistry(), }) + // Development binds loopback only, so a work-in-progress capture surface is + // not reachable from the network the machine happens to be on. host := "localhost:" if isProduction { host = ":" diff --git a/src/application_test.go b/src/application_test.go deleted file mode 100644 index 67c907c..0000000 --- a/src/application_test.go +++ /dev/null @@ -1,136 +0,0 @@ -package main - -import ( - "net" - "testing" - - "github.com/gofiber/fiber/v3" - "github.com/stretchr/testify/assert" - "github.com/valyala/fasthttp" -) - -// schemeFor spins up a request from peerIP carrying the given -// X-Forwarded-Proto (empty = header absent) and returns the scheme Fiber -// resolves for it. The connection itself is plain HTTP, so anything other -// than "http" means the forwarded header was trusted and honoured. -func schemeFor(t *testing.T, app *fiber.App, peerIP, forwardedProto string) string { - t.Helper() - - fctx := &fasthttp.RequestCtx{} - fctx.SetRemoteAddr(&net.TCPAddr{IP: net.ParseIP(peerIP)}) - - c := app.AcquireCtx(fctx) - defer app.ReleaseCtx(c) - - if forwardedProto != "" { - c.Request().Header.Set(fiber.HeaderXForwardedProto, forwardedProto) - } - return c.Scheme() -} - -// proxyTrustApp mirrors the production Fiber config for a request that -// arrives with a PLATFORM configured (TrustProxy enabled): the app trusts -// the fronting proxy on the ranges from trustedProxyConfig. -func proxyTrustApp() *fiber.App { - return fiber.New(fiber.Config{ - TrustProxy: true, - TrustProxyConfig: trustedProxyConfig(), - }) -} - -func TestEndpointURLs(t *testing.T) { - cases := []struct { - name string - scheme string - wantEndURL string - wantSockURL string - }{ - { - name: "https yields wss", - scheme: "https", - wantEndURL: "https://httphq.com/to/purple-frog-0691", - wantSockURL: "wss://httphq.com/ws/purple-frog-0691", - }, - { - name: "http yields ws", - scheme: "http", - wantEndURL: "http://httphq.com/to/purple-frog-0691", - wantSockURL: "ws://httphq.com/ws/purple-frog-0691", - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - endURL, sockURL := endpointURLs(tc.scheme, "httphq.com", "purple-frog-0691") - assert.Equal(t, tc.wantEndURL, endURL) - assert.Equal(t, tc.wantSockURL, sockURL) - }) - } -} - -// TestSchemeHonorsForwardedProtoFromTrustedProxy checks that with TrustProxy -// enabled and a trusted proxy peer (a private, loopback or link-local -// address), the app honours X-Forwarded-Proto: https and reports the https -// scheme, so a request served over HTTPS behind the proxy renders https:// -// and wss:// URLs. -func TestSchemeHonorsForwardedProtoFromTrustedProxy(t *testing.T) { - app := proxyTrustApp() - - // One address from each range trustedProxyConfig covers. - peers := map[string]string{ - "private 10/8": "10.0.0.1", - "private 172.16/12": "172.16.5.4", - "private 192.168/16": "192.168.1.10", - "loopback": "127.0.0.1", - "link-local": "169.254.10.20", - } - - for name, ip := range peers { - t.Run(name, func(t *testing.T) { - assert.Equal(t, "https", schemeFor(t, app, ip, "https"), - "trusted proxy %s should have its X-Forwarded-Proto honoured", ip) - }) - } -} - -// TestSchemeIgnoresForwardedProtoFromUntrustedPeer checks that a peer outside -// the trusted ranges (a public address) has its X-Forwarded-Proto ignored: the -// app reports the real connection scheme, so the scheme and client IP can't be -// forged from there. -func TestSchemeIgnoresForwardedProtoFromUntrustedPeer(t *testing.T) { - app := proxyTrustApp() - - assert.Equal(t, "http", schemeFor(t, app, "203.0.113.7", "https"), - "a public peer must not be able to forge X-Forwarded-Proto") -} - -// TestSchemeDirectModeIgnoresForwardedProto checks that with no PLATFORM -// configured TrustProxy is off, so the app reports the real connection scheme -// and ignores X-Forwarded-Proto even from a private-range peer. -func TestSchemeDirectModeIgnoresForwardedProto(t *testing.T) { - direct := fiber.New(fiber.Config{ - TrustProxy: false, - TrustProxyConfig: trustedProxyConfig(), - }) - - assert.Equal(t, "http", schemeFor(t, direct, "10.0.0.1", "https"), - "direct mode must ignore X-Forwarded-Proto entirely") -} - -// TestResolvePlatformGatesProxyTrust checks the link between PLATFORM and -// proxy trust: TrustProxy is `ipHeader != ""`, so proxy-fronted platforms -// enable trust while an empty or unknown value resolves to direct with no -// trust. -func TestResolvePlatformGatesProxyTrust(t *testing.T) { - trusts := []string{"proxy", "cloudflare", "fly", "heroku", "render"} - for _, name := range trusts { - assert.NotEmpty(t, resolvePlatform(name).ipHeader, - "%s should enable proxy trust", name) - } - - noTrust := []string{"", "direct", "not-a-platform"} - for _, name := range noTrust { - assert.Empty(t, resolvePlatform(name).ipHeader, - "%q should fall back to direct (no proxy trust)", name) - } -} diff --git a/src/assets.go b/src/assets.go new file mode 100644 index 0000000..400b67c --- /dev/null +++ b/src/assets.go @@ -0,0 +1,96 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "io" + "log/slog" + "os" + "sync" + "time" +) + +// versionedAssets are the first-party files referenced from the templates whose +// contents change between deploys. Images are excluded: they are replaced rarely +// and never in a way that breaks a page that fetched the previous copy. +var versionedAssets = []string{ + "/app.css", + "/index.js", + "/render-body.js", + "/har.js", + "/endpoint.js", +} + +// assetIndex maps a served static path to a short hash of its contents. The +// stylesheet and the page scripts live at fixed paths, so without a version in +// the URL a CDN or a browser can pair a cached copy of one deploy with the HTML +// of the next: every class name the new markup asks for resolves to nothing and +// the page renders unstyled. Versioning the URL makes a changed asset a +// different URL, which no cache can confuse for the old one. +type assetIndex struct { + dir string + // reload re-hashes an asset whose modification time has moved, so an edit + // is picked up without a restart. It is off wherever the files cannot + // change under a running process, which spares every render a stat call. + reload bool + + mu sync.Mutex + versions map[string]string + stamps map[string]time.Time +} + +// newAssetIndex hashes every versioned asset once, so a render never pays for +// reading a file that has not changed. +func newAssetIndex(dir string, reload bool) *assetIndex { + index := &assetIndex{ + dir: dir, + reload: reload, + versions: make(map[string]string, len(versionedAssets)), + stamps: make(map[string]time.Time, len(versionedAssets)), + } + for _, path := range versionedAssets { + index.versions[path] = index.hash(path) + } + return index +} + +// url is exposed to templates as `asset`. An asset the index could not read +// falls back to its plain path: an unversioned URL is a caching risk, but a +// broken reference is a broken page. +func (a *assetIndex) url(path string) string { + version := a.versions[path] + if a.reload { + version = a.refresh(path) + } + if version == "" { + return path + } + return path + "?v=" + version +} + +// refresh re-hashes path only when its modification time has moved, so an +// edited asset is picked up without re-reading every asset on every render. +func (a *assetIndex) refresh(path string) string { + a.mu.Lock() + defer a.mu.Unlock() + if info, err := os.Stat(a.dir + path); err == nil && !info.ModTime().Equal(a.stamps[path]) { + a.stamps[path] = info.ModTime() + a.versions[path] = a.hash(path) + } + return a.versions[path] +} + +func (a *assetIndex) hash(path string) string { + f, err := os.Open(a.dir + path) + if err != nil { + slog.Warn("asset missing, serving unversioned", "path", path, "err", err) + return "" + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + slog.Warn("asset unreadable, serving unversioned", "path", path, "err", err) + return "" + } + return hex.EncodeToString(h.Sum(nil))[:10] +} diff --git a/src/assets_test.go b/src/assets_test.go index 2cb480b..9ac106f 100644 --- a/src/assets_test.go +++ b/src/assets_test.go @@ -6,8 +6,10 @@ import ( "regexp" "strings" "testing" + "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // localAssetRef matches a first-party stylesheet or script referenced from a @@ -19,6 +21,10 @@ var localAssetRef = regexp.MustCompile("(?:href|src)=\"(?:\\{\\{asset `)?(/[a-zA // assetHelperCall matches a path that is wrapped in the asset helper. var assetHelperCall = regexp.MustCompile("\\{\\{asset `(/[a-zA-Z0-9._-]+\\.(?:css|js))`\\}\\}") +// versionedURL matches the shape the helper emits: the path, then the short +// content hash it appends. +var versionedURL = regexp.MustCompile(`^(/[a-zA-Z0-9._-]+)\?v=[0-9a-f]{10}$`) + func templateFiles(t *testing.T) []string { t.Helper() var files []string @@ -31,60 +37,154 @@ func templateFiles(t *testing.T) []string { } return nil }) - assert.NoError(t, err) - assert.NotEmpty(t, files, "no templates found; the walk root is probably wrong") + require.NoError(t, err) + require.NotEmpty(t, files, "no templates found; the walk root is probably wrong") return files } +// assetDir populates a throwaway directory with every versioned asset, so an +// index built on it behaves as it does against public/ without depending on +// what happens to be committed there. +func assetDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + for _, path := range versionedAssets { + require.NoError(t, os.WriteFile(filepath.Join(dir, path), []byte("/* "+path+" */"), 0o600)) + } + return dir +} + // A stylesheet or script served from a fixed path lets a cache pair one deploy's // asset with the next deploy's markup, which renders the page unstyled. Every // first-party asset must therefore go through the asset helper, which appends a // content hash. This test exists because adding a new script and forgetting to // version it reintroduces that failure silently: nothing breaks locally, and the // damage only appears at a CDN edge after a deploy. -func TestEveryLocalAssetReferenceIsVersioned(t *testing.T) { - for _, path := range templateFiles(t) { - body, err := os.ReadFile(path) - assert.NoError(t, err) - source := string(body) - - versioned := map[string]bool{} - for _, m := range assetHelperCall.FindAllStringSubmatch(source, -1) { - versioned[m[1]] = true +func TestAssetReferences(t *testing.T) { + t.Run("every local asset reference goes through the helper", func(t *testing.T) { + for _, path := range templateFiles(t) { + body, err := os.ReadFile(path) + require.NoError(t, err) + source := string(body) + + versioned := map[string]bool{} + for _, m := range assetHelperCall.FindAllStringSubmatch(source, -1) { + versioned[m[1]] = true + } + + for _, m := range localAssetRef.FindAllStringSubmatch(source, -1) { + assert.Truef(t, versioned[m[1]], + "%s references %s without the asset helper; wrap it as {{asset `%s`}} so a cache cannot serve a stale copy against new markup", + path, m[1], m[1]) + } } + }) - for _, m := range localAssetRef.FindAllStringSubmatch(source, -1) { - assert.Truef(t, versioned[m[1]], - "%s references %s without the asset helper; wrap it as {{asset `%s`}} so a cache cannot serve a stale copy against new markup", - path, m[1], m[1]) + // Every asset the templates ask the helper to version has to be in the list + // the startup hash pass walks, or it ships unversioned in production. + t.Run("templates only version assets the index knows about", func(t *testing.T) { + known := map[string]bool{} + for _, path := range versionedAssets { + known[path] = true } - } -} + for _, path := range templateFiles(t) { + body, err := os.ReadFile(path) + require.NoError(t, err) + for _, m := range assetHelperCall.FindAllStringSubmatch(string(body), -1) { + assert.Truef(t, known[m[1]], + "%s versions %s but it is missing from versionedAssets, so no hash is computed for it at startup", + path, m[1]) + } + } + }) -// The helper falls back to an unversioned path when it cannot read a file, so a -// typo or a rename would silently reintroduce the fixed-URL problem rather than -// failing loudly. -func TestVersionedAssetsExistOnDisk(t *testing.T) { - for _, path := range versionedAssets { - _, err := os.Stat("../public" + path) - assert.NoErrorf(t, err, "versionedAssets lists %s but no such file exists under public/", path) - } + // The helper falls back to an unversioned path when it cannot read a file, + // so a typo or a rename would silently reintroduce the fixed-URL problem + // rather than failing loudly. + t.Run("every versioned asset exists on disk", func(t *testing.T) { + for _, path := range versionedAssets { + _, err := os.Stat("../public" + path) + assert.NoErrorf(t, err, "versionedAssets lists %s but no such file exists under public/", path) + } + }) } -// Every asset the templates ask the helper to version has to be in the list the -// startup hash pass walks, or it ships unversioned in production. -func TestTemplatesOnlyVersionKnownAssets(t *testing.T) { - known := map[string]bool{} - for _, path := range versionedAssets { - known[path] = true - } - for _, path := range templateFiles(t) { - body, err := os.ReadFile(path) - assert.NoError(t, err) - for _, m := range assetHelperCall.FindAllStringSubmatch(string(body), -1) { - assert.Truef(t, known[m[1]], - "%s versions %s but it is missing from versionedAssets, so no hash is computed for it at startup", - path, m[1]) +func TestAssetIndex(t *testing.T) { + t.Run("a known asset gets its content hash appended", func(t *testing.T) { + index := newAssetIndex(assetDir(t), false) + + for _, path := range versionedAssets { + assert.Regexpf(t, versionedURL, index.url(path), "%s should be served versioned", path) } - } + }) + + t.Run("assets with different contents get different versions", func(t *testing.T) { + index := newAssetIndex(assetDir(t), false) + + assert.NotEqual(t, index.url("/app.css"), index.url("/index.js")) + }) + + t.Run("identical contents anywhere produce the same version", func(t *testing.T) { + first, second := assetDir(t), t.TempDir() + body, err := os.ReadFile(filepath.Join(first, "/app.css")) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(second, "/app.css"), body, 0o600)) + + assert.Equal(t, + newAssetIndex(first, false).url("/app.css"), + newAssetIndex(second, false).url("/app.css")) + }) + + // A missing asset must still render a usable page: an unversioned URL is a + // caching risk, a broken reference is a broken page. + t.Run("an unreadable asset falls back to its plain path", func(t *testing.T) { + index := newAssetIndex(filepath.Join(t.TempDir(), "does-not-exist"), false) + + assert.Equal(t, "/app.css", index.url("/app.css")) + }) + + t.Run("a path the index never hashed is left alone", func(t *testing.T) { + index := newAssetIndex(assetDir(t), false) + + assert.Equal(t, "/logo.svg", index.url("/logo.svg")) + }) + + t.Run("reload picks up an edited asset without a restart", func(t *testing.T) { + dir := assetDir(t) + index := newAssetIndex(dir, true) + before := index.url("/app.css") + + rewriteAsset(t, dir, "/app.css", "/* edited */") + + assert.NotEqual(t, before, index.url("/app.css")) + }) + + // Production hashes once at startup: the files cannot change under a running + // process, and re-stating on every render would tax every page. + t.Run("without reload an edited asset keeps its startup version", func(t *testing.T) { + dir := assetDir(t) + index := newAssetIndex(dir, false) + before := index.url("/app.css") + + rewriteAsset(t, dir, "/app.css", "/* edited */") + + assert.Equal(t, before, index.url("/app.css")) + }) + + t.Run("an unchanged asset keeps one stable URL", func(t *testing.T) { + index := newAssetIndex(assetDir(t), true) + + assert.Equal(t, index.url("/app.css"), index.url("/app.css")) + }) +} + +// rewriteAsset replaces an asset's contents and moves its modification time, +// which is what the reload check keys on. Filesystem timestamp resolution is +// coarse enough that a same-instant rewrite would otherwise look unchanged. +func rewriteAsset(t *testing.T, dir, path, contents string) { + t.Helper() + full := filepath.Join(dir, path) + require.NoError(t, os.WriteFile(full, []byte(contents), 0o600)) + later := time.Now().Add(time.Second) + require.NoError(t, os.Chtimes(full, later, later)) } diff --git a/src/capture.go b/src/capture.go new file mode 100644 index 0000000..fa653be --- /dev/null +++ b/src/capture.go @@ -0,0 +1,120 @@ +package main + +import ( + "encoding/json" + "log/slog" + "net/http" + + "github.com/gofiber/contrib/v3/socketio" + "github.com/gofiber/fiber/v3" + "github.com/google/uuid" + "gorm.io/datatypes" + + "httphq/src/database" +) + +// spoofCurlHeader lets a caller ask for its request to be captured as though a +// command-line client had sent it. A browser adds a dozen headers of its own to +// every fetch; a caller demonstrating a payload wants the payload on screen, +// not the browser's fingerprint around it. +const spoofCurlHeader = "Httphq-Spoof-Curl" + +// browserOnlyHeaders are the headers a browser adds that no command-line client +// sends. Dropped together with the opt-in header itself when curl is spoofed. +var browserOnlyHeaders = []string{ + "Accept-Encoding", + "Accept-Language", + "Connection", + spoofCurlHeader, + "Origin", + "Referer", + "Sec-Fetch-Dest", + "Sec-Fetch-Mode", + "Sec-Fetch-Site", + "Sec-Ch-Ua", + "Sec-Ch-Ua-Mobile", + "Sec-Ch-Ua-Platform", +} + +// spoofedCurlHeaders replace the browser's own values, so the capture reads as +// a plain form post from curl. +var spoofedCurlHeaders = map[string][]string{ + "Content-Type": {"application/x-www-form-urlencoded"}, + "User-Agent": {"curl/7.79.1"}, +} + +// captureHeaders reduces the request's headers to what the user should see: the +// browser's own additions removed when curl is spoofed, then every +// infrastructure header stripped. +func captureHeaders(headers map[string][]string) map[string][]string { + if spoof, ok := headers[spoofCurlHeader]; ok && len(spoof) > 0 && spoof[0] == "true" { + for _, name := range browserOnlyHeaders { + delete(headers, name) + } + for name, value := range spoofedCurlHeaders { + headers[name] = value + } + } + for name := range headers { + if omitHeader(name) { + delete(headers, name) + } + } + return headers +} + +// flattenHeaders turns []string header values into a {key: scalar-or-array} +// shape for storage: RFC 7230 allows a header to repeat, and the UI renders a +// single value as a string rather than a one-element list. +func flattenHeaders(headers map[string][]string) map[string]any { + flat := make(map[string]any, len(headers)) + for name, values := range headers { + if len(values) == 1 { + flat[name] = values[0] + } else { + flat[name] = values + } + } + return flat +} + +// captureRequest stores an inbound request against its endpoint and pushes it to +// every socket watching that endpoint. It answers 200 whatever the payload: +// there is nothing a caller can send that httphq will not record. +func captureRequest(registry *socketRegistry) fiber.Handler { + return func(c fiber.Ctx) error { + endpointID := c.Params("endpoint") + + headers := captureHeaders(c.GetReqHeaders()) + jsonHeaders, err := json.Marshal(flattenHeaders(headers)) + if err != nil { + slog.ErrorContext(c.Context(), "request header marshal failed", "err", err) + } + + request := database.Request{ + UUID: uuid.NewString(), + EndpointID: endpointID, + IP: resolveClientIP(c), + Method: c.Method(), + Path: c.Path(), + QueryString: string(c.Request().URI().QueryString()), + Body: string(c.Body()), + Headers: datatypes.JSON(jsonHeaders), + } + database.CreateRequest(c.Context(), &request) + + // Fan-out to subscribed WS clients. Marshal once, then dispatch + // asynchronously so a slow client doesn't stall the capture handler. + if uuids := registry.uuidsFor(endpointID); len(uuids) > 0 { + marshalled, marshalErr := json.Marshal(request) + if marshalErr != nil { + slog.ErrorContext(c.Context(), "websocket payload marshal failed", "err", marshalErr) + } else { + go socketio.EmitToList(uuids, marshalled) + } + } + + c.Set("Httphq-Request-Uuid", request.UUID) + return c.SendStatus(http.StatusOK) + } +} diff --git a/src/capture_test.go b/src/capture_test.go new file mode 100644 index 0000000..c002738 --- /dev/null +++ b/src/capture_test.go @@ -0,0 +1,87 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCaptureHeaders(t *testing.T) { + t.Run("leaves a caller's own headers alone", func(t *testing.T) { + withPlatform(t, "direct") + + headers := captureHeaders(map[string][]string{ + "Content-Type": {"application/json"}, + "X-Sample": {"value"}, + }) + + assert.Equal(t, map[string][]string{ + "Content-Type": {"application/json"}, + "X-Sample": {"value"}, + }, headers) + }) + + t.Run("drops infrastructure headers", func(t *testing.T) { + withPlatform(t, "cloudflare") + + headers := captureHeaders(map[string][]string{ + "X-Forwarded-For": {"198.51.100.1"}, + "Cf-Ray": {"abc"}, + "X-Sample": {"value"}, + }) + + assert.Equal(t, map[string][]string{"X-Sample": {"value"}}, headers) + }) + + t.Run("spoofing curl replaces the browser's fingerprint", func(t *testing.T) { + withPlatform(t, "direct") + + headers := captureHeaders(map[string][]string{ + spoofCurlHeader: {"true"}, + "Sec-Fetch-Mode": {"cors"}, + "Origin": {"https://example.com"}, + "Accept-Encoding": {"gzip"}, + "Content-Type": {"application/json"}, + "User-Agent": {"Mozilla/5.0"}, + "X-Sample": {"value"}, + }) + + assert.Equal(t, map[string][]string{ + "Content-Type": {"application/x-www-form-urlencoded"}, + "User-Agent": {"curl/7.79.1"}, + "X-Sample": {"value"}, + }, headers) + }) + + t.Run("only the opt-in value turns spoofing on", func(t *testing.T) { + withPlatform(t, "direct") + + headers := captureHeaders(map[string][]string{ + spoofCurlHeader: {"false"}, + "User-Agent": {"Mozilla/5.0"}, + }) + + assert.Equal(t, map[string][]string{ + spoofCurlHeader: {"false"}, + "User-Agent": {"Mozilla/5.0"}, + }, headers) + }) +} + +// The stored shape is what every consumer reads: the request panel, the search +// index and the HAR export all expect a value to be a string or a list of them. +func TestFlattenHeaders(t *testing.T) { + t.Run("a single value becomes a scalar", func(t *testing.T) { + assert.Equal(t, map[string]any{"X-Sample": "value"}, + flattenHeaders(map[string][]string{"X-Sample": {"value"}})) + }) + + t.Run("a repeated header keeps every value in order", func(t *testing.T) { + assert.Equal(t, map[string]any{"Set-Cookie": []string{"a=1", "b=2"}}, + flattenHeaders(map[string][]string{"Set-Cookie": {"a=1", "b=2"}})) + }) + + t.Run("no headers flatten to an empty object", func(t *testing.T) { + assert.Empty(t, flattenHeaders(map[string][]string{})) + }) +} diff --git a/src/database/database.go b/src/database/database.go index 1035908..3700374 100644 --- a/src/database/database.go +++ b/src/database/database.go @@ -59,6 +59,19 @@ func CountRequests(ctx context.Context) int64 { return count } +// CountRequestsForEndpointID counts everything stored for an endpoint, +// ignoring any search. The listing is both filtered and windowed, so it is the +// only way to say how much a control that acts on the whole endpoint will +// affect. +func CountRequestsForEndpointID(ctx context.Context, endpointID string) int64 { + var count int64 + result := DB.Model(&Request{}).Where(&Request{EndpointID: endpointID}).Count(&count) + if result.Error != nil { + slog.ErrorContext(ctx, "count requests for endpoint failed", "err", result.Error, "endpoint_id", endpointID) + } + return count +} + func GetRequestsForEndpointID(ctx context.Context, endpointID string, search string, limit int) []Request { var items []Request result := DB. diff --git a/src/database/database_test.go b/src/database/database_test.go index 9cb6ef6..6b052d2 100644 --- a/src/database/database_test.go +++ b/src/database/database_test.go @@ -1,6 +1,7 @@ package database_test import ( + "context" "fmt" "testing" "time" @@ -11,234 +12,275 @@ import ( "httphq/src/database" ) -/* General */ - -func TestConnect(t *testing.T) { +// freshDB gives a test its own empty in-memory database, so no test depends on +// what another one left behind. +func freshDB(t *testing.T) { + t.Helper() database.Connect(":memory:") - - // It should create all tables - var tables []string - database.DB.Raw(`SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name`).Scan(&tables) - assert.Equal(t, []string{"requests"}, tables) } -/* Request */ +// requestOption customises a fixture request. Only the fields a test asserts on +// need naming at the call site; everything else stays at a valid default. +type requestOption func(*database.Request) -func TestCountRequests(t *testing.T) { - database.Connect(":memory:") - - // It should return 0 if no items exist - assert.Equal(t, int64(0), database.CountRequests(t.Context())) - - // It should return the amount of existing items - var n = 3 - for i := 0; i < n; i++ { - ID := fmt.Sprint(i) - database.CreateRequest(t.Context(), &database.Request{ - UUID: ID, - EndpointID: ID, - IP: ID, - Method: "GET", - Path: "/test", - Body: "test", - }) - } - assert.Equal(t, int64(n), database.CountRequests(t.Context())) +func withEndpointID(id string) requestOption { + return func(r *database.Request) { r.EndpointID = id } } -func TestGetRequestsForEndpointID(t *testing.T) { - database.Connect(":memory:") +func withBody(body string) requestOption { + return func(r *database.Request) { r.Body = body } +} - endpointID := "test-id" +func withHeaders(json string) requestOption { + return func(r *database.Request) { r.Headers = datatypes.JSON(json) } +} - var items []database.Request +func withQueryString(query string) requestOption { + return func(r *database.Request) { r.QueryString = query } +} - // It should return an empty array if no items exist - items = database.GetRequestsForEndpointID(t.Context(),endpointID, "", 32) - assert.Equal(t, []database.Request{}, items) +func withCreatedAt(at time.Time) requestOption { + return func(r *database.Request) { r.CreatedAt = at } +} - database.CreateRequest(t.Context(), &database.Request{ - UUID: "uuid-1", - EndpointID: endpointID, +// storeRequest writes a valid request and returns it. The defaults are +// deliberately uninteresting: a test that cares about a field sets it. +func storeRequest(ctx context.Context, uuid string, options ...requestOption) database.Request { + request := database.Request{ + UUID: uuid, + EndpointID: "test-id", IP: "test-ip", Method: "GET", Path: "/test", - Body: "test-body-1", - Headers: datatypes.JSON(`{ "Test": "Test-Header-1" }`), + Body: "test-body", + Headers: datatypes.JSON(`{ "Test": "Test-Header" }`), + } + for _, option := range options { + option(&request) + } + database.CreateRequest(ctx, &request) + return request +} + +func TestConnect(t *testing.T) { + t.Run("migrates every table the application needs", func(t *testing.T) { + freshDB(t) + + var tables []string + database.DB.Raw(`SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name`).Scan(&tables) + assert.Equal(t, []string{"requests"}, tables) }) - database.CreateRequest(t.Context(), &database.Request{ - UUID: "uuid-2", - EndpointID: endpointID, - IP: "test-ip", - Method: "GET", - Path: "/test", - Body: "test-body-2", - Headers: datatypes.JSON(`{ "Test": "Test-Header-2" }`), +} + +func TestCountRequests(t *testing.T) { + t.Run("returns zero when nothing is stored", func(t *testing.T) { + freshDB(t) + + assert.Equal(t, int64(0), database.CountRequests(t.Context())) + }) + + t.Run("counts every stored request", func(t *testing.T) { + freshDB(t) + + const stored = 3 + for i := range stored { + storeRequest(t.Context(), fmt.Sprint(i), withEndpointID(fmt.Sprint(i))) + } + + assert.Equal(t, int64(stored), database.CountRequests(t.Context())) }) - database.CreateRequest(t.Context(), &database.Request{ - UUID: "uuid-3", - EndpointID: "other-id", - IP: "test-ip", - Method: "GET", - Path: "/test", - Body: "test-body-3", - Headers: datatypes.JSON(`{ "Test": "Test-Header-3" }`), - }) - - // It should return items with the correct shape - items = database.GetRequestsForEndpointID(t.Context(),endpointID, "", 1) - assert.Equal(t, "uuid-2", items[0].UUID) - assert.Equal(t, endpointID, items[0].EndpointID) - assert.Equal(t, "test-ip", items[0].IP) - assert.Equal(t, "GET", items[0].Method) - assert.Equal(t, "/test", items[0].Path) - assert.Equal(t, "test-body-2", items[0].Body) - assert.Equal(t, datatypes.JSON(`{ "Test": "Test-Header-2" }`), items[0].Headers) - assert.Equal(t, time.Now().Format(time.ANSIC), items[0].CreatedAt.Format(time.ANSIC)) - - // It should only return items with the specified endpoint id - items = database.GetRequestsForEndpointID(t.Context(),endpointID, "", 32) - assert.Equal(t, 2, len(items)) - - // It should not return more items than the limit - items = database.GetRequestsForEndpointID(t.Context(),endpointID, "", 1) - assert.Equal(t, 1, len(items)) - - // It should return return items ordered by creation date, newest first - items = database.GetRequestsForEndpointID(t.Context(),endpointID, "", 32) - assert.Equal(t, "test-body-2", items[0].Body) - assert.Equal(t, "test-body-1", items[1].Body) - - // It should not apply any additional filtering if the search string is empty - items = database.GetRequestsForEndpointID(t.Context(),endpointID, "", 32) - assert.Equal(t, 2, len(items)) - - // It should search the body based on the search string - items = database.GetRequestsForEndpointID(t.Context(),endpointID, "test-body", 32) - assert.Equal(t, 2, len(items)) - - items = database.GetRequestsForEndpointID(t.Context(),endpointID, "test-body-1", 32) - assert.Equal(t, 1, len(items)) - - // It should search the headers based on the search string - items = database.GetRequestsForEndpointID(t.Context(),endpointID, "Test-Header", 32) - assert.Equal(t, 2, len(items)) - - items = database.GetRequestsForEndpointID(t.Context(),endpointID, "Test-Header-1", 32) - assert.Equal(t, 1, len(items)) } -func TestCreateRequest(t *testing.T) { - database.Connect(":memory:") +func TestCountRequestsForEndpointID(t *testing.T) { + t.Run("counts only the named endpoint's requests", func(t *testing.T) { + freshDB(t) - endpointID := "test-id" + storeRequest(t.Context(), "uuid-1", withEndpointID("wanted")) + storeRequest(t.Context(), "uuid-2", withEndpointID("wanted")) + storeRequest(t.Context(), "uuid-3", withEndpointID("other")) - database.CreateRequest(t.Context(), &database.Request{ - UUID: "test-uuid", - EndpointID: endpointID, - IP: "test-ip", - Method: "GET", - Path: "/test", - Body: "test-body", - Headers: datatypes.JSON(`{ "Test": "Test-Header" }`), + assert.Equal(t, int64(2), database.CountRequestsForEndpointID(t.Context(), "wanted")) }) - items := database.GetRequestsForEndpointID(t.Context(),endpointID, "", 1) + t.Run("returns zero for an endpoint with no traffic", func(t *testing.T) { + freshDB(t) - 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, int64(0), database.CountRequestsForEndpointID(t.Context(), "never-used")) + }) } -func TestDeleteRequestsForEndpointID(t *testing.T) { - database.Connect(":memory:") +func TestGetRequestsForEndpointID(t *testing.T) { + t.Run("returns an empty slice when nothing is stored", func(t *testing.T) { + freshDB(t) - database.CreateRequest(t.Context(), &database.Request{ - UUID: "test-uuid-1", - EndpointID: "delete-id", - IP: "test-ip", - Method: "GET", - Path: "/test", - Body: "test-body", - Headers: datatypes.JSON(`{ "Test": "Test-Header" }`), + assert.Equal(t, []database.Request{}, + database.GetRequestsForEndpointID(t.Context(), "test-id", "", 32)) }) - database.CreateRequest(t.Context(), &database.Request{ - UUID: "test-uuid-2", - EndpointID: "keep-id", - IP: "test-ip", - Method: "GET", - Path: "/test", - Body: "test-body", - Headers: datatypes.JSON(`{ "Test": "Test-Header" }`), + t.Run("round-trips every stored field", func(t *testing.T) { + freshDB(t) + + stored := storeRequest(t.Context(), "uuid-1", + withBody("test-body-1"), + withHeaders(`{ "Test": "Test-Header-1" }`), + withQueryString("a=1")) + + items := database.GetRequestsForEndpointID(t.Context(), stored.EndpointID, "", 1) + + assert.Len(t, items, 1) + assert.Equal(t, stored.UUID, items[0].UUID) + assert.Equal(t, stored.EndpointID, items[0].EndpointID) + assert.Equal(t, stored.IP, items[0].IP) + assert.Equal(t, stored.Method, items[0].Method) + assert.Equal(t, stored.Path, items[0].Path) + 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)) + }) + + t.Run("returns only the requested endpoint's requests", func(t *testing.T) { + freshDB(t) + + storeRequest(t.Context(), "uuid-1", withEndpointID("wanted")) + storeRequest(t.Context(), "uuid-2", withEndpointID("wanted")) + storeRequest(t.Context(), "uuid-3", withEndpointID("other")) + + assert.Len(t, database.GetRequestsForEndpointID(t.Context(), "wanted", "", 32), 2) + }) + + t.Run("orders newest first", func(t *testing.T) { + freshDB(t) + + storeRequest(t.Context(), "uuid-1", withBody("older")) + storeRequest(t.Context(), "uuid-2", withBody("newer")) + + items := database.GetRequestsForEndpointID(t.Context(), "test-id", "", 32) + + assert.Equal(t, "newer", items[0].Body) + assert.Equal(t, "older", items[1].Body) + }) + + t.Run("never returns more than the limit", func(t *testing.T) { + freshDB(t) + + storeRequest(t.Context(), "uuid-1") + storeRequest(t.Context(), "uuid-2") + + assert.Len(t, database.GetRequestsForEndpointID(t.Context(), "test-id", "", 1), 1) + }) + + t.Run("an empty search applies no filtering", func(t *testing.T) { + freshDB(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) + }) + + t.Run("searches the body", func(t *testing.T) { + freshDB(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) + }) + + t.Run("searches the headers", func(t *testing.T) { + freshDB(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) }) - database.DeleteRequestsForEndpointID(t.Context(),"delete-id") + t.Run("searches the query string", func(t *testing.T) { + freshDB(t) - assert.Equal(t, int64(1), database.CountRequests(t.Context())) - assert.Equal(t, "keep-id", database.GetRequestsForEndpointID(t.Context(),"keep-id", "", 1)[0].EndpointID) + 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) + }) + + t.Run("a search that matches nothing returns an empty slice", func(t *testing.T) { + freshDB(t) + + storeRequest(t.Context(), "uuid-1", withBody("alpha")) + + assert.Empty(t, database.GetRequestsForEndpointID(t.Context(), "test-id", "no-such-term", 32)) + }) } -func TestDeleteRequestForUUID(t *testing.T) { - database.Connect(":memory:") +func TestCreateRequest(t *testing.T) { + t.Run("stores a retrievable request stamped with the current time", func(t *testing.T) { + freshDB(t) - database.CreateRequest(t.Context(), &database.Request{ - UUID: "delete-uuid", - EndpointID: "test-id", - IP: "test-ip", - Method: "GET", - Path: "/test", - Body: "test-body", - Headers: datatypes.JSON(`{ "Test": "Test-Header" }`), + storeRequest(t.Context(), "test-uuid") + + items := database.GetRequestsForEndpointID(t.Context(), "test-id", "", 1) + + assert.Equal(t, "test-uuid", items[0].UUID) + assert.Equal(t, time.Now().Format(time.ANSIC), items[0].CreatedAt.Format(time.ANSIC)) }) +} - database.CreateRequest(t.Context(), &database.Request{ - UUID: "keep-uuid", - EndpointID: "test-id", - IP: "test-ip", - Method: "GET", - Path: "/test", - Body: "test-body", - Headers: datatypes.JSON(`{ "Test": "Test-Header" }`), +func TestDeleteRequestsForEndpointID(t *testing.T) { + t.Run("deletes only the named endpoint's requests", func(t *testing.T) { + freshDB(t) + + storeRequest(t.Context(), "test-uuid-1", withEndpointID("delete-id")) + storeRequest(t.Context(), "test-uuid-2", withEndpointID("keep-id")) + + database.DeleteRequestsForEndpointID(t.Context(), "delete-id") + + assert.Equal(t, int64(1), database.CountRequests(t.Context())) + assert.Equal(t, "keep-id", + database.GetRequestsForEndpointID(t.Context(), "keep-id", "", 1)[0].EndpointID) }) +} + +func TestDeleteRequestForUUID(t *testing.T) { + t.Run("deletes only the named request", func(t *testing.T) { + freshDB(t) + + storeRequest(t.Context(), "delete-uuid") + storeRequest(t.Context(), "keep-uuid") - database.DeleteRequestForUUID(t.Context(),"delete-uuid") + database.DeleteRequestForUUID(t.Context(), "delete-uuid") - assert.Equal(t, int64(1), database.CountRequests(t.Context())) - assert.Equal(t, "keep-uuid", database.GetRequestsForEndpointID(t.Context(),"test-id", "", 1)[0].UUID) + assert.Equal(t, int64(1), database.CountRequests(t.Context())) + assert.Equal(t, "keep-uuid", + database.GetRequestsForEndpointID(t.Context(), "test-id", "", 1)[0].UUID) + }) } func TestDeleteOldRequests(t *testing.T) { - database.Connect(":memory:") + threshold := time.Now().Add(-4 * time.Hour) - endpointID := "test-id" + t.Run("deletes requests created before the threshold", func(t *testing.T) { + freshDB(t) - threshold := time.Now().Add(-1 * 4 * time.Hour) + storeRequest(t.Context(), "uuid-delete", withCreatedAt(threshold.Add(-time.Hour))) - // It should delete items created before the threshold - database.CreateRequest(t.Context(), &database.Request{ - UUID: "uuid-delete", - EndpointID: endpointID, - IP: "test-ip", - Method: "GET", - Path: "/test", - Body: "test-body", - Headers: datatypes.JSON(`{ "Test": "Test-Header" }`), - CreatedAt: threshold.Add(-1 * time.Hour), + database.DeleteOldRequests(t.Context(), threshold) + + assert.Equal(t, int64(0), database.CountRequests(t.Context())) }) - database.DeleteOldRequests(t.Context(),threshold) - assert.Equal(t, int64(0), database.CountRequests(t.Context())) - // It should not delete items created after the threshold - database.CreateRequest(t.Context(), &database.Request{ - UUID: "uuid-keep", - EndpointID: endpointID, - IP: "test-ip", - Method: "GET", - Path: "/test", - Body: "test-body", - Headers: datatypes.JSON(`{ "Test": "Test-Header" }`), - CreatedAt: threshold.Add(1 * time.Hour), + t.Run("keeps requests created after the threshold", func(t *testing.T) { + freshDB(t) + + storeRequest(t.Context(), "uuid-keep", withCreatedAt(threshold.Add(time.Hour))) + + database.DeleteOldRequests(t.Context(), threshold) + + assert.Equal(t, int64(1), database.CountRequests(t.Context())) }) - database.DeleteOldRequests(t.Context(),threshold) - assert.Equal(t, int64(1), database.CountRequests(t.Context())) } diff --git a/src/endpoint.go b/src/endpoint.go new file mode 100644 index 0000000..d0a4f85 --- /dev/null +++ b/src/endpoint.go @@ -0,0 +1,41 @@ +package main + +import ( + "net/http" + "regexp" + + "github.com/gofiber/fiber/v3" +) + +// endpointIDPattern bounds an endpoint ID to the shape haikunator emits +// (lowercase words and digits joined by hyphens). Rejecting anything else +// keeps attacker-controlled characters — quotes, angle brackets, parens — +// out of the rendered pages, the database, and the logs. +var endpointIDPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + +func validEndpointID(id string) bool { + return len(id) <= 64 && endpointIDPattern.MatchString(id) +} + +// requireValidEndpoint guards every route carrying an :endpoint parameter. It +// runs before the handler so no route can reach the database, a template or a +// log line with an unvalidated ID. +func requireValidEndpoint(c fiber.Ctx) error { + if !validEndpointID(c.Params("endpoint")) { + return c.SendStatus(http.StatusNotFound) + } + 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) { + websocketScheme := "ws" + if scheme == "https" { + websocketScheme = "wss" + } + return scheme + "://" + host + "/to/" + endpointID, + websocketScheme + "://" + host + "/ws/" + endpointID +} diff --git a/src/endpoint_test.go b/src/endpoint_test.go new file mode 100644 index 0000000..1f6674a --- /dev/null +++ b/src/endpoint_test.go @@ -0,0 +1,70 @@ +package main + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +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") + + assert.Equal(t, "https://httphq.com/to/purple-frog-0691", endpointURL) + assert.Equal(t, "wss://httphq.com/ws/purple-frog-0691", websocketURL) + }) + + t.Run("an http page advertises a ws socket", func(t *testing.T) { + endpointURL, websocketURL := 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) + }) +} + +// The endpoint ID reaches the rendered pages, the database and the logs, so the +// pattern is the only thing keeping attacker-controlled characters out of all +// three. Anything outside the shape haikunator emits must be rejected. +func TestValidEndpointID(t *testing.T) { + t.Run("accepts the shape haikunator emits", func(t *testing.T) { + accepted := []string{ + "purple-frog-0691", + "a", + "9", + "cool-wave", + "still-brook-1234", + strings.Repeat("a", 64), + } + + for _, id := range accepted { + assert.Truef(t, validEndpointID(id), "%q should be accepted", id) + } + }) + + t.Run("rejects anything that could break out of a page, a query or a log line", func(t *testing.T) { + rejected := []string{ + "", + "UPPER-case", + "under_score", + "has space", + "dot.separated", + "trailing-", + "-leading", + "double--hyphen", + "slash/es", + "quote'd", + `quote"d`, + "