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
454 changes: 454 additions & 0 deletions .impeccable/design.json

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions .impeccable/live/config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"files": ["src/views/layouts/main.html"],
"insertBefore": "</body>",
"commentSyntax": "html",
"cspChecked": true
}
522 changes: 522 additions & 0 deletions DESIGN.md

Large diffs are not rendered by default.

157 changes: 157 additions & 0 deletions PRODUCT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# Product

<!-- impeccable:product-schema 1 -->

## Platform

web

## Users

Two primary users, designed for together rather than in sequence:

- **The mid-debug first-timer.** Arrives from a search result or a colleague's
link while stuck on a webhook, form post, or third-party integration. Needs a
working capture URL within seconds of landing, and leaves once they
understand what was actually sent. Has no prior knowledge of httphq and will
not read documentation to get started.
- **The returning power user.** Already knows the tool and comes back
regularly. Values speed, method filtering, full-text search, HAR export, and
the send-a-test-request panel over any explanation of what httphq is.

Both reach the same surfaces; nothing gates or personalises for either, because
there are no accounts. Self-hosters (Docker, env-var configuration) are a real
audience served by the project, but not the audience the interface is designed
around.

## Product Purpose

httphq generates a unique, disposable HTTP endpoint and shows every request
sent to it in real time — method, path, client IP, headers, query string, and
body — so a developer can see exactly what a client, provider, or device puts
on the wire.

Success is a developer answering "what did it actually send?" in the shortest
possible path from landing to certainty: one click to a URL, point a client at
it, watch the request appear without a refresh.

## Positioning

Three things a neighbouring tool could not truthfully copy in combination:

- **No signup, no friction.** One click produces a working URL. No account, no
email, no verification, no quota wall.
- **Open source and self-hostable.** MIT licensed, a single Go binary and a
Docker image, so the same tool can be run against traffic that must not reach
a third-party service.
- **Honest, unpolluted captures.** Generic forwarding headers (`X-Forwarded-*`,
`Via`, `Trace*`, `X-Real-Ip`) and the configured platform's vendor headers
(`Cf-*`, `Fly-*`) are stripped before display, so users inspect their own
payload rather than the noise of whatever host sits in front of httphq.

Formspark sponsors the project and is credited in the footer and README. That
sponsorship is a fact to preserve, not a positioning claim: httphq is not
positioned as a marketing surface for Formspark.

## Operating Context

- The user is mid-task in another tool — a provider dashboard, a terminal, an
HTML form, a device, a scheduler — and httphq is the second window they keep
open beside it.
- The capture URL is pasted into somewhere else entirely (Stripe/GitHub/Slack
webhook settings, a form `action`, a curl command, firmware config) and then
the user returns to httphq to watch.
- Capture URLs are routinely shared: anyone holding the URL can read the same
live stream, which is how two people on opposite sides of an integration
settle whose payload is malformed.
- Sessions are short and bursty. A page may sit empty and waiting, then receive
a burst of requests, then be abandoned.

## Capabilities and Constraints

Confirmed functionality:

- Create an endpoint with one POST; the ID is a generated haiku-style slug
(`lowercase-words-and-digits`, max 64 chars). Capture URL is `/to/<id>`;
the inspection page is `/<id>`.
- Captured per request: UUID, method, path, query string, body, headers,
client IP, timestamp.
- Live delivery over WebSocket (`/ws/<id>`), with the scheme tracking the page
scheme so HTTPS pages use `wss://`.
- Filter by HTTP method; server-side substring search across headers, query
string, and body.
- Delete a single request or every request for an endpoint.
- Copy a single request or all visible requests as HAR-shaped JSON; copy
headers or body alone.
- Send a test request to your own endpoint from the page (method, headers,
body).
- Content-type-aware body rendering: pretty-printed and highlighted JSON,
multipart/form-data part list, XML highlighting, escaped raw text otherwise.
- Pages: home (`/`), endpoint (`/<id>`), contact (`/contact`). `/api/health`
and `/api/debug` exist for operations, not for users.

Technical constraints:

