This repository has been archived on 2026-06-22. You can view files and clone it, but you cannot make any changes to its state, such as pushing and creating new issues, pull requests or comments.
lnbits-sensei/docs/lnbits-extension-dev.md
Padreug 1a75a04c2f 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>
2026-05-26 00:57:08 +02:00

6.4 KiB
Raw Permalink Blame History

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_keycheck_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 m007m011). 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:

-- 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:

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.