ci.yml / Merge pull request 'fix(cassettes): order ATM state events, and reconcile the bay set' (#44) from fix/cassette-state-reconcile into main (push) Failing after 0s
apply_reported_state upserted the positions in the payload and left every
other row untouched. When a machine's bay count shrank, the stale row
stayed: the dashboard showed a mix of old and new bays, and publish
validation then rejected every operator payload for a position-set
mismatch. The error text told the operator to fix it with atm-tui, which
could not propagate either, so the only way out was DELETE FROM by hand.
The payload is the machine's full bay set and the machine owns that layout,
so positions absent from it are now deleted. The delete runs before the
upserts: since this data layer commits per statement, a crash between the
two leaves rows missing rather than stale, and the next heartbeat
re-inserts them — the safe direction of the two.
Refs #43
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The gate on the ATM-state consumer compared the incoming event id against
the id stored on a single arbitrary row (SELECT ... LIMIT 1, no ORDER BY).
That is a one-event memory, not a watermark: a re-delivered A, B, A applied
three times. Worse, created_at was parsed, written to state_at and then
never compared, so an event arriving late overwrote newer state — nothing
in the path ever looked at the clock.
Events are now applied only when strictly newer than the OLDEST state stamp
on file. Strict '>' subsumes replay dedup, since a replay carries the same
stamp. Oldest rather than newest is deliberate: every execute in this data
layer commits on its own, so a multi-row apply cannot be made atomic here,
and gating on the oldest means a crash mid-apply is re-applied on the next
event instead of being mistaken for a complete one. The ATM republishes on
a heartbeat, so it converges.
Stamps are compared as unix floats because SQLite returns integers,
Postgres returns timestamps and the incoming value is tz-aware; comparing
raw would either raise or quietly mislead. An unparseable incoming stamp
fails closed.
Also renames apply_bootstrap_state to apply_reported_state and corrects the
module comments. There has never been a once-per-machine guard, so calling
it a one-shot bootstrap consumer described something the code did not do.
Refs #43
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A paired ATM pulls its operator pubkey + fee config over the already-
authenticated kind-21000 transport, instead of the operator pubkey being
provisioned into the machine's .env and the fee config learned only from an
operator-signed kind-30078 broadcast. This lets a seed-only machine (blank
.env, scanned spire-seed) leave "awaiting configuration" with zero per-machine
provisioning — closing the gap surfaced by bitspire#70. Client half: bitspire#71.
The transport is already per-machine authenticated (the ATM signs with its
bunker-minted spire key == dca_machines.machine_npub), so the handler resolves
the exact machine → operator from the verified request.sender_pubkey — same
lookup as cashin_transport, no client-supplied trust — and returns only the
caller's own config. Returns {operator_pubkey, fee_config, fiat_code,
machine_npub, wallet_id, created_at}; fee_config is None until the operator has
a super-config. Reuses build_fee_payload + get_account; AUTH_ACCOUNT; registers
soft-failing (older lnbits) like the roster hook. kind-30078 push stays
dual-run for live mid-run updates.
6 tests; full suite 235 pass; black + ruff clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ci.yml / Merge pull request 'fix(ui): make admin dialogs mobile-friendly + add fullscreen QR zoom' (#40) from fix/mobile-friendly-dialogs-and-QR-zoom into main (push) Failing after 0s
lnbits-qrcode has no `options` prop — it renders width:100% of its
parent, capped by a `max-width` PROP that defaults to 450px. So the
previous `:options="{width: qrZoom.size}"` was dead: the QR only ever
grew to its container, and inside the zoom dialog's items-center flex
column the container collapsed to the QR's natural width, so "enlarge"
barely changed anything.
Wrap the zoom QR in an explicit full-width div (capped 96vw), feed the
real `max-width` prop, hide the component's own copy/download buttons
for a clean scan target, and size it to ~full shorter-viewport-edge.
Also swap the inline pair-dialog QR's dead `:options` for `max-width`.
The `min-width: 480px` on dialog cards overrode `max-width: 95vw` on
narrow viewports (CSS min-width wins on conflict), so add-machine / pair
/ etc. dialogs overflowed off-screen on phones. Switch every dialog card
from minWidth to width so maxWidth can shrink it to fit.
Also cap the tall add-machine form section at 65vh with a scroll
container so all fields stay reachable on short screens.
For pairing, the seed-URL QR is now tappable and has an "Enlarge for
scanning" button opening a fullscreen maximized QR sized to the shorter
viewport edge — maximises visibility to the spire's scan camera.
ci.yml / Merge pull request 'feat(pairing): slim the spire seed + carry lnbits_npub (bitspire-#70)' (#37) from bitspire-70-seed-lnbits-npub into main (push) Failing after 0s
The get_key_tokens retry only covered an empty token list; a transient
NsecBunkerTimeoutError on any admin RPC still failed pairing with a 502 (seen
live on aio-demo: create_new_key and get_key_tokens both 15s-timed-out, then a
manual retry succeeded). Generalise to `_bunker_retry`, wrapping every admin
call in the pair_spire chain (create_new_key, ensure_policy, create_new_token,
get_key_tokens): a NsecBunkerTimeoutError (and, for get_key_tokens, an empty
list) is transient → retry with backoff; a NsecBunkerRpcError rejection or
misconfig is terminal → fail fast. create_new_key is replace-by-name and
ensure_policy reconciles idempotently, so retrying on timeout is safe.
Also: _validate_relay now rejects a host-less ws:// (mint was laxer than the
consumer's parseSpireSeed), and stale "nostrclient" comments in the pair UI +
a test are corrected to "transport relay".
229 tests pass (incl. timeout-retry + fail-fast-rejection coverage).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The nostrclient endpoint is a subscription MULTIPLEXER, not a full relay: its
router forwards a client's EVENT upstream but never returns an OK ack (see
nostrclient/router.py). A transport client that awaits OK on publish therefore
times out ("publish timed out"), so kind-21000 RPCs never complete — verified
on the Sintra: connect succeeded but list_wallets hung, and switching to the
nostrrelay endpoint made the whole flow work (wallet, balance, availability).
default_relay_endpoint now derives from settings.nostr_transport_relays — the
relay the transport actually listens on: use it as-is when already
machine-reachable, or re-home its path on lnbits_baseurl when it's a co-located
loopback relay (the bundled nostrrelay). Validation/localhost-reject and the
pair-dialog pre-fill/hint carry over; wording updated to "transport relay".
227 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pair_spire failed with "bunker returned no tokens after create_new_token" when
the nsecbunkerd was briefly slow — get_key_tokens listed nothing in the few ms
after create_new_token before the write landed. Observed live: a nip44_decrypt
timeout, then a 502 on pair, then the identical call 25s later succeeding.
Retry get_key_tokens up to 5x with a 0.4s backoff before giving up, so pairing
survives a sluggish bunker instead of flaking. Tests cover the transient-empty
recovery and the exhausted-attempts failure.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The backend now defaults an omitted relay to the nostrclient proxy endpoint,
but the pair dialog still showed an empty, client-side-required field — so the
operator saw "no default". Wire it through:
- GET /api/v1/dca/default-relay returns default_relay_endpoint() (the derived
ws(s)://<host>/nostrclient/api/v1/relay).
- openPairDialog pre-fills the relay textarea with it; the operator can override
or clear it (blank → same server-side default). Drops the client-side
"at least one relay is required" guard.
- Hint updated to explain the default.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two robustness fixes for on-machine pairing (bitspire-#70), after a QR scan
silently corrupted a seed's relay (ws://→As://) and crash-looped a machine on
an unreachable relay:
- Default relays: when the operator omits `relays`, derive the seed's relay from
THIS lnbits' own nostrclient proxy endpoint — `<ws(s)>://<host>/nostrclient/api/v1/relay`,
built from `lnbits_baseurl` (default_relay_endpoint). Operators configure
upstream relays once in the nostrclient extension (public_ws must be on) and
every seed points at one stable, operator-independent URL. `relays` is now
optional on PairMachineData + the /pair endpoint.
- Validate every relay (+ bunker_relay) is a `ws://`/`wss://` URL AND reject
loopback hosts (localhost/127.0.0.1/::1/0.0.0.0) — a seed is redeemed by a
REMOTE machine, so a localhost relay is exactly the unreachable case. Catches
both the ws://→As:// corruption and the localhost /pair gotcha at mint time.
Consumer side (bitspire): parseSpireSeed rejects non-ws relays, and the wizard
gained a "test relay" reachability button before committing.
223 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mint the new-shape seed the bitspire consumer now expects: the pubkey rides
once as spire_npub (consumer derives the hex + reconstructs bunker_url from
bunker_secret + bunker_relay|relays[0]), and lnbits_npub is embedded so a paired
machine reaches this lnbits' nostr-transport with nothing else provisioned.
- build_seed_url emits {spire_npub, lnbits_npub, bunker_secret, relays} and
bunker_relay only when it differs from relays[0] (omitted in the common case).
Drops spire_pubkey + the full bunker_url from the payload.
- pair_spire reads settings.nostr_transport_public_key, hex_to_npub's it, and
raises PairingError when it's empty (transport not running → can't mint a
self-sufficient seed). bunker_url is still returned in PairResult for operator
display / audit; only the seed stops embedding it.
Consumer side: bitspire packages/nostr-client/src/seed.ts + the #70 machine
wiring. Kept as v: 1 (redefined in place; no shipped seed to preserve).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The pairing endpoint tests' fake_pair predated the bunker_relay parameter
(views_api passes bunker_relay=data.bunker_relay), so it raised TypeError; and
api_pair_machine later grew a get_super_config() read for the post-pair fee
publish that the doubles never mocked, hitting a DB with no spirekeeper.super_config
table. Both were pre-existing failures on main (masked one behind the other).
Add bunker_relay to the fake_pair signature and mock get_super_config → None
(the happy path doesn't exercise fee publishing). Suite green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ci.yml / Merge pull request 'fix(pairing): default bunker_relay to the spire's public event relay, not localhost' (#35) from fix/pair-bunker-relay-default into main (push) Failing after 0s
The seed minted via the Pair UI baked an unreachable bunker relay into
bunker_url. The UI form has no bunker_relay field, so pair_spire fell back to
its default `settings.lnbits_nsec_bunker_url` — which on a deployed instance is
the INTERNAL relay lnbits uses to reach the co-located bunker (e.g.
ws://127.0.0.1:5000/nostrrelay/demo). The remote ATM can't reach localhost, so
connectNewSeed hangs -> BunkerTimeoutError "Signer Unreachable". (Flagged by
bitspire on the demo; the localhost-relay /pair gotcha the coord thread called out.)
Default bunker_relay to the spire's own public event relay (relays[0]) instead:
the bunker lives on the same operator nostrrelay the spire publishes its events
to, so that URL is machine-reachable. An explicit `bunker_relay` still overrides
for split-relay deploys. An empty override now falls back to the same default
rather than raising.
Regression test: with no (or empty) bunker_relay, bunker_url embeds relays[0]
and contains no 127.0.0.1.
NOTE: relays[0] is a pragmatic default; whether the seed should carry multiple
relays / be sourced from the operator's nostrclient relay is a follow-up.
ci.yml / Merge pull request 'fix: guard every machine_npub deref against unpaired machines (500 + cassette-consumer crash)' (#33) from fix/unpaired-machine-npub-guards into main (push) Failing after 0s
Full sweep of every machine_npub deref found one more reachable crash:
_record_rejected (tasks.py) logs machine_npub[:12], and the
assert_nostr_attribution guard now routes an unpaired machine there, so
None[:12] -> TypeError. Fall back to machine.id.
Every other deref is safe by the attribution-gate invariant: a settlement only
flows past assert_nostr_attribution (now rejecting unpaired) for a paired
machine, so the downstream distribution / parse-path / "landed" logs can't see
None; the collision-loop display already uses `(m.machine_npub or m.id)`.
Adds tests/test_unpaired_machine_guards.py: attribution rejects an unpaired
machine with the domain SettlementAttributionError (not AttributeError), and
build_state_d_tags skips it. New tests + every guard-affected suite pass.
(Two pre-existing test_pair_endpoint failures — #29 drift: fake_pair lacks
bunker_relay, and the test DB lacks super_config — are out of scope; filed
separately.)
machine_npub became nullable in #29/m011 (register-unpaired flow), but
several consumers still assumed it's non-None and crashed
`normalize_public_key(None)` with `AttributeError: 'NoneType' object has no
attribute 'startswith'`. On the demo (which had an unpaired machine) this
broke the platform-fee update (500) and spammed the cassette consumer with
errors every 2s. The #29 create/pair paths were guarded; these were missed:
- views_api `api_update_super_config`: the "republish fee to every active
machine" loop → skip unpaired (they get their config at pairing).
- cassette_transport `build_state_d_tags_for_machines`: skip unpaired (no
state-beacon d-tag yet) — the cassette-consumer loop crash.
- crud `get_machine_by_atm_pubkey_hex`: its `except (ValueError,
AssertionError)` didn't catch the AttributeError; skip unpaired before
normalize — the cassette event-handler crash.
- bitspire `assert_nostr_attribution`: reject (SettlementAttributionError) an
unpaired machine instead of crashing the payment listener.
- views_api cassettes/publish endpoint: 400 (not paired) instead of crashing
publish_to_atm.
Verified on the dev stack: with an unpaired active machine present, the
cassette consumer registers (skipping it) and runs clean — no AttributeError.
ci.yml / Merge pull request 'feat(cash-in): secure `create_withdraw` nostr-transport RPC (#31)' (#32) from feat/secure-cashin-rpc into main (push) Failing after 0s
`Lnurl.__str__` is the underlying URL, so `str(lnurl)` returned
`http://<baseurl>/withdraw/...` instead of the bech32 `LNURL1…` — wallets
need the encoded LNURL-withdraw (lud01). Use `str(lnurl.bech32)` and add
`lnurl_url` (the raw URL) alongside, mirroring withdraw's _populate_lnurl
field convention. (Note: the encoded URL still derives from LNBITS_BASEURL —
that must be an externally reachable https URL for a real wallet to claim.)
Wires the server-side per-transaction cash-in ceiling the `create_withdraw`
handler already enforces (it read the value defensively via getattr; this
makes it a first-class config field).
- migrations.py m012: ADD COLUMN super_config.max_cash_in_sats INTEGER (NULL
= no cap).
- models.py: SuperConfig.max_cash_in_sats + UpdateSuperConfigData field with a
>= 0 validator.
- super-fee dialog: a "Max cash-in per transaction (sats)" input; blank sends
null (the PUT skips null, preserving the current value — set 0 to reject
every cash-in). crud `update_super_config` and the PUT endpoint flow the
field through automatically (dynamic dict update; check_super_user gated).
Why a sats cap and not the bunker ACL: the ACL / usage caps (#28) gate call
*rate*, not *sats*, and `principal_sats` is necessarily ATM-attested — so a
single in-rate call could request an arbitrarily large payout. This bounds a
compromised/buggy machine to one capped transaction.
Verified on the dev stack: m012 runs, the model round-trips the column
(GET returns the set value), and a negative value is rejected.
Adds a server-side cash-in RPC so the ATM no longer supplies the withdraw
amount, fee, or attribution. The ATM sends a bunker-signed kind-21000
`create_withdraw` with just the gross `principal_sats` (the hardware-
attested fiat value); the handler derives everything else SERVER-SIDE:
- attribution = the VERIFIED transport `sender_pubkey` (never read from the
body), matched to an active machine on the authenticated wallet;
- fee = round(principal × super_cash_in) + round(principal × operator_cash_in),
per-leg rounding so it matches parse_settlement exactly (fee_mismatch=0);
- net = principal − fee → the withdraw amount the customer receives;
- stamps `extra={source:bitspire, type:cash_in, principal_sats, fee_sats,
nostr_sender_pubkey:<verified>, nostr_event_id}` onto the link.
The customer claims the NET link; the payout carries the stamped extra
(aiolabs/withdraw#3) and `_handle_payment` records the cash_in settlement
(spirekeeper#30) with cryptographic attribution — closing the vector where
`lnurlw_create_link` let the ATM set amount/fee/attribution freely.
Registered via `register_rpc("create_withdraw", …, AUTH_WALLET)` (extensions
register RPCs directly — withdraw already does). Soft-fails on lnbits without
`register_rpc`. Per-tx cap reads `super_config.max_cash_in_sats` defensively
(getattr) — the config field/UI is a fast-follow.
Wire schema pinned in #31. Depends on #30 (consumer-side settlement fix).
ci.yml / Merge pull request 'fix(settlements): process cash-in (outbound) payments, not just cash-out' (#30) from fix/cash-in-settlement into main (push) Failing after 0s
The `_handle_payment` cash-in branch existed but had never been exercised
end-to-end — bitSpire cash-in payouts only started reaching it once the
withdraw extension learned to stamp `source=bitspire` on an LNURL-withdraw
payout (aiolabs/withdraw#3). With that wired up, the first real cash-in
exposed two bugs:
1. `payment.sat` is signed by protocol direction — negative for an
outbound (cash-in) payout. It was passed straight to `parse_settlement`
as `wire_sats`, which enforces `wire_sats >= 0`, so every cash-in was
rejected ("wire_sats must be >= 0, got -75795"). A settlement's
`wire_sats` is a magnitude (direction lives in `tx_type`); pass
`abs(payment.sat)`. Same in `_record_rejected`.
2. `_record_rejected` hard-coded `tx_type="cash_out"`, so a rejected
cash-in showed the wrong direction in the operator dashboard. The
parsed tx_type isn't available on the rejection path, but the
authenticated protocol direction is — derive it: outbound → cash_in,
inbound → cash_out.
Verified on the dev stack: a stamped cash-in now lands a `cash_in`
settlement (net 75795, principal 82386, fee 6591), pays the super its 3%
(2472 sats), and correctly skips the DCA leg (principal stays in the
operator's wallet as liquidity from the cash-in customer).
Three changes from the nsecbunkerd#27 bunker-pairing smoke (validated
end-to-end on the Sintra, 2026-06-21); intermingled per-file, so landed
together.
1. Optional machine_npub (model A1) — register UNPAIRED, bunker mints the
identity at pairing:
- machine_npub now nullable (migration m011 rebuilds dca_machines for
sqlite / ALTER ... DROP NOT NULL for postgres; UNIQUE stays, NULLs
don't collide so any number of unpaired machines coexist).
- CreateMachineData.machine_npub -> str | None; create skips the
collision-check + fee publish when blank; api_pair_machine now
publishes the fee config after minting, so an unpaired machine clears
its awaiting-fees gate once paired.
- Supplying an npub up front is the DEVELOPMENT self-key path (a machine
holding its own signing key) — available to anyone but the form field
is explicitly marked DEVELOPMENT ONLY.
- Frontend: npub field optional, required rule dropped, null-safe
display (shortNpub -> "unpaired", guarded slices), empty -> null.
2. bunker_relay override on POST /machines/{id}/pair: PairMachineData gains
bunker_relay; api_pair_machine threads it to pair_spire. Lets the seed's
bunker:// relay differ from the relay lnbits uses to reach the bunker
(internal docker host vs LAN/public) — needed for split-relay / dev
deploys. Without it the smoke had to mint via a script.
3. Fees are decimal fractions, not percents: relabel super + operator fee
inputs ("decimal fraction, 0-0.15") + a shared _assertFeesDecimal()
guard (super/add/edit submits) so a percent typo (3 instead of 0.03)
gets a clear toast, not a raw 400.
refs: nsecbunkerd#27/#36; aiolabs/bitspire#52; coordination smoke 2026-06-21
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ci.yml / Merge pull request 'docs(pairing): TTL + token-revoke now enforced post-bind (nsecbunkerd#27)' (#28) from docs/ttl-revoke-enforced-post-bind into main (push) Failing after 0s
nsecbunkerd#27 (deployed 2026-06-19) reverses the #24 finding: the
sign-time ACL now evaluates token lifecycle live on every request
(checkIfPubkeyAllowed step 4 joins through a liveWhere filter;
applyToken stopped photocopying grants into SigningConditions). So:
- duration_hours / token expiresAt now bounds an ESTABLISHED binding —
an expired token stops signing post-bind, not just at connect. The
prior docstring (connect-window-only, pointing at the now-closed
nsecbunkerd#24) is corrected.
- Token-revoke is no longer a post-redeem no-op (closes the #22
mechanism bunker-side). revoke_spire keeps using revoke_key_user
because that's the subject-level ban cutting the whole binding, not
just one token's grant — rationale updated, behavior unchanged.
Doc/comment only; 20 pairing tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
duration_hours stamps Token.expiresAt, but nsecbunkerd reads expiresAt
only in validateToken at connect/redeem time — the sign-time ACL never
checks it (materialised SigningConditions carry no expiry; the policy
join filters revokedAt only). So TTL bounds only the un-redeemed connect
window, not an established binding; revoke_key_user is the real post-bind
cutoff. Same ACL-ordering class as the revoke finding (#22). Tracked at
aiolabs/nsecbunkerd#24.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Pair dialog had two interleaved v-if/v-else sibling pairs
(q-card-section + q-card-actions per step). Vue requires v-else to
immediately follow its v-if sibling, so the second v-else (actions)
trailed a v-else (section) — illegal, throwing compiler error 30
("v-else has no adjacent v-if") and breaking the entire Vue mount.
Wrap each step's section+actions in one <template v-if> / <template
v-else> so there's exactly one adjacent pair. Verified with
@vue/compiler-dom and a live pair/revoke round-trip against regtest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
bitspire#52 consumer review (2026-06-18) enumerated the kinds the spire
signs as its OWN identity and found NIP-42 relay AUTH (kind 22242) missing
from SPIRE_POLICY_RULES — a silent bunker reject the moment a relay
challenges with AUTH. It must be bunker-signed (AUTH proves control of
spire_pubkey, which only the bunker holds; can't use the local client_nsec).
Adds 22242. Records the confirmed set in the policy comment: live = 21000 +
30078 + 22242; CLINK 21001-21003 dormant but kept; nip04 unused (v1 path is
dead code). New test locks the required-kinds contract so 22242 can't
silently regress.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Builds on the seed-URL pairing in #21 (stacked).
(b) TTL — PairMachineData.duration_hours (validated > 0) threads through
pair_spire -> create_new_token (lnbits#55). None = non-expiring.
(c) Revoke — POST /machines/{id}/revoke -> revoke_spire ->
admin_client.revoke_key_user(spire-<id>). Per spirekeeper#22, revoke
MUST go through KeyUser.revokedAt (revoke_key_user), NOT token revoke:
lnbits eager-binds (redeems) the connect token at provision, so
nsecbunkerd has materialised the policy into per-KeyUser grants its
ACL checks BEFORE the Token.revokedAt filter -> token revoke is a
silent no-op. Returns RevokeResult{revoked_count}: >=1 = cut, 0 =
never bound. set_machine_unpaired clears paired_at (keeps npub +
bunker_spire_key_name for audit / re-pair).
7 new tests (duration threading + default-None; revoke routes to
revoke_key_user and never token-revoke + error mapping; endpoint wiring
revoke happy/zero/502). 210 green; new code black/ruff-clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adopts aiolabs/lnbits#55 (merged b5fba561): pair_spire now calls the
public ensure_policy(client, name='spirekeeper-spire', rules=...,
methods_no_kind=...) instead of spirekeeper's cache-free
_ensure_spire_policy copy. #55 re-keyed _POLICY_ID_CACHE on
(admin_pubkey, policy_name), so the shared helper no longer returns the
wrong (lnbits-default) id for a non-default policy name — the exact
reason the duplicate existed. Net -45 LOC, one less fork-divergent
reimplementation to keep in sync.
Requires lnbits >= the #55 merge (ensure_policy importable) — already
true on dev/demo.
Tests: FakeBunker gains admin_pubkey; an autouse fixture clears lnbits'
_POLICY_ID_CACHE between tests (the shared helper caches, unlike the old
local one). 203 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator-side producer for seed-URL pairing (S0/#9, model A1). pair_spire()
orchestrates the nsecbunkerd admin chain via lnbits' NsecBunkerAdminClient:
create_new_key(spire-<id>) -> _ensure_spire_policy -> create_new_token ->
get_key_tokens -> package the <npub>#secret token into a bunker:// URL +
base64url seed URL {spire_npub, spire_pubkey, bunker_url, relays}.
The spire later self-signs all its events as that bunker-held key; lnbits'
path-B roster maps the npub to the operator wallet — no nsec on the spire.
spirekeeper does steps 1-4 only; the NIP-46 connect/bind happens spire-side
(bitspire#52) with the spire's own client keypair.
Scoped policy 'spirekeeper-spire': sign_event 21000/21001-3/30078 + nip44
(kind-less via add_policy_rule). Local _ensure_spire_policy (no cache)
avoids lnbits' admin-pubkey-keyed _ensure_policy cache (policy-name-blind).
9 unit tests with a fake bunker (orchestration, policy reconcile, seed/
bunker:// wire shape, error paths); npub<->hex via lnbits' real helpers.
200 tests green.
Known gaps (lnbits NsecBunkerAdminClient): no token-expiry param, no revoke
RPC — re-pair works; 'revoke spire access' deferred to a bunker follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Schema checkpoint for seed-URL pairing (S0 / #9; spire-side bitspire#52),
model A1 — the spire's signing key lives in the operator's nsecbunkerd,
not on the spire's disk.
dca_machines gains:
- bunker_spire_key_name — the spire's key name in the bunker
(spire-<machine_id>); used to re-issue connect tokens on re-pair.
- paired_at — last successful pair; NULL = never paired.
Both nullable, idempotent column-probe add (m009 pattern). Machine model
gains the matching optional fields. Validated on the regtest dev db
(columns present, migrations clean); 191 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the inherited v1 'Lamassu ATM' wording with the bitSpire/
Nostr-native description used in the lnbits-extensions catalog entry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to the satmachineadmin->spirekeeper issue migration. The 20
open issues were recreated on aiolabs/spirekeeper with reassigned
numbers; this repoints in-repo references to the migrated issues at
their new spirekeeper numbers (#3->#1, #4->#2, #8->#4, #9->#5, #10->#6,
#17->#11, #21->#12, #28->#16, #44->#20). References to closed/non-
migrated satmachineadmin issues (#20/#22/#26/#29/#32/#37/#38/#39) stay
pointing at the original repo where they were resolved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SQLite (cp the ext DB file) and Postgres (rename/copy schema) recipes.
Key trap documented: dbversions is keyed by ext id and m001 drops the
legacy v1 tables, so the new install must seed dbversions['spirekeeper']
>= 1 to skip m001 and preserve copied data. Validated on the regtest
dev instance (10 tables, 52 dca_payments etc. preserved across boot).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Runbook SQL (spirekeeper.dca_*), ext URL paths, code-location paths,
and the DB-schema name in docs/CLAUDE/README move to the new identity.
Rewrites the placeholder description.md with a real one. Historical
aiolabs/satmachineadmin#N issue/repo links stay pointing at the
original repo.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fork of satmachineadmin's v2-bitspire line into its own repo. Renames
both identifiers so this extension is fully independent of the original
satmachineadmin install (which remains in service):
- extension id satmachineadmin -> spirekeeper
(router prefix, static path/static_url_for, module symbols, task
names, templates dir, config/manifest paths)
- database name satoshimachine -> spirekeeper
(Database(ext_spirekeeper), all schema-qualified table refs)
Also resets versioning to 0.1.0, sets the display name + manifest to
spirekeeper/aiolabs, and fixes the placeholder pyproject description.
Historical aiolabs/satmachineadmin#N issue references in comments are
left pointing at the original repo where those issues live.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>