Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
cac27fe
admin: a day offset, a breakdown by link, and a count before an export
Sep 25, 2026
7b5649e
admin: pin the count's link filter
Sep 25, 2026
e68d739
admin: the link list newest first, searchable, with its cap in use
Sep 25, 2026
2d1dd80
admin: a status route for how fresh the numbers and the ip lists are
Sep 25, 2026
c1196f6
admin: pin the status route's catch narrowing and its manifest-failur…
Sep 25, 2026
c49afc7
ui: the interface package, its theme, and the gates it passes
Sep 25, 2026
29c5241
ui: catch named colours, new Tailwind palettes, side-effect and names…
Sep 25, 2026
33ea47d
admin: serve the web interface beside the api, under its own policy
Sep 25, 2026
edf1710
admin: key the interface's headers off the route that answered, not t…
Sep 25, 2026
f15d1a7
ui: the api client, its types, and a scheduler for reports
Sep 25, 2026
7d70bd3
ui: test every client request, signal sign-out's 401, and let an aban…
Sep 25, 2026
8f0291d
ui: the building blocks every screen is made of
Sep 25, 2026
854615f
ui: fix border colour, modal, confirm and copy-button guards
Sep 25, 2026
cd311a0
ui: signing in, and the shell every screen sits in
Sep 25, 2026
62ede65
ui: pin the zero/one alerts wording, refresh's listener cleanup and h…
Sep 25, 2026
6cbc1a5
ui: the time window, in the operator's own days
Sep 25, 2026
776a509
ui: bound custom dates to year 9999, name every change of day start, …
Sep 25, 2026
ae35798
ui: the overview, with a chart and breakdowns
Sep 25, 2026
71cac80
ui: fix stale breakdowns, visitor sums, hour-label collisions, card h…
Sep 25, 2026
e97ce08
ui: gate a breakdown's share on its own answer, apply the stale/error…
Sep 25, 2026
4b6f9f2
ui: the link list, and a link's own report
Sep 25, 2026
80c00d9
ui: fix the link list's Load-more race and stale list, and the link p…
Sep 25, 2026
a2e94fe
ui: creating and editing a link
Sep 25, 2026
d19efb6
ui: place every refusal of a link, and pin the link password's autoco…
Sep 25, 2026
8d65498
ui: the click log, and an export that says where it stops
Sep 25, 2026
e8ece3a
ui: keep the click log's link filter visible, count the export agains…
Sep 25, 2026
792d0fb
ui: domains, their records, and taking one down
Sep 25, 2026
45f2127
ui: domains — reload after a check, delete or unverify, and per-field…
Sep 25, 2026
12a1c25
ui: settings, and confirming a change that deletes data
Sep 25, 2026
1aaa148
ui: pin the deletion sentence, its confirm label and the settings scr…
Sep 25, 2026
d0334a0
ui: the account, its second factor, its sessions and its keys
Sep 25, 2026
5175f2a
ui: pin the account's reloads, refusals and one-time secrets
Sep 25, 2026
c7fe035
ui: pin the refresh's success path and the empty-code refusal's reset
Sep 25, 2026
2942de9
test: the interface in a real browser, on the shipped stack
Sep 25, 2026
b4342dc
test: bound the wait for the session list's reload
Sep 25, 2026
49a1fcc
ui: a table scrolls inside the page, and the nav no longer sets the p…
Sep 25, 2026
ed678c2
ui: the theme toggle follows the system, and native controls follow t…
Sep 25, 2026
0fd777e
ui: a link with nothing to report says it is active
Sep 25, 2026
7c91a69
stack: wait on the redirect's snapshot, check the link list, and fit …
Sep 25, 2026
ab5c936
ui: a link with a rule shows the rule, not active
Sep 25, 2026
64c7ce4
ui: the theme toggle stops listening to the system when it goes
Sep 25, 2026
ac6c872
stack: name the theme step for both things it checks
Sep 25, 2026
a5af6a3
docs: the web interface
Sep 25, 2026
45ad273
ui: the csv download survives a refusal, and the link filter's name f…
Sep 25, 2026
5312b1e
admin: static assets refuse a Range request instead of answering 206
Sep 25, 2026
73b5d78
stack: two quick clicks on Download the CSV leave the interface on th…
Sep 25, 2026
1893090
ui: the freshness line goes silent, rather than stale, once a reload …
Sep 25, 2026
1365d09
ui: pin Load more's disabled attribute on Links too
Sep 25, 2026
41aee65
ui: domains uses the global refresh, and its add error goes through e…
Sep 25, 2026
f01edfb
ui: pin the core-owned numeric bounds against drift
Sep 25, 2026
8983bea
ui: delete safeHref, client.alerts and deletesData, all dead
Sep 25, 2026
e08c793
docs: correct the interface's docs against the code as committed
Sep 25, 2026
cb3e675
stack: force the export's refusal instead of racing it
Sep 25, 2026
5c80c51
ui: full-opacity focus rings, and a visible active nav item and press…
Sep 25, 2026
b83c4bb
brand: measure the pairings the web interface draws
Sep 25, 2026
51d9ec9
ci: name the two test fixtures the secret scan mistakes for keys
Sep 25, 2026
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
1 change: 0 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,4 @@ node_modules
.env
test
docs
brand
*.md
7 changes: 4 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,10 @@ jobs:
# `durability` both call `down -v`.
stack:
runs-on: ubuntu-latest
# Six suites now, each bringing the stack up for itself, one of them
# obtaining certificates from a local authority. Three and a half minutes
# with warm images locally; a cold runner builds the image first.
# Seven suites now, each bringing the stack up for itself, one of them
# obtaining certificates from a local authority and one driving a browser
# from Playwright's image. Four minutes with warm images locally; a cold
# runner builds the image first and pulls the browser's (about 1 GB).
timeout-minutes: 40
steps:
- uses: actions/checkout@v7
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,8 @@ docker-compose.override.yml
# The TLS suite writes the local certificate authority root and the test DNS
# zone here before it starts the stack.
test/stack/tmp/

# tsc -b's own incremental build output for the interface package. Vite owns
# dist/ (already ignored above); this is a separate directory so the two
# builds never delete each other's output, and nothing reads it.
packages/ui/dist-ts/
6 changes: 6 additions & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Test fixtures that look like secrets and are not: a made-up API key whose
# secret part is base64 of "secret-secret-…", and a DNS verification token of
# hex digits in order. Each line is gitleaks' fingerprint of the commit that
# introduced the fixture; history is kept, so the fingerprint is the only fix.
d0334a0c90754bdca095ee6967253ae30ca60d43:packages/ui/src/screens/account/ApiKeys.test.tsx:generic-api-key:8
792d0fb97eafb920a7857ca969d10bea93ee45e4:packages/ui/src/screens/domains/Domains.test.tsx:generic-api-key:11
55 changes: 37 additions & 18 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,12 @@ pass), the admin
API (one account, sessions, TOTP with recovery codes, API keys, domain/link/settings
CRUD, answering on `CLICKMONK_ADMIN_HOST` alone — unset, every route but `/health` is a
503 and links serve exactly as before), reporting over that API (a summary, a chart by
hour or day, a breakdown over nine dimensions, the raw click log and a streamed CSV of it,
all reading hourly rollups except the log and its export), retention (two periods on the
settings row, enforced hourly by the worker, dropping raw clicks a partition at a time and
blanking the address on a click in place), the CLI (`migrate`,
hour or day, a breakdown over ten dimensions, the raw click log and a streamed CSV of it,
all reading hourly rollups except the log and its export), the web interface (a React app
the admin service serves from that same host, behind the same host guard — every screen
built on the admin API and nothing it can reach that a script cannot), retention (two
periods on the settings row, enforced hourly by the worker, dropping raw clicks a partition
at a time and blanking the address on a click in place), the CLI (`migrate`,
`domain add|list|verify`, `link add`, `settings show|set`, `ipdata status|update`,
`admin create|passwd`, `admin totp disable`, `apikey create|list|revoke`), `install.sh`, a
Compose stack, and the restart durability and stack test suites.
Expand All @@ -75,31 +77,28 @@ mistyped host name is not a reason to take every link on the install down.

What does not exist yet, and must not be implied by any documentation:

- **A web UI.** There is an admin API; nothing draws it yet, and it cannot be reached over
plain HTTP at all — the admin host answers 308, and the session cookie is `Secure` with
the `__Host-` prefix — so unlike a link domain it cannot be tried on a laptop.
- **More than one admin account.** One account is the whole of the access control: no
roles, no second person, no attribution of who did what. An API key is the only
credential that can be handed out and revoked on its own, and it is not a person.
- **A way back in over the API when both factors are lost.** Removing the second factor
there requires the second factor. `clickmonk admin totp disable` is the answer, and it is
a command on the server for that reason.
- **Anything drawn.** The reports are JSON and nothing plots them. There is no dashboard,
so how fresh the numbers are is `newestHour` in the summary and the chart rather than
something on a screen.
- **A CSV of a report.** The export is the click log. A summary or a breakdown is one small
answer already, and turning it into a file belongs to the web interface.
- **A time zone.** Every window, bucket and retention period is UTC, and a preset like
"yesterday" is the caller's to compute. The hourly grain leaves a whole-hour offset
possible later without a schema change; a half-hour zone would need one.
- **A CSV of a report, from the API itself.** `GET /api/clicks.csv` streams the click log
only. A summary or a breakdown is one small answer already, and turning one into a file is
the web interface's own doing, client-side, not a route the API streams.
- **A time zone, on the API.** Every window and retention period it takes is UTC, and so is a
chart's bucket size — `offset` only moves where a day bucket begins, in whole hours, and does
nothing for an hourly one. The web interface computes a preset like "yesterday" from the
browser's own zone; a caller of the API directly still has to — see
`packages/ui/src/window/range.ts`.
- **Notifications of any kind.** No mail configuration exists; `GET /api/alerts` is what
an operator reads instead.
- **Most link settings in the CLI.** `link add` takes `--target`, `--backup`, `--cap`,
`--expires`, `--no-passthrough` and `--action` only, and no command changes a link
after `link add`. `settings set` sets the install-wide traffic actions, the safe URL,
the abuser threshold and the two retention periods. Device URLs, a returning URL,
country rules, a name, a password and the disabled state are the admin API's, or
hand-written SQL without it.
country rules, a name, a password and the disabled state are the admin API's and the web
interface's, or hand-written SQL without it.
A returning URL also needs HTTPS to do anything: its cookie is marked `Secure`, so a
browser drops it over plain HTTP.
- **Proxy/VPN detection beyond Tor exits, cloud providers' published ranges, and region
Expand Down Expand Up @@ -161,6 +160,11 @@ packages/worker/ ships the spool into ClickHouse; runs migrations on boot;
writer of the settings row that the CLI and the admin API share.
packages/admin/ the admin API: sessions, API keys, TOTP, domain/link/settings CRUD,
the reports, the click log and its CSV export.
packages/ui/ the web interface: React 19, Tailwind 4, a Vite build the admin
service serves from its own host and dist/. No colour in source, no
Radix overlay primitive, no runtime import from a service package,
one <h1> per screen. Every time shown is the browser's own zone, except a
click's raw fields, which are labelled UTC.
packages/cli/ `clickmonk migrate | domain | link add | settings | ipdata | admin | apikey`.
Two commands here are deliberately not API routes: `admin create`,
because nothing can authenticate before the account exists, and
Expand All @@ -178,7 +182,9 @@ test/stack/ brings the whole stack up against a local certificate author
certificate, a sign-in over HTTPS, a password-protected link
answered end to end, a click driven through the whole stack and
read back as a report, a log page and a CSV, and a retention
period lowered and then enforced.
period lowered and then enforced. `ui.test.ts` drives the web
interface itself in a real browser, on that same stack, and fails
on any content security policy violation on any screen.
```

Three properties of the product shape every change:
Expand All @@ -200,6 +206,12 @@ pnpm build # required before the first `pnpm test`
pnpm test
```

**`pnpm test` is two suites, run one after the other: the service packages' own Vitest run,
then `packages/ui`'s** (`vitest run && pnpm --filter @clickmonk/ui test`). The interface's
suite has its own config and runs in `Australia/Adelaide`; see `packages/ui/vitest.config.ts`
and CONTRIBUTING.md. Neither talks to the browser suite in `test/stack/`, which is part of
the stack suites below.

