From c1687197a4d93994a546caf3d03b4d59fdd47c72 Mon Sep 17 00:00:00 2001 From: botre Date: Sat, 8 Aug 2026 19:42:18 +0200 Subject: [PATCH 1/2] Organise the tests, split the app by concern, and lint the scripts src was one 672-line file holding routing, capture, platform, asset and security concerns together, which left most of it reachable only through a listening socket. It is now one file per subject with its tests beside it, and newApplication builds the whole routing surface from arguments so tests drive real requests through it. Tests are grouped by the unit under test with behaviour-stating subtests, and the Playwright suite is grouped by feature area behind a shared harness. Go coverage moves from partial to 81% of src, and the suite covers endpoint-ID validation, client-IP resolution, header stripping, asset versioning, the CSP environment gate, request-ID handling and every route. ESLint now covers the page scripts and the Playwright suite, the latter with type information so an implicit any is an error. The Go commands are scoped to ./src because node_modules ships a stray Go package. Three fixes fall out of this: - Every response now carries its security headers. The static middleware resets the response when a path resolves to a directory, which silently dropped the CSP, X-Frame-Options, nosniff, Referrer-Policy and X-Request-Id from the landing page. They are stamped on the way out, where nothing downstream can discard them. - Controls that act on a whole endpoint report the endpoint. The listing is searched server-side and windowed, so its length is not the endpoint's; with a search active the destructive "Delete all (N)" understated what it would delete, and the filtered-empty panel claimed nothing had been captured. The listing now carries a total. - Counted copy reads correctly at one: "1 request", not "1 requests". Claude-Session: https://claude.ai/code/session_01XRYoAwHndAhq2w7RvxSGoo --- .github/workflows/test.yml | 21 +- AGENTS.md | 14 + DESIGN.md | 32 +- docs/scripts.md | 18 +- e2e/tests/contact-screen.spec.ts | 45 +- e2e/tests/endpoint-screen.spec.ts | 652 ++++++++----- e2e/tests/home-screen.spec.ts | 102 +- e2e/tests/support/harness.ts | 70 ++ eslint.config.mjs | 59 ++ package-lock.json | 1459 ++++++++++++++++++++++++++--- package.json | 13 +- public/app.css | 2 +- public/endpoint.js | 65 +- public/har.js | 3 +- public/render-body.js | 2 +- src/api.go | 57 ++ src/application.go | 651 ++----------- src/application_test.go | 136 --- src/assets.go | 96 ++ src/assets_test.go | 180 +++- src/capture.go | 120 +++ src/capture_test.go | 87 ++ src/database/database.go | 13 + src/database/database_test.go | 408 ++++---- src/endpoint.go | 41 + src/endpoint_test.go | 70 ++ src/logging/logging.go | 34 +- src/logging/logging_test.go | 127 +++ src/pages.go | 74 ++ src/platform.go | 124 +++ src/platform_test.go | 227 +++++ src/requestlog.go | 12 +- src/requestlog_test.go | 56 ++ src/routes_test.go | 423 +++++++++ src/security.go | 52 + src/security_test.go | 38 + src/sockets.go | 101 ++ src/sockets_test.go | 66 ++ src/styles/components.css | 25 +- src/views/endpoint.html | 51 +- src/views/index.html | 10 +- src/views/partials/footer.html | 6 +- src/views/partials/header.html | 6 +- 43 files changed, 4412 insertions(+), 1436 deletions(-) create mode 100644 e2e/tests/support/harness.ts create mode 100644 eslint.config.mjs create mode 100644 src/api.go delete mode 100644 src/application_test.go create mode 100644 src/assets.go create mode 100644 src/capture.go create mode 100644 src/capture_test.go create mode 100644 src/endpoint.go create mode 100644 src/endpoint_test.go create mode 100644 src/logging/logging_test.go create mode 100644 src/pages.go create mode 100644 src/platform.go create mode 100644 src/platform_test.go create mode 100644 src/requestlog_test.go create mode 100644 src/routes_test.go create mode 100644 src/security.go create mode 100644 src/security_test.go create mode 100644 src/sockets.go create mode 100644 src/sockets_test.go 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`, + "