No change to how the vault is written or recovered. This fixes what the service
says when recovery is not possible, and adds a way out that is not "edit files
inside a container that will not start".
The refusal message was wrong in the case that actually matters. It said the
backup "could not be used either" whether the backup was unparseable, empty, or
simply absent — and absent is the common one, because a vault that has not
completed a single write since v0.1.3 introduced backups has no .bak at all.
That wording reads as though a file exists, and sends the operator hunting for
it. The message now names which of the three it is, and lists any
vault.json.corrupt-* files left by earlier recoveries.
DA_VAULT_RESET=1 boots an unreadable vault as empty. It is checked only on the
failure path, so it cannot affect a vault that loads, and it quarantines the
unreadable file *before* writing the fresh one — the first draft of this logged
that the file was preserved while persist would in fact have overwritten it.
Refusing by default is still right: booting empty is indistinguishable from a
vault emptied on purpose. But a refusal with no supported way forward is not a
safety feature, it is a dead end.
149 tests.
The notes now travel in the tag annotation, which is the one thing the runner
provably receives — it is named in the fetch refspec. Fetching them from the
worktree failed three different ways in five runs: `[ -f ]` false, `cat` empty,
and then `git show HEAD:` empty because the shallow fetch had not brought the
blob across. v0.1.3 shipped a placeholder body each time.
Two details that each silently ate the notes on the way here:
* `git tag -a -F` runs its default cleanup, which treats every line starting
with `#` as a comment. These notes are mostly headings, so most of the
document disappeared. `--cleanup=verbatim` is now required when tagging,
and the README shows the exact commands.
* `%(contents:body)` applies the same comment-stripping. The workflow reads
`%(contents)` and drops the subject line instead.
Verified with a round-trip against a scratch repo: tag message in, byte-exact
notes out. The leading H2 is gone from the file, since the release title
already carries it.
Two workflows, both aimed at the same problem: this container runs unattended
on someone's Unraid box, so the things that keep it correct have to happen
without anyone remembering to do them.
## dependency-check.yml
Runs every six hours and reports everything that has drifted in one place:
crates from Cargo.lock, the Rust pin in rust-toolchain.toml and the Dockerfile,
and the Dockerfile's base images. The result lands in a single issue titled
"Dependency updates" that is rewritten in place rather than commented on, and
is closed automatically when nothing is out of date — an open issue that never
changes is one people stop reading.
Nothing is upgraded automatically. `cargo update` on a schedule would put
unreviewed code into a service holding the operator's second factor, so the
report separates what `cargo update` covers from what crosses a breaking
boundary, and leaves the decision alone.
`.gitea/scripts/dependency-report.mjs` does the work and is deliberately
standalone, so the same file serves the sibling Rust repos. Two details worth
knowing:
* Crates are read from the sparse index, not the crates.io API. The API rate
limits around 130 lookups and answers 429, which silently turned most of
the first report into "could not check"; the index is one request per crate
with no limit.
* The pre-1.0 rule is applied properly. semver treats `0.x.y` as unstable, so
`argon2 0.5 -> 0.6` breaks exactly as hard as `1.5 -> 2.0`. Getting this
wrong labelled half the RustCrypto family "minor or patch" when `cargo
update` will not touch any of them.
## release.yml
Tag `vX.Y.Z` and this builds the image, starts it on a fresh root-owned volume,
waits for /healthz, checks the binary reports the tagged version, pushes to the
Gitea registry, and only then creates the release with the image digest in the
notes. Order matters: a release announcing an image that isn't in the registry
is worse than no release, and a broken build must not overwrite a tag that
already works.
It also asserts the tag and Cargo.toml agree, and rebuilds `:latest` on a
Dockerfile change without touching releases. Hand-written notes come from
docs/releases/<version>.md.
Requires a REGISTRY_TOKEN secret with write:package. Note that Gitea reserves
the `GITEA_` prefix for secret names and rejects it, which is why the secret is
not called GITEA_TOKEN.
Fixes a bug that shipped in v0.1: the per-account Edit sheet never opened at
all. Its Cancel button used `onclick: dlg.close`, evaluated while building the
arguments to openModal() and therefore before `const dlg` was initialised — a
temporal-dead-zone ReferenceError that aborted the whole handler. Every other
button in that sheet was already a closure, which is why it went unnoticed:
browser testing had covered add, import and settings, but never opened this
sheet.
Renames now reach Discord almost immediately. The push loop skips any account
whose current code it has already sent, so a rename previously stayed invisible
until the code next rolled — up to a full period. update() now clears the
last-sent code when the naming actually changes, so the next tick treats the
tile as stale and rewrites it in about a second. Toggling push alone is not a
rename and still costs nothing.
- webui: pencil icon and "Edit account" title so the row action reads as edit
rather than settings; both fields labelled, with the second one explained as
the username/email and left untouched unless changed
- webui: topbar is a three-column grid so the search sits on the same centre
line as the account tiles. Below 820px there isn't room for brand + 420px
search + actions, so it falls back to a row rather than clipping the
wordmark; the brand now reads "Discord Authenticator" in full
- webui: the add-account FAB pins to the viewport corner instead of tracking
the content column edge
- brand: docs/brand/icon.svg plus PNGs rendered from it by da-render, so the
README image, Gitea avatar and Unraid template icon all derive from one
source rather than drifting. Unraid's <Icon> now points at a PNG, which it
renders more reliably than SVG
126 tests (was 124).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
Rust workspace serving a Google/Microsoft-Authenticator-style web UI, with
each account's rotating code mirrored into Discord as a message that
rewrites itself in place as the code rolls.
The live mechanism is webhook message editing: POST ?wait=true returns the
message id, PATCH .../messages/{id} rewrites it thereafter. Two approaches
were evaluated and rejected — an externally-hosted image URL is cached by
Discord's media proxy and, worse, must be anonymously reachable, which would
publish live codes on an unauthenticated endpoint; and an animated countdown
drifts out of phase because playback starts at decode time, not on a wall
clock. The countdown instead uses Discord's <t:unix:R> markup, which the
client re-renders every second for free.
Codes are also sent as text in a fenced block so they stay copyable, with
the grouped, readable form on a rendered WebP tile.
- core: RFC 6238 TOTP over HMAC, lenient otpauth:// parsing (issuer conflicts
and non-canonical base32 both occur in the wild), XChaCha20-Poly1305 vault,
Argon2id admin auth
- discord: webhook client with a serialising rate limiter (~2 req/s) so
globally-aligned rollovers don't burst, and error classification that
distinguishes a deleted message (repost) from a dead webhook (stop)
- render: SVG -> WebP tiles via resvg with embedded JetBrains Mono
- http-api: axum, session auth, QR decode with a rescale/contrast/rotation
retry ladder
- webui: offline vanilla JS, Beautiful UI design language, light and dark
Ships as a 27 MB Alpine image (about 8 MB to pull); the binary is fully static musl with no
OpenSSL and no C dependencies, cross-compiled via cargo-zigbuild so building
linux/amd64 on Apple Silicon needs no QEMU.
101 tests, including the RFC 6238 vectors, QR round-trips, and the live push
cycle exercised against a mock Discord.
Co-Authored-By: Claude Opus 4.8 <[email protected]>