**Build before the first test run, and after changing `core`, `db`, `ipdata`, `worker` or
`admin`.**
Packages import each other by name, which resolves to the *built* `dist/`, and `dist/`
Expand Down Expand Up @@ -288,6 +300,13 @@ These hold from the first line of code, whatever the stack turns out to be:
migration changes fresh installs only. Fix it forward in a new one.
- **A redirect never waits on reporting.** Recording a click is not allowed to add
latency to, or a failure mode to, sending the visitor on.
- **`packages/ui/src` writes no colour of its own.** A colour bypasses the measured brand
roles; use a semantic class or `var(--color-…)`.
- **`packages/ui` ships no Radix overlay primitive, no inline script or style, and no
runtime import from a service package.** The first two the content security policy
refuses outright; the third throws at load in a browser that has no Node behind it.
- **Every screen in `packages/ui` has exactly one `<h1>`**, asserted by heading level, not
by its text.

## Writing tests here

Expand Down
34 changes: 34 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,40 @@ You need Node 22, Docker with Compose v2, and `corepack` (it ships with Node). T

`CLAUDE.md` explains why the build comes before the tests, and the order CI runs checks in.

### Working on the web interface

`packages/ui` is a React app the admin service serves. From the repo root:

pnpm --filter @clickmonk/ui test # unit suite
pnpm --filter @clickmonk/ui build # tsc -b, then the production build

The unit suite runs in `Australia/Adelaide` on purpose — a zone that is not a whole number of
hours from UTC and observes daylight saving — so time logic that is only right in UTC fails
here. It runs as part of the root `pnpm test` too.

The browser suite drives a real browser against the shipped stack over HTTPS, so it goes
through the stack suites (see "The stack suites" above), not `pnpm --filter @clickmonk/ui
test`. Set `CLICKMONK_SHOTS=1` to have that run also write a screenshot of every screen, in
both themes and at two widths, to `test/stack/tmp/e2e/shots/`.

Rules the source-rule tests enforce, each with its own reason:

- **No colour in `packages/ui/src`** — no hex, `rgb()`/`hsl()`/`oklch()`, named colour, or
Tailwind palette class. A colour that bypasses the measured brand roles can pass contrast in
one theme and fail in the other. Use a semantic class (`bg-primary`, `text-destructive`…) or,
in SVG, `var(--color-…)`.
- **No Radix overlay primitive** (`Dialog`, `Select`, `Popover`…) — they inject a `<style>`
element at runtime, which the content security policy refuses. Use the native `<dialog>` in
`components/ui/modal.tsx`, or a native `<select>`.
- **No inline script or style in the built output** — the same policy has no exception for one,
so it would be silently blocked rather than shown.
- **No runtime import from `@clickmonk/core`** (`import type` only) — a service package pulls in
Node modules that throw when a browser bundle evaluates them, which is a blank page. Restate
the value in `api/vocabulary.ts`, whose test checks it against the service's own.
- **Every screen has exactly one `<h1>`**, through `PageHeader`; a card or section title is an
`<h2>`. Tests assert this by heading level, not by text, so a change that demotes a page's
only heading is caught.

## Code of conduct

All participation is governed by our [Code of Conduct](CODE_OF_CONDUCT.md).
7 changes: 7 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ WORKDIR /app
RUN corepack enable
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.base.json tsconfig.json ./
COPY packages ./packages
# The interface's stylesheet imports the brand's tokens from brand/, outside
# packages/. Only the build stage reads it: Vite writes everything it takes from
# there into packages/ui/dist, and nothing at runtime reads brand/ again.
COPY brand ./brand
# build:image compiles each package without its tests or the database test
# helpers, which exist only to run the test suite.
RUN pnpm install --frozen-lockfile && pnpm build:image
Expand Down Expand Up @@ -35,6 +39,9 @@ COPY --from=build /app/packages/db/dist packages/db/dist
COPY --from=build /app/packages/ipdata/dist packages/ipdata/dist
COPY --from=build /app/packages/redirect/dist packages/redirect/dist
COPY --from=build /app/packages/admin/dist packages/admin/dist
# No package.json for the interface: it has no runtime dependencies, only the
# files the admin service serves, built in the stage above.
COPY --from=build /app/packages/ui/dist packages/ui/dist
COPY --from=build /app/packages/worker/dist packages/worker/dist
COPY --from=build /app/packages/cli/dist packages/cli/dist
USER clickmonk
Expand Down
Loading
Loading