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.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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue