Settle action on the cash_owed/partial_pending worklist buckets and the
settlements table; the worklist's error cell links the raw code to the
glossary entry; the platform settings dialog gains the alerts pubkey.
Three buckets render first on the worklist — cash_owed, partial_pending,
dispense_unreported (awaiting_dispense older than the threshold) — the
only ones whose meaning is "a customer is owed money". partial_pending
rows open the partial-dispense dialog pre-filled from the machine's
report: the fraction from dispensed_fiat_cents / fiat_amount and the
dispenser's error in the note, so the operator confirms a number the
hardware produced rather than typing one.
Machine detail shows a held-cash-out banner (code, time, reason) with a
Resume button; POST /machines/{id}/resume-cash-out records a
resume_cash_out op and publishes the window. The machine clears the hold
on receipt and the banner clears on its next state report.
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.
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.
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>
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>
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.
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>
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>
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>
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>
2026-06-13 22:30:05 +02:00
Renamed from templates/satmachineadmin/index.html (Browse further)