MotorSignal-App/backend/motorsignal_api/app/main.py

1406 lines
50 KiB
Python

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,
}