omnixient/files/dev-CLAUDE.md
Padreug a8f1045518 chore: Claude/agent guidance and workspace files
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 10:00:14 +02:00

728 lines
33 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> **Source-of-truth path:** `/etc/nixos/files/dev-CLAUDE.md`. `~/dev/CLAUDE.md`
> is a home-manager `mkOutOfStoreSymlink` to here (`/etc/nixos/home.nix`).
> `~/dev/` is not a git repo. Edits at either path take effect immediately
> (no rebuild needed), but **commit from `/etc/nixos`**.
## What this directory is
`~/dev/` is **not** a single repo — it's a workspace of sibling projects for
the aiolabs / atitlan.io stack (Lightning + Nostr). Most projects are
forgejo-hosted at `git.atitlan.io`. Per-project CLAUDE.md files are the
source of truth for each repo; this file holds workspace-level facts that
cross-cut multiple repos.
## Layout convention
Bare repos live under `repos/<name>.git/` and are checked out as
**worktrees** named after the branch (or purpose): `lnbits/main/`,
`lnbits/dev/`, `lamassu-next/lightning-pub/`, etc. Single-worktree projects
typically use `<project>/main/` (`host5-home/main`, `quartz-module/main`,
`webapp-module/main`, `fava-module/main`).
## Per-project CLAUDE.md (defer to these)
- `webapp/CLAUDE.md` — Vue 3 + Vite + Electron client (Nostr + Lightning).
Heavy guidance on modular DI architecture, Shadcn forms, mobile file-input
defenses. Read before touching anything under `webapp/`.
- `bitspire/bitspire/CLAUDE.md`
- `quartz-module/main/CLAUDE.md`
- `docs/castle-docs/CLAUDE.md`
## Key cross-cutting locations
- **`deploy/server-deploy/`** — Unified NixOS infrastructure flake. Consumes
project repos here as flake inputs (`url = "git+ssh://...aiolabs/<x>"`).
See `flake.nix` for the canonical map of which repos feed which hosts.
- **`local/docker/regtest/`** — Local multi-node Lightning regtest stack
(LND/CLN/Eclair + bitcoind/electrs/boltz/fava). See `local/README.md`.
**Default lnbits dev path is FakeWallet — no docker needed.** Only spin up
regtest when testing real channels/payments.
**`LNBITS_SRC` branch awareness:** the dev compose builds lnbits from
`${LNBITS_SRC:-~/dev/lnbits/main}`. If `LNBITS_SRC` points elsewhere
(e.g. a feature branch), commits to `lnbits/main` **don't reach the
dev image even after `--no-cache` rebuild**. Verify resolved context
with `docker compose config | grep -A2 lnbits` before assuming a
rebuild picked up your patch.
**Extension folder = LNbits install target:** the compose mounts
`~/dev/shared/extensions/` at `/shared` and sets `LNBITS_EXTENSIONS_PATH=
/shared`, so the extension git checkout *is* the installed extension.
Clicking "Upgrade" in the LNbits UI extracts the catalog archive
directly over the checkout, wiping `.git`. Mitigation: aio semver
MUST beat upstream tag (see catalog rules).
- **`refs/`** — Curated mirrors of upstream reference codebases (Beancount,
Lemmy, LNbits, LND/CLN/Eclair, nostr-tools, khatru, …) plus weekly
digests. Manifest `refs.toml`; refresh via `bin/refresh`, digest via
`bin/digest`. Layout: `repos/<group>/<owner>/<name>`. Use for grep/browse
*don't* import for builds; pin via flake input or submodule instead.
- **`shared/extensions`, `shared/lamassu-server`, `shared/nix-bitcoin`** —
shared paths used across multiple worktrees.
- **`upstream-prs/`** — branches staged for upstream contribution.
- **`lnbits-extensions/`** — extension catalog (`extensions.json`).
## Working in this workspace
- Treat each subdirectory as its own repo with its own conventions, branches,
remotes. Don't assume changes in one project belong with changes in another.
- Many lnbits worktrees share the same bare repo — be mindful which branch
you're on, especially when running migrations or touching shared state.
- The `webapp` repo's `dev` branch is the staging channel for
`demo.aiolabs.dev`; `main` feeds production. Dev = staging, not "demo".
### Webapp release flow
1. Push to `aiolabs/webapp` **`dev`** (not main).
2. Bump `webapp-dev` input in `deploy/server-deploy/flake.lock`
(`nix flake lock --update-input webapp-dev`). Tracks `dev`.
3. Deploy to `host4` (`./deploy.sh host4`). Smoke as staging vet.
4. Once vetted: fast-forward `aiolabs/webapp` **`main`** to dev's commit
+ bump the `webapp` (not `webapp-dev`) input in `flake.lock`.
**Never push runtime webapp changes directly to main.** If you find
runtime commits on main that should have gone through dev, **stop and
notify the user** — recovery is destructive and needs explicit auth.
**Carve-out: non-runtime / tooling-only changes can go direct to main +
rebase dev on top.** Root-level dotfiles and dev tooling that don't ship
in the Vite/Electron bundle (`.mcp.json`, editorconfig, CLAUDE.md, root
README) have nothing to vet on staging. Pattern: commit on main, push,
then `cd ~/dev/webapp/dev && git fetch && git rebase origin/main &&
git push --force-with-lease`. Still PR for anything touching `src/`,
`electron/`, `package.json`/`pnpm-lock.yaml`, build config.
- Production-bound changes need a corresponding `flake.lock` bump in
`deploy/server-deploy/` to actually reach a host.
- **Git staging discipline:** never `git add -A` / `git add .` — list
filenames explicitly. Hard rule from a 2026-05-14 incident that leaked
an LNbits auth key.
---
## aiolabs Forgejo conventions
### Fork versioning (lnbits + extensions)
Universal across all LNbits-derived aio forks:
- **Tag scheme:** `v<upstream-version>-aio.<patch-number>`. Examples:
`v1.5.4-aio.1`, `v1.3.0-aio.2`. Bump upstream segment on rebase, reset
patch counter to `1`.
- **Hyphen pre-release suffix on tags + extension `config.json`**, NOT
`+aio.N` build-metadata. Confirmed across multiple bumps.
- **EXCEPTION: lnbits `pyproject.toml` requires PEP 440 → `+aio.N` there
only.** Hyphen-suffix isn't PEP 440-valid. Same release, two spellings:
git tag `v1.5.4-aio.1`, `pyproject.toml` `1.5.4+aio.1`. The latter is
what `importlib.metadata` returns and the UI footer displays.
- **Tag at deploy-ready boundaries, not every commit.** Tags mark vetted
states; direct-commit-to-main is fine, but don't tag broken intermediate
commits.
- **Lint pipeline** for aio forks: black + mypy + prettier + ruff.
For `aiolabs/lnbits`: `deploy/server-deploy` pulls lnbits as a flake input
pinned by commit. The tag is a human-readable label, not a functional
dependency — but makes `git log --decorate` and archaeology vastly clearer.
### `aiolabs/lnbits-extensions` catalog rules
`extensions.json` is consumed at runtime by LNbits via
`LNBITS_EXTENSIONS_MANIFESTS`; archives are sha256-pinned per entry.
**The catalog repo is NOT tagged.** It's a curated manifest, not a fork.
Deploy reads it live from `…/raw/branch/main/extensions.json`; audit trail
is `git log` on main, rollback is `git revert`.
- **Production points ONLY at our manifest, never upstream.** The deployed
`LNBITS_EXTENSIONS_MANIFESTS` points at
`git.atitlan.io/aiolabs/lnbits-extensions/raw/branch/main/extensions.json`
(`deploy/server-deploy/modules/services/lnbits.nix:73`). Production
users can only install / upgrade what we've curated. Cycle: upstream
releases → vet in dev → rebase to aio → update our manifest. Never add
`github.com/lnbits/<ext>` URLs for an extension with an active aio fork.
- **Don't overwrite a version entry in place.** When bumping, *add a new
entry* alongside the old — instances running the previous version need
it to remain resolvable.
- **Aio semver MUST beat the upstream tag we're forking** (dev hygiene).
Regtest compose doesn't override `LNBITS_EXTENSIONS_MANIFESTS`, so dev
sees LNbits's built-in default catalog AND our manifest. LNbits's
"upgrade" picks highest semver across all visible entries; if upstream
outranks our `-aio.N`, clicking Upgrade wipes our `.git`. Always bump
aio fork to `v<upstream>-aio.1` immediately on rebase.
- **Catalog bumps don't require a `flake.lock` bump.** LNbits fetches the
manifest live; nix doesn't bake archives in. Explicit exception to the
"production-bound changes need a flake.lock bump" rule.
### Extension version-bump procedure
When user says "bump <ext> and update lnbits-extensions":
1. Sanity-check ext repo: clean tree, on `main`, `git log <last-tag>..main`
matches intended release.
2. Choose semver bump from the diff.
3. **Push the branch first** (force-push after rebase OK for solo-maintainer
forks). Origin/main is recoverable; the tag isn't.
4. **Test locally on dev LNbits BEFORE tagging.** Restart regtest to load
new on-disk code (or uninstall+reinstall via UI); smoke happy paths,
watch logs for migrations + exceptions. Once installs pull the catalog,
bad tags are hand-fix-every-install messy.
5. Tag at HEAD and push: `git tag v… && git push origin v…`.
6. Fetch archive, compute sha256: `curl -sL .../archive/v….zip | sha256sum`.
7. Add a new entry to `aiolabs/lnbits-extensions/extensions.json` (don't
overwrite the old).
8. Commit + push catalog. Live immediately — no flake.lock bump.
### Forgejo issue labels (aiolabs/webapp)
When creating issues on `aiolabs/webapp`, apply labels via
`add_issue_labels` immediately on creation. Two axes:
- **App scope:** `app:activities`, `app:webapp`, `app:marketplace`,
`app:wallet`, etc.
- **Issue type:** `type:bug`, `type:feat`, `type:chore`, etc.
### PR flow on aiolabs-forked extensions (default since 2026-06-12)
For aio LNbits ext forks (`aiolabs/lnurlp`, `aiolabs/withdraw`,
`aiolabs/events`, `aiolabs/libra`, …), **feature-branch + PR is the
default flow** — these extensions move real money and feed production
instances, putting them in the same review category as `aiolabs/lnbits`
and `aiolabs/webapp`.
**Why the flip** (was direct-commit-to-main while single-maintainer):
a libra refactor regression (typed entry links breaking the approval
flow, libra-#42) shipped straight to a production-feeding main on
2026-06-12. The PR gate buys a review pause + a place for CI/tests
before main moves.
Flow: branch → push → open PR (creation via MCP is fine) → **hand off
to the user to merge via the Forgejo web UI** (see PR-merge rule in
user-global CLAUDE.md — the MCP merge endpoint is unreliable and the
user has a final-review ritual). Trivial typo/docs-only commits can
still ride direct-to-main at the user's discretion — ask if unsure.
(L3 Forgejo branch protection on main for these repos is the parked
enforcement follow-up, same as for lnbits/webapp.)
### Issue closing — manual, not commit-keyword auto-close
Do NOT use Forgejo closing keywords (`Closes #N`, `Fixes #N`) in commit
messages on aiolabs repos. Reference issues as prose (`(libra-#42)` in
the subject, plain `#42` in the body) and **close the issue manually**
after the fix lands.
**Why:** auto-close fires when the commit hits main, but on this stack
"on main" ≠ "deployed" — issues should stay open (or at least be closed
deliberately) until the fix actually reaches the affected instance.
Confirmed by user 2026-06-12 (libra-#42).
### aiolabs identity vs upstream
Forks live on Forgejo at `git.atitlan.io/aiolabs/*`. User's handle there
is `padreug`. Fork-internal references (contributors, repo URLs, PR/issue
links) use Forgejo + `padreug`. GitHub handle (`your-github-username`) is separate
— only for upstream PRs. Identity-rewrite details in user-global
`~/.claude/CLAUDE.md`.
---
## LNbits extension development
### Auth decorators
| Decorator | Auth scope | Returns |
|---|---|---|
| `require_invoice_key` | Wallet invoice key — read access | Wallet |
| `require_admin_key` | Wallet admin key — write to **own wallet only** | Wallet |
| `check_admin` | **LNbits admin user** (super_user + lnbits_admin_users), Bearer | Account |
| `check_super_user` | LNbits super user only, Bearer | Account |
`require_admin_key` is **easily confused** with `check_admin` and means
something very different. The first is a wallet-level write key (any user
can have one for their own wallet); the second is LNbits-instance admin
access. Use `check_admin` for cross-user / admin-only operations.
### Testing
Use FakeWallet (`LNBITS_BACKEND_WALLET_CLASS=FakeWallet`) for extension
CRUD / API / UI tests. Spin up full regtest only when end-to-end Lightning
payment behavior is actually under test (regtest costs build + container
time and adds no fidelity for non-payment flows).
### Fork-migrations pattern (`migrations_fork.py`)
Per `aiolabs/lnbits#8` — fork-only schema deltas go in `migrations_fork.py`
alongside the upstream-tracked `migrations.py`, loaded by our patched
`migrate_extension_database()` under `<ext.id>_fork` in `dbversions`. Keeps
upstream rebases conflict-free for the migration file.
**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 INSERT-OR-UPDATE so a new `<ext>_fork`
row appears on first run with no core-side migration.
- Extension data tables live in `ext_<id>.sqlite3` (SQLite) or a Postgres
schema named after `<id>`. Created lazily on first `Database.connect()`.
- **No cross-DB atomicity.** Extension migration commits to
`ext_<id>.sqlite3`; `dbversions` upsert commits to `database.sqlite3`.
If extension write succeeds and dbversions fails, migration is orphaned
and re-runs on next startup. **Every migration MUST be idempotent**
(`_alter_add_column_safe`, `CREATE TABLE IF NOT EXISTS`, etc.).
**Squash recipe for adopting the pattern on existing forks:**
1. Restore `migrations.py` to upstream-byte-identical (drop fork-only
functions and any helpers added for them).
2. Create `migrations_fork.py` with a single `m001_aio_<ext>_schema`
that idempotently applies every fork-only delta the old migrations did.
3. Use `_alter_add_column_safe` per ALTER and `CREATE TABLE IF NOT EXISTS`
per table — no-ops cleanly on installs that already ran old migrations.
**One-time fix on installs adopting the pattern AFTER previously running
old fork migrations:** their `dbversions['<ext>']` row is ahead of upstream
(e.g. `events|11`). After moving to `migrations_fork`, the next upstream
rebase that adds e.g. `m007` would compare `7 > 11 → false` and silently
skip. **Reset the row before the rebase lands:**
```sql
-- Run against the core DB, not the extension DB.
UPDATE dbversions SET version = <upstream-max> WHERE db = '<ext>';
```
Containerized: `docker compose exec lnbits python3 -c "..."` since the
file is root-owned inside the container.
**Upstream-overlap at rebase time:** if upstream eventually adds a schema
change we already carry in `migrations_fork.py`, fresh installs work (our
guards swallow dups) but **existing installs crash** when upstream's
non-idempotent migration runs. Mitigation:
1. **Prune the redundant block from `migrations_fork.py`** so future fresh
installs get the column from upstream.
2. **Pre-deploy `dbversions` surgery on affected installs:**
`UPDATE dbversions SET version = <upstream-max> WHERE db = '<ext>'`.
Don't patch upstream's migration with idempotency guards in our fork —
breaks the "migrations.py == upstream byte-identical" property.
**Upstream PR follow-up:** the extension loader change in
`migrate_extension_database()` is upstreamable as a sibling to the
in-flight `core_fork` PR (`your-github-username/lnbits @ add-fork-migrations-namespace`).
Fork-internal patch for now.
### Settings precedence — env seeds DB on first boot, then DB wins
LNbits has two sources of truth for settings depending on lifecycle, and
the switch happens automatically. Verified 2026-05-24 against `~/dev/lnbits/main`.
**On boot with `lnbits_admin_ui=True`** (`lnbits/core/services/users.py:
231-247`, `check_admin_settings`):
1. Read DB row via `get_super_settings()`.
2. If DB empty → seed from `.env` via `init_admin_settings()` (first-boot
only).
3. `update_cached_settings(settings_db.dict())` overwrites 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. Change via
Admin UI (`PUT /api/v1/settings`, gated by `check_admin`) or by clearing
relevant `system_settings` table rows.
**Exceptions where `.env` still wins every boot:**
- `super_user` — env overrides DB at `users.py:243-245`.
- `lnbits_admin_ui=False` — DB-load block skipped entirely.
- All `ReadOnlySettings` fields (`settings.py:1132` + parents): `host`,
`port`, `lnbits_extensions_path`, `lnbits_path`, `lnbits_title` (API
title — NOT `lnbits_site_title` which is editable), `lnbits_data_folder`,
`lnbits_database_url`, `auth_secret_key`, `first_install_token`,
`lnbits_admin_ui`, `lnbits_allowed_funding_sources`.
`update_cached_settings` skips any key in `readonly_variables`. Editable
settings (site title/tagline, theme, watchdog, fee defaults, rate limits,
per-funding-source credentials, the whole Admin UI form) get DB-frozen.
**`LNBITS_FIRST_INSTALL_TOKEN` rotation does NOT reset settings to env.**
It creates a new super_user account with a fresh UUID and sets
`settings.first_install = True`, re-enabling `/first_install` for a
locked-out admin to re-claim the instance. No env values flow back through.
**Deploy-side consequence for `deploy/server-deploy/modules/services/
lnbits.nix`:** editable env vars only take effect on fresh install
(empty `settings` table). For existing deploys, "set it in nix, redeploy,
done" only works for `ReadOnlySettings` fields.
---
## Nostr architecture
### Patterns reference is the source of truth
Before writing or reviewing any Nostr-related code in `~/dev/webapp/`,
read **`docs/nostr-patterns/`** first. After implementing or refining a
pattern (or fixing a subtle Nostr bug), **update the relevant topic file
in the same commit**. If new, add to the index. Drift between reference
and code defeats the purpose.
### Reference implementations (curated mirror)
`~/dev/refs/repos/nostr/<owner>/<repo>/` holds upstream nostr codebases
worth grepping for patterns: `nostr-protocol/nips`, `fiatjaf/khatru`,
`fiatjaf/nostr-tools`, etc. Refresh weekly via `~/dev/refs/bin/refresh`;
digest under `~/dev/refs/digests/`. For new features: check
`docs/nostr-patterns/` (internal) → then `refs/repos/nostr/` (external).
### Key in-flight initiatives
- **LNbits nostr transport** — `aiolabs/lnbits` PR #4
(`nostr-native-transport` branch). NIP-44 v2 encrypted kind-21000 RPC
over relays, modeled after Lightning.Pub. Lets HTTP-allergic clients
(ATMs, kiosks behind NAT) reach LNbits through commodity relays.
- **Lightning.Pub reference** — `~/dev/lamassu-next/lightning-pub/`. Key files:
`extension-loader/proto/autogenerated/ts/nostr_transport.ts`,
`extension-loader/src/services/nostr/nostrPool.ts`. Patterns: dual NIP-44
v1+v2 support, content sharding for large messages.
### Long-term direction
The eventual goal is webapp + LNbits extensions communicating **exclusively
over Nostr** — eliminating HTTP. Prefer architectural decisions that move
toward Nostr-native. Don't create HTTP-only patterns that will need to be
ripped out. The nostr transport PR (#4) is the first concrete step.
### Bunker for everything: no nsec at rest on LNbits
Once `aiolabs/lnbits#18` (NIP-46 bunker integration) lands, **every nsec
on the LNbits host gets retired** — operator users AND the server identity.
There is no two-tier endgame where users go through bunker and the server
keeps `NOSTR_TRANSPORT_PRIVATE_KEY` on disk.
The plausible-looking carve-outs (server "conceptually different," boot
bootstrap complexity, latency, failure modes) don't survive the threat
model: the LNbits host runs extension code, payment plumbing, a public API
— disk/root access there must NOT equal Nostr-identity compromise.
**Concrete rules:**
- Every signing call routes through the signer abstraction
(`lnbits.core.signers.resolve_signer` from `#17`). Impls: `LocalSigner`
(envelope-encrypted at rest, **transitional**), `ClientSideOnlySigner`
(operator-driven, no server signing), `RemoteBunkerSigner` (NIP-46 via
`#18` — endgame).
- No `if server_is_special:` branch anywhere. Server identity is one more
account from the signer's perspective.
- `NOSTR_TRANSPORT_PRIVATE_KEY` is acceptable as a transitional env-pinned
source for a `LocalSigner` adapter, but with an **explicit sunset**.
- For extension code signing on behalf of operators: same abstraction. The
hybrid pattern at `aiolabs/spirekeeper` commit `e13178d` is the
template (try-import `resolve_signer`, fall back to direct prvkey on
pre-`#17` lnbits, both produce identical signed events).
If tempted to keep static nsec "because it's just the server" or "because
bunker isn't ready yet," push back: the whole point of bunker is removing
static nsec from the LNbits host.
### Respect protocol semantics over friction reduction
When picking a transport / event-kind / message-format for a new feature,
the protocol's intended use case wins over "an existing listener happens to
fire." Quick-fix transport choices get locked in by callers and are harder
to migrate later.
**How to apply** — before wiring a new feature onto an existing listener:
1. Name the relationship the feature serves (ATM↔Customer, ATM↔LNbits,
ATM↔Operator, Operator↔User, LNbits↔Bunker, …).
2. Ask "what is this protocol / kind / RPC *for*?"
3. If answers don't align, design or pick the right primitive even if a
wrong-purpose listener already exists.
Exception: explicit, time-bounded stopgaps with a written migration path.
**Worked example (2026-05-29 / `aiolabs/bitspire#56`).** Cassette-config
publishing was nearly routed through `clink.onManagement` (kind-21003)
because the listener existed. CLINK is a payment-flow protocol; repurposing
would have conflated payment + operator-config concerns. The right
primitive was kind-30078 (NIP-78 replaceable, NIP-44 encrypted,
`["p", atm_npub]`-tagged).
### Nostr kind allocations — avoid the CLINK band (2100121003)
The Nostr ephemeral range (2000029999) is technically unallocated but
several application-protocol families have squatted on specific kinds.
**CLINK has claimed 21001 / 21002 / 21003** for Offers / Debits / Manage
(see `refs/repos/shocknet/shocknet/CLINK/`). We want CLINK-compat preserved
as we adopt it for ATM cash-in/cash-out flows.
**Rule: kind:21001kind:21099 is OFF-LIMITS for aiolabs-specific events.**
CLINK may add adjacent kinds; on-the-wire collisions with
`["clink_version"]`-tagged events aren't worth the namespace-squatting
savings.
**Suggested aiolabs band: 2200022099** for non-replaceable application
events specific to our stack. Pick incrementally; document each allocation
here so future sessions don't reuse a number.
**Settlement-receipt rotation (2026-06-02):** kind:21001 was originally
locked in for bitSpire settlement receipts (`aiolabs/lnbits#22` +
`aiolabs/spirekeeper#11`) before CLINK was in scope. Collision found
during a CLINK primer review. Settlement receipts will land on a non-21001
kind before either PR ships; rotation plan tracked on `aiolabs/spirekeeper#20`.
**Replaceable events (kind:3000039999) are a separate namespace.**
Kind:30078 (NIP-78 application-specific data) is fine for fee_config,
cassette state, fleet roster — by-design replaceable state-of-the-world
events.
### Standalone app pattern (webapp)
Modules can be deployed as standalone PWAs alongside the main webapp via a
**second Vite entry point**. Used for `activities` (sortir); may replicate
for marketplace / wallet / others.
For a new standalone app from an existing module:
1. **HTML entry**: `<app-name>.html` at project root → `src/<app-name>-app/main.ts`.
2. **App shell**: `src/<app-name>-app/{main.ts, App.vue}`.
3. **Vite config**: `vite.<app-name>.config.ts` with its own build target
and PWA scoping (see service worker rules in `webapp/CLAUDE.md`).
4. **npm script**: `dev:<app-name>` and (for the demo host) a roll-up.
The standalone Vue frontend lives in `webapp/` alongside other AIO
standalone apps (chat, forum, market, tasks, wallet, activities, libra) —
the corresponding extension repo (`~/dev/shared/extensions/<name>/` if any)
holds only the LNbits backend Python code.
---
## Matrix homeserver: Continuwuity
Self-hosted Matrix homeserver in the Rust/conduwuit lineage. Lives in
`deploy/server-deploy/modules/services/matrix.nix` for castle hosts that
opt into `services.matrix-stack.enable`. Sibling stack: LiveKit SFU,
lk-jwt-service, Element Web, Element Call SPA, `.well-known/matrix/`
delegation on the apex.
### Required config to avoid quiet breakage
- **`well_known.client` + `well_known.server`** — without these,
Continuwuity uses `server_name` (the apex) when generating user-facing
URLs like password reset links. Set to `https://matrix.${domain}` and
`matrix.${domain}:443` to mirror the JSON nginx serves at
`/.well-known/matrix/` on the apex.
- **`allow_registration = true` is safe.** It does NOT mean open
registration. Without `registration_token`/`_file` AND without the
`yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse`
flag, Continuwuity rejects self-serve signups.
### First-user bootstrap
When no admin exists yet, Continuwuity auto-generates a one-time
registration token at startup and prints it to journalctl. This
**overrides** any `registration_token_file` you might wire up. After the
first user registers, they're auto-promoted to admin.
### Admin commands
All admin ops happen in the auto-created control room via `!admin <group>
<cmd>`. Groups: `token`, `users`, `appservice`, `rooms`, `federation`,
`media`, `server`. Full reference:
<https://continuwuity.org/reference/admin/index.html>.
Token issuance flags are **mutually exclusive** — pick exactly one of
`--once`, `--max-uses N`, `--max-age <30s|5m|7d>`, `--immortal`.
### Bridges + bots
Continuwuity registers appservices via admin commands (`!admin appservice
register` with pasted YAML), not via `app_service_config_files` like
Synapse. The NixOS `services.mautrix-*.registerToSynapse` shortcut does
NOT apply — bridges go through a manual paste step. Bot-only flows
(maubot, custom matrix-nio scripts) don't need appservice registration.
## Maubot plugin development
Maubot is the standard Python plugin framework for Matrix bots; one
running maubot daemon hosts many plugin-bots. Plugin source at
`~/dev/maubot-plugins/<name>/`.
### `maubot.yaml` — `database_type` is API style, not storage backend
Valid values: `asyncpg` (modern, what `mautrix.util.async_db` provides) or
`sqlalchemy` (legacy). NOT `sqlite` or `postgres` — that's the storage
backend, chosen at the daemon level via `plugin_databases.sqlite` /
`plugin_databases.postgres`. Wrong value fails at instance start with
`RuntimeError: Unrecognized database type sqlite`.
### Parent command + freeform text + subcommands
For `!journal <text>` (parent) plus `!journal show [@user]` / `!journal
today` (subcommands), `@command.new` needs **both** flags:
```python
@command.new("journal", require_subcommand=False, arg_fallthrough=False)
@command.argument("text", pass_raw=True, required=False)
async def journal(self, evt, text=""): ...
@journal.subcommand("show", help="...")
@command.argument("user", required=False)
async def show(self, evt, user=None): ...
```
Without `require_subcommand=False`, non-subcommand input shows auto-help
instead of recording. Without `arg_fallthrough=False`, `pass_raw=True`
greedily consumes the rest, so `!journal show @alice` gets recorded as an
entry. Same pattern as upstream `maubot/reminder`.
### Multi-line freeform parent commands need `@command.passive`
The above only works when **everything is on one line**. For multi-line
content (`!journal\n- item one\n- item two`), `@command.new` silently
drops it — maubot's parser only treats *space* as the command/args
delimiter, so a newline immediately after the command name makes maubot
fail to recognise the command at all. No handler invoked, no error logged.
**Fix:** use `@command.passive` with a regex admitting any whitespace,
then dispatch subcommands manually:
```python
_JOURNAL_RE = re.compile(r"^!journal(?:[ \t\r\n]+(.*))?$", re.DOTALL)
@command.passive(regex=_JOURNAL_RE)
async def journal(self, evt, match):
rest = (match[1] or "").strip()
if not rest: ...
first_token, _, after = rest.partition("\n")[0].partition(" ")
if first_token == "show": ...
else: ... # record `rest` verbatim — multi-line preserved
```
You lose `@parent.subcommand("...")` ergonomics but gain reliability for
prose-style inputs. **Rule of thumb:** for commands whose dominant use is
free-text that may span lines, default to passive.
### Database access (asyncpg style)
```python
from mautrix.util.async_db import UpgradeTable, Connection
upgrade_table = UpgradeTable()
@upgrade_table.register(description="Initial schema")
async def upgrade_v1(conn: Connection) -> None:
await conn.execute("CREATE TABLE ...")
class MyBot(Plugin):
@classmethod
def get_db_upgrade_table(cls) -> UpgradeTable:
return upgrade_table
@command.new("foo")
async def foo(self, evt):
await self.database.execute("INSERT INTO entries VALUES ($1, $2)", a, b)
rows = await self.database.fetch("SELECT ... LIMIT 10")
```
Placeholders are `$1, $2, ...` regardless of backend; `async_db` normalizes
across asyncpg/aiosqlite.
### Iteration loop
Edit + bump `version` in `maubot.yaml`, then:
```
cd ~/dev/maubot-plugins/<plugin>
zip -j ../<plugin>.mbp maubot.yaml <plugin>.py
```
Upload via Plugins → click existing plugin → upload new `.mbp`. Instance
reload requires hitting **Save** on the instance after upload — toggling
Enabled and walking away doesn't persist.
### Wiping a plugin's data
Each plugin has its own SQLite DB under `plugin_databases.sqlite`. Cleanest
reset is the maubot UI's per-instance Database tab — `DELETE FROM <table>`
runs against the live DB without restart. Nuking the file works but loses
migration version tracking.
### Pyright false positives
`maubot` / `mautrix` imports unresolved + `.subcommand` "unknown attribute"
warnings are expected — the SDK is dynamic and pyright can't introspect
the decorators. Ignore.
---
## Upstream lnbits PR conventions
For PRs to `github.com/lnbits/lnbits`:
- **Base branch is `dev`, not `main`.** 100% of recent merged PRs target
`dev`. `main` gets release commits (`Merge branch 'dev'` + version bumps).
- **Commit titles: lowercase conventional commits.** `feat:`, `fix:`,
`chore:`, `chore(deps):`, `docs:`, `ci:`. Not capitalized.
- **Identity:** push under GitHub identity (`your-github-username`), not Forgejo.
See user-global `~/.claude/CLAUDE.md` for re-author details.
Verified against last 25 merged PRs on 2026-04-28.
---
## Documentation discipline
For any aiolabs repo with a structured `docs/` tree:
**Any commit that materially changes a database table, an API endpoint,
order/event flow, Nostr publishing convention, or CMS structure must
update the relevant note in `docs/` in the same commit.** Architecture
decisions get ADRs at `docs/adr-NNNN-<slug>.md`.
Drift between docs and code defeats the purpose. Don't park doc updates
for "later" — they get forgotten and docs lose signal.
---
## LNbits + Quasar UMD — frontend gotchas
Applies to anything rendering into LNbits' page shell: lnbits core
(`~/dev/lnbits/*`) and every extension (`~/dev/shared/extensions/*`,
`~/dev/lnbits-extensions/`). All use **Vue 3 + Quasar 2 as UMD globals**
no build step, Jinja templates with per-page JS.
**No self-closing tags.** Per Quasar's UMD rules
(https://quasar.dev/start/umd/#usage), components need explicit-close:
```html
<!-- correct -->
<q-input v-model="foo" label="Foo"></q-input>
<!-- wrong — silently broken in UMD/no-build mode -->
<q-input v-model="foo" label="Foo" />
```
Self-closing is fine in `.vue` SFCs (build step rewrites them), but
UMD-loaded templates are parsed by the browser's HTML parser, which
doesn't honor self-close on non-void elements — close tag gets implied at
the wrong place, nesting breaks silently, subsequent siblings end up
inside the prior component.
**CSS specificity trap.** LNbits applies theme overrides on Quasar
typography utilities (`.text-caption`, `.text-grey-*`) with `!important`.
Class-based CSS in an extension — *even with `!important`* — loses unless
the selector is strictly more specific. Inline `style` attrs (static or
via Vue `:style`) win without an arms race.
**Rule:** for per-element typography/color overrides on LNbits pages, use
Vue `:style` bindings, not `<style>` blocks targeting utility classes.
Background/border tweaks at card-level are fine via classes.
**Cache busting.** Static assets served with `?v={server_startup_time}`
(`lnbits/helpers.py: static_url_for`). Bumping JS requires server restart;
Jinja templates re-render every request. If browser keeps serving stale
JS after restart, hard-refresh (Ctrl+Shift+R) to bypass HTTP cache.
**Dark-mode color discipline.** Pale `bg-{color}-1` utilities render
white-on-cream under dark theme — pair every pale background with an
explicit dark text class (`bg-red-1 text-grey-9` etc).