18 Commits
Author SHA1 Message Date
DSH Agent bd7f09095f v0.1.4: name the failure accurately, and add DA_VAULT_RESET
Build and release image / build (push) Successful in 1m9s
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.
v0.1.4
2026-09-20 04:08:10 -04:00
DSH Agent 72b9cbcab8 Fetch the tag annotation explicitly; checkout was shadowing it
Build and release image / build (push) Successful in 11s
`actions/checkout` fetches the tag by commit sha into refs/tags/<tag>, which
replaces the annotated tag with a lightweight one. `git tag -l` then reports
the *commit* message as the tag message, which is why the last attempt
published my commit message as the release notes. The annotation was never
lost, it just was not what that ref named any more.

The steps that read it are gone and the tag object is now fetched by its own
sha into refs/release-notes/<tag>, a namespace checkout cannot collide with.

Checked against a simulated runner checkout — fetch by sha into refs/tags, then
the workflow's own commands — and the notes come out byte-identical to
docs/releases/v0.1.3.md.
v0.1.3
2026-09-20 03:38:32 -04:00
DSH Agent 1b652a4ba2 Carry release notes in the tag, verbatim
Build and release image / build (push) Successful in 10s
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.
2026-09-20 03:34:01 -04:00
DSH Agent 316248c28d Read release notes from git, not the working tree
Build and release image / build (push) Successful in 10s
Four runs of this workflow produced a worktree where `git ls-files docs`
listed docs/releases/v0.1.3.md, `ls docs/releases` showed the directory, and
the file itself was not there — intermittently, checking out the same commit.
`[ -f ]` failed, `cat` failed, and the probes that proved it are in the
history. The v0.1.3 release went out with a placeholder body twice because of
it.

`git show HEAD:docs/releases/<version>.md` reads the blob from the object
database and does not care what the checkout raced on. Verified against a
fresh shallow clone of the tag before committing.
2026-09-20 03:28:37 -04:00
DSH Agent 3677c977a2 TEMP: probe the worktree contents
Build and release image / build (push) Successful in 10s
2026-09-20 03:23:55 -04:00
DSH Agent 1b9f3251d3 TEMP: probe the notes file read failure
Build and release image / build (push) Successful in 9s
2026-09-20 03:18:39 -04:00
DSH Agent 85e3a08e52 Fix the release notes properly, and drop the probe
Build and release image / build (push) Successful in 11s
The probe settled it: `ls -la docs/releases` listed v0.1.3.md at 3923 bytes in
the same directory, and `test -f docs/releases/0.1.3.md` on the very next line
still failed. That is an act_runner quirk, not a missing file — which is why the
v0.1.3 release carried a placeholder body while every other step read
files from that directory without trouble.

The notes are now collected by catting the file and checking whether anything
came out, in a step of its own before the workspace is touched by anything else.
Same question, asked against the operation actually needed, and it cannot
disagree with itself the way the existence test could.

Removes the temporary probe.
2026-09-20 03:13:49 -04:00
DSH Agent 1f456cad48 TEMP: probe the workspace before publishing the release
Build and release image / build (push) Failing after 9s
2026-09-20 03:08:40 -04:00
DSH Agent 451cdd19da Release notes: read the version from the commit, not a step output
Build and release image / build (push) Successful in 9s
The v0.1.3 release was created with the placeholder body instead of the notes
in docs/releases/v0.1.3.md, even though the file is present at the tagged
commit. The only difference between the check that failed and the tag that
worked is where the value came from: the image tag used the step output
directly, while the notes check used it through a step-level environment
variable.

Rather than keep guessing at that, the version now comes from the checked-out
Cargo.toml. The tag points at that commit, so the two agree by construction —
this removes the cross-step dependency entirely instead of depending on it
arriving intact. The step also logs which notes it used and warns when it falls
back, so a silent placeholder cannot happen again.
2026-09-20 03:01:51 -04:00
DSH Agent 3288b8314e Document the automation, and fix the stale defaults in the manual script
The README now covers both workflows: what the REGISTRY_TOKEN secret is for,
why the release only fires on a tag, and the three commands to cut a release.

`build-and-publish-unraid.sh` still defaulted to IMAGE_TAG=0.1.0 and
GIT_REF=v0.1, which would have built a two-release-old tag by hand and pushed
it as the current one. Both now point at 0.1.3.
2026-09-20 02:58:44 -04:00
DSH Agent 0dcf760f13 Fix two bugs the first CI runs exposed
Build and release image / build (push) Successful in 9s
Neither was visible without running the workflows for real.

## dependency-check: the report never ran

`rust:1.95-bookworm` has no Node, so every run died on `exec: node: not found`
before producing anything. The report needs cargo to resolve the workspace
graph and Node to read four HTTP APIs and compare semantic versions — the rust
image has one, the node image has the other.

`.gitea/scripts/ci.Dockerfile` layers the two together, copying Node's binaries
from the official image rather than adding a third-party apt repository: no
extra signing key, no extra network dependency mid-build. The check now builds
that image on its own pinned Rust version.

Writing the parsing twice, once per language, was the alternative. It would
have been two places for the pre-1.0 semver rule to drift apart.

## release: the smoke test asserted a file that shouldn't exist

It required `push-state.json`, which a fresh boot does not create: `vault.json`
and `push-state.json` are written when there is something to store, and a
brand-new install has no accounts. The container was healthy the whole time —
the assertion was simply wrong about what a first run leaves behind.

It now checks the binary reports the tagged version (which is the check that
actually matters, since it catches a tag pushed without a version bump) and
that `vault.key` was created non-empty, which is the honest proof that the
entrypoint's chown made the volume writable by the service user.

Also serializes image builds across refs: a tag push and a main push can land
together and both want to move the same `:latest`.
2026-09-20 02:54:15 -04:00
DSH Agent ef7ae7116f TEMP: diagnose the smoke test failure 2026-09-20 02:52:23 -04:00
DSH Agent 1f4697a885 Serialize image builds across refs
Build and release image / build (push) Failing after 8s
A tag push and a main push can land together and both want to build the same
version and move the same :latest. A per-ref concurrency group let them run in
parallel, which is exactly the race the group is supposed to prevent.
2026-09-20 02:49:40 -04:00
DSH Agent 60348b8133 Add CI: six-hourly dependency checks, and a tag-driven image release
Build and release image / build (push) Failing after 3m5s
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.
2026-09-20 02:48:27 -04:00
DSH Agent b21ade05eb v0.1.3: make the vault survive a power cut, and recover if it doesn't
The vault was being written with `fs::write(tmp)` → `rename(tmp, path)`. The
rename is atomic, so readers never saw a half-written file — but atomicity is
not durability. Nothing fsynced the new bytes before the rename, so on ext4
(`data=ordered`, the default) a crash or hard power-off could commit the
rename while the data it published never landed. The result is precisely the
failure that stopped the container booting: `vault.json` present, zero bytes,
"EOF while parsing a value at line 1 column 0".

Two things made it worse. The write happened on *every* push tick, so a
ten-account vault was rewriting all of its secrets thousands of times a day,
and there was no backup to fall back on — a damaged file meant a crash loop
with the web UI locked behind the service that would not start.

Storage

  * `store_io::write_atomic` fsyncs the new bytes, renames, then fsyncs the
    directory. That ordering is the whole fix; the module docs explain why.
  * Every vault write refreshes `vault.json.bak`, taken *after* the new file is
    safely in place so the two always agree. Backing up first would leave the
    backup a full change behind and quietly lose the most recent edit.
  * `Vault::load` falls back to the backup when `vault.json` is unparseable,
    moves the damaged file to `vault.json.corrupt-<ts>` rather than deleting
    it, and writes the recovered accounts back so the next boot is ordinary.
    With no usable backup it still refuses to start, as before.

Write volume

  * Discord bookkeeping — message ids, last codes, last errors — moves to its
    own `push-state.json`, batched and flushed at most every five seconds, with
    a final flush on shutdown. Codes roll constantly; accounts change only when
    the operator edits them, and keeping the two together was what put the
    secrets in the path of every rotation.
  * `push-state.json` is a cache by construction: losing or corrupting it costs
    a reposted tile, never a secret, so it is never a reason to refuse to boot.

Tests: recovery from a zero-length vault, backup retention, write-back on
recovery, refusal when both files are bad, reload of tile ids, batching, and
that a rotation leaves `vault.json` byte-identical. 143 passing.
2026-09-20 02:30:22 -04:00
Miles WardandClaude Opus 4.8 7751ebe9b3 v0.1.2: working Edit sheet, centred search, brand assets
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]>
v0.1.2
2026-08-31 01:42:18 -04:00
Miles WardandClaude Opus 4.8 eecdacd890 v0.1.1: import Google Authenticator exports
Google's "Transfer accounts -> Export accounts" produces an
otpauth-migration:// link wrapping a base64 protobuf that holds many accounts
at once, and splits large exports across several QR codes. v0.1 rejected these
outright; now they import.

The protobuf is decoded by hand rather than pulling prost: the schema is four
messages that have not changed since 2020, and the wire format needed is two
types (varint, length-delimited). Unknown fields are skipped properly, so a
future Google addition degrades rather than corrupting the parse.

Two details in this format silently produce plausible-but-wrong codes if taken
backwards, so both are pinned by tests: the `secret` field is RAW BYTES, not
base32, and the payload carries NO period field -- every Google export is
30-second TOTP.

- core/migration.rs: protobuf decode, lenient base64 (all four alphabet and
  padding spellings), issuer reconciliation between the `name` prefix and the
  dedicated `issuer` field, and batch metadata surfaced as "Part 1 of 2"
- vault: add_many() with duplicate detection, persisting once per batch rather
  than once per account; duplicates match on secret AND naming, so keeping one
  secret under two labels stays possible
- http-api: preview returns a list flagged with what is already stored; import
  takes the link plus selected indices and re-parses server-side, so secrets
  never travel out to the browser and back. /api/qr/decode now returns every
  payload in an image, not just the first
- webui: multi-account import checklist with select all/none, per-entry
  algorithm and digit chips, already-present entries pre-unticked, and skipped
  entries named with their reason (HOTP, MD5) instead of silently dropped

Verified end to end against real QR images: upload -> decode -> preview ->
import, with the resulting codes cross-checked against an independent RFC 6238
implementation, including an 8-digit account.

124 tests (was 101).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
v0.1.1
2026-08-31 00:44:59 -04:00
Miles WardandClaude Opus 4.8 f33effe20a v0.1: self-hosted TOTP authenticator with live Discord delivery
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]>
v0.1
2026-08-28 16:06:08 -04:00