feat(v2): operator-side cassette inventory v1.1 + signer.nip44_* migration (#29) #30
2 changed files with 33 additions and 31 deletions
refactor(v2): cassette consumer + API endpoint — position-keyed (#29 v1.1)
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) <noreply@anthropic.com>
commit
34e324b4c5
2
tasks.py
2
tasks.py
|
|
@ -453,7 +453,7 @@ async def _handle_cassette_state_event(
|
||||||
if applied:
|
if applied:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"satmachineadmin: applied bootstrap state event {event_id[:12]}... "
|
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:
|
else:
|
||||||
# Replay: event_id already on file. Normal on relay reconnect.
|
# Replay: event_id already on file. Normal on relay reconnect.
|
||||||
|
|
|
||||||
62
views_api.py
62
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
|
# GET /machines/{id}/cassettes — list rows for the operator UI
|
||||||
# POST /machines/{id}/cassettes/publish — apply edits + publish kind-30078
|
# POST /machines/{id}/cassettes/publish — apply edits + publish kind-30078
|
||||||
#
|
#
|
||||||
# Row creation (new (machine_id, denomination) pairs) is admin-only via the
|
# Row creation (new (machine_id, position) pairs) is admin-only via the
|
||||||
# bootstrap consumer task — denomination set is hardware-determined.
|
# bootstrap consumer task — slot count is hardware-determined. Operator-
|
||||||
# Operator-side flow is edit-and-publish over the existing rows only.
|
# side flow is edit-and-publish over the existing rows only; the editable
|
||||||
|
# fields per row are denomination and count.
|
||||||
|
|
||||||
|
|
||||||
@satmachineadmin_api_router.get(
|
@satmachineadmin_api_router.get(
|
||||||
|
|
@ -793,9 +794,9 @@ async def api_list_machine_cassettes(
|
||||||
machine_id: str, user: User = Depends(check_user_exists)
|
machine_id: str, user: User = Depends(check_user_exists)
|
||||||
) -> list[CassetteConfig]:
|
) -> list[CassetteConfig]:
|
||||||
"""List the cassette config rows for one of the operator's machines,
|
"""List the cassette config rows for one of the operator's machines,
|
||||||
ordered by position then denomination. Empty list = ATM hasn't yet
|
ordered by position. Empty list = ATM hasn't yet published its
|
||||||
published its bootstrap event (or the bootstrap consumer hasn't
|
bootstrap event (or the bootstrap consumer hasn't processed it yet);
|
||||||
processed it yet); UI should show a "waiting for ATM" state."""
|
UI should show a "waiting for ATM" state."""
|
||||||
await _machine_owned_by(machine_id, user.id)
|
await _machine_owned_by(machine_id, user.id)
|
||||||
return await list_cassette_configs_for_machine(machine_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),
|
user: User = Depends(check_user_exists),
|
||||||
) -> list[CassetteConfig]:
|
) -> list[CassetteConfig]:
|
||||||
"""Operator submits the full per-machine cassette state for publish to
|
"""Operator submits the full per-machine cassette state for publish to
|
||||||
the ATM. Validates the denomination set matches what's currently in
|
the ATM. Validates the position set matches what's currently in
|
||||||
cassette_configs for the machine (defensive — UI prevents add/remove
|
cassette_configs for the machine (slot count is hardware-fixed),
|
||||||
but API enforces), upserts each row, then encrypts + signs + publishes
|
upserts each row, then encrypts + signs + publishes a kind-30078
|
||||||
a kind-30078 event tagged with d=bitspire-cassettes:<atm_pubkey_hex>
|
event tagged with d=bitspire-cassettes:<atm_pubkey_hex> and
|
||||||
and p=<atm_pubkey_hex>.
|
p=<atm_pubkey_hex>.
|
||||||
|
|
||||||
The `<m>` placeholder in the published d-tag is the ATM's hex pubkey
|
The `<m>` placeholder in the published d-tag is the ATM's hex pubkey
|
||||||
from machine.machine_npub (canonicalised via normalize_public_key),
|
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.
|
can refresh its table from one round-trip.
|
||||||
|
|
||||||
Errors:
|
Errors:
|
||||||
400 — payload denomination set doesn't match the machine's stored
|
400 — payload position set doesn't match the machine's stored set
|
||||||
set (operator publishing a cassette that doesn't exist on the
|
(operator publishing for a slot that doesn't exist on the
|
||||||
ATM; or the bootstrap hasn't landed yet so no rows exist)
|
ATM; or the bootstrap hasn't landed yet so no rows exist)
|
||||||
400 — operator hasn't onboarded a Nostr identity
|
400 — operator hasn't onboarded a Nostr identity
|
||||||
503 — signer offline / client-side-only, or nostrclient extension
|
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)
|
machine = await _machine_owned_by(machine_id, user.id)
|
||||||
|
|
||||||
existing = await list_cassette_configs_for_machine(machine_id)
|
existing = await list_cassette_configs_for_machine(machine_id)
|
||||||
existing_denoms = {row.denomination for row in existing}
|
existing_positions = {row.position for row in existing}
|
||||||
incoming_denoms = set(payload.denominations.keys())
|
incoming_positions = set(payload.positions.keys())
|
||||||
|
|
||||||
if not existing:
|
if not existing:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -850,29 +851,30 @@ async def api_publish_machine_cassettes(
|
||||||
"receipt."
|
"receipt."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if existing_denoms != incoming_denoms:
|
if existing_positions != incoming_positions:
|
||||||
missing = existing_denoms - incoming_denoms
|
missing = existing_positions - incoming_positions
|
||||||
extra = incoming_denoms - existing_denoms
|
extra = incoming_positions - existing_positions
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.BAD_REQUEST,
|
HTTPStatus.BAD_REQUEST,
|
||||||
(
|
(
|
||||||
"Payload denomination set doesn't match the machine's "
|
"Payload position set doesn't match the machine's stored "
|
||||||
f"stored set. Missing from payload: {sorted(missing)}; "
|
f"set. Missing from payload: {sorted(missing)}; extra in "
|
||||||
f"extra in payload: {sorted(extra)}. "
|
f"payload: {sorted(extra)}. Slot count is hardware-fixed "
|
||||||
"Denomination set is hardware-determined — re-provision "
|
"— re-provision the ATM via atm-tui to add/remove physical "
|
||||||
"the ATM via atm-tui to add/remove cassettes, then "
|
"bays, then re-publish."
|
||||||
"re-publish."
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Apply each per-row edit so the operator-believed state on
|
# Apply each per-row edit so the operator-believed state on
|
||||||
# satmachineadmin reflects the published payload, even if the ATM
|
# satmachineadmin reflects the published payload, even if the ATM
|
||||||
# ack lands later (v2). updated_by audit-stamps the operator user id.
|
# 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(
|
updated = await update_cassette_config(
|
||||||
machine_id,
|
machine_id,
|
||||||
denom,
|
pos,
|
||||||
UpsertCassetteConfigData(count=row.count, position=row.position),
|
UpsertCassetteConfigData(
|
||||||
|
denomination=row.denomination, count=row.count
|
||||||
|
),
|
||||||
updated_by=user.id,
|
updated_by=user.id,
|
||||||
)
|
)
|
||||||
if updated is None:
|
if updated is None:
|
||||||
|
|
@ -880,7 +882,7 @@ async def api_publish_machine_cassettes(
|
||||||
# concurrent delete could land between. Surface as 500.
|
# concurrent delete could land between. Surface as 500.
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.INTERNAL_SERVER_ERROR,
|
HTTPStatus.INTERNAL_SERVER_ERROR,
|
||||||
f"cassette row for denomination {denom} disappeared mid-publish",
|
f"cassette row for position {pos} disappeared mid-publish",
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue