Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,15 @@ Guidance for agents and contributors working in this repository.
tests beside it: `application.go` (wiring and entry point), `platform.go`
(client IP and header stripping), `endpoint.go` (endpoint IDs and URLs),
`capture.go` (the capture handler), `api.go` (the JSON API), `pages.go` (page
rendering), `assets.go` (content-hashed asset URLs), `security.go` (CSP and
security headers), `sockets.go` (the live feed), `requestlog.go` (correlation
IDs and the access log).
rendering), `agent.go` (the prompt an endpoint page hands to a coding agent),
`assets.go` (content-hashed asset URLs), `security.go` (CSP and security
headers), `sockets.go` (the live feed), `requestlog.go` (correlation IDs and
the access log).

A test file covers one subject, is named after it, and names each suite after
what it exercises, so a handler's tests are found beside the handler. The one
exception is `harness_test.go`, which is not a subject: it holds `TestMain` and
the fixtures shared by every test that drives a real request.

`newApplication` builds the entire routing surface from arguments, so tests
drive real requests through it without a listening socket. Anything that pulls
Expand Down
13 changes: 11 additions & 2 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -456,8 +456,9 @@ must never be borrowed for a disabled, errored, or drop-target surface.

The system is implemented, not only described. `src/styles/components.css`
carries the classes these entries specify (`.btn` and its variants, `.field`,
`.field-label`, `.region-label`, `.panel`, `.kv-row`, `.icon`, `.badge`,
`.code-block`, `.empty-value`, `.btn-lg`), and templates compose them rather
`.field-label`, `.region-label`, `.panel`, `.panel-summary`, `.panel-body`,
`.kv-row`, `.icon`, `.badge`, `.code-block`, `.empty-value`, `.btn-lg`), and
templates compose them rather
than repeating utility strings. Every page uses them: a template that re-spells
a component as a utility string is the bug, not a shortcut. Utilities stay the default for one-off composition; anything whose
tokens must not drift between call sites belongs in that file. Before adding a
Expand All @@ -470,6 +471,14 @@ no shadow. A second, quieter panel would be the One Edge Rule broken by another
name, so a surface that needs to read as nested takes its distinction from
spacing or tone rather than from a class of its own.

A disclosure panel is that surface with two more parts: `.panel-summary` is the
row that opens it, and `.panel-body` is what appears below the seam. They are
one component in two spellings, so a panel that opens is composed from them
rather than from a utility string repeated at each panel. The summary drops the
native marker and squares its bottom corners when open, so the seam meets the
panel edge instead of crossing a radius; the chevron that replaces the marker is
a partial, and it reports state rather than competing with the label.

`.field-label` and `.region-label` carry one type token between them. The field
label owns the spacing above its input; the region label takes spacing from the
call site, because a heading inside a flex row must not carry a bottom margin.
Expand Down
8 changes: 7 additions & 1 deletion PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ Confirmed functionality:
body).
- Content-type-aware body rendering: pretty-printed and highlighted JSON,
multipart/form-data part list, XML highlighting, escaped raw text otherwise.
- Poll the JSON listing with a cursor: echo the response's `cursor` back as
`?since=` and each capture is handed over exactly once. The endpoint page
carries a ready-made prompt that hands a coding agent this endpoint's URLs
and that loop.
- Pages: home (`/`), endpoint (`/<id>`), contact (`/contact`). `/api/health`
and `/api/debug` exist for operations, not for users.

Expand All @@ -97,7 +101,9 @@ Technical constraints:
- Storage is SQLite on the container's writable layer. Capture history is lost
on restart, by design. No durable store, no migration path.
- Request body limit is 1 MiB.
- The request list returns at most 128 requests, newest first.
- The request list returns at most 128 requests: newest first when asked
without a cursor, oldest first when asked with one, so a poller drains a
burst in order.
- Rate limit is 150 requests per minute per client IP in production, bucketed
on the platform-resolved IP.
- Client IP resolution is a trust decision driven by the `PLATFORM` env var;
Expand Down
6 changes: 3 additions & 3 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ characters. Anything else is a 404.

## Capturing

Send anything at all to `/to/:endpoint`. Every method is accepted, the response
is always `200`, and the capture's UUID comes back on the
`Httphq-Request-Uuid` header.
Send anything at all to `/to/:endpoint`. Every method is accepted, anything
within the body limit below answers `200`, and the capture's UUID comes back on
the `Httphq-Request-Uuid` header.

