docs: add lnbits frontend gotchas + extension-dev reference

Two new docs to round out the lnbits-specific reference set:

docs/lnbits-frontend-gotchas.md — the Vue/Quasar UMD traps that don't
manifest under the build-step model most tutorials assume. Sections:

- No self-closing tags. UMD-loaded templates are parsed by the
  browser's HTML parser, which doesn't honor self-close on non-void
  elements; nesting silently breaks. Fine in .vue SFCs because the
  build step rewrites them — but lnbits templates aren't SFCs.
- CSS specificity vs Quasar utilities. LNbits applies !important
  overrides on .text-caption / .text-grey-*; class-based rules in
  extension pages lose. Reach for inline :style bindings or static
  style="" attrs instead.
- Cache busting via ?v={server_startup_time}. Bumping JS requires a
  server restart; templates re-render every request.
- Dark-mode color discipline. bg-{color}-1 utilities don't get a
  default text-inversion in dark theme; pair every pale background
  with an explicit text class.

docs/lnbits-extension-dev.md — auth + testing + migration pattern.
Sections:

- Auth decorators table: require_invoice_key, require_admin_key,
  check_admin, check_super_user. The require_admin_key vs
  check_admin distinction is the most common misuse — one is
  wallet-level write access (any user), the other is instance admin.
- Testing: FakeWallet for CRUD/API/UI, regtest only for real
  Lightning behavior. Why the dev CLI defaults to --fakewallet.
- The migrations_fork.py pattern: keep migrations.py byte-identical
  to upstream, put fork-only schema deltas in a sibling file loaded
  under <ext>_fork in dbversions. Covers: architecture facts
  (dbversions in core DB, no cross-DB atomicity → idempotent
  migrations mandatory), squash recipe for adoption, one-time
  dbversions surgery for installs that previously ran old fork
  migrations, the upstream-overlap rebase scenario.

Both linked from README "Further reading". No personal identity in
either file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-26 00:57:08 +02:00
commit 1a75a04c2f
3 changed files with 260 additions and 2 deletions

View file

