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

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/ (see refs.toml), not as a runtime component.