- Retention is 4 hours; a cron sweep runs every 5 minutes.
- 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.
- Rate limit is 125 requests per minute per client IP in production, bucketed
on the platform-resolved IP.
- Client IP resolution is a trust decision driven by the `PLATFORM` env var;
setting it trusts that platform's header unconditionally.
- The listen port (8080) is a constant, not configurable.
- Endpoint IDs are generated words, not secrets. There is no authentication and
no access control on an endpoint; possession of the URL is the only gate, and
IDs are guessable in principle.
- `robots.txt` allows only `/` and `/contact`; endpoint pages are disallowed.

Terminology: *endpoint* (the generated capture target), *request* (one captured
call), *capture URL* (`/to/<id>`), *HAR* (the export shape).

Undecided / not established: whether the 4-hour window, the 128-request list
cap, or the 1 MiB body limit should ever be surfaced as configurable to users.

## Brand Commitments

- Name is lowercase `httphq`, always. Canonical host is `httphq.com`.
- Existing marks: `public/logo.svg` (a periwinkle-indigo diamond, `#707ee7`),
`public/logo.png`, and a full favicon set (`favicon.ico`, 16/32 PNG,
`apple-touch-icon.png`, Android Chrome 192/512).
- The footer credits Formspark as sponsor and links the GitHub repository
(`formspark/httphq`). Both must remain reachable.
- Voice in existing copy is plain, concrete, developer-to-developer, with no
marketing inflation. Sentence case; no exclamation marks.

## Evidence on Hand

Real: the working product itself (a live capture stream is the demonstration),
the MIT licence, the public GitHub repository and its CI badges, the Formspark
sponsorship, and the logo/favicon assets above.

Absent, and must not be fabricated: testimonials, named customers, usage or
traffic numbers, uptime or performance benchmarks, awards, press coverage,
pricing, team or company claims beyond the Formspark sponsorship, and any
security or compliance certification.

## Product Principles

1. **Nothing stands between landing and a live URL.** Any addition that delays
or conditions the first capture is working against the product.
2. **Free and anonymous, permanently.** No accounts, no auth, no quotas, no
paid tier. Design and feature decisions may not assume a logged-in user or a
future one.
3. **Ephemeral is the product, not a limitation.** Short retention and loss on
restart are deliberate. Do not design toward archives, history, or durable
storage.
4. **Show their traffic, not ours.** Infrastructure and vendor noise stays
hidden; what is displayed should be what the client actually sent.
5. **Serve the first-timer and the regular in one surface.** The fast path must
stay obvious to someone who has never seen httphq, without slowing down
someone who uses it weekly.

## Accessibility & Inclusion

No product-specific standard has been established. Nothing in the product's
audience or context relaxes ordinary accessibility expectations for a web tool.
2 changes: 1 addition & 1 deletion docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ go mod tidy
Run project:

```bash
go run ./src/application.go
go run ./src
```

Run unit tests:
Expand Down
45 changes: 30 additions & 15 deletions src/application.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,35 @@ func resolvePlatform(name string) platformConfig {
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 (it compiles directive
// expressions via the Function constructor) and the Tailwind Play CDN
// (compiles utility classes at runtime). All page scripts are external so
// script-src does NOT need 'unsafe-inline'.
// - style-src needs 'unsafe-inline' because Tailwind Play CDN injects
// generated styles into <style> tags, and Alpine x-show toggles via
// inline display style.
// - 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'"
}

// 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
Expand Down Expand Up @@ -333,21 +362,7 @@ func main() {
c.Set("X-Content-Type-Options", "nosniff")
c.Set("Referrer-Policy", "no-referrer")
c.Set("X-Frame-Options", "DENY")
// CSP policy:
// - script-src needs 'unsafe-eval' for Alpine (it compiles directive
// expressions via the Function constructor) and the Tailwind Play
// CDN (compiles utility classes at runtime). All page scripts are
// external so script-src does NOT need 'unsafe-inline'.
// - style-src needs 'unsafe-inline' because Tailwind Play CDN injects
// generated styles into <style> tags, and Alpine x-show toggles via
// inline display style.
c.Set("Content-Security-Policy",
"default-src 'self'; "+
"script-src 'self' 'unsafe-eval' https://unpkg.com https://cdn.jsdelivr.net; "+
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "+
"img-src 'self' data:; "+
"connect-src 'self' ws: wss:; "+
"frame-ancestors 'none'")
c.Set("Content-Security-Policy", contentSecurityPolicy)
return c.Next()
})

