feat(v2): operator-scoped API surface — machines, settlements, payments (P1b)

Replaces the views_api.py stub with the v1 operator-scoped REST surface
needed for the P1 frontend tasks (machine onboarding by npub, settlement
review, payment-leg audit). All endpoints filter on the authenticated
user's id so two operators on the same LNbits instance can never see
each other's data.

Endpoints (12 routes):

Machines (CRUD):
  POST   /api/v1/dca/machines                  — add by npub + wallet_id
  GET    /api/v1/dca/machines                  — operator's fleet
  GET    /api/v1/dca/machines/{id}             — single (ownership check)
  PUT    /api/v1/dca/machines/{id}             — update (ownership check)
  DELETE /api/v1/dca/machines/{id}             — delete (ownership check)

Settlements (read-only at this phase):
  GET    /api/v1/dca/settlements               — operator-wide
  GET    /api/v1/dca/machines/{id}/settlements — per machine
  GET    /api/v1/dca/settlements/{id}          — single (ownership check)

Payments (leg-typed audit):
  GET    /api/v1/dca/payments?leg_type=…       — operator's payment legs

Super config (read-only here):
  GET    /api/v1/dca/super-config              — operators read the
                                                  platform fee they pay

Catch-all:
  /api/v1/dca/{...} → 503 with a precise message for not-yet-implemented
  endpoints (clients, deposits, commission splits, partial-tx,
  balance-settle, super-config write — all P2+).

All ownership checks live at the API boundary: if the route's resource
points to a machine the operator doesn't own, we 404 (not 403) so
operators can't probe for the existence of other operators' machines.

Verified routes register cleanly against LNbits 1.4 (nostr-transport).
22/22 calculation tests still green.

Refs: aiolabs/satmachineadmin#9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-05-14 14:50:07 +02:00
commit 10b79ae900

View file

