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:
parent
0a5713c704
commit
1a75a04c2f
3 changed files with 260 additions and 2 deletions
14
README.md
14
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
143
docs/lnbits-extension-dev.md
Normal file
143
docs/lnbits-extension-dev.md
Normal 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.
|
||||||
105
docs/lnbits-frontend-gotchas.md
Normal file
105
docs/lnbits-frontend-gotchas.md
Normal 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.
|
||||||
Reference in a new issue