Expand Down
79 changes: 59 additions & 20 deletions src/views/endpoint.html
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,20 @@
class="mb-4 flex items-center justify-between gap-4 text-sm text-slate-500"
>
<p class="inline-flex items-center gap-2">
<span aria-hidden="true">⚠️</span>
<svg
viewBox="0 0 24 24"
class="w-4 h-4 shrink-0"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<path
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
/>
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12" y2="17" />
</svg>
<span>Requests are deleted after 4 hours</span>
</p>
<div class="flex items-center gap-2">
Expand Down Expand Up @@ -330,21 +343,40 @@
>
Details
</div>
<dl
class="grid grid-cols-[10rem_1fr] gap-x-3 text-sm divide-y divide-slate-100 [&>dt]:py-1.5 [&>dd]:py-1.5"
>
<dt class="text-slate-500">Time</dt>
<dd
:title="request.createdAt.toLocaleString()"
x-text="formatTimeAgo(request.createdAt)"
></dd>
<dt class="text-slate-500">Client IP</dt>
<dd class="font-mono text-xs" x-text="request.ip || '—'"></dd>
<dt class="text-slate-500">Path</dt>
<dd
class="font-mono text-xs break-all"
x-text="request.path"
></dd>
<!-- Rows are flex, not a two-column grid: below the breakpoint the
label stacks above its value instead of holding a fixed column
that would leave the value almost no width on a phone. Each row
is its own element so the divider spans the whole row rather
than a single column. -->
<dl class="text-sm">
<div
class="flex flex-col sm:flex-row sm:gap-3 py-1.5 border-b border-slate-100 last:border-b-0"
>
<dt class="text-slate-500 sm:w-40 sm:shrink-0">Time</dt>
<dd
class="min-w-0"
:title="request.createdAt.toLocaleString()"
x-text="formatTimeAgo(request.createdAt)"
></dd>
</div>
<div
class="flex flex-col sm:flex-row sm:gap-3 py-1.5 border-b border-slate-100 last:border-b-0"
>
<dt class="text-slate-500 sm:w-40 sm:shrink-0">Client IP</dt>
<dd
class="font-mono text-xs min-w-0"
x-text="request.ip || '—'"
></dd>
</div>
<div
class="flex flex-col sm:flex-row sm:gap-3 py-1.5 border-b border-slate-100 last:border-b-0"
>
<dt class="text-slate-500 sm:w-40 sm:shrink-0">Path</dt>
<dd
class="font-mono text-xs break-all min-w-0"
x-text="request.path"
></dd>
</div>
</dl>
</div>

Expand All @@ -366,18 +398,25 @@
</button>
</div>
<div class="overflow-auto max-h-64">
<dl class="grid grid-cols-[10rem_1fr] gap-x-3 text-xs">
<!-- Same stacking rule as the details rows above. The divider is
`border-b` with the last row cleared rather than `border-t`
with the first cleared: x-for leaves its <template> as the
first child, so a :first-child rule would never match a
rendered row. -->
<dl class="text-xs">
<template
x-for="[k, v] in Object.entries(request.headers)"
:key="k"
>
<div class="contents">
<div
class="flex flex-col sm:flex-row sm:gap-3 py-1 border-b border-slate-100 last:border-b-0"
>
<dt
class="py-1 text-slate-500 font-mono break-all border-t border-slate-100 first:border-t-0"
class="text-slate-500 font-mono break-all sm:w-40 sm:shrink-0"
x-text="k"
></dt>
<dd
class="py-1 font-mono break-all border-t border-slate-100 first:border-t-0"
class="font-mono break-all min-w-0"
x-text="Array.isArray(v) ? v.join(', ') : v"
></dd>
</div>
Expand Down
Loading