@ -1,20 +1,190 @@
# Satoshi Machine v2 — API placeholder. # Satoshi Machine v2 — operator API surface (P1b).
# #
# The v1 super-only Lamassu endpoints have been removed. The v2 operator- # All endpoints are operator-scoped via check_user_exists. Every query
# scoped surface (machines / clients / deposits / settlements / commission # filters by the authenticated user's id so two operators on the same
# splits / partial-tx / balance-settle / super platform-fee) lands in P1+. # LNbits instance can never see each other's machines, settlements, or
# See plan section "Critical files to modify". # clients. The super-only platform-fee write endpoint lands in P2.
#
# This stub keeps __init__.py importable and surfaces a clear 503 on every
# v1 route so existing clients get a precise error instead of a silent 404.
from http import HTTPStatus from http import HTTPStatus
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, Depends, HTTPException
from lnbits.core.models import User
from lnbits.decorators import check_user_exists
from .crud import (
create_machine,
delete_machine,
get_machine,
get_machines_for_operator,
get_payments_for_operator,
get_settlement,
get_settlements_for_machine,
get_settlements_for_operator,
get_super_config,
update_machine,
)
from .models import (
CreateMachineData,
DcaPayment,
DcaSettlement,
Machine,
SuperConfig,
UpdateMachineData,
)
satmachineadmin_api_router = APIRouter() satmachineadmin_api_router = APIRouter()
# =============================================================================
# Machines
# =============================================================================
@satmachineadmin_api_router.post("/api/v1/dca/machines", response_model=Machine)
async def api_create_machine(
data: CreateMachineData, user: User = Depends(check_user_exists)
) -> Machine:
return await create_machine(user.id, data)
@satmachineadmin_api_router.get(
"/api/v1/dca/machines", response_model=list[Machine]
)
async def api_list_machines(
user: User = Depends(check_user_exists),
) -> list[Machine]:
return await get_machines_for_operator(user.id)
@satmachineadmin_api_router.get(
"/api/v1/dca/machines/{machine_id}", response_model=Machine
)
async def api_get_machine(
machine_id: str, user: User = Depends(check_user_exists)
) -> Machine:
machine = await get_machine(machine_id)
if machine is None or machine.operator_user_id != user.id:
raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found")
return machine
@satmachineadmin_api_router.put(
"/api/v1/dca/machines/{machine_id}", response_model=Machine
)
async def api_update_machine(
machine_id: str,
data: UpdateMachineData,
user: User = Depends(check_user_exists),
) -> Machine:
machine = await get_machine(machine_id)
if machine is None or machine.operator_user_id != user.id:
raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found")
updated = await update_machine(machine_id, data)
if updated is None:
raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found")
return updated
@satmachineadmin_api_router.delete(
"/api/v1/dca/machines/{machine_id}", status_code=HTTPStatus.NO_CONTENT
)
async def api_delete_machine(
machine_id: str, user: User = Depends(check_user_exists)
) -> None:
machine = await get_machine(machine_id)
if machine is None or machine.operator_user_id != user.id:
raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found")
await delete_machine(machine_id)
# =============================================================================
# Settlements (read-only at this phase; landing happens in tasks.py)
# =============================================================================
@satmachineadmin_api_router.get(
"/api/v1/dca/settlements", response_model=list[DcaSettlement]
)
async def api_list_settlements(
user: User = Depends(check_user_exists),
) -> list[DcaSettlement]:
return await get_settlements_for_operator(user.id)
@satmachineadmin_api_router.get(
"/api/v1/dca/machines/{machine_id}/settlements",
response_model=list[DcaSettlement],
)
async def api_list_settlements_for_machine(
machine_id: str, user: User = Depends(check_user_exists)
) -> list[DcaSettlement]:
machine = await get_machine(machine_id)
if machine is None or machine.operator_user_id != user.id:
raise HTTPException(HTTPStatus.NOT_FOUND, "Machine not found")
return await get_settlements_for_machine(machine_id)
@satmachineadmin_api_router.get(
"/api/v1/dca/settlements/{settlement_id}", response_model=DcaSettlement
)
async def api_get_settlement(
settlement_id: str, user: User = Depends(check_user_exists)
) -> DcaSettlement:
settlement = await get_settlement(settlement_id)
if settlement is None:
raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found")
machine = await get_machine(settlement.machine_id)
if machine is None or machine.operator_user_id != user.id:
raise HTTPException(HTTPStatus.NOT_FOUND, "Settlement not found")
return settlement
# =============================================================================
# Payments (read-only — the leg-typed breakdown of distributions)
# =============================================================================
@satmachineadmin_api_router.get(
"/api/v1/dca/payments", response_model=list[DcaPayment]
)
async def api_list_payments(
leg_type: str | None = None,
user: User = Depends(check_user_exists),
) -> list[DcaPayment]:
return await get_payments_for_operator(user.id, leg_type=leg_type)
# =============================================================================
# Super config — read-only at this phase. Super-only write endpoint lands in P2.
# =============================================================================
@satmachineadmin_api_router.get(
"/api/v1/dca/super-config", response_model=SuperConfig
)
async def api_get_super_config(
_user: User = Depends(check_user_exists),
) -> SuperConfig:
"""Returns the platform-fee config so operators can display it as a
read-only line item in their UI. The fee is set by the LNbits super
instance-wide; operators see it but can't change it (write endpoint
protected by check_super_user, landing in P2)."""
config = await get_super_config()
if config is None:
raise HTTPException(
HTTPStatus.NOT_FOUND, "Super config not initialised"
)
return config
# =============================================================================
# Catch-all stub for endpoints not yet implemented (clients, deposits,
# commission splits, partial-tx, balance-settle, super-config write). Each
# lands in a follow-up commit. The catch-all comes LAST so specific routes
# above take precedence.
# =============================================================================
@satmachineadmin_api_router.api_route( @satmachineadmin_api_router.api_route(
"/api/v1/dca/{full_path:path}", "/api/v1/dca/{full_path:path}",
methods=["GET", "POST", "PUT", "DELETE", "PATCH"], methods=["GET", "POST", "PUT", "DELETE", "PATCH"],
@ -22,7 +192,6 @@ satmachineadmin_api_router = APIRouter()
async def v2_in_progress_stub(full_path: str) -> None: async def v2_in_progress_stub(full_path: str) -> None:
raise HTTPException( raise HTTPException(
HTTPStatus.SERVICE_UNAVAILABLE, HTTPStatus.SERVICE_UNAVAILABLE,
f"satmachineadmin v2 API not yet implemented (path: /{full_path}). " f"satmachineadmin v2: /api/v1/dca/{full_path} not yet implemented "
"The v1 Lamassu surface has been removed; per-operator endpoints " "(landing in P2+). See aiolabs/satmachineadmin#9.",
"land in P1. See plan.",
) )