feat: super-user REST API for payroll contracts
Contract CRUD plus the account directory the operator picks an employee from. The extension loads and is fully drivable over HTTP after this commit; the console UI lands separately. Auth: the gate is `check_super_user`, applied at the *router* level so a new endpoint cannot be added ungated by forgetting a decorator. Payroll reads accounts the caller does not own and moves money between their wallets, so a wallet-scoped `require_admin_key` — which any user holds for their own wallets — would be the wrong gate here despite the similar name. Validation lives in one place (`_validate_terms`) because the employee and the destination wallet arrive from a form as two independent ids and nothing further down the write path re-checks that they belong together. That check is what stops a payout being aimed at a third party's wallet. Edits are deliberately partial: recipient, source wallet and start date are not patchable. They are the contract's identity — changing them would retroactively alter what already-made payouts were for, and re-anchoring the start date would silently move every remaining payday. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
This commit is contained in:
parent
f253b51aa9
commit
dc29173ada
3 changed files with 257 additions and 0 deletions
69
accounts.py
Normal file
69
accounts.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"""Account directory — the read model behind the operator's user picker.
|
||||
|
||||
Payroll needs to answer one question core does not expose in a
|
||||
payroll-shaped way: *which accounts exist, and which wallets could a
|
||||
payout land in?* This module builds that list out of core's own crud, so
|
||||
there is no second copy of the account table anywhere in this extension.
|
||||
|
||||
It reads accounts the operator does not own, which is exactly why every
|
||||
caller of it is gated on `check_super_user` at the API boundary.
|
||||
"""
|
||||
|
||||
from lnbits.core.crud import get_accounts, get_wallets
|
||||
from lnbits.db import Filters
|
||||
|
||||
from .models import DirectoryUser, DirectoryWallet
|
||||
|
||||
# Core paginates accounts and defaults to 10 per page. A payroll picker
|
||||
# wants the whole roster in one go; this cap keeps a pathological instance
|
||||
# from building a five-figure dropdown, and the search box narrows anything
|
||||
# larger server-side.
|
||||
MAX_DIRECTORY_USERS = 500
|
||||
|
||||
|
||||
async def list_directory_users(
|
||||
search: str | None = None, limit: int = MAX_DIRECTORY_USERS
|
||||
) -> list[DirectoryUser]:
|
||||
"""Accounts with their (non-deleted) wallets, for the payout picker.
|
||||
|
||||
Accounts with no wallet are still returned: they are legitimate payroll
|
||||
targets, they just need a wallet created before a contract can point at
|
||||
one. Hiding them would make the operator wonder why a user they can see
|
||||
in the admin UI is missing here.
|
||||
"""
|
||||
page = await get_accounts(
|
||||
filters=Filters(search=search or None, limit=min(limit, MAX_DIRECTORY_USERS))
|
||||
)
|
||||
|
||||
users: list[DirectoryUser] = []
|
||||
for account in page.data:
|
||||
wallets = await get_wallets(account.id)
|
||||
users.append(
|
||||
DirectoryUser(
|
||||
id=account.id,
|
||||
username=account.username,
|
||||
email=account.email,
|
||||
wallets=[
|
||||
DirectoryWallet(
|
||||
id=w.id, name=w.name, balance_msat=w.balance_msat
|
||||
)
|
||||
for w in wallets
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
# Readable ordering for a dropdown; core sorts by whatever the page
|
||||
# query returned, which is account id.
|
||||
users.sort(key=lambda u: (u.display_name or "").lower())
|
||||
return users
|
||||
|
||||
|
||||
async def owns_wallet(user_id: str, wallet_id: str) -> bool:
|
||||
"""Whether `wallet_id` belongs to `user_id`.
|
||||
|
||||
The guard that stops a payroll contract from being pointed at a wallet
|
||||
belonging to somebody other than the selected employee — the id pair
|
||||
arrives from a form, and nothing else in the write path re-checks it.
|
||||
"""
|
||||
wallets = await get_wallets(user_id)
|
||||
return any(w.id == wallet_id for w in wallets)
|
||||
Loading…
Add table
Add a link
Reference in a new issue