```bash
curl -X POST -d '{"hello":"world"}' https://httphq.com/to/purple-frog-0691
Expand Down
43 changes: 43 additions & 0 deletions e2e/tests/capture-api.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { test, expect } from "@playwright/test";
import { captureUrl, newEndpointId, requestsUrl } from "./support/harness";

/**
* What only a real socket can show. The Go suite drives the same routes through
* the router in memory, which is enough for everything a handler decides; the
* limits enforced by the server around the handler need a client on the wire to
* observe.
*/
test.describe("Capture API", () => {
test.describe("Body limit", () => {
// A shared instance writes to one disk, so an unbounded body is one
// caller's ability to fill it for everyone.
test("a body over the limit is rejected", async ({ request }) => {
const overOneMebibyte = "a".repeat(1024 * 1024 + 1);

const response = await request.post(captureUrl(newEndpointId()), {
data: overOneMebibyte,
headers: { "Content-Type": "text/plain" },
});

expect(response.status()).toBe(413);
});

test("a body at the limit is accepted", async ({ request }) => {
const endpointId = newEndpointId();
const oneMebibyte = "a".repeat(1024 * 1024);

const response = await request.post(captureUrl(endpointId), {
data: oneMebibyte,
headers: { "Content-Type": "text/plain" },
});

expect(response.status()).toBe(200);

const listing = await request.get(requestsUrl(endpointId));
const payload = (await listing.json()) as {
requests: { body: string }[];
};
expect(payload.requests[0].body).toHaveLength(oneMebibyte.length);
});
});
});
43 changes: 43 additions & 0 deletions e2e/tests/endpoint-screen.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { test, expect } from "@playwright/test";
import {
captureUrl,
newEndpointId,
pruneExpiredCaptures,
readClipboard,
readClipboardJson,
requestsUrl,
send,
type HarDocument,
} from "./support/harness";
Expand Down Expand Up @@ -185,6 +187,30 @@ test.describe("Endpoint screen", () => {
);
});

// Nothing tells the page that the server swept a capture out from under it,
// so a list left open long enough would go on rendering requests that no
// longer exist, beside the promise that they were deleted. The page runs
// this on an interval; the test drives the same pass directly rather than
// holding the suite open for it.
test("a capture the server no longer holds stops being rendered", async ({
page,
request,
}) => {
const response = await send(request, endpointUrl, { data: "swept" });
const uuid = response.headers()["httphq-request-uuid"];
await expect(page.locator(`#request-${uuid}`)).toBeAttached();

// Deleted behind the page's back, which is what the retention sweep is
// from the page's point of view.
await request.delete(requestsUrl(endpointId));

await pruneExpiredCaptures(page);

await expect(page.locator('[data-test="requests"]')).toContainText(
"Waiting for requests",
);
});

// Rendering every capture at once is a five-figure node count and a visible
// stall, so the rest stay in the store until asked for.
test("only a page of cards is rendered until more are asked for", async ({
Expand Down Expand Up @@ -662,6 +688,15 @@ test.describe("Endpoint screen", () => {
await expect(page.locator('[data-test="agent-prompt"]')).toBeVisible();
});

// Both panels sit above the stream. Opening one to read a prompt must not
// push the other open on top of it.
test("opening it leaves the send panel closed", async ({ page }) => {
await page.locator('[data-test="agent-toggle"]').click();

await expect(page.locator('[data-test="agent-prompt"]')).toBeVisible();
await expect(page.locator('[data-test="send-submit"]')).toBeHidden();
});

// The prompt is built from the request, so it has to name the host the
// page was actually served from rather than a hardcoded one.
test("the prompt carries this endpoint's own URLs", async ({ page }) => {
Expand Down Expand Up @@ -714,6 +749,14 @@ test.describe("Endpoint screen", () => {
});

test.describe("Sending a test request", () => {
test("the panel is collapsed until it is opened", async ({ page }) => {
await expect(page.locator('[data-test="send-submit"]')).toBeHidden();

await page.locator('[data-test="send-toggle"]').click();

await expect(page.locator('[data-test="send-submit"]')).toBeVisible();
});

test("submitting the panel produces a captured request", async ({
page,
}) => {
Expand Down
21 changes: 21 additions & 0 deletions e2e/tests/support/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,27 @@ export const newEndpointId = () =>
export const captureUrl = (endpointId: string) =>
`${BASE_URL}/to/${endpointId}`;

/** The JSON listing for an endpoint, which is also its delete-all target. */
export const requestsUrl = (endpointId: string) =>
`${BASE_URL}/api/endpoints/${endpointId}/requests`;

/**
* The page drops captures that have aged past the retention window and resyncs
* with the server, on an interval measured in tens of seconds. This runs that
* pass on demand with a window short enough to expire everything, so a test can
* assert what the page does about a swept capture without waiting for a tick.
*/
export const pruneExpiredCaptures = (page: Page) =>
page.evaluate(() => window.Alpine.store("main").pruneExpired(1));

declare global {
interface Window {
Alpine: {
store(name: "main"): { pruneExpired(retentionMs: number): unknown };
};
}
}

export type SendOptions = {
method?: string;
data?: string | object;
Expand Down
2 changes: 1 addition & 1 deletion public/app.css

Large diffs are not rendered by default.

28 changes: 14 additions & 14 deletions public/endpoint.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,17 @@
this.search = "";
},

// Every call is scoped to the endpoint the page is open on, so the route
// is spelled once rather than at each call site.
requestsUrl(suffix = "") {
return `/api/endpoints/${this.endpointId}/requests${suffix}`;
},

fetchRequests() {
if (!this.endpointId) return;
const url =
`/api/endpoints/${this.endpointId}/requests?search=` +
encodeURIComponent(this.search);
const url = this.requestsUrl(
`?search=${encodeURIComponent(this.search)}`,
);
return fetch(url)
.then((r) => r.json())
.then((d) => {
Expand All @@ -97,9 +103,7 @@
},

deleteRequests() {
return fetch(`/api/endpoints/${this.endpointId}/requests`, {
method: "DELETE",
})
return fetch(this.requestsUrl(), { method: "DELETE" })
.then(() => {
this.requests = [];
this.total = 0;
Expand All @@ -108,9 +112,7 @@
},

deleteRequest(uuid) {
return fetch(`/api/endpoints/${this.endpointId}/requests/${uuid}`, {
method: "DELETE",
})
return fetch(this.requestsUrl(`/${uuid}`), { method: "DELETE" })
.then(() => {
this.requests = this.requests.filter((r) => r.uuid !== uuid);
this.total = Math.max(0, this.total - 1);
Expand Down Expand Up @@ -294,6 +296,7 @@
formatTimeAgo: window.formatTimeAgo,
formatClock: window.formatClock,
formatBytes: window.formatBytes,
pluralize: window.pluralize,
renderBody: window.renderBody,

// Screen readers get no navigation on this page, so every change that a
Expand Down Expand Up @@ -325,11 +328,10 @@

// Copies requests as a HAR-shaped document.
copyHar(requests, key) {
const count = requests.length;
return this._copyAndFlash(
window.buildHarExport(requests),
key,
`Copied ${count} ${count === 1 ? "request" : "requests"} to clipboard`,
`Copied ${window.pluralize(requests.length, "request")} to clipboard`,
);
},

Expand All @@ -341,9 +343,7 @@
const count = Alpine.store("main").requests.length;
this.pendingDeleteAll = false;
await Alpine.store("main").deleteRequests();
this.announce(
`Deleted ${count} ${count === 1 ? "request" : "requests"}`,
);
this.announce(`Deleted ${window.pluralize(count, "request")}`);
},

async sendCustom() {
Expand Down
8 changes: 8 additions & 0 deletions public/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ window.formatClock = function (date) {
return clockFormatter.format(date);
};

/* A count with its noun, pluralised by adding an s. Several surfaces state a
count in prose, and one that says "1 requests" reads as a defect in the thing
being counted rather than in the sentence. */

window.pluralize = function (count, noun) {
return `${count} ${count === 1 ? noun : `${noun}s`}`;
};

/* Byte sizes for captured bodies. */

window.formatBytes = function (bytes) {
Expand Down
6 changes: 3 additions & 3 deletions public/render-body.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@ function highlightPrettyJSON(value) {
}

/* Case-insensitive lookup into a headers object whose values are either a
scalar string or a string[] (see the flattening in application.go). Exposed
as window.headerValue because that scalar-or-array contract is shared by
every consumer of a captured request, not just body rendering. */
scalar string or a string[] (see flattenHeaders in capture.go). Exposed as
window.headerValue because that scalar-or-array contract is shared by every
consumer of a captured request, not just body rendering. */
function headerValue(headers, name) {
if (!headers) return undefined;
const key = Object.keys(headers).find(
Expand Down
Loading
Loading