13 KiB
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.
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 (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.
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.
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.
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/(seerefs.toml), not as a runtime component.