first commit

This commit is contained in:
2025-11-24 23:10:55 -06:00
commit 8315fa51c9
279 changed files with 74600 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Utility modules for Code of Conquest."""

444
api/app/utils/auth.py Normal file
View File

@@ -0,0 +1,444 @@
"""
Authentication Utilities
This module provides authentication middleware, decorators, and helper functions
for protecting routes and managing user sessions.
Usage:
from app.utils.auth import require_auth, require_tier, get_current_user
@app.route('/protected')
@require_auth
def protected_route():
user = get_current_user()
return f"Hello, {user.name}!"
@app.route('/premium-feature')
@require_auth
@require_tier('premium')
def premium_feature():
return "Premium content"
"""
from functools import wraps
from typing import Optional, Callable
from flask import request, g, jsonify, redirect, url_for
from app.services.appwrite_service import AppwriteService, UserData
from app.utils.response import unauthorized_response, forbidden_response
from app.utils.logging import get_logger
from app.config import get_config
from appwrite.exception import AppwriteException
# Initialize logger
logger = get_logger(__file__)
def extract_session_token() -> Optional[str]:
"""
Extract the session token from the request cookie.
Returns:
Session token string if found, None otherwise
"""
config = get_config()
cookie_name = config.auth.cookie_name
token = request.cookies.get(cookie_name)
return token
def verify_session(token: str) -> Optional[UserData]:
"""
Verify a session token and return the associated user data.
This function:
1. Validates the session token with Appwrite
2. Checks if the session is still active (not expired)
3. Retrieves and returns the user data
Args:
token: Session token from cookie
Returns:
UserData object if session is valid, None otherwise
"""
try:
appwrite = AppwriteService()
# Validate session
session_data = appwrite.get_session(session_id=token)
# Get user data
user_data = appwrite.get_user(user_id=session_data.user_id)
return user_data
except AppwriteException as e:
logger.warning("Session verification failed", error=str(e), code=e.code)
return None
except Exception as e:
logger.error("Unexpected error during session verification", error=str(e))
return None
def get_current_user() -> Optional[UserData]:
"""
Get the current authenticated user from the request context.
This function retrieves the user object that was attached to the
request context by the @require_auth decorator.
Returns:
UserData object if user is authenticated, None otherwise
Usage:
@app.route('/profile')
@require_auth
def profile():
user = get_current_user()
return f"Welcome, {user.name}!"
"""
return getattr(g, 'current_user', None)
def require_auth(f: Callable) -> Callable:
"""
Decorator to require authentication for a route (API endpoints).
This decorator:
1. Extracts the session token from the cookie
2. Verifies the session with Appwrite
3. Attaches the user object to the request context (g.current_user)
4. Allows the request to proceed if authenticated
5. Returns 401 Unauthorized JSON if not authenticated
For web views, use @require_auth_web instead.
Args:
f: The Flask route function to wrap
Returns:
Wrapped function with authentication check
Usage:
@app.route('/api/protected')
@require_auth
def protected_route():
user = get_current_user()
return f"Hello, {user.name}!"
"""
@wraps(f)
def decorated_function(*args, **kwargs):
# Extract session token from cookie
token = extract_session_token()
if not token:
logger.warning("Authentication required but no session token provided", path=request.path)
return unauthorized_response(message="Authentication required. Please log in.")
# Verify session and get user
user = verify_session(token)
if not user:
logger.warning("Invalid or expired session token", path=request.path)
return unauthorized_response(message="Session invalid or expired. Please log in again.")
# Attach user to request context
g.current_user = user
logger.debug("User authenticated", user_id=user.id, email=user.email, path=request.path)
# Call the original function
return f(*args, **kwargs)
return decorated_function
def require_auth_web(f: Callable) -> Callable:
"""
Decorator to require authentication for a web view route.
This decorator:
1. Extracts the session token from the cookie
2. Verifies the session with Appwrite
3. Attaches the user object to the request context (g.current_user)
4. Allows the request to proceed if authenticated
5. Redirects to login page if not authenticated
For API endpoints, use @require_auth instead.
Args:
f: The Flask route function to wrap
Returns:
Wrapped function with authentication check
Usage:
@app.route('/dashboard')
@require_auth_web
def dashboard():
user = get_current_user()
return render_template('dashboard.html', user=user)
"""
@wraps(f)
def decorated_function(*args, **kwargs):
# Extract session token from cookie
token = extract_session_token()
if not token:
logger.warning("Authentication required but no session token provided", path=request.path)
return redirect(url_for('auth_views.login'))
# Verify session and get user
user = verify_session(token)
if not user:
logger.warning("Invalid or expired session token", path=request.path)
return redirect(url_for('auth_views.login'))
# Attach user to request context
g.current_user = user
logger.debug("User authenticated", user_id=user.id, email=user.email, path=request.path)
# Call the original function
return f(*args, **kwargs)
return decorated_function
def require_tier(minimum_tier: str) -> Callable:
"""
Decorator to require a minimum subscription tier for a route.
This decorator must be used AFTER @require_auth.
Tier hierarchy (from lowest to highest):
- free
- basic
- premium
- elite
Args:
minimum_tier: Minimum required tier (free, basic, premium, elite)
Returns:
Decorator function
Raises:
ValueError: If minimum_tier is invalid
Usage:
@app.route('/premium-feature')
@require_auth
@require_tier('premium')
def premium_feature():
return "Premium content"
"""
# Define tier hierarchy
tier_hierarchy = {
'free': 0,
'basic': 1,
'premium': 2,
'elite': 3
}
if minimum_tier not in tier_hierarchy:
raise ValueError(f"Invalid tier: {minimum_tier}. Must be one of {list(tier_hierarchy.keys())}")
def decorator(f: Callable) -> Callable:
@wraps(f)
def decorated_function(*args, **kwargs):
# Get current user (set by @require_auth)
user = get_current_user()
if not user:
logger.error("require_tier used without require_auth", path=request.path)
return unauthorized_response(message="Authentication required.")
# Get user's tier level
user_tier = user.tier
user_tier_level = tier_hierarchy.get(user_tier, 0)
required_tier_level = tier_hierarchy[minimum_tier]
# Check if user has sufficient tier
if user_tier_level < required_tier_level:
logger.warning(
"Access denied - insufficient tier",
user_id=user.id,
user_tier=user_tier,
required_tier=minimum_tier,
path=request.path
)
return forbidden_response(
message=f"This feature requires {minimum_tier.capitalize()} tier or higher. "
f"Your current tier: {user_tier.capitalize()}."
)
logger.debug(
"Tier requirement met",
user_id=user.id,
user_tier=user_tier,
required_tier=minimum_tier,
path=request.path
)
# Call the original function
return f(*args, **kwargs)
return decorated_function
return decorator
def require_email_verified(f: Callable) -> Callable:
"""
Decorator to require email verification for a route.
This decorator must be used AFTER @require_auth.
Args:
f: The Flask route function to wrap
Returns:
Wrapped function with email verification check
Usage:
@app.route('/verified-only')
@require_auth
@require_email_verified
def verified_only():
return "You can only see this if your email is verified"
"""
@wraps(f)
def decorated_function(*args, **kwargs):
# Get current user (set by @require_auth)
user = get_current_user()
if not user:
logger.error("require_email_verified used without require_auth", path=request.path)
return unauthorized_response(message="Authentication required.")
# Check if email is verified
if not user.email_verified:
logger.warning(
"Access denied - email not verified",
user_id=user.id,
email=user.email,
path=request.path
)
return forbidden_response(
message="Email verification required. Please check your inbox and verify your email address."
)
logger.debug("Email verification confirmed", user_id=user.id, email=user.email, path=request.path)
# Call the original function
return f(*args, **kwargs)
return decorated_function
def optional_auth(f: Callable) -> Callable:
"""
Decorator for routes that optionally use authentication.
This decorator will attach the user to g.current_user if authenticated,
but will NOT block the request if not authenticated. Use this for routes
that should behave differently based on authentication status.
Args:
f: The Flask route function to wrap
Returns:
Wrapped function with optional authentication
Usage:
@app.route('/landing')
@optional_auth
def landing():
user = get_current_user()
if user:
return f"Welcome back, {user.name}!"
else:
return "Welcome! Please log in."
"""
@wraps(f)
def decorated_function(*args, **kwargs):
# Extract session token from cookie
token = extract_session_token()
if token:
# Verify session and get user
user = verify_session(token)
if user:
# Attach user to request context
g.current_user = user
logger.debug("Optional auth - user authenticated", user_id=user.id, path=request.path)
else:
logger.debug("Optional auth - invalid session", path=request.path)
else:
logger.debug("Optional auth - no session token", path=request.path)
# Call the original function regardless of authentication
return f(*args, **kwargs)
return decorated_function
def get_user_tier() -> str:
"""
Get the current user's tier.
Returns:
Tier string (free, basic, premium, elite), defaults to 'free' if not authenticated
Usage:
@app.route('/dashboard')
@require_auth
def dashboard():
tier = get_user_tier()
return f"Your tier: {tier}"
"""
user = get_current_user()
if user:
return user.tier
return 'free'
def is_tier_sufficient(required_tier: str) -> bool:
"""
Check if the current user's tier meets the requirement.
Args:
required_tier: Required tier level
Returns:
True if user's tier is sufficient, False otherwise
Usage:
@app.route('/feature')
@require_auth
def feature():
if is_tier_sufficient('premium'):
return "Premium features enabled"
else:
return "Upgrade to premium for more features"
"""
tier_hierarchy = {
'free': 0,
'basic': 1,
'premium': 2,
'elite': 3
}
user = get_current_user()
if not user:
return False
user_tier_level = tier_hierarchy.get(user.tier, 0)
required_tier_level = tier_hierarchy.get(required_tier, 0)
return user_tier_level >= required_tier_level

272
api/app/utils/logging.py Normal file
View File

@@ -0,0 +1,272 @@
"""
Logging configuration for Code of Conquest.
Sets up structured logging using structlog with JSON output
and context-aware logging throughout the application.
"""
import logging
import sys
from pathlib import Path
from typing import Optional
import structlog
from structlog.stdlib import LoggerFactory
def setup_logging(
log_level: str = "INFO",
log_format: str = "json",
log_file: Optional[str] = None
) -> None:
"""
Configure structured logging for the application.
Args:
log_level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
log_format: Output format ('json' or 'console')
log_file: Optional path to log file
Example:
>>> from app.utils.logging import setup_logging
>>> setup_logging(log_level="DEBUG", log_format="json")
>>> logger = structlog.get_logger(__name__)
>>> logger.info("Application started", version="0.1.0")
"""
# Convert log level string to logging constant
numeric_level = getattr(logging, log_level.upper(), logging.INFO)
# Configure standard library logging
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=numeric_level,
)
# Create logs directory if logging to file
if log_file:
log_path = Path(log_file)
log_path.parent.mkdir(parents=True, exist_ok=True)
# Add file handler
file_handler = logging.FileHandler(log_file)
file_handler.setLevel(numeric_level)
logging.root.addHandler(file_handler)
# Configure structlog processors
processors = [
# Add log level to event dict
structlog.stdlib.add_log_level,
# Add logger name to event dict
structlog.stdlib.add_logger_name,
# Add timestamp
structlog.processors.TimeStamper(fmt="iso"),
# Add stack info for exceptions
structlog.processors.StackInfoRenderer(),
# Format exceptions
structlog.processors.format_exc_info,
# Clean up _record and _from_structlog keys
structlog.processors.UnicodeDecoder(),
]
# Add format-specific processor
if log_format == "json":
# JSON output for production
processors.append(structlog.processors.JSONRenderer())
else:
# Console-friendly output for development
processors.append(
structlog.dev.ConsoleRenderer(
colors=True,
exception_formatter=structlog.dev.plain_traceback
)
)
# Configure structlog
structlog.configure(
processors=processors,
context_class=dict,
logger_factory=LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger,
cache_logger_on_first_use=True,
)
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
"""
Get a configured logger instance.
Args:
name: Logger name (typically __name__)
Returns:
BoundLogger: Configured structlog logger
Example:
>>> logger = get_logger(__name__)
>>> logger.info("User logged in", user_id="123", email="user@example.com")
"""
return structlog.get_logger(name)
class LoggerMixin:
"""
Mixin class to add logging capabilities to any class.
Provides a `self.logger` attribute with context automatically
bound to the class name.
Example:
>>> class MyService(LoggerMixin):
... def do_something(self, user_id):
... self.logger.info("Doing something", user_id=user_id)
"""
@property
def logger(self) -> structlog.stdlib.BoundLogger:
"""Get logger for this class."""
if not hasattr(self, '_logger'):
self._logger = get_logger(self.__class__.__name__)
return self._logger
# Common logging utilities
def log_function_call(logger: structlog.stdlib.BoundLogger):
"""
Decorator to log function calls with arguments and return values.
Args:
logger: Logger instance to use
Example:
>>> logger = get_logger(__name__)
>>> @log_function_call(logger)
... def process_data(data_id):
... return {"status": "processed"}
"""
def decorator(func):
def wrapper(*args, **kwargs):
logger.debug(
f"Calling {func.__name__}",
function=func.__name__,
args=args,
kwargs=kwargs
)
try:
result = func(*args, **kwargs)
logger.debug(
f"{func.__name__} completed",
function=func.__name__,
result=result
)
return result
except Exception as e:
logger.error(
f"{func.__name__} failed",
function=func.__name__,
error=str(e),
exc_info=True
)
raise
return wrapper
return decorator
def log_ai_call(
logger: structlog.stdlib.BoundLogger,
user_id: str,
model: str,
tier: str,
tokens_used: int,
cost_estimate: float,
context_type: str
) -> None:
"""
Log AI API call for cost tracking and analytics.
Args:
logger: Logger instance
user_id: User making the request
model: AI model used
tier: Model tier (free, standard, premium)
tokens_used: Number of tokens consumed
cost_estimate: Estimated cost in USD
context_type: Type of context (narrative, combat, etc.)
"""
logger.info(
"AI call completed",
event_type="ai_call",
user_id=user_id,
model=model,
tier=tier,
tokens_used=tokens_used,
cost_estimate=cost_estimate,
context_type=context_type
)
def log_combat_action(
logger: structlog.stdlib.BoundLogger,
session_id: str,
character_id: str,
action_type: str,
target_id: Optional[str] = None,
damage: Optional[int] = None,
effects: Optional[list] = None
) -> None:
"""
Log combat action for analytics and debugging.
Args:
logger: Logger instance
session_id: Game session ID
character_id: Acting character ID
action_type: Type of action (attack, cast, item, defend)
target_id: Target of action (if applicable)
damage: Damage dealt (if applicable)
effects: Effects applied (if applicable)
"""
logger.info(
"Combat action executed",
event_type="combat_action",
session_id=session_id,
character_id=character_id,
action_type=action_type,
target_id=target_id,
damage=damage,
effects=effects
)
def log_marketplace_transaction(
logger: structlog.stdlib.BoundLogger,
transaction_id: str,
buyer_id: str,
seller_id: str,
item_id: str,
price: int,
transaction_type: str
) -> None:
"""
Log marketplace transaction for analytics and auditing.
Args:
logger: Logger instance
transaction_id: Transaction ID
buyer_id: Buyer user ID
seller_id: Seller user ID
item_id: Item ID
price: Transaction price
transaction_type: Type of transaction
"""
logger.info(
"Marketplace transaction",
event_type="marketplace_transaction",
transaction_id=transaction_id,
buyer_id=buyer_id,
seller_id=seller_id,
item_id=item_id,
price=price,
transaction_type=transaction_type
)

337
api/app/utils/response.py Normal file
View File

@@ -0,0 +1,337 @@
"""
API response wrapper for Code of Conquest.
Provides standardized JSON response format for all API endpoints.
"""
from datetime import datetime
from typing import Any, Dict, Optional
from flask import jsonify, Response
from app.config import get_config
def api_response(
result: Any = None,
status: int = 200,
error: Optional[Dict[str, Any]] = None,
meta: Optional[Dict[str, Any]] = None,
request_id: Optional[str] = None
) -> Response:
"""
Create a standardized API response.
Args:
result: The response data (or None if error)
status: HTTP status code
error: Error information (dict with 'code', 'message', 'details')
meta: Metadata (pagination, etc.)
request_id: Optional request ID for tracking
Returns:
Response: Flask JSON response
Example:
>>> return api_response(
... result={"user_id": "123"},
... status=200
... )
>>> return api_response(
... error={
... "code": "INVALID_INPUT",
... "message": "Email is required",
... "details": {"field": "email"}
... },
... status=400
... )
"""
config = get_config()
response_data = {
"app": config.app.name,
"version": config.app.version,
"status": status,
"timestamp": datetime.utcnow().isoformat() + "Z",
"request_id": request_id,
"result": result,
"error": error,
"meta": meta
}
return jsonify(response_data), status
def success_response(
result: Any = None,
status: int = 200,
meta: Optional[Dict[str, Any]] = None,
request_id: Optional[str] = None
) -> Response:
"""
Create a success response.
Args:
result: The response data
status: HTTP status code (default 200)
meta: Optional metadata
request_id: Optional request ID
Returns:
Response: Flask JSON response
Example:
>>> return success_response({"character_id": "123"})
"""
return api_response(
result=result,
status=status,
meta=meta,
request_id=request_id
)
def error_response(
code: str,
message: str,
status: int = 400,
details: Optional[Dict[str, Any]] = None,
request_id: Optional[str] = None
) -> Response:
"""
Create an error response.
Args:
code: Error code (e.g., "INVALID_INPUT")
message: Human-readable error message
status: HTTP status code (default 400)
details: Optional additional error details
request_id: Optional request ID
Returns:
Response: Flask JSON response
Example:
>>> return error_response(
... code="NOT_FOUND",
... message="Character not found",
... status=404
... )
"""
error = {
"code": code,
"message": message,
"details": details or {}
}
return api_response(
error=error,
status=status,
request_id=request_id
)
def created_response(
result: Any = None,
request_id: Optional[str] = None
) -> Response:
"""
Create a 201 Created response.
Args:
result: The created resource data
request_id: Optional request ID
Returns:
Response: Flask JSON response with status 201
Example:
>>> return created_response({"character_id": "123"})
"""
return success_response(
result=result,
status=201,
request_id=request_id
)
def accepted_response(
result: Any = None,
request_id: Optional[str] = None
) -> Response:
"""
Create a 202 Accepted response (for async operations).
Args:
result: Job information or status
request_id: Optional request ID
Returns:
Response: Flask JSON response with status 202
Example:
>>> return accepted_response({"job_id": "abc123"})
"""
return success_response(
result=result,
status=202,
request_id=request_id
)
def no_content_response(request_id: Optional[str] = None) -> Response:
"""
Create a 204 No Content response.
Args:
request_id: Optional request ID
Returns:
Response: Flask JSON response with status 204
Example:
>>> return no_content_response()
"""
return success_response(
result=None,
status=204,
request_id=request_id
)
def paginated_response(
items: list,
page: int,
limit: int,
total: int,
request_id: Optional[str] = None
) -> Response:
"""
Create a paginated response.
Args:
items: List of items for current page
page: Current page number
limit: Items per page
total: Total number of items
request_id: Optional request ID
Returns:
Response: Flask JSON response with pagination metadata
Example:
>>> return paginated_response(
... items=[{"id": "1"}, {"id": "2"}],
... page=1,
... limit=20,
... total=100
... )
"""
pages = (total + limit - 1) // limit # Ceiling division
meta = {
"page": page,
"limit": limit,
"total": total,
"pages": pages
}
return success_response(
result=items,
meta=meta,
request_id=request_id
)
# Common error responses
def unauthorized_response(
message: str = "Unauthorized",
request_id: Optional[str] = None
) -> Response:
"""401 Unauthorized response."""
return error_response(
code="UNAUTHORIZED",
message=message,
status=401,
request_id=request_id
)
def forbidden_response(
message: str = "Forbidden",
request_id: Optional[str] = None
) -> Response:
"""403 Forbidden response."""
return error_response(
code="FORBIDDEN",
message=message,
status=403,
request_id=request_id
)
def not_found_response(
message: str = "Resource not found",
request_id: Optional[str] = None
) -> Response:
"""404 Not Found response."""
return error_response(
code="NOT_FOUND",
message=message,
status=404,
request_id=request_id
)
def validation_error_response(
message: str,
details: Optional[Dict[str, Any]] = None,
request_id: Optional[str] = None
) -> Response:
"""400 Bad Request for validation errors."""
return error_response(
code="INVALID_INPUT",
message=message,
status=400,
details=details,
request_id=request_id
)
def rate_limit_exceeded_response(
message: str = "Rate limit exceeded",
request_id: Optional[str] = None
) -> Response:
"""429 Too Many Requests response."""
return error_response(
code="RATE_LIMIT_EXCEEDED",
message=message,
status=429,
request_id=request_id
)
def internal_error_response(
message: str = "Internal server error",
request_id: Optional[str] = None
) -> Response:
"""500 Internal Server Error response."""
return error_response(
code="INTERNAL_ERROR",
message=message,
status=500,
request_id=request_id
)
def premium_required_response(
message: str = "This feature requires a premium subscription",
request_id: Optional[str] = None
) -> Response:
"""403 Forbidden for premium-only features."""
return error_response(
code="PREMIUM_REQUIRED",
message=message,
status=403,
request_id=request_id
)