feat(v2): POST cassettes/publish API endpoint + ownership guard (#29 v1)
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:
parent
e57a73083e
commit
f8042f8e4d
1 changed files with 138 additions and 0 deletions
138
views_api.py
138
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:<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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue