Skip to content

chore: release 1.0.0 - #10

Merged
lorem-dev merged 40 commits into
mainfrom
develop
Aug 12, 2026
Merged

lorem-dev merged 40 commits into
mainfrom
develop

Conversation

@lorem-dev

Copy link
Copy Markdown
Owner

Doppel 1.0.0. The browser dashboard is the headline, and the rest of this cycle
is what running it found.

What an operator gets

A dashboard on the admin listener's root, compiled into the binary: the proxy
set, a form over every field of a proxy including its mocks, status and reload.
It is a client of the admin API and bound by the same token rules, so it offers
only what the caller may actually do -- and it works without a token wherever the
API does.

Two endpoints it needed, both useful without it: GET /api/v1/access reports the
calling token's own rights, and GET /api/v1/schema serves the configuration's
JSON Schema, which is what the page checks a field against as it is typed. One
statement of every rule, in the binary.

Doppel now knows its own address (server.external_url, DOPPEL_EXTERNAL_URL,
or host:port) and uses it twice: a rewritten Location names it, and so do the
addresses in the text bodies a proxy relays (rewrite_urls, exact host only).
Both exist to stop a client walking off to the real backend past every injected
fault and every mock.

A write through the admin API is in force when it answers. It used to write the
document and leave the proxy forwarding to the old upstream until somebody
reloaded, which is the one defect here an operator reported from the outside.

Metrics answer more, and answer before anything has happened:
doppel_build_info, doppel_dashboard_info,
doppel_proxy_last_error_timestamp_seconds{code=""} and doppel_proxy_mocks
exist from startup, because a panel reads a never-recorded metric the same way it
reads a process nobody is scraping. The proxy histogram carries replace,
loss and upstream_error; the admin API has its own, labelled by route
template.

Breaking

GET /status is GET /api/v1/status. /metrics, /openapi.json and
/swagger-ui/ are where they were in 0.4.1 -- they moved and moved back during
this cycle, and the changelog records the net effect rather than the journey.

Why 1.0.0

The Changed section carries a breaking entry, and after 1.0 that is a major
bump; before it, the same entry would have been a minor one. The project has been
run against a real deployment now, which is the other half of calling it 1.0:
every fix in this cycle after the dashboard landed came from using it rather than
from reading it.

Verification

make gate clean with the PostgreSQL suite included -- 795 Rust tests, 127 jest,
eslint, both TypeScript projects, the size budgets, a strict docs build, the
schema, the parameter reference, the documentation links and the licence check --
and make e2e clean, 81 Playwright tests. The release scripts were dry-run:
release_notes.py 1.0.0 composes a body from the section this branch writes.

CI runs the whole thing again here. The gate above was captured before the
release commit itself, which changes Cargo.toml, Cargo.lock and CHANGES.md
and nothing else.

After the merge

The tag is cut from main, which is the only place a final tag may come from,
and pushing it is what publishes:

git checkout main && git pull
git tag -a v1.0.0 -m "doppel 1.0.0"
git push origin v1.0.0

lorem-dev and others added 30 commits August 2, 2026 21:07
chore: back-merge main into develop
chore: back-merge main into develop
chore: back-merge main into develop
chore: back-merge main into develop
chore: back-merge main into develop
chore: back-merge main into develop
chore: back-merge main into develop
The icons move to `assets/` at the root, where both builds can reach them: mkdocs
serves only what is under `docs/`, so a hook stages them into `docs/assets/`, and the
frontend's `prebuild` copies the favicon into `public/`. `favicon.ico` is generated
from a small-size variant of the mark, because the full one collapses into a smudge
at 16 pixels.

Tracked through git-lfs, except `icon-128.png`: Docker Hub reads it through
`raw.githubusercontent.com`, which serves the LFS pointer rather than the bytes --
verified, not assumed -- so the overview image would render broken with nothing in
this repository failing to say so.
0002, 0003 and 0004 each added one column and nothing else. Their whole content
was ADD COLUMN, plus a matching edit in load.rs, in save.rs, and in two
hand-written statements a test existed solely to keep in step. Nothing ever
queried admin_host or latency_min in SQL, so a column per field bought nothing
and charged a migration for every field the format gained.

configurations.settings and proxies.document now hold the documents; admin_tokens
and mocks are folded into them. What stays a column is what SQL uses: name and
revision for the compare-and-swap, ordinal for proxy order, config for the
foreign key. load.rs loses twenty-five per-column helpers, and the rows are
parsed by the same Deserialize impls the YAML loader uses, so a stored row is
held to exactly the rules a configuration file is held to.

The backfill in 0005 is checked by tests/migrate.rs, which plants rows in the old
shape, applies the migration, and asserts the result parses back into the same
configuration with the same revision. It has to: the revision is a hash of the
canonical YAML and every write compare-and-swaps against it, so a backfill that
changed one would refuse the next write as a conflict that never happened.

One property moves rather than disappears: the unique index on (config, token)
is gone, and rule V26 refuses duplicate token values on every write instead.
Two fields the browser dashboard needs: whether to serve it, and what heading to
show. Both default to something an existing configuration gets on upgrade without
being edited -- on, and "Doppel".

Both are Option and skipped when absent, so the canonical YAML of a configuration
that mentions neither is unchanged and no stored revision moves. Neither needed a
migration, which is the first dividend of storing the document as JSON; the
conformance fixture now carries both, so a store that went back to needing a
column per field would fail there rather than in production.

AdminTitle is a type rather than a String: non-empty, at most 64 characters,
no control characters, counted in characters so the bound does not depend on the
language the title is written in. It deliberately accepts markup -- escaping
belongs where the HTML is written, not in a type that would forbid a legitimate
heading to paper over a rendering bug.
The dashboard has to know what the caller may do, because a button for an action
the server will refuse is worse than no button: the operator finds out by trying.
Nothing else in the API says anything about the caller's rights.

Answers 200 for everybody, anonymous included -- an endpoint whose purpose is to
report that the caller may do nothing cannot itself demand a right, and it
discloses nothing that attempting the six actions would not.

Every field is authorize() evaluated, never a second reading of the access
blocks: a copy of that decision is a copy that can disagree with the one
enforcing it. the_report_agrees_with_what_the_request_actually_answers issues
every action for every token and asserts a reported false comes back 401 or 403
and a reported true does not, which is what catches the drift.

The per-proxy map is omitted, not emptied, for a caller who may not list: keyed
by proxy name it would be a proxy listing by another route, and access.rs is
explicit that a caller without read access must not be able to tell a real proxy
from an invented one.
Compiled into the binary and served from the admin listener's root: the proxy list,
a form over the whole proxy document including its mocks, status and reload. Most of
what the admin API does, without curl.

Vite, React and Tailwind, embedded by a build script rather than by a crate -- forty
lines, no new dependency. A build with no `frontend/dist` still compiles and answers
503 at `/`, so `cargo install` works on a machine with no Node; CI sets
`DOPPEL_REQUIRE_DASHBOARD_ASSETS` so that concession cannot apply there.

Usable without a token wherever the API is: the dialog opens once and has "Continue
without a token", and signing out returns to the anonymous view rather than a wall.
Controls for actions the caller lacks are disabled with the reason, not hidden --
the page asks `GET /api/v1/access` what the caller may do instead of guessing.

Settings reach the page as the contents of a JSON script element, which is what
allows `script-src 'self'` with no `unsafe-inline`; every less-than in that JSON is
escaped, because `admin.title` accepts markup on purpose. Not indexable three ways
-- header, meta element and `robots.txt` -- because crawlers fail differently.

The Playwright suite earned itself on the first run by finding three bugs nothing
else could: every rights-gated control disabled for the life of the page (a selector
returning a stable reference, so the report's arrival re-rendered nothing), a
template table rendering empty cells (the API calls the field `name`, the model
called it `file`), and the code editor crashing the built bundle with React error
#130 while working on the dev server.

Also here: the Makefile, because the assets are embedded at compile time and
`npm run build` alone changes nothing about a binary; the npm packages in
`THIRD-PARTY.md`; the dashboard's documentation page; and the server's complaint
attached to the field it is about rather than only to the banner.
`make image` did not work on a Mac. It cross-compiled for musl on the host, and
`ring` builds C, so that needs `aarch64-linux-musl-gcc` -- which nothing installs
by default and whose absence arrives as "Compiler family detection failed ...
ToolNotFound" from a build script rather than as anything about cross-compiling.

The Dockerfile now decides. A binary staged at `dist/<platform>/doppel` or
`dist/doppel` is used; when neither is there, one is compiled in a builder stage
for the platform being built, and that stage is discarded -- the image stays Alpine
plus the binary, 66 MB. So `docker build .` works from a checkout on any host, and
the Makefile is back to knowing nothing about Linux binaries: `make image` builds
the dashboard and calls docker.

Three things the tests of it turned up:

- `TARGETPLATFORM` is `linux/arm64/v8` on the classic builder and `linux/arm64`
  under buildx, so a directory named after the first is a binary nobody finds.
  `TARGETOS`/`TARGETARCH` are consulted as well.
- A glob tolerates a missing file but not a missing parent, so `dist/<platform>/`
  on a laptop failed the COPY outright. Copying `dist*` and searching inside it is
  what makes "use it if it is there" work at all -- alongside `.dockerignore`
  itself, which is the source that always matches.
- The release job now passes `BUILDER=alpine:3.24`: it always stages binaries, and
  without this it would pull a Rust image for both platforms to run a `cp`.

Both refusals are deliberate and tested by hand: no dashboard in `frontend/dist`
stops the compile rather than shipping a binary whose own root answers 503, and a
toolchain-free builder with nothing staged says which argument caused it.
Three defects, all visible in a browser and none of them to any test that existed.

**The dark theme rendered a white page.** Every component carried its own dark
text colour and nothing coloured the page, so a dark theme put near-white headings
on white. The page's colours are its own now, and `color-scheme` follows the choice
so the browser's own controls -- a select, a scrollbar, a caret -- stop being light
on a dark page. The new scenario measures contrast between painted pixels rather
than checking for a class: the failure it guards measured about 1.1:1 against a
4.5:1 threshold. Painted pixels because Tailwind 4 emits oklch(), which neither a
regex nor fillStyle will convert -- only a paint does.

**"Add" on a header row did nothing.** The rows were derived from the map on every
render, and a new row has no key, so the map could not hold it and the next render
dropped it. The rows are local state now, reseeded only when the map arrives
holding something they do not describe, and adjusted during render rather than in
an effect -- so there is no pass showing the old rows and nothing fighting the
input.

**Notices never went away.** Three reloads left three of them stacked in the corner
for the rest of the session. They expire on their own now, a failure lasting longer
than a success, timed in the store rather than in the component that renders them:
a page that navigates away would otherwise leave one behind forever.

Also covers the form the way it is actually used. One scenario fills in every field
of a proxy -- including a mock with all three selector maps, a JSON body and
response headers -- and reads the document back through the API rather than off the
screen. Another loads a proxy and saves it untouched, which must leave the document
identical: a control that renders one value and writes another shows up there and
nowhere else.
`/status` was both an endpoint and a page. The dashboard has a Status tab at that
path, and a reload rather than a click reached the endpoint and rendered its JSON --
which is the conflict this removes: everything the API serves lives under `/api/`,
and everything outside it belongs to the page.

That division is what makes a client-side route survive a reload. A GET outside
`/api/` and `/static/` answers with the page, so `/proxies/alpha` can be bookmarked;
a path under `/api/` still answers in the error envelope, so a mistyped endpoint does
not hand a client an HTML document to parse; and a missing asset stays a 404, because
answering it with the page means a typo'd script tag fails somewhere else entirely.

Breaking, and recorded as such: a healthcheck pointed at the old paths will 404.
The proxy table and the edit form were laid out for a narrower page than they get,
and the list did not refetch when a token arrived -- so signing in left the
anonymous view on screen until something else caused a fetch.
The page's own chrome. `Doppelganger` in two tones and an italic serif when nothing
named this Doppel -- two spans rather than an image, so it scales with the heading,
needs no asset and no `img-src`, and says the same thing to a screen reader. A
deployment that named itself gets its name.

The footer is one line: copyright at the left, links at the right. The year comes
from the build rather than from the browser's clock, because the page ships inside
the binary and the year it was published in is a fact about the build -- `httpdate`
turns the stamp into a year, and it is already in the tree under hyper.

And the editors: sized, coloured, and the templates form laid out in a grid rather
than a flex row that mixed a field carrying a hint with one that did not.
`GET /api/v1/schema` serves the configuration's JSON Schema, generated from the same
`ToSchema` impls the OpenAPI document is, cached for ten minutes and
unauthenticated: it describes the shape of a configuration, not a configuration.

It exists so the page's rules are the binary's rules. Every pattern, bound and enum
the form refuses a value by is read from that document at load, so a field cannot
drift from what the server will accept -- the failure this replaces was a regex
duplicated in TypeScript and then relaxed in Rust. What needs more than one field to
judge is still the server's answer on save, mapped back onto the field it is about.
…ntation

Three things that make the form readable, and the reference they point at:

- Syntax colouring where a value has a syntax: a mock's path pattern as a regex, a
  proxy document as YAML, and Jinja expressions wherever a value is rendered through
  the template engine -- including inside a JSON body, which needed the two grammars
  composed by hand. Prism gives overlapping greedy patterns to whichever starts
  first, so a single top-level Jinja token silently misses every expression inside a
  string.
- An (i) beside nearly every field, linking to that field's entry in a generated
  parameter reference, with the version of the running binary in the URL. The page is
  generated from the schema by `scripts/parameters_doc.py` -- 82 parameters, 73 with
  an example -- with a `--check` gate so it cannot drift, and
  `scripts/check_docs_links.py` resolves the links the page builds against the built
  site. A footnote marker rather than a button: it navigates, and a button that
  navigates lies to a keyboard.
- A YAML editing mode for the whole proxy: one editor with colouring, a Tab that
  indents rather than leaves the field, formatting on save and on a button, a link to
  the documentation, and live validation against the running schema -- a message
  under the editor rather than a save that fails.

`@cfworker/json-schema` rather than ajv, which compiles a schema with `new Function`
and cannot run under a policy with no `unsafe-eval`; `yaml` rather than `js-yaml`,
whose `argparse` is Python-2.0 and outside the dependency licence policy.
`/etc/doppel/main.yaml` as a single-file bind mount cannot be saved to: a save writes
a temporary file and renames it over the target, and a rename onto a bind-mounted
file is EBUSY. Reproduced in a container before and after -- `mv: can't rename
'.probe': Resource busy` -- and the fix is in three places: the compose file and the
documentation mount the directory, and the error names the mount and says which to
change instead of reporting the errno.

`main.example.yaml` leaves `admin.title` out, and says why in a comment.
Six changes from watching the page be used, and one of them is the removal of
another: templates were editable in the form, and that was the wrong shape -- a file
store inside a form over a document, with a Save of its own that did not wait for
the form's, and a rule the page could not enforce (the server refuses an upload no
saved mock declares). They stay in the admin API, where `doppel config push` and any
script already write them. A mock that answers with a template file shows which
file, in a field it will not let you edit, and can be moved onto a text or JSON body
instead.

