import uuid import logging import os import smtplib from datetime import datetime, timedelta, timezone from email.message import EmailMessage from email_validator import EmailNotValidError, validate_email from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import func, or_, select, text from sqlalchemy.orm import Session as DbSession from auth import ( create_access_token, create_refresh_token, create_one_time_token, decode_access_token, decode_firebase_id_token, hash_password, hash_refresh_token, hash_one_time_token, normalize_email, refresh_expiry, verify_password, ) from db import check_database, engine from models import ( AuditEvent, AuthRateLimit, EmailVerificationToken, Entitlement, Operator, OpsAuditEvent, OpsIncident, OpsIncidentNote, OpsSession, PasswordResetToken, Ride, Session, Subscription, TelemetryPoint, User, Vehicle, ) logger = logging.getLogger(__name__) app = FastAPI( title="MotorSignal API", version="1.0.0", docs_url=None, redoc_url=None, openapi_url=None, ) class RegisterRequest(BaseModel): email: str password: str = Field(min_length=12, max_length=128) device_name: str | None = Field(default=None, max_length=255) class LoginRequest(BaseModel): email: str password: str device_name: str | None = Field(default=None, max_length=255) class RefreshRequest(BaseModel): refresh_token: str class EmailRequest(BaseModel): email: str class TokenRequest(BaseModel): token: str = Field(min_length=20, max_length=256) class FirebaseLinkRequest(BaseModel): id_token: str = Field(min_length=100, max_length=8192) class PasswordResetRequest(TokenRequest): password: str = Field(min_length=12, max_length=128) class TokenResponse(BaseModel): access_token: str refresh_token: str token_type: str = "bearer" class SessionResponse(BaseModel): id: uuid.UUID device_name: str | None created_at: datetime expires_at: datetime last_used_at: datetime | None current: bool class VehicleUpdate(BaseModel): nickname: str | None = Field(default=None, min_length=1, max_length=120) make: str | None = Field(default=None, min_length=1, max_length=120) model: str | None = Field(default=None, min_length=1, max_length=120) year: int | None = Field(default=None, ge=1885, le=2100) vin: str | None = Field(default=None, min_length=11, max_length=17) class UserVehicleCreate(BaseModel): nickname: str = Field(min_length=1, max_length=120) make: str = Field(min_length=1, max_length=120) model: str = Field(min_length=1, max_length=120) year: int | None = Field(default=None, ge=1885, le=2100) vin: str | None = Field(default=None, min_length=11, max_length=17) class UserRideCreate(BaseModel): vehicle_id: uuid.UUID | None = None started_at: datetime ended_at: datetime | None = None distance_miles: float | None = Field(default=None, ge=0) top_speed_mph: float | None = Field(default=None, ge=0) avg_speed_mph: float | None = Field(default=None, ge=0) status: str = Field(default="completed", min_length=1, max_length=32) class TelemetryPointInput(BaseModel): recorded_at: datetime latitude: float | None = Field(default=None, ge=-90, le=90) longitude: float | None = Field(default=None, ge=-180, le=180) speed_mph: float | None = Field(default=None, ge=0) lean_angle_deg: float | None = Field(default=None, ge=-90, le=90) g_force: float | None = Field(default=None, ge=0) accel_g: float | None = Field(default=None, ge=-10, le=10) brake_g: float | None = Field(default=None, ge=-10, le=10) class TelemetryBatch(BaseModel): points: list[TelemetryPointInput] = Field(min_length=1, max_length=1000) def get_db(): db = DbSession(engine) try: yield db finally: db.close() def enforce_auth_rate_limit(request: Request, db: DbSession, action: str, limit: int = 10, window_seconds: int = 900) -> None: client = request.client.host if request.client else "unknown" key = f"{action}:{client}" now = datetime.now(timezone.utc) bucket = db.get(AuthRateLimit, key, with_for_update=True) if bucket is None: db.add(AuthRateLimit(key=key, window_started=now, attempts=1)) db.commit() return elapsed = (now - bucket.window_started).total_seconds() if elapsed >= window_seconds: bucket.window_started = now bucket.attempts = 1 elif bucket.attempts >= limit: retry_after = max(1, int(window_seconds - elapsed)) raise HTTPException(status_code=429, detail="Too many authentication attempts", headers={"Retry-After": str(retry_after)}) else: bucket.attempts += 1 db.commit() def extract_bearer_token(authorization: str | None) -> str: if not authorization: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing authorization token", ) scheme, _, token = authorization.partition(" ") if scheme.lower() != "bearer" or not token: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid authorization header", ) return token def get_request_context(request: Request) -> tuple[str | None, str | None]: return request.client.host if request.client else None, request.headers.get("user-agent") def audit( db: DbSession, event_type: str, actor_user_id: uuid.UUID | None = None, target_user_id: uuid.UUID | None = None, request: Request | None = None, metadata: dict | None = None, ) -> None: ip_address = request.client.host if request and request.client else None user_agent = request.headers.get("user-agent")[:512] if request and request.headers.get("user-agent") else None db.add(AuditEvent(event_type=event_type, actor_user_id=actor_user_id, target_user_id=target_user_id, ip_address=ip_address, user_agent=user_agent, metadata_json=metadata)) def send_email(recipient: str, subject: str, body: str) -> None: host = os.getenv("SMTP_HOST") if not host: return try: message = EmailMessage() message["From"] = os.environ["SMTP_FROM"] message["To"] = recipient message["Subject"] = subject message.set_content(body) port = int(os.getenv("SMTP_PORT", "587")) with smtplib.SMTP(host, port, timeout=10) as smtp: if os.getenv("SMTP_STARTTLS", "true").lower() == "true": smtp.starttls() if os.getenv("SMTP_USER"): smtp.login(os.environ["SMTP_USER"], os.environ["SMTP_PASSWORD"]) smtp.send_message(message) except (KeyError, OSError, smtplib.SMTPException): logger.exception("MotorSignal email delivery failed") def get_current_identity( authorization: str | None = Header(default=None), db: DbSession = Depends(get_db), ) -> tuple[User, Session]: token = extract_bearer_token(authorization) try: payload = decode_access_token(token) except Exception: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired access token", ) if payload.get("type") != "access": raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid access token", ) try: user_id = uuid.UUID(payload["sub"]) session_id = uuid.UUID(payload["sid"]) except Exception: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid access token", ) session = db.get(Session, session_id) if ( session is None or session.user_id != user_id or session.revoked_at is not None or session.expires_at <= datetime.now(timezone.utc) ): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Session is no longer valid", ) user = db.get(User, user_id) if user is None or not user.is_active: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User is unavailable", ) return user, session def get_current_user(identity: tuple[User, Session] = Depends(get_current_identity)) -> User: return identity[0] @app.get("/api/v1/health") def health(): return { "status": "ok", "service": "motorsignal-api", } @app.get("/api/v1/ready") def readiness(): try: check_database() return { "status": "ready", "database": "ok", } except Exception: raise HTTPException( status_code=503, detail="Service unavailable", ) @app.post( "/api/v1/auth/register", response_model=TokenResponse, status_code=201, ) def register( request: RegisterRequest, http_request: Request, db: DbSession = Depends(get_db), ): enforce_auth_rate_limit(http_request, db, "register") try: validated = validate_email( request.email, check_deliverability=False, ) email = normalize_email(validated.normalized) except EmailNotValidError: raise HTTPException( status_code=400, detail="Invalid email address", ) existing = db.scalar( select(User).where(User.email == email) ) if existing is not None: raise HTTPException( status_code=409, detail="Account already exists", ) user = User( email=email, password_hash=hash_password(request.password), ) db.add(user) db.flush() raw_refresh, refresh_hash = create_refresh_token() session = Session( user_id=user.id, refresh_token_hash=refresh_hash, device_name=request.device_name, expires_at=refresh_expiry(), ) db.add(session) verification_raw, verification_hash = create_one_time_token() db.add(EmailVerificationToken(user_id=user.id, token_hash=verification_hash, expires_at=datetime.now(timezone.utc) + timedelta(hours=24))) audit(db, "auth.register", actor_user_id=user.id, target_user_id=user.id) db.commit() web_base = os.getenv("WEB_BASE_URL", "https://motorsignal.app") send_email(email, "Verify your MotorSignal email", f"Verify your email: {web_base}/verify-email?token={verification_raw}") return TokenResponse( access_token=create_access_token(user.id, session.id), refresh_token=raw_refresh, ) @app.post( "/api/v1/auth/login", response_model=TokenResponse, ) def login( request: LoginRequest, http_request: Request, db: DbSession = Depends(get_db), ): enforce_auth_rate_limit(http_request, db, "login") email = normalize_email(request.email) user = db.scalar( select(User).where(User.email == email) ) if user is None or not verify_password( request.password, user.password_hash, ): raise HTTPException( status_code=401, detail="Invalid email or password", ) if not user.is_active: raise HTTPException( status_code=403, detail="Account disabled", ) raw_refresh, refresh_hash = create_refresh_token() session = Session( user_id=user.id, refresh_token_hash=refresh_hash, device_name=request.device_name, expires_at=refresh_expiry(), ) db.add(session) audit(db, "auth.login", actor_user_id=user.id, target_user_id=user.id) db.commit() return TokenResponse( access_token=create_access_token(user.id, session.id), refresh_token=raw_refresh, ) @app.post( "/api/v1/auth/refresh", response_model=TokenResponse, ) def refresh( request: RefreshRequest, db: DbSession = Depends(get_db), ): old_hash = hash_refresh_token(request.refresh_token) session = db.scalar( select(Session).where( Session.refresh_token_hash == old_hash ) ) now = datetime.now(timezone.utc) if ( session is None or session.revoked_at is not None or session.expires_at <= now ): raise HTTPException( status_code=401, detail="Invalid refresh token", ) user = db.get(User, session.user_id) if user is None or not user.is_active: raise HTTPException( status_code=401, detail="Invalid refresh token", ) new_raw, new_hash = create_refresh_token() session.refresh_token_hash = new_hash session.last_used_at = now session.expires_at = refresh_expiry() db.commit() return TokenResponse( access_token=create_access_token(user.id, session.id), refresh_token=new_raw, ) @app.post("/api/v1/auth/logout", status_code=204) def logout( request: RefreshRequest, db: DbSession = Depends(get_db), ): refresh_hash = hash_refresh_token(request.refresh_token) session = db.scalar( select(Session).where( Session.refresh_token_hash == refresh_hash ) ) if session is not None and session.revoked_at is None: session.revoked_at = datetime.now(timezone.utc) audit(db, "auth.logout", actor_user_id=session.user_id, target_user_id=session.user_id) db.commit() return None @app.get("/api/v1/me") def me(user: User = Depends(get_current_user)): return { "id": str(user.id), "email": user.email, "email_verified": user.email_verified, "firebase_linked": user.firebase_uid is not None, "role": user.role, "created_at": user.created_at, } @app.post("/api/v1/auth/firebase/link") def link_firebase_identity(request: FirebaseLinkRequest, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): try: claims = decode_firebase_id_token(request.id_token) except Exception: raise HTTPException(status_code=401, detail="Invalid Firebase identity") firebase_email = claims.get("email") if claims.get("email_verified") is not True or not isinstance(firebase_email, str) or normalize_email(firebase_email) != user.email: raise HTTPException(status_code=403, detail="Firebase email must be verified and match this account") firebase_uid = claims["sub"] linked_user = db.scalar(select(User).where(User.firebase_uid == firebase_uid)) if linked_user is not None and linked_user.id != user.id: raise HTTPException(status_code=409, detail="Firebase identity is already linked") if user.firebase_uid is not None and user.firebase_uid != firebase_uid: raise HTTPException(status_code=409, detail="Account is already linked to another Firebase identity") if user.firebase_uid is None: user.firebase_uid = firebase_uid user.email_verified = True audit(db, "auth.firebase_linked", actor_user_id=user.id, target_user_id=user.id) db.commit() return {"firebase_linked": True} @app.post("/api/v1/auth/verify-email/request", status_code=202) def request_email_verification(payload: EmailRequest, http_request: Request, db: DbSession = Depends(get_db)): enforce_auth_rate_limit(http_request, db, "verify-email-request") email = normalize_email(payload.email) user = db.scalar(select(User).where(User.email == email)) if user is not None and not user.email_verified and user.is_active: raw, token_hash = create_one_time_token() db.add(EmailVerificationToken(user_id=user.id, token_hash=token_hash, expires_at=datetime.now(timezone.utc) + timedelta(hours=24))) audit(db, "auth.email_verification_requested", target_user_id=user.id) db.commit() web_base = os.getenv("WEB_BASE_URL", "https://motorsignal.app") send_email(email, "Verify your MotorSignal email", f"Verify your email: {web_base}/verify-email?token={raw}") return {"detail": "If the account can be verified, instructions have been sent."} @app.post("/api/v1/auth/verify-email", status_code=204) def verify_email(request: TokenRequest, http_request: Request, db: DbSession = Depends(get_db)): enforce_auth_rate_limit(http_request, db, "verify-email") token = db.scalar(select(EmailVerificationToken).where(EmailVerificationToken.token_hash == hash_one_time_token(request.token))) now = datetime.now(timezone.utc) if token is None or token.used_at is not None or token.expires_at <= now: raise HTTPException(status_code=400, detail="Invalid or expired verification token") user = db.get(User, token.user_id) if user is None: raise HTTPException(status_code=400, detail="Invalid verification token") user.email_verified = True token.used_at = now audit(db, "auth.email_verified", actor_user_id=user.id, target_user_id=user.id) db.commit() return None @app.post("/api/v1/auth/password-reset/request", status_code=202) def request_password_reset(payload: EmailRequest, http_request: Request, db: DbSession = Depends(get_db)): enforce_auth_rate_limit(http_request, db, "password-reset-request") email = normalize_email(payload.email) user = db.scalar(select(User).where(User.email == email)) if user is not None and user.is_active: raw, token_hash = create_one_time_token() db.add(PasswordResetToken(user_id=user.id, token_hash=token_hash, expires_at=datetime.now(timezone.utc) + timedelta(hours=1))) audit(db, "auth.password_reset_requested", target_user_id=user.id) db.commit() web_base = os.getenv("WEB_BASE_URL", "https://motorsignal.app") send_email(email, "Reset your MotorSignal password", f"Reset your password: {web_base}/forgot-password?token={raw}") return {"detail": "If the account exists, reset instructions have been sent."} @app.post("/api/v1/auth/password-reset", status_code=204) def reset_password(request: PasswordResetRequest, http_request: Request, db: DbSession = Depends(get_db)): enforce_auth_rate_limit(http_request, db, "password-reset") token = db.scalar(select(PasswordResetToken).where(PasswordResetToken.token_hash == hash_one_time_token(request.token))) now = datetime.now(timezone.utc) if token is None or token.used_at is not None or token.expires_at <= now: raise HTTPException(status_code=400, detail="Invalid or expired reset token") user = db.get(User, token.user_id) if user is None or not user.is_active: raise HTTPException(status_code=400, detail="Invalid reset token") user.password_hash = hash_password(request.password) token.used_at = now db.query(Session).filter(Session.user_id == user.id, Session.revoked_at.is_(None)).update({Session.revoked_at: now}, synchronize_session=False) audit(db, "auth.password_reset_completed", actor_user_id=user.id, target_user_id=user.id) db.commit() return None @app.get("/api/v1/auth/sessions", response_model=list[SessionResponse]) def list_sessions(identity: tuple[User, Session] = Depends(get_current_identity), db: DbSession = Depends(get_db)): user, current = identity sessions = db.scalars(select(Session).where(Session.user_id == user.id, Session.revoked_at.is_(None)).order_by(Session.created_at.desc())).all() return [SessionResponse(id=item.id, device_name=item.device_name, created_at=item.created_at, expires_at=item.expires_at, last_used_at=item.last_used_at, current=item.id == current.id) for item in sessions] @app.delete("/api/v1/auth/sessions/{session_id}", status_code=204) def revoke_session(session_id: uuid.UUID, identity: tuple[User, Session] = Depends(get_current_identity), db: DbSession = Depends(get_db)): user, _ = identity session = db.scalar(select(Session).where(Session.id == session_id, Session.user_id == user.id)) if session is None: raise HTTPException(status_code=404, detail="Session not found") if session.revoked_at is None: session.revoked_at = datetime.now(timezone.utc) audit(db, "auth.session_revoked", actor_user_id=user.id, target_user_id=user.id, metadata={"session_id": str(session.id)}) db.commit() return None @app.post("/api/v1/auth/logout-all", status_code=204) def logout_all(identity: tuple[User, Session] = Depends(get_current_identity), db: DbSession = Depends(get_db)): user, _ = identity now = datetime.now(timezone.utc) db.query(Session).filter(Session.user_id == user.id, Session.revoked_at.is_(None)).update({Session.revoked_at: now}, synchronize_session=False) audit(db, "auth.logout_all", actor_user_id=user.id, target_user_id=user.id) db.commit() return None def vehicle_payload(vehicle: Vehicle) -> dict: return {"id": str(vehicle.id), "user_id": str(vehicle.user_id), "nickname": vehicle.nickname, "make": vehicle.make, "model": vehicle.model, "year": vehicle.year, "vin": vehicle.vin, "created_at": vehicle.created_at, "updated_at": vehicle.updated_at} def ride_payload(ride: Ride) -> dict: return {"id": str(ride.id), "user_id": str(ride.user_id), "vehicle_id": str(ride.vehicle_id) if ride.vehicle_id else None, "started_at": ride.started_at, "ended_at": ride.ended_at, "distance_miles": ride.distance_miles, "top_speed_mph": ride.top_speed_mph, "avg_speed_mph": ride.avg_speed_mph, "status": ride.status, "created_at": ride.created_at} @app.get("/api/v1/vehicles") def list_my_vehicles(user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): vehicles = db.scalars(select(Vehicle).where(Vehicle.user_id == user.id).order_by(Vehicle.created_at.desc())).all() return [vehicle_payload(vehicle) for vehicle in vehicles] @app.post("/api/v1/vehicles", status_code=201) def create_my_vehicle(request: UserVehicleCreate, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): vehicle = Vehicle(user_id=user.id, **request.model_dump()) db.add(vehicle) db.flush() audit(db, "vehicle.created", actor_user_id=user.id, target_user_id=user.id, metadata={"vehicle_id": str(vehicle.id)}) db.commit() db.refresh(vehicle) return vehicle_payload(vehicle) @app.get("/api/v1/vehicles/{vehicle_id}") def get_my_vehicle(vehicle_id: uuid.UUID, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): vehicle = db.scalar(select(Vehicle).where(Vehicle.id == vehicle_id, Vehicle.user_id == user.id)) if vehicle is None: raise HTTPException(status_code=404, detail="Vehicle not found") return vehicle_payload(vehicle) @app.patch("/api/v1/vehicles/{vehicle_id}") def update_my_vehicle(vehicle_id: uuid.UUID, request: VehicleUpdate, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): vehicle = db.scalar(select(Vehicle).where(Vehicle.id == vehicle_id, Vehicle.user_id == user.id)) if vehicle is None: raise HTTPException(status_code=404, detail="Vehicle not found") changes = request.model_dump(exclude_unset=True) for field, value in changes.items(): setattr(vehicle, field, value) audit(db, "vehicle.updated", actor_user_id=user.id, target_user_id=user.id, metadata={"vehicle_id": str(vehicle.id), "fields": list(changes)}) db.commit() db.refresh(vehicle) return vehicle_payload(vehicle) @app.delete("/api/v1/vehicles/{vehicle_id}", status_code=204) def delete_my_vehicle(vehicle_id: uuid.UUID, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): vehicle = db.scalar(select(Vehicle).where(Vehicle.id == vehicle_id, Vehicle.user_id == user.id)) if vehicle is None: raise HTTPException(status_code=404, detail="Vehicle not found") audit(db, "vehicle.deleted", actor_user_id=user.id, target_user_id=user.id, metadata={"vehicle_id": str(vehicle.id)}) db.delete(vehicle) db.commit() return None @app.get("/api/v1/rides") def list_my_rides(page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=100), user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): query = select(Ride).where(Ride.user_id == user.id) total = db.scalar(select(func.count()).select_from(Ride).where(Ride.user_id == user.id)) or 0 rides = db.scalars(query.order_by(Ride.started_at.desc()).offset((page - 1) * page_size).limit(page_size)).all() return {"items": [ride_payload(ride) for ride in rides], "page": page, "page_size": page_size, "total": total} @app.post("/api/v1/rides", status_code=201) def create_my_ride(request: UserRideCreate, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): if request.vehicle_id is not None: vehicle = db.scalar(select(Vehicle).where(Vehicle.id == request.vehicle_id, Vehicle.user_id == user.id)) if vehicle is None: raise HTTPException(status_code=400, detail="Vehicle not found") ride = Ride(user_id=user.id, **request.model_dump()) db.add(ride) db.flush() audit(db, "ride.created", actor_user_id=user.id, target_user_id=user.id, metadata={"ride_id": str(ride.id)}) db.commit() db.refresh(ride) return ride_payload(ride) @app.get("/api/v1/rides/{ride_id}") def get_my_ride(ride_id: uuid.UUID, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): ride = db.scalar(select(Ride).where(Ride.id == ride_id, Ride.user_id == user.id)) if ride is None: raise HTTPException(status_code=404, detail="Ride not found") return ride_payload(ride) def telemetry_payload(point: TelemetryPoint) -> dict: return {"id": str(point.id), "ride_id": str(point.ride_id), "recorded_at": point.recorded_at, "latitude": point.latitude, "longitude": point.longitude, "speed_mph": point.speed_mph, "lean_angle_deg": point.lean_angle_deg, "g_force": point.g_force, "accel_g": point.accel_g, "brake_g": point.brake_g} def get_owned_ride(ride_id: uuid.UUID, user_id: uuid.UUID, db: DbSession) -> Ride: ride = db.scalar(select(Ride).where(Ride.id == ride_id, Ride.user_id == user_id)) if ride is None: raise HTTPException(status_code=404, detail="Ride not found") return ride @app.post("/api/v1/rides/{ride_id}/telemetry", status_code=201) def ingest_telemetry(ride_id: uuid.UUID, request: TelemetryBatch, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): get_owned_ride(ride_id, user.id, db) points = [TelemetryPoint(ride_id=ride_id, **point.model_dump()) for point in request.points] db.add_all(points) audit(db, "telemetry.ingested", actor_user_id=user.id, target_user_id=user.id, metadata={"ride_id": str(ride_id), "point_count": len(points)}) db.commit() return {"accepted": len(points)} @app.get("/api/v1/rides/{ride_id}/telemetry") def list_my_telemetry(ride_id: uuid.UUID, limit: int = Query(default=1000, ge=1, le=5000), user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): get_owned_ride(ride_id, user.id, db) points = db.scalars(select(TelemetryPoint).where(TelemetryPoint.ride_id == ride_id).order_by(TelemetryPoint.recorded_at.asc()).limit(limit)).all() return {"items": [telemetry_payload(point) for point in points], "count": len(points)} def telemetry_summary(ride_id: uuid.UUID, db: DbSession) -> dict: count, first_recorded, last_recorded, max_speed, max_lean, max_g = db.execute( select(func.count(TelemetryPoint.id), func.min(TelemetryPoint.recorded_at), func.max(TelemetryPoint.recorded_at), func.max(TelemetryPoint.speed_mph), func.max(func.abs(TelemetryPoint.lean_angle_deg)), func.max(TelemetryPoint.g_force)).where(TelemetryPoint.ride_id == ride_id) ).one() duration_seconds = (last_recorded - first_recorded).total_seconds() if first_recorded and last_recorded else 0 return {"ride_id": str(ride_id), "point_count": count, "duration_seconds": duration_seconds, "max_speed_mph": max_speed, "max_lean_angle_deg": max_lean, "max_g_force": max_g} @app.get("/api/v1/rides/{ride_id}/telemetry/summary") def my_telemetry_summary(ride_id: uuid.UUID, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): get_owned_ride(ride_id, user.id, db) return telemetry_summary(ride_id, db) def subscription_payload(subscription: Subscription) -> dict: return {"id": str(subscription.id), "user_id": str(subscription.user_id), "plan": subscription.plan, "status": subscription.status, "current_period_start": subscription.current_period_start, "current_period_end": subscription.current_period_end, "created_at": subscription.created_at, "updated_at": subscription.updated_at} def entitlement_payload(entitlement: Entitlement) -> dict: return {"id": str(entitlement.id), "user_id": str(entitlement.user_id), "key": entitlement.key, "value": entitlement.value, "expires_at": entitlement.expires_at, "created_at": entitlement.created_at} def get_or_create_subscription(user_id: uuid.UUID, db: DbSession) -> Subscription: subscription = db.scalar(select(Subscription).where(Subscription.user_id == user_id)) if subscription is None: subscription = Subscription(user_id=user_id, plan="free", status="active") db.add(subscription) db.flush() return subscription @app.get("/api/v1/me/subscription") def get_my_subscription(user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): subscription = get_or_create_subscription(user.id, db) db.commit() db.refresh(subscription) return subscription_payload(subscription) @app.get("/api/v1/me/entitlements") def list_my_entitlements(user: User = Depends(get_current_user), db: DbSession = Depends(get_db)): entitlements = db.scalars(select(Entitlement).where(Entitlement.user_id == user.id).order_by(Entitlement.created_at.desc())).all() return [entitlement_payload(item) for item in entitlements if item.expires_at is None or item.expires_at > datetime.now(timezone.utc)] # ============================================================ # Operations (OPS-3B / OPS-3C / OPS-4) Platform Endpoints # ============================================================ import hashlib def get_current_operator( request: Request, db: DbSession = Depends(get_db), ) -> tuple[Operator, OpsSession]: auth_header = request.headers.get("authorization") token: str | None = None if auth_header: scheme, _, param = auth_header.partition(" ") if scheme.lower() == "bearer" and param: token = param if not token: token = request.cookies.get("__Host-motorsignal_ops") or request.cookies.get("motorsignal_ops") if not token: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing Operations session credentials", ) token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest() now = datetime.now(timezone.utc) stmt = ( select(OpsSession, Operator) .join(Operator, OpsSession.operator_id == Operator.id) .where( OpsSession.session_token_hash == token_hash, OpsSession.revoked_at.is_(None), OpsSession.expires_at > now, Operator.status == "active", ) ) result = db.execute(stmt).first() if result is None: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Operations session expired or invalid", ) ops_session, operator = result ops_session.last_activity_at = now db.commit() return operator, ops_session def require_ops_roles(*roles: str): def dependency( auth: tuple[Operator, OpsSession] = Depends(get_current_operator), ) -> tuple[Operator, OpsSession]: operator, session = auth if operator.role not in roles: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Insufficient Operations authority", ) return operator, session return dependency def ops_audit( db: DbSession, action: str, actor_identity: str, actor_role: str | None, target_resource: str | None = None, reason_code: str | None = None, case_reference: str | None = None, result: str = "success", metadata: dict | None = None, ) -> OpsAuditEvent: event = OpsAuditEvent( timestamp=datetime.now(timezone.utc), action=action, actor_identity=actor_identity, actor_role=actor_role, target_resource=target_resource, reason_code=reason_code, case_reference=case_reference, result=result, metadata_json=metadata, ) db.add(event) return event def generate_incident_case_reference(db: DbSession) -> str: year = datetime.now(timezone.utc).year seq_val = db.execute(text("SELECT nextval('ops.incident_case_seq')")).scalar() return f"OPS-{year}-{int(seq_val):06d}" class OpsIncidentCreate(BaseModel): model_config = ConfigDict(extra="forbid") title: str = Field(min_length=1, max_length=255) summary: str = Field(min_length=1, max_length=4000) severity: str = Field(default="medium") class OpsIncidentPatch(BaseModel): model_config = ConfigDict(extra="forbid") severity: str | None = None status: str | None = None resolution: str | None = Field(default=None, max_length=4000) class OpsIncidentNoteCreate(BaseModel): model_config = ConfigDict(extra="forbid") body: str = Field(min_length=1, max_length=4000) @app.get("/api/v1/ops/me") def ops_get_me( auth: tuple[Operator, OpsSession] = Depends(get_current_operator), ): operator, session = auth return { "identity": operator.identity, "role": operator.role, "status": operator.status, "created_at": operator.created_at.isoformat(), "last_activity_at": session.last_activity_at.isoformat() if session.last_activity_at else None, "expires_at": session.expires_at.isoformat(), } @app.get("/api/v1/ops/health") def ops_get_health(): now_iso = datetime.now(timezone.utc).isoformat() db_ok = False try: db_ok = check_database() except Exception: db_ok = False overall = "available" if db_ok else "degraded" return { "status": overall, "checked_at": now_iso, "components": { "operations_api": { "status": "available", "description": "FastAPI core endpoint runtime & ingress", }, "operations_database": { "status": "available" if db_ok else "unavailable", "description": "PostgreSQL ops schema relational persistence", }, "session_validation": { "status": "available" if db_ok else "degraded", "description": "__Host-motorsignal_ops token verification & authority", }, "audit_persistence": { "status": "available" if db_ok else "degraded", "description": "Immutable operational security activity persistence", }, }, } @app.delete("/api/v1/ops/sessions/current", status_code=204) def ops_logout_current_session( auth: tuple[Operator, OpsSession] = Depends(get_current_operator), db: DbSession = Depends(get_db), ): operator, session = auth session.revoked_at = datetime.now(timezone.utc) ops_audit( db=db, action="auth.session_revoked", actor_identity=operator.identity, actor_role=operator.role, target_resource="ops.session", result="success", ) db.commit() @app.get("/api/v1/ops/audit-events") def ops_list_audit_events( cursor: str | None = None, limit: int = Query(default=25, ge=1, le=100), auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("owner")), db: DbSession = Depends(get_db), ): stmt = select(OpsAuditEvent).order_by(OpsAuditEvent.timestamp.desc()) if cursor: try: cursor_dt = datetime.fromisoformat(cursor) stmt = stmt.where(OpsAuditEvent.timestamp < cursor_dt) except ValueError: pass events = db.scalars(stmt.limit(limit + 1)).all() has_more = len(events) > limit returned_events = events[:limit] next_cursor = returned_events[-1].timestamp.isoformat() if has_more and returned_events else None total_count = db.scalar(select(func.count(OpsAuditEvent.id))) or 0 items = [ { "id": str(e.id), "timestamp": e.timestamp.isoformat(), "action": e.action, "actor_identity": e.actor_identity, "actor_role": e.actor_role, "target_resource": e.target_resource, "reason_code": e.reason_code, "case_reference": e.case_reference, "result": e.result, "metadata": e.metadata_json or {}, } for e in returned_events ] return { "items": items, "next_cursor": next_cursor, "total": total_count, } # ============================================================ # Incidents Endpoints (OPS-4) # ============================================================ VALID_SEVERITIES = {"low", "medium", "high", "critical"} VALID_STATUSES = {"open", "investigating", "monitoring", "resolved", "closed", "archived"} ALLOWED_TRANSITIONS = { "open": {"investigating", "resolved", "closed"}, "investigating": {"monitoring", "resolved", "closed"}, "monitoring": {"investigating", "resolved", "closed"}, "resolved": {"closed", "investigating"}, "closed": {"investigating"}, # Reopen (Owner only) "archived": set(), # Terminal (no further mutation) } @app.get("/api/v1/ops/incidents") def ops_list_incidents( status: str | None = None, severity: str | None = None, case_reference: str | None = None, cursor: str | None = None, limit: int = Query(default=25, ge=1, le=100), auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")), db: DbSession = Depends(get_db), ): stmt = select(OpsIncident).order_by(OpsIncident.created_at.desc()) if case_reference: stmt = stmt.where(OpsIncident.case_reference == case_reference.strip()) if status: stmt = stmt.where(OpsIncident.status == status.strip().lower()) if severity: stmt = stmt.where(OpsIncident.severity == severity.strip().lower()) if cursor: try: cursor_dt = datetime.fromisoformat(cursor) stmt = stmt.where(OpsIncident.created_at < cursor_dt) except ValueError: pass incidents = db.scalars(stmt.limit(limit + 1)).all() has_more = len(incidents) > limit returned_incidents = incidents[:limit] next_cursor = returned_incidents[-1].created_at.isoformat() if has_more and returned_incidents else None items = [ { "case_reference": inc.case_reference, "title": inc.title, "severity": inc.severity, "status": inc.status, "created_at": inc.created_at.isoformat(), "updated_at": inc.updated_at.isoformat(), } for inc in returned_incidents ] return { "items": items, "next_cursor": next_cursor, } @app.post("/api/v1/ops/incidents", status_code=201) def ops_create_incident( request: OpsIncidentCreate, auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")), db: DbSession = Depends(get_db), ): operator, _ = auth sev = request.severity.strip().lower() if sev not in VALID_SEVERITIES: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"Invalid severity '{request.severity}'. Must be one of: {sorted(VALID_SEVERITIES)}", ) case_ref = generate_incident_case_reference(db) now = datetime.now(timezone.utc) incident = OpsIncident( case_reference=case_ref, title=request.title.strip(), summary=request.summary.strip(), severity=sev, status="open", created_at=now, created_by_operator_id=operator.id, updated_at=now, ) db.add(incident) db.flush() # Transactional Audit ops_audit( db=db, action="incident.created", actor_identity=operator.identity, actor_role=operator.role, target_resource="ops.incident", case_reference=case_ref, result="success", metadata={"severity": sev}, ) db.commit() db.refresh(incident) return { "case_reference": incident.case_reference, "title": incident.title, "summary": incident.summary, "severity": incident.severity, "status": incident.status, "created_at": incident.created_at.isoformat(), "updated_at": incident.updated_at.isoformat(), "closed_at": None, "resolution": None, "created_by_identity": operator.identity, "closed_by_identity": None, } @app.get("/api/v1/ops/incidents/{case_reference}") def ops_get_incident_detail( case_reference: str, auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")), db: DbSession = Depends(get_db), ): stmt = select(OpsIncident).where(OpsIncident.case_reference == case_reference.strip()) incident = db.scalar(stmt) if incident is None: raise HTTPException(status_code=404, detail="Incident not found") creator = db.get(Operator, incident.created_by_operator_id) closer = db.get(Operator, incident.closed_by_operator_id) if incident.closed_by_operator_id else None return { "case_reference": incident.case_reference, "title": incident.title, "summary": incident.summary, "severity": incident.severity, "status": incident.status, "created_at": incident.created_at.isoformat(), "updated_at": incident.updated_at.isoformat(), "closed_at": incident.closed_at.isoformat() if incident.closed_at else None, "resolution": incident.resolution, "created_by_identity": creator.identity if creator else "operator", "closed_by_identity": closer.identity if closer else None, } @app.patch("/api/v1/ops/incidents/{case_reference}") def ops_patch_incident( case_reference: str, patch: OpsIncidentPatch, auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")), db: DbSession = Depends(get_db), ): operator, _ = auth stmt = select(OpsIncident).where(OpsIncident.case_reference == case_reference.strip()) incident = db.scalar(stmt) if incident is None: raise HTTPException(status_code=404, detail="Incident not found") if incident.status == "archived": raise HTTPException(status_code=409, detail="Archived incidents cannot be modified") now = datetime.now(timezone.utc) target_status = patch.status.strip().lower() if patch.status else None target_severity = patch.severity.strip().lower() if patch.severity else None new_resolution = patch.resolution.strip() if patch.resolution is not None else None # Severity update if target_severity: if target_severity not in VALID_SEVERITIES: raise HTTPException( status_code=422, detail=f"Invalid severity '{patch.severity}'. Must be: {sorted(VALID_SEVERITIES)}", ) if target_severity != incident.severity: old_sev = incident.severity incident.severity = target_severity incident.updated_at = now ops_audit( db=db, action="incident.severity_changed", actor_identity=operator.identity, actor_role=operator.role, target_resource="ops.incident", case_reference=incident.case_reference, result="success", metadata={"from": old_sev, "to": target_severity}, ) # Resolution update if new_resolution is not None and new_resolution != incident.resolution: incident.resolution = new_resolution incident.updated_at = now ops_audit( db=db, action="incident.resolved", actor_identity=operator.identity, actor_role=operator.role, target_resource="ops.incident", case_reference=incident.case_reference, result="success", ) # Status transition if target_status and target_status != incident.status: if target_status not in VALID_STATUSES: raise HTTPException( status_code=422, detail=f"Invalid status '{patch.status}'. Must be: {sorted(VALID_STATUSES)}", ) # Owner only transitions if target_status == "archived" and operator.role != "owner": raise HTTPException(status_code=403, detail="Only Owners may archive incidents") if incident.status == "closed" and target_status == "investigating" and operator.role != "owner": raise HTTPException(status_code=403, detail="Only Owners may reopen closed incidents") # Allowed transitions allowed = ALLOWED_TRANSITIONS.get(incident.status, set()) # Add archived as valid destination for non-archived if owner if operator.role == "owner" and incident.status != "archived": allowed = allowed | {"archived"} if target_status not in allowed: raise HTTPException( status_code=409, detail=f"Invalid status transition from '{incident.status}' to '{target_status}'", ) # Resolution required for resolved or closed effective_res = new_resolution if new_resolution is not None else incident.resolution if target_status in {"resolved", "closed"} and (not effective_res or not effective_res.strip()): raise HTTPException( status_code=422, detail="Resolution text is required before resolving or closing an incident", ) old_status = incident.status incident.status = target_status incident.updated_at = now if target_status == "closed": incident.closed_at = now incident.closed_by_operator_id = operator.id audit_action = "incident.closed" elif target_status == "resolved": audit_action = "incident.resolved" elif old_status == "closed" and target_status == "investigating": incident.closed_at = None incident.closed_by_operator_id = None audit_action = "incident.reopened" elif target_status == "archived": audit_action = "incident.archived" else: audit_action = "incident.status_changed" ops_audit( db=db, action=audit_action, actor_identity=operator.identity, actor_role=operator.role, target_resource="ops.incident", case_reference=incident.case_reference, result="success", metadata={"from": old_status, "to": target_status}, ) db.commit() db.refresh(incident) creator = db.get(Operator, incident.created_by_operator_id) closer = db.get(Operator, incident.closed_by_operator_id) if incident.closed_by_operator_id else None return { "case_reference": incident.case_reference, "title": incident.title, "summary": incident.summary, "severity": incident.severity, "status": incident.status, "created_at": incident.created_at.isoformat(), "updated_at": incident.updated_at.isoformat(), "closed_at": incident.closed_at.isoformat() if incident.closed_at else None, "resolution": incident.resolution, "created_by_identity": creator.identity if creator else "operator", "closed_by_identity": closer.identity if closer else None, } @app.get("/api/v1/ops/incidents/{case_reference}/notes") def ops_list_incident_notes( case_reference: str, auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")), db: DbSession = Depends(get_db), ): stmt = select(OpsIncident).where(OpsIncident.case_reference == case_reference.strip()) incident = db.scalar(stmt) if incident is None: raise HTTPException(status_code=404, detail="Incident not found") notes_stmt = ( select(OpsIncidentNote, Operator.identity) .join(Operator, OpsIncidentNote.created_by_operator_id == Operator.id) .where(OpsIncidentNote.incident_id == incident.id) .order_by(OpsIncidentNote.created_at.desc()) ) notes_rows = db.execute(notes_stmt).all() return { "notes": [ { "created_at": note.created_at.isoformat(), "created_by_identity": op_identity, "body": note.body, } for note, op_identity in notes_rows ] } @app.post("/api/v1/ops/incidents/{case_reference}/notes", status_code=201) def ops_create_incident_note( case_reference: str, request: OpsIncidentNoteCreate, auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")), db: DbSession = Depends(get_db), ): operator, _ = auth stmt = select(OpsIncident).where(OpsIncident.case_reference == case_reference.strip()) incident = db.scalar(stmt) if incident is None: raise HTTPException(status_code=404, detail="Incident not found") if incident.status == "archived": raise HTTPException(status_code=409, detail="Archived incidents cannot be modified") body_text = request.body.strip() if not body_text: raise HTTPException(status_code=422, detail="Note body cannot be empty") now = datetime.now(timezone.utc) note = OpsIncidentNote( incident_id=incident.id, created_at=now, created_by_operator_id=operator.id, body=body_text, ) db.add(note) incident.updated_at = now db.flush() # Transactional Audit ops_audit( db=db, action="incident.note_added", actor_identity=operator.identity, actor_role=operator.role, target_resource="ops.incident", case_reference=incident.case_reference, result="success", ) db.commit() db.refresh(note) return { "created_at": note.created_at.isoformat(), "created_by_identity": operator.identity, "body": note.body, }