From f8042f8e4d1a0a1da49c7b7be303a89d11787f1f Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 30 May 2026 18:21:51 +0200 Subject: [PATCH] feat(v2): POST cassettes/publish API endpoint + ownership guard (#29 v1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two operator-scoped endpoints, both gated by check_user_exists + _machine_owned_by: GET /api/v1/dca/machines/{machine_id}/cassettes List the operator-owned machine's cassette_configs rows. Empty list means the ATM hasn't published its bootstrap event yet (or the consumer task hasn't drained it); UI shows a "waiting for ATM" state. POST /api/v1/dca/machines/{machine_id}/cassettes/publish Operator submits the full per-machine cassette state (PublishCassettes Payload) for publish to the ATM. Validates the denomination set matches what's stored (defensive — UI prevents add/remove but API enforces), upserts each row with the operator's user id as audit updated_by, then calls cassette_transport.publish_to_atm to encrypt+ sign+publish kind-30078. The path param `{machine_id}` is satmachineadmin's internal dca_machines.id UUID; the handler fetches Machine and uses machine.machine_npub canonicalised via normalize_public_key as the `` value in the d-tag bitspire-cassettes: per the locked design and the 2026-05-30T11:50Z coord-log nudge. Translation happens inside cassette_transport._atm_hex_pubkey so the API handler stays thin. Error mapping: 400 — payload denomination set doesn't match stored set (operator publishing for a cassette the ATM doesn't have, or no rows exist because the bootstrap hasn't landed) 400 — OperatorIdentityMissing (operator hasn't onboarded a Nostr identity via LNbits Nostr-login) 503 — SignerUnavailable (signer offline / client-side-only) 503 — RelayUnavailable (nostrclient extension not installed) 500 — anything else from the publish path Returns the fresh cassette_configs rows after the upserts so the UI refreshes its table from one round-trip. Total: 146 passed (route registration verified via FastAPI router introspection), 1 skipped (cross-test fixture pending), 1 pre-existing async-plugin failure unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- views_api.py | 138 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) diff --git a/views_api.py b/views_api.py index 93ceeeb..712e4d6 100644 --- a/views_api.py +++ b/views_api.py @@ -12,6 +12,13 @@ from lnbits.core.crud import get_wallet from lnbits.core.models import User from lnbits.decorators import check_super_user, check_user_exists +from .cassette_transport import ( + CassetteTransportError, + OperatorIdentityMissing, + RelayUnavailable, + SignerUnavailable, + publish_to_atm, +) from .crud import ( append_settlement_note, count_completed_legs_for_settlement, @@ -39,9 +46,11 @@ from .crud import ( get_settlements_for_operator, get_stuck_settlements_for_operator, get_super_config, + list_cassette_configs_for_machine, lp_is_onboarded, replace_commission_splits, reset_settlement_for_retry, + update_cassette_config, update_dca_client, update_deposit, update_deposit_status, @@ -55,6 +64,7 @@ from .distribution import ( ) from .models import ( AppendSettlementNoteData, + CassetteConfig, ClientBalanceSummary, CommissionSplit, CreateDcaClientData, @@ -66,6 +76,7 @@ from .models import ( DcaSettlement, Machine, PartialDispenseData, + PublishCassettesPayload, SetCommissionSplitsData, SettleBalanceData, StuckSettlementsResponse, @@ -75,6 +86,7 @@ from .models import ( UpdateDepositStatusData, UpdateMachineData, UpdateSuperConfigData, + UpsertCassetteConfigData, ) satmachineadmin_api_router = APIRouter() @@ -759,3 +771,129 @@ async def api_update_super_config( HTTPStatus.INTERNAL_SERVER_ERROR, "Failed to update super config" ) return config + + +# ============================================================================= +# Cassette configs (#29 v1) — per-machine ATM cassette inventory +# ============================================================================= +# v1 surface, paired with aiolabs/lamassu-next#56 ATM-side. Two endpoints: +# GET /machines/{id}/cassettes — list rows for the operator UI +# POST /machines/{id}/cassettes/publish — apply edits + publish kind-30078 +# +# Row creation (new (machine_id, denomination) pairs) is admin-only via the +# bootstrap consumer task — denomination set is hardware-determined. +# Operator-side flow is edit-and-publish over the existing rows only. + + +@satmachineadmin_api_router.get( + "/api/v1/dca/machines/{machine_id}/cassettes", + response_model=list[CassetteConfig], +) +async def api_list_machine_cassettes( + machine_id: str, user: User = Depends(check_user_exists) +) -> list[CassetteConfig]: + """List the cassette config rows for one of the operator's machines, + ordered by position then denomination. Empty list = ATM hasn't yet + published its bootstrap event (or the bootstrap consumer hasn't + processed it yet); UI should show a "waiting for ATM" state.""" + await _machine_owned_by(machine_id, user.id) + return await list_cassette_configs_for_machine(machine_id) + + +@satmachineadmin_api_router.post( + "/api/v1/dca/machines/{machine_id}/cassettes/publish", + response_model=list[CassetteConfig], +) +async def api_publish_machine_cassettes( + machine_id: str, + payload: PublishCassettesPayload, + user: User = Depends(check_user_exists), +) -> list[CassetteConfig]: + """Operator submits the full per-machine cassette state for publish to + the ATM. Validates the denomination set matches what's currently in + cassette_configs for the machine (defensive — UI prevents add/remove + but API enforces), upserts each row, then encrypts + signs + publishes + a kind-30078 event tagged with d=bitspire-cassettes: + and p=. + + The `` placeholder in the published d-tag is the ATM's hex pubkey + from machine.machine_npub (canonicalised via normalize_public_key), + NOT the internal dca_machines.id UUID — see #29 'machine_id semantics' + section and coord-log 2026-05-30T11:50Z load-bearing nudge. + + Returns the fresh cassette_configs rows after the upserts so the UI + can refresh its table from one round-trip. + + Errors: + 400 — payload denomination set doesn't match the machine's stored + set (operator publishing a cassette that doesn't exist on the + ATM; or the bootstrap hasn't landed yet so no rows exist) + 400 — operator hasn't onboarded a Nostr identity + 503 — signer offline / client-side-only, or nostrclient extension + not installed on this LNbits instance + 500 — anything else from the publish path + """ + machine = await _machine_owned_by(machine_id, user.id) + + existing = await list_cassette_configs_for_machine(machine_id) + existing_denoms = {row.denomination for row in existing} + incoming_denoms = set(payload.denominations.keys()) + + if not existing: + raise HTTPException( + HTTPStatus.BAD_REQUEST, + ( + "No cassette_configs rows exist for this machine yet — " + "waiting for the ATM's bootstrap state event. Power on the " + "ATM and confirm it has reached the configured relay; " + "satmachineadmin will auto-populate cassette_configs on " + "receipt." + ), + ) + if existing_denoms != incoming_denoms: + missing = existing_denoms - incoming_denoms + extra = incoming_denoms - existing_denoms + raise HTTPException( + HTTPStatus.BAD_REQUEST, + ( + "Payload denomination set doesn't match the machine's " + f"stored set. Missing from payload: {sorted(missing)}; " + f"extra in payload: {sorted(extra)}. " + "Denomination set is hardware-determined — re-provision " + "the ATM via atm-tui to add/remove cassettes, then " + "re-publish." + ), + ) + + # Apply each per-row edit so the operator-believed state on + # satmachineadmin reflects the published payload, even if the ATM + # ack lands later (v2). updated_by audit-stamps the operator user id. + for denom, row in payload.denominations.items(): + updated = await update_cassette_config( + machine_id, + denom, + UpsertCassetteConfigData(count=row.count, position=row.position), + updated_by=user.id, + ) + if updated is None: + # Defensive — we just validated the row exists, but a + # concurrent delete could land between. Surface as 500. + raise HTTPException( + HTTPStatus.INTERNAL_SERVER_ERROR, + f"cassette row for denomination {denom} disappeared mid-publish", + ) + + try: + await publish_to_atm(machine, payload, user.id) + except OperatorIdentityMissing as exc: + raise HTTPException(HTTPStatus.BAD_REQUEST, str(exc)) from exc + except SignerUnavailable as exc: + raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, str(exc)) from exc + except RelayUnavailable as exc: + raise HTTPException(HTTPStatus.SERVICE_UNAVAILABLE, str(exc)) from exc + except CassetteTransportError as exc: + raise HTTPException( + HTTPStatus.INTERNAL_SERVER_ERROR, str(exc) + ) from exc + + return await list_cassette_configs_for_machine(machine_id)