Commit graph

9 commits

Author SHA1 Message Date
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
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
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
a059e3f596 refactor: rename extension identity to spirekeeper
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
aeaee1f568 refactor(v2): extract kind-30078 publish primitives to nostr_publish.py (#39 1/3)
Layer 2 prep: a second consumer (fee_transport.py for #39) is about to
land that uses the same operator-signer + NIP-44 v2 + nostrclient publish
flow as cassette_transport.py. Extracting shared primitives now rather
than duplicating ~100 lines.

New `nostr_publish.py` module:
- Error hierarchy: NostrPublishError base + OperatorIdentityMissing,
  SignerUnavailable, RelayUnavailable subclasses (all transport-layer
  failures, domain-agnostic).
- `resolve_operator_signer(operator_user_id)` — fetch account + resolve
  to NostrSigner, with the can-sign + has-pubkey checks.
- `sign_as_operator(operator_user_id, event)` — wrap signer.sign_event,
  set created_at before signing.
- `nip44_encrypt_via_signer` + `nip44_decrypt_via_signer` — transitional
  LocalSigner → RemoteBunkerSigner cascade (bunker handles natively;
  LocalSigner falls back to hand-rolled NIP-44 v2 against the stored
  prvkey).
- `publish_signed_event(signed)` — nostrclient relay-manager publish
  with lazy import + RelayUnavailable on missing extension.
- High-level `publish_encrypted_kind_30078(operator_user_id,
  recipient_pubkey_hex, d_tag, payload)` — builds event, encrypts via
  signer, signs, publishes. The whole flow in one call; callers
  (cassette_transport, soon fee_transport) just specify domain.

`cassette_transport.py`:
- Imports from nostr_publish; CassetteTransportError becomes a subclass
  of NostrPublishError so existing catches still work.
- `publish_to_atm` reduces to a thin wrapper that builds the
  cassette-specific payload + d-tag and delegates to
  `publish_encrypted_kind_30078`.
- Consumer path (`decrypt_and_parse_state_event`) still owns
  cassette-specific decode/transient distinctions; uses imported
  `nip44_decrypt_via_signer`.
- Re-exports OperatorIdentityMissing / SignerUnavailable /
  RelayUnavailable so views_api can keep importing from
  cassette_transport without change.

`tasks.py` — cassette bootstrap consumer imports `resolve_operator_signer`
from nostr_publish directly instead of the cassette_transport
underscore-prefixed name.

164/164 tests green; behavior unchanged.

Refs: aiolabs/satmachineadmin#37 (parent), #39 (Layer 2, this commit
is prep).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 19:54:08 +02:00
d448fab0d2 chore(v2): lint pass — black + ruff auto-fix + mypy regressions (#29 v1.1)
Pre-merge lint hygiene on the PR #30 touched files:

- `black` reformatted 9 files (cassette_transport, crud, models, tasks,
  views_api, nip44, all 3 cassette test files, migrations). Cosmetic:
  line lengths, trailing commas, multi-line argument layout.
- `ruff check --fix` cleared 176 of 202 errors auto-fixed. Mostly
  `UP006` `typing.Optional` → `| None` modernization, `I001` import
  sort order, `UP035` typing-extensions cleanup.
- Two new mypy regressions introduced by the migration commit dcb7de0
  fixed:
  - `crud.py:apply_bootstrap_state` — annotated `existing_first: dict
    | None` on the dedup fetch.
  - `tasks.py:_cassette_consumer_tick` — `# type: ignore[arg-type]` on
    the `nostr_client.relay_manager.add_subscription` call; nostrclient's
    upstream typing declares `list[str]` for filters but the actual
    Nostr protocol takes `list[<filter-dict>]`. The runtime accepts it
    (live smoke at 13:43Z dispatched `nip44_decrypt` cleanly through
    this subscription); the typing mismatch is upstream's.

Remaining lint state, intentionally not addressed in this commit
(all pre-existing baseline, not regressions):
- 8 mypy errors in `calculations.py` + the unchanged-by-this-PR parts
  of `crud.py` — pre-existing on v2-bitspire.
- 26 ruff style warnings: 14 are N805 false-positives on Pydantic
  validators (`cls` first-arg is correct for `@validator`-decorated
  methods); 4 are N818 exception-name-suffix preferences on my new
  exception classes (renaming would touch many call sites; keep
  `OperatorIdentityMissing` / `SignerUnavailable` / `RelayUnavailable`
  / `_NostrclientUnavailable` as-is for clarity); 5 are E501 line-too-
  long on docstrings (the long lines are formatted for clarity);
  1 RUF002 unicode-minus in a docstring.

Tests: 155 passed, 1 pre-existing async-plugin failure unchanged.
Live smoke (both publish + consume directions through the bunker)
unaffected — this is purely a code-style pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 15:50:14 +02:00
dcb7de0c27 refactor(v2): cassette transport — signer.nip44_* migration (#29 v1.1 / closes #21 partial)
Migrates the cassette transport's encrypt/decrypt paths off direct
`account.prvkey` reads to `signer.nip44_encrypt` / `signer.nip44_decrypt`
on the NostrSigner ABC landed by aiolabs/lnbits PR #38 (phase 2.4). Closes
the operator-side regression flagged at coord-log 2026-05-31T06:50Z:
Greg's RemoteBunkerSigner-migrated account had `accounts.prvkey IS NULL`
post-bunker, which the old code couldn't handle — consumer was logging
WARN every poll cycle and skipping every inbound state event.

## What changed

### cassette_transport.py

- New imports: `resolve_signer`, `SignerError`, `SignerUnavailableError`,
  `NsecBunkerTimeoutError`, `NsecBunkerRpcError` from the post-#38 lnbits
  surface. (The `try: from lnbits.core.signers import SignerError` block
  in the old code was permanently failing because `SignerError` actually
  lives in `lnbits.core.signers.base`, not the package root — fixed.)
- New `_resolve_operator_signer(operator_user_id)`: single source of
  truth for "give me the operator's account + NostrSigner, or raise an
  operator-facing error." Used by both the publish path and the consumer
  task.
- New `_nip44_encrypt_via_signer(account, signer, plaintext, peer)`
  and `_nip44_decrypt_via_signer(...)`: route through `signer.nip44_*`
  first; on `SignerUnavailableError` from a LocalSigner stub (the
  post-#38 ABC has LocalSigner raise on nip44_* explicitly — bunker
  migration required for NIP-44 v2), fall back to the hand-rolled impl
  against `account.prvkey`. Transitional until every operator on the
  instance is bunker-backed (S7).
- `_sign_as_operator` simplified: now `await signer.sign_event(event)`
  (the ABC is async; the old code passed `signer.sign_event` to the
  caller without await, returning a coroutine — also broken but never
  hit because the ImportError fallback fired first).
- `publish_to_atm` flow: `_resolve_operator_signer` → `_nip44_encrypt_
  via_signer` → `_sign_as_operator` → publish. Each step maps bunker /
  signer errors to `OperatorIdentityMissing` (400) / `SignerUnavailable`
  (503) / `CassetteTransportError` (500) for the API handler.
- `decrypt_and_parse_state_event` now `async` and takes `(event, account,
  signer)` instead of `(event, operator_privkey_hex)`. Maps
  `NsecBunkerTimeoutError` → `CassetteEventTransientError` (caller
  should retry on next poll, NOT advance `state_event_id`).
  `NsecBunkerRpcError` / `SignerUnavailableError` / `Nip44Error` / etc.
  → `CassetteEventDecodeError` (terminal — caller logs + skips).
- New `CassetteEventTransientError` class for the bunker-timeout case.
  Distinct from `CassetteEventDecodeError` so the consumer can log at
  INFO + retry vs WARNING + advance.
- Deleted `_get_operator_privkey_hex` (no longer needed).

### tasks.py — _handle_cassette_state_event

- Resolves the signer via `_resolve_operator_signer(machine.operator_
  user_id)`. On `CassetteTransportError` (OperatorIdentityMissing /
  SignerUnavailable), logs + skips.
- Awaits `decrypt_and_parse_state_event(event_obj, account, signer)`.
  On `CassetteEventTransientError`, logs at INFO + returns (state_event_
  id NOT advanced → consumer retries on next poll cycle).
  On `CassetteEventDecodeError`, logs at WARNING + returns (still
  state_event_id NOT advanced for v1; the WARN log surfaces the
  underlying issue for operator triage).

### tests/test_cassette_state_consumer.py — rewritten

- Three test doubles: `_FakeBunkerSigner` (working nip44_decrypt via
  hand-rolled impl), `_FakeLocalSignerStub` (raises like the post-#38
  LocalSigner stub), `_FakeRaisingSigner` (configurable exception).
- `_fake_account` helper using SimpleNamespace — the code under test
  only reads `.signer_type` + `.prvkey`.
- Five test classes covering: bunker-signer happy path (incl. multi-
  same-denom round-trip), LocalSigner transitional fallback,
  bunker-error mapping (timeout → transient, rpc reject → decode),
  payload validation (tamper / wrong-key / missing-fields / garbage
  JSON / wrong shape), d-tag construction (unchanged, kept as
  regression guard).
- Async coroutines driven via `asyncio.run` — matches the existing
  project pattern (no pytest-asyncio plugin in CI; see test_init.py
  failure mode).

### nip44.py — docstring update

Added a "Runtime status (post lnbits PR #38, 2026-05-31)" section
documenting that runtime usage moved to `signer.nip44_*` and this
module's role narrowed to (a) the LocalSigner transitional fallback
called from `cassette_transport`, and (b) test-only fixtures in
test_nip44_v2.py for spec-vector + bitspire cross-test validation.
"Don't add new runtime call sites here. The signer abstraction is
the path."

## Verification

- 155 passed, 1 pre-existing async-plugin failure unchanged. The 19
  consumer tests cover bunker happy path + LocalSigner fallback +
  bunker error mapping + payload validation + d-tag construction.
- Live smoke against Greg's RemoteBunkerSigner-migrated account
  on the regtest container: consumer correctly resolves the bunker
  signer, fires `NIP-46 rpc -> method=nip44_decrypt`, catches the
  resulting `NsecBunkerTimeoutError` (the local nsecbunkerd is not
  responding within 15s — separate operational concern), maps to
  `CassetteEventTransientError`, logs at INFO with "will retry next
  poll", and crucially does NOT advance `state_event_id` on the
  cassette_configs rows. Retry semantics preserved.

## Outstanding

- The bunker timeout itself is an operational issue (nsecbunkerd
  config / policy / process state for kind-less nip44_decrypt RPC) —
  not a satmachineadmin code concern; surface to the nsecbunkerd /
  lnbits sessions if it persists.
- Once every operator on the instance is on RemoteBunkerSigner (S7
  fully landed), the `_nip44_*_via_signer` helpers collapse to a
  direct `await signer.nip44_*` call, the LocalSigner fallback can
  be deleted, and `nip44.py`'s runtime exports retire (test-only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 09:21:43 +02:00
5dbd7314f4 refactor(v2): cassette CRUD + transport — position-keyed (#29 v1.1)
CRUD layer flips:
  - get_cassette_config(machine_id, position) — was (..., denomination)
  - list_cassette_configs_for_machine returns ORDER BY position alone
    (no secondary denomination ordering — position is the unique key)
  - update_cassette_config(machine_id, position, data, updated_by) —
    operator edits denomination + count for a fixed slot
  - apply_bootstrap_state upserts ON CONFLICT(machine_id, position)
    iterating payload.positions; populates new state_denomination
    column from row.denomination alongside state_count

cassette_transport.py needs almost no functional change — the wire
shape is implicit via PublishCassettesPayload.to_wire_dict (now emits
{"positions": {...}}) and decrypt_and_parse_state_event accepts what
the model parses. Just the module docstring + the publish log line
get updated to reference positions rather than denominations.

Tests still red until commit f rewrites them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 22:25:27 +02:00
b9d5ea3c57 feat(v2): cassette_transport — kind-30078 publish + decrypt (#29 v1)
The Nostr-wire layer for operator ↔ ATM cassette config. Owns both
directions:

  operator → ATM (publish_to_atm):
    build PublishCassettesPayload → NIP-44 v2 encrypt to ATM pubkey →
    sign as operator via _sign_as_operator hybrid → publish through
    nostrclient.router.nostr_client.relay_manager
    d-tag: bitspire-cassettes:<atm_pubkey_hex>
    p-tag: <atm_pubkey_hex>

  ATM → operator (decrypt_and_parse_state_event):
    consumer task feeds inbound events (already sig-verified by the
    subscription layer); we NIP-44 v2 decrypt with operator privkey +
    event sender pubkey, JSON-parse, validate as PublishCassettesPayload
    d-tag: bitspire-cassettes-state:<atm_pubkey_hex>
    p-tag: <operator_pubkey_hex>

`_sign_as_operator` recovers the hybrid signer pattern from commits
131ff92 / e13178d (removed in dcd0874 for the NIP-78 fleet rip): tries
`from lnbits.core.signers import resolve_signer` first (post-#17 path),
falls back to a direct `account.prvkey` read for pre-#17 lnbits hosts.
Both paths produce identical signed events. Unlike the prior fleet-
publish that soft-failed on missing identity (CRUD side-effect), this
publish is operator-initiated so missing identity raises
OperatorIdentityMissing for the API to surface as 400.

`_atm_hex_pubkey(machine)` centralises the `<m>` placeholder rule from
the 2026-05-30T11:50Z coord-log entry: always normalize_public_key on
machine.machine_npub, NEVER use the internal dca_machines.id UUID. The
build_state_d_tags_for_machines helper exposes the canonical d-tag
list for the consumer subscription filter to use.

Typed errors map cleanly to HTTP statuses in the API caller:
  - OperatorIdentityMissing → 400 (operator hasn't onboarded)
  - SignerUnavailable → 503 (signer offline / client-side-only)
  - RelayUnavailable → 503 (nostrclient not installed)
  - CassetteEventDecodeError → consumer-side log + skip (never crash)

NIP-44 v2 ECDH needs the raw operator scalar, which the signer
abstraction's high-level sign_event doesn't expose. v1 reads
account.prvkey directly (same surface as the pre-#17 sign fallback);
post-bunker (lnbits#18) this becomes a NIP-44-over-bunker RPC and the
operator nsec leaves the LNbits host — v2 follow-up.

Smoke-tested via docker exec: round-trip publish (build → encrypt →
parse) of the realistic {"denominations": {"20": ..., "50": ...}}
payload; tamper detection on a corrupted content field; malformed
pubkey rejection.

Full suite: 132 passed, 1 skipped, 1 pre-existing async-plugin failure.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-30 18:14:16 +02:00