From 3d2d541f98003d44636ec3f2aaf561302c0fde41 Mon Sep 17 00:00:00 2001 From: Peter Rounce Date: Wed, 17 Jun 2026 10:23:43 +0000 Subject: [PATCH 1/8] docs: design for About page Recent Releases (release notes) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-17-about-release-notes-design.md | 158 ++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-17-about-release-notes-design.md diff --git a/docs/superpowers/specs/2026-06-17-about-release-notes-design.md b/docs/superpowers/specs/2026-06-17-about-release-notes-design.md new file mode 100644 index 0000000..0144ebb --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-about-release-notes-design.md @@ -0,0 +1,158 @@ +# About page: Recent Releases (release notes) — design + +Date: 2026-06-17 +Branch: `claude/about-release-notes` +Base: `origin/main` @ `0fcad68` (includes PR #40 per-commit version markers) + +## Problem + +The About page shows a "Recent Commits" list with a muted `vX.Y.Z` marker per +commit (PR #40). It reads like a raw, untidied changelog — merge-commit noise, +developer phrasing, and no grouping into releases. The user wants proper, +user-readable release notes grouped by release, scoped to the gap between the +hub's running version and the latest available version. + +## Goals + +1. Publish real **GitHub Releases** automatically when the app version is + bumped, with notes sourced from the commits in that release, tidied. +2. Replace the About page's "Recent Commits" card with **"Recent Releases"**, + sourced from the GitHub Releases API. +3. Show the release notes **between the running version and the latest + available version** (inclusive of both ends). When up to date, show only the + running version's notes. + +## Non-goals + +- No AI/LLM summarisation of notes (CI can't call a model). "Tidied" means + merge-commit noise stripped + version-bump lines dropped + bullet formatting. +- No backfilling of historical releases (the gap from v0.10.0 is handled by a + one-time capped "catch-up" release, not by reconstructing per-version tags). +- No new markdown dependency in the frontend. + +## Decisions (from brainstorming) + +- **Notes source:** auto-publish GitHub Releases in CI on version bump. +- **Tidying method:** commit subjects with merge commits stripped + (`git log --no-merges`), version-bump lines filtered. +- **Up-to-date display:** show only the current (running) version's notes. + +## Part 1 — CI: auto-publish a GitHub Release on version bump + +File: `.github/workflows/ci.yml`. Add a `release` job. + +- `needs: [docker]` — only release once images build/push succeeds. +- Gate: `if: github.event_name == 'push' && github.ref == 'refs/heads/main'`. +- **Job-level** `permissions: contents: write` (the workflow default stays + `contents: read` — least privilege preserved; only this job can tag/release). +- `actions/checkout@v4` with `fetch-depth: 0` so full history + tags are present. +- Steps (shell): + 1. Extract `V` from `docker/card/build/build.go` + (`grep -oP 'Version string = "\K[0-9]+\.[0-9]+\.[0-9]+'`). + 2. If tag `v$V` already exists (`git rev-parse -q --verify "refs/tags/v$V"`), + **skip the rest** (idempotent: re-runs and merges without a version bump + never create a duplicate release). + 3. Determine the previous release tag: + `PREV=$(git tag -l 'v*' --sort=-v:refname | head -n1)`. + - If empty (no tags at all), use the root commit as the range start. + 4. Build the notes body: + - `git log --no-merges --pretty=format:'%s' "$PREV"..HEAD` (or whole + history if no `PREV`). + - Drop lines that are pure version bumps + (grep -viE pattern: `^(bump|chore: bump).*version|^version bump|^bump to v?[0-9]`). + - Prefix each remaining subject with `- `. + - **Cap at 30 lines.** If more, keep the 30 most recent and append: + `` and `N` earlier commits`` plus a blank line and + `Full changelog: https://github.com/boltcard/hub/compare/$PREV...v$V`. + (No `PREV` → omit the compare line.) + 5. `gh release create "v$V" --title "v$V" --notes "$BODY"` + with `env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}`. + +Idempotency + the cap make the first ("catch-up") release readable rather than a +several-hundred-commit dump; every subsequent per-version release has a small, +clean span. + +## Part 2 — Backend: `/about/releases` replaces `/about/commits` + +Files: `docker/card/web/admin_api_about.go`, `docker/card/web/admin_api.go`. + +- Remove `adminApiCommits` and the per-commit version machinery it relied on: + `fetchCommitVersion`, `commitVersionMu`, `commitVersionCache`, + `parseVersionFromBuildGo`, `buildVersionRegex`. The N+1 GitHub contents fetch + is gone. (Keep `adminApiLogs`, `ansiToHTML`, `ansiColorMap`, `ansiRegex`.) +- Remove the `/admin/api/about/commits` route; add + `GET /admin/api/about/releases` → `adminApiAuth(app.adminApiReleases)`. + +`adminApiReleases`: + +- `R := build.Version`. `L := strings.TrimSpace(r.URL.Query().Get("latest"))` + — the frontend already has the latest version from `/about`, so it passes it + through; this avoids a second Docker Hub round-trip. `L` is public release + data used only to bound which already-public notes to show, so trusting the + client param has no security impact. +- One GET to `https://api.github.com/repos/boltcard/hub/releases?per_page=100` + (10s timeout, `Accept: application/vnd.github.v3+json`). Non-200 / error → + return `{"releases": []}`. +- Decode `[]{ tag_name, name, body, published_at, html_url }`. +- Compute the upper bound: + `upper := R; if L parses (3-part numeric) and CompareVersions(R, L) == 1 { upper = L }` + (`CompareVersions(current, latest)` returns 1 when `latest > current`). +- For each release: parse the version from `tag_name` (strip a leading `v`; + skip tags that aren't 3-part numeric). Include it when ver is in `[R, upper]`. + With `CompareVersions(a, b)` returning `1` when `b > a`: + - ver ≥ R ⇔ `CompareVersions(R, ver) >= 0` + - ver ≤ upper ⇔ `CompareVersions(ver, upper) >= 0` +- Sort the included releases descending by version. +- Respond `{"releases": [ { version, name, body, date, url, isCurrent } ]}` + where `isCurrent = (ver == R)`, `date = published_at`, `url = html_url`. + +Extract the pure decision — "given R, L and a list of release versions, which +versions are shown and in what order" — into a small testable helper +(e.g. `selectReleases(running, latest string, versions []string) []string`) +so the range logic is unit-tested without hitting the network. + +## Part 3 — Frontend: `about.tsx` + +File: `docker/card/admin-ui/src/pages/about.tsx`. + +- Replace the `Commit`/`CommitsData` types and the `about-commits` query with a + `Release`/`ReleasesData` type and an `about-releases` query: + `apiFetch(\`/about/releases?latest=${encodeURIComponent(data.latestVersion || "")}\`)`. + Gate the query on `data` being loaded (it needs `latestVersion`). +- Rename the card title **"Recent Commits" → "Recent Releases"**. +- Render each release as a block: + - Heading row: `v{version}` (mono) + formatted `date`; a `Current` badge when + `isCurrent`; the heading links to `url` (release page) in a new tab. + - Body: split on newlines; lines starting with `- ` become `
  • ` in a `
      `; + other non-empty lines become `

      `; bare URLs are linkified. Plain text via + React children (auto-escaped) — no `dangerouslySetInnerHTML`, no markdown lib. +- Empty state: if `releases.length === 0`, render a muted + "No release notes available." line (or hide the card body) instead of the list. + +## Part 4 — Version bump + docs + +- `docker/card/build/build.go`: `0.21.0` → `0.22.0` (MINOR — new feature). +- `CLAUDE.md`: update the `build/` bullet's "currently 0.21.0" reference to + `0.22.0`. Optionally note the new `release` CI job + `/about/releases` endpoint + in the relevant sections. + +## Testing + +- **Go:** table-driven test for `selectReleases` covering: + up-to-date (`L==R` → only R), behind (`R < L` → R..L inclusive, descending), + running version has no release in the list (fewer/none returned), + unparseable/empty `L` (falls back to only R), and tag strings with/without a + `v` prefix. Run `cd docker/card && go test -race -count=1 ./web/`. +- **Frontend:** build-checked only (no FE test runner) — `npm run build` in + `docker/card/admin-ui`. +- **CI release job:** not unit-tested (shell in the workflow); validated by + the idempotency guard (safe to re-run) and reviewed manually. + +## Edge cases + +- GitHub API down / rate-limited → empty releases list → empty state. +- Hub running a version older than the first published release → range yields + nothing → empty state (expected until releases accumulate). +- Docker Hub check failed (`L` empty) → treated as up-to-date → only R's notes. +- Tag `vX.Y.Z` exists but no matching version in build.go yet → handled by the + CI "skip if tag exists" guard. From f90e4bdb94af4cd27c26c59ef95d02cfab0d5a31 Mon Sep 17 00:00:00 2001 From: Peter Rounce Date: Wed, 17 Jun 2026 10:30:33 +0000 Subject: [PATCH 2/8] docs: implementation plan for About Recent Releases Co-Authored-By: Claude Opus 4.8 (1M context) --- .../plans/2026-06-17-about-release-notes.md | 864 ++++++++++++++++++ 1 file changed, 864 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-17-about-release-notes.md diff --git a/docs/superpowers/plans/2026-06-17-about-release-notes.md b/docs/superpowers/plans/2026-06-17-about-release-notes.md new file mode 100644 index 0000000..7ba64d5 --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-about-release-notes.md @@ -0,0 +1,864 @@ +# About page: Recent Releases Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the About page's raw "Recent Commits" list with "Recent Releases" — proper GitHub Releases (auto-published in CI on version bump from tidied commit subjects), scoped to the gap between the hub's running version and the latest available version. + +**Architecture:** A CI `release` job tags `v{version}` and publishes a GitHub Release whenever `build/build.go`'s version changes. The card backend swaps its N+1 per-commit GitHub fetch for a single Releases-API call, filters the releases to the `[running, latest]` range with a pure testable helper, and the React About page renders them as release blocks. + +**Tech Stack:** Go 1.25.11 (Gorilla Mux handlers, CGo/sqlite), React 19 + Vite + TypeScript + Tailwind v4 + shadcn/ui, GitHub Actions, `gh` CLI. + +## Global Constraints + +- Module name is `card`; imports are `card/build`, `card/web`, etc. Working dir for Go tests: `docker/card/`. +- Go tests need CGo (default-on) and run via `go test -race -count=1 ./...`. +- Admin API handler pattern: `func (app *App) adminApiX(w http.ResponseWriter, r *http.Request)`; routes registered in `web/admin_api.go` behind `adminApiAuth(...)`; JSON responses via `writeJSON(w, v)`. +- `CompareVersions(current, latest string) int` returns `1` when `latest > current`, `0` when equal, `-1` when `latest < current` (in `web/update.go`). +- Bump `Version` in `docker/card/build/build.go` for this PR: `0.21.0` → `0.22.0` (MINOR — new feature). Keep the `build/` line in `CLAUDE.md` ("currently \"0.21.0\"") in sync. +- Frontend: no new markdown dependency; React auto-escapes children — do not use `dangerouslySetInnerHTML` for release bodies. +- Node/npm in this dev env: `export NVM_DIR="/home/debian/.nvm" && . "$NVM_DIR/nvm.sh" && nvm use v22.22.0` before npm; frontend build = `npm run build` in `docker/card/admin-ui`. +- Repo: `boltcard/hub`. Default branch `main`. Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) `. + +--- + +## File Structure + +- `docker/card/web/admin_api_about.go` — Modify. Add `selectReleases` + `isVersion` + `versionRegex` (Task 1). Replace `adminApiCommits` with `adminApiReleases`; remove `fetchCommitVersion`, `commitVersionMu`, `commitVersionCache`, `parseVersionFromBuildGo`, `buildVersionRegex` and the `encoding/base64`/`sync` imports; add `sort` (Task 2). Keep `adminApiAbout`, `adminApiTriggerUpdate`, `adminApiLogs`, `ansiToHTML`, `ansiColorMap`, `ansiRegex`. +- `docker/card/web/admin_api_about_test.go` — Modify. Add `TestSelectReleases` (Task 1); remove `TestParseVersionFromBuildGo` (Task 2). +- `docker/card/web/admin_api.go` — Modify. Swap the `/about/commits` route for `/about/releases` (Task 2). +- `docker/card/admin-ui/src/pages/about.tsx` — Modify (full replacement). "Recent Releases" card + releases query + inline notes renderer (Task 3). +- `.github/workflows/ci.yml` — Modify. Add the `release` job (Task 4). +- `docker/card/build/build.go` + `CLAUDE.md` — Modify. Version bump + doc sync (Task 5). + +--- + +## Task 1: Release-range selection helper (`selectReleases`) + +**Files:** +- Modify: `docker/card/web/admin_api_about.go` (add helpers; also add `sort` to imports) +- Test: `docker/card/web/admin_api_about_test.go` (add `TestSelectReleases`) + +**Interfaces:** +- Consumes: `CompareVersions(current, latest string) int` from `web/update.go`. +- Produces: + - `func isVersion(s string) bool` — true iff `s` matches `^\d+\.\d+\.\d+$`. + - `func selectReleases(running, latest string, versions []string) []string` — returns the subset of `versions` (each a bare `X.Y.Z`) to display, sorted newest-first. Range: `running <= ver <= upper`, where `upper = latest` when `latest` is a valid version greater than `running`, else `upper = running`. Non-version entries in `versions` are skipped. Returns `nil` when `running` is empty. + +- [ ] **Step 1: Write the failing test** + +Add to `docker/card/web/admin_api_about_test.go`: + +```go +func TestSelectReleases(t *testing.T) { + tests := []struct { + name string + running string + latest string + versions []string + want []string + }{ + { + name: "up to date shows only running", + running: "0.22.0", + latest: "0.22.0", + versions: []string{"0.22.0", "0.21.0", "0.20.0"}, + want: []string{"0.22.0"}, + }, + { + name: "behind shows running through latest, descending", + running: "0.20.0", + latest: "0.22.0", + versions: []string{"0.19.0", "0.22.0", "0.20.0", "0.21.0"}, + want: []string{"0.22.0", "0.21.0", "0.20.0"}, + }, + { + name: "running version absent from list", + running: "0.20.5", + latest: "0.22.0", + versions: []string{"0.22.0", "0.21.0", "0.20.0"}, + want: []string{"0.22.0", "0.21.0"}, + }, + { + name: "empty latest falls back to running only", + running: "0.22.0", + latest: "", + versions: []string{"0.22.0", "0.21.0"}, + want: []string{"0.22.0"}, + }, + { + name: "garbage latest falls back to running only", + running: "0.22.0", + latest: "not-a-version", + versions: []string{"0.22.0", "0.21.0"}, + want: []string{"0.22.0"}, + }, + { + name: "latest below running is ignored (no downgrade range)", + running: "0.22.0", + latest: "0.21.0", + versions: []string{"0.22.0", "0.21.0"}, + want: []string{"0.22.0"}, + }, + { + name: "non-version tags are skipped", + running: "0.21.0", + latest: "0.22.0", + versions: []string{"0.22.0", "latest", "v0.21.0", "0.21.0"}, + want: []string{"0.22.0", "0.21.0"}, + }, + { + name: "empty running returns nil", + running: "", + latest: "0.22.0", + versions: []string{"0.22.0"}, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := selectReleases(tt.running, tt.latest, tt.versions) + if len(got) != len(tt.want) { + t.Fatalf("selectReleases() = %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("selectReleases() = %v, want %v", got, tt.want) + } + } + }) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd docker/card && go test ./web/ -run TestSelectReleases -v` +Expected: FAIL — `undefined: selectReleases` (and `isVersion`). + +- [ ] **Step 3: Add the helpers** + +In `docker/card/web/admin_api_about.go`, add `"sort"` to the import block (alphabetical order: after `regexp`/before `strings`), and append these helpers (e.g. near the bottom, before `ansiColorMap`): + +```go +var versionRegex = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) + +// isVersion reports whether s is a bare three-part numeric version (X.Y.Z). +func isVersion(s string) bool { + return versionRegex.MatchString(s) +} + +// selectReleases returns the release versions to display, newest-first, given +// the running version, the latest available version (may be "" or invalid), +// and the available release versions (each a bare "X.Y.Z"). The shown range is +// running <= ver <= upper, where upper is the latest version when it is a valid +// version greater than running, otherwise running. Non-version entries are +// skipped. Returns nil when running is not a valid version. +func selectReleases(running, latest string, versions []string) []string { + if !isVersion(running) { + return nil + } + upper := running + if isVersion(latest) && CompareVersions(running, latest) == 1 { + upper = latest + } + + var out []string + for _, v := range versions { + if !isVersion(v) { + continue + } + // running <= v <= upper + if CompareVersions(running, v) >= 0 && CompareVersions(v, upper) >= 0 { + out = append(out, v) + } + } + + sort.Slice(out, func(i, j int) bool { + // descending: out[i] before out[j] when out[i] > out[j] + return CompareVersions(out[i], out[j]) == -1 + }) + return out +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd docker/card && go test ./web/ -run TestSelectReleases -v` +Expected: PASS (all subtests). + +- [ ] **Step 5: Commit** + +```bash +git add docker/card/web/admin_api_about.go docker/card/web/admin_api_about_test.go +git commit -m "Add selectReleases range helper for About releases + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 2: `/about/releases` endpoint replaces `/about/commits` + +**Files:** +- Modify: `docker/card/web/admin_api_about.go` (replace handler + remove dead code + imports) +- Modify: `docker/card/web/admin_api_about_test.go` (remove `TestParseVersionFromBuildGo`) +- Modify: `docker/card/web/admin_api.go` (route swap) + +**Interfaces:** +- Consumes: `selectReleases`, `isVersion` (Task 1); `build.Version`; `writeJSON`; `CompareVersions`. +- Produces: `GET /admin/api/about/releases?latest=` → JSON `{"releases": [{ "version": string, "name": string, "body": string, "date": string, "url": string, "isCurrent": bool }]}`, newest-first. + +- [ ] **Step 1: Replace the handler and remove dead code** + +In `docker/card/web/admin_api_about.go`: + +(a) Remove `"encoding/base64"` and `"sync"` from the import block (no longer used after this task). Keep `card/build`, `encoding/json`, `html`, `io`, `net/http`, `regexp`, `sort`, `strings`, and the logrus import. + +(b) Delete the entire `adminApiCommits` function. + +(c) Delete `parseVersionFromBuildGo`, `buildVersionRegex`, the `commitVersionMu`/`commitVersionCache` `var` block, and the `fetchCommitVersion` function. + +(d) Add the new handler (e.g. where `adminApiCommits` was): + +```go +func (app *App) adminApiReleases(w http.ResponseWriter, r *http.Request) { + type release struct { + Version string `json:"version"` + Name string `json:"name"` + Body string `json:"body"` + Date string `json:"date"` + URL string `json:"url"` + IsCurrent bool `json:"isCurrent"` + } + + empty := map[string]interface{}{"releases": []release{}} + + running := build.Version + latest := strings.TrimSpace(r.URL.Query().Get("latest")) + + client := &http.Client{Timeout: 10e9} + req, err := http.NewRequest("GET", "https://api.github.com/repos/boltcard/hub/releases?per_page=100", nil) + if err != nil { + writeJSON(w, empty) + return + } + req.Header.Set("Accept", "application/vnd.github.v3+json") + resp, err := client.Do(req) + if err != nil { + writeJSON(w, empty) + return + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + writeJSON(w, empty) + return + } + + var ghReleases []struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + PublishedAt string `json:"published_at"` + HTMLURL string `json:"html_url"` + } + if err := json.NewDecoder(resp.Body).Decode(&ghReleases); err != nil { + writeJSON(w, empty) + return + } + + byVersion := map[string]release{} + var versions []string + for _, g := range ghReleases { + ver := strings.TrimPrefix(g.TagName, "v") + if !isVersion(ver) { + continue + } + if _, dup := byVersion[ver]; dup { + continue + } + name := g.Name + if name == "" { + name = "v" + ver + } + byVersion[ver] = release{ + Version: ver, + Name: name, + Body: g.Body, + Date: g.PublishedAt, + URL: g.HTMLURL, + IsCurrent: ver == running, + } + versions = append(versions, ver) + } + + selected := selectReleases(running, latest, versions) + releases := make([]release, 0, len(selected)) + for _, v := range selected { + releases = append(releases, byVersion[v]) + } + + writeJSON(w, map[string]interface{}{"releases": releases}) +} +``` + +- [ ] **Step 2: Remove the obsolete test** + +In `docker/card/web/admin_api_about_test.go`, delete the entire `TestParseVersionFromBuildGo` function (it references the now-removed `parseVersionFromBuildGo`). Leave `TestSelectReleases`. The file's only remaining import is `"testing"`. + +- [ ] **Step 3: Swap the route** + +In `docker/card/web/admin_api.go`, replace: + +```go + case path == "/admin/api/about/commits" && r.Method == "GET": + app.adminApiAuth(app.adminApiCommits)(w, r) +``` + +with: + +```go + case path == "/admin/api/about/releases" && r.Method == "GET": + app.adminApiAuth(app.adminApiReleases)(w, r) +``` + +- [ ] **Step 4: Verify it builds, vets, and tests pass** + +Run: `cd docker/card && go vet ./web/ && go build ./... && go test ./web/ -count=1` +Expected: no vet errors, build succeeds, tests PASS. (If `go vet` flags an unused import, you missed removing `encoding/base64` or `sync` in Step 1(a).) + +- [ ] **Step 5: Commit** + +```bash +git add docker/card/web/admin_api_about.go docker/card/web/admin_api_about_test.go docker/card/web/admin_api.go +git commit -m "Replace About /commits with /releases endpoint + +Single GitHub Releases API call (drops the per-commit N+1 fetch); filters +to the running..latest version range via selectReleases. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 3: About page — "Recent Releases" card + +**Files:** +- Modify (full replacement): `docker/card/admin-ui/src/pages/about.tsx` + +**Interfaces:** +- Consumes: `GET /about/releases?latest=` JSON from Task 2 (`{ releases: [{ version, name, body, date, url, isCurrent }] }`). +- Produces: no downstream consumers (leaf page). + +- [ ] **Step 1: Replace the file contents** + +Overwrite `docker/card/admin-ui/src/pages/about.tsx` with: + +```tsx +import { useQuery, useMutation } from "@tanstack/react-query"; +import { apiFetch, apiPost } from "@/lib/api"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + Table, + TableBody, + TableCell, + TableRow, +} from "@/components/ui/table"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { ArrowUpCircle, Loader2 } from "lucide-react"; +import { useState, type ReactNode } from "react"; +import { toast } from "sonner"; + +interface AboutData { + version: string; + buildDate: string; + buildTime: string; + latestVersion: string; + updateAvailable: boolean; +} + +interface LogsData { + logs: string[]; +} + +interface Release { + version: string; + name: string; + body: string; + date: string; + url: string; + isCurrent: boolean; +} + +interface ReleasesData { + releases: Release[]; +} + +// linkify turns bare http(s) URLs in a line into anchor elements; other text is +// returned verbatim (React escapes it). +function linkify(text: string): ReactNode { + const parts = text.split(/(https?:\/\/[^\s]+)/g); + return parts.map((part, i) => + /^https?:\/\//.test(part) ? ( + + {part} + + ) : ( + {part} + ), + ); +} + +// ReleaseNotes renders a release body: "- " lines become a bullet list, blank +// lines break groups, everything else is a paragraph. No markdown dependency. +function ReleaseNotes({ body }: { body: string }) { + const elements: ReactNode[] = []; + let bullets: string[] = []; + + const flush = () => { + if (bullets.length > 0) { + const items = bullets; + elements.push( +

        + {items.map((b, i) => ( +
      • {linkify(b)}
      • + ))} +
      , + ); + bullets = []; + } + }; + + for (const raw of body.split("\n")) { + const line = raw.trimEnd(); + if (line.startsWith("- ")) { + bullets.push(line.slice(2)); + } else if (line.trim() === "") { + flush(); + } else { + flush(); + elements.push( +

      + {linkify(line)} +

      , + ); + } + } + flush(); + + return
      {elements}
      ; +} + +export function AboutPage() { + const { data, isLoading } = useQuery({ + queryKey: ["about"], + queryFn: () => apiFetch("/about"), + }); + + const { data: logsData } = useQuery({ + queryKey: ["about-logs"], + queryFn: () => apiFetch("/about/logs"), + }); + + const { data: releasesData } = useQuery({ + queryKey: ["about-releases", data?.latestVersion], + queryFn: () => + apiFetch( + `/about/releases?latest=${encodeURIComponent(data?.latestVersion ?? "")}`, + ), + enabled: !!data, + }); + + const [dialogOpen, setDialogOpen] = useState(false); + + const [updating, setUpdating] = useState(false); + + const triggerUpdate = useMutation({ + mutationFn: () => apiPost("/about/update"), + onSettled: () => { + setDialogOpen(false); + setUpdating(true); + toast.success("Update triggered — restarting containers…"); + // Poll until the server comes back with a new version + const poll = setInterval(async () => { + try { + const res = await fetch("/admin/api/about"); + if (res.ok) { + clearInterval(poll); + window.location.reload(); + } + } catch { + // server still restarting + } + }, 3000); + // Stop polling after 2 minutes + setTimeout(() => clearInterval(poll), 120_000); + }, + }); + + if (isLoading || !data) { + return ( +
      +

      About

      +
      +
      + ); + } + + return ( +
      +

      About

      + + + + Software + + + + + + Version + {data.version} + + + Build Date + {data.buildDate} + + + Build Time + {data.buildTime} + + + Latest Version + + + {data.latestVersion || "unable to check"} + + {data.updateAvailable && ( + + Update available + + )} + + + +
      + + {data.updateAvailable && !updating && ( +
      + + + + + + + Confirm Update + + Pull latest images and restart containers? + + + + + + + + +
      + )} + + {updating && ( +
      + +
      +

      Updating…

      +

      + Pulling images and restarting containers. This page will + reload automatically. +

      +
      +
      + )} +
      +
      + + {logsData && logsData.logs.length > 0 && ( + + + Recent Logs + + +
      +          
      +        
      +      )}
      +
      +      {releasesData && (
      +        
      +          
      +            Recent Releases
      +          
      +          
      +            {releasesData.releases.length === 0 ? (
      +              

      + No release notes available. +

      + ) : ( + releasesData.releases.map((rel) => ( +
      +
      + + v{rel.version} + + {rel.isCurrent && ( + + Current + + )} + {rel.date && ( + + {new Date(rel.date).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + })} + + )} +
      + {rel.body.trim() && } +
      + )) + )} +
      +
      + )} +
      + ); +} +``` + +- [ ] **Step 2: Type-check and build the frontend** + +Run: +```bash +export NVM_DIR="/home/debian/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use v22.22.0 > /dev/null 2>&1 +cd docker/card/admin-ui && npm run build +``` +Expected: build succeeds with no TypeScript errors. (`npm run build` runs `tsc` then `vite build`.) + +- [ ] **Step 3: Commit** + +```bash +git add docker/card/admin-ui/src/pages/about.tsx +git commit -m "About: show Recent Releases instead of Recent Commits + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 4: CI — auto-publish a GitHub Release on version bump + +**Files:** +- Modify: `.github/workflows/ci.yml` (append a `release` job) + +**Interfaces:** +- Consumes: `docker` job success; `secrets.GITHUB_TOKEN`; tags `v*` in repo history. +- Produces: a git tag `v{version}` + GitHub Release whenever `build/build.go`'s version changes on `main`. + +- [ ] **Step 1: Add the `release` job** + +Append to `.github/workflows/ci.yml` (after the `docker` job, same indentation level — a new top-level entry under `jobs:`): + +```yaml + release: + runs-on: ubuntu-latest + needs: [docker] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Publish GitHub Release on version bump + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + V=$(grep -oP 'Version string = "\K[0-9]+\.[0-9]+\.[0-9]+' docker/card/build/build.go) + echo "Version in build.go: $V" + + if git rev-parse -q --verify "refs/tags/v$V" >/dev/null; then + echo "Tag v$V already exists — nothing to release." + exit 0 + fi + + PREV=$(git tag -l 'v*' --sort=-v:refname | head -n1) + echo "Previous release tag: ${PREV:-(none)}" + if [ -n "$PREV" ]; then + RANGE="$PREV..HEAD" + else + RANGE="HEAD" + fi + + # Tidied notes: non-merge commit subjects, version-bump lines dropped. + mapfile -t SUBJECTS < <(git log --no-merges --pretty=format:'%s' $RANGE \ + | grep -viE '^(bump|chore: ?bump).*version|^version bump|^bump to v?[0-9]' || true) + + TOTAL=${#SUBJECTS[@]} + LIMIT=30 + BODY="" + COUNT=0 + for s in "${SUBJECTS[@]}"; do + [ "$COUNT" -ge "$LIMIT" ] && break + BODY+="- $s"$'\n' + COUNT=$((COUNT + 1)) + done + if [ "$TOTAL" -gt "$LIMIT" ]; then + REMAIN=$((TOTAL - LIMIT)) + BODY+=$'\n'"…and $REMAIN earlier commits."$'\n' + if [ -n "$PREV" ]; then + BODY+=$'\n'"Full changelog: https://github.com/boltcard/hub/compare/$PREV...v$V"$'\n' + fi + fi + if [ -z "$BODY" ]; then + BODY="Release v$V" + fi + + echo "----- release notes -----" + printf '%s\n' "$BODY" + echo "-------------------------" + + gh release create "v$V" --target "$GITHUB_SHA" --title "v$V" --notes "$BODY" +``` + +- [ ] **Step 2: Validate the workflow YAML** + +Run: `python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/ci.yml')); print('YAML OK')"` +Expected: `YAML OK`. (If `python3`/`yaml` is unavailable, instead confirm the `release:` block is indented identically to the sibling `build:`/`docker:` jobs and that `permissions:` sits under `release:`, not at workflow scope.) + +- [ ] **Step 3: Commit** + +```bash +git add .github/workflows/ci.yml +git commit -m "CI: publish GitHub Release on version bump + +New release job tags v{version} and publishes notes built from non-merge +commit subjects since the previous tag (capped at 30). Idempotent: skips if +the tag already exists. Job-scoped contents:write keeps the workflow default +at contents:read. + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Task 5: Version bump + docs + +**Files:** +- Modify: `docker/card/build/build.go` +- Modify: `CLAUDE.md` + +**Interfaces:** +- Consumes: nothing. +- Produces: version `0.22.0`. + +- [ ] **Step 1: Bump the version** + +In `docker/card/build/build.go`, change: + +```go +var Version string = "0.21.0" +``` +to: +```go +var Version string = "0.22.0" +``` + +- [ ] **Step 2: Sync CLAUDE.md** + +In `CLAUDE.md`, change the `build/` bullet: + +``` +- `build/` — Version string (currently "0.21.0"), date/time injected at build +``` +to: +``` +- `build/` — Version string (currently "0.22.0"), date/time injected at build +``` + +- [ ] **Step 3: Full verification (Go + frontend)** + +Run: +```bash +cd docker/card && go vet ./... && go build -o /tmp/app && go test -race -count=1 ./... +``` +Expected: vet clean, build succeeds, all tests PASS. + +Then: +```bash +export NVM_DIR="/home/debian/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use v22.22.0 > /dev/null 2>&1 +cd docker/card/admin-ui && npm run build +``` +Expected: frontend build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add docker/card/build/build.go CLAUDE.md +git commit -m "Bump version to 0.22.0 + +Co-Authored-By: Claude Opus 4.8 (1M context) " +``` + +--- + +## Final verification checklist + +- [ ] `cd docker/card && go vet ./... && go test -race -count=1 ./...` — clean. +- [ ] `npm run build` in `docker/card/admin-ui` — clean. +- [ ] `grep -rn "adminApiCommits\|fetchCommitVersion\|commitVersionCache\|parseVersionFromBuildGo\|/about/commits" docker/card/` — no matches (dead code fully removed). +- [ ] `build/build.go` says `0.22.0`; `CLAUDE.md` build line says `0.22.0`. +- [ ] `.github/workflows/ci.yml` has a `release` job with job-level `permissions: contents: write`; workflow-level `permissions:` is still `contents: read`. From 1ff9ec7b93bff2586d12456836664341d6cfeb7c Mon Sep 17 00:00:00 2001 From: Peter Rounce Date: Wed, 17 Jun 2026 10:35:10 +0000 Subject: [PATCH 3/8] Add selectReleases range helper for About releases Co-Authored-By: Claude Opus 4.8 (1M context) --- docker/card/web/admin_api_about.go | 41 +++++++++++++ docker/card/web/admin_api_about_test.go | 81 +++++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/docker/card/web/admin_api_about.go b/docker/card/web/admin_api_about.go index dd96acf..0994e0e 100644 --- a/docker/card/web/admin_api_about.go +++ b/docker/card/web/admin_api_about.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "regexp" + "sort" "strings" "sync" @@ -213,6 +214,46 @@ func fetchCommitVersion(client *http.Client, sha string) string { return version } +var versionRegex = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) + +// isVersion reports whether s is a bare three-part numeric version (X.Y.Z). +func isVersion(s string) bool { + return versionRegex.MatchString(s) +} + +// selectReleases returns the release versions to display, newest-first, given +// the running version, the latest available version (may be "" or invalid), +// and the available release versions (each a bare "X.Y.Z"). The shown range is +// running <= ver <= upper, where upper is the latest version when it is a valid +// version greater than running, otherwise running. Non-version entries are +// skipped. Returns nil when running is not a valid version. +func selectReleases(running, latest string, versions []string) []string { + if !isVersion(running) { + return nil + } + upper := running + if isVersion(latest) && CompareVersions(running, latest) == 1 { + upper = latest + } + + var out []string + for _, v := range versions { + if !isVersion(v) { + continue + } + // running <= v <= upper + if CompareVersions(running, v) >= 0 && CompareVersions(v, upper) >= 0 { + out = append(out, v) + } + } + + sort.Slice(out, func(i, j int) bool { + // descending: out[i] before out[j] when out[i] > out[j] + return CompareVersions(out[i], out[j]) == -1 + }) + return out +} + var ansiColorMap = map[string]string{ "30": "#6b7280", // black/gray "31": "#ef4444", // red diff --git a/docker/card/web/admin_api_about_test.go b/docker/card/web/admin_api_about_test.go index 4a0500c..aa90316 100644 --- a/docker/card/web/admin_api_about_test.go +++ b/docker/card/web/admin_api_about_test.go @@ -38,3 +38,84 @@ func TestParseVersionFromBuildGo(t *testing.T) { }) } } + +func TestSelectReleases(t *testing.T) { + tests := []struct { + name string + running string + latest string + versions []string + want []string + }{ + { + name: "up to date shows only running", + running: "0.22.0", + latest: "0.22.0", + versions: []string{"0.22.0", "0.21.0", "0.20.0"}, + want: []string{"0.22.0"}, + }, + { + name: "behind shows running through latest, descending", + running: "0.20.0", + latest: "0.22.0", + versions: []string{"0.19.0", "0.22.0", "0.20.0", "0.21.0"}, + want: []string{"0.22.0", "0.21.0", "0.20.0"}, + }, + { + name: "running version absent from list", + running: "0.20.5", + latest: "0.22.0", + versions: []string{"0.22.0", "0.21.0", "0.20.0"}, + want: []string{"0.22.0", "0.21.0"}, + }, + { + name: "empty latest falls back to running only", + running: "0.22.0", + latest: "", + versions: []string{"0.22.0", "0.21.0"}, + want: []string{"0.22.0"}, + }, + { + name: "garbage latest falls back to running only", + running: "0.22.0", + latest: "not-a-version", + versions: []string{"0.22.0", "0.21.0"}, + want: []string{"0.22.0"}, + }, + { + name: "latest below running is ignored (no downgrade range)", + running: "0.22.0", + latest: "0.21.0", + versions: []string{"0.22.0", "0.21.0"}, + want: []string{"0.22.0"}, + }, + { + name: "non-version tags are skipped", + running: "0.21.0", + latest: "0.22.0", + versions: []string{"0.22.0", "latest", "v0.21.0", "0.21.0"}, + want: []string{"0.22.0", "0.21.0"}, + }, + { + name: "empty running returns nil", + running: "", + latest: "0.22.0", + versions: []string{"0.22.0"}, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := selectReleases(tt.running, tt.latest, tt.versions) + if len(got) != len(tt.want) { + t.Fatalf("selectReleases() = %v, want %v", got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("selectReleases() = %v, want %v", got, tt.want) + } + } + }) + } +} From 19315444f0fd73920be17b415dc6c6d50bbc9f08 Mon Sep 17 00:00:00 2001 From: Peter Rounce Date: Wed, 17 Jun 2026 10:40:04 +0000 Subject: [PATCH 4/8] Replace About /commits with /releases endpoint Single GitHub Releases API call (drops the per-commit N+1 fetch); filters to the running..latest version range via selectReleases. Co-Authored-By: Claude Opus 4.8 (1M context) --- docker/card/web/admin_api.go | 4 +- docker/card/web/admin_api_about.go | 169 ++++++++---------------- docker/card/web/admin_api_about_test.go | 37 ------ 3 files changed, 57 insertions(+), 153 deletions(-) diff --git a/docker/card/web/admin_api.go b/docker/card/web/admin_api.go index d3a6751..2cea761 100644 --- a/docker/card/web/admin_api.go +++ b/docker/card/web/admin_api.go @@ -141,8 +141,8 @@ func (app *App) CreateHandler_AdminApi() http.HandlerFunc { case path == "/admin/api/about/logs" && r.Method == "GET": app.adminApiAuth(app.adminApiLogs)(w, r) - case path == "/admin/api/about/commits" && r.Method == "GET": - app.adminApiAuth(app.adminApiCommits)(w, r) + case path == "/admin/api/about/releases" && r.Method == "GET": + app.adminApiAuth(app.adminApiReleases)(w, r) case path == "/admin/api/database/stats" && r.Method == "GET": app.adminApiAuth(app.adminApiDatabaseStats)(w, r) diff --git a/docker/card/web/admin_api_about.go b/docker/card/web/admin_api_about.go index 0994e0e..67dd665 100644 --- a/docker/card/web/admin_api_about.go +++ b/docker/card/web/admin_api_about.go @@ -2,7 +2,6 @@ package web import ( "card/build" - "encoding/base64" "encoding/json" "html" "io" @@ -10,7 +9,6 @@ import ( "regexp" "sort" "strings" - "sync" log "github.com/sirupsen/logrus" ) @@ -77,141 +75,84 @@ func (app *App) adminApiLogs(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]interface{}{"logs": lines}) } -func (app *App) adminApiCommits(w http.ResponseWriter, r *http.Request) { - type commit struct { - Sha string `json:"sha"` - Message string `json:"message"` - Date string `json:"date"` - Version string `json:"version"` +func (app *App) adminApiReleases(w http.ResponseWriter, r *http.Request) { + type release struct { + Version string `json:"version"` + Name string `json:"name"` + Body string `json:"body"` + Date string `json:"date"` + URL string `json:"url"` + IsCurrent bool `json:"isCurrent"` } - var commits []commit + empty := map[string]interface{}{"releases": []release{}} + + running := build.Version + latest := strings.TrimSpace(r.URL.Query().Get("latest")) client := &http.Client{Timeout: 10e9} - url := "https://api.github.com/repos/boltcard/hub/commits?per_page=10" - req, err := http.NewRequest("GET", url, nil) + req, err := http.NewRequest("GET", "https://api.github.com/repos/boltcard/hub/releases?per_page=100", nil) if err != nil { - writeJSON(w, map[string]interface{}{"commits": []commit{}}) + writeJSON(w, empty) return } req.Header.Set("Accept", "application/vnd.github.v3+json") resp, err := client.Do(req) if err != nil { - writeJSON(w, map[string]interface{}{"commits": []commit{}}) + writeJSON(w, empty) return } defer resp.Body.Close() - if resp.StatusCode == 200 { - var ghCommits []struct { - Sha string `json:"sha"` - Commit struct { - Message string `json:"message"` - Author struct { - Date string `json:"date"` - } `json:"author"` - } `json:"commit"` - } - if err := json.NewDecoder(resp.Body).Decode(&ghCommits); err == nil { - for _, c := range ghCommits { - msg := c.Commit.Message - if idx := strings.Index(msg, "\n"); idx != -1 { - msg = msg[:idx] - } - commits = append(commits, commit{ - Sha: c.Sha, - Message: msg, - Date: c.Commit.Author.Date, - }) - } - } - } - - // Annotate each commit with the app version recorded in build/build.go at - // that commit. The commits-list API doesn't return file contents, so this - // needs one extra fetch per commit; results are cached by SHA (immutable) - // and fetched concurrently so a cold load doesn't serialise 10 round-trips. - var wg sync.WaitGroup - for i := range commits { - wg.Add(1) - go func(i int) { - defer wg.Done() - commits[i].Version = fetchCommitVersion(client, commits[i].Sha) - }(i) - } - wg.Wait() - - if commits == nil { - commits = []commit{} - } - - writeJSON(w, map[string]interface{}{"commits": commits}) -} - -var buildVersionRegex = regexp.MustCompile(`Version string = "([^"]+)"`) - -// parseVersionFromBuildGo extracts the version string from build/build.go -// source, returning "" if no version declaration is found. -func parseVersionFromBuildGo(content string) string { - m := buildVersionRegex.FindStringSubmatch(content) - if len(m) == 2 { - return m[1] - } - return "" -} - -var ( - commitVersionMu sync.Mutex - commitVersionCache = map[string]string{} -) - -// fetchCommitVersion returns the app version recorded in build/build.go at the -// given commit SHA, fetching it from the GitHub contents API. Results are -// memoised per SHA — a commit's tree is immutable, so the cache never goes -// stale. Returns "" on any error (rate limit, missing file in old commits). -func fetchCommitVersion(client *http.Client, sha string) string { - if sha == "" { - return "" - } - - commitVersionMu.Lock() - if v, ok := commitVersionCache[sha]; ok { - commitVersionMu.Unlock() - return v + if resp.StatusCode != 200 { + writeJSON(w, empty) + return } - commitVersionMu.Unlock() - url := "https://api.github.com/repos/boltcard/hub/contents/docker/card/build/build.go?ref=" + sha - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return "" + var ghReleases []struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + PublishedAt string `json:"published_at"` + HTMLURL string `json:"html_url"` } - req.Header.Set("Accept", "application/vnd.github.v3+json") - resp, err := client.Do(req) - if err != nil { - return "" + if err := json.NewDecoder(resp.Body).Decode(&ghReleases); err != nil { + writeJSON(w, empty) + return } - defer resp.Body.Close() - version := "" - if resp.StatusCode == 200 { - var payload struct { - Content string `json:"content"` - Encoding string `json:"encoding"` + byVersion := map[string]release{} + var versions []string + for _, g := range ghReleases { + ver := strings.TrimPrefix(g.TagName, "v") + if !isVersion(ver) { + continue } - if err := json.NewDecoder(resp.Body).Decode(&payload); err == nil && payload.Encoding == "base64" { - // GitHub wraps base64 content at 60 chars with newlines. - decoded, err := base64.StdEncoding.DecodeString(strings.ReplaceAll(payload.Content, "\n", "")) - if err == nil { - version = parseVersionFromBuildGo(string(decoded)) - } + if _, dup := byVersion[ver]; dup { + continue } + name := g.Name + if name == "" { + name = "v" + ver + } + byVersion[ver] = release{ + Version: ver, + Name: name, + Body: g.Body, + Date: g.PublishedAt, + URL: g.HTMLURL, + IsCurrent: ver == running, + } + versions = append(versions, ver) + } + + selected := selectReleases(running, latest, versions) + releases := make([]release, 0, len(selected)) + for _, v := range selected { + releases = append(releases, byVersion[v]) } - commitVersionMu.Lock() - commitVersionCache[sha] = version - commitVersionMu.Unlock() - return version + writeJSON(w, map[string]interface{}{"releases": releases}) } var versionRegex = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) diff --git a/docker/card/web/admin_api_about_test.go b/docker/card/web/admin_api_about_test.go index aa90316..b4fa11f 100644 --- a/docker/card/web/admin_api_about_test.go +++ b/docker/card/web/admin_api_about_test.go @@ -2,43 +2,6 @@ package web import "testing" -func TestParseVersionFromBuildGo(t *testing.T) { - tests := []struct { - name string - content string - want string - }{ - { - name: "current format", - content: "package build\n\nvar Version string = \"0.20.1\"\nvar Date string\nvar Time string\n", - want: "0.20.1", - }, - { - name: "older multi-digit version", - content: "var Version string = \"0.2.10\"\n", - want: "0.2.10", - }, - { - name: "no version line", - content: "package build\n\nvar Date string\n", - want: "", - }, - { - name: "empty content", - content: "", - want: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := parseVersionFromBuildGo(tt.content); got != tt.want { - t.Errorf("parseVersionFromBuildGo() = %q, want %q", got, tt.want) - } - }) - } -} - func TestSelectReleases(t *testing.T) { tests := []struct { name string From 8488ed70bd328c4f9b2cdffa784892b6d829381c Mon Sep 17 00:00:00 2001 From: Peter Rounce Date: Wed, 17 Jun 2026 10:44:04 +0000 Subject: [PATCH 5/8] About: show Recent Releases instead of Recent Commits Co-Authored-By: Claude Opus 4.8 (1M context) --- docker/card/admin-ui/src/pages/about.tsx | 172 ++++++++++++++++------- 1 file changed, 118 insertions(+), 54 deletions(-) diff --git a/docker/card/admin-ui/src/pages/about.tsx b/docker/card/admin-ui/src/pages/about.tsx index 6f90f55..787f8dc 100644 --- a/docker/card/admin-ui/src/pages/about.tsx +++ b/docker/card/admin-ui/src/pages/about.tsx @@ -19,7 +19,7 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { ArrowUpCircle, Loader2 } from "lucide-react"; -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { toast } from "sonner"; interface AboutData { @@ -34,15 +34,81 @@ interface LogsData { logs: string[]; } -interface Commit { - sha: string; - message: string; - date: string; +interface Release { version: string; + name: string; + body: string; + date: string; + url: string; + isCurrent: boolean; +} + +interface ReleasesData { + releases: Release[]; +} + +// linkify turns bare http(s) URLs in a line into anchor elements; other text is +// returned verbatim (React escapes it). +function linkify(text: string): ReactNode { + const parts = text.split(/(https?:\/\/[^\s]+)/g); + return parts.map((part, i) => + /^https?:\/\//.test(part) ? ( + + {part} + + ) : ( + {part} + ), + ); } -interface CommitsData { - commits: Commit[]; +// ReleaseNotes renders a release body: "- " lines become a bullet list, blank +// lines break groups, everything else is a paragraph. No markdown dependency. +function ReleaseNotes({ body }: { body: string }) { + const elements: ReactNode[] = []; + let bullets: string[] = []; + + const flush = () => { + if (bullets.length > 0) { + const items = bullets; + elements.push( +
        + {items.map((b, i) => ( +
      • {linkify(b)}
      • + ))} +
      , + ); + bullets = []; + } + }; + + for (const raw of body.split("\n")) { + const line = raw.trimEnd(); + if (line.startsWith("- ")) { + bullets.push(line.slice(2)); + } else if (line.trim() === "") { + flush(); + } else { + flush(); + elements.push( +

      + {linkify(line)} +

      , + ); + } + } + flush(); + + return
      {elements}
      ; } export function AboutPage() { @@ -56,9 +122,13 @@ export function AboutPage() { queryFn: () => apiFetch("/about/logs"), }); - const { data: commitsData } = useQuery({ - queryKey: ["about-commits"], - queryFn: () => apiFetch("/about/commits"), + const { data: releasesData } = useQuery({ + queryKey: ["about-releases", data?.latestVersion], + queryFn: () => + apiFetch( + `/about/releases?latest=${encodeURIComponent(data?.latestVersion ?? "")}`, + ), + enabled: !!data, }); const [dialogOpen, setDialogOpen] = useState(false); @@ -200,56 +270,50 @@ export function AboutPage() { )} - {commitsData && commitsData.commits.length > 0 && ( + {releasesData && ( - Recent Commits + Recent Releases - - {Object.entries( - commitsData.commits.reduce>( - (groups, c) => { - const day = new Date(c.date).toLocaleDateString(undefined, { - weekday: "short", - year: "numeric", - month: "short", - day: "numeric", - }); - (groups[day] ??= []).push(c); - return groups; - }, - {}, - ), - ).map(([date, commits]) => ( -
      -

      - {date} -

      -
        - {commits.map((c) => ( -
      • - - {c.message} - - {c.version && ( - - v{c.version} - - )} -
      • - ))} -
      -
      - ))} + + {releasesData.releases.length === 0 ? ( +

      + No release notes available. +

      + ) : ( + releasesData.releases.map((rel) => ( +
      +
      + + v{rel.version} + + {rel.isCurrent && ( + + Current + + )} + {rel.date && ( + + {new Date(rel.date).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + })} + + )} +
      + {rel.body.trim() && } +
      + )) + )}
      )} -
      ); } From fc7a32c941b9071f77934f6ca2e242c210db27df Mon Sep 17 00:00:00 2001 From: Peter Rounce Date: Wed, 17 Jun 2026 10:47:08 +0000 Subject: [PATCH 6/8] CI: publish GitHub Release on version bump New release job tags v{version} and publishes notes built from non-merge commit subjects since the previous tag (capped at 30). Idempotent: skips if the tag already exists. Job-scoped contents:write keeps the workflow default at contents:read. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 62 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82d98bf..94993dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,3 +83,65 @@ jobs: - name: Push webproxy image if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: docker push boltcard/webproxy:latest + + release: + runs-on: ubuntu-latest + needs: [docker] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Publish GitHub Release on version bump + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + V=$(grep -oP 'Version string = "\K[0-9]+\.[0-9]+\.[0-9]+' docker/card/build/build.go) + echo "Version in build.go: $V" + + if git rev-parse -q --verify "refs/tags/v$V" >/dev/null; then + echo "Tag v$V already exists — nothing to release." + exit 0 + fi + + PREV=$(git tag -l 'v*' --sort=-v:refname | head -n1) + echo "Previous release tag: ${PREV:-(none)}" + if [ -n "$PREV" ]; then + RANGE="$PREV..HEAD" + else + RANGE="HEAD" + fi + + # Tidied notes: non-merge commit subjects, version-bump lines dropped. + mapfile -t SUBJECTS < <(git log --no-merges --pretty=format:'%s' $RANGE \ + | grep -viE '^(bump|chore: ?bump).*version|^version bump|^bump to v?[0-9]' || true) + + TOTAL=${#SUBJECTS[@]} + LIMIT=30 + BODY="" + COUNT=0 + for s in "${SUBJECTS[@]}"; do + [ "$COUNT" -ge "$LIMIT" ] && break + BODY+="- $s"$'\n' + COUNT=$((COUNT + 1)) + done + if [ "$TOTAL" -gt "$LIMIT" ]; then + REMAIN=$((TOTAL - LIMIT)) + BODY+=$'\n'"…and $REMAIN earlier commits."$'\n' + if [ -n "$PREV" ]; then + BODY+=$'\n'"Full changelog: https://github.com/boltcard/hub/compare/$PREV...v$V"$'\n' + fi + fi + if [ -z "$BODY" ]; then + BODY="Release v$V" + fi + + echo "----- release notes -----" + printf '%s\n' "$BODY" + echo "-------------------------" + + gh release create "v$V" --target "$GITHUB_SHA" --title "v$V" --notes "$BODY" From 706d40ef5cf57356c14f1d152b97374facf940b2 Mon Sep 17 00:00:00 2001 From: Peter Rounce Date: Wed, 17 Jun 2026 10:54:59 +0000 Subject: [PATCH 7/8] Bump version to 0.22.0 Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- docker/card/build/build.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d9956ee..50e8e83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,7 +93,7 @@ Entry point: `main.go` → opens SQLite DB → runs CLI or starts HTTP server on - `phoenix/` — HTTP client for Phoenix Server API (invoices, payments, balance, channels). Uses basic auth from phoenix config (password cached at startup with `sync.Once`) - `crypto/` — AES-CMAC authentication and AES decryption for Bolt Card NFC protocol - `util/` — Error handling helpers (`CheckAndLog`), random hex generation, QR code encoding -- `build/` — Version string (currently "0.21.0"), date/time injected at build +- `build/` — Version string (currently "0.22.0"), date/time injected at build - `web-content/` — Static assets under `public/`, SPA build output under `admin/spa/` ### Route Groups (`web/app.go`) diff --git a/docker/card/build/build.go b/docker/card/build/build.go index e7c8d5e..5340356 100644 --- a/docker/card/build/build.go +++ b/docker/card/build/build.go @@ -1,5 +1,5 @@ package build -var Version string = "0.21.0" +var Version string = "0.22.0" var Date string var Time string From 43371b88022259d0c062288ede66a5e10dd326e8 Mon Sep 17 00:00:00 2001 From: Peter Rounce Date: Wed, 17 Jun 2026 11:14:57 +0000 Subject: [PATCH 8/8] Gitignore planning docs; drop docs:/chore: from release notes - Add docs/superpowers/ to .gitignore (matches existing docs/plans/ 'local planning docs' convention) and untrack the planning specs/plans that were committed before the rule (kept locally via --cached). - Extend the CI release-notes grep to also drop docs:/chore: subjects (incl. conventional-commit scopes) so planning/chore commits don't appear as release-note bullets. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 5 +- .gitignore | 3 +- .../2026-03-04-lightning-address-design.md | 101 -- .../2026-03-04-lightning-address-plan.md | 1178 ------------ .../plans/2026-06-15-admin-totp-2fa.md | 1589 ----------------- .../plans/2026-06-17-about-release-notes.md | 864 --------- .../specs/2026-06-15-admin-totp-2fa-design.md | 206 --- .../2026-06-17-about-release-notes-design.md | 158 -- 8 files changed, 5 insertions(+), 4099 deletions(-) delete mode 100644 docs/plans/2026-03-04-lightning-address-design.md delete mode 100644 docs/plans/2026-03-04-lightning-address-plan.md delete mode 100644 docs/superpowers/plans/2026-06-15-admin-totp-2fa.md delete mode 100644 docs/superpowers/plans/2026-06-17-about-release-notes.md delete mode 100644 docs/superpowers/specs/2026-06-15-admin-totp-2fa-design.md delete mode 100644 docs/superpowers/specs/2026-06-17-about-release-notes-design.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94993dc..6afc11b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,9 +116,10 @@ jobs: RANGE="HEAD" fi - # Tidied notes: non-merge commit subjects, version-bump lines dropped. + # Tidied notes: non-merge commit subjects, with version-bump, + # docs:, and chore: lines dropped (incl. conventional-commit scopes). mapfile -t SUBJECTS < <(git log --no-merges --pretty=format:'%s' $RANGE \ - | grep -viE '^(bump|chore: ?bump).*version|^version bump|^bump to v?[0-9]' || true) + | grep -viE '^(bump|chore: ?bump).*version|^version bump|^bump to v?[0-9]|^docs(\(|:)|^chore(\(|:)' || true) TOTAL=${#SUBJECTS[@]} LIMIT=30 diff --git a/.gitignore b/.gitignore index e223928..36698b7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ docker/card/card notes .github/prompts/* -# local planning docs +# local planning docs (brainstorm specs + implementation plans — kept local, not committed) docs/plans/ +docs/superpowers/ diff --git a/docs/plans/2026-03-04-lightning-address-design.md b/docs/plans/2026-03-04-lightning-address-design.md deleted file mode 100644 index 9dff18a..0000000 --- a/docs/plans/2026-03-04-lightning-address-design.md +++ /dev/null @@ -1,101 +0,0 @@ -# Lightning Address Support — Design - -## Summary - -Add lightning address support so each card has a randomly generated address (e.g. `a3f7b2c1@domain.com`) that anyone can pay to load funds onto the card. Addresses are auto-generated on card creation, visible on the admin card detail page with QR code, and can be toggled on/off per card. - -## Database Changes (Schema v7) - -Add two columns to `cards` table: - -- `ln_address CHAR(12) NOT NULL DEFAULT ''` — random hex username (8 chars) -- `ln_address_enabled CHAR(1) NOT NULL DEFAULT 'Y'` — toggle - -Partial unique index: `CREATE UNIQUE INDEX idx_cards_ln_address ON cards(ln_address) WHERE ln_address != ''` - -Migration backfills existing cards with random hex values. New cards get one generated at insert time. - -## LNURL-pay Protocol - -Lightning address spec: `username@domain` resolves to `GET https://domain/.well-known/lnurlp/username` - -### Endpoint 1: `GET /.well-known/lnurlp/{username}` - -Metadata response. Looks up card by `ln_address` where `ln_address_enabled = 'Y'` and `wiped = 'N'`. - -Response: -```json -{ - "tag": "payRequest", - "callback": "https://{host_domain}/.well-known/lnurlp/{username}/callback", - "minSendable": 1000, - "maxSendable": 100000000000, - "metadata": "[[\"text/plain\",\"Payment to {username}@{host_domain}\"]]", - "commentAllowed": 140 -} -``` - -Min 1 sat, max 100M sats (in millisats). - -### Endpoint 2: `GET /.well-known/lnurlp/{username}/callback?amount={msats}&comment={text}` - -Creates invoice. Same card lookup. Validates amount is between min/max sendable. - -- Calls `phoenix.CreateInvoice()` with amount and metadata description hash -- Inserts `card_receipt` via `Db_add_card_receipt()` (starts unpaid) -- Returns `{"pr": "", "routes": []}` - -Settlement: existing Phoenix listener calls `Db_set_receipt_paid()` by payment hash — no new code needed. - -### Metadata Hash - -Per LUD-06, the invoice description hash must be SHA256 of the metadata JSON string. The metadata string is `[[\"text/plain\",\"Payment to {username}@{host_domain}\"]]`. - -## Admin UI Changes - -Card detail page — new "Lightning Address" card between Info and Balance: - -- Lightning address in monospace: `a3f7b2c1@domain.com` -- QR code encoding `lightning:a3f7b2c1@domain.com` (compact, wallet-compatible) -- Enable/Disable toggle -- Copy button - -API changes: -- `GET /admin/api/cards/{id}` — add `lnAddress`, `lnAddressEnabled` fields -- `PUT /admin/api/cards/{id}/limits` — add `lnAddressEnabled` to request body - -## Route Registration - -In `app.go`, two new public routes (no auth): - -```go -router.Path("/.well-known/lnurlp/{username}").Methods("GET").HandlerFunc(app.CreateHandler_LnurlpRequest()) -router.Path("/.well-known/lnurlp/{username}/callback").Methods("GET").HandlerFunc(app.CreateHandler_LnurlpCallback()) -``` - -Handler code in new file `web/lnurlp.go`. - -## Rate Limiting - -Add `/.well-known/lnurlp/*` to the Caddyfile `@api_paths` matcher (30 req/min per IP), same tier as other LNURL/payment endpoints. - -## Files Changed - -**Go backend:** -- `db/db_create.go` — `update_schema_6()` migration -- `db/db_init.go` — bump schema version to 7, call migration -- `db/db_get.go` — `Db_get_card_by_ln_address()`, add fields to `Card` struct, update `Db_get_card` scan -- `db/db_insert.go` — generate `ln_address` in insert functions -- `db/db_update.go` — add `ln_address_enabled` to card update functions -- `web/app.go` — register two new routes -- `web/lnurlp.go` — new file, two handlers -- `web/admin_api_cards.go` — add fields to get/update endpoints -- `build/build.go` — bump version -- `Caddyfile` — add LNURL-pay paths to rate limit matcher - -**Frontend:** -- `admin-ui/src/pages/card-detail.tsx` — lightning address card with QR, toggle, copy - -**Tests:** -- `db/db_test.go` — schema migration, new DB functions -- `web/web_test.go` — LNURL-pay endpoint tests diff --git a/docs/plans/2026-03-04-lightning-address-plan.md b/docs/plans/2026-03-04-lightning-address-plan.md deleted file mode 100644 index 7153edf..0000000 --- a/docs/plans/2026-03-04-lightning-address-plan.md +++ /dev/null @@ -1,1178 +0,0 @@ -# Lightning Address Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add lightning address support so each card has a randomly generated address (e.g. `a3f7b2c1@domain.com`) that can receive Lightning payments. - -**Architecture:** Two new LNURL-pay endpoints serve the lightning address protocol. A `ln_address` column on the `cards` table maps hex usernames to cards. Invoice creation reuses the existing `phoenix.CreateInvoice()` + `Db_add_card_receipt()` flow; settlement happens automatically via the existing Phoenix WebSocket listener. - -**Tech Stack:** Go 1.25.7 (CGo/SQLite), Gorilla Mux, Phoenix Server API, React 19 + TypeScript + Tailwind v4 + shadcn/ui - -**Design doc:** `docs/plans/2026-03-04-lightning-address-design.md` - ---- - -### Task 1: Schema Migration — Add ln_address columns - -**Files:** -- Modify: `docker/card/db/db_create.go` (append new function after line 197) -- Modify: `docker/card/db/db_init.go` (lines 40-46) -- Modify: `docker/card/db/db_test.go` (line 25 — update schema version check) - -**Step 1: Write the migration function** - -Add `update_schema_6()` to the end of `docker/card/db/db_create.go`: - -```go -func update_schema_6(db *sql.DB) { - - // Generate random hex addresses for existing cards - rows, err := db.Query("SELECT card_id FROM cards WHERE 1=1") - if err != nil { - log.Printf("update_schema_6 select error: %q", err) - return - } - var cardIds []int - for rows.Next() { - var id int - rows.Scan(&id) - cardIds = append(cardIds, id) - } - rows.Close() - - sqlStmt := ` - BEGIN TRANSACTION; - ALTER TABLE cards ADD COLUMN ln_address CHAR(12) NOT NULL DEFAULT ''; - ALTER TABLE cards ADD COLUMN ln_address_enabled CHAR(1) NOT NULL DEFAULT 'Y'; - UPDATE settings SET value='7' WHERE name='schema_version_number'; - COMMIT TRANSACTION; - ` - _, err = db.Exec(sqlStmt) - if err != nil { - log.Printf("update_schema_6 alter error: %q", err) - return - } - - // Backfill existing cards with random hex addresses - for _, id := range cardIds { - addr := randomHex8() - _, err := db.Exec("UPDATE cards SET ln_address = $1 WHERE card_id = $2", addr, id) - if err != nil { - log.Printf("update_schema_6 backfill error for card %d: %q", id, err) - } - } - - // Create partial unique index - _, err = db.Exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_cards_ln_address ON cards(ln_address) WHERE ln_address != ''") - if err != nil { - log.Printf("update_schema_6 index error: %q", err) - } -} - -// randomHex8 generates an 8-character random hex string for lightning addresses. -func randomHex8() string { - b := make([]byte, 4) - _, err := rand.Read(b) - if err != nil { - return fmt.Sprintf("%08x", time.Now().UnixNano()&0xFFFFFFFF) - } - return hex.EncodeToString(b) -} -``` - -Note: Add `"crypto/rand"`, `"encoding/hex"`, `"fmt"`, and `"time"` to the import block in `db_create.go`. - -**Step 2: Update db_init.go to call the migration** - -In `docker/card/db/db_init.go`, replace lines 40-46: - -```go - if Db_get_setting(db_conn, "schema_version_number") == "5" { - update_schema_5(db_conn) // note column - } - - if Db_get_setting(db_conn, "schema_version_number") != "6" { - panic("database schema is not as expected") - } -``` - -with: - -```go - if Db_get_setting(db_conn, "schema_version_number") == "5" { - update_schema_5(db_conn) // note column - } - - if Db_get_setting(db_conn, "schema_version_number") == "6" { - update_schema_6(db_conn) // ln_address columns - } - - if Db_get_setting(db_conn, "schema_version_number") != "7" { - panic("database schema is not as expected") - } -``` - -**Step 3: Update schema version test** - -In `docker/card/db/db_test.go`, update `TestDbInit_SchemaMigratesToLatest` (line 25): - -Change `"6"` to `"7"` in the expected version. - -**Step 4: Run tests to verify migration works** - -Run: `cd docker/card && go test -race -count=1 ./db/` - -Expected: All tests pass. The schema version check test now expects "7". - -**Step 5: Commit** - -``` -feat: add schema v7 migration for lightning address columns -``` - ---- - -### Task 2: Update Card struct and Db_get_card to include ln_address fields - -**Files:** -- Modify: `docker/card/db/db_get.go` (lines 157-222 — Card struct and Db_get_card) - -**Step 1: Add fields to Card struct** - -In `docker/card/db/db_get.go`, add two fields to the `Card` struct (after `Note` field at line 181): - -```go - Ln_address string - Ln_address_enabled string -``` - -**Step 2: Update Db_get_card SELECT and Scan** - -In `docker/card/db/db_get.go`, update the `Db_get_card` function: - -Change the SQL (line 188-194) to add `ln_address, ln_address_enabled` to the SELECT: - -```go - sqlStatement := `SELECT card_id, key0_auth, key1_enc, ` + - `key2_cmac, key3, key4, login, password, access_token, ` + - `refresh_token, uid, last_counter_value, ` + - `lnurlw_request_timeout_sec, lnurlw_enable, ` + - `lnurlw_k1, lnurlw_k1_expiry, tx_limit_sats, ` + - `day_limit_sats, uid_privacy, pin_enable, pin_number, ` + - `pin_limit_sats, wiped, note, ln_address, ln_address_enabled FROM cards WHERE card_id=$1 AND wiped = 'N';` -``` - -Add two more Scan fields (after `&c.Note` at line 220): - -```go - &c.Ln_address, - &c.Ln_address_enabled) -``` - -Remove the closing paren from the `&c.Note)` line so it becomes `&c.Note,`. - -**Step 3: Run tests** - -Run: `cd docker/card && go test -race -count=1 ./db/` - -Expected: All tests pass. - -**Step 4: Commit** - -``` -feat: add ln_address fields to Card struct and Db_get_card -``` - ---- - -### Task 3: Add Db_get_card_by_ln_address lookup function - -**Files:** -- Modify: `docker/card/db/db_get.go` (add new function) -- Modify: `docker/card/db/db_test.go` (add test) - -**Step 1: Write the test** - -Add to `docker/card/db/db_test.go`: - -```go -func TestDbGetCardByLnAddress_Found(t *testing.T) { - db := openTestDB(t) - Db_init(db) - Db_insert_card(db, "k0", "k1", "k2", "k3", "k4", "login1", "pass1") - - // Get the auto-generated ln_address - card, err := Db_get_card(db, 1) - if err != nil { - t.Fatal(err) - } - if card.Ln_address == "" { - t.Fatal("expected non-empty ln_address") - } - - cardId := Db_get_card_by_ln_address(db, card.Ln_address) - if cardId != 1 { - t.Fatalf("expected card_id 1, got %d", cardId) - } -} - -func TestDbGetCardByLnAddress_NotFound(t *testing.T) { - db := openTestDB(t) - Db_init(db) - - cardId := Db_get_card_by_ln_address(db, "nonexistent") - if cardId != 0 { - t.Fatalf("expected 0 for unknown address, got %d", cardId) - } -} - -func TestDbGetCardByLnAddress_DisabledExcluded(t *testing.T) { - db := openTestDB(t) - Db_init(db) - Db_insert_card(db, "k0", "k1", "k2", "k3", "k4", "login1", "pass1") - - card, _ := Db_get_card(db, 1) - // Disable ln_address - db.Exec("UPDATE cards SET ln_address_enabled = 'N' WHERE card_id = 1") - - cardId := Db_get_card_by_ln_address(db, card.Ln_address) - if cardId != 0 { - t.Fatalf("expected 0 for disabled address, got %d", cardId) - } -} - -func TestDbGetCardByLnAddress_WipedExcluded(t *testing.T) { - db := openTestDB(t) - Db_init(db) - Db_insert_card(db, "k0", "k1", "k2", "k3", "k4", "login1", "pass1") - - card, _ := Db_get_card(db, 1) - Db_wipe_card(db, 1) - - cardId := Db_get_card_by_ln_address(db, card.Ln_address) - if cardId != 0 { - t.Fatalf("expected 0 for wiped card, got %d", cardId) - } -} -``` - -**Step 2: Run tests to verify they fail** - -Run: `cd docker/card && go test -race -count=1 ./db/ -run TestDbGetCardByLnAddress` - -Expected: FAIL — `Db_get_card_by_ln_address` not defined. - -**Step 3: Implement the function** - -Add to `docker/card/db/db_get.go`: - -```go -func Db_get_card_by_ln_address(db_conn *sql.DB, ln_address string) (card_id int) { - - sqlStatement := `SELECT card_id FROM cards WHERE ln_address=$1 AND ln_address_enabled='Y' AND wiped='N';` - row := db_conn.QueryRow(sqlStatement, ln_address) - - value := 0 - err := row.Scan(&value) - if err != nil { - return 0 - } - - return value -} -``` - -**Step 4: Run tests to verify they pass** - -Run: `cd docker/card && go test -race -count=1 ./db/ -run TestDbGetCardByLnAddress` - -Expected: PASS - -**Step 5: Commit** - -``` -feat: add Db_get_card_by_ln_address lookup function -``` - ---- - -### Task 4: Update card insert functions to generate ln_address - -**Files:** -- Modify: `docker/card/db/db_insert.go` (both insert functions) -- Modify: `docker/card/db/db_test.go` (verify ln_address populated) - -**Step 1: Write test** - -Add to `docker/card/db/db_test.go`: - -```go -func TestDbInsertCard_GeneratesLnAddress(t *testing.T) { - db := openTestDB(t) - Db_init(db) - Db_insert_card(db, "k0", "k1", "k2", "k3", "k4", "login1", "pass1") - - card, err := Db_get_card(db, 1) - if err != nil { - t.Fatal(err) - } - if len(card.Ln_address) != 8 { - t.Fatalf("expected 8-char ln_address, got %q (len %d)", card.Ln_address, len(card.Ln_address)) - } - if card.Ln_address_enabled != "Y" { - t.Fatalf("expected ln_address_enabled 'Y', got %q", card.Ln_address_enabled) - } -} - -func TestDbInsertCardWithUid_GeneratesLnAddress(t *testing.T) { - db := openTestDB(t) - Db_init(db) - Db_insert_card_with_uid(db, "k0", "k1", "k2", "k3", "k4", "login1", "pass1", "uid1", "tag1") - - card, err := Db_get_card(db, 1) - if err != nil { - t.Fatal(err) - } - if len(card.Ln_address) != 8 { - t.Fatalf("expected 8-char ln_address, got %q (len %d)", card.Ln_address, len(card.Ln_address)) - } -} - -func TestDbInsertCard_UniqueLnAddresses(t *testing.T) { - db := openTestDB(t) - Db_init(db) - Db_insert_card(db, "k0", "k1", "k2", "k3", "k4", "login1", "pass1") - Db_insert_card(db, "k0", "k1", "k2", "k3", "k4", "login2", "pass2") - - card1, _ := Db_get_card(db, 1) - card2, _ := Db_get_card(db, 2) - if card1.Ln_address == card2.Ln_address { - t.Fatalf("expected unique ln_addresses, both got %q", card1.Ln_address) - } -} -``` - -**Step 2: Run tests to verify they fail** - -Run: `cd docker/card && go test -race -count=1 ./db/ -run TestDbInsertCard_Generates` - -Expected: FAIL — ln_address is empty because insert doesn't set it yet. - -**Step 3: Update Db_insert_card** - -In `docker/card/db/db_insert.go`, update `Db_insert_card` to include `ln_address`: - -```go -func Db_insert_card(db_conn *sql.DB, key0 string, key1 string, k2 string, key3 string, key4 string, - login string, password string) { - - lnAddress := randomHex8() - - // insert a new card record - sqlStatement := `INSERT INTO cards (key0_auth, key1_enc,` + - ` key2_cmac, key3, key4, login, password, ln_address)` + - ` VALUES ($1, $2, $3, $4, $5, $6, $7, $8);` - res, err := db_conn.Exec(sqlStatement, key0, key1, k2, key3, key4, login, password, lnAddress) - if err != nil { - log.Error("db_insert_card error: ", err) - return - } - count, err := res.RowsAffected() - if err != nil { - log.Error("db_insert_card rows affected error: ", err) - return - } - if count != 1 { - log.Error("db_insert_card: expected one record to be inserted") - } -} -``` - -Do the same for `Db_insert_card_with_uid`: - -```go -func Db_insert_card_with_uid(db_conn *sql.DB, key0 string, key1 string, k2 string, key3 string, key4 string, - login string, password string, uid string, group_tag string) { - - lnAddress := randomHex8() - - // insert a new card record - sqlStatement := `INSERT INTO cards (key0_auth, key1_enc,` + - ` key2_cmac, key3, key4, login, password, uid, group_tag, ln_address)` + - ` VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);` - res, err := db_conn.Exec(sqlStatement, key0, key1, k2, key3, key4, login, password, uid, group_tag, lnAddress) - if err != nil { - log.Error("db_insert_card_with_uid error: ", err) - return - } - count, err := res.RowsAffected() - if err != nil { - log.Error("db_insert_card_with_uid rows affected error: ", err) - return - } - if count != 1 { - log.Error("db_insert_card_with_uid: expected one record to be inserted") - } -} -``` - -Note: `randomHex8()` is defined in `db_create.go` (Task 1). Both files are in package `db` so it's accessible. - -**Step 4: Run all db tests** - -Run: `cd docker/card && go test -race -count=1 ./db/` - -Expected: All tests pass. - -**Step 5: Commit** - -``` -feat: auto-generate ln_address on card insert -``` - ---- - -### Task 5: Update admin API to expose ln_address fields - -**Files:** -- Modify: `docker/card/web/admin_api_cards.go` (lines 84-106 for GET, lines 122-146 for PUT limits) - -**Step 1: Update adminApiGetCard response** - -In `docker/card/web/admin_api_cards.go`, update `adminApiGetCard` (around line 94-106) to include the new fields: - -```go - hostDomain := db.Db_get_setting(app.db_conn, "host_domain") - - writeJSON(w, map[string]any{ - "cardId": card.Card_id, - "uid": card.Uid, - "note": card.Note, - "balanceSats": balance, - "lnurlwEnable": card.Lnurlw_enable, - "txLimitSats": card.Tx_limit_sats, - "dayLimitSats": card.Day_limit_sats, - "pinEnable": card.Pin_enable, - "pinLimitSats": card.Pin_limit_sats, - "wiped": card.Wiped, - "lnAddress": card.Ln_address, - "lnAddressEnabled": card.Ln_address_enabled, - "hostDomain": hostDomain, - }) -``` - -**Step 2: Update adminApiUpdateCardLimits to accept lnAddressEnabled** - -In `docker/card/web/admin_api_cards.go`, update `adminApiUpdateCardLimits`: - -Add `LnAddressEnabled` to the request struct: - -```go - var req struct { - TxLimitSats int `json:"txLimitSats"` - DayLimitSats int `json:"dayLimitSats"` - LnurlwEnable string `json:"lnurlwEnable"` - LnAddressEnabled string `json:"lnAddressEnabled"` - } -``` - -After the existing `lnurlwEnable` validation, add: - -```go - // Validate lnAddressEnabled (optional — default to current value) - if req.LnAddressEnabled != "" && req.LnAddressEnabled != "Y" && req.LnAddressEnabled != "N" { - w.WriteHeader(http.StatusBadRequest) - writeJSON(w, map[string]string{"error": "lnAddressEnabled must be Y or N"}) - return - } -``` - -After the existing `Db_update_card_without_pin` call, add: - -```go - if req.LnAddressEnabled != "" { - db.Db_update_card_ln_address_enabled(app.db_conn, cardId, req.LnAddressEnabled) - } -``` - -**Step 3: Add Db_update_card_ln_address_enabled to db_update.go** - -Add to `docker/card/db/db_update.go`: - -```go -func Db_update_card_ln_address_enabled(db_conn *sql.DB, card_id int, ln_address_enabled string) { - - sqlStatement := `UPDATE cards SET ln_address_enabled = $1 WHERE card_id = $2 AND wiped = 'N';` - _, err := db_conn.Exec(sqlStatement, ln_address_enabled, card_id) - if err != nil { - log.Error("db_update_card_ln_address_enabled error: ", err) - } -} -``` - -**Step 4: Run all tests** - -Run: `cd docker/card && go test -race -count=1 ./...` - -Expected: All tests pass. - -**Step 5: Commit** - -``` -feat: expose ln_address fields in admin API -``` - ---- - -### Task 6: LNURL-pay request handler - -**Files:** -- Create: `docker/card/web/lnurlp.go` -- Modify: `docker/card/web/app.go` (add routes at line 47, before `/new`) - -**Step 1: Write the LNURL-pay request handler test** - -Add to `docker/card/web/web_test.go`: - -```go -func TestLnurlpRequest_ValidAddress(t *testing.T) { - app := openTestApp(t) - db.Db_insert_card(app.db_conn, "k0", "k1", "k2", "k3", "k4", "login1", "pass1") - - card, _ := db.Db_get_card(app.db_conn, 1) - - handler := app.CreateHandler_LnurlpRequest() - r := httptest.NewRequest("GET", "/.well-known/lnurlp/"+card.Ln_address, nil) - r.SetPathValue("username", card.Ln_address) - w := httptest.NewRecorder() - handler.ServeHTTP(w, r) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", w.Code) - } - - var resp map[string]interface{} - json.Unmarshal(w.Body.Bytes(), &resp) - - if resp["tag"] != "payRequest" { - t.Fatalf("expected tag 'payRequest', got %v", resp["tag"]) - } - if resp["commentAllowed"] != float64(140) { - t.Fatalf("expected commentAllowed 140, got %v", resp["commentAllowed"]) - } - if resp["minSendable"] != float64(1000) { - t.Fatalf("expected minSendable 1000, got %v", resp["minSendable"]) - } -} - -func TestLnurlpRequest_NotFound(t *testing.T) { - app := openTestApp(t) - handler := app.CreateHandler_LnurlpRequest() - r := httptest.NewRequest("GET", "/.well-known/lnurlp/nonexistent", nil) - r.SetPathValue("username", "nonexistent") - w := httptest.NewRecorder() - handler.ServeHTTP(w, r) - - if w.Code != http.StatusNotFound { - t.Fatalf("expected 404, got %d", w.Code) - } -} - -func TestLnurlpRequest_DisabledAddress(t *testing.T) { - app := openTestApp(t) - db.Db_insert_card(app.db_conn, "k0", "k1", "k2", "k3", "k4", "login1", "pass1") - - card, _ := db.Db_get_card(app.db_conn, 1) - db.Db_update_card_ln_address_enabled(app.db_conn, 1, "N") - - handler := app.CreateHandler_LnurlpRequest() - r := httptest.NewRequest("GET", "/.well-known/lnurlp/"+card.Ln_address, nil) - r.SetPathValue("username", card.Ln_address) - w := httptest.NewRecorder() - handler.ServeHTTP(w, r) - - if w.Code != http.StatusNotFound { - t.Fatalf("expected 404 for disabled address, got %d", w.Code) - } -} -``` - -Note: These tests use `r.SetPathValue("username", ...)` because the handler reads the username from the request path. If using Gorilla Mux vars, the test will need to use `mux.SetURLVars(r, map[string]string{"username": ...})` instead. Check which approach the handler uses. - -**Step 2: Run tests to verify they fail** - -Run: `cd docker/card && go test -race -count=1 ./web/ -run TestLnurlp` - -Expected: FAIL — `CreateHandler_LnurlpRequest` not defined. - -**Step 3: Create `web/lnurlp.go`** - -Create `docker/card/web/lnurlp.go`: - -```go -package web - -import ( - "card/db" - "crypto/sha256" - "encoding/hex" - "fmt" - "net/http" - - "github.com/gorilla/mux" - log "github.com/sirupsen/logrus" -) - -func lnurlpMetadata(username, hostDomain string) string { - return fmt.Sprintf(`[["text/plain","Payment to %s@%s"]]`, username, hostDomain) -} - -func (app *App) CreateHandler_LnurlpRequest() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - vars := mux.Vars(r) - username := vars["username"] - if username == "" { - w.WriteHeader(http.StatusNotFound) - writeJSON(w, map[string]string{"status": "ERROR", "reason": "not found"}) - return - } - - cardId := db.Db_get_card_by_ln_address(app.db_conn, username) - if cardId == 0 { - w.WriteHeader(http.StatusNotFound) - writeJSON(w, map[string]string{"status": "ERROR", "reason": "not found"}) - return - } - - hostDomain := db.Db_get_setting(app.db_conn, "host_domain") - metadata := lnurlpMetadata(username, hostDomain) - - writeJSON(w, map[string]interface{}{ - "tag": "payRequest", - "callback": "https://" + hostDomain + "/.well-known/lnurlp/" + username + "/callback", - "minSendable": 1000, - "maxSendable": 100000000000, - "metadata": metadata, - "commentAllowed": 140, - }) - } -} - -func descriptionHash(metadata string) string { - hash := sha256.Sum256([]byte(metadata)) - return hex.EncodeToString(hash[:]) -} -``` - -**Step 4: Register routes in app.go** - -In `docker/card/web/app.go`, add before the `/new` route (before line 47): - -```go - // Lightning Address (LNURL-pay) - router.Path("/.well-known/lnurlp/{username}").Methods("GET").HandlerFunc(app.CreateHandler_LnurlpRequest()) - router.Path("/.well-known/lnurlp/{username}/callback").Methods("GET").HandlerFunc(app.CreateHandler_LnurlpCallback()) -``` - -Note: `CreateHandler_LnurlpCallback` doesn't exist yet — add a stub for now: - -Add to `docker/card/web/lnurlp.go`: - -```go -func (app *App) CreateHandler_LnurlpCallback() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusNotImplemented) - writeJSON(w, map[string]string{"status": "ERROR", "reason": "not implemented"}) - } -} -``` - -**Step 5: Update tests to use mux.SetURLVars** - -Since the handler uses `mux.Vars(r)`, tests need to set vars via Gorilla Mux. Update the test to use: - -```go -import "github.com/gorilla/mux" - -// In each test, replace r.SetPathValue with: -r = mux.SetURLVars(r, map[string]string{"username": card.Ln_address}) -``` - -**Step 6: Run tests** - -Run: `cd docker/card && go test -race -count=1 ./web/ -run TestLnurlp` - -Expected: PASS - -**Step 7: Commit** - -``` -feat: add LNURL-pay request handler and route registration -``` - ---- - -### Task 7: LNURL-pay callback handler - -**Files:** -- Modify: `docker/card/web/lnurlp.go` (replace stub) - -**Step 1: Write callback tests** - -Add to `docker/card/web/web_test.go`: - -```go -func TestLnurlpCallback_MissingAmount(t *testing.T) { - app := openTestApp(t) - db.Db_insert_card(app.db_conn, "k0", "k1", "k2", "k3", "k4", "login1", "pass1") - card, _ := db.Db_get_card(app.db_conn, 1) - - handler := app.CreateHandler_LnurlpCallback() - r := httptest.NewRequest("GET", "/.well-known/lnurlp/"+card.Ln_address+"/callback", nil) - r = mux.SetURLVars(r, map[string]string{"username": card.Ln_address}) - w := httptest.NewRecorder() - handler.ServeHTTP(w, r) - - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400, got %d", w.Code) - } -} - -func TestLnurlpCallback_AmountTooLow(t *testing.T) { - app := openTestApp(t) - db.Db_insert_card(app.db_conn, "k0", "k1", "k2", "k3", "k4", "login1", "pass1") - card, _ := db.Db_get_card(app.db_conn, 1) - - handler := app.CreateHandler_LnurlpCallback() - r := httptest.NewRequest("GET", "/.well-known/lnurlp/"+card.Ln_address+"/callback?amount=999", nil) - r = mux.SetURLVars(r, map[string]string{"username": card.Ln_address}) - w := httptest.NewRecorder() - handler.ServeHTTP(w, r) - - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400 for amount too low, got %d", w.Code) - } -} - -func TestLnurlpCallback_AmountTooHigh(t *testing.T) { - app := openTestApp(t) - db.Db_insert_card(app.db_conn, "k0", "k1", "k2", "k3", "k4", "login1", "pass1") - card, _ := db.Db_get_card(app.db_conn, 1) - - handler := app.CreateHandler_LnurlpCallback() - r := httptest.NewRequest("GET", "/.well-known/lnurlp/"+card.Ln_address+"/callback?amount=100000000001", nil) - r = mux.SetURLVars(r, map[string]string{"username": card.Ln_address}) - w := httptest.NewRecorder() - handler.ServeHTTP(w, r) - - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400 for amount too high, got %d", w.Code) - } -} - -func TestLnurlpCallback_UnknownAddress(t *testing.T) { - app := openTestApp(t) - - handler := app.CreateHandler_LnurlpCallback() - r := httptest.NewRequest("GET", "/.well-known/lnurlp/unknown/callback?amount=5000", nil) - r = mux.SetURLVars(r, map[string]string{"username": "unknown"}) - w := httptest.NewRecorder() - handler.ServeHTTP(w, r) - - if w.Code != http.StatusNotFound { - t.Fatalf("expected 404, got %d", w.Code) - } -} - -func TestLnurlpCallback_ValidAmount_PhoenixUnavailable(t *testing.T) { - app := openTestApp(t) - db.Db_insert_card(app.db_conn, "k0", "k1", "k2", "k3", "k4", "login1", "pass1") - card, _ := db.Db_get_card(app.db_conn, 1) - - handler := app.CreateHandler_LnurlpCallback() - r := httptest.NewRequest("GET", "/.well-known/lnurlp/"+card.Ln_address+"/callback?amount=5000000", nil) - r = mux.SetURLVars(r, map[string]string{"username": card.Ln_address}) - w := httptest.NewRecorder() - handler.ServeHTTP(w, r) - - // Phoenix is unavailable in tests, so this should return an error - // but it exercises the validation path successfully - if w.Code == http.StatusBadRequest || w.Code == http.StatusNotFound { - t.Fatalf("expected validation to pass (not 400/404), got %d", w.Code) - } -} -``` - -**Step 2: Run tests to verify they fail** - -Run: `cd docker/card && go test -race -count=1 ./web/ -run TestLnurlpCallback` - -Expected: FAIL — stub returns 501 for everything. - -**Step 3: Implement the callback handler** - -Replace the stub in `docker/card/web/lnurlp.go`: - -```go -func (app *App) CreateHandler_LnurlpCallback() http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - - vars := mux.Vars(r) - username := vars["username"] - if username == "" { - w.WriteHeader(http.StatusNotFound) - writeJSON(w, map[string]string{"status": "ERROR", "reason": "not found"}) - return - } - - cardId := db.Db_get_card_by_ln_address(app.db_conn, username) - if cardId == 0 { - w.WriteHeader(http.StatusNotFound) - writeJSON(w, map[string]string{"status": "ERROR", "reason": "not found"}) - return - } - - // Validate amount (in millisats) - amountStr := r.URL.Query().Get("amount") - if amountStr == "" { - w.WriteHeader(http.StatusBadRequest) - writeJSON(w, map[string]string{"status": "ERROR", "reason": "missing amount"}) - return - } - - amountMsat, err := strconv.ParseInt(amountStr, 10, 64) - if err != nil || amountMsat < 1000 || amountMsat > 100000000000 { - w.WriteHeader(http.StatusBadRequest) - writeJSON(w, map[string]string{"status": "ERROR", "reason": "amount out of range"}) - return - } - - amountSats := int(amountMsat / 1000) - - hostDomain := db.Db_get_setting(app.db_conn, "host_domain") - metadata := lnurlpMetadata(username, hostDomain) - dHash := descriptionHash(metadata) - - // Create invoice via Phoenix with description hash - createInvoiceResponse, err := phoenix.CreateInvoice(phoenix.CreateInvoiceRequest{ - Description: dHash, - AmountSat: strconv.Itoa(amountSats), - ExternalId: "", - }) - if err != nil { - log.Error("lnurlp CreateInvoice error: ", err) - w.WriteHeader(http.StatusInternalServerError) - writeJSON(w, map[string]string{"status": "ERROR", "reason": "failed to create invoice"}) - return - } - - // Insert pending receipt - db.Db_add_card_receipt(app.db_conn, cardId, - createInvoiceResponse.Serialized, createInvoiceResponse.PaymentHash, amountSats) - - log.Info("lnurlp invoice created for ", username, " amount=", amountSats) - - writeJSON(w, map[string]interface{}{ - "pr": createInvoiceResponse.Serialized, - "routes": []string{}, - }) - } -} -``` - -Add these imports to `lnurlp.go`: - -```go -import ( - "card/db" - "card/phoenix" - "crypto/sha256" - "encoding/hex" - "fmt" - "net/http" - "strconv" - - "github.com/gorilla/mux" - log "github.com/sirupsen/logrus" -) -``` - -**Step 4: Run tests** - -Run: `cd docker/card && go test -race -count=1 ./web/ -run TestLnurlpCallback` - -Expected: PASS (validation tests pass; Phoenix-unavailable test returns 500, not 400/404). - -**Step 5: Run all tests** - -Run: `cd docker/card && go test -race -count=1 ./...` - -Expected: All pass. - -**Step 6: Commit** - -``` -feat: add LNURL-pay callback handler with invoice creation -``` - ---- - -### Task 8: Caddyfile rate limiting - -**Files:** -- Modify: `Caddyfile` (line 37) - -**Step 1: Add LNURL-pay path to rate-limited API paths** - -In `Caddyfile`, update the `@api_paths` matcher (line 37): - -Change: -``` - path /create /payinvoice /addinvoice /ln /cb -``` - -To: -``` - path /create /payinvoice /addinvoice /ln /cb /.well-known/lnurlp/* -``` - -**Step 2: Commit** - -``` -feat: rate-limit LNURL-pay endpoints (30 req/min per IP) -``` - ---- - -### Task 9: Admin UI — Lightning Address card on card detail page - -**Files:** -- Modify: `docker/card/admin-ui/src/pages/card-detail.tsx` - -**Step 1: Update CardDetail interface** - -Add fields to the `CardDetail` interface (around line 41-51): - -```typescript - lnAddress: string; - lnAddressEnabled: string; - hostDomain: string; -``` - -**Step 2: Add Lightning Address card section** - -Between the Info/Balance grid (line 237) and the Limits card (line 239), add a new Lightning Address card. - -Import `QRCodeSVG` — install the package first: - -Run: `cd docker/card/admin-ui && npm install qrcode.react` - -Add to the imports: - -```typescript -import { QRCodeSVG } from "qrcode.react"; -import { Copy, Zap } from "lucide-react"; -``` - -Add the Lightning Address card component JSX after the closing `` of the grid (after line 237) and before the Limits card: - -```tsx - {/* Lightning Address */} - - - - - Lightning Address - - - {card.lnAddressEnabled === "Y" ? "Enabled" : "Disabled"} - - - -
      - - {card.lnAddress}@{card.hostDomain} - - -
      - {card.lnAddressEnabled === "Y" && ( -
      - -
      - )} -
      -
      -``` - -**Step 3: Add ln_address_enabled toggle to the Limits form** - -In the limits form section (inside the `limitsForm` state and form JSX), add a toggle for `lnAddressEnabled`. - -Update the `limitsForm` state type to include `lnAddressEnabled`: - -```typescript - const [limitsForm, setLimitsForm] = useState<{ - txLimitSats: string; - dayLimitSats: string; - lnurlwEnable: string; - lnAddressEnabled: string; - } | null>(null); -``` - -Update `startEditLimits`: - -```typescript - function startEditLimits() { - setLimitsForm({ - txLimitSats: String(card!.txLimitSats), - dayLimitSats: String(card!.dayLimitSats), - lnurlwEnable: card!.lnurlwEnable, - lnAddressEnabled: card!.lnAddressEnabled, - }); - } -``` - -Update `saveLimits` to include `lnAddressEnabled`: - -```typescript - function saveLimits() { - if (!limitsForm) return; - limitsMutation.mutate({ - txLimitSats: Number(limitsForm.txLimitSats) || 0, - dayLimitSats: Number(limitsForm.dayLimitSats) || 0, - lnurlwEnable: limitsForm.lnurlwEnable, - lnAddressEnabled: limitsForm.lnAddressEnabled, - }); - } -``` - -Update `limitsMutation.mutationFn` type: - -```typescript - const limitsMutation = useMutation({ - mutationFn: (data: { - txLimitSats: number; - dayLimitSats: number; - lnurlwEnable: string; - lnAddressEnabled: string; - }) => apiPut(`/cards/${id}/limits`, data), -``` - -Add a fourth column to the limits form grid (change `sm:grid-cols-3` to `sm:grid-cols-4`), and add: - -```tsx -
      - - -
      -``` - -Also add a display row for ln address enabled in the non-editing view (add a 4th column, update grid to `sm:grid-cols-4`): - -```tsx -
      - Lightning Address -

      {card.lnAddressEnabled === "Y" ? "Enabled" : "Disabled"}

      -
      -``` - -**Step 4: Build frontend** - -Run: `cd docker/card/admin-ui && npm run build` - -Expected: Build succeeds with no errors. - -**Step 5: Commit** - -``` -feat: add lightning address card with QR code to admin card detail page -``` - ---- - -### Task 10: Bump version and final integration test - -**Files:** -- Modify: `docker/card/build/build.go` (line 3) - -**Step 1: Bump version** - -In `docker/card/build/build.go`, change: - -```go -var Version string = "0.16.0" -``` - -to: - -```go -var Version string = "0.17.0" -``` - -**Step 2: Run all tests** - -Run: `cd docker/card && go test -race -count=1 ./...` - -Expected: All tests pass. - -**Step 3: Build Docker image** - -Run: `cd /home/debian/hub && docker compose build card` - -Expected: Build succeeds. - -**Step 4: Commit** - -``` -bump version to 0.17.0 -``` - ---- - -## Summary of all tasks - -| Task | Description | Files | -|------|-------------|-------| -| 1 | Schema v7 migration | `db_create.go`, `db_init.go`, `db_test.go` | -| 2 | Card struct + Db_get_card update | `db_get.go` | -| 3 | Db_get_card_by_ln_address | `db_get.go`, `db_test.go` | -| 4 | Card insert generates ln_address | `db_insert.go`, `db_test.go` | -| 5 | Admin API exposes ln_address | `admin_api_cards.go`, `db_update.go` | -| 6 | LNURL-pay request handler | `lnurlp.go` (new), `app.go`, `web_test.go` | -| 7 | LNURL-pay callback handler | `lnurlp.go`, `web_test.go` | -| 8 | Caddyfile rate limiting | `Caddyfile` | -| 9 | Admin UI lightning address card | `card-detail.tsx` | -| 10 | Version bump + integration | `build.go` | diff --git a/docs/superpowers/plans/2026-06-15-admin-totp-2fa.md b/docs/superpowers/plans/2026-06-15-admin-totp-2fa.md deleted file mode 100644 index a1b73b4..0000000 --- a/docs/superpowers/plans/2026-06-15-admin-totp-2fa.md +++ /dev/null @@ -1,1589 +0,0 @@ -# Admin Login 2FA (TOTP) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add optional TOTP two-factor authentication to the single-admin login, with recovery codes and a CLI escape hatch. - -**Architecture:** TOTP secret + bcrypt-hashed recovery codes live in the existing `settings` key-value table (no schema migration). `pquerna/otp` handles RFC 6238. Login becomes a stateless single request `{password, code?}`: password is verified first, then — only when `admin_totp_enabled == "Y"` — a TOTP or recovery code is required before a session is issued. Enrollment and disable run through new session-protected `/admin/api/auth/2fa/*` endpoints. A `DisableAdmin2FA` CLI command clears the keys for lost-authenticator recovery. - -**Tech Stack:** Go 1.25 (CGo sqlite), `github.com/pquerna/otp`, `golang.org/x/crypto/bcrypt`, React 19 + Vite + shadcn/ui. - -**Spec:** `docs/superpowers/specs/2026-06-15-admin-totp-2fa-design.md` - -**Working branch:** `claude/admin-totp-2fa` (already checked out; carries the uncommitted CLAUDE.md SemVer note from brainstorming — it folds into Task 10's commit). - -**Conventions reminder:** -- All Go commands run from `docker/card/`. Tests: `go test -race -count=1 ./web/` (sets `HOST_DOMAIN` via helpers). -- Frontend build (typecheck) from `docker/card/admin-ui/`: - ```bash - export NVM_DIR="/home/debian/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use v22.22.0 > /dev/null 2>&1 - npm run build - ``` -- Settings access: `db.Db_get_setting(conn, key)` / `db.Db_set_setting(conn, key, value)`. Reads use `app.db_read`, writes use `app.db_write`. - ---- - -## File Structure - -**Backend (create):** -- `docker/card/web/totp.go` — pure TOTP/recovery-code helpers (no DB). -- `docker/card/web/totp_test.go` — unit tests for the helpers. -- `docker/card/web/admin_api_2fa.go` — settings helpers (DB) + the four `/admin/api/auth/2fa/*` handlers. -- `docker/card/web/admin_api_2fa_test.go` — handler + login-flow tests. - -**Backend (modify):** -- `docker/card/web/admin_api.go` — route the four new endpoints; add TOTP enforcement to `adminApiLogin`. -- `docker/card/web/admin_api_settings.go` — add `_secret` to the redaction suffix list. -- `docker/card/cli.go` — `DisableAdmin2FA` command. -- `docker/card/go.mod` / `go.sum` — add `github.com/pquerna/otp`. -- `docker/card/build/build.go` — version bump. - -**Frontend (modify):** -- `docker/card/admin-ui/src/hooks/use-auth.tsx` — `login(password, code?)` + `TotpRequiredError`. -- `docker/card/admin-ui/src/pages/login.tsx` — conditional code field + recovery toggle. -- `docker/card/admin-ui/src/pages/settings.tsx` — render the new 2FA card. - -**Frontend (create):** -- `docker/card/admin-ui/src/components/two-factor-card.tsx` — enable/disable/enrollment UI. - -**Docs (modify):** -- `CLAUDE.md` — settings keys, CLI command (SemVer note already edited, uncommitted). - ---- - -## Task 1: Add `pquerna/otp` + TOTP helper module - -**Files:** -- Create: `docker/card/web/totp.go` -- Create: `docker/card/web/totp_test.go` -- Modify: `docker/card/go.mod`, `docker/card/go.sum` - -- [ ] **Step 1: Add the dependency** - -From `docker/card/`: -```bash -go get github.com/pquerna/otp@v1.4.0 -go mod tidy -``` -Expected: `go.mod` gains `github.com/pquerna/otp v1.4.0` and `go.sum` updates (also pulls indirect `github.com/boombuler/barcode`). - -- [ ] **Step 2: Write the failing test** - -Create `docker/card/web/totp_test.go`: -```go -package web - -import ( - "strings" - "testing" - "time" - - "github.com/pquerna/otp/totp" -) - -func TestNewTotpKey_ProducesValidatableSecret(t *testing.T) { - secret, url, err := newTotpKey("hub.example.com") - if err != nil { - t.Fatal(err) - } - if secret == "" { - t.Fatal("expected non-empty secret") - } - if !strings.HasPrefix(url, "otpauth://totp/") { - t.Fatalf("expected otpauth URI, got %q", url) - } - - code, err := totp.GenerateCode(secret, time.Now()) - if err != nil { - t.Fatal(err) - } - if !validateTotpCode(secret, code) { - t.Fatal("freshly generated code should validate") - } - if validateTotpCode(secret, "000000") { - t.Fatal("an arbitrary wrong code should not validate") - } -} - -func TestGenerateRecoveryCodes_HashesMatch(t *testing.T) { - plain, hashes, err := generateRecoveryCodes(10) - if err != nil { - t.Fatal(err) - } - if len(plain) != 10 || len(hashes) != 10 { - t.Fatalf("expected 10 codes and 10 hashes, got %d and %d", len(plain), len(hashes)) - } - for i := range plain { - if !CheckPassword(plain[i], hashes[i]) { - t.Fatalf("hash %d does not verify against its plaintext", i) - } - } - // codes must be unique - seen := map[string]bool{} - for _, c := range plain { - if seen[c] { - t.Fatalf("duplicate recovery code %q", c) - } - seen[c] = true - } -} - -func TestMatchRecoveryCode(t *testing.T) { - plain, hashes, err := generateRecoveryCodes(3) - if err != nil { - t.Fatal(err) - } - idx, ok := matchRecoveryCode(plain[1], hashes) - if !ok || idx != 1 { - t.Fatalf("expected match at index 1, got idx=%d ok=%v", idx, ok) - } - if _, ok := matchRecoveryCode("nope-not-a-code", hashes); ok { - t.Fatal("a non-code should not match") - } -} -``` - -- [ ] **Step 3: Run the test to verify it fails** - -Run: `go test ./web/ -run 'Totp|RecoveryCode' -v` -Expected: compile failure — `undefined: newTotpKey`, `validateTotpCode`, `generateRecoveryCodes`, `matchRecoveryCode`. - -- [ ] **Step 4: Implement the helpers** - -Create `docker/card/web/totp.go`: -```go -package web - -import ( - "crypto/rand" - "encoding/hex" - - "github.com/pquerna/otp/totp" -) - -// newTotpKey generates a fresh TOTP secret for the admin. accountName is the -// label shown in the authenticator app (we pass host_domain). Returns the -// base32 secret and the otpauth:// provisioning URI. -func newTotpKey(accountName string) (secret string, url string, err error) { - key, err := totp.Generate(totp.GenerateOpts{ - Issuer: "Bolt Card Hub", - AccountName: accountName, - }) - if err != nil { - return "", "", err - } - return key.Secret(), key.URL(), nil -} - -// validateTotpCode checks a 6-digit code against the secret. totp.Validate -// already applies a ±1 time-step skew to tolerate clock drift. -func validateTotpCode(secret, code string) bool { - return totp.Validate(code, secret) -} - -// generateRecoveryCodes returns `count` single-use recovery codes (8 hex chars -// each, 32 bits of entropy) plus their bcrypt hashes. Only the hashes are -// persisted; the plaintext is shown to the admin exactly once. -func generateRecoveryCodes(count int) (plain []string, hashes []string, err error) { - for i := 0; i < count; i++ { - b := make([]byte, 4) - if _, err = rand.Read(b); err != nil { - return nil, nil, err - } - code := hex.EncodeToString(b) - h, herr := HashPassword(code) - if herr != nil { - return nil, nil, herr - } - plain = append(plain, code) - hashes = append(hashes, h) - } - return plain, hashes, nil -} - -// matchRecoveryCode returns the index of the first hash that verifies against -// code, or ok=false if none match. -func matchRecoveryCode(code string, hashes []string) (int, bool) { - for i, h := range hashes { - if CheckPassword(code, h) { - return i, true - } - } - return -1, false -} -``` - -- [ ] **Step 5: Run the tests to verify they pass** - -Run: `go test ./web/ -run 'Totp|RecoveryCode' -v` -Expected: PASS for all three tests. - -- [ ] **Step 6: Commit** - -```bash -git add docker/card/web/totp.go docker/card/web/totp_test.go docker/card/go.mod docker/card/go.sum -git commit -m "Add TOTP and recovery-code helpers" -``` - ---- - -## Task 2: Redact the TOTP secret in the settings list - -**Files:** -- Modify: `docker/card/web/admin_api_settings.go:24-26` -- Test: `docker/card/web/admin_api_2fa_test.go` (new file; first test added here) - -- [ ] **Step 1: Write the failing test** - -Create `docker/card/web/admin_api_2fa_test.go`. Start with only the imports this first test needs; Tasks 4 and 6 add `"time"` and `"github.com/pquerna/otp/totp"` when their tests first use them (Go errors on unused imports, so don't add them early): -```go -package web - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "strings" - "testing" - - "card/db" -) - -func TestAdminApiSettings_RedactsTotpSecret(t *testing.T) { - app := openTestApp(t) - token := setupAdminSession(t, app) - db.Db_set_setting(app.db_write, "admin_totp_secret", "SUPERSECRETBASE32") - - handler := app.CreateHandler_AdminApi() - r := httptest.NewRequest("GET", "/admin/api/settings", nil) - r.AddCookie(&http.Cookie{Name: "admin_session_token", Value: token}) - w := httptest.NewRecorder() - handler.ServeHTTP(w, r) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - if strings.Contains(w.Body.String(), "SUPERSECRETBASE32") { - t.Fatal("settings response leaked the raw TOTP secret") - } - - var resp struct { - Settings []struct { - Name string `json:"name"` - Value string `json:"value"` - } `json:"settings"` - } - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatal(err) - } - found := false - for _, s := range resp.Settings { - if s.Name == "admin_totp_secret" { - found = true - if s.Value != "REDACTED" { - t.Fatalf("expected REDACTED, got %q", s.Value) - } - } - } - if !found { - t.Fatal("admin_totp_secret not present in settings list") - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `go test ./web/ -run TestAdminApiSettings_RedactsTotpSecret -v` -Expected: FAIL — value is the raw secret, not `REDACTED`. - -- [ ] **Step 3: Add the `_secret` suffix to the redaction guard** - -In `docker/card/web/admin_api_settings.go`, change: -```go - if strings.HasSuffix(s.Name, "_hash") || - strings.HasSuffix(s.Name, "_token") || - strings.HasSuffix(s.Name, "_code") { - value = "REDACTED" -``` -to: -```go - if strings.HasSuffix(s.Name, "_hash") || - strings.HasSuffix(s.Name, "_token") || - strings.HasSuffix(s.Name, "_code") || - strings.HasSuffix(s.Name, "_secret") { - value = "REDACTED" -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `go test ./web/ -run TestAdminApiSettings_RedactsTotpSecret -v` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add docker/card/web/admin_api_settings.go docker/card/web/admin_api_2fa_test.go -git commit -m "Redact admin_totp_secret in settings list" -``` - ---- - -## Task 3: 2FA settings helpers + status endpoint - -**Files:** -- Create: `docker/card/web/admin_api_2fa.go` -- Modify: `docker/card/web/admin_api.go` (route) -- Test: `docker/card/web/admin_api_2fa_test.go` - -- [ ] **Step 1: Write the failing test** - -Append to `docker/card/web/admin_api_2fa_test.go`: -```go -func TestAdminApi2faStatus_DisabledByDefault(t *testing.T) { - app := openTestApp(t) - token := setupAdminSession(t, app) - - handler := app.CreateHandler_AdminApi() - r := httptest.NewRequest("GET", "/admin/api/auth/2fa/status", nil) - r.AddCookie(&http.Cookie{Name: "admin_session_token", Value: token}) - w := httptest.NewRecorder() - handler.ServeHTTP(w, r) - - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - var resp struct { - Enabled bool `json:"enabled"` - RecoveryCodesRemaining int `json:"recoveryCodesRemaining"` - } - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatal(err) - } - if resp.Enabled { - t.Fatal("expected enabled=false by default") - } - if resp.RecoveryCodesRemaining != 0 { - t.Fatalf("expected 0 remaining, got %d", resp.RecoveryCodesRemaining) - } -} - -func TestAdminApi2faStatus_RequiresSession(t *testing.T) { - app := openTestApp(t) - handler := app.CreateHandler_AdminApi() - r := httptest.NewRequest("GET", "/admin/api/auth/2fa/status", nil) - w := httptest.NewRecorder() - handler.ServeHTTP(w, r) - if w.Code != http.StatusUnauthorized { - t.Fatalf("expected 401 without session, got %d", w.Code) - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `go test ./web/ -run TestAdminApi2faStatus -v` -Expected: FAIL — route returns 404 (`adminApi2faStatus` not wired / not defined). - -- [ ] **Step 3: Create the handler file with helpers + status handler** - -Create `docker/card/web/admin_api_2fa.go` (the `card/util` import is added in Task 4 when the setup handler first needs it — adding it now would be an unused-import error): -```go -package web - -import ( - "card/db" - "encoding/json" - "net/http" - - log "github.com/sirupsen/logrus" -) - -// totpEnabled reports whether admin TOTP 2FA is currently active. -func (app *App) totpEnabled() bool { - return db.Db_get_setting(app.db_read, "admin_totp_enabled") == "Y" -} - -// loadRecoveryHashes returns the stored bcrypt hashes of unused recovery codes. -func (app *App) loadRecoveryHashes() []string { - raw := db.Db_get_setting(app.db_read, "admin_totp_recovery_hash") - if raw == "" { - return nil - } - var hashes []string - if err := json.Unmarshal([]byte(raw), &hashes); err != nil { - log.Error("recovery hash unmarshal error: ", err) - return nil - } - return hashes -} - -// saveRecoveryHashes persists the recovery-code hashes as a JSON array. -func (app *App) saveRecoveryHashes(hashes []string) { - b, err := json.Marshal(hashes) - if err != nil { - log.Error("recovery hash marshal error: ", err) - return - } - db.Db_set_setting(app.db_write, "admin_totp_recovery_hash", string(b)) -} - -// consumeRecoveryCode returns true if code matches an unused recovery code, -// removing it (single use) before returning. -func (app *App) consumeRecoveryCode(code string) bool { - hashes := app.loadRecoveryHashes() - idx, ok := matchRecoveryCode(code, hashes) - if !ok { - return false - } - hashes = append(hashes[:idx], hashes[idx+1:]...) - app.saveRecoveryHashes(hashes) - return true -} - -func (app *App) adminApi2faStatus(w http.ResponseWriter, r *http.Request) { - writeJSON(w, map[string]interface{}{ - "enabled": app.totpEnabled(), - "recoveryCodesRemaining": len(app.loadRecoveryHashes()), - }) -} -``` - -- [ ] **Step 4: Wire the route** - -In `docker/card/web/admin_api.go`, inside the `CreateHandler_AdminApi` switch, add after the existing auth cases (e.g. just after the `auth/logout` case): -```go - case path == "/admin/api/auth/2fa/status" && r.Method == "GET": - app.adminApiAuth(app.adminApi2faStatus)(w, r) -``` - -- [ ] **Step 5: Run the tests to verify they pass** - -Run: `go test ./web/ -run TestAdminApi2faStatus -v` -Expected: PASS for both status tests. - -- [ ] **Step 6: Commit** - -```bash -git add docker/card/web/admin_api_2fa.go docker/card/web/admin_api.go docker/card/web/admin_api_2fa_test.go -git commit -m "Add 2FA status endpoint and settings helpers" -``` - ---- - -## Task 4: 2FA setup + enable endpoints - -**Files:** -- Modify: `docker/card/web/admin_api_2fa.go` -- Modify: `docker/card/web/admin_api.go` (routes) -- Test: `docker/card/web/admin_api_2fa_test.go` - -- [ ] **Step 1: Write the failing test** - -First add the two imports this test introduces to the import block of `docker/card/web/admin_api_2fa_test.go`: -```go - "time" - - "github.com/pquerna/otp/totp" -``` -(`"time"` goes in the stdlib group; the `totp` import goes in its own group after `"card/db"`.) - -Then append to `docker/card/web/admin_api_2fa_test.go`: -```go -func TestAdminApi2faSetupThenEnable(t *testing.T) { - app := openTestApp(t) - token := setupAdminSession(t, app) - handler := app.CreateHandler_AdminApi() - cookie := &http.Cookie{Name: "admin_session_token", Value: token} - - // setup → returns a secret + QR, leaves 2FA disabled - rs := httptest.NewRequest("POST", "/admin/api/auth/2fa/setup", nil) - rs.AddCookie(cookie) - ws := httptest.NewRecorder() - handler.ServeHTTP(ws, rs) - if ws.Code != http.StatusOK { - t.Fatalf("setup: expected 200, got %d: %s", ws.Code, ws.Body.String()) - } - var setup struct { - Secret string `json:"secret"` - OtpauthUri string `json:"otpauthUri"` - QrPng string `json:"qrPng"` - } - if err := json.Unmarshal(ws.Body.Bytes(), &setup); err != nil { - t.Fatal(err) - } - if setup.Secret == "" || setup.QrPng == "" { - t.Fatal("expected non-empty secret and qrPng") - } - if app.totpEnabled() { - t.Fatal("2FA must not be enabled until a code is confirmed") - } - - // enable with a wrong code → 400 (session is valid; the code is not) - rbad := httptest.NewRequest("POST", "/admin/api/auth/2fa/enable", - strings.NewReader(`{"code":"000000"}`)) - rbad.AddCookie(cookie) - wbad := httptest.NewRecorder() - handler.ServeHTTP(wbad, rbad) - if wbad.Code != http.StatusBadRequest { - t.Fatalf("enable(bad code): expected 400, got %d", wbad.Code) - } - if app.totpEnabled() { - t.Fatal("2FA must stay disabled after a wrong code") - } - - // enable with a valid code → 200, returns recovery codes, 2FA active - code, _ := totp.GenerateCode(setup.Secret, time.Now()) - rok := httptest.NewRequest("POST", "/admin/api/auth/2fa/enable", - strings.NewReader(`{"code":"`+code+`"}`)) - rok.AddCookie(cookie) - wok := httptest.NewRecorder() - handler.ServeHTTP(wok, rok) - if wok.Code != http.StatusOK { - t.Fatalf("enable(valid): expected 200, got %d: %s", wok.Code, wok.Body.String()) - } - var en struct { - RecoveryCodes []string `json:"recoveryCodes"` - } - if err := json.Unmarshal(wok.Body.Bytes(), &en); err != nil { - t.Fatal(err) - } - if len(en.RecoveryCodes) != 10 { - t.Fatalf("expected 10 recovery codes, got %d", len(en.RecoveryCodes)) - } - if !app.totpEnabled() { - t.Fatal("2FA should be enabled after a valid code") - } -} - -func TestAdminApi2faSetup_RejectedWhenAlreadyEnabled(t *testing.T) { - app := openTestApp(t) - token := setupAdminSession(t, app) - db.Db_set_setting(app.db_write, "admin_totp_enabled", "Y") - - handler := app.CreateHandler_AdminApi() - r := httptest.NewRequest("POST", "/admin/api/auth/2fa/setup", nil) - r.AddCookie(&http.Cookie{Name: "admin_session_token", Value: token}) - w := httptest.NewRecorder() - handler.ServeHTTP(w, r) - if w.Code != http.StatusBadRequest { - t.Fatalf("expected 400 when already enabled, got %d", w.Code) - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `go test ./web/ -run TestAdminApi2faSetup -v` -Expected: FAIL — routes 404 (`adminApi2faSetup`/`adminApi2faEnable` undefined). - -- [ ] **Step 3: Implement setup + enable handlers** - -First add `"card/util"` to the import block of `docker/card/web/admin_api_2fa.go` (the setup handler uses `util.QrPngBase64Encode`). Then append: -```go -func (app *App) adminApi2faSetup(w http.ResponseWriter, r *http.Request) { - if app.totpEnabled() { - w.WriteHeader(http.StatusBadRequest) - writeJSON(w, map[string]string{"error": "2fa already enabled"}) - return - } - - hostDomain := db.Db_get_setting(app.db_read, "host_domain") - secret, url, err := newTotpKey(hostDomain) - if err != nil { - log.Error("totp generate error: ", err) - w.WriteHeader(http.StatusInternalServerError) - writeJSON(w, map[string]string{"error": "internal error"}) - return - } - - // Store the pending secret; it is inert until enable sets enabled=Y. - db.Db_set_setting(app.db_write, "admin_totp_secret", secret) - db.Db_set_setting(app.db_write, "admin_totp_enabled", "N") - - writeJSON(w, map[string]string{ - "secret": secret, - "otpauthUri": url, - "qrPng": util.QrPngBase64Encode(url), - }) -} - -func (app *App) adminApi2faEnable(w http.ResponseWriter, r *http.Request) { - if app.totpEnabled() { - w.WriteHeader(http.StatusBadRequest) - writeJSON(w, map[string]string{"error": "2fa already enabled"}) - return - } - - var req struct { - Code string `json:"code"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - w.WriteHeader(http.StatusBadRequest) - writeJSON(w, map[string]string{"error": "invalid request body"}) - return - } - - secret := db.Db_get_setting(app.db_read, "admin_totp_secret") - if secret == "" { - w.WriteHeader(http.StatusBadRequest) - writeJSON(w, map[string]string{"error": "no pending 2fa setup"}) - return - } - if !validateTotpCode(secret, req.Code) { - // session is valid; only the submitted code is wrong → 400, not 401, - // so the shared apiFetch does not mislabel it as "session expired". - w.WriteHeader(http.StatusBadRequest) - writeJSON(w, map[string]string{"error": "invalid code"}) - return - } - - plain, hashes, err := generateRecoveryCodes(10) - if err != nil { - log.Error("recovery code generation error: ", err) - w.WriteHeader(http.StatusInternalServerError) - writeJSON(w, map[string]string{"error": "internal error"}) - return - } - app.saveRecoveryHashes(hashes) - db.Db_set_setting(app.db_write, "admin_totp_enabled", "Y") - - writeJSON(w, map[string]interface{}{"recoveryCodes": plain}) -} -``` - -- [ ] **Step 4: Wire the routes** - -In `docker/card/web/admin_api.go`, after the `2fa/status` case add: -```go - case path == "/admin/api/auth/2fa/setup" && r.Method == "POST": - app.adminApiAuth(app.adminApi2faSetup)(w, r) - - case path == "/admin/api/auth/2fa/enable" && r.Method == "POST": - app.adminApiAuth(app.adminApi2faEnable)(w, r) -``` - -- [ ] **Step 5: Run the tests to verify they pass** - -Run: `go test ./web/ -run TestAdminApi2faSetup -v` -Expected: PASS for both setup/enable tests. - -- [ ] **Step 6: Commit** - -```bash -git add docker/card/web/admin_api_2fa.go docker/card/web/admin_api.go docker/card/web/admin_api_2fa_test.go -git commit -m "Add 2FA setup and enable endpoints" -``` - ---- - -## Task 5: 2FA disable endpoint - -**Files:** -- Modify: `docker/card/web/admin_api_2fa.go` -- Modify: `docker/card/web/admin_api.go` (route) -- Test: `docker/card/web/admin_api_2fa_test.go` - -- [ ] **Step 1: Write the failing test** - -Append to `docker/card/web/admin_api_2fa_test.go`: -```go -func TestAdminApi2faDisable(t *testing.T) { - app := openTestApp(t) - token := setupAdminSession(t, app) // sets admin_password_hash for "testpass" - db.Db_set_setting(app.db_write, "admin_totp_enabled", "Y") - db.Db_set_setting(app.db_write, "admin_totp_secret", "SOMESECRET") - app.saveRecoveryHashes([]string{"h1", "h2"}) - - handler := app.CreateHandler_AdminApi() - cookie := &http.Cookie{Name: "admin_session_token", Value: token} - - // wrong password → 400, 2FA stays on - rbad := httptest.NewRequest("POST", "/admin/api/auth/2fa/disable", - strings.NewReader(`{"password":"wrong"}`)) - rbad.AddCookie(cookie) - wbad := httptest.NewRecorder() - handler.ServeHTTP(wbad, rbad) - if wbad.Code != http.StatusBadRequest { - t.Fatalf("disable(wrong pw): expected 400, got %d", wbad.Code) - } - if !app.totpEnabled() { - t.Fatal("2FA should remain enabled after a wrong password") - } - - // correct password → 200, all keys cleared - rok := httptest.NewRequest("POST", "/admin/api/auth/2fa/disable", - strings.NewReader(`{"password":"testpass"}`)) - rok.AddCookie(cookie) - wok := httptest.NewRecorder() - handler.ServeHTTP(wok, rok) - if wok.Code != http.StatusOK { - t.Fatalf("disable(correct pw): expected 200, got %d: %s", wok.Code, wok.Body.String()) - } - if app.totpEnabled() { - t.Fatal("2FA should be disabled") - } - if db.Db_get_setting(app.db_read, "admin_totp_secret") != "" { - t.Fatal("secret should be cleared") - } - if len(app.loadRecoveryHashes()) != 0 { - t.Fatal("recovery hashes should be cleared") - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `go test ./web/ -run TestAdminApi2faDisable -v` -Expected: FAIL — route 404 (`adminApi2faDisable` undefined). - -- [ ] **Step 3: Implement the disable handler** - -Append to `docker/card/web/admin_api_2fa.go`: -```go -func (app *App) adminApi2faDisable(w http.ResponseWriter, r *http.Request) { - var req struct { - Password string `json:"password"` - } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - w.WriteHeader(http.StatusBadRequest) - writeJSON(w, map[string]string{"error": "invalid request body"}) - return - } - if !app.verifyAdminPassword(req.Password) { - // in-session re-auth failure → 400 (see enable handler rationale) - w.WriteHeader(http.StatusBadRequest) - writeJSON(w, map[string]string{"error": "invalid password"}) - return - } - - db.Db_set_setting(app.db_write, "admin_totp_enabled", "N") - db.Db_set_setting(app.db_write, "admin_totp_secret", "") - db.Db_set_setting(app.db_write, "admin_totp_recovery_hash", "") - - writeJSON(w, map[string]bool{"ok": true}) -} -``` - -- [ ] **Step 4: Wire the route** - -In `docker/card/web/admin_api.go`, after the `2fa/enable` case add: -```go - case path == "/admin/api/auth/2fa/disable" && r.Method == "POST": - app.adminApiAuth(app.adminApi2faDisable)(w, r) -``` - -- [ ] **Step 5: Run the tests to verify they pass** - -Run: `go test ./web/ -run TestAdminApi2faDisable -v` -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add docker/card/web/admin_api_2fa.go docker/card/web/admin_api.go docker/card/web/admin_api_2fa_test.go -git commit -m "Add 2FA disable endpoint" -``` - ---- - -## Task 6: Enforce TOTP at login - -**Files:** -- Modify: `docker/card/web/admin_api.go` (`adminApiLogin`) -- Test: `docker/card/web/admin_api_2fa_test.go` - -- [ ] **Step 1: Write the failing tests** - -Append to `docker/card/web/admin_api_2fa_test.go`. This helper enables 2FA for an app and returns the secret: -```go -func enable2faForTest(t *testing.T, app *App) (secret string, recovery []string) { - t.Helper() - hash, _ := HashPassword("testpass") - db.Db_set_setting(app.db_write, "admin_password_hash", hash) - - s, _, err := newTotpKey("hub.example.com") - if err != nil { - t.Fatal(err) - } - db.Db_set_setting(app.db_write, "admin_totp_secret", s) - db.Db_set_setting(app.db_write, "admin_totp_enabled", "Y") - - plain, hashes, err := generateRecoveryCodes(10) - if err != nil { - t.Fatal(err) - } - app.saveRecoveryHashes(hashes) - return s, plain -} - -func postLogin(app *App, body string) *httptest.ResponseRecorder { - handler := app.CreateHandler_AdminApi() - r := httptest.NewRequest("POST", "/admin/api/auth/login", strings.NewReader(body)) - r.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - handler.ServeHTTP(w, r) - return w -} - -func TestAdminLogin_2faRequired_PasswordOnly(t *testing.T) { - app := openTestApp(t) - enable2faForTest(t, app) - - w := postLogin(app, `{"password":"testpass"}`) - if w.Code != http.StatusUnauthorized { - t.Fatalf("expected 401, got %d: %s", w.Code, w.Body.String()) - } - var resp struct { - TotpRequired bool `json:"totpRequired"` - } - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatal(err) - } - if !resp.TotpRequired { - t.Fatal("expected totpRequired=true") - } - // no session token should be issued - if db.Db_get_setting(app.db_read, "admin_session_token") != "" { - t.Fatal("no session should be issued without a code") - } -} - -func TestAdminLogin_2fa_ValidCode(t *testing.T) { - app := openTestApp(t) - secret, _ := enable2faForTest(t, app) - code, _ := totp.GenerateCode(secret, time.Now()) - - w := postLogin(app, `{"password":"testpass","code":"`+code+`"}`) - if w.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) - } - if db.Db_get_setting(app.db_read, "admin_session_token") == "" { - t.Fatal("expected a session token to be issued") - } -} - -func TestAdminLogin_2fa_InvalidCode(t *testing.T) { - app := openTestApp(t) - enable2faForTest(t, app) - - w := postLogin(app, `{"password":"testpass","code":"000000"}`) - if w.Code != http.StatusUnauthorized { - t.Fatalf("expected 401, got %d", w.Code) - } -} - -func TestAdminLogin_2fa_RecoveryCodeConsumedOnce(t *testing.T) { - app := openTestApp(t) - _, recovery := enable2faForTest(t, app) - - // first use → success - w1 := postLogin(app, `{"password":"testpass","code":"`+recovery[0]+`"}`) - if w1.Code != http.StatusOK { - t.Fatalf("recovery first use: expected 200, got %d: %s", w1.Code, w1.Body.String()) - } - if len(app.loadRecoveryHashes()) != 9 { - t.Fatalf("expected 9 codes remaining, got %d", len(app.loadRecoveryHashes())) - } - - // reuse of the same code → 401 - w2 := postLogin(app, `{"password":"testpass","code":"`+recovery[0]+`"}`) - if w2.Code != http.StatusUnauthorized { - t.Fatalf("recovery reuse: expected 401, got %d", w2.Code) - } -} - -func TestAdminLogin_NoTotp_PasswordOnlyStillWorks(t *testing.T) { - app := openTestApp(t) - hash, _ := HashPassword("testpass") - db.Db_set_setting(app.db_write, "admin_password_hash", hash) - - w := postLogin(app, `{"password":"testpass"}`) - if w.Code != http.StatusOK { - t.Fatalf("expected 200 with 2FA off, got %d: %s", w.Code, w.Body.String()) - } -} -``` - -(Wrong-password behavior with 2FA on is already covered by the existing `invalid password` login tests, so no separate test is added here.) - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `go test ./web/ -run 'TestAdminLogin_2fa|TestAdminLogin_NoTotp' -v` -Expected: FAIL — `TestAdminLogin_2faRequired_PasswordOnly` currently returns 200 and issues a session (no enforcement yet). - -- [ ] **Step 3: Add TOTP enforcement to `adminApiLogin`** - -In `docker/card/web/admin_api.go`, update the request struct and add the enforcement block. Change: -```go - var req struct { - Password string `json:"password"` - } -``` -to: -```go - var req struct { - Password string `json:"password"` - Code string `json:"code"` - } -``` -Then, immediately **after** the `verifyAdminPassword` failure check (after its closing `}`) and **before** `sessionToken := util.Random_hex()`, insert: -```go - if app.totpEnabled() { - if req.Code == "" { - w.WriteHeader(http.StatusUnauthorized) - writeJSON(w, map[string]interface{}{ - "error": "2fa code required", - "totpRequired": true, - }) - return - } - secret := db.Db_get_setting(app.db_read, "admin_totp_secret") - if !validateTotpCode(secret, req.Code) && !app.consumeRecoveryCode(req.Code) { - w.WriteHeader(http.StatusUnauthorized) - writeJSON(w, map[string]interface{}{ - "error": "invalid code", - "totpRequired": true, - }) - return - } - } -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `go test ./web/ -run 'TestAdminLogin_2fa|TestAdminLogin_NoTotp' -v` -Expected: PASS for all. - -- [ ] **Step 5: Run the full web package to check for regressions** - -Run: `go test -race -count=1 ./web/` -Expected: PASS (all existing + new tests). - -- [ ] **Step 6: Commit** - -```bash -git add docker/card/web/admin_api.go docker/card/web/admin_api_2fa_test.go -git commit -m "Require TOTP or recovery code at admin login when 2FA enabled" -``` - ---- - -## Task 7: `DisableAdmin2FA` CLI command - -**Files:** -- Modify: `docker/card/cli.go` - -- [ ] **Step 1: Add the command case** - -In `docker/card/cli.go`, inside the `switch args[0]` in `processArgs`, add before `default:`: -```go - case "DisableAdmin2FA": - disableAdmin2FA(db_conn) -``` - -- [ ] **Step 2: Add the function** - -Append to `docker/card/cli.go`: -```go -// DisableAdmin2FA clears admin TOTP 2FA. Recovery path for a lost -// authenticator: run via `docker exec -it card ./app DisableAdmin2FA`. -func disableAdmin2FA(db_conn *sql.DB) { - db.Db_set_setting(db_conn, "admin_totp_enabled", "N") - db.Db_set_setting(db_conn, "admin_totp_secret", "") - db.Db_set_setting(db_conn, "admin_totp_recovery_hash", "") - log.Info("admin 2FA disabled") -} -``` - -- [ ] **Step 3: Verify it builds and vets** - -Run: `go build ./... && go vet ./...` -Expected: no errors. - -- [ ] **Step 4: Commit** - -```bash -git add docker/card/cli.go -git commit -m "Add DisableAdmin2FA CLI command for lost-authenticator recovery" -``` - ---- - -## Task 8: Frontend — `login()` with TOTP support - -**Files:** -- Modify: `docker/card/admin-ui/src/hooks/use-auth.tsx` - -The shared `apiFetch` throws `AuthError` on any 401 and discards the body, so `login()` must do its own fetch to read `totpRequired` and the precise error message. - -- [ ] **Step 1: Add `TotpRequiredError` and update `login`** - -In `docker/card/admin-ui/src/hooks/use-auth.tsx`: - -Add near the top (after imports): -```tsx -export class TotpRequiredError extends Error { - constructor() { - super("2fa required"); - this.name = "TotpRequiredError"; - } -} -``` - -Change the context type: -```tsx - login: (password: string) => Promise; -``` -to: -```tsx - login: (password: string, code?: string) => Promise; -``` - -Replace the `login` implementation: -```tsx - const login = async (password: string) => { - await apiPost("/auth/login", { password }); - await refresh(); - }; -``` -with: -```tsx - const login = async (password: string, code?: string) => { - const res = await fetch("/admin/api/auth/login", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ password, code }), - }); - if (res.ok) { - await refresh(); - return; - } - const body = await res.json().catch(() => ({}) as { error?: string; totpRequired?: boolean }); - if (body.totpRequired) { - throw new TotpRequiredError(); - } - throw new Error(body.error || `HTTP ${res.status}`); - }; -``` - -(`code` undefined is dropped by `JSON.stringify`, so the backend sees no code — identical to the password-only case.) - -- [ ] **Step 2: Verify it typechecks** - -Run (from `docker/card/admin-ui/`): -```bash -export NVM_DIR="/home/debian/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use v22.22.0 > /dev/null 2>&1 -npm run build -``` -Expected: build succeeds (no TS errors). - -- [ ] **Step 3: Commit** - -```bash -git add docker/card/admin-ui/src/hooks/use-auth.tsx -git commit -m "Frontend: login() handles TOTP code and totpRequired signal" -``` - ---- - -## Task 9: Frontend — login page code field - -**Files:** -- Modify: `docker/card/admin-ui/src/pages/login.tsx` - -- [ ] **Step 1: Replace the login page** - -Replace the body of `docker/card/admin-ui/src/pages/login.tsx` with: -```tsx -import { useState, type FormEvent } from "react"; -import { useAuth, TotpRequiredError } from "@/hooks/use-auth"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Zap } from "lucide-react"; - -export function LoginPage() { - const { login } = useAuth(); - const [password, setPassword] = useState(""); - const [code, setCode] = useState(""); - const [totpRequired, setTotpRequired] = useState(false); - const [useRecovery, setUseRecovery] = useState(false); - const [error, setError] = useState(""); - const [loading, setLoading] = useState(false); - - async function handleSubmit(e: FormEvent) { - e.preventDefault(); - setError(""); - setLoading(true); - try { - await login(password, totpRequired ? code : undefined); - } catch (err) { - if (err instanceof TotpRequiredError) { - setTotpRequired(true); - setError(""); - } else { - setError(err instanceof Error ? err.message : "Login failed"); - } - } finally { - setLoading(false); - } - } - - return ( -
      - - - - - - - - Bolt Card Hub - - - - -
      - {error && ( - - {error} - - )} -
      - - setPassword(e.target.value)} - required - autoFocus - disabled={totpRequired} - /> -
      - {totpRequired && ( -
      - - setCode(e.target.value.trim())} - required - autoFocus - placeholder={useRecovery ? "8-character code" : "6-digit code"} - /> - -
      - )} - -
      -
      -
      -
      - ); -} -``` - -- [ ] **Step 2: Verify it typechecks** - -Run (from `docker/card/admin-ui/`): -```bash -export NVM_DIR="/home/debian/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use v22.22.0 > /dev/null 2>&1 -npm run build -``` -Expected: build succeeds. - -- [ ] **Step 3: Commit** - -```bash -git add docker/card/admin-ui/src/pages/login.tsx -git commit -m "Frontend: login page prompts for TOTP / recovery code" -``` - ---- - -## Task 10: Frontend — 2FA management card on Settings - -**Files:** -- Create: `docker/card/admin-ui/src/components/two-factor-card.tsx` -- Modify: `docker/card/admin-ui/src/pages/settings.tsx` - -- [ ] **Step 1: Create the 2FA card component** - -Create `docker/card/admin-ui/src/components/two-factor-card.tsx`: -```tsx -import { useState } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { apiFetch, apiPost } from "@/lib/api"; -import { toast } from "sonner"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Badge } from "@/components/ui/badge"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from "@/components/ui/dialog"; - -interface TwoFaStatus { - enabled: boolean; - recoveryCodesRemaining: number; -} - -interface SetupData { - secret: string; - otpauthUri: string; - qrPng: string; -} - -export function TwoFactorCard() { - const queryClient = useQueryClient(); - const { data } = useQuery({ - queryKey: ["2fa-status"], - queryFn: () => apiFetch("/auth/2fa/status"), - }); - - const [setup, setSetup] = useState(null); - const [code, setCode] = useState(""); - const [recoveryCodes, setRecoveryCodes] = useState(null); - const [disablePassword, setDisablePassword] = useState(""); - const [showDisable, setShowDisable] = useState(false); - - const invalidate = () => - queryClient.invalidateQueries({ queryKey: ["2fa-status"] }); - - const startSetup = useMutation({ - mutationFn: () => apiPost("/auth/2fa/setup"), - onSuccess: (d) => { - setSetup(d); - setCode(""); - }, - onError: (err) => toast.error(err.message), - }); - - const enable = useMutation({ - mutationFn: () => apiPost<{ recoveryCodes: string[] }>("/auth/2fa/enable", { code }), - onSuccess: (d) => { - setSetup(null); - setRecoveryCodes(d.recoveryCodes); - invalidate(); - toast.success("Two-factor authentication enabled"); - }, - onError: (err) => toast.error(err.message), - }); - - const disable = useMutation({ - mutationFn: () => apiPost("/auth/2fa/disable", { password: disablePassword }), - onSuccess: () => { - setShowDisable(false); - setDisablePassword(""); - invalidate(); - toast.success("Two-factor authentication disabled"); - }, - onError: (err) => toast.error(err.message), - }); - - return ( - - - - Two-Factor Authentication - {data?.enabled ? ( - Enabled - ) : ( - Disabled - )} - - - - {data?.enabled ? ( - <> -

      - Login requires a code from your authenticator app. - {" "}Recovery codes remaining: {data.recoveryCodesRemaining}. -

      - - - ) : ( - <> -

      - Add a second factor (TOTP) to admin login using an authenticator app. -

      - - - )} -
      - - {/* Enrollment dialog: QR + confirm code */} - !o && setSetup(null)}> - - - Scan with your authenticator - - {setup && ( -
      - TOTP QR code -

      - Manual key: {setup.secret} -

      -
      - - setCode(e.target.value.trim())} - placeholder="123456" - /> -
      -
      - )} - - - -
      -
      - - {/* Recovery codes shown once */} - !o && setRecoveryCodes(null)}> - - - Save your recovery codes - -

      - Store these somewhere safe. Each can be used once if you lose your - authenticator. They will not be shown again. -

      -
      - {recoveryCodes?.map((c) => ( - {c} - ))} -
      - - - - -
      -
      - - {/* Disable confirmation */} - - - - Disable two-factor authentication - -
      - - setDisablePassword(e.target.value)} - /> -
      - - - -
      -
      -
      - ); -} -``` - -- [ ] **Step 2: Render it on the Settings page** - -In `docker/card/admin-ui/src/pages/settings.tsx`: - -Add the import: -```tsx -import { TwoFactorCard } from "@/components/two-factor-card"; -``` - -In the returned JSX, place the card just under the heading, before the settings table. Change: -```tsx -
      -

      Settings

      - - {data.settings.length === 0 ? ( -``` -to: -```tsx -
      -

      Settings

      - - - - {data.settings.length === 0 ? ( -``` - -- [ ] **Step 3: Verify it typechecks/builds** - -Run (from `docker/card/admin-ui/`): -```bash -export NVM_DIR="/home/debian/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use v22.22.0 > /dev/null 2>&1 -npm run build -``` -Expected: build succeeds. If `apiPost("/auth/2fa/setup")` with no body trips lint, it's fine — `apiPost` treats body as optional. - -- [ ] **Step 4: Commit** - -```bash -git add docker/card/admin-ui/src/components/two-factor-card.tsx docker/card/admin-ui/src/pages/settings.tsx -git commit -m "Frontend: 2FA management card on settings page" -``` - ---- - -## Task 11: Version bump + docs - -**Files:** -- Modify: `docker/card/build/build.go` -- Modify: `CLAUDE.md` (CLI command list, settings keys; SemVer note already edited and currently uncommitted) - -- [ ] **Step 1: Bump the version (SemVer minor — new feature)** - -In `docker/card/build/build.go` change: -```go -var Version string = "0.19.8" -``` -to: -```go -var Version string = "0.20.0" -``` - -- [ ] **Step 2: Update CLAUDE.md** - -In `CLAUDE.md`: - -(a) Sync the version reference in the `build/` architecture bullet: -``` -- `build/` — Version string (currently "0.19.8"), date/time injected at build -``` -to: -``` -- `build/` — Version string (currently "0.20.0"), date/time injected at build -``` - -(b) Add `DisableAdmin2FA` to the CLI Commands code block: -``` -./app WipeCard -``` -add a line after it: -``` -./app DisableAdmin2FA -``` - -(c) In the **Settings** section's active-settings list, add: -``` -- `admin_totp_enabled`, `admin_totp_secret`, `admin_totp_recovery_hash` — optional admin login 2FA (TOTP). `admin_totp_secret` (base32) and `admin_totp_recovery_hash` (JSON array of bcrypt-hashed single-use recovery codes) are redacted in the settings UI; `admin_totp_enabled` ("Y"/"N") gates enforcement at login. Cleared by the `DisableAdmin2FA` CLI command. -``` - -(d) In the **Authentication** section, under the Admin bullet, append: -``` -Optional TOTP 2FA: when `admin_totp_enabled="Y"`, login also requires a 6-digit TOTP code or a single-use recovery code (see `web/admin_api_2fa.go`, `web/totp.go`). -``` - -(e) In the redaction note ("values with `_hash`, `_token`, `_code` suffixes shown as REDACTED"), add `_secret`: -``` -sensitive values (`_hash`, `_token`, `_code`, `_secret` suffixes) redacted -``` - -- [ ] **Step 3: Final full-suite verification** - -Run from `docker/card/`: -```bash -go vet ./... && go build ./... && go test -race -count=1 ./... -``` -Expected: all PASS. - -Run from `docker/card/admin-ui/`: -```bash -export NVM_DIR="/home/debian/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use v22.22.0 > /dev/null 2>&1 -npm run build -``` -Expected: build succeeds. - -- [ ] **Step 4: Commit (folds in the SemVer CLAUDE.md edit from brainstorming)** - -```bash -git add docker/card/build/build.go CLAUDE.md -git commit -m "Bump version to 0.20.0 and document admin 2FA" -``` - ---- - -## Post-implementation - -- [ ] Update the project memory file (`~/.claude/projects/-home-debian-hub/memory/MEMORY.md`) with: the 2FA settings keys, the `DisableAdmin2FA` recovery command, login-only enforcement, and the `pquerna/otp` dependency. (Required by CLAUDE.md "Memory File" convention.) -- [ ] Open the PR per the finishing-a-development-branch flow. Confirm CI is green (vet, build, test, govulncheck, frontend build, Docker builds). - ---- - -## Manual verification checklist (after deploy to test hub) - -These exercise paths unit tests can't (real authenticator app, browser): -1. Settings → Enable 2FA → scan QR with an authenticator → confirm code → recovery codes shown once. -2. Log out → log in: password accepted → prompted for code → correct code logs in. -3. Wrong code rejected; "Use a recovery code instead" accepts a recovery code once; reused recovery code rejected. -4. Settings shows decremented "recovery codes remaining" after a recovery login. -5. `docker exec -it card ./app DisableAdmin2FA` → next login is password-only. -6. Settings list shows `admin_totp_secret` as REDACTED. diff --git a/docs/superpowers/plans/2026-06-17-about-release-notes.md b/docs/superpowers/plans/2026-06-17-about-release-notes.md deleted file mode 100644 index 7ba64d5..0000000 --- a/docs/superpowers/plans/2026-06-17-about-release-notes.md +++ /dev/null @@ -1,864 +0,0 @@ -# About page: Recent Releases Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the About page's raw "Recent Commits" list with "Recent Releases" — proper GitHub Releases (auto-published in CI on version bump from tidied commit subjects), scoped to the gap between the hub's running version and the latest available version. - -**Architecture:** A CI `release` job tags `v{version}` and publishes a GitHub Release whenever `build/build.go`'s version changes. The card backend swaps its N+1 per-commit GitHub fetch for a single Releases-API call, filters the releases to the `[running, latest]` range with a pure testable helper, and the React About page renders them as release blocks. - -**Tech Stack:** Go 1.25.11 (Gorilla Mux handlers, CGo/sqlite), React 19 + Vite + TypeScript + Tailwind v4 + shadcn/ui, GitHub Actions, `gh` CLI. - -## Global Constraints - -- Module name is `card`; imports are `card/build`, `card/web`, etc. Working dir for Go tests: `docker/card/`. -- Go tests need CGo (default-on) and run via `go test -race -count=1 ./...`. -- Admin API handler pattern: `func (app *App) adminApiX(w http.ResponseWriter, r *http.Request)`; routes registered in `web/admin_api.go` behind `adminApiAuth(...)`; JSON responses via `writeJSON(w, v)`. -- `CompareVersions(current, latest string) int` returns `1` when `latest > current`, `0` when equal, `-1` when `latest < current` (in `web/update.go`). -- Bump `Version` in `docker/card/build/build.go` for this PR: `0.21.0` → `0.22.0` (MINOR — new feature). Keep the `build/` line in `CLAUDE.md` ("currently \"0.21.0\"") in sync. -- Frontend: no new markdown dependency; React auto-escapes children — do not use `dangerouslySetInnerHTML` for release bodies. -- Node/npm in this dev env: `export NVM_DIR="/home/debian/.nvm" && . "$NVM_DIR/nvm.sh" && nvm use v22.22.0` before npm; frontend build = `npm run build` in `docker/card/admin-ui`. -- Repo: `boltcard/hub`. Default branch `main`. Commit trailer: `Co-Authored-By: Claude Opus 4.8 (1M context) `. - ---- - -## File Structure - -- `docker/card/web/admin_api_about.go` — Modify. Add `selectReleases` + `isVersion` + `versionRegex` (Task 1). Replace `adminApiCommits` with `adminApiReleases`; remove `fetchCommitVersion`, `commitVersionMu`, `commitVersionCache`, `parseVersionFromBuildGo`, `buildVersionRegex` and the `encoding/base64`/`sync` imports; add `sort` (Task 2). Keep `adminApiAbout`, `adminApiTriggerUpdate`, `adminApiLogs`, `ansiToHTML`, `ansiColorMap`, `ansiRegex`. -- `docker/card/web/admin_api_about_test.go` — Modify. Add `TestSelectReleases` (Task 1); remove `TestParseVersionFromBuildGo` (Task 2). -- `docker/card/web/admin_api.go` — Modify. Swap the `/about/commits` route for `/about/releases` (Task 2). -- `docker/card/admin-ui/src/pages/about.tsx` — Modify (full replacement). "Recent Releases" card + releases query + inline notes renderer (Task 3). -- `.github/workflows/ci.yml` — Modify. Add the `release` job (Task 4). -- `docker/card/build/build.go` + `CLAUDE.md` — Modify. Version bump + doc sync (Task 5). - ---- - -## Task 1: Release-range selection helper (`selectReleases`) - -**Files:** -- Modify: `docker/card/web/admin_api_about.go` (add helpers; also add `sort` to imports) -- Test: `docker/card/web/admin_api_about_test.go` (add `TestSelectReleases`) - -**Interfaces:** -- Consumes: `CompareVersions(current, latest string) int` from `web/update.go`. -- Produces: - - `func isVersion(s string) bool` — true iff `s` matches `^\d+\.\d+\.\d+$`. - - `func selectReleases(running, latest string, versions []string) []string` — returns the subset of `versions` (each a bare `X.Y.Z`) to display, sorted newest-first. Range: `running <= ver <= upper`, where `upper = latest` when `latest` is a valid version greater than `running`, else `upper = running`. Non-version entries in `versions` are skipped. Returns `nil` when `running` is empty. - -- [ ] **Step 1: Write the failing test** - -Add to `docker/card/web/admin_api_about_test.go`: - -```go -func TestSelectReleases(t *testing.T) { - tests := []struct { - name string - running string - latest string - versions []string - want []string - }{ - { - name: "up to date shows only running", - running: "0.22.0", - latest: "0.22.0", - versions: []string{"0.22.0", "0.21.0", "0.20.0"}, - want: []string{"0.22.0"}, - }, - { - name: "behind shows running through latest, descending", - running: "0.20.0", - latest: "0.22.0", - versions: []string{"0.19.0", "0.22.0", "0.20.0", "0.21.0"}, - want: []string{"0.22.0", "0.21.0", "0.20.0"}, - }, - { - name: "running version absent from list", - running: "0.20.5", - latest: "0.22.0", - versions: []string{"0.22.0", "0.21.0", "0.20.0"}, - want: []string{"0.22.0", "0.21.0"}, - }, - { - name: "empty latest falls back to running only", - running: "0.22.0", - latest: "", - versions: []string{"0.22.0", "0.21.0"}, - want: []string{"0.22.0"}, - }, - { - name: "garbage latest falls back to running only", - running: "0.22.0", - latest: "not-a-version", - versions: []string{"0.22.0", "0.21.0"}, - want: []string{"0.22.0"}, - }, - { - name: "latest below running is ignored (no downgrade range)", - running: "0.22.0", - latest: "0.21.0", - versions: []string{"0.22.0", "0.21.0"}, - want: []string{"0.22.0"}, - }, - { - name: "non-version tags are skipped", - running: "0.21.0", - latest: "0.22.0", - versions: []string{"0.22.0", "latest", "v0.21.0", "0.21.0"}, - want: []string{"0.22.0", "0.21.0"}, - }, - { - name: "empty running returns nil", - running: "", - latest: "0.22.0", - versions: []string{"0.22.0"}, - want: nil, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := selectReleases(tt.running, tt.latest, tt.versions) - if len(got) != len(tt.want) { - t.Fatalf("selectReleases() = %v, want %v", got, tt.want) - } - for i := range got { - if got[i] != tt.want[i] { - t.Fatalf("selectReleases() = %v, want %v", got, tt.want) - } - } - }) - } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `cd docker/card && go test ./web/ -run TestSelectReleases -v` -Expected: FAIL — `undefined: selectReleases` (and `isVersion`). - -- [ ] **Step 3: Add the helpers** - -In `docker/card/web/admin_api_about.go`, add `"sort"` to the import block (alphabetical order: after `regexp`/before `strings`), and append these helpers (e.g. near the bottom, before `ansiColorMap`): - -```go -var versionRegex = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) - -// isVersion reports whether s is a bare three-part numeric version (X.Y.Z). -func isVersion(s string) bool { - return versionRegex.MatchString(s) -} - -// selectReleases returns the release versions to display, newest-first, given -// the running version, the latest available version (may be "" or invalid), -// and the available release versions (each a bare "X.Y.Z"). The shown range is -// running <= ver <= upper, where upper is the latest version when it is a valid -// version greater than running, otherwise running. Non-version entries are -// skipped. Returns nil when running is not a valid version. -func selectReleases(running, latest string, versions []string) []string { - if !isVersion(running) { - return nil - } - upper := running - if isVersion(latest) && CompareVersions(running, latest) == 1 { - upper = latest - } - - var out []string - for _, v := range versions { - if !isVersion(v) { - continue - } - // running <= v <= upper - if CompareVersions(running, v) >= 0 && CompareVersions(v, upper) >= 0 { - out = append(out, v) - } - } - - sort.Slice(out, func(i, j int) bool { - // descending: out[i] before out[j] when out[i] > out[j] - return CompareVersions(out[i], out[j]) == -1 - }) - return out -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `cd docker/card && go test ./web/ -run TestSelectReleases -v` -Expected: PASS (all subtests). - -- [ ] **Step 5: Commit** - -```bash -git add docker/card/web/admin_api_about.go docker/card/web/admin_api_about_test.go -git commit -m "Add selectReleases range helper for About releases - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 2: `/about/releases` endpoint replaces `/about/commits` - -**Files:** -- Modify: `docker/card/web/admin_api_about.go` (replace handler + remove dead code + imports) -- Modify: `docker/card/web/admin_api_about_test.go` (remove `TestParseVersionFromBuildGo`) -- Modify: `docker/card/web/admin_api.go` (route swap) - -**Interfaces:** -- Consumes: `selectReleases`, `isVersion` (Task 1); `build.Version`; `writeJSON`; `CompareVersions`. -- Produces: `GET /admin/api/about/releases?latest=` → JSON `{"releases": [{ "version": string, "name": string, "body": string, "date": string, "url": string, "isCurrent": bool }]}`, newest-first. - -- [ ] **Step 1: Replace the handler and remove dead code** - -In `docker/card/web/admin_api_about.go`: - -(a) Remove `"encoding/base64"` and `"sync"` from the import block (no longer used after this task). Keep `card/build`, `encoding/json`, `html`, `io`, `net/http`, `regexp`, `sort`, `strings`, and the logrus import. - -(b) Delete the entire `adminApiCommits` function. - -(c) Delete `parseVersionFromBuildGo`, `buildVersionRegex`, the `commitVersionMu`/`commitVersionCache` `var` block, and the `fetchCommitVersion` function. - -(d) Add the new handler (e.g. where `adminApiCommits` was): - -```go -func (app *App) adminApiReleases(w http.ResponseWriter, r *http.Request) { - type release struct { - Version string `json:"version"` - Name string `json:"name"` - Body string `json:"body"` - Date string `json:"date"` - URL string `json:"url"` - IsCurrent bool `json:"isCurrent"` - } - - empty := map[string]interface{}{"releases": []release{}} - - running := build.Version - latest := strings.TrimSpace(r.URL.Query().Get("latest")) - - client := &http.Client{Timeout: 10e9} - req, err := http.NewRequest("GET", "https://api.github.com/repos/boltcard/hub/releases?per_page=100", nil) - if err != nil { - writeJSON(w, empty) - return - } - req.Header.Set("Accept", "application/vnd.github.v3+json") - resp, err := client.Do(req) - if err != nil { - writeJSON(w, empty) - return - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - writeJSON(w, empty) - return - } - - var ghReleases []struct { - TagName string `json:"tag_name"` - Name string `json:"name"` - Body string `json:"body"` - PublishedAt string `json:"published_at"` - HTMLURL string `json:"html_url"` - } - if err := json.NewDecoder(resp.Body).Decode(&ghReleases); err != nil { - writeJSON(w, empty) - return - } - - byVersion := map[string]release{} - var versions []string - for _, g := range ghReleases { - ver := strings.TrimPrefix(g.TagName, "v") - if !isVersion(ver) { - continue - } - if _, dup := byVersion[ver]; dup { - continue - } - name := g.Name - if name == "" { - name = "v" + ver - } - byVersion[ver] = release{ - Version: ver, - Name: name, - Body: g.Body, - Date: g.PublishedAt, - URL: g.HTMLURL, - IsCurrent: ver == running, - } - versions = append(versions, ver) - } - - selected := selectReleases(running, latest, versions) - releases := make([]release, 0, len(selected)) - for _, v := range selected { - releases = append(releases, byVersion[v]) - } - - writeJSON(w, map[string]interface{}{"releases": releases}) -} -``` - -- [ ] **Step 2: Remove the obsolete test** - -In `docker/card/web/admin_api_about_test.go`, delete the entire `TestParseVersionFromBuildGo` function (it references the now-removed `parseVersionFromBuildGo`). Leave `TestSelectReleases`. The file's only remaining import is `"testing"`. - -- [ ] **Step 3: Swap the route** - -In `docker/card/web/admin_api.go`, replace: - -```go - case path == "/admin/api/about/commits" && r.Method == "GET": - app.adminApiAuth(app.adminApiCommits)(w, r) -``` - -with: - -```go - case path == "/admin/api/about/releases" && r.Method == "GET": - app.adminApiAuth(app.adminApiReleases)(w, r) -``` - -- [ ] **Step 4: Verify it builds, vets, and tests pass** - -Run: `cd docker/card && go vet ./web/ && go build ./... && go test ./web/ -count=1` -Expected: no vet errors, build succeeds, tests PASS. (If `go vet` flags an unused import, you missed removing `encoding/base64` or `sync` in Step 1(a).) - -- [ ] **Step 5: Commit** - -```bash -git add docker/card/web/admin_api_about.go docker/card/web/admin_api_about_test.go docker/card/web/admin_api.go -git commit -m "Replace About /commits with /releases endpoint - -Single GitHub Releases API call (drops the per-commit N+1 fetch); filters -to the running..latest version range via selectReleases. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 3: About page — "Recent Releases" card - -**Files:** -- Modify (full replacement): `docker/card/admin-ui/src/pages/about.tsx` - -**Interfaces:** -- Consumes: `GET /about/releases?latest=` JSON from Task 2 (`{ releases: [{ version, name, body, date, url, isCurrent }] }`). -- Produces: no downstream consumers (leaf page). - -- [ ] **Step 1: Replace the file contents** - -Overwrite `docker/card/admin-ui/src/pages/about.tsx` with: - -```tsx -import { useQuery, useMutation } from "@tanstack/react-query"; -import { apiFetch, apiPost } from "@/lib/api"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Button } from "@/components/ui/button"; -import { - Table, - TableBody, - TableCell, - TableRow, -} from "@/components/ui/table"; -import { Badge } from "@/components/ui/badge"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@/components/ui/dialog"; -import { ArrowUpCircle, Loader2 } from "lucide-react"; -import { useState, type ReactNode } from "react"; -import { toast } from "sonner"; - -interface AboutData { - version: string; - buildDate: string; - buildTime: string; - latestVersion: string; - updateAvailable: boolean; -} - -interface LogsData { - logs: string[]; -} - -interface Release { - version: string; - name: string; - body: string; - date: string; - url: string; - isCurrent: boolean; -} - -interface ReleasesData { - releases: Release[]; -} - -// linkify turns bare http(s) URLs in a line into anchor elements; other text is -// returned verbatim (React escapes it). -function linkify(text: string): ReactNode { - const parts = text.split(/(https?:\/\/[^\s]+)/g); - return parts.map((part, i) => - /^https?:\/\//.test(part) ? ( - - {part} - - ) : ( - {part} - ), - ); -} - -// ReleaseNotes renders a release body: "- " lines become a bullet list, blank -// lines break groups, everything else is a paragraph. No markdown dependency. -function ReleaseNotes({ body }: { body: string }) { - const elements: ReactNode[] = []; - let bullets: string[] = []; - - const flush = () => { - if (bullets.length > 0) { - const items = bullets; - elements.push( -
        - {items.map((b, i) => ( -
      • {linkify(b)}
      • - ))} -
      , - ); - bullets = []; - } - }; - - for (const raw of body.split("\n")) { - const line = raw.trimEnd(); - if (line.startsWith("- ")) { - bullets.push(line.slice(2)); - } else if (line.trim() === "") { - flush(); - } else { - flush(); - elements.push( -

      - {linkify(line)} -

      , - ); - } - } - flush(); - - return
      {elements}
      ; -} - -export function AboutPage() { - const { data, isLoading } = useQuery({ - queryKey: ["about"], - queryFn: () => apiFetch("/about"), - }); - - const { data: logsData } = useQuery({ - queryKey: ["about-logs"], - queryFn: () => apiFetch("/about/logs"), - }); - - const { data: releasesData } = useQuery({ - queryKey: ["about-releases", data?.latestVersion], - queryFn: () => - apiFetch( - `/about/releases?latest=${encodeURIComponent(data?.latestVersion ?? "")}`, - ), - enabled: !!data, - }); - - const [dialogOpen, setDialogOpen] = useState(false); - - const [updating, setUpdating] = useState(false); - - const triggerUpdate = useMutation({ - mutationFn: () => apiPost("/about/update"), - onSettled: () => { - setDialogOpen(false); - setUpdating(true); - toast.success("Update triggered — restarting containers…"); - // Poll until the server comes back with a new version - const poll = setInterval(async () => { - try { - const res = await fetch("/admin/api/about"); - if (res.ok) { - clearInterval(poll); - window.location.reload(); - } - } catch { - // server still restarting - } - }, 3000); - // Stop polling after 2 minutes - setTimeout(() => clearInterval(poll), 120_000); - }, - }); - - if (isLoading || !data) { - return ( -
      -

      About

      -
      -
      - ); - } - - return ( -
      -

      About

      - - - - Software - - - - - - Version - {data.version} - - - Build Date - {data.buildDate} - - - Build Time - {data.buildTime} - - - Latest Version - - - {data.latestVersion || "unable to check"} - - {data.updateAvailable && ( - - Update available - - )} - - - -
      - - {data.updateAvailable && !updating && ( -
      - - - - - - - Confirm Update - - Pull latest images and restart containers? - - - - - - - - -
      - )} - - {updating && ( -
      - -
      -

      Updating…

      -

      - Pulling images and restarting containers. This page will - reload automatically. -

      -
      -
      - )} -
      -
      - - {logsData && logsData.logs.length > 0 && ( - - - Recent Logs - - -
      -          
      -        
      -      )}
      -
      -      {releasesData && (
      -        
      -          
      -            Recent Releases
      -          
      -          
      -            {releasesData.releases.length === 0 ? (
      -              

      - No release notes available. -

      - ) : ( - releasesData.releases.map((rel) => ( -
      -
      - - v{rel.version} - - {rel.isCurrent && ( - - Current - - )} - {rel.date && ( - - {new Date(rel.date).toLocaleDateString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - })} - - )} -
      - {rel.body.trim() && } -
      - )) - )} -
      -
      - )} -
      - ); -} -``` - -- [ ] **Step 2: Type-check and build the frontend** - -Run: -```bash -export NVM_DIR="/home/debian/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use v22.22.0 > /dev/null 2>&1 -cd docker/card/admin-ui && npm run build -``` -Expected: build succeeds with no TypeScript errors. (`npm run build` runs `tsc` then `vite build`.) - -- [ ] **Step 3: Commit** - -```bash -git add docker/card/admin-ui/src/pages/about.tsx -git commit -m "About: show Recent Releases instead of Recent Commits - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 4: CI — auto-publish a GitHub Release on version bump - -**Files:** -- Modify: `.github/workflows/ci.yml` (append a `release` job) - -**Interfaces:** -- Consumes: `docker` job success; `secrets.GITHUB_TOKEN`; tags `v*` in repo history. -- Produces: a git tag `v{version}` + GitHub Release whenever `build/build.go`'s version changes on `main`. - -- [ ] **Step 1: Add the `release` job** - -Append to `.github/workflows/ci.yml` (after the `docker` job, same indentation level — a new top-level entry under `jobs:`): - -```yaml - release: - runs-on: ubuntu-latest - needs: [docker] - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Publish GitHub Release on version bump - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - set -euo pipefail - V=$(grep -oP 'Version string = "\K[0-9]+\.[0-9]+\.[0-9]+' docker/card/build/build.go) - echo "Version in build.go: $V" - - if git rev-parse -q --verify "refs/tags/v$V" >/dev/null; then - echo "Tag v$V already exists — nothing to release." - exit 0 - fi - - PREV=$(git tag -l 'v*' --sort=-v:refname | head -n1) - echo "Previous release tag: ${PREV:-(none)}" - if [ -n "$PREV" ]; then - RANGE="$PREV..HEAD" - else - RANGE="HEAD" - fi - - # Tidied notes: non-merge commit subjects, version-bump lines dropped. - mapfile -t SUBJECTS < <(git log --no-merges --pretty=format:'%s' $RANGE \ - | grep -viE '^(bump|chore: ?bump).*version|^version bump|^bump to v?[0-9]' || true) - - TOTAL=${#SUBJECTS[@]} - LIMIT=30 - BODY="" - COUNT=0 - for s in "${SUBJECTS[@]}"; do - [ "$COUNT" -ge "$LIMIT" ] && break - BODY+="- $s"$'\n' - COUNT=$((COUNT + 1)) - done - if [ "$TOTAL" -gt "$LIMIT" ]; then - REMAIN=$((TOTAL - LIMIT)) - BODY+=$'\n'"…and $REMAIN earlier commits."$'\n' - if [ -n "$PREV" ]; then - BODY+=$'\n'"Full changelog: https://github.com/boltcard/hub/compare/$PREV...v$V"$'\n' - fi - fi - if [ -z "$BODY" ]; then - BODY="Release v$V" - fi - - echo "----- release notes -----" - printf '%s\n' "$BODY" - echo "-------------------------" - - gh release create "v$V" --target "$GITHUB_SHA" --title "v$V" --notes "$BODY" -``` - -- [ ] **Step 2: Validate the workflow YAML** - -Run: `python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/ci.yml')); print('YAML OK')"` -Expected: `YAML OK`. (If `python3`/`yaml` is unavailable, instead confirm the `release:` block is indented identically to the sibling `build:`/`docker:` jobs and that `permissions:` sits under `release:`, not at workflow scope.) - -- [ ] **Step 3: Commit** - -```bash -git add .github/workflows/ci.yml -git commit -m "CI: publish GitHub Release on version bump - -New release job tags v{version} and publishes notes built from non-merge -commit subjects since the previous tag (capped at 30). Idempotent: skips if -the tag already exists. Job-scoped contents:write keeps the workflow default -at contents:read. - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Task 5: Version bump + docs - -**Files:** -- Modify: `docker/card/build/build.go` -- Modify: `CLAUDE.md` - -**Interfaces:** -- Consumes: nothing. -- Produces: version `0.22.0`. - -- [ ] **Step 1: Bump the version** - -In `docker/card/build/build.go`, change: - -```go -var Version string = "0.21.0" -``` -to: -```go -var Version string = "0.22.0" -``` - -- [ ] **Step 2: Sync CLAUDE.md** - -In `CLAUDE.md`, change the `build/` bullet: - -``` -- `build/` — Version string (currently "0.21.0"), date/time injected at build -``` -to: -``` -- `build/` — Version string (currently "0.22.0"), date/time injected at build -``` - -- [ ] **Step 3: Full verification (Go + frontend)** - -Run: -```bash -cd docker/card && go vet ./... && go build -o /tmp/app && go test -race -count=1 ./... -``` -Expected: vet clean, build succeeds, all tests PASS. - -Then: -```bash -export NVM_DIR="/home/debian/.nvm" && [ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh" && nvm use v22.22.0 > /dev/null 2>&1 -cd docker/card/admin-ui && npm run build -``` -Expected: frontend build succeeds. - -- [ ] **Step 4: Commit** - -```bash -git add docker/card/build/build.go CLAUDE.md -git commit -m "Bump version to 0.22.0 - -Co-Authored-By: Claude Opus 4.8 (1M context) " -``` - ---- - -## Final verification checklist - -- [ ] `cd docker/card && go vet ./... && go test -race -count=1 ./...` — clean. -- [ ] `npm run build` in `docker/card/admin-ui` — clean. -- [ ] `grep -rn "adminApiCommits\|fetchCommitVersion\|commitVersionCache\|parseVersionFromBuildGo\|/about/commits" docker/card/` — no matches (dead code fully removed). -- [ ] `build/build.go` says `0.22.0`; `CLAUDE.md` build line says `0.22.0`. -- [ ] `.github/workflows/ci.yml` has a `release` job with job-level `permissions: contents: write`; workflow-level `permissions:` is still `contents: read`. diff --git a/docs/superpowers/specs/2026-06-15-admin-totp-2fa-design.md b/docs/superpowers/specs/2026-06-15-admin-totp-2fa-design.md deleted file mode 100644 index 009d19f..0000000 --- a/docs/superpowers/specs/2026-06-15-admin-totp-2fa-design.md +++ /dev/null @@ -1,206 +0,0 @@ -# Admin Login 2FA (TOTP) — Design - -**Date:** 2026-06-15 -**Status:** Approved (design) -**Target version:** 0.20.0 - -## Summary - -Add **optional** TOTP-based two-factor authentication to the single-admin -login. When enabled, login requires a 6-digit TOTP code (or a one-time -recovery code) in addition to the existing admin password. The feature is: - -- Implemented with `github.com/pquerna/otp` (no hand-rolled crypto). -- Stored in the existing `settings` key-value table — **no schema migration**. -- Enrolled via a QR-code flow in the admin UI Settings page. -- Recoverable via either one-time backup codes **or** a `docker exec` CLI - command, so a lost authenticator never permanently locks the admin out. - -Enforcement is **login-only**. The withdraw handler keeps its existing -password re-prompt; it does not additionally require a TOTP code. - -## Decisions (locked during brainstorming) - -| Decision | Choice | Rationale | -|----------|--------|-----------| -| Recovery | One-time recovery codes **and** a CLI disable command | Defense in depth; never weakens the second factor | -| TOTP impl | `pquerna/otp` library | Avoids subtle RFC 6238 / base32 / skew bugs | -| Enforcement | Login only | Session is already 2FA-backed; avoids 30s-code friction during a payout | -| Versioning | SemVer minor bump (0.19.8 → 0.20.0) | New backward-compatible feature | - -### Non-goals (YAGNI) - -- No TOTP requirement on the withdraw re-prompt (login-only enforcement). -- No separate backend rate-limiter — Caddy already rate-limits - `/admin/api/auth/login` at 10 req/min/IP. -- No multi-admin / per-user 2FA — the system is single-admin by design. -- No "password still works as fallback" mode — that would defeat 2FA. - -## Storage (settings table — no migration) - -Three new keys in the `settings` table: - -| Key | Value | Settings-UI visibility | -|-----|-------|------------------------| -| `admin_totp_enabled` | `"Y"` / `"N"` (or empty) | Shown — status only | -| `admin_totp_secret` | base32 TOTP shared secret | **Redacted** | -| `admin_totp_recovery_hash` | JSON array of bcrypt hashes of the **unused** recovery codes | **Redacted** (matches existing `_hash` suffix) | - -**Redaction:** add a `_secret` suffix to the `strings.HasSuffix` guard in -`web/admin_api_settings.go` (currently `_hash` / `_token` / `_code`). The -recovery key already ends in `_hash`, so it is redacted with no change. - -**Why the secret is plaintext but redacted:** TOTP is a shared-secret scheme, -so the secret must be stored recoverable to validate codes. Redaction keeps it -out of the settings list endpoint. Recovery codes, by contrast, are stored -**bcrypt-hashed** — a DB read never yields usable codes. - -**Enforcement keys on `admin_totp_enabled == "Y"`**, never on the mere presence -of a secret. This guarantees a half-finished enrollment (secret generated but -code never confirmed) cannot lock anyone out. - -## TOTP implementation - -Package: `github.com/pquerna/otp` + `github.com/pquerna/otp/totp`. - -- **Secret generation:** `totp.Generate(totp.GenerateOpts{Issuer: "Bolt Card - Hub", AccountName: })` → returns an `*otp.Key` exposing - `.Secret()` (base32) and `.URL()` (the `otpauth://` URI). -- **Validation:** `totp.ValidateCustom(code, secret, time.Now(), - totp.ValidateOpts{Period: 30, Skew: 1, Digits: 6, Algorithm: SHA1})` — - **±1 time-step skew** tolerates client/server clock drift. -- **QR rendering:** feed `key.URL()` into the **existing** - `util.QrPngBase64Encode()` (skip2/go-qrcode) — we do not use the library's - own image path. The authenticator app shows - "Bolt Card Hub (hub.example.com)" using the `host_domain` setting as the - account label. - -New dependency footprint: `github.com/pquerna/otp` and its transitive -`github.com/boombuler/barcode` (small, pure Go). `go.mod` / `go.sum` must be -committed (CI fails otherwise). - -## Login flow (stateless — no half-authenticated state) - -Extend `adminApiLogin` (`web/admin_api.go`) to accept `{ password, code? }`: - -1. Verify password via existing `verifyAdminPassword`. Wrong → `401 - { error: "invalid password" }`. -2. If `admin_totp_enabled != "Y"` → issue session (unchanged behavior). -3. If enabled and `code` is empty → `401 { error: "2fa required", - totpRequired: true }`. **No session issued.** -4. If `code` is present, accept it if **either**: - - it is a valid TOTP code for `admin_totp_secret` (±1 skew), **or** - - it matches an unused entry in `admin_totp_recovery_hash` - (bcrypt compare). On a recovery match, **remove that hash** from the JSON - array and persist (single-use), then issue the session. - - Otherwise → `401 { error: "invalid code", totpRequired: true }`. -5. On success, issue the session token exactly as today (24h cookie). - -The password is re-sent together with the code, so there is **no intermediate -"pending 2FA" token** to store or expire. Brute force is bounded by Caddy's -existing 10 req/min/IP limit (a 6-digit code with ±1 skew = 3 valid values out -of 1,000,000 per window). - -`adminApiAuthCheck` is unchanged — it only validates an already-issued session -cookie, which is only minted after the full factor(s) pass. - -## New endpoints (all behind existing `adminApiAuth` session) - -Registered in the `CreateHandler_AdminApi` switch in `web/admin_api.go`: - -| Method + path | Body | Returns | -|---------------|------|---------| -| `GET /admin/api/auth/2fa/status` | — | `{ enabled: bool, recoveryCodesRemaining: int }` | -| `POST /admin/api/auth/2fa/setup` | — | `{ secret, otpauthUri, qrPng }` — generates a **pending** secret (`enabled` stays `N`). Returns `400` if `admin_totp_enabled == "Y"` (must `disable` first) so an active secret is never clobbered | -| `POST /admin/api/auth/2fa/enable` | `{ code }` | Validates `code` against the pending secret; sets `enabled=Y`; generates and returns `{ recoveryCodes: [...] }` **once** (stores only their bcrypt hashes) | -| `POST /admin/api/auth/2fa/disable` | `{ password }` | Re-verifies password via `verifyAdminPassword`; clears all three keys | - -Handler code lives in a new `web/admin_api_2fa.go` (mirrors the -`admin_api_*.go` per-domain convention). TOTP helpers (generate/validate/ -recovery-code generate+hash) live in a new `web/totp.go`. - -**Recovery codes:** generated as ~10 random codes (e.g. 10 hex/base32 chars, -formatted for readability), shown to the admin exactly once on enable, and -persisted only as bcrypt hashes. The UI must warn that they cannot be shown -again. - -## CLI recovery - -Add `case "DisableAdmin2FA"` to the switch in `cli.go` → -clears `admin_totp_enabled`, `admin_totp_secret`, and -`admin_totp_recovery_hash` via `Db_set_setting(... "")`. Documented usage: - -```bash -docker exec -it card ./app DisableAdmin2FA -``` - -Also add it to the CLI Commands list in CLAUDE.md. - -## Frontend (admin-ui React SPA) - -- **`hooks/use-auth.tsx`:** `login()` gains an optional `code` argument and - surfaces a `totpRequired` signal (e.g. a typed error or returned status) so - the login page can reveal the code field without losing the entered password. -- **`pages/login.tsx`:** when `totpRequired` is signalled, reveal a 6-digit - code input plus a "Use a recovery code instead" toggle, and resubmit - password + code. Follows existing shadcn `Input` / `Alert` / `Button` - patterns already in the file. -- **`pages/settings.tsx`:** new **Security** card — "Two-Factor - Authentication": - - Disabled state → "Enable" button → setup flow: show QR (`qrPng`) + the - manual base32 secret → enter a code → confirm → show recovery codes once - (with copy/download affordance + "save these now" warning). - - Enabled state → "codes remaining: N" + "Disable" button → confirm with - password. - - Backed by the four `/admin/api/auth/2fa/*` endpoints. - -## Testing - -Web-package unit tests using existing helpers (`openTestApp`, -`setupAdminSession`), generating live codes with `totp.GenerateCode(secret, -time.Now())`: - -- 2FA enabled + password only → `401 totpRequired`. -- 2FA enabled + valid TOTP code → session issued. -- 2FA enabled + invalid code → `401`. -- Recovery code logs in once, then is **consumed** (second use of the same - code fails). -- `setup` → `enable` → `status` reflects `enabled: true`; `disable` clears all - three keys and `status` returns `enabled: false`. -- Settings-list endpoint never exposes `admin_totp_secret` - (redaction assertion). - -## Versioning - -Bump `Version` in `docker/card/build/build.go` `0.19.8 → 0.20.0` (SemVer -minor: new backward-compatible feature) and update the matching `Version:` -references in CLAUDE.md and the project memory file. - -**Convention refinement** (to record in CLAUDE.md + memory): adopt semantic -versioning — feature PRs bump MINOR, bug-fix/maintenance PRs bump PATCH, -breaking changes bump MAJOR. This supersedes the prior "patch-bump every PR" -rule. - -## File-change checklist - -**Backend** -- `web/totp.go` (new) — generate/validate TOTP, generate + hash recovery codes. -- `web/admin_api_2fa.go` (new) — status / setup / enable / disable handlers. -- `web/admin_api.go` — route the four new endpoints; extend `adminApiLogin` - with `code` + TOTP/recovery verification. -- `web/admin_api_settings.go` — add `_secret` to the redaction suffix check. -- `cli.go` — `DisableAdmin2FA` command. -- `go.mod` / `go.sum` — add `github.com/pquerna/otp`. -- `build/build.go` — version bump. - -**Frontend** -- `admin-ui/src/hooks/use-auth.tsx` — `login(password, code?)` + `totpRequired`. -- `admin-ui/src/pages/login.tsx` — conditional code field + recovery toggle. -- `admin-ui/src/pages/settings.tsx` — Security / 2FA management card. - -**Docs** -- `CLAUDE.md` — settings keys, CLI command, versioning convention. -- Project memory file — new facts + versioning convention. - -**Tests** -- `web/admin_api_2fa_test.go` (new) — login + enrollment + recovery + redaction. diff --git a/docs/superpowers/specs/2026-06-17-about-release-notes-design.md b/docs/superpowers/specs/2026-06-17-about-release-notes-design.md deleted file mode 100644 index 0144ebb..0000000 --- a/docs/superpowers/specs/2026-06-17-about-release-notes-design.md +++ /dev/null @@ -1,158 +0,0 @@ -# About page: Recent Releases (release notes) — design - -Date: 2026-06-17 -Branch: `claude/about-release-notes` -Base: `origin/main` @ `0fcad68` (includes PR #40 per-commit version markers) - -## Problem - -The About page shows a "Recent Commits" list with a muted `vX.Y.Z` marker per -commit (PR #40). It reads like a raw, untidied changelog — merge-commit noise, -developer phrasing, and no grouping into releases. The user wants proper, -user-readable release notes grouped by release, scoped to the gap between the -hub's running version and the latest available version. - -## Goals - -1. Publish real **GitHub Releases** automatically when the app version is - bumped, with notes sourced from the commits in that release, tidied. -2. Replace the About page's "Recent Commits" card with **"Recent Releases"**, - sourced from the GitHub Releases API. -3. Show the release notes **between the running version and the latest - available version** (inclusive of both ends). When up to date, show only the - running version's notes. - -## Non-goals - -- No AI/LLM summarisation of notes (CI can't call a model). "Tidied" means - merge-commit noise stripped + version-bump lines dropped + bullet formatting. -- No backfilling of historical releases (the gap from v0.10.0 is handled by a - one-time capped "catch-up" release, not by reconstructing per-version tags). -- No new markdown dependency in the frontend. - -## Decisions (from brainstorming) - -- **Notes source:** auto-publish GitHub Releases in CI on version bump. -- **Tidying method:** commit subjects with merge commits stripped - (`git log --no-merges`), version-bump lines filtered. -- **Up-to-date display:** show only the current (running) version's notes. - -## Part 1 — CI: auto-publish a GitHub Release on version bump - -File: `.github/workflows/ci.yml`. Add a `release` job. - -- `needs: [docker]` — only release once images build/push succeeds. -- Gate: `if: github.event_name == 'push' && github.ref == 'refs/heads/main'`. -- **Job-level** `permissions: contents: write` (the workflow default stays - `contents: read` — least privilege preserved; only this job can tag/release). -- `actions/checkout@v4` with `fetch-depth: 0` so full history + tags are present. -- Steps (shell): - 1. Extract `V` from `docker/card/build/build.go` - (`grep -oP 'Version string = "\K[0-9]+\.[0-9]+\.[0-9]+'`). - 2. If tag `v$V` already exists (`git rev-parse -q --verify "refs/tags/v$V"`), - **skip the rest** (idempotent: re-runs and merges without a version bump - never create a duplicate release). - 3. Determine the previous release tag: - `PREV=$(git tag -l 'v*' --sort=-v:refname | head -n1)`. - - If empty (no tags at all), use the root commit as the range start. - 4. Build the notes body: - - `git log --no-merges --pretty=format:'%s' "$PREV"..HEAD` (or whole - history if no `PREV`). - - Drop lines that are pure version bumps - (grep -viE pattern: `^(bump|chore: bump).*version|^version bump|^bump to v?[0-9]`). - - Prefix each remaining subject with `- `. - - **Cap at 30 lines.** If more, keep the 30 most recent and append: - `` and `N` earlier commits`` plus a blank line and - `Full changelog: https://github.com/boltcard/hub/compare/$PREV...v$V`. - (No `PREV` → omit the compare line.) - 5. `gh release create "v$V" --title "v$V" --notes "$BODY"` - with `env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}`. - -Idempotency + the cap make the first ("catch-up") release readable rather than a -several-hundred-commit dump; every subsequent per-version release has a small, -clean span. - -## Part 2 — Backend: `/about/releases` replaces `/about/commits` - -Files: `docker/card/web/admin_api_about.go`, `docker/card/web/admin_api.go`. - -- Remove `adminApiCommits` and the per-commit version machinery it relied on: - `fetchCommitVersion`, `commitVersionMu`, `commitVersionCache`, - `parseVersionFromBuildGo`, `buildVersionRegex`. The N+1 GitHub contents fetch - is gone. (Keep `adminApiLogs`, `ansiToHTML`, `ansiColorMap`, `ansiRegex`.) -- Remove the `/admin/api/about/commits` route; add - `GET /admin/api/about/releases` → `adminApiAuth(app.adminApiReleases)`. - -`adminApiReleases`: - -- `R := build.Version`. `L := strings.TrimSpace(r.URL.Query().Get("latest"))` - — the frontend already has the latest version from `/about`, so it passes it - through; this avoids a second Docker Hub round-trip. `L` is public release - data used only to bound which already-public notes to show, so trusting the - client param has no security impact. -- One GET to `https://api.github.com/repos/boltcard/hub/releases?per_page=100` - (10s timeout, `Accept: application/vnd.github.v3+json`). Non-200 / error → - return `{"releases": []}`. -- Decode `[]{ tag_name, name, body, published_at, html_url }`. -- Compute the upper bound: - `upper := R; if L parses (3-part numeric) and CompareVersions(R, L) == 1 { upper = L }` - (`CompareVersions(current, latest)` returns 1 when `latest > current`). -- For each release: parse the version from `tag_name` (strip a leading `v`; - skip tags that aren't 3-part numeric). Include it when ver is in `[R, upper]`. - With `CompareVersions(a, b)` returning `1` when `b > a`: - - ver ≥ R ⇔ `CompareVersions(R, ver) >= 0` - - ver ≤ upper ⇔ `CompareVersions(ver, upper) >= 0` -- Sort the included releases descending by version. -- Respond `{"releases": [ { version, name, body, date, url, isCurrent } ]}` - where `isCurrent = (ver == R)`, `date = published_at`, `url = html_url`. - -Extract the pure decision — "given R, L and a list of release versions, which -versions are shown and in what order" — into a small testable helper -(e.g. `selectReleases(running, latest string, versions []string) []string`) -so the range logic is unit-tested without hitting the network. - -## Part 3 — Frontend: `about.tsx` - -File: `docker/card/admin-ui/src/pages/about.tsx`. - -- Replace the `Commit`/`CommitsData` types and the `about-commits` query with a - `Release`/`ReleasesData` type and an `about-releases` query: - `apiFetch(\`/about/releases?latest=${encodeURIComponent(data.latestVersion || "")}\`)`. - Gate the query on `data` being loaded (it needs `latestVersion`). -- Rename the card title **"Recent Commits" → "Recent Releases"**. -- Render each release as a block: - - Heading row: `v{version}` (mono) + formatted `date`; a `Current` badge when - `isCurrent`; the heading links to `url` (release page) in a new tab. - - Body: split on newlines; lines starting with `- ` become `
    • ` in a `
        `; - other non-empty lines become `

        `; bare URLs are linkified. Plain text via - React children (auto-escaped) — no `dangerouslySetInnerHTML`, no markdown lib. -- Empty state: if `releases.length === 0`, render a muted - "No release notes available." line (or hide the card body) instead of the list. - -## Part 4 — Version bump + docs - -- `docker/card/build/build.go`: `0.21.0` → `0.22.0` (MINOR — new feature). -- `CLAUDE.md`: update the `build/` bullet's "currently 0.21.0" reference to - `0.22.0`. Optionally note the new `release` CI job + `/about/releases` endpoint - in the relevant sections. - -## Testing - -- **Go:** table-driven test for `selectReleases` covering: - up-to-date (`L==R` → only R), behind (`R < L` → R..L inclusive, descending), - running version has no release in the list (fewer/none returned), - unparseable/empty `L` (falls back to only R), and tag strings with/without a - `v` prefix. Run `cd docker/card && go test -race -count=1 ./web/`. -- **Frontend:** build-checked only (no FE test runner) — `npm run build` in - `docker/card/admin-ui`. -- **CI release job:** not unit-tested (shell in the workflow); validated by - the idempotency guard (safe to re-run) and reviewed manually. - -## Edge cases - -- GitHub API down / rate-limited → empty releases list → empty state. -- Hub running a version older than the first published release → range yields - nothing → empty state (expected until releases accumulate). -- Docker Hub check failed (`L` empty) → treated as up-to-date → only R's notes. -- Tag `vX.Y.Z` exists but no matching version in build.go yet → handled by the - CI "skip if tag exists" guard.