feat(V1-API): implement TREX-Basic consumer API contract with encryption and persistence

- Add V1 API endpoints: assets, ledger, user-info
- Implement Fernet-based credential payload encryption (req-010)
- Extend health response schema with runtime info (req-004)
- Add service token runtime ID issuance (req-005)
- Implement database schema migration for V1 API (req-009)
- Add comprehensive test coverage for V1 endpoints
- Update SRS with 10 new requirements (REQ-004 through REQ-011)
- Create batch planning, design, and verification for BAT-001
- Integrate review-discovery StagePilot skill
- Update documentation indices and baseline specs
This commit is contained in:
2026-05-02 09:06:15 +00:00
parent 24096a1e58
commit ce63c75c9c
37 changed files with 2851 additions and 21 deletions
+25 -3
View File
@@ -1,10 +1,32 @@
"""Health and readiness endpoints."""
from __future__ import annotations
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter
from pydantic import BaseModel
from app.core.config import get_settings
router = APIRouter(tags=["health"])
_KST = timezone(timedelta(hours=9))
@router.get("/health")
def healthcheck() -> dict[str, str]:
return {"status": "ok"}
class HealthResponse(BaseModel):
status: str
service: str
version: str
checked_at: str
@router.get("/health", response_model=HealthResponse)
def healthcheck() -> HealthResponse:
settings = get_settings()
return HealthResponse(
status="ok",
service="portfolio-hub",
version=settings.app_version,
checked_at=datetime.now(_KST).isoformat(),
)
+61
View File
@@ -0,0 +1,61 @@
"""GET /v1/assets — service-token authenticated asset list endpoint."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from app.core.auth import AuthContext, require_read
from app.repositories.factory import get_portfolio_repository_from_settings
from app.repositories.portfolio_repository import PortfolioRepository
from app.schemas.v1 import AssetItem, AssetsResponse
router = APIRouter(prefix="/v1", tags=["v1"])
def _require_service_token(auth: AuthContext = Depends(require_read)) -> AuthContext:
if auth.token_source != "db" or auth.token_type != "service":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Service token required",
)
return auth
@router.get("/assets", response_model=AssetsResponse)
def list_assets(auth: AuthContext = Depends(_require_service_token)) -> AssetsResponse:
from app.core.config import get_settings
settings = get_settings()
repo = get_portfolio_repository_from_settings(settings)
try:
portfolios = repo.list_portfolios_with_strategies(
owner_user_id=None,
include_disabled=False,
)
finally:
if callable(getattr(type(repo), "close", None)):
repo.close()
items = [
AssetItem(
asset_id=p.portfolio_id,
broker=p.broker,
market=p.market,
symbol=p.symbol,
name=p.name,
status=p.status,
cash_balance=p.cash_balance,
asset_quantity=p.asset_quantity,
price=p.price,
profit_buffer=p.profit_buffer,
active_strategy_id=p.active_strategy_id,
available_strategies=p.avabile_strategies,
tags=p.tags,
owner_user_id=p.owner_user_id,
created_at=p.created_at,
updated_at=p.updated_at,
last_trade_at=p.last_trade_at,
)
for p in portfolios
]
return AssetsResponse(assets=items, total=len(items))
+66
View File
@@ -0,0 +1,66 @@
"""POST /v1/assets/{asset_id}/ledger/trades — idempotent ledger trade recording."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from app.core.auth import AuthContext, require_read
from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.v1 import LedgerTradeRequest, LedgerTradeResponse
router = APIRouter(prefix="/v1", tags=["v1"])
def _require_service_token(auth: AuthContext = Depends(require_read)) -> AuthContext:
if auth.token_source != "db" or auth.token_type != "service":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Service token required",
)
return auth
@router.post(
"/assets/{asset_id}/ledger/trades",
response_model=LedgerTradeResponse,
status_code=status.HTTP_201_CREATED,
)
def record_ledger_trade(
asset_id: str,
body: LedgerTradeRequest,
auth: AuthContext = Depends(_require_service_token),
) -> LedgerTradeResponse:
from app.core.config import get_settings
settings = get_settings()
repo = get_portfolio_repository_from_settings(settings)
try:
trade, created = repo.insert_ledger_trade(
asset_id=asset_id,
order_id=body.order_id,
execution_id=body.execution_id,
side=body.side,
quantity=float(body.quantity),
price=float(body.price),
executed_at=body.executed_at,
fee_amount=float(body.fee_amount),
tax_amount=float(body.tax_amount),
)
finally:
if callable(getattr(type(repo), "close", None)):
repo.close()
return LedgerTradeResponse(
ledger_entry_id=trade.ledger_entry_id,
asset_id=trade.asset_id,
order_id=trade.order_id,
execution_id=trade.execution_id,
side=trade.side,
quantity=trade.quantity,
price=trade.price,
fee_amount=trade.fee_amount,
tax_amount=trade.tax_amount,
executed_at=trade.executed_at,
recorded_at=trade.recorded_at,
created=created,
)
+53
View File
@@ -0,0 +1,53 @@
"""GET /v1/user_info — service-token authenticated user info endpoint."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, status
from app.core.auth import AuthContext, require_read
from app.repositories.factory import get_portfolio_repository_from_settings
from app.schemas.v1 import UserInfoResponse
router = APIRouter(prefix="/v1", tags=["v1"])
def _require_service_token(auth: AuthContext = Depends(require_read)) -> AuthContext:
if auth.token_source != "db" or auth.token_type != "service":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Service token required",
)
return auth
@router.get("/user_info", response_model=UserInfoResponse)
def get_user_info(auth: AuthContext = Depends(_require_service_token)) -> UserInfoResponse:
if not auth.runtime_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="runtime_id not assigned to this token",
)
from app.core.config import get_settings
settings = get_settings()
repo = get_portfolio_repository_from_settings(settings)
try:
user = repo.get_user(auth.user_id) # type: ignore[arg-type]
finally:
if callable(getattr(type(repo), "close", None)):
repo.close()
disabled_reason: str | None = None
if not user.is_active:
disabled_reason = "account_inactive"
return UserInfoResponse(
user_id=user.id,
username=user.username,
email=user.email,
permission=user.permission,
is_active=user.is_active,
disabled_reason=disabled_reason,
runtime_id=auth.runtime_id,
)
+1
View File
@@ -78,6 +78,7 @@ def issue_service_token(
name=name,
expires_at=issued.auth_token.expires_at,
revoked=issued.auth_token.revoked_at is not None,
runtime_id=issued.auth_token.runtime_id,
token=issued.token,
)
render_service_token_result(ctx.obj.console, ctx.obj.output_format, response)
+2
View File
@@ -28,6 +28,7 @@ class AuthContext:
token_id: str | None = None
token_type: str | None = None
token_source: str = "env"
runtime_id: str | None = None
def get_auth_repository(settings: Settings = Depends(get_settings)) -> Generator[PortfolioRepository, None, None]:
repository = get_portfolio_repository_from_settings(settings)
@@ -66,6 +67,7 @@ def _resolve_auth_context(token: str, settings: Settings, repository: PortfolioR
token_id=principal.token_id,
token_type=principal.token_type,
token_source="db",
runtime_id=principal.runtime_id,
)
+17 -1
View File
@@ -7,7 +7,7 @@ from pathlib import Path
from urllib.parse import quote_plus
from dotenv import load_dotenv
from pydantic import Field, computed_field
from pydantic import Field, computed_field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Load .env file from project root
@@ -50,6 +50,7 @@ class Settings(BaseSettings):
loki_host: str = Field(default="localhost", validation_alias="LOKI_HOST")
loki_port: int = Field(default=3100, validation_alias="LOKI_PORT")
loki_enabled: bool = Field(default=False, validation_alias="LOKI_ENABLED")
credential_encryption_key: str = Field(default="", validation_alias="CREDENTIAL_ENCRYPTION_KEY")
model_config = SettingsConfigDict(
env_prefix="PORTFOLIOHUB_",
@@ -58,6 +59,21 @@ class Settings(BaseSettings):
populate_by_name=True,
)
@model_validator(mode="after")
def _validate_credential_encryption_key(self) -> "Settings":
# Only validate at runtime when the key is actually used.
# This avoids blocking test environments that don't exercise encryption.
return self
def require_credential_encryption_key(self) -> str:
"""Return the key or raise RuntimeError if it is not configured."""
if not self.credential_encryption_key.strip():
raise RuntimeError(
"CREDENTIAL_ENCRYPTION_KEY is not configured. "
"Generate one with: python -c \"from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())\""
)
return self.credential_encryption_key
@computed_field
@property
def allowed_origins(self) -> list[str]:
+26
View File
@@ -0,0 +1,26 @@
"""Fernet symmetric encryption utilities for credential payloads."""
from __future__ import annotations
from cryptography.fernet import Fernet, InvalidToken # noqa: F401 — re-exported for callers
def make_fernet(key: str) -> Fernet:
"""Return a Fernet instance from a base64-encoded key string."""
return Fernet(key.encode() if isinstance(key, str) else key)
def encrypt_payload(key: str, plaintext: str) -> str:
"""Encrypt *plaintext* and return the base64 ciphertext string."""
f = make_fernet(key)
return f.encrypt(plaintext.encode()).decode()
def decrypt_payload(key: str, ciphertext: str) -> str:
"""Decrypt *ciphertext* and return the plaintext string.
Raises ``cryptography.fernet.InvalidToken`` if the key is wrong or the
token is malformed.
"""
f = make_fernet(key)
return f.decrypt(ciphertext.encode()).decode()
+28
View File
@@ -41,6 +41,7 @@ class AuthToken:
last_used_at: datetime | None = None
revoked_at: datetime | None = None
created_at: datetime | None = None
runtime_id: str | None = None
@dataclass
@@ -49,6 +50,7 @@ class TokenPrincipal:
user_id: str
permission: str
token_type: str
runtime_id: str | None = None
@dataclass
@@ -58,6 +60,7 @@ class Portfolio:
status: str
name: str
symbol: str
market: str | None = None
tags: list[str] | None = None
owner_user_id: str | None = None
cash_balance: float = 0.0
@@ -107,3 +110,28 @@ class PortfolioTag:
@runtime_checkable
class LiveAssetSource(Protocol):
def get_asset_quantity(self, symbol: str) -> float: ...
@dataclass
class BrokerCredential:
user_id: str
broker: str
account_ref: str
encrypted_payload: str
credential_version: int = 1
last_rotated_at: datetime | None = None
@dataclass
class LedgerTrade:
ledger_entry_id: int
asset_id: str
order_id: str
execution_id: str
side: str
quantity: float
price: float
fee_amount: float
tax_amount: float
executed_at: datetime
recorded_at: datetime | None = None
+6
View File
@@ -11,6 +11,9 @@ from app.api.auth_routes import router as auth_router
from app.api.portfolios import router as portfolio_router
from app.api.strategy_sets import router as strategy_sets_router
from app.api.users import router as users_router
from app.api.v1_assets import router as v1_assets_router
from app.api.v1_user_info import router as v1_user_info_router
from app.api.v1_ledger import router as v1_ledger_router
from app.core.config import Settings, get_settings
from app.core.errors import register_exception_handlers
from app.core.security import hash_password
@@ -73,6 +76,9 @@ def create_app(
app.include_router(portfolio_router)
app.include_router(strategy_sets_router)
app.include_router(users_router)
app.include_router(v1_assets_router)
app.include_router(v1_user_info_router)
app.include_router(v1_ledger_router)
register_exception_handlers(app)
return app
+211 -8
View File
@@ -11,7 +11,7 @@ from typing import Any
import psycopg
from psycopg import sql
from app.domain.models import AuthToken, LedgerEntry, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
from app.domain.models import AuthToken, BrokerCredential, LedgerEntry, LedgerTrade, Portfolio, PortfolioComment, PortfolioTag, StrategySet, TokenPrincipal, User
_SELECT_USER = """
SELECT
@@ -38,7 +38,8 @@ _SELECT_AUTH_TOKEN = """
expires_at,
last_used_at,
revoked_at,
created_at
created_at,
runtime_id
FROM auth_tokens
"""
@@ -58,7 +59,8 @@ _SELECT_PORTFOLIO = """
owner_user_id,
created_at,
updated_at,
last_trade_at
last_trade_at,
market
FROM portfolios
"""
@@ -300,6 +302,7 @@ class PostgresPortfolioRepository:
last_used_at=row[7] if len(row) > 7 else None,
revoked_at=row[8] if len(row) > 8 else None,
created_at=row[9] if len(row) > 9 else None,
runtime_id=row[10] if len(row) > 10 else None,
)
@staticmethod
@@ -332,6 +335,7 @@ class PostgresPortfolioRepository:
created_at=row[12] if len(row) > 12 else None,
updated_at=row[13] if len(row) > 13 else None,
last_trade_at=row[14] if len(row) > 14 else None,
market=row[15] if len(row) > 15 else None,
)
@staticmethod
@@ -532,6 +536,7 @@ class PostgresPortfolioRepository:
permission: str,
token_type: str,
expires_at: datetime | None = None,
runtime_id: str | None = None,
) -> AuthToken:
self.ensure_schema()
with self._connect().cursor() as cur:
@@ -542,14 +547,14 @@ class PostgresPortfolioRepository:
cur.execute(
"""
INSERT INTO auth_tokens (
id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at
id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING
id, user_id, name, token_prefix, permission, token_type,
expires_at, last_used_at, revoked_at, created_at
expires_at, last_used_at, revoked_at, created_at, runtime_id
""",
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at),
(token_id, user_id, name, token_prefix, token_hash, permission, token_type, expires_at, runtime_id),
)
row = cur.fetchone()
return self._row_to_auth_token(row)
@@ -612,7 +617,7 @@ class PostgresPortfolioRepository:
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT t.id, t.user_id, t.permission, t.token_type
SELECT t.id, t.user_id, t.permission, t.token_type, t.runtime_id
FROM auth_tokens t
WHERE token_hash = %s
AND t.revoked_at IS NULL
@@ -629,6 +634,7 @@ class PostgresPortfolioRepository:
user_id=row[1],
permission=row[2],
token_type=row[3],
runtime_id=row[4] if len(row) > 4 else None,
)
def get(self, portfolio_id: str) -> Portfolio:
@@ -1296,6 +1302,203 @@ class PostgresPortfolioRepository:
if cur.fetchone() is None:
raise ValueError("댓글을 찾을 수 없습니다")
# ------------------------------------------------------------------
# Broker credentials
# ------------------------------------------------------------------
@staticmethod
def _row_to_broker_credential(row: Any) -> BrokerCredential:
if row is None:
raise ValueError("자격증명을 찾을 수 없습니다")
return BrokerCredential(
user_id=row[0],
broker=row[1],
account_ref=row[2],
encrypted_payload=row[3],
credential_version=int(row[4]),
last_rotated_at=row[5] if len(row) > 5 else None,
)
def get_broker_credential(self, user_id: str, broker: str) -> BrokerCredential | None:
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
FROM broker_credentials
WHERE user_id = %s AND broker = %s
""",
(user_id, broker),
)
row = cur.fetchone()
if row is None:
return None
return self._row_to_broker_credential(row)
def upsert_broker_credential(
self,
user_id: str,
broker: str,
*,
account_ref: str,
encrypted_payload: str,
) -> BrokerCredential:
with self._connect().cursor() as cur:
cur.execute(
"""
INSERT INTO broker_credentials (user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at)
VALUES (%s, %s, %s, %s, 1, NOW())
ON CONFLICT (user_id, broker) DO UPDATE
SET account_ref = EXCLUDED.account_ref,
encrypted_payload = EXCLUDED.encrypted_payload,
credential_version = broker_credentials.credential_version + 1,
last_rotated_at = NOW()
RETURNING user_id, broker, account_ref, encrypted_payload, credential_version, last_rotated_at
""",
(user_id, broker, account_ref, encrypted_payload),
)
row = cur.fetchone()
return self._row_to_broker_credential(row)
# ------------------------------------------------------------------
# Ledger trades
# ------------------------------------------------------------------
@staticmethod
def _row_to_ledger_trade(row: Any) -> LedgerTrade:
if row is None:
raise ValueError("원장 거래를 찾을 수 없습니다")
return LedgerTrade(
ledger_entry_id=int(row[0]),
asset_id=row[1],
order_id=row[2],
execution_id=row[3],
side=row[4],
quantity=float(row[5]),
price=float(row[6]),
fee_amount=float(row[7]),
tax_amount=float(row[8]),
executed_at=row[9],
recorded_at=row[10] if len(row) > 10 else None,
)
def insert_ledger_trade(
self,
asset_id: str,
order_id: str,
execution_id: str,
side: str,
quantity: float,
price: float,
executed_at: datetime,
*,
fee_amount: float = 0.0,
tax_amount: float = 0.0,
) -> tuple[LedgerTrade, bool]:
"""Insert a ledger trade, returning (trade, created).
Returns ``created=False`` and the existing row when the (asset_id, order_id, execution_id)
triplet already exists (idempotent).
"""
from decimal import Decimal
with self._connect().cursor() as cur:
try:
cur.execute(
"""
INSERT INTO ledger_trades
(asset_id, order_id, execution_id, side, quantity, price, fee_amount, tax_amount, executed_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING
ledger_entry_id, asset_id, order_id, execution_id, side,
quantity, price, fee_amount, tax_amount, executed_at, recorded_at
""",
(
asset_id,
order_id,
execution_id,
side,
Decimal(str(quantity)),
Decimal(str(price)),
Decimal(str(fee_amount)),
Decimal(str(tax_amount)),
executed_at,
),
)
row = cur.fetchone()
return self._row_to_ledger_trade(row), True
except psycopg.errors.UniqueViolation:
cur.execute(
"""
SELECT
ledger_entry_id, asset_id, order_id, execution_id, side,
quantity, price, fee_amount, tax_amount, executed_at, recorded_at
FROM ledger_trades
WHERE asset_id = %s AND order_id = %s AND execution_id = %s
""",
(asset_id, order_id, execution_id),
)
row = cur.fetchone()
return self._row_to_ledger_trade(row), False
def list_ledger_trades(self, asset_id: str, *, limit: int = 100) -> list[LedgerTrade]:
with self._connect().cursor() as cur:
cur.execute(
"""
SELECT
ledger_entry_id, asset_id, order_id, execution_id, side,
quantity, price, fee_amount, tax_amount, executed_at, recorded_at
FROM ledger_trades
WHERE asset_id = %s
ORDER BY executed_at DESC, ledger_entry_id DESC
LIMIT %s
""",
(asset_id, limit),
)
rows = cur.fetchall()
return [self._row_to_ledger_trade(row) for row in rows]
# ------------------------------------------------------------------
# Portfolio+strategy_set join query for /v1/assets
# ------------------------------------------------------------------
def list_portfolios_with_strategies(
self,
owner_user_id: str | None = None,
*,
broker: str | None = None,
include_disabled: bool = False,
) -> list[Portfolio]:
"""Return portfolios with tags attached. Optionally filter by owner or broker."""
conditions: list[str] = []
params: list[object] = []
if not include_disabled:
conditions.append("p.status != 'disabled'")
if owner_user_id is not None:
conditions.append("p.owner_user_id = %s")
params.append(owner_user_id)
if broker is not None:
conditions.append("p.broker = %s")
params.append(broker)
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
with self._connect().cursor() as cur:
cur.execute(
f"""
SELECT
p.portfolio_id, p.broker, p.status, p.name, p.symbol,
p.cash_balance, p.asset_quantity, p.asset_price, p.profit_buffer,
p.active_strategy_id, p.avabile_strategies, p.owner_user_id,
p.created_at, p.updated_at, p.last_trade_at, p.market
FROM portfolios p
{where}
ORDER BY p.updated_at DESC, p.portfolio_id ASC
""",
params,
)
rows = cur.fetchall()
portfolios = [self._row_to_portfolio(row) for row in rows]
self._attach_tags(cur, portfolios)
return portfolios
def database_exists(config: PortfolioDBConfig, *, maintenance_db: str = "postgres") -> bool:
with psycopg.connect(
+1
View File
@@ -75,6 +75,7 @@ class ServiceTokenIssueCliResponse(BaseModel):
name: str
expires_at: datetime | None = None
revoked: bool
runtime_id: str | None = None
token: str | None = None
+82
View File
@@ -0,0 +1,82 @@
"""Schemas for v1 (Consumer-Driven Contract) API endpoints."""
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import Literal
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# GET /v1/assets
# ---------------------------------------------------------------------------
class AssetItem(BaseModel):
asset_id: str
broker: str
market: str | None = None
symbol: str
name: str
status: str
cash_balance: float
asset_quantity: float
price: float
profit_buffer: float
active_strategy_id: str | None = None
available_strategies: list[str] | None = None
tags: list[str] | None = None
owner_user_id: str | None = None
created_at: datetime | None = None
updated_at: datetime | None = None
last_trade_at: datetime | None = None
class AssetsResponse(BaseModel):
assets: list[AssetItem]
total: int
# ---------------------------------------------------------------------------
# GET /v1/user_info
# ---------------------------------------------------------------------------
class UserInfoResponse(BaseModel):
user_id: str
username: str
email: str | None = None
permission: str
is_active: bool
disabled_reason: str | None = None
runtime_id: str
# ---------------------------------------------------------------------------
# POST /v1/assets/{asset_id}/ledger/trades
# ---------------------------------------------------------------------------
class LedgerTradeRequest(BaseModel):
order_id: str = Field(..., min_length=1)
execution_id: str = Field(..., min_length=1)
side: Literal["buy", "sell"]
quantity: Decimal = Field(..., gt=0)
price: Decimal = Field(..., gt=0)
fee_amount: Decimal = Field(default=Decimal("0"), ge=0)
tax_amount: Decimal = Field(default=Decimal("0"), ge=0)
executed_at: datetime
class LedgerTradeResponse(BaseModel):
ledger_entry_id: int
asset_id: str
order_id: str
execution_id: str
side: str
quantity: float
price: float
fee_amount: float
tax_amount: float
executed_at: datetime
recorded_at: datetime | None = None
created: bool
+3
View File
@@ -2,6 +2,7 @@
from __future__ import annotations
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
@@ -131,6 +132,7 @@ class AuthService:
expires_at = datetime.now(timezone.utc) + timedelta(days=days)
raw_token = generate_token()
runtime_id = str(uuid.uuid4())
try:
auth_token = self._repository.create_auth_token(
user.id,
@@ -140,6 +142,7 @@ class AuthService:
permission=permission,
token_type="service",
expires_at=expires_at,
runtime_id=runtime_id,
)
except psycopg.errors.UniqueViolation as exc:
raise ValueError(f"이미 존재하는 서비스 토큰 이름입니다: {name}") from exc