Commit graph

303 commits

Author SHA1 Message Date
d09c51f277 Merge pull request 'Publish cassette operations instead of counts' (#46) from feat/cassette-ops-publisher into main
Some checks failed
ci.yml / Merge pull request 'Publish cassette operations instead of counts' (#46) from feat/cassette-ops-publisher into main (push) Failing after 0s
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
v0.1.6
Reviewed-on: #46
2026-09-23 21:16:33 +00:00
5f60b3fe31 fix(cassettes): break the same-second tie with the machine's counter
Some checks failed
ci.yml / fix(cassettes): break the same-second tie with the machine's counter (pull_request) Failing after 0s
The ordering gate compares created_at, which NIP-01 defines at one-second
granularity. A dispense and the publish that follows it land inside one
second routinely, so the report was dropped and the operator kept the
pre-dispense count until the next heartbeat five minutes later.

The machine bumps a counter on every local change to a bay count and
carries it in its state document. m015 stores it per row, and the gate
consults it only when the stamps are equal, where created_at carries no
information at all.

Only on equality, deliberately. A machine whose state.db was replaced
restarts its counter at zero while its wall clock keeps moving forward;
gating on the counter across different stamps would lock that machine out
for good. Equal stamps with no counter on either side stay closed, which
costs one heartbeat and risks nothing.
2026-09-23 12:58:04 +02:00
79a4f83293 feat(cassettes): record operations from the dashboard instead of counts
The cassettes tab no longer has editable count fields, because there is
no longer an endpoint that would accept them. The bays render read-only
from the machine's own report, and a Record-operation dialog captures
what the operator did: a refill in notes added, an empty, a recount, a
denomination change.

This removes the failure the tab used to invite. A form loaded before a
dispense held a count that was already wrong, and publishing it
overwrote the dispense with no error on either side. Recording a delta
instead means a dispense that happened while the dialog was open is kept
rather than discarded, and a recount is now an explicit act — what an
operator opening a bay and counting actually does — rather than being
indistinguishable from a stale form.

A recent-operations list shows each one as Applied or Pending from
acked_at, which is the machine echoing the id back. Pending needs no
retry button: every publish carries the recent window, so an operation
that missed its own publish keeps being re-offered until it lands, and
saying so in the panel is more useful than a button that would do
nothing new.

The counts-uncertain banner tells the operator when the machine cannot
vouch for its own numbers and asks for the recount that clears it. The
machine row is re-read on every cassette refresh, since that flag is set
by the consumer while the dialog is open.
2026-09-23 12:50:08 +02:00
c76a1bb125 feat(cassettes): persist the machine's counts-uncertain marker
The machine has been publishing counts_uncertain_since since v1 of the
state document and spirekeeper has been parsing it into a field nobody
read. That defeats the point of the marker: it exists so a human opens
the bay and recounts.

A dispenser can throw, or time out, after notes have physically moved.
The machine cannot know how many left, so rather than decrement a number
it would be guessing at, it stamps the moment (bitspire ADR-004,
decision 3). m014 gives that stamp a home on dca_machines, and the
consumer mirrors it on every state event.

Stored on the machine rather than the bay because the uncertainty is
about the dispense as a whole; a multi-bay dispense that fails midway
gives no reliable way to attribute it to one position.

Written through even when the machine reports None. The machine clearing
the marker is as important as setting it — the operator recounted, the
bay is trustworthy again — and a banner that never goes away is a banner
nobody reads.
2026-09-23 12:47:30 +02:00
2ac3e2064e feat(cassettes): swap the count-publish endpoint for operation endpoints
The operator can no longer write a count. POST .../cassettes/ops records
one operation — refill, empty, recount, set_denomination — and publishes
the machine's recent window; GET .../cassettes/ops lists them newest
first with acked_at, so the dashboard can tell a delivered operation from
one merely sent.

POST .../cassettes/publish is gone, along with update_cassette_config and
UpsertCassetteConfigData. Nothing in the operator can now set a count,
which is the point: a value with one writer cannot be clobbered. Under
the old endpoint a dashboard form loaded before a dispense silently
discarded that dispense on publish, and neither side could detect it —
addressable events order by created_at at second granularity and a relay
returns OK for an event it then drops, so the losing writer is never told.

The op is recorded before the publish and is deliberately not rolled back
when the publish fails. It records something that physically happened;
notes went into a bay whether or not a relay was reachable. The window
carries recent operations rather than just the newest, so an op that
missed its own publish rides out with the next one.

Validation rejects an unpaired machine and a position the machine has not
reported. Bay count stays hardware-determined.
2026-09-23 12:44:18 +02:00
3d8368bcc4 feat(cassettes): consume the machine's operation acknowledgements
The machine echoes the operation ids it has applied in its state
document, and this records them. That echo is the only acknowledgement
this transport can carry: an addressable event gives its publisher no
failure signal at all, since the relay returns OK for an event it then
discards. Without it the dashboard could only ever show an operation as
sent, never as delivered.

Deliberately not gated on whether the state event advanced the counts.
The machine echoes its applied ids on every publish, heartbeats included,
so an event carrying nothing new about the counts can still be the first
one to tell us an operation landed.

The consumer goes in before the producer on purpose. The machine does not
send applied_ops yet, and every new field on the state payload defaults to
a value meaning "this machine does not report that yet" rather than to
one that would be wrong — an absent list reads as nothing acknowledged,
which is exactly right for a machine that has applied nothing.

Also picks up seq and counts_uncertain_since, which the machine already
publishes and this side was dropping on the floor.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 12:38:12 +02:00
90bd43d6da feat(cassettes): publish operations to the ATM
The v2 operator to ATM wire. Same kind-30078 document and the same d-tag
the counts wire used, because the machine subscribes by that tag and the
document is addressable, so v2 replaces v1 in place.

Sends a WINDOW of recent operations, oldest-first, not just the newest
change. Each publish replaces the last, so a machine that was offline for
one of them would otherwise never see that operation again; carrying the
recent history means the channel heals itself without anyone noticing it
broke. Re-delivery costs nothing because every op carries an id the
machine dedups on.

Tests pin the contract rather than the implementation: the d-tag, that
the payload declares v2 and carries no positions key, that window order
survives the publisher untouched, that an empty window still ships a
well-formed document so a machine can tell "no operations" from "operator
still on v1", and that an npub entered in the UI is normalised to hex —
get that last one wrong and the machine's subscription filter silently
never matches.

Additive. The endpoints still publish counts until the next commit, so
the tree is not left half-switched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 12:35:42 +02:00
b2157db219 feat(cassettes): record and read operator operations
Append-only, and deliberately nothing here writes cassette_configs. That
table now holds only what the machine has reported; letting an operation
write it would put back the second writer this whole design exists to
remove.

get_cassette_ops_window returns oldest-first because order is meaning: a
recount followed by a refill is not the same as the reverse. It takes the
most recent N and reverses, so the window slides without the machine ever
seeing them out of sequence.

The window is what makes the channel self-healing, so it has to cover a
plausible outage rather than just the newest change — a machine that
missed one event still sees the operation in the next.

_should_ack_op is extracted pure, the same way the state-event gate is,
because three of its rules are easy to get wrong and none need a database
to test: an unknown id closes out nothing, one machine must never be able
to ack another machine's operation, and the FIRST acknowledgement is the
one worth keeping. That last one matters because the machine echoes a
window, so every id comes back many times over; overwriting would keep
sliding the timestamp forward and lose when the operation actually landed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 12:33:46 +02:00
d8190375a6 feat(cassettes): schema and models for operator operations
First piece of the v2 wire (bitspire ADR-004). The operator stops
publishing counts and starts publishing what it DID; the machine, which
holds the notes, keeps the running total. A value with one writer cannot
be clobbered, which is the whole point: the absolute-count wire let a
form loaded before a dispense discard that dispense when published, and
nothing in an addressable event can tell the loser it lost.

m013 adds cassette_ops, append-only. The id is minted here and is the
idempotency key the machine dedups on, because a delta applied twice is
wrong and addressable events are re-delivered on reconnect. acked_at is
set when the machine reports that id back, which is the only
acknowledgement this transport can carry.

The models enforce that an op carries exactly the one field its type
means, so an instance is publishable by construction — the same contract
FeeConfigPayload has — and nulls never reach the wire for the machine to
disambiguate. recount is the only absolute, deliberately: it is what an
operator opening a bay and counting actually does, and it stays
auditable as its own act rather than looking like a stale form.

Vocabulary mirrors lamassu-server's cash_unit_operation_type.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 09:50:47 +02:00
30870ebd16 Merge pull request 'fix(cassettes): order ATM state events, and reconcile the bay set' (#44) from fix/cassette-state-reconcile into main
Some checks failed
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
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
v0.1.5
Reviewed-on: #44
2026-09-22 20:30:44 +00:00
106da5b46b fix(cassettes): drop bays the machine no longer reports
Some checks failed
ci.yml / fix(cassettes): drop bays the machine no longer reports (pull_request) 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>
2026-09-22 22:23:40 +02:00
27449e1d11 fix(cassettes): order state events by created_at, not by one remembered id
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>
2026-09-22 22:23:19 +02:00
08eea1ba16 Merge pull request 'feat(transport): get_machine_config RPC — server-delivered machine config (#41)' (#42) from feat/get-machine-config-rpc into main
Some checks failed
ci.yml / Merge pull request 'feat(transport): get_machine_config RPC — server-delivered machine config (#41)' (#42) from feat/get-machine-config-rpc into main (push) Failing after 0s
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
v0.1.4
Reviewed-on: #42
2026-07-02 21:52:49 +00:00
1f5652b425 feat(transport): get_machine_config RPC — server-delivered machine config (#41)
Some checks failed
ci.yml / feat(transport): get_machine_config RPC — server-delivered machine config (#41) (pull_request) Failing after 0s
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>
2026-07-02 21:54:40 +02:00
eefadb6c20 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
Some checks failed
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
Reviewed-on: #40
2026-07-02 18:56:04 +00:00
282d8e75c5 fix(ui): make the zoom QR actually fill the screen
Some checks failed
ci.yml / fix(ui): make the zoom QR actually fill the screen (pull_request) 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`.
2026-07-02 19:58:05 +02:00
015208500a fix(ui): make spirekeeper dialogs mobile-friendly + add fullscreen QR zoom
Some checks failed
ci.yml / fix(ui): make spirekeeper dialogs mobile-friendly + add fullscreen QR zoom (pull_request) Failing after 0s
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.
2026-07-02 19:45:11 +02:00
8b64e9c742 Merge pull request 'feat(pairing): slim the spire seed + carry lnbits_npub (bitspire-#70)' (#37) from bitspire-70-seed-lnbits-npub into main
Some checks failed
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
Reviewed-on: #37
2026-07-02 17:45:01 +00:00
2b90590104 fix(pairing): retry all bunker admin RPCs past transient timeouts (#38)
Some checks failed
ci.yml / fix(pairing): retry all bunker admin RPCs past transient timeouts (#38) (pull_request) 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>
2026-07-02 15:30:55 +02:00
0bb9939822 fix(pairing): default relay to the transport's nostrrelay, not nostrclient proxy
Some checks failed
ci.yml / fix(pairing): default relay to the transport's nostrrelay, not nostrclient proxy (pull_request) Failing after 0s
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>
2026-07-02 00:43:54 +02:00
765b07737b fix(pairing): retry get_key_tokens past a transient empty result
Some checks failed
ci.yml / fix(pairing): retry get_key_tokens past a transient empty result (pull_request) Failing after 0s
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>
2026-07-02 00:29:08 +02:00
4e36b98534 feat(pairing-ui): pre-fill the pair dialog relay with the default endpoint
Some checks failed
ci.yml / feat(pairing-ui): pre-fill the pair dialog relay with the default endpoint (pull_request) Failing after 0s
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>
2026-07-02 00:16:46 +02:00
c3791ed6c8 feat(pairing): default seed relay to the nostrclient endpoint + validate relays
Some checks failed
ci.yml / feat(pairing): default seed relay to the nostrclient endpoint + validate relays (pull_request) Failing after 0s
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>
2026-07-02 00:00:13 +02:00
9dc4d09973 feat(pairing): slim the spire seed + carry lnbits_npub (bitspire-#70)
Some checks failed
ci.yml / feat(pairing): slim the spire seed + carry lnbits_npub (bitspire-#70) (pull_request) Failing after 0s
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>
2026-07-01 21:16:10 +02:00
b93e3fb698 test(pair-endpoint): update stale pair_spire double + mock get_super_config
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>
2026-07-01 21:16:10 +02:00
a5ab02e4b6 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
Some checks failed
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
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
v0.1.3
Reviewed-on: #35
2026-06-22 15:21:12 +00:00
b55fc8bc1c fix(pairing): default bunker_relay to the spire's public event relay, not localhost
Some checks failed
ci.yml / fix(pairing): default bunker_relay to the spire's public event relay, not localhost (pull_request) 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.
2026-06-22 17:18:24 +02:00
d0d20b0f94 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
Some checks failed
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
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
v0.1.2
Reviewed-on: #33
2026-06-22 14:58:03 +00:00
8dad72a00d fix: complete the unpaired-machine sweep + regression test
Some checks failed
ci.yml / fix: complete the unpaired-machine sweep + regression test (pull_request) 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.)
2026-06-22 16:55:33 +02:00
d52a3bfafe fix: guard every machine_npub deref against unpaired machines (None)
Some checks failed
ci.yml / fix: guard every machine_npub deref against unpaired machines (None) (pull_request) Failing after 0s
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.
2026-06-22 16:45:29 +02:00
622c1be5d3 Merge pull request 'feat(cash-in): secure create_withdraw nostr-transport RPC (#31)' (#32) from feat/secure-cashin-rpc into main
Some checks failed
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
/ release (push) Has been cancelled
/ pullrequest (push) Has been cancelled
v0.1.1
Reviewed-on: #32
2026-06-22 13:54:43 +00:00
f67cb49bc3 fix(cash-in): return bech32 LNURL, not the raw URL
Some checks failed
ci.yml / fix(cash-in): return bech32 LNURL, not the raw URL (pull_request) 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.)
2026-06-22 15:32:44 +02:00
9abf695fd5 feat(cash-in): super_config.max_cash_in_sats per-tx cap + UI (#31)
Some checks failed
ci.yml / feat(cash-in): super_config.max_cash_in_sats per-tx cap + UI (#31) (pull_request) Failing after 0s
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.
2026-06-22 12:51:59 +02:00
607b71e796 feat(cash-in): secure create_withdraw nostr-transport RPC (#31)
Some checks failed
ci.yml / feat(cash-in): secure `create_withdraw` nostr-transport RPC (#31) (pull_request) Failing after 0s
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).
2026-06-22 12:21:23 +02:00
56ac4a69e9 Merge pull request 'fix(settlements): process cash-in (outbound) payments, not just cash-out' (#30) from fix/cash-in-settlement into main
Some checks failed
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
Reviewed-on: #30
2026-06-22 10:19:44 +00:00
7b55dc152b fix(settlements): process cash-in (outbound) payments, not just cash-out
Some checks failed
ci.yml / fix(settlements): process cash-in (outbound) payments, not just cash-out (pull_request) 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).
2026-06-21 17:27:58 +02:00
490605333c Merge pull request 'feat(pairing,ui): optional machine_npub + bunker_relay override + fee decimal-input UX' (#29) from feat/optional-machine-npub-pairing-ux into main
Some checks failed
ci.yml / Merge pull request 'feat(pairing,ui): optional machine_npub + bunker_relay override + fee decimal-input UX' (#29) from feat/optional-machine-npub-pairing-ux into main (push) Failing after 0s
Reviewed-on: #29
2026-06-21 13:41:57 +00:00
73bd274979 feat(pairing,ui): optional machine_npub + bunker_relay override + fee decimal-input UX
Some checks failed
ci.yml / feat(pairing,ui): optional machine_npub + bunker_relay override + fee decimal-input UX (pull_request) Failing after 0s
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>
2026-06-21 12:31:55 +02:00
47b7efc53c Merge pull request 'docs(pairing): TTL + token-revoke now enforced post-bind (nsecbunkerd#27)' (#28) from docs/ttl-revoke-enforced-post-bind into main
Some checks failed
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
Reviewed-on: #28
2026-06-20 10:05:36 +00:00
b193f6262d docs(pairing): TTL + token-revoke now enforced post-bind (nsecbunkerd#27)
Some checks failed
ci.yml / docs(pairing): TTL + token-revoke now enforced post-bind (nsecbunkerd#27) (pull_request) 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>
2026-06-19 23:23:11 +02:00
cc8d786331 Merge pull request 'fix(fleet-ui): pair-dialog v-else compiler error + TTL docstring correction' (#27) from fix/pair-dialog-and-ttl-doc into main
Some checks failed
ci.yml / Merge pull request 'fix(fleet-ui): pair-dialog v-else compiler error + TTL docstring correction' (#27) from fix/pair-dialog-and-ttl-doc into main (push) Failing after 0s
Reviewed-on: #27
2026-06-18 23:01:15 +00:00
554b2e2e17 docs(pairing): correct duration_hours TTL docstring
Some checks failed
ci.yml / docs(pairing): correct duration_hours TTL docstring (pull_request) Failing after 0s
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>
2026-06-19 00:59:29 +02:00
76090ab5da fix(fleet-ui): wrap pair-dialog steps in template v-if/v-else
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>
2026-06-19 00:59:29 +02:00
22678dfb4f feat(pairing): authorize kind-22242 (NIP-42 AUTH) in spire policy (#52)
Some checks failed
ci.yml / feat(pairing): authorize kind-22242 (NIP-42 AUTH) in spire policy (#52) (pull_request) Failing after 0s
ci.yml / feat(pairing): authorize kind-22242 (NIP-42 AUTH) in spire policy (#52) (push) Failing after 0s
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>
2026-06-18 17:29:43 +00:00
a18f653ca7 feat(ui): Fleet Pair / Revoke spire UI (#9/#12)
Some checks failed
ci.yml / feat(ui): Fleet Pair / Revoke spire UI (#9/#12) (push) Failing after 0s
Operator-facing front for POST /machines/{id}/pair + /revoke (#21/#23):
  - Pairing chip per machine row (paired / not-paired + paired-at tooltip).
  - 'Pair' (qr_code_2) opens a dialog -> relays + optional duration_hours
    -> POST /pair -> renders the seed_url as <lnbits-qrcode> + copy, shows
    the bunker-minted spire npub. Re-pair relabels.
  - 'Revoke' (link_off, shown when paired) -> confirm -> POST /revoke ->
    updates the row, reports revoked_count (>=1 cut / 0 never-bound).
  - Row reflects the bunker-minted identity immediately (machine_npub <-
    spire_pubkey_hex, paired_at).

Quasar-UMD conventions: explicit close tags, ${ } delimiters, :style.
JS syntax-checked, conforms to .prettierrc; 210 backend tests unaffected.
Needs a manual browser smoke (superuser-gated page).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 17:29:33 +00:00
846d5d9d0f Merge pull request 'feat(pairing): optional token TTL + revoke endpoint (#9/#12, #22)' (#23) from feat/pairing-revoke-ttl into main
Some checks failed
ci.yml / Merge pull request 'feat(pairing): optional token TTL + revoke endpoint (#9/#12, #22)' (#23) from feat/pairing-revoke-ttl into main (push) Failing after 0s
Reviewed-on: #23
2026-06-18 17:09:01 +00:00
4db5c3de4e Merge branch 'main' into feat/pairing-revoke-ttl
Some checks failed
ci.yml / Merge branch 'main' into feat/pairing-revoke-ttl (pull_request) Failing after 0s
2026-06-18 17:08:44 +00:00
32959d1533 Merge pull request 'feat(pairing): seed-URL pairing — operator-side producer (S0 / #9)' (#21) from feat/seed-url-pairing into main
Some checks failed
ci.yml / Merge pull request 'feat(pairing): seed-URL pairing — operator-side producer (S0 / #9)' (#21) from feat/seed-url-pairing into main (push) Failing after 0s
Reviewed-on: #21
2026-06-18 17:03:34 +00:00
a5efdf22a1 feat(pairing): optional token TTL + revoke endpoint (#9/#12, #22)
Some checks failed
ci.yml / feat(pairing): optional token TTL + revoke endpoint (#9/#12, #22) (pull_request) Failing after 0s
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>
2026-06-18 18:51:54 +02:00
9c5f07c72e refactor(pairing): use lnbits' public ensure_policy, drop fork duplicate (#9)
Some checks failed
ci.yml / refactor(pairing): use lnbits' public ensure_policy, drop fork duplicate (#9) (pull_request) Failing after 0s
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>
2026-06-18 18:31:21 +02:00