omnixient/modules/dev-env/docs/stack-overview.md
Padreug 346042f1c5 docs(dev-env): aiolabs stack overview and lnbits workflow notes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 09:59:05 +02:00

286 lines
13 KiB
Markdown

# Aiolabs stack — high-level overview
Orientation map for new contributors. The substrate is **Omnixient** (this
repo): a NixOS desktop that declaratively materialises every project
worktree below via `modules/dev-env/` + the
[`aiolabs` preset](../presets/aiolabs.nix).
```mermaid
flowchart TB
subgraph DEV["Omnixient — NixOS dev environment (this repo)"]
DEVENV["modules/dev-env + aiolabs preset<br/>worktrees · tmux · git hooks<br/>Forgejo origin + GitHub upstream forks"]
end
subgraph DEPLOY["Deploy"]
DEPLOYFLAKE["server-deploy flake<br/>host1 · host5 · host7 · host8<br/>host4 · host3 · host6 · 484"]
end
subgraph FRONT["User-facing frontends"]
direction LR
BITSPIRE["bitSpire<br/>Bitcoin ATM frontend<br/>KYC-free · Nostr-native"]
WEBAPP["webapp / 'AIO'<br/>Vue 3 + TS · Electron + PWA<br/>wallet · events · Nostr"]
end
subgraph CORE["LNbits — Lightning backend (Python / FastAPI)"]
LNBITS["lnbits core<br/>wallets · per-wallet API keys · extension runtime<br/>nostr-native transport (kind-21000, NIP-44 v2)"]
end
subgraph EXT["LNbits extensions — shared/extensions/"]
direction LR
SPIRE["spirekeeper<br/>bitSpire operator dashboard"]
NREL["nostrrelay"]
NCLI["nostrclient"]
EVENTS["events"]
NMKT["nostrmarket"]
LIBRA["libra"]
LNURLP["lnurlp"]
REST["restaurant"]
end
RELAYS(("Nostr relays<br/>event bus"))
BTC["Bitcoin / Lightning<br/>LND · CLN · Phoenixd · …"]
FAVA["Fava + Beancount<br/>double-entry ledger"]
REF["Lightning.Pub (shocknet)<br/>reference implementation for the<br/>nostr-native Lightning interface"]
DEV -. develops .-> CORE
DEV -. develops .-> FRONT
DEV -. drives .-> DEPLOY
DEPLOY -- ships --> CORE
DEPLOY -- ships --> FRONT
LNBITS -- hosts --> EXT
%% On Nostr there are no point-to-point links: the nostr-native
%% parts all meet on the relay bus, addressed by pubkey. bitSpire↔
%% LNbits (payments) and bitSpire↔spirekeeper (fleet) both resolve
%% through the relay, not over a direct edge.
BITSPIRE <-- "Lightning RPC + fleet (kind-21000)" --> RELAYS
SPIRE <-- "bitSpire fleet control" --> RELAYS
NCLI -- "LNbits ↔ relay" --> RELAYS
NREL -- "in-stack relay" --> RELAYS
EVENTS -- publishes --> RELAYS
NMKT -- marketplace --> RELAYS
WEBAPP -- subscribes --> RELAYS
%% non-Nostr edges
WEBAPP -- REST + WebSocket --> LNBITS
LNBITS -- funding source --> BTC
LIBRA -- HTTP / JSON API --> FAVA
REF -. reference impl .-> LNBITS
classDef reference stroke-dasharray: 4 4,opacity:0.65;
class REF reference;
```
## How to read this
- **Omnixient** is where you *work*. Cloning it and running the dev-env
bootstrap materialises every box below as a worktree under
`~/dev/`, set up with the right Forgejo origin and GitHub upstream
fork.
- **LNbits** is the backend platform. Everything user-visible either
*is* an LNbits extension or *talks to* LNbits. It holds wallets,
funds them from a Bitcoin/Lightning source, runs the extension
runtime, and exposes both a classic HTTP/WebSocket API and a
**nostr-native transport** — signed-event RPC over a relay
(kind-21000, NIP-44 v2) with no bearer tokens on the wire.
- **Extensions** are Python modules dropped into LNbits' extension
runtime. They're cloned independently into `shared/extensions/` so
multiple LNbits worktrees (dev/main) can share them.
- **libra** is the unusual one: it doesn't reimplement double-entry
accounting, it speaks to **Fava**'s JSON API. Fava is the web UI on
top of **Beancount** plain-text ledgers. So libra = LNbits plugin
that delegates accounting to a separate Fava service.
- **webapp** ("AIO") is the polished, abstracted user frontend. It
treats LNbits as a backend; configured via `VITE_LNBITS_BASE_URL`.
- **bitSpire** is the Bitcoin ATM frontend — a KYC-free, Nostr-native
ATM. It talks to its Lightning backend over the **nostr-native
transport** on a relay rather than HTTP, so the kiosk holds no admin
tokens: the ATM's own Nostr key *is* its credential, and LNbits
auto-provisions its wallet on first contact from that signature.
- **spirekeeper** is the operator control dashboard for bitSpire
fleets, built as an LNbits extension. It owns the operator side of a
deployment: fleet and cassette-inventory configuration, commission /
DCA distribution (parsed from each cash-out's `Payment.extra`),
operator branding, and telemetry — all carried over Nostr.
spirekeeper and bitSpire never connect directly: like every
nostr-native pair in the stack, they meet on the relay, each
addressed by its own Nostr pubkey.
- **server-deploy** is a separate flake that pins each project's
source revision and builds NixOS configurations for the production
hosts (`host8`, `host7`, `host5`, …).
> **Credit — Lightning.Pub.** LNbits' ATM-facing side — its
> nostr-native Lightning node-management interface — is modeled on
> [**Lightning.Pub**](https://github.com/shocknet/Lightning.Pub)
> (shocknet), the reference implementation for this pattern. bitSpire
> talks to LNbits the way it would talk to a Lightning.Pub node; the
> functionality lives in LNbits, but the shape of the interface is
> Lightning.Pub's.
## Future direction: Nostr-native transport
The `bitSpire ↔ LNbits` edge already runs on the nostr-native
transport — signed-event RPC over a relay, with Nostr's
key/signature model as the security primitive (auth, capabilities,
audit trail) instead of bearer tokens and TLS-only trust. The
longer-term direction is to move the *rest* of the stack's
inter-service edges the same way, so `webapp ↔ LNbits` and
`libra ↔ Fava` migrate from REST/WebSocket onto signed Nostr events
too. The proposed security structure is described in
[aiolabs/lnbits#9](https://git.atitlan.io/aiolabs/lnbits/issues/9).
The nostrclient/nostrrelay extensions already make LNbits a
first-class Nostr participant; the issue tracks the broader
auth/permissions design that has to land before the rest of the stack
can follow.
The same model is the candidate for **unified authentication**
across the whole platform: instead of each service running its own
account system (LNbits accounts, Authentik SSO, Forgejo logins, …),
a user's Nostr keypair becomes the identity and per-service
capabilities are granted as signed permission events. One identity,
one signing key, role-based access fanned out across LNbits,
Authentik, Forgejo and every other internal service.
## Deployment model: location-portable instances
The stack is designed to be **deployed anywhere** — every venue (a
co-living house, a makerspace, a community space, a bar running an
ATM) runs its own full instance: LNbits + the relevant extensions +
a local Nostr relay + supporting services. Each location is its own
sovereign tenant; nothing has to phone home to a central server.
Because identity lives in the user's Nostr keypair (see above)
rather than in any per-location account, the user experience is
*roaming*: when someone walks into a new location they point their
client at that location's relay and the same app surface comes up,
populated with that location's data — their local membership,
balances, ledger position, events. Identity travels with the user;
data stays scoped to the place.
The `server-deploy` flake already supports this shape — `host8`,
`host7`, `host4`, `host5` etc. are independent instances built
from the same source tree.
## Target architecture (Nostr-native, location-portable)
If everything above lands, the picture collapses dramatically. There
are no per-edge transports to draw and no per-service auth boxes —
just **one identity**, **one client surface**, and **N self-similar
location instances** that the client roams between by switching
relays.
```mermaid
flowchart TB
subgraph USER["The user"]
KEY["🔑 Nostr keypair<br/>identity · auth · permissions"]
CLIENT["Client surface<br/>webapp · bitSpire · future apps"]
KEY -. signs every event .-> CLIENT
end
subgraph LOC_A["Location A — e.g. Castle"]
RELAY_A(("Nostr relay"))
SVCS_A["LNbits + extensions<br/>Fava / Beancount<br/>Authentik · Forgejo<br/>Bitcoin / Lightning"]
SVCS_A <-- Nostr events --> RELAY_A
end
subgraph LOC_B["Location B"]
RELAY_B(("Nostr relay"))
SVCS_B["same stack<br/>per-location data"]
SVCS_B <-- Nostr events --> RELAY_B
end
subgraph LOC_N["Location N …"]
RELAY_N(("Nostr relay"))
SVCS_N["same stack<br/>per-location data"]
SVCS_N <-- Nostr events --> RELAY_N
end
CLIENT == currently connected ==> RELAY_A
CLIENT -. roam .-> RELAY_B
CLIENT -. roam .-> RELAY_N
```
Read this against the first diagram and the simplification is the
point: the tangle of REST/WebSocket arrows becomes a single
event-bus edge per service, the auth boxes disappear into the
keypair, and every location is the same shape. New locations are
just another copy of the bubble; new client apps are just another
signer using the same key.
## The ATM as cash ↔ sats bridge
The picture above is the *sovereign* half of the story. The other
half is that people still live in a **cash** economy — and at each
location the ATM (`bitSpire`) is the protected portal between the two
worlds. It sits inside the location instance and acts as a one-stop,
identity-preserving bridge:
- **Cash stays anonymous.** Walking up to the ATM with a banknote
needs no account, no KYC, no Nostr key. Cash keeps its
permissionless, locally-spendable nature — useful at every local
merchant whether or not they speak Lightning.
- **Onramp: cash → sats.** The ATM credits the user's LNbits wallet
at this location. The wallet is **custodial for now** — the
operator holds the sats, the way a bartender holds a cash tab. The
user trusts the operator to "carry the cash" between visits; the
trust is bounded by the escape hatch below.
- **Offramp: sats → cash.** The user can spend that location-wallet
balance back out as cash through the same ATM, or use it directly
with local merchants and LNbits extensions at the venue. No KYC
is required in either direction.
- **Escape to self-custody.** The custodial wallet is a checkpoint,
not a destination. One tap pulls sats out to the user's **cold
on-chain Bitcoin wallet** or to their **own self-custodied
Lightning wallet** — so anyone uncomfortable trusting the operator
can leave at any time with their full balance.
```mermaid
flowchart LR
subgraph FIAT["Local fiat economy"]
CASH["💵 Cash<br/>anonymous · permissionless<br/>spendable at local merchants"]
end
subgraph LOC["Location instance"]
direction TB
ATM["🏧 bitSpire ATM<br/>protected portal<br/>no KYC"]
WALLET["⚡ LNbits wallet<br/>custodial · operator-held<br/>Nostr key = login"]
STACK["LNbits + extensions<br/>local Nostr relay"]
ATM <-- "nostr-native transport" --> WALLET
WALLET --- STACK
end
subgraph SELF["User self-custody (off-platform)"]
COLD["❄️ cold on-chain<br/>Bitcoin wallet"]
OWNLN["⚡ own Lightning<br/>wallet / node"]
end
CASH <-- "cash ↔ sats" --> ATM
WALLET == "withdraw on-chain" ==> COLD
WALLET == "withdraw via LN" ==> OWNLN
```
The ATM is therefore the **gateway** that lets a user move value
freely between each location's legacy cash economy and the
Nostr-native ecosystem — without surrendering the anonymity that
cash and a Nostr keypair each provide on their own side of the
boundary. Cash works as cash; sats work as sats; the ATM is the
seam, and crossing it doesn't cost you your identity.
## Where the source lives
| Box | Path under `~/dev/` | Origin (Forgejo) |
|----------------------|--------------------------------|------------------------------------|
| Omnixient | (this repo, `/etc/nixos`) | `aiolabs/omnixient` |
| LNbits | `lnbits/{dev,main}` | `aiolabs/lnbits` (no fork) |
| Extensions | `shared/extensions/<name>` | `aiolabs/<name>` |
| spirekeeper | `shared/extensions/spirekeeper`| `aiolabs/spirekeeper` |
| webapp | `webapp/` | `aiolabs/webapp` |
| bitSpire | `bitspire/bitspire/{main,dev}` | `aiolabs/bitspire` |
| atm-tui | `bitspire/atm-tui` | `aiolabs/atm-tui` |
| server-deploy | `deploy/server-deploy` | `aiolabs/server-deploy` |
| nips (reference) | `nostr-protocol/nips` | nostr-protocol/nips (read-only) |
| regtest | `local/docker/regtest` | `aiolabs/regtest` |
> Lightning.Pub is kept as a read-only reference mirror under
> `~/dev/refs/` (see `refs.toml`), not as a runtime component.