Added
-
Signed, checksummed releases — and a verifier that fails closed. Nothing
in the release pipeline previously vouched for the bytes it published. The
release workflow now stages the published archive intorelease/, emits a
SHA256SUMSmanifest over that directory (so "published" and "covered" are
the same set by construction, not two hand-maintained lists), asserts one
manifest line per staged asset, and attaches a sigstore build-provenance
attestation minted from the workflow's OIDC identity — no long-lived signing
key exists, so there is none to leak, own or rotate. A release that staged
nothing, or whose manifest does not cover what it staged, is now a red
release rather than a green one with an empty manifest.
scripts/verify.shis the user-facing half: it fetches the manifest, looks up
the exact entry for the requested asset (string comparison on field 2 — a
substring/regex match would let…tar.gz.siganswer for…tar.gz) and
compares digests. Two outcomes only, verified or non-zero with a distinct
diagnostic; there is no--skip-verifyand no path where an absent
SHA256SUMSmeans "nothing to check" — that shrug is the bug the file exists
not to have, because it converts "I don't know" into "it's fine". The
release job runsverify.shagainst its own output before publishing, so
producer and consumer cannot drift apart silently. -
A CI failure matrix for the verifier (
bash scripts/verify.sh --selftest,
also a release-job step) — 24 synthetic-origin cases covering every refusal:
manifest 404, manifest served as an HTML error page (both by content-type and
by sniffing a lying one), empty/junk/truncated manifest, no entry for the
asset, the.sigand regex-wildcard name traps (one arranged so a naive
substring match would report exit 0 on an artifact nobody vouched for),
asset 404, asset served as HTML, truncated download, digest mismatch,
plaintext origin, missing curl or digest tool, and--attestwith nogh
installed. Each case asserts the exit code and that a diagnostic was
printed — a guard that aborts silently reads as a crash, not a refusal, and
"died at a pipeline underset -e" is precisely how a sibling installer's
unreachable guard shipped. -
Policy rates have a UI.
/api/v1/interest/*has shipped in the binary for
some time with nothing rendering it. The app now has a Policy rates
section: featured areas as cards with the rate, its grade, a stepped history
sparkline and the change over the trailing year, plus a disclosure listing
every other area carried. The sparkline is stepped rather than smoothed
because a policy rate holds flat and jumps at a meeting — interpolating
between decisions would draw moves that never happened. The grade earns its
keep here more than anywhere: the BIS still publishes legacy pre-euro national
series last observed in the 1990s, and the grade plus an explicit observation
date is what stops a 1998 number reading as today's. -
The rate board is sortable and filterable. Sort by grade, age, hop count,
rate or code; filter by code or full currency name. The useful question is
rarely "what is 1 ZAR in AED", it is "which of these numbers should I not lean
on", and that needs sorting by grade. -
npm --prefix web run shots(web/scripts/shots.mjs) captures every
screenshot on the README and the site from a running engine, in both themes
and at phone width, and re-encodes them to WebP. Nothing shown to a reader is
drawn by hand, so the images cannot drift from the app. -
E2E coverage for the policy section, board sorting and board filtering,
including the disabled-interest-engine configuration
(-interest-sources ""), which must degrade to a message rather than throw. -
THIRD-PARTY-NOTICES.txt— generated, full-text third-party licence notices
(Go stdlib/modules, npm packages including the OFL-1.1 webfonts, vendored
site bundles), produced byscripts/gen-notices.shand never hand-edited.
Served by the binary at/licenses.txtand by the marketing site, both
linked from their footers. -
Playwright end-to-end tests for the web UI (
web/e2e/boot.e2e.js,
web/e2e/converter.e2e.js) that boot the production-built bundle in real
Chromium and fail on any uncaught exception, blank root, or SPA-fallback
bug — wired intonpm testand a newweb-e2eCI job. -
Regression test coverage across
internal/ratelimit,internal/store,
internal/ratestore, andinternal/sources(XFF spoofing, bucket sweep
eviction, concurrent store access under-race, fixture-driven source
Fetchparsing, and secret-leak redaction for paid sources). -
README "Deployment modes" section documenting the two current shapes:
self-hosted binary and embedded Go library.
Changed
-
One logo, not two. The repo had drifted into carrying two different marks:
the two offset arcs used by the app, the site, the favicon and the README, and
a separate square tile inbrand/logo.svgdrawing two swapping arrows. The
arcs win and the arrows are retired — a rate is a ratio, two quantities held
against each other, which is what the offset arcs show, whereas swapping
arrows are the commonest glyph in the category and collapse into mush at
16 px.brand/logo.svgis now the arcs on the standard Vulos product tile
(128 box,rx28, near-black ground tinted toward the product's own hue).
That ground moved from#0F1D2Eto#08111D: the old value sat at roughly
twice the luminance of the fleet's other tiles and disappeared against the
dark product grid these are displayed on. The bare mark's title and
aria-labelare now "openrate" rather than "open rate", matching the product
name, in all four byte-identical copies. -
The interface was rebuilt around showing the working. A rate's path
through the currency graph is now drawn — each node a currency, each hop
carrying its own rate, source and age — and cross-source disagreement is
plotted on a scale zoomed to the quotes, with the mean marked. This is the
product's actual claim and it was previously buried in a table of numbers.
The same panel is shared by the converter and every expanded board row, so
the two cannot drift. -
New art direction, in the app and on the site. Security-print engraving:
a generated guilloché rosette (a real hypotrochoid, drawn in SVG), hairline
rules, grades struck as seals, and a warm banknote-paper light theme rather
than a cold white one. Type is Instrument Serif for display, Archivo for the
interface (its width axis gives the board genuinely condensed lettering) and
JetBrains Mono for every figure. Inter has been dropped. All faces stay
vendored via@fontsource— never fetched from Google Fonts. -
The site left the shared dark product template.
site/index.htmland
site/docs.htmlare rebuilt on the app's own theme and share
site/assets/site.css; the docs viewer gains cross-document search, per-block
copy buttons and the same theme switch, with the choice shared with the app. -
The embedded UI is now one hand-written HTML file, not a Vite/React app.
web/ui.html(590 lines, inline<style>and<script>, vanilla JS, no
build step) replaces the compiledweb/distbundle.web/embed.gonow
exposesHandler()(wasFS());cmd/openrate/main.goand root
openrate.gowere updated to call it. The converter and the rates board —
including the "show the working" panel (graph path, hops, sources, spread)
and the live A–D grade badge — are reimplemented in plain HTML and carried
over intact; what a reader sees of the FX side is unchanged, only how it
ships. No webfonts ship with it any more — system font stacks only. -
Rate-limiter
ClientIPnow walksX-Forwarded-Forfrom the right and skips
configured trusted-proxy hops instead of trusting the left-most (client
forgeable) entry. -
Vendored the Inter and JetBrains Mono webfonts locally instead of loading
them from Google Fonts at runtime, so a self-hosted instance never phones
home for UI assets. -
Bumped
vite^5→^8 and@vitejs/plugin-react^4→^6 (clears dev-tooling
npm auditadvisories);vite.config.jsnow preserves upstream@license
banners in the shipped bundle (output.comments.legal = true). -
Bumped the Go toolchain to go1.25.12 to clear reachable stdlib
vulnerabilities. -
README rewritten to be self-contained (dropped the "Part of VulOS" suite
banner/product-map section, added a footer logo instead) and CLOUD.md/README
updated to mark hosted, multi-tenant openrate as exploratory/deferred rather
than a current Vulos product; stale mail/"Workspace" references renamed to
"lilmail" and "Office" renamed to "Diwan" in the site footer.
Removed
quality.Assessment.Explainandratequality.Assessment.Explain: dead code
with no non-test caller in aninternal/package, whose doc comment claimed
it was "used in docs/tooltips" when the tooltip is built independently in
web/src/App.jsx.- The vendored mermaid bundle (3.5 MB).
site/docs.htmlloaded it eagerly on
every documentation page to render diagrams in documents that contain zero
diagrams — not one```mermaidfence exists in the repo. Its
third-party notice and the old shared-template stylesheet went with it. - Stale pricing and hosted-plan claims from the structured data. The JSON-LD
inweb/index.htmlstill advertised $9/$39/$149 tiers and an "openrate Cloud"
with an SLA, months after commit333882eremoved those claims from
everywhere a human could see them. Search engines could still read them. - The Node/Vite toolchain and every npm-derived byte in
web/. Deleted
web/dist/,web/src/,web/e2e/,web/public/,web/scripts/,
web/tools/,web/index.html,web/test-results/,package.json,
package-lock.json,vite.config.jsandplaywright.config.js.web/now
ships zero npm-derived code, with no build step, no npm, and no
node_modulesanywhere in the build path.scripts/gen-notices.shno
longer shells out tonpm ci/license-checkeragainstweb/; the
Geist-font and vendored-JS attribution it still owes now reads directly off
the filessite/vendors undersite/assets/fonts/and
site/assets/vendor/, which were the ones actually requiring it all along. - The interest-rate ("Policy rates") UI, added above earlier in this same
Unreleased cycle, is gone along with the in-binary docs viewer, the accuracy
explainer page, the marketing footer, the Vulos mark and the guilloché
decoration in the app./api/v1/interest/*is untouched and still serves
data — it just has no page rendering it right now.
Fixed
-
Every screenshot on the site rendered stretched. The stylesheet reset
pairedmax-width: 100%with noheight: auto, so an<img>carrying an
explicitheightattribute — which they all do, to reserve layout before they
load — had that height applied literally while its width was constrained. -
A non-finite rate could blank an entire API response. Every FX source
guards its parse withrate <= 0, which is false forNaNand+Inf, and
strconv.ParseFloataccepts the literal strings"NaN"/"Inf"without an
error (BIS already publishes literalNaNfor missing days). Such a value
entered the currency graph, multiplied into every path crossing that edge, and
aborted the JSON encoder mid-write — and becausewriteJSONhad already sent
a200and discarded the encoder error, consumers received200 OKwith an
empty body.internal/graphnow refuses any rate that is not positive and
finite, in both the quoted and the derived (1/rate) direction. -
Path arithmetic could overflow even when every leg was finite. A
triangulated rate is the product of its legs, which can reach+Inf(or
underflow to0) from representable inputs. Such a path is now skipped rather
than materialized; a longer representable path may still serve the pair. -
/api/v1/convertreturned200with an empty body for a largeamount.
The existing guard rejected non-finite input, but a finite amount times a
finite rate can still overflow. An unrepresentable result is now a400
({"error":"amount out of range for this pair"}). -
gradecould contradict theconfidencepublished beside it. The grade
was computed from the raw factor product while the response carried the
rounded value, so an ordinary day-old 2-hop exchange cross published
"confidence": 0.78next to"grade": "C"— against the documentedB ≥ 0.78
band. Both quality engines now grade the confidence they actually publish. -
Fixed a goroutine and
time.Tickerleak in the rate limiter:Limiternow
has adonechannel andStop(),gc()selects on it, andLocal.Close()
drains the background sweep goroutine on shutdown. -
New(0, _)no longer divides by zero —rpm/burstare clamped to at
least 1 soRetry-Afteris always finite. -
GET /api/v1/convertnow rejects non-finite (Inf/NaN)amountvalues
with a clean400instead of letting them poison the arithmetic and produce
a truncated200response body.
Documentation
docs/api.mddescribed theas_ofcontract backwards. It saidas_ofwas
the freshest edge on the path; the engine has always used the oldest, so
age_secis an upper bound on staleness. Corrected, and expanded with the
full as-of contract, the identity-pair case, and whyas_ofis not a change
cursor (built_atis).- Documented the complete
qualityblock for consumers writing their own
client: every field and its values, thatcaveatsis omitted (never[])
when empty, thatmean/min/maxappear only with ≥2 quotes and
stdev/stdev_bpsonly when non-zero, and thatagree(≤50 bps) uses a
different threshold from the confidence bands (25/100/300 bps). ACCURACY.md: completed the source-class table, which omittedpolygon,
tradermade,twelvedataandoxr, and theunknownclass; replaced the
"Typical coverage" per-currency grade claims — which the model does not
support — with the measured provenance table now pinned by tests.SOURCES.md: documented the four implemented key-gated FX sources (oxr,
twelvedata,polygon,tradermade) that the catalog omitted entirely.docs/interest-rates.md: the worked example published"confidence": 0.85
where the engine computes0.78(0.85 omits the US target-range caveat).- Removed
"caveats": []from every example — the key is never emitted empty. - Fixed the broken
../LICENSElink and MIT-only claim indocs/README.md
(the project is MIT OR Apache-2.0), and the stale "SARB stub" layout note. - Reconciled
site/docs/withdocs/, and rewrote its internal links to the
hash-router anchors the site actually resolves — every one was broken.
Testing / CI
- Removed a race from the E2E suite. The app issues three independent
requests on mount (/meta,/rates, and the interest pair) and each replaces
a different part of the tree as it resolves. Tests that clicked as soon as
their own target appeared were racing the other two: Playwright passed the
actionability check, React swapped the node underneath, and the click landed
on a detached element. It reproduced roughly once in forty runs and only under
parallel load. Interaction tests now establish the precondition explicitly via
a sharedsettled()helper rather than relying on timing; verified over 260
consecutive executions with no failure. - New coverage for the invariants above: non-finite rejection at the graph,
source and HTTP layers; an all-pairs "every materialized rate is finite and
JSON-encodable" property; and exhaustive verification thatgradeand
confidenceagree across all 2160 (FX) and 250 (interest) factor combinations. - Checksum-pinned the ISO 4217 currency table (
internal/sources/fiat.go) so
drift is detectable, plus structural checks and a guard that the web UI's
separate currency table covers the engine's set. - First tests for
internal/ratesources(previously none): BIS CSV parsing
including literal-NaNrows, ragged rows, name fallbacks, and error paths;
plus registry resolution and key auto-enable. - Asserted every registered source has a quality rank, so a new source cannot
silently grade asunknown. - CI now runs
gofmt,go test -race, and verifies the committedweb/dist
matches a fresh build (it is embedded in the binary, so a stale bundle ships). web-e2e(Node/Playwright/vite build/dist-staleness) is gone from CI,
replaced by ago test ./web -vstep.web/embed_test.goasserts the
embedded UI is present, non-trivial, exposes the converter/board markup and
the/api/v1/*calls they issue, and references no external origin — the
same boot-guard properties the Playwright suite checked, now against the
embedded bytes directly and with nothing to install.
Verify before you run this
curl -fsSLO https://raw.githubusercontent.com/vul-os/openrate/v0.1.1/scripts/verify.sh
bash verify.sh --tag v0.1.1 --attest openrate_0.1.1_source.zipverify.sh fetches SHA256SUMS, looks up the exact entry for the
asset and compares digests. It exits non-zero, with a distinct
diagnostic, on a missing or malformed manifest, a missing entry, a
truncated download or a digest mismatch. There is no skip path and a
missing SHA256SUMS is never treated as "nothing to check".
--attest additionally checks the sigstore build provenance (needs
the gh CLI); without it, provenance is NOT checked and the script
says so rather than letting a pass imply more than it checked.
Full Changelog: v0.1.0...v0.1.1