@ -0,0 +1,143 @@
# lnbits extension development
Reference for building and maintaining LNbits extensions — the parts
that catch first-time extension authors and the patterns worth
adopting once you maintain a fork-modified extension.
## Auth decorators
Easy to confuse. Different scopes:
| Decorator | Auth scope | Returns | Use for |
|---|---|---|---|
| `require_invoice_key` | Wallet invoice key — read access | `Wallet` | Read endpoints (balance, payment history) callable with a user's invoice key |
| `require_admin_key` | Wallet admin key — write to **own wallet only** | `Wallet` | Endpoints that create payments / modify wallet state |
| `check_admin` | **LNbits instance admin** (super_user + `lnbits_admin_users`), Bearer token | `Account` | Cross-user / admin-only operations |
| `check_super_user` | LNbits super user only, Bearer token | `Account` | Operations restricted to the single super_user |
**`require_admin_key``check_admin`.** This is the most common
misuse. `require_admin_key` is a *wallet*-level write key — every
user has one for each of their own wallets. `check_admin` is
*instance* admin access. Endpoints that operate on other users' data
or change global settings need `check_admin`, not `require_admin_key`.
## Testing
Use **FakeWallet**
(`LNBITS_BACKEND_WALLET_CLASS=FakeWallet`) for testing extension CRUD,
API endpoints, and UI changes. Spin up the full regtest stack only
when end-to-end Lightning payment behavior is actually under test.
**Why:** regtest takes time to start (docker build + multiple LND/CLN
containers), uses real resources, and adds no fidelity for non-payment
flows. FakeWallet makes payments succeed instantly with no network —
ideal for testing the surrounding logic.
If you ship a `dev` CLI (see lnbits-sensei's pattern), keep
`--fakewallet` as the default mode for this reason.
## Fork-migrations pattern (`migrations_fork.py`)
Long-lived forks need to add schema columns and tables on top of
upstream's. The naive approach — append fork-only migration functions
to `migrations.py` — guarantees merge conflicts on every upstream
rebase. The pattern below sidesteps that by keeping `migrations.py`
byte-identical to upstream and putting fork deltas in a sibling file.
> **Note:** this pattern requires a patch to LNbits core's
> `migrate_extension_database()` that loads `migrations_fork.py` under
> a `<ext.id>_fork` key in the `dbversions` table. As of 2026 that
> patch is fork-internal (an upstream PR is the natural follow-up).
> If you maintain a fork that ships this patch, the rest of this
> section is the user-facing pattern.
### Architecture facts
- `dbversions` lives in the **core LNbits DB** (`database.sqlite3`),
not per-extension. Schema: `(db TEXT PRIMARY KEY, version INT)`.
`update_migration_version` is an INSERT-OR-UPDATE, so a new
`<ext>_fork` row appears on first run with no schema migration
needed core-side.
- Extension data tables live in `ext_<id>.sqlite3` (SQLite) or a
Postgres schema named after `<id>`. Created lazily on first
`Database.connect()` via `ATTACH` (SQLite) or `CREATE SCHEMA`
(Postgres).
- The version-bump connection is routed based on `db.schema`: `None`
means a core migration (same connection), set means an extension
(opens a fresh `core_db.connect()` to write `dbversions`). See
`lnbits/core/helpers.py:run_migration`.
- **No cross-DB atomicity.** Extension migration writes commit to
`ext_<id>.sqlite3`; the `dbversions` upsert commits to
`database.sqlite3`. If the extension write succeeds and the
dbversions write fails, the migration is orphaned — re-runs on
next startup. **Every fork migration MUST be idempotent** (use
an `_alter_add_column_safe` wrapper that swallows
duplicate-column errors, `CREATE TABLE IF NOT EXISTS`, etc.).
Self-healing covers the orphan case.
### Squash recipe (adopting the pattern on an existing fork)
If your fork has accumulated fork-only migrations interleaved into
`migrations.py`:
1. **Restore `migrations.py` to upstream-byte-identical content.**
Drop all fork-only `m{NNN}_*` functions and any helper added only
for them.
2. **Create `migrations_fork.py`** with a SINGLE
`m001_<your-tag>_<ext>_schema` function that idempotently applies
every fork-only schema delta the old migrations used to do. One
readable file forever.
3. The squash uses `_alter_add_column_safe` per ALTER and
`CREATE TABLE IF NOT EXISTS` per table — no-ops cleanly on
installs that already ran the old fork migrations.
### One-time fix on existing installs adopting the pattern
Installs that previously ran the old fork migrations have their
`dbversions['<ext>']` row ahead of upstream (e.g. `events|11` after
fork-only `m007``m011`). After moving to `migrations_fork`, the
next upstream rebase that adds e.g. `m007_add_allow_fiat` would
compare `7 > 11 → false` and **silently skip** the new upstream
migration.
**Reset the row to match upstream's actual migration count** before
the rebase lands:
```sql
-- Run against the CORE DB (database.sqlite3), not the extension DB.
UPDATE dbversions SET version = <upstream-max> WHERE db = '<ext>';
```
For containerized dev where the file is root-owned inside the
container:
```bash
docker compose exec lnbits python3 -c "import sqlite3; \
c = sqlite3.connect('database.sqlite3'); \
c.execute(\"UPDATE dbversions SET version = <N> WHERE db = '<ext>'\"); \
c.commit()"
```
### Upstream-overlap scenario at rebase time
If upstream eventually adds a schema change you already carry in
`migrations_fork.py` (e.g. they add the same `ALTER TABLE … ADD COLUMN
bar` you shipped), the next rebase creates a problem: fresh installs
work (your `_alter_add_column_safe` guards swallow the dup), but
**existing installs crash** when upstream's now-redundant migration
runs without an idempotency guard (upstream rarely uses them).
Mitigation at rebase time:
1. **Prune the redundant block from `migrations_fork.py`** so future
fresh installs get the column from upstream's migration.
2. **Pre-deploy `dbversions` surgery on every affected install**:
`UPDATE dbversions SET version = <upstream-max> WHERE db = '<ext>'`
so the loader skips the now-overlapping upstream migration.
Do both — (1) keeps future fresh installs clean, (2) keeps existing
installs alive through the deploy.
**Don't** patch upstream's migration with idempotency guards in your
fork. That breaks the "migrations.py == upstream byte-identical"
property and reintroduces every-rebase conflicts.