Compare commits

...

5 commits

Author SHA1 Message Date
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
0a5713c704 feat(claude): seed curated CLAUDE.md files into ~/dev/ workspace
Adds the omnixy-pattern out-of-store-symlink wiring so consumers can
opt into Claude Code orientation without copying anything by hand.
Files live in lnbits-sensei (so they evolve via PR), symlinks point
back at the consumer's checkout (so edits take effect on the next
Claude session without a nixos-rebuild).

New files under files/:

- dev-CLAUDE.md — generic workspace orientation. Workspace layout,
  quick command set, pointers to docs/. Opt-in (clobbers an existing
  ~/dev/CLAUDE.md, so off by default).
- lnbits-CLAUDE.md — per-project orientation for the lnbits worktree
  subtree. Inline summary of the four most-stepped-on gotchas
  (settings precedence, Quasar UMD self-closing rule, auth-decorator
  distinctions, upstream PR target = `dev` not `main`) plus pointers
  to the docs/ for full reference.

New options under lnbits-sensei.devEnv:

- scaffoldPath — absolute path to the consumer's lnbits-sensei
  checkout. types.str (not types.path) intentionally: types.path
  would copy the file into the Nix store and defeat
  mkOutOfStoreSymlink. Required when any claude.* flag is on.
- claude.enable — seeds ~/dev/lnbits/CLAUDE.md.
- claude.workspaceOrientation — additionally seeds ~/dev/CLAUDE.md.

Wiring lives in home.nix (gated via `osConfig.lnbits-sensei.devEnv.*`)
rather than the dev-env NixOS module, since the file destinations are
under home-manager's purview and the home-manager scope is where
`mkOutOfStoreSymlink` is in scope.

`nix flake check` stays green — `optionalAttrs` is lazy, so
`scaffoldPath` isn't accessed when claude.{enable,workspaceOrientation}
are both false (their defaults).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 00:54:55 +02:00
35ef01fd70 docs(workspace-notes): add settings precedence (.env vs DB)
LNbits has two sources of truth for settings depending on lifecycle.
The trap: on first boot, .env seeds the DB; on every subsequent boot,
the DB row overwrites the in-memory Settings. Editing .env after the
first boot is a no-op for editable fields — they can only change via
the Admin UI or by clearing rows in system_settings.

Documented:
- the boot-time read-DB → seed-from-env → overwrite-cached-Settings
  sequence with file:line references for verification
- exceptions where env still wins (super_user, lnbits_admin_ui=False,
  the full ReadOnlySettings field list)
- LNBITS_FIRST_INSTALL_TOKEN rotation does NOT reset settings to env
  (it's an admin-recovery escape hatch, not a config-refresh)
- the deploy-side implication: declarative env vars are a *seed*
  for EditableSettings, authoritative for ReadOnlySettings — plan
  your deploy story accordingly

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:32:48 +02:00
6c9f76bd70 docs: surface lnbits workspace gotchas as a committed reference
New docs/lnbits-workspace-notes.md collects the day-to-day gotchas
that have repeatedly surprised people doing lnbits dev work — content
that lives in personal CLAUDE.md / memory notes elsewhere but is
genuinely useful as a public reference once stripped of identity.

Sections:
- pick a non-default port (5000 collides with macOS AirPlay etc.)
- LNBITS_SRC + docker-compose build context (commits don't reach
  the dev image if the build context points at a stale worktree;
  `docker compose config | grep -A2 lnbits` to verify)
- extension folder mounted as install target: clicking "Upgrade"
  in the LNbits UI extracts the catalog tarball over the mounted
  fork checkout, wiping .git
- Nostr key handling: upstream stores user nsecs plaintext in
  accounts.prvkey; signer-abstraction shape (LocalSigner with
  envelope-encrypted blob / RemoteBunkerSigner via NIP-46 /
  ClientSideOnlySigner) with the "prefer don't-store-the-key"
  principle
- CLINK protocol scope: Shocknet's 21001-21003 event kinds are
  Lightning.Pub-specific; LNbits has zero CLINK knowledge; how
  to think about cross-system designs without conflating them
- fork versioning: v<upstream>-<tag>.<N> + the PEP 440
  `+<tag>.<N>` package-version form for pyproject.toml
- codebase reading tips (key paths, --first-parent log reading)

Linked from README "Further reading". No personal identity in the
new doc — scrubbed of aiolabs/atitlan/bohm/etc references.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:24:28 +02:00
30fbd0cef9 docs: add upstream-PR workflow + lnbits branch-model reference
Two new docs under docs/:

- upstream-prs.md — how to send a PR upstream using the
  ~/dev/upstream-prs/ worktree flow. Opens with a "Primer" section
  for anyone new to forks / pull requests (the three remote roles —
  upstream / github-fork / origin — explained as a table), then the
  per-project prerequisites, the prb/prc helper-driven workflow, and
  a "Common pitfalls" section.

- lnbits-upstream-flow.md — reference for how lnbits/lnbits actually
  moves: the dev/main branch split, the squash-merge PR convention,
  the non-FF release merge from dev into main, and how to read the
  history with --first-parent. Adapted from internal aiolabs notes
  with the fork-versioning specifics stripped; closing section is
  generic guidance for anyone maintaining their own long-lived fork.

README links both from a new "Further reading" section. The prior
"Contributing" section is retitled "Contributing to this scaffold"
to avoid colliding with the new upstream-PR doc on the term.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 20:07:03 +02:00
10 changed files with 1026 additions and 1 deletions

View file

@ -114,6 +114,40 @@ in follow-up passes.
The `dev` CLI is the single entry point. See the top of this README The `dev` CLI is the single entry point. See the top of this README
for the verb set. for the verb set.
### Claude orientation seeding (optional)
If you use [Claude Code](https://docs.anthropic.com/en/docs/claude-code),
opt in to having curated CLAUDE.md files seeded into your workspace.
The files live under `files/` in this repo and are symlinked into
`~/dev/` via home-manager's `mkOutOfStoreSymlink` — edits in your
checkout take effect on the next Claude session, no rebuild.
Wire it in `settings.nix`:
```nix
devEnv = {
enable = true;
scaffoldPath = "/home/<you>/dev/lnbits-sensei"; # absolute path to your checkout
claude = {
enable = true; # ~/dev/lnbits/CLAUDE.md
workspaceOrientation = false; # also ~/dev/CLAUDE.md (opt-in;
# clobbers any existing one)
};
};
```
- `claude.enable` seeds `~/dev/lnbits/CLAUDE.md` from
`files/lnbits-CLAUDE.md` — project-level orientation that loads for
any Claude session in the lnbits worktree subtree (settings
precedence, Quasar UMD self-closing-tag rule, auth-decorator
distinctions, …).
- `claude.workspaceOrientation` *additionally* seeds `~/dev/CLAUDE.md`
from `files/dev-CLAUDE.md` — generic workspace orientation. Off by
default because most people already maintain their own.
If you don't use Claude Code, leave both off — the same content is in
`docs/` for human reading.
### Workspace layout (the `~/dev/` tree) ### Workspace layout (the `~/dev/` tree)
The dev-env module's job is to put every project, every worktree, and The dev-env module's job is to put every project, every worktree, and
@ -192,7 +226,33 @@ lb dev # cd ~/dev/lnbits/dev
lb fix-issue-123 # cd ~/dev/lnbits/fix-issue-123 lb fix-issue-123 # cd ~/dev/lnbits/fix-issue-123
``` ```
## Contributing ## Further reading
- [`docs/remotes.md`](docs/remotes.md) — the three remote-topology
patterns (upstream-only / github-fork / multi-remote).
- [`docs/upstream-prs.md`](docs/upstream-prs.md) — how to send a PR
upstream using the `~/dev/upstream-prs/` worktree flow. Includes
a primer for anyone new to forks / pull requests.
- [`docs/lnbits-upstream-flow.md`](docs/lnbits-upstream-flow.md) —
reference for how `lnbits/lnbits` itself moves: `dev` / `main`
branch model, squash-merge convention, release tagging.
- [`docs/lnbits-workspace-notes.md`](docs/lnbits-workspace-notes.md) —
practical gotchas and design constraints for day-to-day lnbits work:
port choice, `LNBITS_SRC` build-context traps, the
extension-folder-upgrade-wipes-fork issue, settings precedence
(`.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
Iteration happens under the sandboxed-claude policy from omnixy's Iteration happens under the sandboxed-claude policy from omnixy's
`scripts/sandbox-claude.sh` — a per-launch `.claude/settings.json` `scripts/sandbox-claude.sh` — a per-launch `.claude/settings.json`

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.

View file

@ -0,0 +1,128 @@
# `lnbits/lnbits` — Upstream Development Flow
Reference for [`github.com/lnbits/lnbits`](https://github.com/lnbits/lnbits).
Verified against git history on 2026-05-18; bump the date when you
re-verify, and patch this doc if the model has drifted.
## Branch model
Two long-lived branches:
| Branch | Role | How it moves |
|---|---|---|
| `dev` | Integration / staging | One squash-merge commit per PR. Linear. |
| `main` | Release | A non-fast-forward merge of `dev` at each release. |
`main` never receives feature PRs directly. **Every change reaches
`main` through `dev`.**
## PR flow → `dev`
1. Contributor opens a PR targeting `dev` (not `main`).
2. Maintainer squash-merges via the GitHub UI.
3. The resulting commit on `dev` has:
- exactly one parent (linear history),
- subject ending in `(#NNNN)` — the squash-merge signature,
- lowercase conventional-commit prefix: `feat:`, `fix:`, `chore:`,
`chore(deps):`, `docs:`, `ci:`, `test:`, `refactor:`.
4. CI runs on the PR; nothing else is required between merge and the
commit appearing on `dev`.
Example chain on `dev`:
```
c9c68bd8 Fix: Use default reaction on bootstrap (#3965)
810a1372 fix: tighten agents file (#3966)
36d696b2 Fix: wrong use of `in` operator (#3960)
8b426efa test: add pyinstrument profiler (#3955)
```
Each is a single squashed commit. No merge commits inside `dev`.
## Release flow → `main`
When `dev` is ready to ship:
1. A release-candidate version bump lands on `dev` as a normal PR:
`chore: update to version vX.Y.Z-rcN (#NNNN)`.
2. Validation happens against the RC.
3. A final version-bump PR lands on `dev`:
`chore: update to version vX.Y.Z (#NNNN)`.
4. A maintainer runs the release merge locally:
```bash
git checkout main
git pull --ff-only origin main
git merge dev # true (non-FF) merge, default message
git push origin main
git tag vX.Y.Z # tag the merge commit or the bump commit
git push origin vX.Y.Z
```
5. The resulting merge commit on `main` has:
- **two parents** (prior `main` tip + `dev` tip),
- subject exactly `Merge branch 'dev'` (git default when on `main`),
- **not** authored via the GitHub PR-merge UI (that would produce
`Merge pull request #N from …`).
Because `main` typically carries a stray release-bump commit that isn't
on `dev`, the histories have diverged and git is forced into a true
merge. `--no-ff` is not needed for that reason.
## Reading the history
```bash
# Release log (one entry per release):
git log main --first-parent --oneline
# Full changelog leading into the next release:
git log dev --oneline
# Verify a merge is a true non-FF merge:
git log <sha> -1 --format='%P' # two parent hashes = true merge
```
## Diagram
```
(PRs squash-merged one at a time)
dev: ── A ── B ── C ── D ── E ── F (rc1) ── G ── H (v1.5.4)
╲ (true merge)
main: ────────── prev release ─────────────── X ─────────── M
Merge branch 'dev'
tag: v1.5.4
```
## Implications for contributors
- **Base PRs on `dev`.** PRs against `main` will not be accepted.
- **Use lowercase conventional-commit titles.** Verified stable across
the last 25+ merged PRs.
- **Don't expect `main` to move between releases.** It only advances
when a maintainer cuts a release merge.
- **Tagging is on `main`.** Consumers pinning to a tag get the
released state, never a `dev` snapshot.
## Adapting this in your own fork
If you maintain a long-lived fork of `lnbits/lnbits` for production use,
you'll likely want to:
1. Mirror upstream's `dev` / `main` split — easier to track upstream
merges back into your fork.
2. Adopt a version-suffix convention that surfaces fork identity in
tags and the packaged `pyproject.toml` version, e.g.
`v<upstream>-<your-tag>.<N>`. This makes it unambiguous in logs and
deployed-package metadata that you're running fork-modified code.
3. Decide whether you also need pre-release channels (`-rcN`, `-devN`)
on top of upstream's; useful if your fork ships to staging hosts
before promotion.
Specifics depend entirely on your team's needs — this scaffold
deliberately doesn't prescribe a fork-versioning scheme.

View file

@ -0,0 +1,243 @@
# lnbits workspace notes
Practical reference for day-to-day work in an lnbits dev environment.
Collected gotchas, conventions, and design constraints that have
repeatedly surprised people. Not a tutorial — assumes you're already
running lnbits and contributing or extending it.
## Pick a non-default port for your local dev server
LNbits defaults to `:5000`. That collides with macOS AirPlay Receiver,
common docker-compose stacks, and other dev tools. Pick something
unambiguous (many forks settle on `:5001`) and stick to it across:
- `settings.nix``lnbits.port`
- any MCP server config you wire against your dev instance
- bookmarks, env files, CI configs
Once you wire it in three places, switching the port retroactively is
mostly find-and-replace pain. Decide early.
## LNBITS_SRC and docker-compose build context
If you run a docker-compose dev stack (regtest or otherwise) that
builds lnbits from a local checkout, the `Dockerfile` typically reads
from `${LNBITS_SRC:-/some/default}`.
The trap: **commits to your day-to-day worktree don't reach the dev
image if `LNBITS_SRC` is currently pointed elsewhere** (e.g. at a
feature-branch worktree you were testing). Even
`docker compose build --no-cache` happily rebuilds from the wrong
checkout.
Sanity-check before assuming a rebuild picked up your change:
```sh
docker compose config | grep -A2 lnbits
# look for the resolved `context:` path
```
If it's pointing somewhere stale, either cherry-pick your commit onto
the active build branch or flip `LNBITS_SRC` and rebuild.
## Extension folder is the install target — "Upgrade" wipes forks
If your dev setup mounts an extension checkout directly into the lnbits
container (e.g. `~/dev/shared/extensions/``/shared`, with
`LNBITS_EXTENSIONS_PATH=/shared`), **the extension git checkout IS the
installed extension**. There's no separate copy.
Consequence: clicking **"Upgrade"** in the LNbits UI on an extension
that's mounted from a fork checkout will:
1. Download the catalog tarball.
2. Extract it directly over the mounted directory.
3. **Wipe `.git`**, replace every file with the catalog version, and
silently discard any local changes / fork patches.
If this happens, recover with `git clone <your-fork-url> <dir>` over
the wiped directory. Mitigations:
- Set an extension version in your fork that always sorts above the
catalog's so "Upgrade" never thinks the catalog is newer.
- Or don't expose the catalog upgrade UI to a workflow that has
mounted-fork extensions.
## Settings precedence: `.env` seeds the DB on first boot, then the DB wins
This one trips up most people who deploy lnbits declaratively (via NixOS,
docker-compose, ansible, …). Verified 2026-05-24 against upstream
`lnbits/main`.
LNbits has two sources of truth for settings depending on lifecycle.
**On boot, when `lnbits_admin_ui=True`** (the default):
1. Read DB row via `get_super_settings()`
(`lnbits/core/services/users.py:236`).
2. If the DB row is empty → seed it from `.env` via
`init_admin_settings()`. First-boot only.
3. `update_cached_settings(settings_db.dict())` overwrites the
in-memory `Settings` with the DB row. **`.env` values loaded by
Pydantic at startup are clobbered.**
**Practical consequence:** once an instance has booted once, editing
`.env` and restarting **changes nothing** for editable fields. They can
only be changed via:
- The Admin UI (`PUT /api/v1/settings`, gated by `check_admin`), or
- Clearing the relevant rows in the `system_settings` table in the
core DB.
**Exceptions where `.env` still wins on every boot:**
- `super_user` — env overrides DB explicitly in `users.py`.
- `lnbits_admin_ui=False` — the whole DB-load block is skipped; env
stays authoritative (there's no Admin UI to populate the DB anyway).
- All `ReadOnlySettings` fields (defined in `lnbits/settings.py`
`EnvSettings` / `PersistenceSettings` / `SuperUserSettings` /
`ExtensionsInstallSettings`). Concretely:
- `host`, `port`
- `lnbits_path`, `lnbits_data_folder`, `lnbits_extensions_path`
- `lnbits_database_url`
- `auth_secret_key`, `first_install_token`
- `lnbits_title` (the API title — NOT `lnbits_site_title`, which is
editable)
- `lnbits_admin_ui` itself
- `lnbits_allowed_funding_sources` (the *list of which sources can be
enabled* — the per-source credentials live in `FundingSourcesSettings`
`EditableSettings` and ARE DB-frozen)
`update_cached_settings` skips any key in `readonly_variables`, so
env-loaded values for these fields survive the DB-load overwrite.
**Editable** settings — site title/tagline, theme, watchdog thresholds,
fee defaults, rate limits, all per-funding-source credentials, the
whole Admin UI form — get DB-frozen after first boot.
### `LNBITS_FIRST_INSTALL_TOKEN` rotation ≠ "reset settings to env"
Rotating the first-install token creates a new super_user account with
a fresh random UUID and re-enables the `/first_install` endpoint. It is
an **escape hatch for a locked-out admin** to re-claim the instance
with a new username/password. It does NOT refresh any settings values
from `.env` — by the time it runs, the DB row has already overwritten
cached `settings`, and `init_admin_settings()` then upserts those same
DB values back, so no env values flow through.
### Deploy-side implication
If you manage lnbits config declaratively (NixOS module, ansible role,
docker-compose env file), **editable env vars only take effect on a
fresh install** (empty `settings` table). For an existing deployment,
changing them in your declarative source and redeploying won't change
runtime behavior — you have to edit through the Admin UI OR clear the
relevant rows in `system_settings`.
The "set it in nix, redeploy, done" mental model only works for
`ReadOnlySettings` fields. Anything an admin can edit in the UI is
DB-frozen post-first-boot. Plan your deploy story accordingly:
- For `ReadOnlySettings` (host, port, paths, secret_key, …):
declarative source is authoritative on every boot. Reproducible.
- For `EditableSettings` (site title, fees, funding-source creds, …):
declarative source is a *seed*, not authoritative. To re-seed an
existing instance, you must explicitly clear the DB row.
## Nostr key handling — don't persist plaintext nsecs
As of 2026, LNbits's upstream account model server-generates a Nostr
private key (`accounts.prvkey`) for every new account and stores it
**plaintext in the DB**. The key is returned to clients over HTTPS via
auth endpoints. A DB snapshot, backup leak, or curious operator is a
wholesale identity compromise for every user.
If your fork or extensions touch user Nostr keys, design around this.
A defensible shape:
- A `NostrSigner` abstraction with at least three implementations:
- `LocalSigner` — server-side key, but envelope-encrypted at rest.
User unlocks per session.
- `RemoteBunkerSigner` — NIP-46 connection to a bunker the user
runs themselves. Server never sees the private key.
- `ClientSideOnlySigner` — the server only knows the user's
public key. Signing happens entirely in the browser / NIP-07
extension / hardware signer.
- Optionally a `DelegatedSigner` via NIP-26 for accounts that want
a server-side signer for low-value operations while keeping
high-value ops client-side.
Also audit every server-side Nostr key your extensions persist
(merchant signing keys, notification keys, transport keys, …) and
either encrypt at rest or document why ephemeral is fine.
The general principle: **prefer "don't store the key" when possible.**
Let the user's signer (browser extension, hardware device, NIP-46
bunker) do the signing.
## CLINK is Lightning.Pub-specific, not LNbits
Shocknet's [CLINK protocol](https://github.com/shocknet/CLINK) — the
Nostr-native payment flow with event kinds 21001 (offer/noffer),
21002 (debit/ndebit request), 21003 (debit response) — lives inside
`Lightning.Pub`, not LNbits. LNbits as of 2026 has zero CLINK
knowledge.
If you're thinking about `ndebit` / `noffer` semantics in an lnbits
context:
- They're Lightning.Pub primitives. LNbits doesn't implement them.
- Building "equivalent outcomes" in LNbits typically goes through
LNURL + payment-hash subscriptions + opaque `extra` payloads
rather than CLINK-style events. Different primitives, similar
effective semantics.
- If you genuinely need CLINK in lnbits, it's a new transport
module, not a tweak to existing code paths. NIP-44 plumbing in
the nostr-transport surface is the natural building block.
Don't conflate the two — keep CLINK semantics on the Lightning.Pub
side of any cross-system design.
## Fork versioning — surface your fork in the version string
If you maintain a long-lived fork (especially one running in
production), the released version string should make it obvious that
fork-modified code is what's running. Consumers of the package, log
readers, and bug reporters all benefit.
A common pattern:
- **Git tag:** `v<upstream-version>-<your-tag>.<N>`
e.g. `v1.5.4-aio.1`, `v1.5.4-aio.2`, …
- **`pyproject.toml` `version`:** `<upstream-version>+<your-tag>.<N>`
e.g. `1.5.4+aio.1`.
The two spellings differ because PEP 440 doesn't accept
hyphen-with-arbitrary-suffix as a valid package version, but the local-
version form (`+<tag>.<N>`) is valid. Extensions don't have the PEP 440
constraint (their `config.json` is freeform), so the tag form works
everywhere except `pyproject.toml`.
Decide your own tag string and stick to it. The key property is that
`importlib.metadata.version("lnbits")` and the LNbits UI footer make
it visible to anyone looking.
For the upstream lnbits branch / release model these tags sit on top
of, see [lnbits-upstream-flow.md](lnbits-upstream-flow.md).
## Reading the codebase fast
For mass-grep and reference reading, point a tools-friendly mirror of
the lnbits source at a known location (see your `~/dev/refs/` setup if
you use one). Key paths once you have a checkout:
- `lnbits/core/services/` — core money flows. Start here for invoice
/ payment / wallet logic.
- `lnbits/wallets/` — backend implementations. One file per
`LNBITS_BACKEND_WALLET_CLASS` value.
- `lnbits/extensions/` — first-party extensions. Third-party
extensions follow the same shape (`config.json`, `views.py`,
`migrations.py`, etc.).
- `git log --first-parent dev --oneline` — see the squash-merge
sequence into `dev` (see lnbits-upstream-flow.md for why
`--first-parent` matters here).

138
docs/upstream-prs.md Normal file
View file

@ -0,0 +1,138 @@
# Contributing back to upstream
How to send fixes or features upstream (to projects like `lnbits/lnbits`)
using the worktree-based PR flow built into this scaffold's dev-env
layer.
> **New to forks and pull requests?** Skim the [Primer](#primer) first.
> Otherwise jump to [Workflow](#workflow).
## Primer
GitHub, Forgejo, Gitea, and similar services host *git repositories*.
To contribute to a project you don't own:
1. **Fork** the upstream repo via the web UI ("Fork" button). This
creates `<host>/<your-user>/<repo>` — your personal copy.
2. **Branch** off your fork's `main` (or whichever branch upstream
accepts PRs against). Make your change.
3. **Push** the branch to your fork.
4. **Open a Pull Request** from your fork's branch into the upstream.
5. **Iterate** — maintainers review, you push follow-up commits to the
same branch and the PR updates automatically.
6. **Maintainer merges** — usually as a single squashed commit on
upstream's target branch.
7. **Delete the branch** on both sides once merged.
The three roles a remote can play in your local clone:
| Remote name | What it points at | Read or write? |
|---|---|---|
| `upstream` | The canonical project you're contributing to | Read (you fetch from it) |
| `github-fork` (or `fork`) | Your personal copy you push to | Write (you push to it) |
| `origin` | Optional: a private mirror you control (forgejo / gitea / codeberg / sourcehut) | Read + write |
This scaffold's `settings.nix` declares all three via `git.remotes`;
the bootstrap script wires them into the local bare repo so every
worktree sees the same set automatically. See
[remotes.md](remotes.md) for topology patterns.
## Prerequisites (per project)
1. **Fork** the upstream repo on the relevant host.
2. **Set your fork URL** in `settings.nix`:
```nix
git.remotes.fork = "https://github.com/<your-username>/lnbits";
```
Use the SSH form (`git@github.com:<your-username>/lnbits.git`) if
you've set up SSH push.
3. **Bootstrap** (or re-bootstrap). The (planned) bootstrap script
adds the `github-fork` remote to the bare repo at
`~/dev/repos/lnbits.git`.
Verify the remotes are wired:
```sh
git -C ~/dev/repos/lnbits.git remote -v
```
Expected at minimum:
```
upstream https://github.com/lnbits/lnbits (fetch / push)
github-fork https://github.com/<your-user>/lnbits (fetch / push)
```
If `origin` is set, it points at your private remote (forgejo etc.).
## Workflow
### 1. Create the PR branch
```sh
prb lnbits fix-invoice-validation
```
The (planned) `prb` helper:
- `git fetch upstream`
- creates branch `fix-invoice-validation` off `upstream/main`
- adds a worktree at `~/dev/upstream-prs/lnbits-fix-invoice-validation`
- sets the worktree's push target to your fork (`github-fork`)
### 2. Make changes
```sh
cd ~/dev/upstream-prs/lnbits-fix-invoice-validation
# … edit, test, commit …
git commit -am "Fix invoice validation for zero-amount invoices"
```
### 3. Push to your fork + open the PR
```sh
git push -u github-fork fix-invoice-validation
```
git prints a URL to open the PR. Alternatively, with the GitHub CLI:
```sh
gh pr create --base main --head <your-username>:fix-invoice-validation
```
(Use the project's preferred target branch — for `lnbits/lnbits` it's
`dev`, not `main`. See [lnbits-upstream-flow.md](lnbits-upstream-flow.md).)
### 4. Iterate on review
Push more commits to the same branch — the PR updates automatically.
Don't force-push unless you've squashed/rebased deliberately.
### 5. After merge — clean up
```sh
prc lnbits fix-invoice-validation
```
The (planned) `prc` helper removes the worktree and prunes the local
branch. On the web UI, click "Delete branch" on the merged PR to
remove your fork's copy too.
## Common pitfalls
- **PR target branch.** Many projects (including `lnbits/lnbits`)
accept PRs against `dev`, not `main`. Read the project's
CONTRIBUTING file or its branch-model reference before opening.
- **Stale branches on your fork.** They accumulate forever if you
don't clean up. `prc` handles the local side; the web UI handles
the fork side.
- **Commits drifting onto your fork's `main`.** Keep your fork's
`main` strictly in lockstep with `upstream/main`. All feature
work lives in branches.
- **Wrong remote when pushing.** If `git push` defaults to `upstream`
(because that's `origin`), you'll get an access-denied error. The
worktree's tracking remote should be `github-fork`; if not, set it:
`git branch --set-upstream-to=github-fork/<branch>`.

61
files/dev-CLAUDE.md Normal file
View file

@ -0,0 +1,61 @@
# `~/dev` — lnbits dev workspace
You're working in a dev workspace bootstrapped by **lnbits-sensei**.
This file is workspace-level orientation; per-project CLAUDE.md files
(e.g. `~/dev/lnbits/CLAUDE.md`) layer project-specific gotchas on top.
> This file is a symlink into your lnbits-sensei checkout's
> `files/dev-CLAUDE.md` (via home-manager's `mkOutOfStoreSymlink`).
> Edits should happen in the checkout and be committed there.
## Layout
```
~/dev/
├── repos/ # bare git repos, one per project
├── <project>/<worktree>/ # worktrees per branch
├── upstream-prs/<topic>/ # PR branches against upstreams
├── shared/, scratch/ # workspace utilities
└── refs/ # curated reference repos (optional)
```
Rationale: bare-repos + per-project-worktree directories means one git
object database per project (no clone-per-branch churn) and an explicit
PR contract in `upstream-prs/`. See the lnbits-sensei README's
"Workspace layout" section for the full reasoning.
## Quick commands
- `dev up [--fakewallet|--regtest]` — start the lnbits dev server.
Default `--fakewallet` is instant, no docker, good for
extension/UI/API work.
- `dev down` / `dev logs` / `dev shell` — control / inspect.
- *(planned)* `prb <repo> <branch>` — create a PR worktree branched
from `upstream/main` under `~/dev/upstream-prs/`.
- *(planned)* `lb <worktree>` — cd shortcut into `~/dev/lnbits/<worktree>`.
## Reference docs
Full reference lives in the lnbits-sensei checkout's `docs/`:
- `docs/remotes.md` — three remote-topology patterns (upstream-only /
github-fork / multi-remote-with-private).
- `docs/upstream-prs.md` — PR workflow with a primer for anyone new
to fork-based contribution.
- `docs/lnbits-upstream-flow.md` — how `lnbits/lnbits` itself moves
(the `dev` / `main` branch split, squash-merge convention).
- `docs/lnbits-workspace-notes.md` — practical gotchas: port choice,
`LNBITS_SRC` build-context traps, extension-folder-upgrade wiping
forks, Nostr key handling, CLINK scope, fork versioning, **settings
precedence (`.env` vs DB)**.
## Per-project orientations
Per-project CLAUDE.md files are placed at the worktree root:
- `~/dev/lnbits/CLAUDE.md` — LNbits dev gotchas (settings precedence,
frontend rules, auth decorators, …).
These are also symlinked from your lnbits-sensei checkout (under
`files/`); the seeding is driven by `lnbits-sensei.devEnv.claude.*`
options.

94
files/lnbits-CLAUDE.md Normal file
View file

@ -0,0 +1,94 @@
# `~/dev/lnbits` — LNbits dev orientation
Per-project CLAUDE.md for the LNbits worktree subtree. Workspace-level
orientation is at `~/dev/CLAUDE.md` (when wired). Full reference
material lives in your lnbits-sensei checkout under `docs/`.
> This file is a symlink to `files/lnbits-CLAUDE.md` in your
> lnbits-sensei checkout. Edits should happen there.
## Critical gotchas (read first)
### Settings: `.env` seeds the DB on first boot, then DB wins
After first boot, **editing `.env` and restarting changes nothing**
for editable fields. Only `ReadOnlySettings` (`host`, `port`,
`lnbits_path`, `lnbits_data_folder`, `lnbits_extensions_path`,
`lnbits_database_url`, `auth_secret_key`, `first_install_token`,
`lnbits_title`, `lnbits_admin_ui`, `lnbits_allowed_funding_sources`)
re-read env every boot.
Editable settings (site title, fees, watchdog, funding-source
credentials, the whole Admin UI form) change only via:
- `PUT /api/v1/settings` (Admin UI), or
- Clearing the relevant rows in the `system_settings` table.
Deploy-side: declarative env vars are a *seed* for editable fields,
not authoritative. Plan accordingly.
Full reference: `docs/lnbits-workspace-notes.md` → "Settings precedence".
### Frontend templates: NO self-closing tags
LNbits pages load Vue 3 + Quasar 2 as UMD globals. The browser's
HTML parser doesn't honor self-close on non-void elements — the
close tag gets implied at the wrong place and subsequent siblings
end up inside the prior component.
```html
<q-input v-model="foo" label="Foo"></q-input> <!-- ✓ correct -->
<q-input v-model="foo" label="Foo" /> <!-- ✗ silently broken -->
```
Applies to `lnbits/templates/*.html`, every extension's `templates/`,
every fork. Fine in `.vue` SFCs (the build step rewrites them) — if
you copy a snippet from a Vue SFC repo into an LNbits template,
**expand all self-closing tags before saving**.
### Auth decorators
Easy to confuse. Different scopes:
| Decorator | Auth scope |
|---|---|
| `require_invoice_key` | Wallet invoice key — read access |
| `require_admin_key` | Wallet admin key — write to **own wallet only** |
| `check_admin` | **LNbits instance admin** (super_user / admin users) |
| `check_super_user` | LNbits super user only |
`require_admin_key``check_admin`. Use `check_admin` for
cross-user / admin-only endpoints.
### Upstream PRs target `dev`, not `main`
PRs to `github.com/lnbits/lnbits` go to `dev`. `main` only moves on
release merges. Use lowercase conventional-commit titles
(`feat:`, `fix:`, `chore:`, `chore(deps):`, `docs:`, `ci:`).
Full reference: `docs/lnbits-upstream-flow.md`.
## Default dev workflow
```
dev up # FakeWallet, instant
dev up --regtest # multi-node regtest (slower boot, real channels)
```
Use **FakeWallet** for extension CRUD / UI / API work. Spin up
**regtest** only when actual channel/payment behavior is under test.
## Key paths
- `~/dev/lnbits/main/` — worktree on `main` (your fork's day-to-day)
- `~/dev/lnbits/dev/` — worktree on `dev` (integration / staging)
- `~/dev/upstream-prs/lnbits-<topic>/` — PR worktrees branched from
`upstream/main`
- `~/dev/repos/lnbits.git` — the bare repo all the above point at
## When in doubt
The canonical reference for any of the above lives in your
lnbits-sensei checkout's `docs/`. Trust the docs over this file when
they diverge — this CLAUDE.md is a quick-reference; the docs are the
source of truth.

View file

@ -35,4 +35,25 @@
# Future passes: shell prompt, editor, lnbits dev shell aliases, # Future passes: shell prompt, editor, lnbits dev shell aliases,
# direnv integration, tmux launcher, etc. Keep this file thin and # direnv integration, tmux launcher, etc. Keep this file thin and
# delegate behaviour to modules/. # delegate behaviour to modules/.
# CLAUDE.md seeding — symlinks under ~/dev/ that point at files
# tracked in your lnbits-sensei checkout, so Claude sessions in the
# workspace pick up the curated orientation automatically. Gated on
# `lnbits-sensei.devEnv.claude.*` options in the NixOS config.
#
# `mkOutOfStoreSymlink` keeps the link pointed at your live checkout
# (not the Nix store), so editing the source file takes effect on
# the next Claude session without a rebuild.
home.file = lib.mkMerge [
(lib.optionalAttrs osConfig.lnbits-sensei.devEnv.claude.enable {
"dev/lnbits/CLAUDE.md".source =
config.lib.file.mkOutOfStoreSymlink
"${osConfig.lnbits-sensei.devEnv.scaffoldPath}/files/lnbits-CLAUDE.md";
})
(lib.optionalAttrs osConfig.lnbits-sensei.devEnv.claude.workspaceOrientation {
"dev/CLAUDE.md".source =
config.lib.file.mkOutOfStoreSymlink
"${osConfig.lnbits-sensei.devEnv.scaffoldPath}/files/dev-CLAUDE.md";
})
];
} }

View file

@ -97,6 +97,21 @@ in
options.lnbits-sensei.devEnv = { options.lnbits-sensei.devEnv = {
enable = mkEnableOption "lnbits-sensei dev-env tooling"; enable = mkEnableOption "lnbits-sensei dev-env tooling";
scaffoldPath = mkOption {
type = types.str;
description = ''
Absolute path to your lnbits-sensei checkout on this machine.
Used to source the seedable CLAUDE.md files (and, later, the
dev-env scripts) via `mkOutOfStoreSymlink` so edits in your
checkout take effect without a rebuild.
Required when any `claude.*` integration is enabled. Type is
`str` (not `path`) intentionally `path` would copy the file
into the Nix store and break the out-of-store-symlink semantics.
'';
example = "/home/alice/dev/lnbits-sensei";
};
root = mkOption { root = mkOption {
type = types.str; type = types.str;
default = "/home/${config.lnbits-sensei.user or "user"}/dev"; default = "/home/${config.lnbits-sensei.user or "user"}/dev";
@ -108,6 +123,23 @@ in
''; '';
}; };
claude = {
enable = mkEnableOption ''
Seed `~/dev/lnbits/CLAUDE.md` from
`''${scaffoldPath}/files/lnbits-CLAUDE.md` so Claude sessions
anywhere in the lnbits worktree subtree pick up project-specific
orientation (settings precedence, frontend rules, auth
decorators, )
'';
workspaceOrientation = mkEnableOption ''
Also seed `~/dev/CLAUDE.md` from
`''${scaffoldPath}/files/dev-CLAUDE.md`. Skip if you already
maintain a workspace-level CLAUDE.md this option clobbers
whatever is there
'';
};
projects = mkOption { projects = mkOption {
type = types.attrsOf projectType; type = types.attrsOf projectType;
default = { }; default = { };