nostrmarket/views_api.py
Padreug 9f116ff1f8 feat(signer): stop reading account.prvkey in merchant provision/rotate paths (#5)
Pre-cascade prerequisite for aiolabs/lnbits#17 (signer abstraction
phase 1), which lands an m002 startup job that fail-closed NULLs the
legacy `accounts.prvkey` column. This commit migrates the two sites
in `views_api.py` that read `account.prvkey` so they no longer
silently undo m002, and fail-closed cleanly when prvkey is missing.

Scope intentionally narrow — this is the prvkey-elimination subset
of aiolabs/nostrmarket#5. The full phase A (envelope-encrypt
`merchants.private_key` → `signer_blob`) and phase B (route
`Merchant.sign_hash` through core's `NostrSigner`) work remains
tracked under that issue.

## What changed

### views_api.py — `_auto_create_merchant`

Was: lazy fallback that, if `account.prvkey` was missing, generated
a fresh keypair and wrote it back into the account (lines 112-118).
After m002 NULLs `accounts.prvkey`, this regenerate-and-write-back
path would silently undo the migration AND change the user's
Nostr pubkey out from under them.

Now: no longer touches the account. Asserts `account.prvkey` is
present (matching the existing pubkey assertion) with a clear
fail-closed message pointing at aiolabs/nostrmarket#5 for the
phase A/B fix. For accounts that still carry a plaintext prvkey
(pre-m002, FakeWallet local dev, etc.) the auto-provision path
continues to work unchanged. For migrated accounts, the assertion
fires fast with an actionable error.

Removed the regenerate block entirely. Dropped now-unused imports:
`update_account`, `generate_keypair`.

### views_api.py — `api_migrate_merchant_keys`

Was: same `account and account.pubkey and account.prvkey` assertion
with the generic message "Account has no Nostr keypair".

Now: assertion updated with the same bridge-state framing — points
at aiolabs/nostrmarket#5 for the phase A/B fix.

## Acceptance

- [x] regenerate-and-write-back block removed (would undo m002)
- [x] `account.prvkey` references in views_api.py are assertions only
      (fail-closed guards, not data reads)
- [x] unused imports dropped (`update_account`, `generate_keypair`)
- [x] error messages reference aiolabs/nostrmarket#5 for the
      phase A/B fix path

Manual smoke / version bump / tag / catalog entry deferred until
the lnbits cascade lands AND phase A's schema migration ships;
this commit alone doesn't change the on-disk merchants table.

## Out of scope (per aiolabs/nostrmarket#5)

- Phase A: envelope-encrypting `merchants.private_key` column.
- Phase B (full): refactoring `Merchant.sign_hash` /
  `helpers.sign_message_hash` through core's `NostrSigner`.
- Phase C: NIP-46 bunker + NIP-26 delegation variants.
- Re-enabling `_create_default_merchant` on the lnbits core side.

## Cross-references

- aiolabs/nostrmarket#5 — issue this is a partial step toward
- aiolabs/lnbits#17 — the cascading signer-abstraction PR whose
  m002 fail-closed NULLs `accounts.prvkey`
- aiolabs/lnbits#21 — umbrella audit (5 affected extensions)
- aiolabs/events#23 / aiolabs/tasks#3 / aiolabs/restaurant#11 —
  sister migrations already on signer-abstraction branches

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 08:58:31 +02:00

1256 lines
39 KiB
Python

import json
from http import HTTPStatus
from typing import List, Optional
from fastapi import Depends
from fastapi.exceptions import HTTPException
from lnbits.core.crud import get_account
from lnbits.core.services import websocket_updater
from lnbits.decorators import (
WalletTypeInfo,
require_admin_key,
require_invoice_key,
)
from lnbits.utils.exchange_rates import currencies
from loguru import logger
from . import nostr_client, nostrmarket_ext
from .crud import (
create_customer,
create_direct_message,
create_merchant,
create_product,
create_stall,
create_zone,
delete_merchant,
delete_merchant_direct_messages,
delete_merchant_orders,
delete_merchant_products,
delete_merchant_stalls,
delete_merchant_zones,
delete_product,
delete_stall,
delete_zone,
get_customer,
get_customers,
get_direct_message_by_event_id,
get_direct_messages,
get_last_direct_messages_time,
get_merchant_by_pubkey,
get_merchant_for_user,
update_merchant_keys,
get_order,
get_order_by_event_id,
get_orders,
get_orders_for_stall,
get_orders_from_direct_messages,
get_product,
get_products,
get_stall,
get_stalls,
get_zone,
get_zones,
touch_merchant,
update_customer_no_unread_messages,
update_merchant,
update_order,
update_order_shipped_status,
update_product,
update_stall,
update_zone,
)
from .helpers import normalize_public_key
from .models import (
CreateMerchantRequest,
Customer,
DirectMessage,
DirectMessageType,
Merchant,
MerchantConfig,
Order,
OrderReissue,
OrderStatusUpdate,
PartialDirectMessage,
PartialMerchant,
PartialOrder,
PaymentOption,
PaymentRequest,
Product,
Stall,
Zone,
)
from .services import (
build_order_with_payment,
create_or_update_order_from_dm,
provision_merchant,
reply_to_structured_dm,
resubscribe_to_all_merchants,
send_dm,
sign_and_send_to_nostr,
subscribe_to_all_merchants,
update_merchant_to_nostr,
)
######################################## MERCHANT ######################################
async def _auto_create_merchant(
wallet: WalletTypeInfo,
config: MerchantConfig | None = None,
) -> Merchant:
"""
Lazy fallback: provision a merchant from the user's account keypair when
the LNbits-side eager provisioning didn't run.
Delegates to services.provision_merchant — the canonical implementation.
Pre-cascade bridge state (see aiolabs/nostrmarket#5):
After aiolabs/lnbits#17 m002 lands, `accounts.prvkey` is fail-closed
NULL'd for migrated accounts (the cleartext nsec lives encrypted in
`signer_config`, owned by the core signer abstraction). Auto-provision
cannot extract that cleartext to copy into `merchants.private_key`,
so this path fails-closed when prvkey is missing. The proper fix is
phase A (envelope-encrypt `merchants.private_key` → `signer_blob`)
followed by phase B (route `Merchant.sign_hash` through core's
`NostrSigner`) per aiolabs/nostrmarket#5. Until then, migrated
accounts must explicitly provision a merchant through the future
phase-A-aware flow.
The previous regenerate-and-write-back block (generated a fresh
keypair and stored it into the account) was removed because it
would silently undo m002's NULL'ing.
"""
account = await get_account(wallet.wallet.user)
assert account, "User account not found"
assert account.pubkey and account.prvkey, (
"Account has no plaintext Nostr keypair available for merchant "
"provisioning (see aiolabs/nostrmarket#5 for the phase A/B fix)"
)
merchant = await provision_merchant(
user_id=wallet.wallet.user,
wallet_id=wallet.wallet.id,
public_key=account.pubkey,
private_key=account.prvkey,
display_name=account.username,
config=config,
)
await resubscribe_to_all_merchants()
await nostr_client.merchant_temp_subscription(account.pubkey)
return merchant
@nostrmarket_ext.post("/api/v1/merchant")
async def api_create_merchant(
data: CreateMerchantRequest,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Merchant:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant is None, "A merchant already exists for this user"
return await _auto_create_merchant(wallet, data.config)
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create merchant",
) from ex
@nostrmarket_ext.get("/api/v1/merchant")
async def api_get_merchant(
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> Merchant:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
if not merchant:
# Auto-provision merchant from the user's account keypair
merchant = await _auto_create_merchant(wallet)
merchant = await touch_merchant(wallet.wallet.user, merchant.id)
assert merchant
last_dm_time = await get_last_direct_messages_time(merchant.id)
assert merchant.time
merchant.config.restore_in_progress = (merchant.time - last_dm_time) < 30
# Detect keypair rotation: account key no longer matches merchant key
account = await get_account(wallet.wallet.user)
if account and account.pubkey and account.pubkey != merchant.public_key:
merchant.config.key_mismatch = True
return merchant
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot get merchant",
) from ex
@nostrmarket_ext.delete("/api/v1/merchant/{merchant_id}")
async def api_delete_merchant(
merchant_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
assert merchant.id == merchant_id, "Wrong merchant ID"
await nostr_client.unsubscribe_merchants()
await delete_merchant_orders(merchant.id)
await delete_merchant_products(merchant.id)
await delete_merchant_stalls(merchant.id)
await delete_merchant_direct_messages(merchant.id)
await delete_merchant_zones(merchant.id)
await delete_merchant(merchant.id)
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot get merchant",
) from ex
finally:
await subscribe_to_all_merchants()
@nostrmarket_ext.post("/api/v1/merchant/{merchant_id}/migrate-keys")
async def api_migrate_merchant_keys(
merchant_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Merchant:
"""
Migrate a merchant to the current account keypair.
When a user rotates their Nostr keypair, the merchant still holds the old
key. This endpoint updates the merchant's keys to match the account,
then republishes all stalls and products under the new identity.
Orders and DM history are preserved (they reference customer pubkeys,
not the merchant key). Old stall/product events on relays become
orphaned — clients following the new pubkey will see the fresh events.
"""
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
assert merchant.id == merchant_id, "Wrong merchant ID"
account = await get_account(wallet.wallet.user)
# account.prvkey is fail-closed NULL'd by aiolabs/lnbits#17 m002
# for migrated accounts. Rotation cannot copy a cleartext nsec
# into merchants.private_key until phase A lands — see
# aiolabs/nostrmarket#5 for the migration plan.
assert account and account.pubkey and account.prvkey, (
"Account has no plaintext Nostr keypair available for key "
"rotation (see aiolabs/nostrmarket#5 for the phase A/B fix)"
)
if account.pubkey == merchant.public_key:
return merchant # already in sync
# Check no other merchant is using the new pubkey
existing = await get_merchant_by_pubkey(account.pubkey)
assert existing is None, (
"Another merchant already uses this public key"
)
old_pubkey = merchant.public_key
# Update merchant keys in DB
merchant = await update_merchant_keys(
wallet.wallet.user, merchant.id,
account.prvkey, account.pubkey,
)
assert merchant
# Republish all stalls and products under the new key
merchant = await update_merchant_to_nostr(merchant)
logger.info(
f"[NOSTRMARKET] Migrated merchant {merchant.id} "
f"from {old_pubkey[:16]}... to {account.pubkey[:16]}..."
)
# Resubscribe with new pubkey
await resubscribe_to_all_merchants()
return merchant
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot migrate merchant keys",
) from ex
@nostrmarket_ext.patch("/api/v1/merchant/{merchant_id}")
async def api_update_merchant(
merchant_id: str,
config: MerchantConfig,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
assert merchant.id == merchant_id, "Wrong merchant ID"
updated_merchant = await update_merchant(
wallet.wallet.user, merchant_id, config
)
return updated_merchant
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot update merchant",
) from ex
@nostrmarket_ext.put("/api/v1/merchant/{merchant_id}/nostr")
async def api_republish_merchant(
merchant_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
assert merchant.id == merchant_id, "Wrong merchant ID"
merchant = await update_merchant_to_nostr(merchant)
await update_merchant(wallet.wallet.user, merchant.id, merchant.config)
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot republish to nostr",
) from ex
@nostrmarket_ext.get("/api/v1/merchant/{merchant_id}/nostr")
async def api_refresh_merchant(
merchant_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
assert merchant.id == merchant_id, "Wrong merchant ID"
await nostr_client.merchant_temp_subscription(merchant.public_key)
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot refresh from nostr",
) from ex
@nostrmarket_ext.put("/api/v1/merchant/{merchant_id}/toggle")
async def api_toggle_merchant(
merchant_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Merchant:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
assert merchant.id == merchant_id, "Wrong merchant ID"
merchant.config.active = not merchant.config.active
await update_merchant(wallet.wallet.user, merchant.id, merchant.config)
return merchant
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot get merchant",
) from ex
@nostrmarket_ext.delete("/api/v1/merchant/{merchant_id}/nostr")
async def api_delete_merchant_on_nostr(
merchant_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
assert merchant.id == merchant_id, "Wrong merchant ID"
merchant = await update_merchant_to_nostr(merchant, True)
await update_merchant(wallet.wallet.user, merchant.id, merchant.config)
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot get merchant",
) from ex
######################################## ZONES ########################################
@nostrmarket_ext.get("/api/v1/zone")
async def api_get_zones(
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> List[Zone]:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
return await get_zones(merchant.id)
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot get zone",
) from ex
@nostrmarket_ext.post("/api/v1/zone")
async def api_create_zone(
data: Zone, wallet: WalletTypeInfo = Depends(require_admin_key)
):
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
zone = await create_zone(merchant.id, data)
return zone
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create zone",
) from ex
@nostrmarket_ext.patch("/api/v1/zone/{zone_id}")
async def api_update_zone(
data: Zone,
zone_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Zone:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
zone = await get_zone(merchant.id, zone_id)
if not zone:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Zone does not exist.",
)
zone = await update_zone(merchant.id, data)
assert zone, "Cannot find updated zone"
return zone
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot update zone",
) from ex
@nostrmarket_ext.delete("/api/v1/zone/{zone_id}")
async def api_delete_zone(zone_id, wallet: WalletTypeInfo = Depends(require_admin_key)):
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
zone = await get_zone(merchant.id, zone_id)
if not zone:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Zone does not exist.",
)
await delete_zone(merchant.id, zone_id)
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot delete zone",
) from ex
######################################## STALLS ########################################
@nostrmarket_ext.post("/api/v1/stall")
async def api_create_stall(
data: Stall,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Stall:
try:
# shipping_zones = await
data.validate_stall()
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
stall = await create_stall(merchant.id, data=data)
event = await sign_and_send_to_nostr(merchant, stall)
stall.event_id = event.id
await update_stall(merchant.id, stall)
return stall
except (ValueError, AssertionError) as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create stall",
) from ex
@nostrmarket_ext.put("/api/v1/stall/{stall_id}")
async def api_update_stall(
data: Stall,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Stall:
try:
data.validate_stall()
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
stall = await update_stall(merchant.id, data)
assert stall, "Cannot update stall"
event = await sign_and_send_to_nostr(merchant, stall)
stall.event_id = event.id
await update_stall(merchant.id, stall)
return stall
except (ValueError, AssertionError) as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot update stall",
) from ex
@nostrmarket_ext.get("/api/v1/stall/{stall_id}")
async def api_get_stall(
stall_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
):
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
stall = await get_stall(merchant.id, stall_id)
if not stall:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Stall does not exist.",
)
return stall
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except HTTPException as ex:
raise ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot get stall",
) from ex
@nostrmarket_ext.get("/api/v1/stall")
async def api_get_stalls(
pending: Optional[bool] = False,
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
stalls = await get_stalls(merchant.id, pending)
return stalls
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot get stalls",
) from ex
@nostrmarket_ext.get("/api/v1/stall/product/{stall_id}")
async def api_get_stall_products(
stall_id: str,
pending: Optional[bool] = False,
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
products = await get_products(merchant.id, stall_id, pending)
return products
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot get stall products",
) from ex
@nostrmarket_ext.get("/api/v1/stall/order/{stall_id}")
async def api_get_stall_orders(
stall_id: str,
paid: Optional[bool] = None,
shipped: Optional[bool] = None,
pubkey: Optional[str] = None,
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
orders = await get_orders_for_stall(
merchant.id, stall_id, paid=paid, shipped=shipped, public_key=pubkey
)
return orders
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot get stall products",
) from ex
@nostrmarket_ext.delete("/api/v1/stall/{stall_id}")
async def api_delete_stall(
stall_id: str, wallet: WalletTypeInfo = Depends(require_admin_key)
):
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
stall = await get_stall(merchant.id, stall_id)
if not stall:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Stall does not exist.",
)
await delete_stall(merchant.id, stall_id)
event = await sign_and_send_to_nostr(merchant, stall, True)
stall.event_id = event.id
await update_stall(merchant.id, stall)
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot delete stall",
) from ex
######################################## PRODUCTS ######################################
@nostrmarket_ext.post("/api/v1/product")
async def api_create_product(
data: Product,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Product:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
stall = await get_stall(merchant.id, data.stall_id)
assert stall, "Stall missing for product"
data.config.currency = stall.currency
# Re-publish the parent stall before publishing the product. NIP-33
# parameterized replaceable events make this idempotent on relays.
# This guarantees the customer client never sees a product whose
# parent stall isn't on the relay (e.g., when the original stall
# publish failed transiently or never ran).
try:
stall_event = await sign_and_send_to_nostr(merchant, stall)
stall.event_id = stall_event.id
await update_stall(merchant.id, stall)
except Exception as ex:
logger.warning(
f"[NOSTRMARKET] Failed to refresh stall {stall.id} "
f"before product publish: {ex}"
)
product = await create_product(merchant.id, data=data)
event = await sign_and_send_to_nostr(merchant, product)
product.event_id = event.id
await update_product(merchant.id, product)
return product
except (ValueError, AssertionError) as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create product",
) from ex
@nostrmarket_ext.patch("/api/v1/product/{product_id}")
async def api_update_product(
product_id: str,
product: Product,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Product:
try:
if product_id != product.id:
raise ValueError("Bad product ID")
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
stall = await get_stall(merchant.id, product.stall_id)
assert stall, "Stall missing for product"
product.config.currency = stall.currency
product = await update_product(merchant.id, product)
event = await sign_and_send_to_nostr(merchant, product)
product.event_id = event.id
await update_product(merchant.id, product)
return product
except (ValueError, AssertionError) as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot update product",
) from ex
@nostrmarket_ext.get("/api/v1/product/{product_id}")
async def api_get_product(
product_id: str,
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> Optional[Product]:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
products = await get_product(merchant.id, product_id)
return products
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot get product",
) from ex
@nostrmarket_ext.delete("/api/v1/product/{product_id}")
async def api_delete_product(
product_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
):
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
product = await get_product(merchant.id, product_id)
if not product:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Product does not exist.",
)
await delete_product(merchant.id, product_id)
await sign_and_send_to_nostr(merchant, product, True)
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot delete product",
) from ex
######################################## ORDERS ########################################
@nostrmarket_ext.get("/api/v1/order/{order_id}")
async def api_get_order(
order_id: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
):
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
order = await get_order(merchant.id, order_id)
if not order:
raise HTTPException(
status_code=HTTPStatus.NOT_FOUND,
detail="Order does not exist.",
)
return order
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot get order",
) from ex
@nostrmarket_ext.get("/api/v1/order")
async def api_get_orders(
paid: Optional[bool] = None,
shipped: Optional[bool] = None,
pubkey: Optional[str] = None,
wallet: WalletTypeInfo = Depends(require_invoice_key),
):
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
orders = await get_orders(
merchant_id=merchant.id, paid=paid, shipped=shipped, public_key=pubkey
)
return orders
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot get orders",
) from ex
@nostrmarket_ext.patch("/api/v1/order/{order_id}")
async def api_update_order_status(
data: OrderStatusUpdate,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Order:
try:
assert data.shipped is not None, "Shipped value is required for order"
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found for order {data.id}"
order = await update_order_shipped_status(merchant.id, data.id, data.shipped)
assert order, "Cannot find updated order"
data.paid = order.paid
dm_content = json.dumps(
{"type": DirectMessageType.ORDER_PAID_OR_SHIPPED.value, **data.dict()},
separators=(",", ":"),
ensure_ascii=False,
)
await send_dm(
merchant,
order.public_key,
DirectMessageType.ORDER_PAID_OR_SHIPPED.value,
dm_content,
)
return order
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot update order",
) from ex
@nostrmarket_ext.put("/api/v1/order/restore/{event_id}")
async def api_restore_order(
event_id: str,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Optional[Order]:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
dm = await get_direct_message_by_event_id(merchant.id, event_id)
assert dm, "Canot find direct message"
await create_or_update_order_from_dm(merchant.id, merchant.public_key, dm)
return await get_order_by_event_id(merchant.id, event_id)
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot restore order",
) from ex
@nostrmarket_ext.put("/api/v1/orders/restore")
async def api_restore_orders(
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> None:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
dms = await get_orders_from_direct_messages(merchant.id)
for dm in dms:
try:
await create_or_update_order_from_dm(
merchant.id, merchant.public_key, dm
)
except Exception as e:
logger.debug(
f"Failed to restore order from event '{dm.event_id}': '{e!s}'."
)
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot restore orders",
) from ex
@nostrmarket_ext.put("/api/v1/order/reissue")
async def api_reissue_order_invoice(
reissue_data: OrderReissue,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Order:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
data = await get_order(merchant.id, reissue_data.id)
assert data, "Order cannot be found"
if reissue_data.shipping_id:
data.shipping_id = reissue_data.shipping_id
order, invoice, receipt = await build_order_with_payment(
merchant.id, merchant.public_key, PartialOrder(**data.dict())
)
order_update = {
"stall_id": order.stall_id,
"total": order.total,
"invoice_id": order.invoice_id,
"shipping_id": order.shipping_id,
"extra_data": json.dumps(order.extra.dict()),
}
await update_order(
merchant.id,
order.id,
**order_update,
)
payment_req = PaymentRequest(
id=data.id,
payment_options=[PaymentOption(type="ln", link=invoice)],
message=receipt,
)
response = {
"type": DirectMessageType.PAYMENT_REQUEST.value,
**payment_req.dict(),
}
dm_reply = json.dumps(response, separators=(",", ":"), ensure_ascii=False)
await reply_to_structured_dm(
merchant,
order.public_key,
DirectMessageType.PAYMENT_REQUEST.value,
dm_reply,
)
return order
except (AssertionError, ValueError) as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot reissue order invoice",
) from ex
######################################## DIRECT MESSAGES ###############################
@nostrmarket_ext.get("/api/v1/message/{public_key}")
async def api_get_messages(
public_key: str, wallet: WalletTypeInfo = Depends(require_invoice_key)
) -> List[DirectMessage]:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
messages = await get_direct_messages(merchant.id, public_key)
await update_customer_no_unread_messages(merchant.id, public_key)
return messages
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot get direct message",
) from ex
@nostrmarket_ext.post("/api/v1/message")
async def api_create_message(
data: PartialDirectMessage, wallet: WalletTypeInfo = Depends(require_admin_key)
) -> DirectMessage:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
dm_reply = await send_dm(
merchant,
data.public_key,
data.type,
data.message,
)
return dm_reply
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create message",
) from ex
######################################## CUSTOMERS #####################################
@nostrmarket_ext.get("/api/v1/customer")
async def api_get_customers(
wallet: WalletTypeInfo = Depends(require_invoice_key),
) -> List[Customer]:
try:
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "Merchant cannot be found"
return await get_customers(merchant.id)
except AssertionError as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create message",
) from ex
@nostrmarket_ext.post("/api/v1/customer")
async def api_create_customer(
data: Customer,
wallet: WalletTypeInfo = Depends(require_admin_key),
) -> Customer:
try:
pubkey = normalize_public_key(data.public_key)
merchant = await get_merchant_for_user(wallet.wallet.user)
assert merchant, "A merchant does not exists for this user"
assert merchant.id == data.merchant_id, "Invalid merchant id for user"
existing_customer = await get_customer(merchant.id, pubkey)
assert existing_customer is None, "This public key already exists"
customer = await create_customer(
merchant.id, Customer(merchant_id=merchant.id, public_key=pubkey)
)
await nostr_client.user_profile_temp_subscribe(pubkey)
return customer
except (ValueError, AssertionError) as ex:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=str(ex),
) from ex
except Exception as ex:
logger.warning(ex)
raise HTTPException(
status_code=HTTPStatus.INTERNAL_SERVER_ERROR,
detail="Cannot create customer",
) from ex
######################################## OTHER ########################################
@nostrmarket_ext.get("/api/v1/currencies")
async def api_list_currencies_available():
return list(currencies.keys())
@nostrmarket_ext.put("/api/v1/restart")
async def restart_nostr_client(wallet: WalletTypeInfo = Depends(require_admin_key)):
try:
await nostr_client.restart()
except Exception as ex:
logger.warning(ex)