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

@ -239,8 +239,18 @@ lb fix-issue-123 # cd ~/dev/lnbits/fix-issue-123
- [`docs/lnbits-workspace-notes.md`](docs/lnbits-workspace-notes.md) — - [`docs/lnbits-workspace-notes.md`](docs/lnbits-workspace-notes.md) —
practical gotchas and design constraints for day-to-day lnbits work: practical gotchas and design constraints for day-to-day lnbits work:
port choice, `LNBITS_SRC` build-context traps, the port choice, `LNBITS_SRC` build-context traps, the
extension-folder-upgrade-wipes-fork issue, Nostr key handling, extension-folder-upgrade-wipes-fork issue, settings precedence
CLINK protocol scope, fork versioning. (`.env` vs DB), Nostr key handling, CLINK protocol scope, fork
versioning.
- [`docs/lnbits-extension-dev.md`](docs/lnbits-extension-dev.md) —
building and maintaining LNbits extensions: auth-decorator
distinctions, FakeWallet vs regtest testing strategy, the
`migrations_fork.py` pattern for keeping fork-only schema deltas
out of upstream-tracked `migrations.py`.
- [`docs/lnbits-frontend-gotchas.md`](docs/lnbits-frontend-gotchas.md) —
Vue/Quasar UMD traps in lnbits page templates: no self-closing
tags, CSS specificity vs Quasar's `!important` utilities, cache
busting via `?v={server_startup_time}`, dark-mode color discipline.
## Contributing to this scaffold ## Contributing to this scaffold

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.

View file

@ -0,0 +1,105 @@
# lnbits frontend gotchas
LNbits ships its UI with **Vue 3 + Quasar 2 as UMD globals** — no
build step, plain Jinja templates with per-page JS. This applies
across lnbits core (`lnbits/templates/*.html`), every extension
(`<ext>/templates/`), and every fork that doesn't restructure the
frontend stack. The UMD load model has several traps that don't
manifest under the build-step model most Vue tutorials assume.
## No self-closing tags
Per [Quasar's UMD usage rules](https://quasar.dev/start/umd/#usage),
components must use the explicit-close form:
```html
<!-- correct -->
<q-input v-model="foo" label="Foo"></q-input>
<q-btn @click="bar" label="Bar"></q-btn>
<!-- wrong — silently broken in UMD/no-build mode -->
<q-input v-model="foo" label="Foo" />
<q-btn @click="bar" label="Bar" />
```
**Why:** UMD-loaded templates are parsed by the browser's HTML parser,
not Vue's compiler. The HTML parser doesn't honor self-close on
non-void elements (per the HTML spec). The close tag gets implied at
the wrong place, nesting breaks silently, and subsequent siblings end
up nested inside the prior component.
Self-closing is fine in `.vue` SFCs (the build step rewrites them
before the browser sees anything), so if you copy a snippet from a
Vue SFC repo into an LNbits template, **expand all self-closing tags
before saving**.
## CSS specificity trap
LNbits applies its own theme overrides on Quasar's typography
utilities (`.text-caption`, `.text-grey-*`, etc.) with `!important`.
Class-based CSS rules in an extension page — *even with `!important`*
lose this fight unless your selector is strictly more specific than
the upstream rule.
**Rule:** for per-element typography/color overrides on LNbits pages,
reach for Vue `:style` bindings (or static `style="..."` attrs), not
`<style>` blocks targeting Quasar utility classes:
```html
<!-- ✗ likely loses to upstream's !important rule -->
<style>
.text-caption.my-fix { color: #ff0000 !important; }
</style>
<!-- ✓ inline style wins without an arms race -->
<span :style="{ color: '#ff0000' }">…</span>
<span style="color: #ff0000">…</span>
```
Background/border tweaks at card-level via class are fine — the trap
is specifically the typography utilities (`text-*`) and Quasar's
color utilities.
## Cache busting
Static assets are served with `?v={server_startup_time}` appended
(see `static_url_for` in `lnbits/helpers.py`). Consequences:
- **Bumping JS requires a server restart.** Reloading the browser
doesn't help if `?v=` hasn't changed — the browser keeps serving
the cached file.
- **Jinja templates re-render on every request** (the `?v=` is only
on static assets). No restart needed for template edits — just
refresh.
If a browser keeps serving stale JS after a restart, hard-refresh
(`Ctrl+Shift+R`) to bypass the HTTP cache.
## Dark-mode color discipline
LNbits's dark theme inverts text colors on most surfaces but **not**
on `bg-{color}-1` pale-background utilities. Result: a `bg-red-1`
without an explicit text color renders white-on-cream under dark
theme — basically invisible.
**Rule:** pair every pale-background utility with an explicit dark
text class:
```html
<!-- ✗ unreadable on dark theme -->
<div class="bg-red-1 q-pa-md">Warning</div>
<!-- ✓ explicit text class survives theme switch -->
<div class="bg-red-1 text-grey-9 q-pa-md">Warning</div>
```
Same for `bg-green-1`, `bg-blue-1`, `bg-amber-1`, etc. The `text-grey-9`
choice is the safe default; pick a darker shade if you want stronger
contrast.
## When in doubt
Test under both light and dark themes (Quasar's theme toggle is at
the top of every LNbits page once you're logged in). Most of the
above gotchas are silent under one theme and obvious under the other
— don't ship UI without flipping the toggle at least once.