From 34e324b4c5e3009a57bf17631b20eb88d8ff85f2 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sat, 30 May 2026 22:26:55 +0200 Subject: [PATCH] =?UTF-8?q?refactor(v2):=20cassette=20consumer=20+=20API?= =?UTF-8?q?=20endpoint=20=E2=80=94=20position-keyed=20(#29=20v1.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API endpoint: - api_publish_machine_cassettes validates incoming payload.positions set matches stored cassette_configs.position set (was: denomination set match). Error message updated to "slot count is hardware-fixed — re-provision the ATM via atm-tui to add/remove physical bays." - Per-row upsert loop iterates payload.positions and passes UpsertCassetteConfigData(denomination=, count=) — operator edits denomination + count for a fixed slot. Bootstrap consumer task: just a log-message field rename (now reports "N cassettes" from len(payload.positions) instead of len(payload. denominations)). Per-event handler already routes through the transport's decrypt_and_parse_state_event, which returns a PublishCassettesPayload that's now position-keyed via the model. Tests still red until commit f. Co-Authored-By: Claude Opus 4.7 (1M context) --- tasks.py | 2 +- views_api.py | 62 +++++++++++++++++++++++++++------------------------- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/tasks.py b/tasks.py index 8be382f..ab945f4 100644 --- a/tasks.py +++ b/tasks.py @@ -453,7 +453,7 @@ async def _handle_cassette_state_event( if applied: logger.info( f"satmachineadmin: applied bootstrap state event {event_id[:12]}... " - f"to machine {machine.id} ({len(payload.denominations)} cassettes)" + f"to machine {machine.id} ({len(payload.positions)} cassettes)" ) else: # Replay: event_id already on file. Normal on relay reconnect. diff --git a/views_api.py b/views_api.py index 712e4d6..e49e01e 100644 --- a/views_api.py +++ b/views_api.py @@ -774,15 +774,16 @@ async def api_update_super_config( # ============================================================================= -# Cassette configs (#29 v1) — per-machine ATM cassette inventory +# Cassette configs (#29 v1.1) — per-machine ATM cassette inventory # ============================================================================= -# v1 surface, paired with aiolabs/lamassu-next#56 ATM-side. Two endpoints: +# v1.1 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. +# Row creation (new (machine_id, position) pairs) is admin-only via the +# bootstrap consumer task — slot count is hardware-determined. Operator- +# side flow is edit-and-publish over the existing rows only; the editable +# fields per row are denomination and count. @satmachineadmin_api_router.get( @@ -793,9 +794,9 @@ 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.""" + ordered by position. 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) @@ -810,11 +811,11 @@ async def api_publish_machine_cassettes( 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 ATM. Validates the position set matches what's currently in + cassette_configs for the machine (slot count is hardware-fixed), + 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), @@ -825,8 +826,8 @@ async def api_publish_machine_cassettes( 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 + 400 — payload position set doesn't match the machine's stored set + (operator publishing for a slot 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 @@ -836,8 +837,8 @@ async def api_publish_machine_cassettes( 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()) + existing_positions = {row.position for row in existing} + incoming_positions = set(payload.positions.keys()) if not existing: raise HTTPException( @@ -850,29 +851,30 @@ async def api_publish_machine_cassettes( "receipt." ), ) - if existing_denoms != incoming_denoms: - missing = existing_denoms - incoming_denoms - extra = incoming_denoms - existing_denoms + if existing_positions != incoming_positions: + missing = existing_positions - incoming_positions + extra = incoming_positions - existing_positions 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." + "Payload position set doesn't match the machine's stored " + f"set. Missing from payload: {sorted(missing)}; extra in " + f"payload: {sorted(extra)}. Slot count is hardware-fixed " + "— re-provision the ATM via atm-tui to add/remove physical " + "bays, 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(): + for pos, row in payload.positions.items(): updated = await update_cassette_config( machine_id, - denom, - UpsertCassetteConfigData(count=row.count, position=row.position), + pos, + UpsertCassetteConfigData( + denomination=row.denomination, count=row.count + ), updated_by=user.id, ) if updated is None: @@ -880,7 +882,7 @@ async def api_publish_machine_cassettes( # concurrent delete could land between. Surface as 500. raise HTTPException( HTTPStatus.INTERNAL_SERVER_ERROR, - f"cassette row for denomination {denom} disappeared mid-publish", + f"cassette row for position {pos} disappeared mid-publish", ) try: