feat(v2): POST cassettes/publish API endpoint + ownership guard (#29 v1)
Some checks failed
ci.yml / feat(v2): POST cassettes/publish API endpoint + ownership guard (#29 v1) (pull_request) Failing after 0s

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 `<m>` value in the d-tag
bitspire-cassettes:<atm_pubkey_hex> 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) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-30 18:21:51 +02:00
commit f8042f8e4d

View file

@ -12,6 +12,13 @@ from lnbits.core.crud import get_wallet
from lnbits.core.models import User from lnbits.core.models import User
from lnbits.decorators import check_super_user, check_user_exists from lnbits.decorators import check_super_user, check_user_exists
from .cassette_transport import (
CassetteTransportError,
OperatorIdentityMissing,
RelayUnavailable,
SignerUnavailable,
publish_to_atm,
)
from .crud import ( from .crud import (
append_settlement_note, append_settlement_note,
count_completed_legs_for_settlement, count_completed_legs_for_settlement,
@ -39,9 +46,11 @@ from .crud import (
get_settlements_for_operator, get_settlements_for_operator,
get_stuck_settlements_for_operator, get_stuck_settlements_for_operator,
get_super_config, get_super_config,
list_cassette_configs_for_machine,
lp_is_onboarded, lp_is_onboarded,
replace_commission_splits, replace_commission_splits,
reset_settlement_for_retry, reset_settlement_for_retry,
update_cassette_config,
update_dca_client, update_dca_client,
update_deposit, update_deposit,
update_deposit_status, update_deposit_status,
@ -55,6 +64,7 @@ from .distribution import (
) )
from .models import ( from .models import (
AppendSettlementNoteData, AppendSettlementNoteData,
CassetteConfig,
ClientBalanceSummary, ClientBalanceSummary,
CommissionSplit, CommissionSplit,
CreateDcaClientData, CreateDcaClientData,
@ -66,6 +76,7 @@ from .models import (
DcaSettlement, DcaSettlement,
Machine, Machine,
PartialDispenseData, PartialDispenseData,
PublishCassettesPayload,
SetCommissionSplitsData, SetCommissionSplitsData,
SettleBalanceData, SettleBalanceData,
StuckSettlementsResponse, StuckSettlementsResponse,
@ -75,6 +86,7 @@ from .models import (
UpdateDepositStatusData, UpdateDepositStatusData,
UpdateMachineData, UpdateMachineData,
UpdateSuperConfigData, UpdateSuperConfigData,
UpsertCassetteConfigData,
) )
satmachineadmin_api_router = APIRouter() satmachineadmin_api_router = APIRouter()
@ -759,3 +771,129 @@ async def api_update_super_config(
HTTPStatus.INTERNAL_SERVER_ERROR, "Failed to update super config" HTTPStatus.INTERNAL_SERVER_ERROR, "Failed to update super config"
) )
return 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:<atm_pubkey_hex>
and p=<atm_pubkey_hex>.
The `<m>` 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)