The rest:

- A proxy can be renamed through an update, and its template directory moves with
  it. A collision is refused before anything is written, and a rename that moved the
  files but could not save the document reports both names.
- A save that would *remove* something asks first, and names what: each injected
  header, access override, mock, entry of a mock's four maps, and a template file the
  answer was switched away from. A changed value is not confirmed -- it is visible in
  the field the operator is looking at. Deleting a proxy asks too.
- Refresh on a refusal, beside the button that enters a token. A form reads once when
  it opens, so signing in afterwards used to leave the refusal on screen with nothing
  to press.
- The page stopped reporting its own policy violations to the console.
  `react-simple-code-editor` renders a `<style>` per editor and the policy allowed no
  inline stylesheet, so an operator reading the console for their own problems found
  dozens of ours. The policy names that one stylesheet by hash, recomputed by a test
  so an upgrade of the package fails the build rather than the console.
- The wordmark does not take a selection, and the title is a link home.
`docker build .` refused on a fresh checkout: the compile branch needs
frontend/dist, because the admin crate embeds whatever is there and an empty
directory yields a binary that starts, serves the API and answers 503 at its
own root. The refusal said to run `make frontend` first, which is a thing you
have to know.

It builds the dashboard itself now -- nodejs and npm installed in the builder
stage for exactly that, and only on the path that needs them. Building it on
the host stays faster and stays what `make image` does; it is no longer
required.

The context grew by what `vite build` reads: the package files, index.html,
the vite and tsconfig, src/, scripts/, and the favicon the prebuild step
copies out of assets/. Listed one by one rather than admitting frontend/,
because node_modules is larger than everything else in this context together.

The one refusal left is the honest one: neither a built dashboard nor the
sources to build it, which means the context is not what it should be.
The verification section named the Rust three, from when those were the gate.
`make gate` is ten targets now -- it builds the dashboard, runs both Rust
suites, jest, the bundle budgets, mkdocs and three generators -- so running it
after each task spends minutes re-checking what the task did not touch.

Same rule as before, with the table the section was missing: what to run for a
crate, for frontend/src, for a browser behaviour, for docs, for the schema, for
a dependency. The gate stays where it was: at the end, once.
`authorize()` answers every action as public while `admin.public` is set, so a
proxy's `access` block decides nothing there -- the process already says as much
among its startup advisories. The form offered the four fields anyway, which is
the page disagreeing with the binary serving it: an operator sets `delete` to a
group, saves, and the next anonymous request deletes the proxy.

The section is left out entirely rather than disabled. There is no state in
which those fields could be filled in usefully, and a disabled control invites
the question of what would enable it.

A proxy whose document still holds overrides -- the state an operator reaches by
turning `public: true` on over proxies they had already restricted -- says so in
one line where the section was. Without that, the YAML mode shows an `access`
block the form has no section for, and nothing accounts for the difference.
Nothing is dropped: the document keeps what it held, and a save preserves it.

Covered by an e2e test with its own instance, over both answers: `alpha` holds
an override and `beta` holds none. Mutation-checked twice -- rendering the
section unconditionally, and never rendering the line -- and each failed.
Four screenshots: the proxy list at the top of the page, the token dialog where the
access rules are explained, the form beside the paragraph that describes it, and the
YAML mode after the document it shows.

They live in `docs/screenshots/` rather than `assets/`. The icons are in `assets/`
because two builds need them -- mkdocs and the frontend's favicon -- and these are
read by mkdocs alone, so the directory that says so is the docs tree.
`docs/assets/` was not an option: it is git-ignored, being what the build hook stages
the icons into.

`*.webp` goes through git-lfs, for the reason the icons do: a screenshot is a new
whole file rather than a diff, and every visible change to the page brings one. Every
workflow that builds the documentation already checks out with `lfs: true`, which is
what stops this from shipping a pointer file as a broken image.

The year and the version are blurred out of the footers. They are the two things in
a screenshot that date it, and a reader who compares them against the version they
are running learns nothing except that the picture is older.
`config/` without a leading slash matches a directory of that name at any
depth, so it covered `crates/doppel-core/src/config/` as well as the one the
compose file mounts. Nothing broke: git keeps tracking files it already knows,
and those eight are tracked -- but every editor that reads `.gitignore` drew the
crate's own configuration module as excluded, and a new file added there would
have needed `git add -f`.

Anchored, along with `main.yaml` beside it, which had the same shape.
…pect

Two paths that were answered wrongly, both found by using them rather than by
reading them:

- `GET /metrics` returned the dashboard. Moving the exposition under `/api/v1/` was
  recorded as breaking on the grounds that a scrape of the old path would 404 -- it
  did not, because everything outside `/api/` and `/static/` is answered by the page,
  so a scraper received `200 text/html` and 1412 bytes of HTML. Measured against a
  running container. `/metrics` is the exposition again: that path is
  `metrics_path`'s default in Prometheus and in every agent and annotation that
  scrapes one, and it cannot collide with a page.
- A trailing slash was a 404 everywhere. Axum stopped redirecting between the two
  spellings in 0.8, so `/metrics/` answered nothing while `/metrics` answered, and a
  scrape config or a curl carrying a slash got nothing with the endpoint sitting
  right there. Trimmed before routing, because a route layer runs after routing has
  already decided there is nothing there.

The proxy listener gets none of the second: a proxied path is relayed byte for byte,
`/orders/` and `/orders` are two resources upstream, and one of this project's own
mocks matches `^/api/v1/resource/9/$`.
A rewritten `Location` was root-relative: an upstream answering
`Location: https://api.example.com/v2/orders/7` under that base produced
`/orders/7`. Correct, and enough for a client that resolves it against the URL
it used -- but nothing else. Anything that logs, stores or prints the header
gets a path with no host in it, and the case the relative form cannot express
was left escaping altogether: a redirect to the upstream's own host *outside*
the proxied path was relayed pointing at the upstream, so the client walked off
past every fault and every mock.

Both now name Doppel. Outside the base the path is kept as the upstream wrote
it, which is what `nginx`'s `proxy_redirect` does; whether Doppel serves that
path is a question about the configuration, and one that shows up in the logs
rather than as a client quietly talking to the backend. A redirect to another
host is still left alone -- Doppel does not proxy it, and naming itself would be
a lie.

That needs an address Doppel cannot infer, because `Host` is a claim by the
caller and building a redirect out of it hands the caller the redirect. So:

  1. `DOPPEL_EXTERNAL_URL`
  2. `server.external_url`
  3. `server.host` and `server.port`, with a wildcard bind read as loopback

The third makes the common case work with no configuration at all, and is the
one place this can be wrong: behind a port mapping or an ingress the client used
neither that address nor that port. Startup logs which address it settled on, so
that is one line away from being checked. The variable is read like
`DOPPEL_ADMIN_TOKENS` -- validated at startup, never merged into `Config`,
because the revision is computed over the document and folding the environment
in would make two instances reading one document disagree about it.

`ExternalUrl` is held to the same rules as an upstream base through one shared
`parse_base`, so the two cannot drift.

Five unit tests over the rewriting rules and four over the address resolution,
plus two through the built binary -- the wiring is what an operator reported
broken, and only an end-to-end test covers it. The shared test upstream learned
one redirect to its own authority for them. Mutation-checked by dropping
`with_external_url` at the call site: both end-to-end tests fail, one seeing the
old relative form.
This is the `mocks` flake, root cause and fix. It failed about one run in
twenty-five with `ECONNRESET` on a request the server never logged, the server
still alive and still listening -- 6 failures across 540 runs while I measured
it.

`free_port` bound `127.0.0.1:0`, took the port the kernel offered and closed the
listener. `std` sets `SO_REUSEADDR` on every `TcpListener`, and macOS then allows
`127.0.0.1:P` to be bound while another process holds `0.0.0.0:P` -- the more
specific address wins for connections to it. So the probe could draw a port a
server under test was already serving on, shadow it for the microseconds before
the listener dropped, and reset whatever connection landed in that window. The
`mocks` suite is the one that runs `main.example.yaml` with its `host: 0.0.0.0`,
which is why the flake lived there.

Proven rather than reasoned: a wildcard listener serving, a second socket bound
to `127.0.0.1` on the same port, a request to it, the shadow closed without
accepting -- `[Errno 54] Connection reset by peer` at the client, and the real
server never saw the connection. That is the fingerprint, down to the missing
log line.

A wildcard probe cannot do it: the kernel refuses a second wildcard bind on a
busy port with `EADDRINUSE`, so a collision becomes the lost-port race
`start_with_env` already retries. 500 runs, 0 failures.

Also ruled out along the way, each by measurement, so nobody re-treads them:
descriptor exhaustion, pipe-buffer exhaustion, ephemeral port exhaustion, and a
"wedge" from an earlier session that turned out to be the probe's own
single-threaded keep-alive upstream serialising eight forwards.
A `PUT` that changed an upstream answered `200`, wrote the document, and left the
proxy forwarding to the old host. Reproduced on a running instance with two
upstreams: the API read the new URL back, `/api/v1/status` reported the old
revision, and the traffic did not move until `POST /api/v1/config/reload`. The API
agreed with the operator while the process disagreed with both, and nothing
anywhere said the two had parted.

`POST`, `PUT` and `DELETE` now promote the stored configuration before replying,
through the same function and the same mutex the reload endpoint uses, so the
three cannot swap runtimes in the wrong order. Authorization is not repeated: the
caller was authorized for the write, and this is the second half of it rather than
a promotion of its own.

When the promotion fails the response names both halves -- stored, not running,
reload retries -- in the shape `renamed_but_stranded` already uses for the other
write that cannot be undone. The write stands, because it is not this process's to
take back.

Reload keeps the job it is actually for: a configuration changed *behind* the API,
which is the one thing no handler can promote for itself -- `main.yaml` edited by
hand, `doppel config push`, another instance on the same database.

Three tests changed sides, and each says something.
`status_reports_what_is_running_not_what_is_stored` still holds, now written
straight to the store, which is where that property lives. The one that said a
proxy created over HTTP "serves traffic after a reload" says without a reload. And
the form's end-to-end scenario had to start reading its own proxy back as
`reader`: it writes `access.read: reader` through the form, and that override is
now in force by the time the save answers -- root got a 403, which is the override
doing exactly what the page said it would.

Two new: the runtime's own view after a create, an update and a delete, and the
whole chain through the built binary -- two upstreams, both listeners, a `PUT`, and
a proxied request that comes back from the new one. Mutation-checked by dropping
the call from `update`: both fail, one showing `/api/v1/status` still naming the
old upstream.
lorem-dev and others added 10 commits August 13, 2026 01:20
…ponses

`/api/swagger-ui/` answered `303 See Other` with `location: /api/swagger-ui/` --
itself. A browser followed that until it gave up. The trailing-slash rewrite added
yesterday was in front of it: `utoipa-swagger-ui` answers the bare path with a
redirect to the slashed one, the layer trimmed the slash back off, and the pair
became a loop. Measured as exactly that before the fix.

`tower-http`'s `NormalizePathLayer` trims unconditionally and cannot exempt a
subtree, so the trimming is ours now: fifteen lines, one exception for
`/swagger-ui`, and `/` left alone because an empty path matches nothing.

The UI is `/swagger-ui/` and the document is `/openapi.json`, both outside `/api/`.
Neither is a resource of the API -- one is a page served to a browser, the other
describes every version this binary knows -- and both are where the tools that
consume them look by default, the way `/metrics` is. It also stops an ingress
routing `/api/*` to a JSON service from handing a browser HTML.

The listener compresses what it sends, `br` or `gzip` by negotiation, `br` first
when a client takes both. The dashboard is 140 KB of JavaScript and CSS and was
going out uncompressed on every visit -- embedded as bytes, served as bytes -- and
the Swagger UI's own stylesheet is another 150 KB. That is `tower-http` again,
which is why it stays a dependency: same crate, different feature, already in the
graph through utoipa-swagger-ui. Seven crates join the notices, all inside the
licence policy. The proxy listener is deliberately left out: it relays the
upstream's own encoding, because that is part of what a client is being tested
against.

The e2e suite gains the coverage that would have caught the loop, in a real
browser: the UI renders and lists an operation out of the document with no request
over 400, the bare path reaches it without looping, the exposition is Prometheus
text under both spellings, the document names the paths this binary serves, an
asset comes back `br`, `gzip` or plain by what was asked for, and the page at `/`
carries the *substituted* configuration rather than the placeholder that ships in
`index.html`. The harness hands out the proxy URL now, because an exposition with
no traffic behind it has no series to assert on.
The one file this listener cannot cache: the assets are content-hashed and cached
for a year, and `index.html` is rebuilt per request with the configuration spliced
into it, so its comments and indentation were paid for on every page load. 1422
bytes to 748.

`html-minifier-terser` in a `transformIndexHtml` hook, build-time only, and
deliberately conservative -- two things in that file are load-bearing and would
fail quietly if minification touched them. `id="doppel-config"` with double quotes
is how `dashboard.rs` finds the element to splice into and what `build.rs` asserts
is present, so `removeAttributeQuotes` is off and named in the comment for whoever
considers turning it on. `minifyJS` is off as well: the element is
`application/json`, and handing JSON to a JS minifier is a way to find out what it
does with it.

Verified where it would break rather than where it is easy: the binary builds (so
`build.rs` still finds the element), 119 jest tests pass, and the browser suite
reads the substituted configuration out of the served page -- title, version and
`titleIsDefault` from the configuration Doppel was started with, not from the file.
…s exist

Six changes, one theme: a scrape should answer the questions an operator has
during an incident, and answer them from a process that has served nothing yet.

`doppel_proxy_request_duration_seconds` carries `replace`, `loss` and
`upstream_error`, each `1` or `0`. Three independent flags rather than one enum,
because they are not exclusive: a mock can answer a request its own loss roll then
drops, and `upstream_error` covers a transport failure, a timeout and a relayed
5xx. Its buckets now reach 15, 30 and 60 seconds -- `latency` injection is asked
to be slow on purpose, so those are real measurements rather than outliers.

`doppel_admin_request_duration_seconds{route,method,status}` is the admin API's
own latency, labelled by the route template: `/api/v1/proxies/{name}`, never
`/api/v1/proxies/alpha`, so a hundred proxies are one series and a query string is
none. The middleware sits inside routing, which is what makes `MatchedPath`
available, and inside compression, so it measures the work rather than the
deflating. Its ladder stops at five seconds, because nothing here has any business
above that.

Four series exist from startup, before anything happens:

  doppel_build_info{version="..."} 1
  doppel_dashboard_info{enabled="true"} 1
  doppel_proxy_last_error_timestamp_seconds{code=""} 0
  doppel_proxy_mocks{proxy="..."} N

A panel and an alert both read a never-recorded metric as "no data", which is
indistinguishable from a process nobody is scraping -- so the facts that are true
at startup are published at startup. Build and dashboard are two info metrics
rather than one: the first describes the artifact and never changes, the second
what this deployment turned on, and folding a flag into build info makes two
builds of one version look different. The last error is a timestamp because the
question is "is this still happening", which `time() - metric` answers at any
scrape interval; a counter needs a rate window to say anything about now.

`doppel_proxy_mocks` follows the configuration in force rather than the one the
process started with: it is published from `RuntimeHolder`, so startup, a reload
and a write through the admin API all go through it. A proxy that leaves the
configuration is set to zero rather than abandoned -- the exporter cannot delete a
series, and a stale one reads as a proxy that still exists with three mocks.

Nine unit tests, one for the middleware, and the e2e suite asserts both halves in
a real browser: that the always-present series are present in a process that has
served nothing, and that two requests to one route with different parameters and a
query string produce one templated series with a count of two. Mutation-checked by
labelling with the raw path, and by leaving a departed proxy's gauge alone.
The field sat against the sentence explaining it, so the dialog read as one block.
`mt-3`, the same gap the buttons below already had. Screenshot replaced, with the
year and the version blurred out of the footer as on the others.

Tokens through the API, both ways round, which is the half the page cannot show:

- Private. The header names the caller -- `root`/`admin`, `reader`/`user` -- and the
  rights report matches what a write then does. An unrecognised token is *equal* to
  sending nothing, asserted by comparing the two responses, because answering
  differently would confirm which tokens exist. A refused write is 401 without a
  token and 403 with one that may not write, each naming the action, and nothing is
  written either way.
- Public. There is no way to set a token from the page, so the remaining route is
  one this browser kept from when the deployment was private: it must not resurrect
  the sign-in flow, and it does not -- no dialog, no Sign in, no Sign out, still
  anonymous, still allowed everything. Through the API a stale token is ignored
  rather than punished, which is what "unauthenticated" has to mean: a client that
  keeps sending its old header must not start getting 401s because a deployment
  went public.
`rewrite_redirects` keeps a client inside Doppel when the upstream answers a
redirect. A body does the same thing one layer down and was not covered: a page, a
script or a JSON document naming `https://api.example.com/v2/orders` sends the
client straight to the backend on its next request -- past every injected fault and
every mock, with nothing logged and nothing failing. `nginx` has `sub_filter` for
this. `rewrite_urls`, per proxy, on by default.

A URL under the proxied path loses that prefix, because that is where Doppel serves
it; one on the same host outside the path keeps its own, exactly as a rewritten
redirect does; and Doppel's own path prefix survives, so a deployment behind
`https://gw.example.com/doppel/` stays behind it.

**Only the exact host**, and that is the part worth reading twice. The needle
includes the scheme, and a match is rejected when the next character could continue
a hostname -- a letter, a digit, a dot or a hyphen. So `https://cdn.api.example.com`
is a different host and is left alone, and `https://api.example.com.evil.test` is
not turned into Doppel's address with somebody else's suffix glued on, which is what
a plain string replacement would have done. Both have tests.

Three limits, each relaying the body untouched rather than guessing: text only by
content type, uncompressed only, and bounded by the proxy's `body_limit` -- a bigger
body streams on from where the buffering stopped, since rewriting needs the whole
thing. A body declared as text that is not valid UTF-8 goes out as it came.

A rewritten body is not the entity the upstream sent, so `ETag` and the digest
headers go with it and `Content-Length` is restated. A conditional request carrying
the upstream's validator would otherwise be answered `304` for content the client
has never seen.

Nine unit tests over the rules, one through the built binary -- an upstream page
naming its own host and a `cdn.` sibling that must survive it -- and the form and
the generated parameter reference carry the field. Mutation-checked by disabling the
rewrite at the call site: the end-to-end test fails with the upstream's own address
in the link.
…it Doppel

A deployment that names itself got its name in the page's sans, beside a footer
that read as Doppel's own. Both now belong to it:

- The title is set in the wordmark's italic serif at the same size, split into
  words and toned like `Doppel` + `ganger` -- the first word strong, the rest in the
  accent colour. Three boundaries, because a name uses all of them: a space, an
  underscore or a hyphen, and a case change. Every character survives; `billing_api`
  reads as `billing_api` with `_api` toned, not as two words with the underscore
  eaten. An acronym is one word (`APIGateway`), and a single word is not drawn in
  the accent colour for no reason.
- The footer says "Built with Doppel <version>" before the copyright, links to
  "Doppel Documentation" rather than to "Documentation", and drops the repository
  link -- on `billing-api (staging)`, "Repository" reads as a link to the billing
  API's own, which it is not.

All of it only when `admin.title` is set. An unnamed Doppel is Doppel's own page and
keeps the footer it had, which the e2e suite now asserts from both sides.

The token dialog says "Access token": the token is what the API takes from any
caller, and `admin` in that heading read as a claim about which token it wants.

And the stored token's hour slides. Every request the page makes with a token
restarts it, so an operator working through the dashboard is not asked to type it
again in the middle -- while one who walked away still loses it an hour after their
last request, which is the claim the lifetime was always making. It cannot
resurrect an entry that has already aged out: `loadToken` decides that, and
`touchToken` asks it first.
CI failed on `a rejected document lands on the field the server complained about`,
and the failure was in a helper rather than in the page: `fieldMessage` read
`aria-describedby` with a single `getAttribute`, so it held `null` while the server's
refusal was still in flight. The assertion after it retries, which is why this was
the line that fell over. That run took 34 seconds for a suite that takes 15 on a
laptop, and six local repeats of the scenario passed.

Both helpers wait now. `paintedRgbAll` had the same shape -- `all()` returns whatever
matches at that instant, while the spans it is pointed at are the highlighter's and
appear on a React update rather than with the keystroke -- and was found by looking
for the pattern rather than by waiting for it to fail.
feat: a browser dashboard on the admin listener
The pre-release pass, and it found four things that were not true any more.

CHANGES.md described the journey rather than the destination. Three entries said
`/metrics` came back, the Swagger UI moved and every endpoint went under `/api/`
-- and the net effect against 0.4.1 is that `GET /status` is now
`/api/v1/status` and nothing else moved, which is checked against the routes at
the tag rather than remembered. The dashboard entry still claimed it manages mock
templates, which it stopped doing before it shipped. Every entry is now inside
the twenty-five words the changelog asks for, sixteen in total.

`docs/usage/configuration.md` was missing `server.external_url` and
`rewrite_urls`; both existed only in the generated reference, and the rule is
that every field in the configuration appears in the hand-written one too.

`DOCKERHUB.md` claimed the dashboard edits templates, and said nothing about
`DOPPEL_EXTERNAL_URL` -- which is precisely the environment where Doppel's guess
at its own address is wrong, since a port mapping is invisible from inside the
container. It now has the `docker run` form, the compose line commented out for
the case where the ports match, and the same fact is in `docs/usage/docker.md`,
because a fact that changed in one and not the other is how those two rot.

The overview's example tag goes back to the placeholder `1.2.3`: the release
workflow rewrites it, so a real version there is a second number to remember.

`pre-release-check` gains a step for that file. It is the only page published
somewhere this repository cannot see, nothing fails when it goes stale, and it
has now been wrong once -- which is the definition of a check worth writing down.
Against the code, against the Docker page, against the dashboard, plus the
placeholder and Docker Hub's 25000-byte truncation.

The compose file's comments are shorter by a fifth, and its
`DOPPEL_EXTERNAL_URL` says why it is there: 8080 inside, 58080 published, and a
rewritten redirect would otherwise name a port nobody can reach.
@lorem-dev
lorem-dev merged commit 244d8f7 into main Aug 12, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant