diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82d98bf..6afc11b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,3 +83,66 @@ 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, 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]|^docs(\(|:)|^chore(\(|:)' || 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" 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/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/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( + , + ); + 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() && } +
+ )) + )}
)} - ); } 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 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 dd96acf..67dd665 100644 --- a/docker/card/web/admin_api_about.go +++ b/docker/card/web/admin_api_about.go @@ -2,14 +2,13 @@ package web import ( "card/build" - "encoding/base64" "encoding/json" "html" "io" "net/http" "regexp" + "sort" "strings" - "sync" log "github.com/sirupsen/logrus" ) @@ -76,141 +75,124 @@ 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, - }) - } - } + if resp.StatusCode != 200 { + writeJSON(w, empty) + return } - // 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) + 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"` } - wg.Wait() - - if commits == nil { - commits = []commit{} + if err := json.NewDecoder(resp.Body).Decode(&ghReleases); err != nil { + writeJSON(w, empty) + return } - 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] + 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) } - 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 "" + selected := selectReleases(running, latest, versions) + releases := make([]release, 0, len(selected)) + for _, v := range selected { + releases = append(releases, byVersion[v]) } - commitVersionMu.Lock() - if v, ok := commitVersionCache[sha]; ok { - commitVersionMu.Unlock() - return v - } - commitVersionMu.Unlock() + writeJSON(w, map[string]interface{}{"releases": releases}) +} - 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 "" - } - req.Header.Set("Accept", "application/vnd.github.v3+json") - resp, err := client.Do(req) - if err != nil { - return "" - } - defer resp.Body.Close() +var versionRegex = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`) - version := "" - if resp.StatusCode == 200 { - var payload struct { - Content string `json:"content"` - Encoding string `json:"encoding"` +// 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 } - 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)) - } + // running <= v <= upper + if CompareVersions(running, v) >= 0 && CompareVersions(v, upper) >= 0 { + out = append(out, v) } } - commitVersionMu.Lock() - commitVersionCache[sha] = version - commitVersionMu.Unlock() - return version + 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{ diff --git a/docker/card/web/admin_api_about_test.go b/docker/card/web/admin_api_about_test.go index 4a0500c..b4fa11f 100644 --- a/docker/card/web/admin_api_about_test.go +++ b/docker/card/web/admin_api_about_test.go @@ -2,38 +2,82 @@ package web import "testing" -func TestParseVersionFromBuildGo(t *testing.T) { +func TestSelectReleases(t *testing.T) { tests := []struct { - name string - content string - want string + name string + running string + latest string + versions []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: "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: "older multi-digit version", - content: "var Version string = \"0.2.10\"\n", - want: "0.2.10", + 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: "no version line", - content: "package build\n\nvar Date string\n", - want: "", + 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 content", - content: "", - want: "", + 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) { - if got := parseVersionFromBuildGo(tt.content); got != tt.want { - t.Errorf("parseVersionFromBuildGo() = %q, want %q", got, tt.want) + 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) + } } }) } 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/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.