libra/docs/CODE-REVIEW-2026-06.md
Padreug ec6cac51f0 chore: hygiene sweep — dead code, role race, user lookup, stale files
One pass over the LOW-tier review items plus two folded issues:

- Delete validate_journal_entry (dead since the Fava migration; it
  validated the pre-string-amount model) with its exports, unused
  crud imports, and tests. Beancount validates entries now.
- Migration m006: UNIQUE index on user_roles(user_id, role_id) after
  deduping; assign_user_role inserts with ON CONFLICT DO NOTHING and
  returns the existing assignment — closes the auto-assign
  check-then-act race on concurrent logins.
- Extract _get_username_from_user_id (110 lines in views_api, fresh
  LNbits Database per call inside per-row hot paths) into
  user_lookup.py with one shared core-DB handle, a 60s TTL cache and
  a batch get_usernames API (review #18).
- Receivable-entry responses report CLEARED, matching the flag the
  formatter actually writes; PENDING misled the UI (libra-#35).
- Replace the remaining print() calls in tasks.py with logger.
- get_all_accounts derives valid roots from
  account_utils.ACCOUNT_TYPE_ROOTS instead of a hardcoded tuple, and
  the no-op per-test rate-limit reset is gone (libra-#54).
- Delete migrations_old.py.bak, MIGRATION_SQUASH_SUMMARY.md,
  docs/PHASE*_COMPLETE.md and the rendered .html; .gitignore data/
  (it holds the runtime .lnbits_auth_key secret).
- Track docs/CODE-REVIEW-2026-06.md with finding statuses updated for
  the PR #55-#59 + chore/hygiene series.
- CLAUDE.md notes LNbits pins Pydantic v1: keep .dict(), don't
  "modernize" to .model_dump().

Note: format_payment_entry's is_payable docstring (flagged in review
follow-up) turned out to be consistent with the body — no change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 16:01:34 +02:00

260 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Code review — 2026-06-05
Findings from a deep review of the Libra LNbits extension (12k LOC,
14 files). Each finding has `file:line` references, a one-line fix
proposal, and a status tag:
-**fixed** — merged in commit listed
-**outstanding** — still needs work
- 🚫 **downgraded** — initially flagged, verified not a bug on closer read
Triage order at the bottom prioritises blast radius over file location.
> **2026-07-12 refactor series:** findings #2#19 fixed across PRs
> #55#59 + the chore/hygiene branch (stacked; merge in order). LOW
> items fixed in chore/hygiene except `parse_legacy_account_name`
> fragility (documented assumption, internal input only) and the
> `is_active`/`is_virtual` filter inconsistency (still open).
---
## CRITICAL
### ✅ #1 — Mass `require_admin_key` mis-use → cross-user privilege escalation
**Status:** fixed in `1201557` (`aiolabs/libra` main, 2026-06-05) +
`4c704e5` (`aiolabs/webapp` dev).
27 endpoints documented "(admin only)" used `require_admin_key`, which
only checks the caller owns *some* wallet with its admin key — i.e.
any authenticated user. Cluster included `receivable`/`revenue`
creation, equity-eligibility grant/revoke, account-permission CRUD
(grant yourself MANAGE on any account → ledger god mode), role and
user-role CRUD, account-sync admin, cross-user reports.
Also deleted the duplicate `api_pay_user` at `views_api.py:1937` (the
correctly-gated `/api/v1/payables/pay` at L2144 replaces it).
Webapp side: deleted orphaned `PermissionManager.vue` +
`GrantPermissionDialog.vue` admin components that were never imported
or routed and whose backing API methods pointed at non-existent paths.
### ✅ #2 — `format_net_settlement_entry` ships unbalanced postings on partial payments
`beancount_format.py:761-777` emits three postings whose weights sum to
`net_fiat total_receivable + total_payable`. The docstring example
assumes `net_fiat == total_receivable total_payable`. But
`tasks.py:251-258` sets `total_receivable = total_prior_balance` and
`net_fiat = invoice_fiat_amount` — only equal when the user paid the
full balance. Any partial payment ships unbalanced postings; Beancount
will reject or apply tolerance silently.
**Fix:** add `assert abs(net_fiat (receivable payable)) <= 0.005`
inside the formatter and raise on violation. Then fix the caller in
`tasks.py:218-322` to settle only what the payment covers (likely a
two-posting `DR Lightning / CR Receivable`-for-payment-amount, not
net-settlement).
### ✅ #3 — Migrations not idempotent (violates fork-migrations contract)
`migrations.py:347, 377` (`ALTER TABLE accounts ADD COLUMN
is_active/is_virtual`) and `migrations.py:441, 472, 510` (`CREATE TABLE
roles/role_permissions/user_roles`) lack idempotency guards. Seed
`INSERT`s at `migrations.py:319-330, 400-412, 587-597` have no `ON
CONFLICT DO NOTHING`. Per CLAUDE.md, the cross-DB write between
`ext_libra` and core `dbversions` is non-atomic — a failed version-bump
leaves the migration to re-run on boot and crash with `duplicate
column` / `table exists` / UNIQUE violation. Bricks the extension until
manual `dbversions` surgery.
**Fix:** wrap ALTERs with `_alter_add_column_safe`, switch CREATEs to
`CREATE TABLE IF NOT EXISTS`, gate seed INSERTs with `INSERT ... ON
CONFLICT DO NOTHING`.
### ✅ #4 — Lightning payment recording has no local idempotency gate
`tasks.py:218-322` relies entirely on `fava.add_entry_idempotent` for
dedup, which itself does a read-then-write race on the Fava ledger. On
lnbits restart with a persisted invoice queue, the same `payment_hash`
can re-fire; the per-user lock is in-process only and doesn't survive
restart. Webhook + poller hitting concurrently both pass the
"not present" check and both insert.
**Fix:** add a `processed_payments(payment_hash TEXT PRIMARY KEY)`
table; `INSERT OR IGNORE` at the top of `on_invoice_paid`; only
proceed if `rowcount == 1`.
---
## HIGH
### ✅ #5 — Auth prefix-match in `can_access_user_data`
`auth.py:248-251` uses `caller.user_id[:8] == target.user_id[:8]`.
Eight hex chars = 32 bits; birthday collision at ~65k users.
**Fix:** require full UUID equality; never resolve by prefix in an
authorisation decision.
### ✅ #6 — `can_access_account` substring match
`auth.py:178-180` does `f"User-{short}" in account.name` — matches
`Expenses:Misc-User-deadbeef` too.
**Fix:** split on `:`, require segment equality.
### ✅ #7 — `ChecksumConflictError` never raised by update/delete
`fava_client.py:1392-1482`: Fava 409/412 propagates as raw
`HTTPStatusError`. The `ChecksumConflictError` type exists but isn't
raised by these methods; callers see stack-trace 500s instead of a
clean retry path.
**Fix:** `if status in (409, 412): raise ChecksumConflictError(...)`.
### ✅ #8 — `float()` arithmetic in fiat-rate metadata
`views_api.py:1061-1062, 1263-1264, 1364-1365, 1759-1760` compute
`fiat_rate` / `btc_rate` via `float()`, then persist into Beancount
metadata as the cost basis for that entry. Float drift cascades
through reporting.
**Fix:** keep `Decimal` end-to-end; only stringify at JSON-serialise
time.
### ✅ #9 — Background loop swallows `raise` in `on_invoice_paid`
`tasks.py:320-322` does `logger.error(...); raise`.
`wait_for_paid_invoices` at `tasks.py:178-180` has no surrounding
try/except, so one unhandled exception kills the listener for the
rest of the process lifetime — no further Lightning payments get
recorded, no alarm.
**Fix:** wrap the iteration body in
`try/except Exception: logger.exception(...)`; never `raise` from
`on_invoice_paid`.
### ✅ #10 — `record-payment` dedup is non-atomic AND exception-swallowing
`views_api.py:1841-1924` (per subagent report — needs verification)
catches all exceptions in the dedup window with a 5-second timeout
and treats Fava errors as "not duplicate", producing double-entries
on transient Fava blips.
**Fix:** fail closed on transport error; narrow the exception type
catch to `httpx.HTTPError` only.
### ✅ #11 — `validate_journal_entry` is stale
`core/validation.py:21-93` validates the pre-string-amount model —
sums one bag of integers, doesn't balance per currency. Doesn't match
production data shape post-Fava migration.
**Fix:** rewrite to parse `"X CCY"` strings and balance per currency,
or delete if Beancount-side validation is now considered sufficient.
---
## MEDIUM
### ✅ #12 — `m001_initial` seed `INSERT` into `accounts` non-idempotent
`migrations.py:319-330` — same shape as #3.
### ✅ #13 — `format_posting_at_average_cost` emits `{}` when `cost_currency=None`
`beancount_format.py:256``<sats> SATS {}` isn't valid Beancount
syntax; drop the braces when cost is unset.
### ✅ #14 — Per-call `httpx.AsyncClient` instantiation in fava_client
~30 sites build a new `httpx.AsyncClient` per call. TCP handshake every
time.
**Fix:** construct once on `FavaClient.__init__`, expose `aclose()`.
### ✅ #15 — BQL string interpolation without quoting
`fava_client.py:250, 621, 770-775, 853-858` interpolate
`account_name` / user-id-prefix raw into BQL `WHERE account = '{...}'`.
The 8-char hex prefix is safe in practice; arbitrary `account_name`
input is not.
**Fix:** validate against `^[A-Za-z0-9:_-]+$` before interpolation.
### ✅ #16 — `approve_manual_payment_request` not status-guarded
`crud.py:559-579` overwrites `status='approved'` regardless of current
state. Two concurrent admins → two journal entries.
**Fix:** `UPDATE ... WHERE id=:id AND status='pending'`, check
`rowcount == 1`.
### ✅ #17 — Account name not validated on receivable/revenue/expense
`views_api.py:1066-1074, 1224-1236, 1369-1376, 1477-1494` accept
free-string `data.expense_account` (etc.) with no Beancount-syntax
check before lookup.
**Fix:** enforce `^[A-Z][A-Za-z0-9:-]*$`.
### ✅ #18 — `_get_username_from_user_id` creates a fresh LNbits DB per call
`views_api.py:697-708` opens an LNbits DB inside a per-row hot path.
**Fix:** cache the Database instance at module load; batch-load
usernames once per request via single `IN (…)` query.
### ✅ #19 — `get_user_balance` regex rejects decimal SATS
`fava_client.py:346, 503` patterns require `(-?\d+)` SATS. Fava's
`@@→@` normalisation can emit decimal SATS.
**Fix:** `(-?[\d.]+)`.
### ⏳ #20 — `fava_url` default `http://localhost:3333` and sandboxed lnbits
Loopback breaks if the lnbits service unit gains `PrivateNetwork=true`.
Not a code bug — worth documenting in deploy assumptions.
---
## LOW
-`tasks.py` mixes `print()` with `logger.*` (`:61, 65-69, 81, 89,
94, 162`).
- ⏳ Dead model imports in `crud.py:21-27` (`JournalEntry`,
`EntryLine`) after `entry_lines` table dropped.
- ⏳ `auto_assign_default_role` check-then-act race
(`crud.py:1609-1641`) — add UNIQUE constraint on
`user_roles(user_id, role_id)`.
- ⏳ Pydantic v1 `.dict()` calls (`crud.py:381, 400, 411, 450`) if
upstream is on v2.
- ⏳ `account_utils.parse_legacy_account_name` splits on ` - ` —
fragile if ever called on user input.
- ⏳ `Account.is_active` vs `is_virtual` default-filter inconsistency
hides virtual parents in permission-grant UI (`crud.py:146-167`).
---
## 🚫 Downgraded (initially flagged, verified not a bug)
### Subagent A — "inverted balance sign in tasks.py:251-258"
`fava_client.py:303` docstring says positive = user owes libra
(bookkeeper perspective). `tasks.py:249-250` matches. CLAUDE.md
describes the *user's* perspective (positive = libra owes user), which
is consistent at the UI layer. Both representations are internally
coherent — no bug, just a doc-vs-code perspective collision.
### Subagent A — "fava_ledger_slug default doesn't match deploy"
Verified empirically by user: Fava in single-ledger mode appears to
accept arbitrary slugs against the JSON API, and the deploy's seed
title `"Libra Ledger"` → slugify → `libra-ledger` matches the
extension default in `models.py:159` anyway. False alarm.
---
## Triage order (when picking the next item)
1. **#2 (unbalanced net settlement) + #4 (idempotency)** — silently
corrupts the ledger on every partial Lightning payment + every
restart with a persisted invoice queue. Real-money blast radius.
2. **#3 (migrations) + #12** — guaranteed boot crash on the documented
failure mode; bricks the extension.
3. **#8 (float in fiat metadata)** — every entry written today carries
float-drift cost basis into Beancount.
4. **#9 (silent listener death)** — operational; the kind of bug
discovered when nobody can pay for a week.
5. **#5, #6 (auth narrowing)** — residual privilege risk; smaller blast
than #1 (already fixed) but worth closing.
6. Everything else, in arbitrary order; mostly hygiene.
---
## Commits applied
| Commit | Repo / branch | What |
|---|---|---|
| `1201557` | `aiolabs/libra` `main` | Gate cross-user admin endpoints behind `require_super_user`; delete duplicate `api_pay_user` |
| `4c704e5` | `aiolabs/webapp` `dev` | Delete orphaned `PermissionManager.vue` + `GrantPermissionDialog.vue` + 3 API methods + 4 dead types |