feat(pairing): optional token TTL + revoke endpoint (#9/#12, #22) #23
6 changed files with 223 additions and 10 deletions
feat(pairing): optional token TTL + revoke endpoint (#9/#12, #22)
Some checks failed
ci.yml / feat(pairing): optional token TTL + revoke endpoint (#9/#12, #22) (pull_request) Failing after 0s
Some checks failed
ci.yml / feat(pairing): optional token TTL + revoke endpoint (#9/#12, #22) (pull_request) Failing after 0s
Builds on the seed-URL pairing in #21 (stacked). (b) TTL — PairMachineData.duration_hours (validated > 0) threads through pair_spire -> create_new_token (lnbits#55). None = non-expiring. (c) Revoke — POST /machines/{id}/revoke -> revoke_spire -> admin_client.revoke_key_user(spire-<id>). Per spirekeeper#22, revoke MUST go through KeyUser.revokedAt (revoke_key_user), NOT token revoke: lnbits eager-binds (redeems) the connect token at provision, so nsecbunkerd has materialised the policy into per-KeyUser grants its ACL checks BEFORE the Token.revokedAt filter -> token revoke is a silent no-op. Returns RevokeResult{revoked_count}: >=1 = cut, 0 = never bound. set_machine_unpaired clears paired_at (keeps npub + bunker_spire_key_name for audit / re-pair). 7 new tests (duration threading + default-None; revoke routes to revoke_key_user and never token-revoke + error mapping; endpoint wiring revoke happy/zero/502). 210 green; new code black/ruff-clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
commit
a5efdf22a1
18
crud.py
18
crud.py
|
|
@ -233,6 +233,24 @@ async def set_machine_pairing(
|
||||||
return await get_machine(machine_id)
|
return await get_machine(machine_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def set_machine_unpaired(machine_id: str) -> Machine | None:
|
||||||
|
"""Mark a machine unpaired after revoking its spire's bunker access
|
||||||
|
(POST /revoke). Clears `paired_at`; keeps `machine_npub` +
|
||||||
|
`bunker_spire_key_name` for audit / re-pair. The bunker-side
|
||||||
|
`KeyUser.revokedAt` (set by `revoke_spire`) is what actually stops the
|
||||||
|
spire signing — this just records the operator-visible state."""
|
||||||
|
await db.execute(
|
||||||
|
"""
|
||||||
|
UPDATE spirekeeper.dca_machines
|
||||||
|
SET paired_at = NULL,
|
||||||
|
updated_at = :updated_at
|
||||||
|
WHERE id = :id
|
||||||
|
""",
|
||||||
|
{"updated_at": datetime.now(), "id": machine_id},
|
||||||
|
)
|
||||||
|
return await get_machine(machine_id)
|
||||||
|
|
||||||
|
|
||||||
async def delete_machine(machine_id: str) -> None:
|
async def delete_machine(machine_id: str) -> None:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"DELETE FROM spirekeeper.dca_machines WHERE id = :id",
|
"DELETE FROM spirekeeper.dca_machines WHERE id = :id",
|
||||||
|
|
|
||||||
10
models.py
10
models.py
|
|
@ -85,9 +85,17 @@ class PairMachineData(BaseModel):
|
||||||
"""Body for POST /machines/{id}/pair (S0 / #9). `relays` are the relays
|
"""Body for POST /machines/{id}/pair (S0 / #9). `relays` are the relays
|
||||||
the spire will use for its own events (kind-21000/30078) — typically the
|
the spire will use for its own events (kind-21000/30078) — typically the
|
||||||
operator's nostrrelay; the bunker connection relay is added separately
|
operator's nostrrelay; the bunker connection relay is added separately
|
||||||
from the lnbits bunker settings."""
|
from the lnbits bunker settings. `duration_hours` optionally time-bounds
|
||||||
|
the spire's connect token (None = non-expiring)."""
|
||||||
|
|
||||||
relays: list[str]
|
relays: list[str]
|
||||||
|
duration_hours: int | None = None
|
||||||
|
|
||||||
|
@validator("duration_hours")
|
||||||
|
def _positive_duration(cls, v):
|
||||||
|
if v is not None and v <= 0:
|
||||||
|
raise ValueError("duration_hours must be positive when set")
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
|
||||||
47
pairing.py
47
pairing.py
|
|
@ -97,6 +97,14 @@ class PairResult(BaseModel):
|
||||||
seed_url: str
|
seed_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class RevokeResult(BaseModel):
|
||||||
|
"""Output of revoke. `revoked_count` >= 1 = the spire's signing access
|
||||||
|
is cut (KeyUser.revokedAt set); 0 = nothing was bound (token minted but
|
||||||
|
the spire never connected)."""
|
||||||
|
|
||||||
|
revoked_count: int
|
||||||
|
|
||||||
|
|
||||||
def spire_key_name(machine_id: str) -> str:
|
def spire_key_name(machine_id: str) -> str:
|
||||||
"""The spire's key name in the bunker keystore. Stable across re-pairs
|
"""The spire's key name in the bunker keystore. Stable across re-pairs
|
||||||
so re-issuing a token reuses the same underlying key (create_new_key
|
so re-issuing a token reuses the same underlying key (create_new_key
|
||||||
|
|
@ -147,10 +155,16 @@ async def pair_spire(
|
||||||
admin_client: NsecBunkerAdminClient,
|
admin_client: NsecBunkerAdminClient,
|
||||||
bunker_relay: str | None = None,
|
bunker_relay: str | None = None,
|
||||||
keystore_passphrase: str | None = None,
|
keystore_passphrase: str | None = None,
|
||||||
|
duration_hours: int | None = None,
|
||||||
) -> PairResult:
|
) -> PairResult:
|
||||||
"""Mint a bunker-held key + scoped connect token for `machine` and
|
"""Mint a bunker-held key + scoped connect token for `machine` and
|
||||||
return the seed URL the spire redeems at first boot.
|
return the seed URL the spire redeems at first boot.
|
||||||
|
|
||||||
|
`duration_hours` (optional, aiolabs/lnbits#54 item 2) sets a TTL on the
|
||||||
|
spire's connect token — the bunker stamps `expiresAt` and rejects the
|
||||||
|
token once it lapses, forcing a re-pair. None = non-expiring (the only
|
||||||
|
invalidation path is then revoke, `revoke_spire`).
|
||||||
|
|
||||||
`admin_client` must already be connected (the caller owns the
|
`admin_client` must already be connected (the caller owns the
|
||||||
`async with NsecBunkerAdminClient.from_settings()` context) — keeps
|
`async with NsecBunkerAdminClient.from_settings()` context) — keeps
|
||||||
connection lifecycle out of the orchestration so this is unit-testable
|
connection lifecycle out of the orchestration so this is unit-testable
|
||||||
|
|
@ -196,7 +210,9 @@ async def pair_spire(
|
||||||
rules=SPIRE_POLICY_RULES,
|
rules=SPIRE_POLICY_RULES,
|
||||||
methods_no_kind=SPIRE_POLICY_METHODS_NO_KIND,
|
methods_no_kind=SPIRE_POLICY_METHODS_NO_KIND,
|
||||||
)
|
)
|
||||||
await admin_client.create_new_token(key_name, client_name, policy_id)
|
await admin_client.create_new_token(
|
||||||
|
key_name, client_name, policy_id, duration_hours=duration_hours
|
||||||
|
)
|
||||||
tokens = await admin_client.get_key_tokens(key_name)
|
tokens = await admin_client.get_key_tokens(key_name)
|
||||||
except NsecBunkerNotConfiguredError as exc:
|
except NsecBunkerNotConfiguredError as exc:
|
||||||
raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc
|
raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc
|
||||||
|
|
@ -223,3 +239,32 @@ async def pair_spire(
|
||||||
bunker_url=bunker_url,
|
bunker_url=bunker_url,
|
||||||
seed_url=seed_url,
|
seed_url=seed_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def revoke_spire(
|
||||||
|
machine: Machine, *, admin_client: NsecBunkerAdminClient
|
||||||
|
) -> int:
|
||||||
|
"""Revoke a spire's bunker access (the "Revoke spire access" UX,
|
||||||
|
aiolabs/spirekeeper#9/#12; security model per #22).
|
||||||
|
|
||||||
|
Calls `revoke_key_user` — NOT `revoke_token` / `revoke_key_token`.
|
||||||
|
lnbits eager-binds (redeems) the connect token at provision time
|
||||||
|
(aiolabs/lnbits#32), so nsecbunkerd has already materialised the
|
||||||
|
token's policy into standing per-`KeyUser` `SigningCondition` grants;
|
||||||
|
its sign-time ACL checks those *before* the `Token.revokedAt` filter,
|
||||||
|
so revoking the token is a silent no-op (the spire keeps signing).
|
||||||
|
Only `KeyUser.revokedAt` — set by `revoke_user` / `revoke_key_user` —
|
||||||
|
actually cuts off signing (verified live 2026-06-18, #22).
|
||||||
|
|
||||||
|
Returns the number of KeyUsers revoked: >= 1 means the spire's signing
|
||||||
|
access is now cut; 0 means nothing was bound (token minted but the
|
||||||
|
spire never connected). Raises PairingError on any bunker failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await admin_client.revoke_key_user(spire_key_name(machine.id))
|
||||||
|
except NsecBunkerNotConfiguredError as exc:
|
||||||
|
raise PairingError(f"nsecbunkerd is not configured: {exc}") from exc
|
||||||
|
except NsecBunkerError as exc:
|
||||||
|
raise PairingError(
|
||||||
|
f"bunker admin RPC failed during revoke: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ def _wire(monkeypatch, *, pair="ok"):
|
||||||
async def fake_owned(machine_id, user_id):
|
async def fake_owned(machine_id, user_id):
|
||||||
return _machine()
|
return _machine()
|
||||||
|
|
||||||
async def fake_pair(machine, *, relays, admin_client):
|
async def fake_pair(machine, *, relays, admin_client, duration_hours=None):
|
||||||
if pair == "error":
|
if pair == "error":
|
||||||
raise PairingError("boom")
|
raise PairingError("boom")
|
||||||
return _result()
|
return _result()
|
||||||
|
|
@ -118,3 +118,50 @@ def test_pair_failure_maps_to_bad_gateway(monkeypatch):
|
||||||
assert ei.value.status_code == 502
|
assert ei.value.status_code == 502
|
||||||
# nothing persisted on failure
|
# nothing persisted on failure
|
||||||
assert state["persisted"] is None
|
assert state["persisted"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def _wire_revoke(monkeypatch, *, revoke="ok", count=2):
|
||||||
|
state = {"unpaired": None}
|
||||||
|
|
||||||
|
async def fake_owned(machine_id, user_id):
|
||||||
|
return _machine()
|
||||||
|
|
||||||
|
async def fake_revoke(machine, *, admin_client):
|
||||||
|
if revoke == "error":
|
||||||
|
raise PairingError("boom")
|
||||||
|
return count
|
||||||
|
|
||||||
|
async def fake_unpaired(machine_id):
|
||||||
|
state["unpaired"] = machine_id
|
||||||
|
return _machine()
|
||||||
|
|
||||||
|
monkeypatch.setattr(views_api, "_machine_owned_by", fake_owned)
|
||||||
|
monkeypatch.setattr(views_api, "NsecBunkerAdminClient", _FakeAdmin)
|
||||||
|
monkeypatch.setattr(views_api, "revoke_spire", fake_revoke)
|
||||||
|
monkeypatch.setattr(views_api, "set_machine_unpaired", fake_unpaired)
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
def _call_revoke():
|
||||||
|
user = SimpleNamespace(id="op1")
|
||||||
|
return asyncio.run(views_api.api_revoke_machine("m1", user))
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_cuts_access_and_marks_unpaired(monkeypatch):
|
||||||
|
state = _wire_revoke(monkeypatch, count=2)
|
||||||
|
result = _call_revoke()
|
||||||
|
assert result.revoked_count == 2
|
||||||
|
assert state["unpaired"] == "m1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_zero_when_nothing_bound(monkeypatch):
|
||||||
|
_wire_revoke(monkeypatch, count=0)
|
||||||
|
assert _call_revoke().revoked_count == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_failure_maps_to_bad_gateway(monkeypatch):
|
||||||
|
state = _wire_revoke(monkeypatch, revoke="error")
|
||||||
|
with pytest.raises(HTTPException) as ei:
|
||||||
|
_call_revoke()
|
||||||
|
assert ei.value.status_code == 502
|
||||||
|
assert state["unpaired"] is None # not persisted on failure
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from lnbits.core.services.nsec_bunker import NsecBunkerError
|
||||||
from lnbits.utils.nostr import hex_to_npub
|
from lnbits.utils.nostr import hex_to_npub
|
||||||
|
|
||||||
from ..models import Machine
|
from ..models import Machine
|
||||||
|
|
@ -27,6 +28,7 @@ from ..pairing import (
|
||||||
PairingError,
|
PairingError,
|
||||||
build_seed_url,
|
build_seed_url,
|
||||||
pair_spire,
|
pair_spire,
|
||||||
|
revoke_spire,
|
||||||
spire_key_name,
|
spire_key_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -70,9 +72,10 @@ class FakeBunker:
|
||||||
admin_pubkey = "fake-admin-pubkey"
|
admin_pubkey = "fake-admin-pubkey"
|
||||||
|
|
||||||
# pragma: allowlist secret
|
# pragma: allowlist secret
|
||||||
def __init__(self, *, policies=None, token_secret="s3cr3t"):
|
def __init__(self, *, policies=None, token_secret="s3cr3t", revoke_count=1):
|
||||||
self._policies = policies or []
|
self._policies = policies or []
|
||||||
self._token_secret = token_secret
|
self._token_secret = token_secret
|
||||||
|
self._revoke_count = revoke_count
|
||||||
self.calls: list[tuple] = []
|
self.calls: list[tuple] = []
|
||||||
self._next_policy_id = 7
|
self._next_policy_id = 7
|
||||||
|
|
||||||
|
|
@ -93,8 +96,16 @@ class FakeBunker:
|
||||||
async def add_policy_rule(self, policy_id, rule):
|
async def add_policy_rule(self, policy_id, rule):
|
||||||
self.calls.append(("add_policy_rule", policy_id, rule))
|
self.calls.append(("add_policy_rule", policy_id, rule))
|
||||||
|
|
||||||
async def create_new_token(self, key_name, client_name, policy_id):
|
async def create_new_token(
|
||||||
self.calls.append(("create_new_token", key_name, client_name, policy_id))
|
self, key_name, client_name, policy_id, duration_hours=None
|
||||||
|
):
|
||||||
|
self.calls.append(
|
||||||
|
("create_new_token", key_name, client_name, policy_id, duration_hours)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def revoke_key_user(self, key_name):
|
||||||
|
self.calls.append(("revoke_key_user", key_name))
|
||||||
|
return self._revoke_count
|
||||||
|
|
||||||
async def get_key_tokens(self, key_name):
|
async def get_key_tokens(self, key_name):
|
||||||
self.calls.append(("get_key_tokens", key_name))
|
self.calls.append(("get_key_tokens", key_name))
|
||||||
|
|
@ -251,3 +262,46 @@ def test_build_seed_url_roundtrip():
|
||||||
payload = json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4)))
|
payload = json.loads(base64.urlsafe_b64decode(blob + "=" * (-len(blob) % 4)))
|
||||||
assert payload["spire_pubkey"] == _SPIRE_HEX
|
assert payload["spire_pubkey"] == _SPIRE_HEX
|
||||||
assert payload["relays"] == _RELAYS
|
assert payload["relays"] == _RELAYS
|
||||||
|
|
||||||
|
|
||||||
|
def test_pair_threads_duration_hours():
|
||||||
|
bunker = FakeBunker()
|
||||||
|
asyncio.run(
|
||||||
|
pair_spire(
|
||||||
|
_machine(),
|
||||||
|
relays=_RELAYS,
|
||||||
|
admin_client=bunker,
|
||||||
|
bunker_relay=_BUNKER_RELAY,
|
||||||
|
keystore_passphrase=_PASSPHRASE,
|
||||||
|
duration_hours=720,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# create_new_token tuple is (name, key, client, policy_id, duration_hours)
|
||||||
|
assert bunker.named("create_new_token")[0][4] == 720
|
||||||
|
|
||||||
|
|
||||||
|
def test_pair_default_duration_is_none():
|
||||||
|
bunker = FakeBunker()
|
||||||
|
_pair(bunker) # no duration_hours
|
||||||
|
assert bunker.named("create_new_token")[0][4] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_spire_calls_revoke_key_user():
|
||||||
|
# revoke MUST go through revoke_key_user (KeyUser.revokedAt), not token
|
||||||
|
# revoke — token revoke is a no-op once redeemed (spirekeeper#22).
|
||||||
|
bunker = FakeBunker(revoke_count=2)
|
||||||
|
count = asyncio.run(revoke_spire(_machine(), admin_client=bunker))
|
||||||
|
assert count == 2
|
||||||
|
assert bunker.named("revoke_key_user") == [("revoke_key_user", "spire-m1")]
|
||||||
|
assert not bunker.named("revoke_token") # never token-revoke
|
||||||
|
|
||||||
|
|
||||||
|
def test_revoke_spire_maps_bunker_error():
|
||||||
|
bunker = FakeBunker()
|
||||||
|
|
||||||
|
async def _boom(key_name):
|
||||||
|
raise NsecBunkerError("nope")
|
||||||
|
|
||||||
|
bunker.revoke_key_user = _boom
|
||||||
|
with pytest.raises(PairingError, match="revoke"):
|
||||||
|
asyncio.run(revoke_spire(_machine(), admin_client=bunker))
|
||||||
|
|
|
||||||
49
views_api.py
49
views_api.py
|
|
@ -29,7 +29,13 @@ from .cassette_transport import (
|
||||||
publish_to_atm,
|
publish_to_atm,
|
||||||
)
|
)
|
||||||
from .fee_transport import publish_fee_config
|
from .fee_transport import publish_fee_config
|
||||||
from .pairing import PairResult, PairingError, pair_spire
|
from .pairing import (
|
||||||
|
PairResult,
|
||||||
|
PairingError,
|
||||||
|
RevokeResult,
|
||||||
|
pair_spire,
|
||||||
|
revoke_spire,
|
||||||
|
)
|
||||||
from .crud import (
|
from .crud import (
|
||||||
append_settlement_note,
|
append_settlement_note,
|
||||||
count_completed_legs_for_settlement,
|
count_completed_legs_for_settlement,
|
||||||
|
|
@ -63,6 +69,7 @@ from .crud import (
|
||||||
replace_commission_splits,
|
replace_commission_splits,
|
||||||
reset_settlement_for_retry,
|
reset_settlement_for_retry,
|
||||||
set_machine_pairing,
|
set_machine_pairing,
|
||||||
|
set_machine_unpaired,
|
||||||
update_cassette_config,
|
update_cassette_config,
|
||||||
update_dca_client,
|
update_dca_client,
|
||||||
update_deposit,
|
update_deposit,
|
||||||
|
|
@ -297,15 +304,21 @@ async def api_pair_machine(
|
||||||
npub, so lnbits' path-B roster routes the spire's cash-out RPCs to this
|
npub, so lnbits' path-B roster routes the spire's cash-out RPCs to this
|
||||||
operator's wallet — no nsec ever lands on the spire.
|
operator's wallet — no nsec ever lands on the spire.
|
||||||
|
|
||||||
Re-pair is supported (re-issues a token for the same spire key). Token
|
Re-pair is supported (re-issues a token for the same spire key).
|
||||||
revocation + expiry are gated on aiolabs/lnbits#54 (admin-client gaps)."""
|
`duration_hours` (optional) time-bounds the token; revoke via the
|
||||||
|
sibling `POST .../revoke` endpoint."""
|
||||||
machine = await _machine_owned_by(machine_id, user.id)
|
machine = await _machine_owned_by(machine_id, user.id)
|
||||||
if not data.relays:
|
if not data.relays:
|
||||||
raise HTTPException(HTTPStatus.BAD_REQUEST, "at least one relay is required")
|
raise HTTPException(HTTPStatus.BAD_REQUEST, "at least one relay is required")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with NsecBunkerAdminClient.from_settings() as client:
|
async with NsecBunkerAdminClient.from_settings() as client:
|
||||||
result = await pair_spire(machine, relays=data.relays, admin_client=client)
|
result = await pair_spire(
|
||||||
|
machine,
|
||||||
|
relays=data.relays,
|
||||||
|
admin_client=client,
|
||||||
|
duration_hours=data.duration_hours,
|
||||||
|
)
|
||||||
except NsecBunkerNotConfiguredError as exc:
|
except NsecBunkerNotConfiguredError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
HTTPStatus.SERVICE_UNAVAILABLE,
|
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
|
|
@ -327,6 +340,34 @@ async def api_pair_machine(
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@spirekeeper_api_router.post(
|
||||||
|
"/api/v1/dca/machines/{machine_id}/revoke", response_model=RevokeResult
|
||||||
|
)
|
||||||
|
async def api_revoke_machine(
|
||||||
|
machine_id: str,
|
||||||
|
user: User = Depends(check_user_exists),
|
||||||
|
) -> RevokeResult:
|
||||||
|
"""Revoke a spire's bunker access — the "Revoke spire access" UX
|
||||||
|
(#9/#12). Cuts the spire's signing ability at the bunker
|
||||||
|
(`KeyUser.revokedAt` via `revoke_key_user`; token-revoke alone is a
|
||||||
|
no-op once the token is redeemed — see #22), then marks the machine
|
||||||
|
unpaired. `revoked_count` >= 1 = access cut; 0 = nothing was bound."""
|
||||||
|
machine = await _machine_owned_by(machine_id, user.id)
|
||||||
|
try:
|
||||||
|
async with NsecBunkerAdminClient.from_settings() as client:
|
||||||
|
revoked_count = await revoke_spire(machine, admin_client=client)
|
||||||
|
except NsecBunkerNotConfiguredError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
HTTPStatus.SERVICE_UNAVAILABLE,
|
||||||
|
f"nsecbunkerd is not configured on this LNbits instance: {exc}",
|
||||||
|
) from exc
|
||||||
|
except (PairingError, NsecBunkerError) as exc:
|
||||||
|
raise HTTPException(HTTPStatus.BAD_GATEWAY, f"revoke failed: {exc}") from exc
|
||||||
|
|
||||||
|
await set_machine_unpaired(machine_id)
|
||||||
|
return RevokeResult(revoked_count=revoked_count)
|
||||||
|
|
||||||
|
|
||||||
@spirekeeper_api_router.get("/api/v1/dca/machines", response_model=list[Machine])
|
@spirekeeper_api_router.get("/api/v1/dca/machines", response_model=list[Machine])
|
||||||
async def api_list_machines(
|
async def api_list_machines(
|
||||||
user: User = Depends(check_user_exists),
|
user: User = Depends(check_user_exists),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue