feat: add Operations incident management

This commit is contained in:
Developer 2026-09-10 14:48:08 -04:00
parent d31f88e97d
commit 8681e6f259
23 changed files with 4597 additions and 12 deletions

View File

@ -0,0 +1,24 @@
"""link firebase identity
Revision ID: e0f5a7123b46
Revises: d9e4f6012a35
Create Date: 2026-09-08
"""
from alembic import op
import sqlalchemy as sa
revision = "e0f5a7123b46"
down_revision = "d9e4f6012a35"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("users", sa.Column("firebase_uid", sa.String(length=128), nullable=True))
op.create_index("ix_users_firebase_uid", "users", ["firebase_uid"], unique=True)
def downgrade() -> None:
op.drop_index("ix_users_firebase_uid", table_name="users")
op.drop_column("users", "firebase_uid")

View File

@ -0,0 +1,61 @@
"""add ops incidents
Revision ID: f1a2b3c4d5e6
Revises: f1a6b8234c57
Create Date: 2026-09-10
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID
revision = "f1a2b3c4d5e6"
down_revision = "f1b7c8345d68"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1. ops.incident_case_seq for readable immutable case references: OPS-YYYY-NNNNNN
op.execute("CREATE SEQUENCE IF NOT EXISTS ops.incident_case_seq START 1")
# 2. ops.incidents table
op.create_table(
"incidents",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("case_reference", sa.String(length=32), nullable=False, unique=True),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("summary", sa.Text(), nullable=False),
sa.Column("severity", sa.String(length=32), nullable=False, server_default="medium"),
sa.Column("status", sa.String(length=32), nullable=False, server_default="open"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("created_by_operator_id", UUID(as_uuid=True), sa.ForeignKey("ops.operators.id", ondelete="RESTRICT"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("closed_by_operator_id", UUID(as_uuid=True), sa.ForeignKey("ops.operators.id", ondelete="RESTRICT"), nullable=True),
sa.Column("resolution", sa.Text(), nullable=True),
schema="ops",
)
op.create_index("ix_ops_incidents_case_reference", "incidents", ["case_reference"], unique=True, schema="ops")
op.create_index("ix_ops_incidents_status", "incidents", ["status"], unique=False, schema="ops")
op.create_index("ix_ops_incidents_severity", "incidents", ["severity"], unique=False, schema="ops")
op.create_index("ix_ops_incidents_created_at", "incidents", ["created_at"], unique=False, schema="ops")
# 3. ops.incident_notes table
op.create_table(
"incident_notes",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("incident_id", UUID(as_uuid=True), sa.ForeignKey("ops.incidents.id", ondelete="CASCADE"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("created_by_operator_id", UUID(as_uuid=True), sa.ForeignKey("ops.operators.id", ondelete="RESTRICT"), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
schema="ops",
)
op.create_index("ix_ops_incident_notes_incident_id", "incident_notes", ["incident_id"], unique=False, schema="ops")
op.create_index("ix_ops_incident_notes_created_at", "incident_notes", ["created_at"], unique=False, schema="ops")
def downgrade() -> None:
op.drop_table("incident_notes", schema="ops")
op.drop_table("incidents", schema="ops")
op.execute("DROP SEQUENCE IF EXISTS ops.incident_case_seq")

View File

@ -0,0 +1,108 @@
"""Add isolated Operations foundation; no rider table changes.
Revision ID: f1a6b8234c57
Revises: e0f5a7123b46
"""
from alembic import op
import sqlalchemy as sa
revision = "f1a6b8234c57"
down_revision = "e0f5a7123b46"
branch_labels = None
depends_on = None
def upgrade():
op.execute("CREATE SCHEMA ops")
op.create_table(
"operators",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("firebase_uid", sa.String(128), nullable=False, unique=True),
sa.Column("verified_email", sa.String(320), nullable=False),
sa.Column("role", sa.String(16), nullable=False),
sa.Column("active", sa.Boolean(), nullable=False),
sa.Column("authority_version", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.CheckConstraint("role IN ('operator', 'owner')", name="operator_role"),
sa.CheckConstraint("authority_version > 0", name="operator_version"),
sa.CheckConstraint("length(firebase_uid) > 0 AND length(verified_email) > 0", name="operator_identity"),
schema="ops",
)
op.create_table(
"sessions",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("operator_id", sa.Uuid(), sa.ForeignKey("ops.operators.id"), nullable=False),
sa.Column("token_digest", sa.String(64), nullable=False, unique=True),
sa.Column("authority_version", sa.Integer(), nullable=False),
sa.Column("firebase_auth_time", sa.BigInteger(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("absolute_expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("idle_timeout_seconds", sa.Integer(), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True)),
sa.CheckConstraint("authority_version > 0", name="session_version"),
sa.CheckConstraint("idle_timeout_seconds > 0 AND idle_timeout_seconds <= 900", name="session_idle"),
sa.CheckConstraint("token_digest ~ '^[a-f0-9]{64}$'", name="session_digest"),
sa.CheckConstraint("absolute_expires_at > created_at AND absolute_expires_at <= created_at + interval '8 hours'", name="session_absolute"),
schema="ops",
)
op.create_index("ix_ops_sessions_operator_id", "sessions", ["operator_id"], schema="ops")
op.create_table(
"audit_events",
sa.Column("id", sa.Uuid(), primary_key=True),
sa.Column("schema_version", sa.Integer(), nullable=False),
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("actor_operator_id", sa.Uuid(), sa.ForeignKey("ops.operators.id")),
sa.Column("actor_kind", sa.String(24), nullable=False),
sa.Column("actor_role", sa.String(16)),
sa.Column("action", sa.String(32), nullable=False),
sa.Column("target_type", sa.String(16), nullable=False),
sa.Column("target_id", sa.Uuid()),
sa.Column("request_id", sa.Uuid(), nullable=False),
sa.Column("operation_id", sa.Uuid()),
sa.Column("result", sa.String(16), nullable=False),
sa.Column("reason_code", sa.String(32)),
sa.Column("case_reference", sa.String(64)),
sa.Column("sanitized_metadata", sa.JSON(), nullable=False),
sa.CheckConstraint("schema_version = 1", name="audit_schema_version"),
sa.CheckConstraint("actor_kind IN ('operator', 'unidentified', 'manual_admin')", name="audit_actor"),
sa.CheckConstraint("actor_role IS NULL OR actor_role IN ('operator', 'owner')", name="audit_role"),
sa.CheckConstraint("action IN ('session.create', 'session.logout', 'authorization.deny', 'session.failure', 'operator.bootstrap', 'operator.disable')", name="audit_action"),
sa.CheckConstraint("target_type IN ('operator', 'session')", name="audit_target"),
sa.CheckConstraint("result IN ('success', 'denied')", name="audit_result"),
sa.CheckConstraint("case_reference IS NULL OR case_reference ~ '^OPS-[0-9]{1,12}$'", name="audit_case"),
schema="ops",
)
op.create_index("ix_ops_audit_events_occurred_at", "audit_events", ["occurred_at"], schema="ops")
# Guard immutable identity and version invalidation even for manual SQL updates.
op.execute("""
CREATE FUNCTION ops.guard_operator_identity() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog AS $$
BEGIN
IF NEW.id IS DISTINCT FROM OLD.id OR NEW.firebase_uid IS DISTINCT FROM OLD.firebase_uid THEN
RAISE EXCEPTION 'Operations identity is immutable';
END IF;
IF NEW.authority_version < OLD.authority_version THEN
RAISE EXCEPTION 'Operations authority version cannot decrease';
END IF;
IF NEW.role IS DISTINCT FROM OLD.role OR NEW.active IS DISTINCT FROM OLD.active THEN
NEW.authority_version := GREATEST(NEW.authority_version, OLD.authority_version + 1);
END IF;
NEW.updated_at := CURRENT_TIMESTAMP;
RETURN NEW;
END $$
""")
op.execute("CREATE TRIGGER guard_operator_identity BEFORE UPDATE ON ops.operators FOR EACH ROW EXECUTE FUNCTION ops.guard_operator_identity()")
op.execute("REVOKE ALL ON SCHEMA ops FROM PUBLIC")
op.execute("REVOKE ALL ON ALL TABLES IN SCHEMA ops FROM PUBLIC")
op.execute("REVOKE ALL ON ALL FUNCTIONS IN SCHEMA ops FROM PUBLIC")
def downgrade():
# Explicit downgrade removes Operations data only. Never run in production.
op.drop_table("audit_events", schema="ops")
op.drop_table("sessions", schema="ops")
op.drop_table("operators", schema="ops")
op.execute("DROP FUNCTION ops.guard_operator_identity()")
op.execute("DROP SCHEMA ops")

View File

@ -0,0 +1,99 @@
"""evolve ops runtime schema preserving security invariants
Revision ID: f1b7c8345d68
Revises: f1a6b8234c57
Create Date: 2026-09-10
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB, UUID
revision = "f1b7c8345d68"
down_revision = "f1a6b8234c57"
branch_labels = None
depends_on = None
def upgrade() -> None:
# 1. Evolve ops.operators
op.add_column("operators", sa.Column("identity", sa.String(length=320), nullable=True), schema="ops")
op.execute("UPDATE ops.operators SET identity = verified_email WHERE identity IS NULL")
op.alter_column("operators", "identity", nullable=False, schema="ops")
op.create_index("ix_ops_operators_identity", "operators", ["identity"], unique=True, schema="ops")
op.add_column("operators", sa.Column("status", sa.String(length=32), nullable=True), schema="ops")
op.execute("UPDATE ops.operators SET status = CASE WHEN active THEN 'active' ELSE 'suspended' END WHERE status IS NULL")
op.alter_column("operators", "status", nullable=False, server_default="active", schema="ops")
op.create_check_constraint("operator_status", "operators", "status IN ('active', 'suspended', 'revoked')", schema="ops")
# Update trigger to protect new identity column and status field
op.execute("""
CREATE OR REPLACE FUNCTION ops.guard_operator_identity() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog AS $$
BEGIN
IF NEW.id IS DISTINCT FROM OLD.id OR NEW.identity IS DISTINCT FROM OLD.identity THEN
RAISE EXCEPTION 'Operations identity is immutable';
END IF;
IF NEW.authority_version < OLD.authority_version THEN
RAISE EXCEPTION 'Operations authority version cannot decrease';
END IF;
IF NEW.role IS DISTINCT FROM OLD.role OR NEW.status IS DISTINCT FROM OLD.status THEN
NEW.authority_version := GREATEST(NEW.authority_version, OLD.authority_version + 1);
END IF;
NEW.updated_at := CURRENT_TIMESTAMP;
RETURN NEW;
END $$;
""")
# 2. Evolve ops.sessions
op.add_column("sessions", sa.Column("session_token_hash", sa.String(length=128), nullable=True), schema="ops")
op.execute("UPDATE ops.sessions SET session_token_hash = token_digest WHERE session_token_hash IS NULL")
op.alter_column("sessions", "session_token_hash", nullable=False, schema="ops")
op.create_index("ix_ops_sessions_token_hash", "sessions", ["session_token_hash"], unique=True, schema="ops")
op.add_column("sessions", sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), schema="ops")
op.execute("UPDATE ops.sessions SET expires_at = absolute_expires_at WHERE expires_at IS NULL")
op.alter_column("sessions", "expires_at", nullable=False, schema="ops")
op.add_column("sessions", sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True), schema="ops")
op.execute("UPDATE ops.sessions SET last_activity_at = last_seen_at WHERE last_activity_at IS NULL")
# 3. Evolve ops.audit_events
op.add_column("audit_events", sa.Column("timestamp", sa.DateTime(timezone=True), nullable=True), schema="ops")
op.execute("UPDATE ops.audit_events SET timestamp = occurred_at WHERE timestamp IS NULL")
op.alter_column("audit_events", "timestamp", nullable=False, server_default=sa.func.now(), schema="ops")
op.create_index("ix_ops_audit_events_timestamp", "audit_events", ["timestamp"], unique=False, schema="ops")
op.add_column("audit_events", sa.Column("actor_identity", sa.String(length=320), nullable=True), schema="ops")
op.execute("UPDATE ops.audit_events SET actor_identity = actor_kind WHERE actor_identity IS NULL")
op.alter_column("audit_events", "actor_identity", nullable=False, schema="ops")
op.add_column("audit_events", sa.Column("target_resource", sa.String(length=96), nullable=True), schema="ops")
op.execute("UPDATE ops.audit_events SET target_resource = target_type WHERE target_resource IS NULL")
op.add_column("audit_events", sa.Column("metadata_json", JSONB, nullable=True), schema="ops")
op.execute("UPDATE ops.audit_events SET metadata_json = sanitized_metadata::jsonb WHERE metadata_json IS NULL")
# 4. Revoke public permissions
op.execute("REVOKE ALL ON SCHEMA ops FROM PUBLIC")
op.execute("REVOKE ALL ON ALL TABLES IN SCHEMA ops FROM PUBLIC")
op.execute("REVOKE ALL ON ALL FUNCTIONS IN SCHEMA ops FROM PUBLIC")
def downgrade() -> None:
op.drop_column("audit_events", "metadata_json", schema="ops")
op.drop_column("audit_events", "target_resource", schema="ops")
op.drop_column("audit_events", "actor_identity", schema="ops")
op.drop_index("ix_ops_audit_events_timestamp", table_name="audit_events", schema="ops")
op.drop_column("audit_events", "timestamp", schema="ops")
op.drop_column("sessions", "last_activity_at", schema="ops")
op.drop_column("sessions", "expires_at", schema="ops")
op.drop_index("ix_ops_sessions_token_hash", table_name="sessions", schema="ops")
op.drop_column("sessions", "session_token_hash", schema="ops")
op.drop_constraint("operator_status", "operators", schema="ops")
op.drop_column("operators", "status", schema="ops")
op.drop_index("ix_ops_operators_identity", table_name="operators", schema="ops")
op.drop_column("operators", "identity", schema="ops")

View File

@ -1,10 +1,14 @@
import hashlib
import json
import os
import secrets
import time
import urllib.request
import uuid
from datetime import datetime, timedelta, timezone
import jwt
from cryptography import x509
from pwdlib import PasswordHash
@ -15,6 +19,9 @@ ACCESS_TOKEN_MINUTES = int(os.environ.get("ACCESS_TOKEN_MINUTES", "15"))
REFRESH_TOKEN_DAYS = int(os.environ.get("REFRESH_TOKEN_DAYS", "30"))
JWT_ALGORITHM = "HS256"
FIREBASE_CERTS_URL = "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com"
_firebase_certs: dict[str, str] = {}
_firebase_certs_expires_at = 0.0
def normalize_email(email: str) -> str:
@ -87,3 +94,33 @@ def decode_access_token(token: str) -> dict:
JWT_SECRET,
algorithms=[JWT_ALGORITHM],
)
def _load_firebase_certs() -> dict[str, str]:
global _firebase_certs, _firebase_certs_expires_at
now = time.time()
if _firebase_certs and now < _firebase_certs_expires_at:
return _firebase_certs
with urllib.request.urlopen(FIREBASE_CERTS_URL, timeout=5) as response:
certs = json.loads(response.read())
cache_control = response.headers.get("Cache-Control", "")
max_age = next((int(value.split("=", 1)[1]) for value in cache_control.split(",") if value.strip().startswith("max-age=")), 300)
_firebase_certs = certs
_firebase_certs_expires_at = now + max_age
return certs
def decode_firebase_id_token(token: str) -> dict:
project_id = os.environ["FIREBASE_PROJECT_ID"]
header = jwt.get_unverified_header(token)
if header.get("alg") != "RS256" or not header.get("kid"):
raise jwt.InvalidTokenError("Invalid Firebase token header")
certificate_pem = _load_firebase_certs().get(header["kid"])
if certificate_pem is None:
raise jwt.InvalidTokenError("Unknown Firebase signing key")
public_key = x509.load_pem_x509_certificate(certificate_pem.encode("ascii")).public_key()
claims = jwt.decode(token, public_key, algorithms=["RS256"], audience=project_id, issuer=f"https://securetoken.google.com/{project_id}")
subject = claims.get("sub")
if not isinstance(subject, str) or not subject or len(subject) > 128:
raise jwt.InvalidTokenError("Invalid Firebase subject")
return claims

View File

@ -7,8 +7,8 @@ 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, Field
from sqlalchemy import func, or_, select
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import func, or_, select, text
from sqlalchemy.orm import Session as DbSession
from auth import (
@ -16,6 +16,7 @@ from auth import (
create_refresh_token,
create_one_time_token,
decode_access_token,
decode_firebase_id_token,
hash_password,
hash_refresh_token,
hash_one_time_token,
@ -24,7 +25,24 @@ from auth import (
verify_password,
)
from db import check_database, engine
from models import AuditEvent, AuthRateLimit, EmailVerificationToken, Entitlement, PasswordResetToken, Ride, Session, Subscription, TelemetryPoint, User, Vehicle
from models import (
AuditEvent,
AuthRateLimit,
EmailVerificationToken,
Entitlement,
Operator,
OpsAuditEvent,
OpsIncident,
OpsIncidentNote,
OpsSession,
PasswordResetToken,
Ride,
Session,
Subscription,
TelemetryPoint,
User,
Vehicle,
)
logger = logging.getLogger(__name__)
@ -63,6 +81,10 @@ 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)
@ -546,10 +568,35 @@ def me(user: User = Depends(get_current_user)):
"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")
@ -982,6 +1029,20 @@ def list_my_telemetry(ride_id: uuid.UUID, limit: int = Query(default=1000, ge=1,
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)
@app.get("/api/v1/admin/rides/{ride_id}/telemetry")
def list_admin_telemetry(ride_id: uuid.UUID, limit: int = Query(default=1000, ge=1, le=5000), user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)):
if db.get(Ride, ride_id) is None:
@ -990,6 +1051,13 @@ def list_admin_telemetry(ride_id: uuid.UUID, limit: int = Query(default=1000, ge
return {"items": [telemetry_payload(point) for point in points], "count": len(points)}
@app.get("/api/v1/admin/rides/{ride_id}/telemetry/summary")
def admin_telemetry_summary(ride_id: uuid.UUID, user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)):
if db.get(Ride, ride_id) is None:
raise HTTPException(status_code=404, detail="Ride not found")
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}
@ -1061,3 +1129,614 @@ def create_entitlement(user_id: uuid.UUID, request: EntitlementCreate, actor: Us
db.commit()
db.refresh(entitlement)
return entitlement_payload(entitlement)
# ============================================================
# 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,
}

View File

@ -50,6 +50,10 @@ class User(Base):
index=True,
)
firebase_uid: Mapped[str | None] = mapped_column(
String(128), unique=True, nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
@ -231,3 +235,146 @@ class AuthRateLimit(Base):
key: Mapped[str] = mapped_column(String(255), primary_key=True)
window_started: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
attempts: Mapped[int] = mapped_column(nullable=False, default=0)
# ============================================================
# Operations (ops schema) Models
# ============================================================
class Operator(Base):
__tablename__ = "operators"
__table_args__ = {"schema": "ops"}
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
identity: Mapped[str] = mapped_column(
String(320), unique=True, nullable=False, index=True
)
role: Mapped[str] = mapped_column(
String(32), nullable=False, default="operator", index=True
)
status: Mapped[str] = mapped_column(
String(32), nullable=False, default="active"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
sessions: Mapped[list["OpsSession"]] = relationship(
back_populates="operator", cascade="all, delete-orphan"
)
class OpsSession(Base):
__tablename__ = "sessions"
__table_args__ = {"schema": "ops"}
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
operator_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("ops.operators.id", ondelete="CASCADE"), nullable=False, index=True
)
session_token_hash: Mapped[str] = mapped_column(
String(128), unique=True, nullable=False, index=True
)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
last_activity_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False
)
revoked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
operator: Mapped["Operator"] = relationship(back_populates="sessions")
class OpsAuditEvent(Base):
__tablename__ = "audit_events"
__table_args__ = {"schema": "ops"}
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
timestamp: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False, index=True
)
action: Mapped[str] = mapped_column(String(96), nullable=False, index=True)
actor_identity: Mapped[str] = mapped_column(String(320), nullable=False)
actor_role: Mapped[str | None] = mapped_column(String(32), nullable=True)
target_resource: Mapped[str | None] = mapped_column(String(96), nullable=True)
reason_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
case_reference: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
result: Mapped[str] = mapped_column(String(32), nullable=False, default="success")
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
class OpsIncident(Base):
__tablename__ = "incidents"
__table_args__ = {"schema": "ops"}
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
case_reference: Mapped[str] = mapped_column(
String(32), unique=True, nullable=False, index=True
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
summary: Mapped[str] = mapped_column(String, nullable=False)
severity: Mapped[str] = mapped_column(
String(32), nullable=False, default="medium", index=True
)
status: Mapped[str] = mapped_column(
String(32), nullable=False, default="open", index=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False, index=True
)
created_by_operator_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("ops.operators.id", ondelete="RESTRICT"), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
)
closed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
closed_by_operator_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("ops.operators.id", ondelete="RESTRICT"), nullable=True
)
resolution: Mapped[str | None] = mapped_column(String, nullable=True)
notes: Mapped[list["OpsIncidentNote"]] = relationship(
back_populates="incident", cascade="all, delete-orphan", order_by="OpsIncidentNote.created_at.desc()"
)
class OpsIncidentNote(Base):
__tablename__ = "incident_notes"
__table_args__ = {"schema": "ops"}
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
incident_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("ops.incidents.id", ondelete="CASCADE"), nullable=False, index=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=datetime.utcnow, nullable=False, index=True
)
created_by_operator_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("ops.operators.id", ondelete="RESTRICT"), nullable=False
)
body: Mapped[str] = mapped_column(String, nullable=False)
incident: Mapped["OpsIncident"] = relationship(back_populates="notes")

View File

@ -0,0 +1,62 @@
import os
import sys
import time
from datetime import datetime, timedelta, timezone
import jwt
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography import x509
from cryptography.x509.oid import NameOID
ROOT = os.path.dirname(os.path.dirname(__file__))
sys.path.insert(0, os.path.join(ROOT, ".python-packages"))
sys.path.insert(0, os.path.join(ROOT, "app"))
os.environ.setdefault("JWT_SECRET", "local-test-secret")
os.environ["FIREBASE_PROJECT_ID"] = "motoapp-d22e0"
from auth import decode_firebase_id_token # noqa: E402
import auth # noqa: E402
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
now = datetime.now(timezone.utc)
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "firebase-test")])
certificate = (
x509.CertificateBuilder()
.subject_name(name)
.issuer_name(name)
.public_key(private_key.public_key())
.serial_number(x509.random_serial_number())
.not_valid_before(now - timedelta(minutes=1))
.not_valid_after(now + timedelta(days=1))
.sign(private_key, algorithm=hashes.SHA256())
)
auth._firebase_certs = {"test-key": certificate.public_bytes(serialization.Encoding.PEM).decode()}
auth._firebase_certs_expires_at = time.time() + 60
def token(**overrides):
claims = {
"sub": "firebase-user-123",
"email": "rider@example.com",
"email_verified": True,
"aud": "motoapp-d22e0",
"iss": "https://securetoken.google.com/motoapp-d22e0",
"iat": now,
"exp": now + timedelta(minutes=5),
}
claims.update(overrides)
return jwt.encode(claims, private_key, algorithm="RS256", headers={"kid": "test-key"})
assert decode_firebase_id_token(token())["sub"] == "firebase-user-123"
for invalid in (token(aud="other-project"), token(iss="https://securetoken.google.com/other-project"), token(sub="")):
try:
decode_firebase_id_token(invalid)
except jwt.PyJWTError:
pass
else:
raise AssertionError("Invalid Firebase token accepted")
print("PASS: Firebase signature, project, issuer, and subject validation")

View File

@ -0,0 +1,559 @@
import hashlib
import os
import sys
import unittest
import uuid
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
ROOT = os.path.dirname(os.path.dirname(__file__))
sys.path.insert(0, os.path.join(ROOT, ".python-packages"))
sys.path.insert(0, os.path.join(ROOT, "app"))
os.environ.setdefault("JWT_SECRET", "local-test-secret")
os.environ["FIREBASE_PROJECT_ID"] = "motoapp-d22e0"
os.environ["DATABASE_USER"] = "test"
os.environ["DATABASE_PASSWORD"] = "test"
os.environ["DATABASE_NAME"] = "test"
from sqlalchemy import create_engine, event, select, text
from sqlalchemy.orm import Session as DbSession
from starlette.testclient import TestClient
import main
from main import (
app,
generate_incident_case_reference,
get_db,
OpsIncidentCreate,
OpsIncidentPatch,
OpsIncidentNoteCreate,
)
from models import Base, Operator, OpsAuditEvent, OpsIncident, OpsIncidentNote, OpsSession
def hash_token(raw_token: str) -> str:
return hashlib.sha256(raw_token.encode()).hexdigest()
class TestOpsIncidents(unittest.TestCase):
@classmethod
def setUpClass(cls):
# Create an isolated SQLite database attached with 'ops' schema
cls.db_path = os.path.join(ROOT, "tests", "test_ops_incidents.db")
cls.ops_db_path = os.path.join(ROOT, "tests", "test_ops_schema.db")
for p in (cls.db_path, cls.ops_db_path):
if os.path.exists(p):
try:
os.remove(p)
except OSError:
pass
cls.engine = create_engine(f"sqlite:///{cls.db_path}")
cls.seq_val = 0
@event.listens_for(cls.engine, "connect")
def attach_ops(dbapi_con, con_record):
dbapi_con.execute(f"ATTACH DATABASE '{cls.ops_db_path}' AS ops")
def sqlite_nextval(name):
cls.seq_val += 1
return cls.seq_val
dbapi_con.create_function("nextval", 1, sqlite_nextval)
Base.metadata.create_all(cls.engine)
def override_get_db():
db = DbSession(cls.engine)
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
cls.client = TestClient(app, raise_server_exceptions=False)
@classmethod
def tearDownClass(cls):
app.dependency_overrides.clear()
cls.engine.dispose()
for p in (cls.db_path, cls.ops_db_path):
if os.path.exists(p):
try:
os.remove(p)
except OSError:
pass
def setUp(self):
# Clear tables and seed standard test operators & sessions
with DbSession(self.engine) as db:
db.query(OpsIncidentNote).delete()
db.query(OpsIncident).delete()
db.query(OpsAuditEvent).delete()
db.query(OpsSession).delete()
db.query(Operator).delete()
db.commit()
# Seed Active Operator
self.op_id = uuid.uuid4()
self.op_identity = "operator@motorsignal.internal"
self.op_token = "valid-operator-token-123"
op = Operator(
id=self.op_id,
identity=self.op_identity,
role="operator",
status="active",
created_at=datetime.now(timezone.utc),
)
op_session = OpsSession(
operator_id=self.op_id,
session_token_hash=hash_token(self.op_token),
expires_at=datetime.now(timezone.utc) + timedelta(hours=8),
created_at=datetime.now(timezone.utc),
)
# Seed Active Owner
self.owner_id = uuid.uuid4()
self.owner_identity = "owner@motorsignal.internal"
self.owner_token = "valid-owner-token-456"
owner = Operator(
id=self.owner_id,
identity=self.owner_identity,
role="owner",
status="active",
created_at=datetime.now(timezone.utc),
)
owner_session = OpsSession(
operator_id=self.owner_id,
session_token_hash=hash_token(self.owner_token),
expires_at=datetime.now(timezone.utc) + timedelta(hours=8),
created_at=datetime.now(timezone.utc),
)
# Seed Suspended Operator
self.suspended_id = uuid.uuid4()
self.suspended_token = "suspended-operator-token"
suspended_op = Operator(
id=self.suspended_id,
identity="suspended@motorsignal.internal",
role="operator",
status="suspended",
created_at=datetime.now(timezone.utc),
)
suspended_session = OpsSession(
operator_id=self.suspended_id,
session_token_hash=hash_token(self.suspended_token),
expires_at=datetime.now(timezone.utc) + timedelta(hours=8),
created_at=datetime.now(timezone.utc),
)
# Seed Expired Session
self.expired_token = "expired-token-789"
expired_session = OpsSession(
operator_id=self.op_id,
session_token_hash=hash_token(self.expired_token),
expires_at=datetime.now(timezone.utc) - timedelta(minutes=5),
created_at=datetime.now(timezone.utc) - timedelta(hours=2),
)
# Seed Revoked Session
self.revoked_token = "revoked-token-000"
revoked_session = OpsSession(
operator_id=self.op_id,
session_token_hash=hash_token(self.revoked_token),
expires_at=datetime.now(timezone.utc) + timedelta(hours=8),
revoked_at=datetime.now(timezone.utc) - timedelta(minutes=1),
created_at=datetime.now(timezone.utc) - timedelta(hours=1),
)
db.add_all([op, owner, suspended_op, op_session, owner_session, expired_session, revoked_session])
db.commit()
self.op_headers = {"Authorization": f"Bearer {self.op_token}"}
self.owner_headers = {"Authorization": f"Bearer {self.owner_token}"}
# ============================================================
# 1. Authority and Authentication Tests
# ============================================================
def test_unauthenticated_requests_rejected(self):
routes = [
("GET", "/api/v1/ops/incidents", None),
("POST", "/api/v1/ops/incidents", {"title": "Test", "summary": "Sum"}),
("GET", "/api/v1/ops/incidents/OPS-2026-000001", None),
("PATCH", "/api/v1/ops/incidents/OPS-2026-000001", {"status": "investigating"}),
("GET", "/api/v1/ops/incidents/OPS-2026-000001/notes", None),
("POST", "/api/v1/ops/incidents/OPS-2026-000001/notes", {"body": "Note"}),
]
for method, path, json_data in routes:
response = self.client.request(method, path, json=json_data)
self.assertEqual(response.status_code, 401, f"{method} {path} should reject unauthenticated request")
def test_invalid_bearer_token_rejected(self):
headers = {"Authorization": "Bearer completely-invalid-token"}
res = self.client.get("/api/v1/ops/incidents", headers=headers)
self.assertEqual(res.status_code, 401)
def test_expired_and_revoked_session_rejected(self):
res_expired = self.client.get("/api/v1/ops/incidents", headers={"Authorization": f"Bearer {self.expired_token}"})
self.assertEqual(res_expired.status_code, 401)
res_revoked = self.client.get("/api/v1/ops/incidents", headers={"Authorization": f"Bearer {self.revoked_token}"})
self.assertEqual(res_revoked.status_code, 401)
def test_suspended_operator_rejected(self):
res = self.client.get("/api/v1/ops/incidents", headers={"Authorization": f"Bearer {self.suspended_token}"})
self.assertEqual(res.status_code, 401)
# ============================================================
# 2. Case Reference Generation & Format Tests (Hardened)
# ============================================================
def test_case_reference_format_and_sequence(self):
year = datetime.now(timezone.utc).year
payload = {"title": "Sequential Test Incident", "summary": "Testing format", "severity": "low"}
res1 = self.client.post("/api/v1/ops/incidents", json=payload, headers=self.op_headers)
self.assertEqual(res1.status_code, 201)
data1 = res1.json()
case_ref1 = data1["case_reference"]
# Invariant checks: prefix OPS-YYYY-, numeric suffix >= 6 digits, untruncated
self.assertTrue(case_ref1.startswith(f"OPS-{year}-"))
parts1 = case_ref1.split("-")
self.assertEqual(len(parts1), 3)
self.assertEqual(parts1[0], "OPS")
self.assertEqual(parts1[1], str(year))
self.assertTrue(parts1[2].isdigit())
self.assertGreaterEqual(len(parts1[2]), 6)
res2 = self.client.post("/api/v1/ops/incidents", json=payload, headers=self.op_headers)
self.assertEqual(res2.status_code, 201)
data2 = res2.json()
case_ref2 = data2["case_reference"]
self.assertTrue(case_ref2.startswith(f"OPS-{year}-"))
self.assertNotEqual(case_ref1, case_ref2)
def test_case_reference_sequence_greater_than_999999(self):
"""Values > 999999 format properly without truncation (e.g. sequence 1000000 -> OPS-2026-1000000)."""
year = datetime.now(timezone.utc).year
with DbSession(self.engine) as db:
with patch.object(db, "execute") as mock_exec:
mock_exec.return_value.scalar.return_value = 1000000
case_ref = generate_incident_case_reference(db)
self.assertEqual(case_ref, f"OPS-{year}-1000000")
self.assertEqual(len(case_ref), 16)
parts = case_ref.split("-")
self.assertEqual(parts[0], "OPS")
self.assertEqual(parts[1], str(year))
self.assertEqual(parts[2], "1000000")
def test_fail_closed_case_reference_if_sequence_errors(self):
with patch.object(main, "generate_incident_case_reference", side_effect=RuntimeError("Sequence connection lost")):
payload = {"title": "Fail Closed Test", "summary": "Should fail", "severity": "high"}
res = self.client.post("/api/v1/ops/incidents", json=payload, headers=self.op_headers)
self.assertEqual(res.status_code, 500)
# Confirm zero incidents persisted
with DbSession(self.engine) as db:
count = db.scalar(select(OpsIncident.id))
self.assertIsNone(count)
# ============================================================
# 3. Pydantic Extra-Field Hardening Tests (extra='forbid')
# ============================================================
def test_incident_create_forbids_extra_fields(self):
forbidden_fields = [
{"id": str(uuid.uuid4())},
{"case_reference": "OPS-2026-999999"},
{"created_by_operator_id": str(uuid.uuid4())},
{"closed_by_operator_id": str(uuid.uuid4())},
{"actor_identity": "hacker@evil.com"},
{"actor_role": "owner"},
{"created_at": "2026-01-01T00:00:00Z"},
]
for extra in forbidden_fields:
payload = {"title": "Valid Title", "summary": "Valid summary", "severity": "low"}
payload.update(extra)
res = self.client.post("/api/v1/ops/incidents", json=payload, headers=self.op_headers)
self.assertEqual(res.status_code, 422, f"Extra field {list(extra.keys())[0]} must be rejected with 422")
def test_incident_patch_forbids_extra_fields(self):
inc_res = self.client.post("/api/v1/ops/incidents", json={"title": "Patch Extra Test", "summary": "Sum", "severity": "low"}, headers=self.op_headers)
case_ref = inc_res.json()["case_reference"]
forbidden_fields = [
{"id": str(uuid.uuid4())},
{"case_reference": "OPS-2026-888888"},
{"created_by_operator_id": str(uuid.uuid4())},
{"actor_role": "owner"},
{"created_at": "2026-01-01T00:00:00Z"},
]
for extra in forbidden_fields:
payload = {"status": "investigating"}
payload.update(extra)
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json=payload, headers=self.op_headers)
self.assertEqual(res.status_code, 422, f"Extra field {list(extra.keys())[0]} must be rejected with 422")
def test_incident_note_create_forbids_extra_fields(self):
inc_res = self.client.post("/api/v1/ops/incidents", json={"title": "Note Extra Test", "summary": "Sum", "severity": "low"}, headers=self.op_headers)
case_ref = inc_res.json()["case_reference"]
forbidden_fields = [
{"id": str(uuid.uuid4())},
{"case_reference": "OPS-2026-777777"},
{"created_by_operator_id": str(uuid.uuid4())},
{"actor_role": "owner"},
{"created_at": "2026-01-01T00:00:00Z"},
]
for extra in forbidden_fields:
payload = {"body": "Legitimate note body"}
payload.update(extra)
res = self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json=payload, headers=self.op_headers)
self.assertEqual(res.status_code, 422, f"Extra field {list(extra.keys())[0]} must be rejected with 422")
# ============================================================
# 4. Standard Input Validation Tests
# ============================================================
def test_incident_create_validation(self):
# Empty title
res = self.client.post("/api/v1/ops/incidents", json={"title": "", "summary": "Valid summary", "severity": "low"}, headers=self.op_headers)
self.assertEqual(res.status_code, 422)
# Empty summary
res = self.client.post("/api/v1/ops/incidents", json={"title": "Valid title", "summary": "", "severity": "low"}, headers=self.op_headers)
self.assertEqual(res.status_code, 422)
# Invalid severity
res = self.client.post("/api/v1/ops/incidents", json={"title": "Valid", "summary": "Valid", "severity": "catastrophic"}, headers=self.op_headers)
self.assertEqual(res.status_code, 422)
# Title over 255 chars
res = self.client.post("/api/v1/ops/incidents", json={"title": "A" * 256, "summary": "Valid", "severity": "low"}, headers=self.op_headers)
self.assertEqual(res.status_code, 422)
def test_note_create_validation(self):
inc_res = self.client.post("/api/v1/ops/incidents", json={"title": "Note Test", "summary": "Sum", "severity": "medium"}, headers=self.op_headers)
case_ref = inc_res.json()["case_reference"]
# Empty note
res = self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json={"body": ""}, headers=self.op_headers)
self.assertEqual(res.status_code, 422)
# Whitespace-only note
res = self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json={"body": " \n\t "}, headers=self.op_headers)
self.assertEqual(res.status_code, 422)
# Note on nonexistent incident
res = self.client.post("/api/v1/ops/incidents/OPS-9999-999999/notes", json={"body": "Note"}, headers=self.op_headers)
self.assertEqual(res.status_code, 404)
# ============================================================
# 5. State Machine Transitions & Role Restrictions
# ============================================================
def test_standard_incident_lifecycle(self):
# 1. Create -> Initial status is "open"
res = self.client.post("/api/v1/ops/incidents", json={"title": "Lifecycle", "summary": "Sum", "severity": "high"}, headers=self.op_headers)
self.assertEqual(res.status_code, 201)
case_ref = res.json()["case_reference"]
self.assertEqual(res.json()["status"], "open")
# 2. open -> investigating
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "investigating"}, headers=self.op_headers)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json()["status"], "investigating")
# 3. investigating -> monitoring
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "monitoring"}, headers=self.op_headers)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json()["status"], "monitoring")
# 4. monitoring -> resolved without resolution fails (422)
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "resolved"}, headers=self.op_headers)
self.assertEqual(res.status_code, 422)
# 5. monitoring -> resolved with resolution succeeds
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "resolved", "resolution": "Applied hotfix patch to ingress"}, headers=self.op_headers)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json()["status"], "resolved")
self.assertEqual(res.json()["resolution"], "Applied hotfix patch to ingress")
# 6. resolved -> closed succeeds
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "closed"}, headers=self.op_headers)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json()["status"], "closed")
self.assertIsNotNone(res.json()["closed_at"])
self.assertEqual(res.json()["closed_by_identity"], self.op_identity)
def test_reopen_closed_incident_requires_owner(self):
# Create and close incident
res = self.client.post("/api/v1/ops/incidents", json={"title": "Reopen Test", "summary": "Sum", "severity": "medium"}, headers=self.op_headers)
case_ref = res.json()["case_reference"]
self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "investigating"}, headers=self.op_headers)
self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "closed", "resolution": "Fix verified"}, headers=self.op_headers)
# Operator attempt to reopen -> 403 Forbidden
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "investigating"}, headers=self.op_headers)
self.assertEqual(res.status_code, 403)
self.assertIn("Only Owners may reopen", res.json()["detail"])
# Owner attempt to reopen -> 200 OK
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "investigating"}, headers=self.owner_headers)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json()["status"], "investigating")
self.assertIsNone(res.json()["closed_at"])
self.assertIsNone(res.json()["closed_by_identity"])
def test_archive_incident_requires_owner_and_locks_modifications(self):
res = self.client.post("/api/v1/ops/incidents", json={"title": "Archive Test", "summary": "Sum", "severity": "low"}, headers=self.op_headers)
case_ref = res.json()["case_reference"]
# Operator attempt to archive -> 403 Forbidden
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "archived"}, headers=self.op_headers)
self.assertEqual(res.status_code, 403)
self.assertIn("Only Owners may archive", res.json()["detail"])
# Owner archives -> 200 OK
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "archived"}, headers=self.owner_headers)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.json()["status"], "archived")
# Any subsequent patch on archived incident -> 409 Conflict
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"severity": "high"}, headers=self.owner_headers)
self.assertEqual(res.status_code, 409)
# Note creation on archived incident -> 409 Conflict
res = self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json={"body": "Post-archive note"}, headers=self.owner_headers)
self.assertEqual(res.status_code, 409)
def test_invalid_status_transition_rejected(self):
res = self.client.post("/api/v1/ops/incidents", json={"title": "Invalid Transition", "summary": "Sum", "severity": "low"}, headers=self.op_headers)
case_ref = res.json()["case_reference"]
# open -> monitoring is disallowed (must go through investigating)
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "monitoring"}, headers=self.op_headers)
self.assertEqual(res.status_code, 409)
# ============================================================
# 6. Append-Only Notes Tests
# ============================================================
def test_append_only_notes_chronology(self):
res = self.client.post("/api/v1/ops/incidents", json={"title": "Notes Chronology", "summary": "Sum", "severity": "high"}, headers=self.op_headers)
case_ref = res.json()["case_reference"]
# Add Note 1 (by operator)
n1 = self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json={"body": "Initial triage complete."}, headers=self.op_headers)
self.assertEqual(n1.status_code, 201)
self.assertEqual(n1.json()["created_by_identity"], self.op_identity)
# Add Note 2 (by owner)
n2 = self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json={"body": "Infrastructure team engaged."}, headers=self.owner_headers)
self.assertEqual(n2.status_code, 201)
self.assertEqual(n2.json()["created_by_identity"], self.owner_identity)
# Fetch notes list -> returned in reverse chronological order (newest first)
notes_res = self.client.get(f"/api/v1/ops/incidents/{case_ref}/notes", headers=self.op_headers)
self.assertEqual(notes_res.status_code, 200)
notes = notes_res.json()["notes"]
self.assertEqual(len(notes), 2)
self.assertEqual(notes[0]["body"], "Infrastructure team engaged.")
self.assertEqual(notes[0]["created_by_identity"], self.owner_identity)
self.assertEqual(notes[1]["body"], "Initial triage complete.")
self.assertEqual(notes[1]["created_by_identity"], self.op_identity)
# ============================================================
# 7. Transactional Audit Logging Tests
# ============================================================
def test_audit_events_recorded_on_incident_lifecycle(self):
# Create incident
res = self.client.post("/api/v1/ops/incidents", json={"title": "Audit Lifecycle", "summary": "Sum", "severity": "medium"}, headers=self.op_headers)
case_ref = res.json()["case_reference"]
# Change severity
self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"severity": "critical"}, headers=self.op_headers)
# Add note
self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json={"body": "Investigating spike in 5xx errors"}, headers=self.op_headers)
# Transition status
self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "investigating"}, headers=self.op_headers)
# Check audit events recorded in database
with DbSession(self.engine) as db:
events = db.scalars(
select(OpsAuditEvent)
.where(OpsAuditEvent.case_reference == case_ref)
.order_by(OpsAuditEvent.timestamp.asc())
).all()
actions = [e.action for e in events]
self.assertIn("incident.created", actions)
self.assertIn("incident.severity_changed", actions)
self.assertIn("incident.note_added", actions)
self.assertIn("incident.status_changed", actions)
# Verify actor identity and safe metadata
for e in events:
self.assertEqual(e.actor_identity, self.op_identity)
self.assertEqual(e.target_resource, "ops.incident")
self.assertEqual(e.result, "success")
# ============================================================
# 8. Audit-Failure Rollback Tests (P1 Critical Requirement)
# ============================================================
def test_audit_failure_rolls_back_incident_creation(self):
"""If ops_audit fails during incident creation, database transaction rolls back completely."""
payload = {"title": "Rollback Create Incident", "summary": "Should never persist", "severity": "high"}
# Force ops_audit to raise RuntimeError
with patch("main.ops_audit", side_effect=RuntimeError("Audit persistence failure!")):
res = self.client.post("/api/v1/ops/incidents", json=payload, headers=self.op_headers)
self.assertEqual(res.status_code, 500)
# Assert ZERO records were committed to ops.incidents
with DbSession(self.engine) as db:
incident = db.scalar(select(OpsIncident).where(OpsIncident.title == "Rollback Create Incident"))
self.assertIsNone(incident, "Incident must NOT be committed if audit fails")
def test_audit_failure_rolls_back_note_creation(self):
"""If ops_audit fails during note creation, database transaction rolls back completely."""
res = self.client.post("/api/v1/ops/incidents", json={"title": "Rollback Note Test", "summary": "Sum", "severity": "low"}, headers=self.op_headers)
case_ref = res.json()["case_reference"]
with patch("main.ops_audit", side_effect=RuntimeError("Audit disk full!")):
res = self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json={"body": "Phantom note"}, headers=self.op_headers)
self.assertEqual(res.status_code, 500)
with DbSession(self.engine) as db:
notes = db.scalars(select(OpsIncidentNote).where(OpsIncidentNote.body == "Phantom note")).all()
self.assertEqual(len(notes), 0, "Note must NOT be committed if audit fails")
def test_audit_failure_rolls_back_status_patch(self):
"""If ops_audit fails during status mutation, database transaction rolls back completely."""
res = self.client.post("/api/v1/ops/incidents", json={"title": "Rollback Patch Test", "summary": "Sum", "severity": "medium"}, headers=self.op_headers)
case_ref = res.json()["case_reference"]
with patch("main.ops_audit", side_effect=RuntimeError("Audit stream broken!")):
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "investigating"}, headers=self.op_headers)
self.assertEqual(res.status_code, 500)
with DbSession(self.engine) as db:
incident = db.scalar(select(OpsIncident).where(OpsIncident.case_reference == case_ref))
self.assertIsNotNone(incident)
self.assertEqual(incident.status, "open", "Status must remain 'open' following audit failure rollback")
# ============================================================
# 9. Foundation Security Invariants Tests
# ============================================================
def test_session_revocation_endpoint(self):
"""DELETE /api/v1/ops/sessions/current revokes active session and logs audit event."""
res = self.client.delete("/api/v1/ops/sessions/current", headers=self.op_headers)
self.assertEqual(res.status_code, 204)
# Subsequent request with revoked session fails
res2 = self.client.get("/api/v1/ops/incidents", headers=self.op_headers)
self.assertEqual(res2.status_code, 401)
if __name__ == "__main__":
unittest.main()

View File

@ -12,11 +12,12 @@ import {
interface AdminPageProps {
searchParams?: Promise<{
nav?: string;
case?: string;
}>;
}
function parseNavParam(nav?: string): OperationsNavId {
if (nav === 'audit' || nav === 'operators' || nav === 'session') {
if (nav === 'incidents' || nav === 'audit' || nav === 'operators' || nav === 'session') {
return nav;
}
return 'overview';
@ -25,9 +26,10 @@ function parseNavParam(nav?: string): OperationsNavId {
export default async function AdminPage({ searchParams }: AdminPageProps) {
const resolvedParams = searchParams ? await searchParams : {};
const initialNav = parseNavParam(resolvedParams.nav);
const initialCaseRef = typeof resolvedParams.case === 'string' ? resolvedParams.case : null;
const cookieStore = await cookies();
const isProduction = process.env.NODE_ENV === 'production';
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
const opsToken = isProduction
? cookieStore.get('__Host-motorsignal_ops')?.value
: cookieStore.get('__Host-motorsignal_ops')?.value ??
@ -148,6 +150,7 @@ export default async function AdminPage({ searchParams }: AdminPageProps) {
total: auditTotal,
}}
initialNav={initialNav}
initialCaseRef={initialCaseRef}
/>
);
} catch {

View File

@ -0,0 +1,173 @@
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import { sanitizeIncidentNote } from '../../../../../../lib/ops-data';
import { hasValidOrigin } from '../../../../../../lib/request-origin';
import { SafeIncidentNote } from '../../../../../../components/ops/types';
interface RouteContext {
params: Promise<{
caseRef: string;
}>;
}
export async function GET(request: NextRequest, context: RouteContext) {
const cookieStore = await cookies();
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
const opsToken = isProduction
? cookieStore.get('__Host-motorsignal_ops')?.value
: cookieStore.get('__Host-motorsignal_ops')?.value ??
cookieStore.get('motorsignal_ops')?.value;
if (!opsToken) {
return NextResponse.json(
{ detail: 'Authentication required.' },
{ status: 401 }
);
}
const { caseRef } = await context.params;
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
try {
const response = await fetch(`${baseUrl}/api/v1/ops/incidents/${encodeURIComponent(caseRef)}/notes`, {
method: 'GET',
headers: {
authorization: `Bearer ${opsToken}`,
accept: 'application/json',
},
cache: 'no-store',
});
if (response.status === 401) {
return NextResponse.json(
{ detail: 'Your Operations session has expired.' },
{ status: 401 }
);
}
if (response.status === 403) {
return NextResponse.json(
{ detail: 'You do not have authority to view incident notes.' },
{ status: 403 }
);
}
if (response.status === 404) {
return NextResponse.json(
{ detail: 'Incident not found.' },
{ status: 404 }
);
}
if (!response.ok) {
return NextResponse.json(
{ detail: 'Operations is temporarily unavailable.' },
{ status: response.status >= 500 ? response.status : 503 }
);
}
const data = await response.json();
const rawNotes = Array.isArray(data?.notes) ? data.notes : [];
const notes: SafeIncidentNote[] = rawNotes
.map(sanitizeIncidentNote)
.filter((n: SafeIncidentNote | null): n is SafeIncidentNote => n !== null);
return NextResponse.json({ notes });
} catch {
return NextResponse.json(
{ detail: 'Unable to connect to Operations platform.' },
{ status: 503 }
);
}
}
export async function POST(request: NextRequest, context: RouteContext) {
if (!hasValidOrigin(request)) {
return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 });
}
const cookieStore = await cookies();
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
const opsToken = isProduction
? cookieStore.get('__Host-motorsignal_ops')?.value
: cookieStore.get('__Host-motorsignal_ops')?.value ??
cookieStore.get('motorsignal_ops')?.value;
if (!opsToken) {
return NextResponse.json(
{ detail: 'Authentication required.' },
{ status: 401 }
);
}
const { caseRef } = await context.params;
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ detail: 'Invalid request body.' },
{ status: 422 }
);
}
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
try {
const response = await fetch(`${baseUrl}/api/v1/ops/incidents/${encodeURIComponent(caseRef)}/notes`, {
method: 'POST',
headers: {
authorization: `Bearer ${opsToken}`,
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify(body),
cache: 'no-store',
});
if (response.status === 401) {
return NextResponse.json(
{ detail: 'Your Operations session has expired.' },
{ status: 401 }
);
}
if (response.status === 403) {
return NextResponse.json(
{ detail: 'You do not have authority to add notes.' },
{ status: 403 }
);
}
if (response.status === 404) {
return NextResponse.json(
{ detail: 'Incident not found.' },
{ status: 404 }
);
}
if (response.status === 409) {
const data = await response.json().catch(() => null);
return NextResponse.json(
{ detail: data?.detail ?? 'Archived incidents cannot be modified.' },
{ status: 409 }
);
}
if (response.status === 422) {
const data = await response.json().catch(() => null);
return NextResponse.json(
{ detail: data?.detail ?? 'Review the note fields and try again.' },
{ status: 422 }
);
}
if (!response.ok) {
return NextResponse.json(
{ detail: 'Operations is temporarily unavailable.' },
{ status: response.status >= 500 ? response.status : 503 }
);
}
const data = await response.json();
const sanitized = sanitizeIncidentNote(data);
return NextResponse.json(sanitized ?? data, { status: 201 });
} catch {
return NextResponse.json(
{ detail: 'Unable to connect to Operations platform.' },
{ status: 503 }
);
}
}

View File

@ -0,0 +1,176 @@
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import { sanitizeIncidentDetail } from '../../../../../lib/ops-data';
import { hasValidOrigin } from '../../../../../lib/request-origin';
interface RouteContext {
params: Promise<{
caseRef: string;
}>;
}
export async function GET(request: NextRequest, context: RouteContext) {
const cookieStore = await cookies();
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
const opsToken = isProduction
? cookieStore.get('__Host-motorsignal_ops')?.value
: cookieStore.get('__Host-motorsignal_ops')?.value ??
cookieStore.get('motorsignal_ops')?.value;
if (!opsToken) {
return NextResponse.json(
{ detail: 'Authentication required.' },
{ status: 401 }
);
}
const { caseRef } = await context.params;
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
try {
const response = await fetch(`${baseUrl}/api/v1/ops/incidents/${encodeURIComponent(caseRef)}`, {
method: 'GET',
headers: {
authorization: `Bearer ${opsToken}`,
accept: 'application/json',
},
cache: 'no-store',
});
if (response.status === 401) {
return NextResponse.json(
{ detail: 'Your Operations session has expired.' },
{ status: 401 }
);
}
if (response.status === 403) {
return NextResponse.json(
{ detail: 'You do not have authority to view this Incident.' },
{ status: 403 }
);
}
if (response.status === 404) {
return NextResponse.json(
{ detail: 'Incident not found.' },
{ status: 404 }
);
}
if (!response.ok) {
return NextResponse.json(
{ detail: 'Operations is temporarily unavailable.' },
{ status: response.status >= 500 ? response.status : 503 }
);
}
const data = await response.json();
const sanitized = sanitizeIncidentDetail(data);
if (!sanitized) {
return NextResponse.json(
{ detail: 'Invalid incident response from backend.' },
{ status: 502 }
);
}
return NextResponse.json(sanitized);
} catch {
return NextResponse.json(
{ detail: 'Unable to connect to Operations platform.' },
{ status: 503 }
);
}
}
export async function PATCH(request: NextRequest, context: RouteContext) {
if (!hasValidOrigin(request)) {
return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 });
}
const cookieStore = await cookies();
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
const opsToken = isProduction
? cookieStore.get('__Host-motorsignal_ops')?.value
: cookieStore.get('__Host-motorsignal_ops')?.value ??
cookieStore.get('motorsignal_ops')?.value;
if (!opsToken) {
return NextResponse.json(
{ detail: 'Authentication required.' },
{ status: 401 }
);
}
const { caseRef } = await context.params;
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ detail: 'Invalid request body.' },
{ status: 422 }
);
}
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
try {
const response = await fetch(`${baseUrl}/api/v1/ops/incidents/${encodeURIComponent(caseRef)}`, {
method: 'PATCH',
headers: {
authorization: `Bearer ${opsToken}`,
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify(body),
cache: 'no-store',
});
if (response.status === 401) {
return NextResponse.json(
{ detail: 'Your Operations session has expired.' },
{ status: 401 }
);
}
if (response.status === 403) {
const data = await response.json().catch(() => null);
return NextResponse.json(
{ detail: data?.detail ?? 'You do not have authority for this action.' },
{ status: 403 }
);
}
if (response.status === 404) {
return NextResponse.json(
{ detail: 'Incident not found.' },
{ status: 404 }
);
}
if (response.status === 409) {
const data = await response.json().catch(() => null);
return NextResponse.json(
{ detail: data?.detail ?? 'That status transition is not permitted.' },
{ status: 409 }
);
}
if (response.status === 422) {
const data = await response.json().catch(() => null);
return NextResponse.json(
{ detail: data?.detail ?? 'Review the incident fields and try again.' },
{ status: 422 }
);
}
if (!response.ok) {
return NextResponse.json(
{ detail: 'Operations is temporarily unavailable.' },
{ status: response.status >= 500 ? response.status : 503 }
);
}
const data = await response.json();
const sanitized = sanitizeIncidentDetail(data);
return NextResponse.json(sanitized ?? data);
} catch {
return NextResponse.json(
{ detail: 'Unable to connect to Operations platform.' },
{ status: 503 }
);
}
}

View File

@ -0,0 +1,158 @@
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';
import { sanitizeIncidentSummary } from '../../../../lib/ops-data';
import { hasValidOrigin } from '../../../../lib/request-origin';
import { SafeIncidentSummary } from '../../../../components/ops/types';
export async function GET(request: NextRequest) {
const cookieStore = await cookies();
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
const opsToken = isProduction
? cookieStore.get('__Host-motorsignal_ops')?.value
: cookieStore.get('__Host-motorsignal_ops')?.value ??
cookieStore.get('motorsignal_ops')?.value;
if (!opsToken) {
return NextResponse.json(
{ detail: 'Authentication required.' },
{ status: 401 }
);
}
const searchParams = request.nextUrl.searchParams;
const status = searchParams.get('status');
const severity = searchParams.get('severity');
const caseRef = searchParams.get('case_reference');
const cursor = searchParams.get('cursor');
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
const targetUrl = new URL(`${baseUrl}/api/v1/ops/incidents`);
if (status) targetUrl.searchParams.set('status', status);
if (severity) targetUrl.searchParams.set('severity', severity);
if (caseRef) targetUrl.searchParams.set('case_reference', caseRef);
if (cursor) targetUrl.searchParams.set('cursor', cursor);
try {
const response = await fetch(targetUrl.toString(), {
method: 'GET',
headers: {
authorization: `Bearer ${opsToken}`,
accept: 'application/json',
},
cache: 'no-store',
});
if (response.status === 401) {
return NextResponse.json(
{ detail: 'Your Operations session has expired.' },
{ status: 401 }
);
}
if (response.status === 403) {
return NextResponse.json(
{ detail: 'You do not have authority to view Incidents.' },
{ status: 403 }
);
}
if (!response.ok) {
return NextResponse.json(
{ detail: 'Operations is temporarily unavailable.' },
{ status: response.status >= 500 ? response.status : 503 }
);
}
const data = await response.json();
const rawItems = Array.isArray(data?.items) ? data.items : [];
const items: SafeIncidentSummary[] = rawItems
.map(sanitizeIncidentSummary)
.filter((i: SafeIncidentSummary | null): i is SafeIncidentSummary => i !== null);
return NextResponse.json({
items,
next_cursor: data?.next_cursor ?? null,
});
} catch {
return NextResponse.json(
{ detail: 'Unable to connect to Operations platform.' },
{ status: 503 }
);
}
}
export async function POST(request: NextRequest) {
if (!hasValidOrigin(request)) {
return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 });
}
const cookieStore = await cookies();
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
const opsToken = isProduction
? cookieStore.get('__Host-motorsignal_ops')?.value
: cookieStore.get('__Host-motorsignal_ops')?.value ??
cookieStore.get('motorsignal_ops')?.value;
if (!opsToken) {
return NextResponse.json(
{ detail: 'Authentication required.' },
{ status: 401 }
);
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json(
{ detail: 'Invalid request body.' },
{ status: 422 }
);
}
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
try {
const response = await fetch(`${baseUrl}/api/v1/ops/incidents`, {
method: 'POST',
headers: {
authorization: `Bearer ${opsToken}`,
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify(body),
cache: 'no-store',
});
if (response.status === 401) {
return NextResponse.json(
{ detail: 'Your Operations session has expired.' },
{ status: 401 }
);
}
if (response.status === 403) {
return NextResponse.json(
{ detail: 'You do not have authority to create Incidents.' },
{ status: 403 }
);
}
if (response.status === 422) {
const data = await response.json().catch(() => null);
return NextResponse.json(
{ detail: data?.detail ?? 'Invalid incident fields.' },
{ status: 422 }
);
}
if (!response.ok) {
return NextResponse.json(
{ detail: 'Operations is temporarily unavailable.' },
{ status: response.status >= 500 ? response.status : 503 }
);
}
const data = await response.json();
return NextResponse.json(data, { status: 201 });
} catch {
return NextResponse.json(
{ detail: 'Unable to connect to Operations platform.' },
{ status: 503 }
);
}
}

View File

@ -0,0 +1,212 @@
'use client';
import React, { useState } from 'react';
import { IncidentSeverity } from './types';
interface OperationsCreateIncidentProps {
onIncidentCreated: (caseRef: string) => void;
onCancel: () => void;
}
export function OperationsCreateIncident({
onIncidentCreated,
onCancel,
}: OperationsCreateIncidentProps) {
const [title, setTitle] = useState('');
const [summary, setSummary] = useState('');
const [severity, setSeverity] = useState<IncidentSeverity>('medium');
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) {
setErrorMessage('Incident title is required.');
return;
}
if (!summary.trim()) {
setErrorMessage('Incident summary is required.');
return;
}
setIsSubmitting(true);
setErrorMessage(null);
try {
const response = await fetch('/api/ops/incidents', {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify({
title: title.trim(),
summary: summary.trim(),
severity,
}),
});
if (response.status === 401) {
setErrorMessage('Your Operations session has expired.');
setIsSubmitting(false);
return;
}
if (response.status === 403) {
setErrorMessage('You do not have authority to create incidents.');
setIsSubmitting(false);
return;
}
if (response.status === 422) {
const data = await response.json().catch(() => null);
setErrorMessage(data?.detail ?? 'Please check the incident fields and try again.');
setIsSubmitting(false);
return;
}
if (!response.ok) {
setErrorMessage('Operations platform is temporarily unavailable.');
setIsSubmitting(false);
return;
}
const data = await response.json();
if (data?.case_reference) {
onIncidentCreated(data.case_reference);
} else {
onCancel();
}
} catch {
setErrorMessage('Unable to connect to Operations platform.');
setIsSubmitting(false);
}
}
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between border-b border-outline-variant/20 pb-4">
<div>
<h1 className="text-xl font-headline-sm font-bold tracking-tight text-on-surface uppercase">
Create Operational Incident
</h1>
<p className="text-xs text-on-surface-variant mt-0.5">
Case reference and open status will be generated automatically.
</p>
</div>
<button
type="button"
onClick={onCancel}
className="px-3 py-1.5 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-medium text-on-surface-variant hover:text-on-surface transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
>
Cancel
</button>
</div>
{/* Error Alert */}
{errorMessage && (
<div className="p-4 rounded-xl bg-error/10 border border-error/30 text-error flex items-center gap-3 text-xs">
<span className="material-symbols-outlined text-[18px]">error</span>
<span>{errorMessage}</span>
</div>
)}
{/* Form Card */}
<form onSubmit={handleSubmit} className="bg-surface-container-low p-6 rounded-xl border border-outline-variant/30 space-y-5">
{/* Title */}
<div className="space-y-1.5">
<label htmlFor="incident-title" className="block text-xs font-semibold text-on-surface">
Incident Title <span className="text-error">*</span>
</label>
<input
id="incident-title"
type="text"
required
maxLength={255}
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Ingress latency spike on edge cluster"
className="w-full bg-surface-container px-3.5 py-2 rounded-lg border border-outline-variant/40 text-xs text-on-surface placeholder:text-outline focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
/>
</div>
{/* Severity Selection */}
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-on-surface">
Initial Severity <span className="text-error">*</span>
</label>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5">
{(['low', 'medium', 'high', 'critical'] as IncidentSeverity[]).map((sev) => {
const isSelected = severity === sev;
return (
<button
key={sev}
type="button"
onClick={() => setSeverity(sev)}
className={`px-3 py-2.5 rounded-lg border text-xs font-mono font-bold uppercase transition-all flex flex-col items-center gap-1 focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none ${
isSelected
? sev === 'critical'
? 'bg-error/20 border-error text-error shadow-[0_0_12px_rgba(255,100,100,0.2)]'
: sev === 'high'
? 'bg-amber-500/20 border-amber-500 text-amber-300'
: sev === 'medium'
? 'bg-primary-container/20 border-primary-container text-primary-container'
: 'bg-surface-container-high border-outline text-on-surface'
: 'bg-surface-container border-outline-variant/30 text-outline hover:text-on-surface hover:bg-surface-container-high'
}`}
>
<span>{sev}</span>
</button>
);
})}
</div>
</div>
{/* Summary */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label htmlFor="incident-summary" className="block text-xs font-semibold text-on-surface">
Summary & Symptoms <span className="text-error">*</span>
</label>
<span className="text-[10px] font-mono text-outline">
{summary.length} / 4000
</span>
</div>
<textarea
id="incident-summary"
required
rows={5}
maxLength={4000}
value={summary}
onChange={(e) => setSummary(e.target.value)}
placeholder="Describe the incident symptoms, initial scope, and observed system behavior..."
className="w-full bg-surface-container px-3.5 py-2.5 rounded-lg border border-outline-variant/40 text-xs text-on-surface placeholder:text-outline focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none resize-y"
/>
</div>
{/* Actions Bar */}
<div className="flex items-center justify-end gap-3 pt-3 border-t border-outline-variant/20">
<button
type="button"
onClick={onCancel}
disabled={isSubmitting}
className="px-4 py-2 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-semibold text-on-surface-variant hover:text-on-surface transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className="flex items-center gap-2 px-5 py-2 rounded-lg bg-primary-container text-on-primary-container text-xs font-bold shadow-md hover:bg-primary-container/90 transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none disabled:opacity-50"
>
{isSubmitting && (
<span className="material-symbols-outlined text-[16px] animate-spin">
progress_activity
</span>
)}
<span>{isSubmitting ? 'Creating...' : 'Create Incident'}</span>
</button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,797 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
SafeIncidentDetail,
SafeIncidentNote,
IncidentSeverity,
IncidentStatus,
OperationsRole,
} from './types';
import {
OperationsIncidentModal,
IncidentModalAction,
} from './operations-incident-modal';
import { isValidStatusTransition } from '../../lib/ops-data';
interface OperationsIncidentDetailProps {
caseReference: string;
role: OperationsRole;
onBack: () => void;
}
export function OperationsIncidentDetail({
caseReference,
role,
onBack,
}: OperationsIncidentDetailProps) {
const isOwner = role === 'owner';
const [incident, setIncident] = useState<SafeIncidentDetail | null>(null);
const [notes, setNotes] = useState<SafeIncidentNote[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Note form state
const [newNoteBody, setNewNoteBody] = useState('');
const [isAddingNote, setIsAddingNote] = useState(false);
const [noteError, setNoteError] = useState<string | null>(null);
// Mutation state
const [isMutating, setIsMutating] = useState(false);
const [mutationSuccess, setMutationSuccess] = useState<string | null>(null);
// Confirmation Modal state
const [modalOpen, setModalOpen] = useState(false);
const [modalAction, setModalAction] = useState<IncidentModalAction>('close');
const [isSubmittingModal, setIsSubmittingModal] = useState(false);
async function fetchIncidentData() {
setIsLoading(true);
setErrorMessage(null);
try {
const [detailRes, notesRes] = await Promise.all([
fetch(`/api/ops/incidents/${encodeURIComponent(caseReference)}`, {
method: 'GET',
headers: { accept: 'application/json' },
cache: 'no-store',
}),
fetch(`/api/ops/incidents/${encodeURIComponent(caseReference)}/notes`, {
method: 'GET',
headers: { accept: 'application/json' },
cache: 'no-store',
}),
]);
if (detailRes.status === 401 || notesRes.status === 401) {
setErrorMessage('Your Operations session has expired.');
setIsLoading(false);
return;
}
if (detailRes.status === 403 || notesRes.status === 403) {
setErrorMessage('You do not have authority to view this incident.');
setIsLoading(false);
return;
}
if (detailRes.status === 404) {
setErrorMessage('Incident not found.');
setIsLoading(false);
return;
}
if (!detailRes.ok) {
setErrorMessage('Failed to load incident detail.');
setIsLoading(false);
return;
}
const detailData = (await detailRes.json()) as SafeIncidentDetail;
setIncident(detailData);
if (notesRes.ok) {
const notesData = await notesRes.json();
setNotes(Array.isArray(notesData?.notes) ? notesData.notes : []);
}
} catch {
setErrorMessage('Unable to connect to Operations platform.');
} finally {
setIsLoading(false);
}
}
useEffect(() => {
fetchIncidentData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [caseReference]);
// Handle Add Note
async function handleAddNote(e: React.FormEvent) {
e.preventDefault();
if (!newNoteBody.trim()) {
setNoteError('Note body cannot be empty.');
return;
}
setIsAddingNote(true);
setNoteError(null);
try {
const res = await fetch(`/api/ops/incidents/${encodeURIComponent(caseReference)}/notes`, {
method: 'POST',
headers: {
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify({ body: newNoteBody.trim() }),
});
if (res.status === 401) {
setNoteError('Your Operations session has expired.');
setIsAddingNote(false);
return;
}
if (res.status === 403) {
setNoteError('You do not have authority to add notes.');
setIsAddingNote(false);
return;
}
if (res.status === 409) {
setNoteError('Archived incidents cannot be modified.');
setIsAddingNote(false);
return;
}
if (!res.ok) {
setNoteError('Failed to append note.');
setIsAddingNote(false);
return;
}
const addedNote = (await res.json()) as SafeIncidentNote;
setNotes((prev) => [addedNote, ...prev]);
setNewNoteBody('');
} catch {
setNoteError('Unable to connect to Operations platform.');
} finally {
setIsAddingNote(false);
}
}
// Handle Severity Change
async function handleSeverityChange(newSeverity: IncidentSeverity) {
if (!incident || incident.severity === newSeverity || isMutating) return;
setIsMutating(true);
setMutationSuccess(null);
setErrorMessage(null);
try {
const res = await fetch(`/api/ops/incidents/${encodeURIComponent(caseReference)}`, {
method: 'PATCH',
headers: {
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify({ severity: newSeverity }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
setErrorMessage(data?.detail ?? 'Failed to update severity.');
setIsMutating(false);
return;
}
const updated = (await res.json()) as SafeIncidentDetail;
setIncident(updated);
setMutationSuccess(`Severity updated to ${newSeverity.toUpperCase()}`);
setTimeout(() => setMutationSuccess(null), 4000);
} catch {
setErrorMessage('Unable to connect to Operations platform.');
} finally {
setIsMutating(false);
}
}
// Handle Status Transition via Select
async function handleStatusChange(targetStatus: IncidentStatus) {
if (!incident || incident.status === targetStatus || isMutating) return;
// If target status is closed, route to modal to capture resolution
if (targetStatus === 'closed') {
setModalAction('close');
setModalOpen(true);
return;
}
// If target status is archived, route to modal
if (targetStatus === 'archived') {
setModalAction('archive');
setModalOpen(true);
return;
}
// If target status is resolved without existing resolution, prompt for modal
if (targetStatus === 'resolved' && (!incident.resolution || !incident.resolution.trim())) {
setModalAction('close');
setModalOpen(true);
return;
}
setIsMutating(true);
setMutationSuccess(null);
setErrorMessage(null);
try {
const res = await fetch(`/api/ops/incidents/${encodeURIComponent(caseReference)}`, {
method: 'PATCH',
headers: {
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify({ status: targetStatus }),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
setErrorMessage(data?.detail ?? 'Failed to update incident status.');
setIsMutating(false);
return;
}
const updated = (await res.json()) as SafeIncidentDetail;
setIncident(updated);
setMutationSuccess(`Status transitioned to ${targetStatus.toUpperCase()}`);
setTimeout(() => setMutationSuccess(null), 4000);
} catch {
setErrorMessage('Unable to connect to Operations platform.');
} finally {
setIsMutating(false);
}
}
// Handle Modal Confirmation
async function handleModalConfirm(modalResolution?: string) {
if (!incident || isSubmittingModal) return;
setIsSubmittingModal(true);
setErrorMessage(null);
let patchPayload: Record<string, string> = {};
if (modalAction === 'close') {
patchPayload = {
status: 'closed',
resolution: modalResolution ?? '',
};
} else if (modalAction === 'reopen') {
patchPayload = { status: 'investigating' };
} else if (modalAction === 'archive') {
patchPayload = { status: 'archived' };
}
try {
const res = await fetch(`/api/ops/incidents/${encodeURIComponent(caseReference)}`, {
method: 'PATCH',
headers: {
'content-type': 'application/json',
accept: 'application/json',
},
body: JSON.stringify(patchPayload),
});
if (!res.ok) {
const data = await res.json().catch(() => null);
setErrorMessage(data?.detail ?? `Failed to perform ${modalAction} action.`);
setIsSubmittingModal(false);
setModalOpen(false);
return;
}
const updated = (await res.json()) as SafeIncidentDetail;
setIncident(updated);
setModalOpen(false);
setMutationSuccess(`Incident successfully ${modalAction === 'close' ? 'closed' : modalAction === 'reopen' ? 'reopened' : 'archived'}.`);
setTimeout(() => setMutationSuccess(null), 4000);
} catch {
setErrorMessage('Unable to connect to Operations platform.');
} finally {
setIsSubmittingModal(false);
}
}
function formatDate(iso?: string | null) {
if (!iso) return '—';
try {
const d = new Date(iso);
return d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
} catch {
return iso;
}
}
function getSeverityBadge(severity: IncidentSeverity) {
switch (severity) {
case 'critical':
return (
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-bold tracking-wider bg-error/15 text-error border border-error/30">
CRITICAL
</span>
);
case 'high':
return (
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-bold tracking-wider bg-amber-500/15 text-amber-400 border border-amber-500/30">
HIGH
</span>
);
case 'medium':
return (
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-medium tracking-wider bg-primary-container/15 text-primary-container border border-primary-container/30">
MEDIUM
</span>
);
case 'low':
default:
return (
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-medium tracking-wider bg-surface-container text-on-surface-variant border border-outline-variant/30">
LOW
</span>
);
}
}
function getStatusBadge(status: IncidentStatus) {
switch (status) {
case 'open':
return (
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-bold tracking-wider bg-blue-500/15 text-blue-400 border border-blue-500/30">
OPEN
</span>
);
case 'investigating':
return (
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-bold tracking-wider bg-amber-500/15 text-amber-300 border border-amber-500/30">
INVESTIGATING
</span>
);
case 'monitoring':
return (
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-bold tracking-wider bg-purple-500/15 text-purple-300 border border-purple-500/30">
MONITORING
</span>
);
case 'resolved':
return (
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-bold tracking-wider bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
RESOLVED
</span>
);
case 'closed':
return (
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-medium tracking-wider bg-surface-container-high text-outline border border-outline-variant/30">
CLOSED
</span>
);
case 'archived':
default:
return (
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-medium tracking-wider bg-surface-container-highest/60 text-outline border border-outline-variant/20">
ARCHIVED
</span>
);
}
}
if (isLoading) {
return (
<div className="p-16 text-center text-outline bg-surface-container-lowest rounded-xl border border-outline-variant/20 flex flex-col items-center justify-center gap-3">
<span className="material-symbols-outlined text-3xl animate-spin text-primary-container">
progress_activity
</span>
<span className="text-xs font-medium">Loading incident details...</span>
</div>
);
}
if (!incident) {
return (
<div className="space-y-4">
<button
type="button"
onClick={onBack}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-semibold text-on-surface hover:bg-surface-container-high transition-colors"
>
← Back to Incidents
</button>
<div className="p-8 text-center text-error bg-error/10 border border-error/30 rounded-xl text-xs">
{errorMessage ?? 'Incident not found.'}
</div>
</div>
);
}
const isArchived = incident.status === 'archived';
const isClosed = incident.status === 'closed';
return (
<div className="space-y-6">
{/* Navigation & Actions Topbar */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-outline-variant/20 pb-4">
<div className="flex items-center gap-3">
<button
type="button"
onClick={onBack}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-semibold text-on-surface hover:bg-surface-container-high transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
>
← Back
</button>
<span className="font-mono font-bold text-sm text-primary-container">
{incident.case_reference}
</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => fetchIncidentData()}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-medium text-on-surface-variant hover:text-on-surface transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
>
<span className="material-symbols-outlined text-[16px]">refresh</span>
<span>Refresh</span>
</button>
{/* Quick Modal Triggers */}
{!isArchived && !isClosed && (
<button
type="button"
onClick={() => {
setModalAction('close');
setModalOpen(true);
}}
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-surface-container-high border border-outline-variant/40 text-xs font-semibold text-on-surface hover:bg-surface-container-highest transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
>
<span className="material-symbols-outlined text-[16px] text-emerald-400">
task_alt
</span>
<span>Close Incident...</span>
</button>
)}
{isClosed && isOwner && (
<button
type="button"
onClick={() => {
setModalAction('reopen');
setModalOpen(true);
}}
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-amber-500/15 border border-amber-500/30 text-xs font-bold text-amber-300 hover:bg-amber-500/25 transition-all focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:outline-none"
>
<span className="material-symbols-outlined text-[16px]">replay</span>
<span>Reopen (Owner)</span>
</button>
)}
{!isArchived && isOwner && (
<button
type="button"
onClick={() => {
setModalAction('archive');
setModalOpen(true);
}}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-error/10 border border-error/25 text-xs font-semibold text-error hover:bg-error/20 transition-all focus-visible:ring-2 focus-visible:ring-error focus-visible:outline-none"
>
<span className="material-symbols-outlined text-[16px]">archive</span>
<span>Archive (Owner)</span>
</button>
)}
</div>
</div>
{/* Alerts */}
{errorMessage && (
<div className="p-4 rounded-xl bg-error/10 border border-error/30 text-error flex items-center gap-3 text-xs">
<span className="material-symbols-outlined text-[18px]">error</span>
<span>{errorMessage}</span>
</div>
)}
{mutationSuccess && (
<div className="p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 flex items-center gap-3 text-xs">
<span className="material-symbols-outlined text-[18px]">check_circle</span>
<span>{mutationSuccess}</span>
</div>
)}
{/* Incident Header Card */}
<div className="bg-surface-container-low p-6 rounded-2xl border border-outline-variant/30 shadow-sm space-y-4">
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2.5">
<span className="font-mono text-xs font-bold text-primary-fixed-dim uppercase tracking-wider">
{incident.case_reference}
</span>
<div className="flex items-center gap-2">
{getSeverityBadge(incident.severity)}
{getStatusBadge(incident.status)}
</div>
</div>
<h1 className="text-lg font-headline-sm font-bold text-on-surface">
{incident.title}
</h1>
</div>
{/* Mutation Selects (Active incidents only) */}
{!isArchived && (
<div className="flex flex-wrap items-center gap-3 bg-surface-container p-3 rounded-xl border border-outline-variant/30 shrink-0">
{/* Severity Selector */}
<div className="space-y-1">
<span className="block text-[10px] font-mono uppercase text-outline">
Severity
</span>
<select
value={incident.severity}
disabled={isMutating}
onChange={(e) => handleSeverityChange(e.target.value as IncidentSeverity)}
className="bg-surface-container-high px-2.5 py-1 rounded-lg border border-outline-variant/40 text-xs font-mono font-bold text-on-surface focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none disabled:opacity-50"
>
<option value="critical">CRITICAL</option>
<option value="high">HIGH</option>
<option value="medium">MEDIUM</option>
<option value="low">LOW</option>
</select>
</div>
{/* Status Transition Selector */}
<div className="space-y-1">
<span className="block text-[10px] font-mono uppercase text-outline">
Transition Status
</span>
<select
value={incident.status}
disabled={isMutating}
onChange={(e) => handleStatusChange(e.target.value as IncidentStatus)}
className="bg-surface-container-high px-2.5 py-1 rounded-lg border border-outline-variant/40 text-xs font-mono font-bold text-on-surface focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none disabled:opacity-50"
>
<option value={incident.status} disabled>
Current ({incident.status.toUpperCase()})
</option>
{(['open', 'investigating', 'monitoring', 'resolved', 'closed', 'archived'] as IncidentStatus[])
.filter((s) => isValidStatusTransition(incident.status, s, role))
.map((s) => (
<option key={s} value={s}>
→ {s.toUpperCase()}
</option>
))}
</select>
</div>
</div>
)}
</div>
{/* Metadata Grid */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 pt-4 border-t border-outline-variant/20 text-xs">
<div>
<span className="block text-[10px] font-mono uppercase text-outline">Created By</span>
<span className="font-mono text-on-surface-variant break-all">
{incident.created_by_identity}
</span>
</div>
<div>
<span className="block text-[10px] font-mono uppercase text-outline">Created At</span>
<span className="font-mono text-on-surface-variant">
{formatDate(incident.created_at)}
</span>
</div>
<div>
<span className="block text-[10px] font-mono uppercase text-outline">Last Updated</span>
<span className="font-mono text-on-surface-variant">
{formatDate(incident.updated_at)}
</span>
</div>
<div>
<span className="block text-[10px] font-mono uppercase text-outline">Closed</span>
<span className="font-mono text-on-surface-variant">
{incident.closed_at ? formatDate(incident.closed_at) : '—'}
</span>
</div>
</div>
</div>
{/* Summary Card */}
<div className="bg-surface-container-low p-6 rounded-2xl border border-outline-variant/30 space-y-2">
<h2 className="text-xs font-headline-sm font-bold uppercase tracking-wider text-outline">
Incident Summary
</h2>
<div className="text-xs text-on-surface whitespace-pre-wrap leading-relaxed">
{incident.summary}
</div>
</div>
{/* Resolution Card (if present) */}
{incident.resolution && (
<div className="bg-surface-container-low p-6 rounded-2xl border border-emerald-500/30 space-y-2">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-emerald-400 text-[18px]">
task_alt
</span>
<h2 className="text-xs font-headline-sm font-bold uppercase tracking-wider text-emerald-400">
Resolution Record
</h2>
</div>
<div className="text-xs text-on-surface whitespace-pre-wrap leading-relaxed bg-surface-container/60 p-4 rounded-xl border border-outline-variant/20 font-mono">
{incident.resolution}
</div>
</div>
)}
{/* Two Column Layout: Operational Timeline & Append-only Notes */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Append-only Notes Section */}
<div className="bg-surface-container-low p-6 rounded-2xl border border-outline-variant/30 space-y-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-primary-container">
notes
</span>
<h2 className="text-xs font-headline-sm font-bold uppercase tracking-wider text-on-surface">
Operator Notes
</h2>
</div>
<span className="text-[10px] font-mono text-outline">
Append-Only ({notes.length})
</span>
</div>
{/* Add Note Form */}
{!isArchived && (
<form onSubmit={handleAddNote} className="space-y-3 bg-surface-container p-4 rounded-xl border border-outline-variant/30">
<div className="flex items-center justify-between">
<label htmlFor="incident-note-body" className="block text-[11px] font-semibold text-on-surface">
Add Operator Note
</label>
<span className="text-[10px] font-mono text-outline">
{newNoteBody.length} / 4000
</span>
</div>
<textarea
id="incident-note-body"
required
rows={3}
maxLength={4000}
value={newNoteBody}
onChange={(e) => setNewNoteBody(e.target.value)}
placeholder="Record operational observations, actions taken, or debugging findings..."
className="w-full bg-surface-container-high px-3 py-2 rounded-lg border border-outline-variant/40 text-xs text-on-surface placeholder:text-outline focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none resize-y"
/>
{noteError && (
<p className="text-[11px] text-error">{noteError}</p>
)}
<div className="flex justify-end">
<button
type="submit"
disabled={isAddingNote || !newNoteBody.trim()}
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-primary-container text-on-primary-container text-xs font-bold shadow-sm hover:bg-primary-container/90 transition-all disabled:opacity-50"
>
{isAddingNote && (
<span className="material-symbols-outlined text-[14px] animate-spin">
progress_activity
</span>
)}
<span>Add Note</span>
</button>
</div>
</form>
)}
{/* Notes Feed */}
{notes.length === 0 ? (
<p className="text-xs text-outline text-center py-6">
No operator notes recorded for this incident yet.
</p>
) : (
<div className="space-y-3 max-h-96 overflow-y-auto pr-1">
{notes.map((note, idx) => (
<div
key={`${note.created_at}-${idx}`}
className="p-3.5 rounded-xl bg-surface-container/60 border border-outline-variant/20 space-y-1.5 text-xs"
>
<div className="flex items-center justify-between text-[10px] font-mono text-outline">
<span className="text-primary-fixed-dim font-bold">
{note.created_by_identity}
</span>
<span>{formatDate(note.created_at)}</span>
</div>
<div className="text-on-surface whitespace-pre-wrap font-mono text-xs leading-relaxed">
{note.body}
</div>
</div>
))}
</div>
)}
</div>
{/* Operational Timeline Section */}
<div className="bg-surface-container-low p-6 rounded-2xl border border-outline-variant/30 space-y-4">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-[18px] text-primary-container">
history
</span>
<h2 className="text-xs font-headline-sm font-bold uppercase tracking-wider text-on-surface">
Operational Timeline
</h2>
</div>
<div className="relative pl-6 space-y-6 before:absolute before:left-2 before:top-2 before:bottom-2 before:w-0.5 before:bg-outline-variant/30">
{/* Created Event */}
<div className="relative space-y-1">
<span className="absolute -left-6 top-0.5 w-3 h-3 rounded-full bg-blue-500 border-2 border-surface-container-low" />
<div className="flex items-center justify-between text-[10px] font-mono text-outline">
<span className="font-bold text-on-surface uppercase">Incident Opened</span>
<span>{formatDate(incident.created_at)}</span>
</div>
<p className="text-xs text-on-surface-variant">
Created by <span className="font-mono text-primary-container">{incident.created_by_identity}</span> with initial severity <span className="font-mono uppercase font-semibold">{incident.severity}</span>.
</p>
</div>
{/* Notes Count Event */}
{notes.length > 0 && (
<div className="relative space-y-1">
<span className="absolute -left-6 top-0.5 w-3 h-3 rounded-full bg-purple-400 border-2 border-surface-container-low" />
<div className="flex items-center justify-between text-[10px] font-mono text-outline">
<span className="font-bold text-on-surface uppercase">Operator Notes ({notes.length})</span>
<span>{formatDate(notes[0]?.created_at)}</span>
</div>
<p className="text-xs text-on-surface-variant">
{notes.length} append-only operational {notes.length === 1 ? 'note' : 'notes'} recorded.
</p>
</div>
)}
{/* Resolution Event (if present) */}
{incident.resolution && (
<div className="relative space-y-1">
<span className="absolute -left-6 top-0.5 w-3 h-3 rounded-full bg-emerald-400 border-2 border-surface-container-low" />
<div className="flex items-center justify-between text-[10px] font-mono text-outline">
<span className="font-bold text-emerald-400 uppercase">Resolution Documented</span>
<span>{formatDate(incident.updated_at)}</span>
</div>
<p className="text-xs text-on-surface-variant">
Resolution summary recorded for this case.
</p>
</div>
)}
{/* Closed Event (if closed) */}
{incident.closed_at && (
<div className="relative space-y-1">
<span className="absolute -left-6 top-0.5 w-3 h-3 rounded-full bg-outline border-2 border-surface-container-low" />
<div className="flex items-center justify-between text-[10px] font-mono text-outline">
<span className="font-bold text-on-surface uppercase">Incident Closed</span>
<span>{formatDate(incident.closed_at)}</span>
</div>
<p className="text-xs text-on-surface-variant">
Closed by <span className="font-mono text-on-surface">{incident.closed_by_identity ?? 'operator'}</span>.
</p>
</div>
)}
</div>
</div>
</div>
{/* Confirmation Dialog Modal */}
<OperationsIncidentModal
isOpen={modalOpen}
action={modalAction}
caseReference={incident.case_reference}
initialResolution={incident.resolution ?? ''}
isSubmitting={isSubmittingModal}
onConfirm={handleModalConfirm}
onClose={() => setModalOpen(false)}
/>
</div>
);
}

View File

@ -0,0 +1,193 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
export type IncidentModalAction = 'close' | 'reopen' | 'archive';
interface OperationsIncidentModalProps {
isOpen: boolean;
action: IncidentModalAction;
caseReference: string;
initialResolution?: string;
isSubmitting: boolean;
onConfirm: (resolution?: string) => void;
onClose: () => void;
}
export function OperationsIncidentModal({
isOpen,
action,
caseReference,
initialResolution = '',
isSubmitting,
onConfirm,
onClose,
}: OperationsIncidentModalProps) {
const [resolution, setResolution] = useState(initialResolution);
const [error, setError] = useState<string | null>(null);
const modalRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
setResolution(initialResolution);
setError(null);
}, [isOpen, initialResolution, action]);
useEffect(() => {
if (isOpen) {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !isSubmitting) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
// Auto-focus appropriate element
setTimeout(() => {
if (action === 'close' && textareaRef.current) {
textareaRef.current.focus();
} else if (modalRef.current) {
modalRef.current.focus();
}
}, 50);
return () => window.removeEventListener('keydown', handleKeyDown);
}
}, [isOpen, isSubmitting, action, onClose]);
if (!isOpen) return null;
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (action === 'close') {
if (!resolution.trim()) {
setError('Resolution summary is required before closing this incident.');
return;
}
onConfirm(resolution.trim());
} else {
onConfirm();
}
}
const titles: Record<IncidentModalAction, string> = {
close: 'Close Incident',
reopen: 'Reopen Incident',
archive: 'Archive Incident',
};
const descriptions: Record<IncidentModalAction, string> = {
close: `Closing incident ${caseReference} requires an authoritative resolution record.`,
reopen: `Reopening incident ${caseReference} will transition status back to INVESTIGATING. (Owner authorization required)`,
archive: `Archiving incident ${caseReference} is terminal. No further mutations will be permitted. (Owner authorization required)`,
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-in fade-in duration-150">
<div
ref={modalRef}
tabIndex={-1}
className="w-full max-w-md bg-surface-container-low border border-outline-variant/40 rounded-2xl p-6 shadow-2xl space-y-5 focus:outline-none"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
{/* Modal Header */}
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="material-symbols-outlined text-primary-container text-[20px]">
{action === 'close' ? 'task_alt' : action === 'reopen' ? 'replay' : 'archive'}
</span>
<h2 id="modal-title" className="text-base font-headline-sm font-bold text-on-surface uppercase">
{titles[action]}
</h2>
</div>
<p className="text-xs font-mono font-bold text-primary-fixed-dim">
{caseReference}
</p>
</div>
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="text-outline hover:text-on-surface p-1 rounded-lg hover:bg-surface-container transition-colors"
aria-label="Close modal"
>
<span className="material-symbols-outlined text-[18px]">close</span>
</button>
</div>
<p className="text-xs text-on-surface-variant leading-relaxed">
{descriptions[action]}
</p>
{error && (
<div className="p-3 rounded-lg bg-error/10 border border-error/30 text-error text-xs flex items-center gap-2">
<span className="material-symbols-outlined text-[16px]">error</span>
<span>{error}</span>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
{action === 'close' && (
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label htmlFor="modal-resolution" className="block text-xs font-semibold text-on-surface">
Resolution Summary <span className="text-error">*</span>
</label>
<span className="text-[10px] font-mono text-outline">
{resolution.length} / 4000
</span>
</div>
<textarea
ref={textareaRef}
id="modal-resolution"
required
rows={4}
maxLength={4000}
value={resolution}
onChange={(e) => setResolution(e.target.value)}
placeholder="Explain the root cause and resolution applied..."
className="w-full bg-surface-container px-3.5 py-2 rounded-lg border border-outline-variant/40 text-xs text-on-surface placeholder:text-outline focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none resize-y"
/>
</div>
)}
{/* Modal Actions */}
<div className="flex items-center justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
disabled={isSubmitting}
className="px-4 py-2 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-semibold text-on-surface-variant hover:text-on-surface transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
>
Cancel
</button>
<button
type="submit"
disabled={isSubmitting}
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs font-bold shadow-md transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none disabled:opacity-50 ${
action === 'archive'
? 'bg-error text-on-error hover:bg-error/90'
: 'bg-primary-container text-on-primary-container hover:bg-primary-container/90'
}`}
>
{isSubmitting && (
<span className="material-symbols-outlined text-[16px] animate-spin">
progress_activity
</span>
)}
<span>
{action === 'close'
? 'Confirm & Close'
: action === 'reopen'
? 'Confirm Reopen'
: 'Confirm Archive'}
</span>
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,436 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
SafeIncidentSummary,
IncidentSeverity,
IncidentStatus,
OperationsRole,
} from './types';
interface OperationsIncidentsListProps {
role: OperationsRole;
onSelectIncident: (caseRef: string) => void;
onCreateIncident: () => void;
}
export function OperationsIncidentsList({
role,
onSelectIncident,
onCreateIncident,
}: OperationsIncidentsListProps) {
const [incidents, setIncidents] = useState<SafeIncidentSummary[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Filters
const [statusFilter, setStatusFilter] = useState<string>('all');
const [severityFilter, setSeverityFilter] = useState<string>('all');
const [caseSearch, setCaseSearch] = useState<string>('');
async function fetchIncidents(cursor?: string) {
if (cursor) {
setIsLoadingMore(true);
} else {
setIsLoading(true);
setErrorMessage(null);
}
const params = new URLSearchParams();
if (statusFilter !== 'all') params.set('status', statusFilter);
if (severityFilter !== 'all') params.set('severity', severityFilter);
if (caseSearch.trim()) params.set('case_reference', caseSearch.trim());
if (cursor) params.set('cursor', cursor);
try {
const response = await fetch(`/api/ops/incidents?${params.toString()}`, {
method: 'GET',
headers: { accept: 'application/json' },
cache: 'no-store',
});
if (response.status === 401) {
setErrorMessage('Your Operations session has expired.');
setIsLoading(false);
setIsLoadingMore(false);
return;
}
if (response.status === 403) {
setErrorMessage('You do not have authority to view incidents.');
setIsLoading(false);
setIsLoadingMore(false);
return;
}
if (!response.ok) {
setErrorMessage('Failed to load operational incidents.');
setIsLoading(false);
setIsLoadingMore(false);
return;
}
const data = await response.json();
const items: SafeIncidentSummary[] = Array.isArray(data?.items) ? data.items : [];
if (cursor) {
setIncidents((prev) => [...prev, ...items]);
} else {
setIncidents(items);
}
setNextCursor(data?.next_cursor ?? null);
} catch {
setErrorMessage('Unable to connect to Operations platform.');
} finally {
setIsLoading(false);
setIsLoadingMore(false);
}
}
useEffect(() => {
fetchIncidents();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statusFilter, severityFilter]);
function handleSearchSubmit(e: React.FormEvent) {
e.preventDefault();
fetchIncidents();
}
function getSeverityBadge(severity: IncidentSeverity) {
switch (severity) {
case 'critical':
return (
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-bold tracking-wider bg-error/15 text-error border border-error/30">
CRITICAL
</span>
);
case 'high':
return (
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-bold tracking-wider bg-amber-500/15 text-amber-400 border border-amber-500/30">
HIGH
</span>
);
case 'medium':
return (
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-medium tracking-wider bg-primary-container/15 text-primary-container border border-primary-container/30">
MEDIUM
</span>
);
case 'low':
default:
return (
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-medium tracking-wider bg-surface-container text-on-surface-variant border border-outline-variant/30">
LOW
</span>
);
}
}
function getStatusBadge(status: IncidentStatus) {
switch (status) {
case 'open':
return (
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-semibold tracking-wider bg-blue-500/15 text-blue-400 border border-blue-500/30">
OPEN
</span>
);
case 'investigating':
return (
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-semibold tracking-wider bg-amber-500/15 text-amber-300 border border-amber-500/30">
INVESTIGATING
</span>
);
case 'monitoring':
return (
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-semibold tracking-wider bg-purple-500/15 text-purple-300 border border-purple-500/30">
MONITORING
</span>
);
case 'resolved':
return (
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-semibold tracking-wider bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
RESOLVED
</span>
);
case 'closed':
return (
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-medium tracking-wider bg-surface-container-high text-outline border border-outline-variant/30">
CLOSED
</span>
);
case 'archived':
default:
return (
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-medium tracking-wider bg-surface-container-highest/60 text-outline border border-outline-variant/20">
ARCHIVED
</span>
);
}
}
function formatDate(iso: string) {
try {
const d = new Date(iso);
return d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
} catch {
return iso;
}
}
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-outline-variant/20 pb-4">
<div>
<div className="flex items-center gap-2">
<h1 className="text-xl font-headline-sm font-bold tracking-tight text-on-surface uppercase">
Operational Incidents
</h1>
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase tracking-wider bg-primary-container/10 text-primary-container border border-primary-container/20">
OPS-4
</span>
</div>
<p className="text-xs text-on-surface-variant mt-0.5">
Isolated reliability & incident tracking with immutable case references.
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => fetchIncidents()}
disabled={isLoading}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-medium text-on-surface-variant hover:text-on-surface hover:bg-surface-container-high transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
title="Refresh incidents list"
>
<span className={`material-symbols-outlined text-[16px] ${isLoading ? 'animate-spin' : ''}`}>
refresh
</span>
<span>Refresh</span>
</button>
<button
type="button"
onClick={onCreateIncident}
className="flex items-center gap-1.5 px-3.5 py-2 rounded-lg bg-primary-container text-on-primary-container text-xs font-bold shadow-md hover:bg-primary-container/90 transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
>
<span className="material-symbols-outlined text-[16px]">add_circle</span>
<span>New Incident</span>
</button>
</div>
</div>
{/* Filter Bar */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 bg-surface-container-low p-3.5 rounded-xl border border-outline-variant/20">
<div className="flex flex-wrap items-center gap-3">
{/* Status Filter */}
<div className="flex items-center gap-1.5 text-xs">
<span className="text-outline font-medium">Status:</span>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="bg-surface-container px-2.5 py-1.5 rounded-lg border border-outline-variant/30 text-xs text-on-surface font-medium focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
>
<option value="all">All Statuses</option>
<option value="open">Open</option>
<option value="investigating">Investigating</option>
<option value="monitoring">Monitoring</option>
<option value="resolved">Resolved</option>
<option value="closed">Closed</option>
<option value="archived">Archived</option>
</select>
</div>
{/* Severity Filter */}
<div className="flex items-center gap-1.5 text-xs">
<span className="text-outline font-medium">Severity:</span>
<select
value={severityFilter}
onChange={(e) => setSeverityFilter(e.target.value)}
className="bg-surface-container px-2.5 py-1.5 rounded-lg border border-outline-variant/30 text-xs text-on-surface font-medium focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
>
<option value="all">All Severities</option>
<option value="critical">Critical</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
</select>
</div>
</div>
{/* Case Reference Search */}
<form onSubmit={handleSearchSubmit} className="flex items-center gap-2">
<div className="relative flex-1 md:w-56">
<input
type="text"
value={caseSearch}
onChange={(e) => setCaseSearch(e.target.value)}
placeholder="Search Case Reference..."
className="w-full bg-surface-container px-3 py-1.5 pr-8 rounded-lg border border-outline-variant/30 text-xs font-mono text-on-surface placeholder:text-outline focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
/>
{caseSearch && (
<button
type="button"
onClick={() => {
setCaseSearch('');
fetchIncidents();
}}
className="absolute right-2 top-1/2 -translate-y-1/2 text-outline hover:text-on-surface"
>
<span className="material-symbols-outlined text-[14px]">close</span>
</button>
)}
</div>
<button
type="submit"
className="px-3 py-1.5 bg-surface-container-high border border-outline-variant/40 rounded-lg text-xs font-medium text-on-surface hover:bg-surface-container-highest transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
>
Filter
</button>
</form>
</div>
{/* Error Banner */}
{errorMessage && (
<div className="p-4 rounded-xl bg-error/10 border border-error/30 text-error flex items-center gap-3 text-xs">
<span className="material-symbols-outlined text-[18px]">error</span>
<span>{errorMessage}</span>
</div>
)}
{/* Incidents Content: Desktop Table & Mobile Cards */}
{isLoading ? (
<div className="p-12 text-center text-outline bg-surface-container-lowest rounded-xl border border-outline-variant/20 flex flex-col items-center justify-center gap-3">
<span className="material-symbols-outlined text-3xl animate-spin text-primary-container">
progress_activity
</span>
<span className="text-xs font-medium">Loading operational incidents...</span>
</div>
) : incidents.length === 0 ? (
<div className="p-12 text-center text-outline bg-surface-container-lowest rounded-xl border border-outline-variant/20 flex flex-col items-center justify-center gap-3">
<span className="material-symbols-outlined text-3xl text-outline-variant">
assignment_turned_in
</span>
<div className="space-y-1">
<p className="text-xs font-semibold text-on-surface">No operational incidents found</p>
<p className="text-[11px] text-outline">
{statusFilter !== 'all' || severityFilter !== 'all' || caseSearch
? 'No incidents match the selected filter criteria.'
: 'No incidents recorded in the Operations platform.'}
</p>
</div>
<button
type="button"
onClick={onCreateIncident}
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary-container text-on-primary-container text-xs font-semibold hover:bg-primary-container/90 transition-all"
>
<span className="material-symbols-outlined text-[14px]">add</span>
<span>Create First Incident</span>
</button>
</div>
) : (
<>
{/* Desktop Table View (hidden on small screens) */}
<div className="hidden md:block overflow-hidden rounded-xl border border-outline-variant/30 bg-surface-container-lowest shadow-sm">
<table className="w-full text-left text-xs border-collapse">
<thead>
<tr className="border-b border-outline-variant/20 bg-surface-container-low text-[11px] font-mono text-outline uppercase tracking-wider">
<th className="py-3 px-4 font-semibold">CASE</th>
<th className="py-3 px-4 font-semibold">TITLE</th>
<th className="py-3 px-4 font-semibold">SEVERITY</th>
<th className="py-3 px-4 font-semibold">STATUS</th>
<th className="py-3 px-4 font-semibold">UPDATED</th>
<th className="py-3 px-4 font-semibold">CREATED</th>
</tr>
</thead>
<tbody className="divide-y divide-outline-variant/10 text-on-surface">
{incidents.map((incident) => (
<tr
key={incident.case_reference}
onClick={() => onSelectIncident(incident.case_reference)}
className="hover:bg-surface-container/50 cursor-pointer transition-colors group focus-within:bg-surface-container"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onSelectIncident(incident.case_reference);
}
}}
>
<td className="py-3 px-4 font-mono font-bold text-primary-container group-hover:underline">
{incident.case_reference}
</td>
<td className="py-3 px-4 font-medium text-on-surface max-w-xs truncate">
{incident.title}
</td>
<td className="py-3 px-4">{getSeverityBadge(incident.severity)}</td>
<td className="py-3 px-4">{getStatusBadge(incident.status)}</td>
<td className="py-3 px-4 text-[11px] font-mono text-on-surface-variant">
{formatDate(incident.updated_at)}
</td>
<td className="py-3 px-4 text-[11px] font-mono text-outline">
{formatDate(incident.created_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile Stacked Card View (shown only on small screens) */}
<div className="md:hidden space-y-3">
{incidents.map((incident) => (
<button
key={incident.case_reference}
type="button"
onClick={() => onSelectIncident(incident.case_reference)}
className="w-full text-left p-4 rounded-xl border border-outline-variant/20 bg-surface-container-lowest hover:bg-surface-container-low transition-colors shadow-sm focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none space-y-2.5"
>
<div className="flex items-center justify-between gap-2">
<span className="font-mono font-bold text-xs text-primary-container">
{incident.case_reference}
</span>
<div className="flex items-center gap-1.5">
{getSeverityBadge(incident.severity)}
{getStatusBadge(incident.status)}
</div>
</div>
<p className="text-xs font-semibold text-on-surface line-clamp-2">
{incident.title}
</p>
<div className="flex items-center justify-between text-[10px] font-mono text-outline pt-1 border-t border-outline-variant/10">
<span>Updated: {formatDate(incident.updated_at)}</span>
<span className="text-primary-fixed-dim">View Details →</span>
</div>
</button>
))}
</div>
{/* Cursor Pagination */}
{nextCursor && (
<div className="pt-2 text-center">
<button
type="button"
onClick={() => fetchIncidents(nextCursor)}
disabled={isLoadingMore}
className="px-4 py-2 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-semibold text-on-surface hover:bg-surface-container-high transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
>
{isLoadingMore ? 'Loading More...' : 'Load More Incidents'}
</button>
</div>
)}
</>
)}
</div>
);
}

View File

@ -13,6 +13,9 @@ import { OperationsOverview } from './operations-overview';
import { OperationsAudit } from './operations-audit';
import { OperationsOperators } from './operations-operators';
import { OperationsSessionView } from './operations-session';
import { OperationsIncidentsList } from './operations-incidents-list';
import { OperationsCreateIncident } from './operations-create-incident';
import { OperationsIncidentDetail } from './operations-incident-detail';
interface OperationsShellProps {
initialSession: OperatorSession;
@ -23,6 +26,7 @@ interface OperationsShellProps {
total?: number;
};
initialNav?: OperationsNavId;
initialCaseRef?: string | null;
}
export function OperationsShell({
@ -30,11 +34,18 @@ export function OperationsShell({
initialHealth,
initialAudit,
initialNav = 'overview',
initialCaseRef = null,
}: OperationsShellProps) {
const [session, setSession] = useState<OperatorSession>(initialSession);
const [activeNav, setActiveNav] = useState<OperationsNavId>(initialNav);
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
// Incidents navigation sub-state
const [incidentView, setIncidentView] = useState<'list' | 'create' | 'detail'>(
initialCaseRef ? 'detail' : 'list'
);
const [selectedCaseRef, setSelectedCaseRef] = useState<string | null>(initialCaseRef);
// Health state
const [health, setHealth] = useState<OperationsHealth>(initialHealth);
const [isRefreshingHealth, setIsRefreshingHealth] = useState(false);
@ -163,6 +174,42 @@ export function OperationsShell({
/>
)}
{activeNav === 'incidents' && (
<>
{incidentView === 'list' && (
<OperationsIncidentsList
role={session.role}
onSelectIncident={(caseRef) => {
setSelectedCaseRef(caseRef);
setIncidentView('detail');
}}
onCreateIncident={() => setIncidentView('create')}
/>
)}
{incidentView === 'create' && (
<OperationsCreateIncident
onIncidentCreated={(caseRef) => {
setSelectedCaseRef(caseRef);
setIncidentView('detail');
}}
onCancel={() => setIncidentView('list')}
/>
)}
{incidentView === 'detail' && selectedCaseRef && (
<OperationsIncidentDetail
caseReference={selectedCaseRef}
role={session.role}
onBack={() => {
setSelectedCaseRef(null);
setIncidentView('list');
}}
/>
)}
</>
)}
{activeNav === 'audit' && (
<OperationsAudit
role={session.role}

View File

@ -1,4 +1,4 @@
'use client';
'use client';
import React from 'react';
import { OperationsNavId, OperationsRole } from './types';
@ -32,6 +32,11 @@ export function OperationsSidebar({
label: 'Overview',
icon: 'dashboard',
},
{
id: 'incidents',
label: 'Incidents',
icon: 'assignment',
},
{
id: 'audit',
label: 'Audit',

View File

@ -50,4 +50,69 @@ export interface OperatorSession {
environment?: string;
}
export type OperationsNavId = 'overview' | 'audit' | 'operators' | 'session';
export type OperationsNavId =
| 'overview'
| 'incidents'
| 'audit'
| 'operators'
| 'session';
export type IncidentSeverity = 'low' | 'medium' | 'high' | 'critical';
export type IncidentStatus =
| 'open'
| 'investigating'
| 'monitoring'
| 'resolved'
| 'closed'
| 'archived';
export interface SafeIncidentSummary {
case_reference: string;
title: string;
severity: IncidentSeverity;
status: IncidentStatus;
created_at: string;
updated_at: string;
}
export interface SafeIncidentDetail {
case_reference: string;
title: string;
summary: string;
severity: IncidentSeverity;
status: IncidentStatus;
created_at: string;
updated_at: string;
closed_at?: string | null;
resolution?: string | null;
created_by_identity: string;
closed_by_identity?: string | null;
}
export interface SafeIncidentNote {
created_at: string;
created_by_identity: string;
body: string;
}
export interface IncidentListResponse {
items: SafeIncidentSummary[];
next_cursor?: string | null;
}
export interface CreateIncidentInput {
title: string;
summary: string;
severity: IncidentSeverity;
}
export interface UpdateIncidentInput {
severity?: IncidentSeverity;
status?: IncidentStatus;
resolution?: string;
}
export interface AddIncidentNoteInput {
body: string;
}

View File

@ -212,3 +212,137 @@ export function sanitizeAuditEvent(raw: unknown): SafeAuditEvent | null {
metadata,
};
}
// ============================================================
// Incident Sanitizers & Validators (OPS-4)
// ============================================================
export function isValidIncidentSeverity(severity: unknown): severity is import('../components/ops/types').IncidentSeverity {
return (
severity === 'low' ||
severity === 'medium' ||
severity === 'high' ||
severity === 'critical'
);
}
export function isValidIncidentStatus(status: unknown): status is import('../components/ops/types').IncidentStatus {
return (
status === 'open' ||
status === 'investigating' ||
status === 'monitoring' ||
status === 'resolved' ||
status === 'closed' ||
status === 'archived'
);
}
const ALLOWED_INCIDENT_TRANSITIONS: Record<string, Set<string>> = {
open: new Set(['investigating', 'resolved', 'closed']),
investigating: new Set(['monitoring', 'resolved', 'closed']),
monitoring: new Set(['investigating', 'resolved', 'closed']),
resolved: new Set(['closed', 'investigating']),
closed: new Set(['investigating']), // Reopen (Owner only)
archived: new Set(), // Terminal
};
export function isValidStatusTransition(
current: import('../components/ops/types').IncidentStatus,
target: import('../components/ops/types').IncidentStatus,
role: import('../components/ops/types').OperationsRole
): boolean {
if (current === target) return false;
if (current === 'archived') return false; // Terminal
if (target === 'archived') return role === 'owner'; // Owner only
if (current === 'closed' && target === 'investigating') return role === 'owner'; // Owner only
const allowed = ALLOWED_INCIDENT_TRANSITIONS[current];
return allowed ? allowed.has(target) : false;
}
export function sanitizeIncidentSummary(raw: unknown): import('../components/ops/types').SafeIncidentSummary | null {
if (!raw || typeof raw !== 'object') return null;
const obj = raw as Record<string, unknown>;
if (typeof obj.case_reference !== 'string' || !obj.case_reference.trim()) return null;
const case_reference = obj.case_reference.trim();
if (typeof obj.title !== 'string' || !obj.title.trim()) return null;
const title = obj.title.trim();
if (!isValidIncidentSeverity(obj.severity)) return null;
const severity = obj.severity;
if (!isValidIncidentStatus(obj.status)) return null;
const status = obj.status;
if (typeof obj.created_at !== 'string' || !obj.created_at.trim()) return null;
const created_at = obj.created_at.trim();
const updated_at = typeof obj.updated_at === 'string' && obj.updated_at.trim()
? obj.updated_at.trim()
: created_at;
return {
case_reference,
title,
severity,
status,
created_at,
updated_at,
};
}
export function sanitizeIncidentDetail(raw: unknown): import('../components/ops/types').SafeIncidentDetail | null {
if (!raw || typeof raw !== 'object') return null;
const obj = raw as Record<string, unknown>;
const summaryData = sanitizeIncidentSummary(raw);
if (!summaryData) return null;
if (typeof obj.summary !== 'string' || !obj.summary.trim()) return null;
const summary = obj.summary.trim();
if (typeof obj.created_by_identity !== 'string' || !obj.created_by_identity.trim()) return null;
const created_by_identity = obj.created_by_identity.trim();
const closed_at = typeof obj.closed_at === 'string' && obj.closed_at.trim()
? obj.closed_at.trim()
: null;
const resolution = typeof obj.resolution === 'string' && obj.resolution.trim()
? obj.resolution.trim()
: null;
const closed_by_identity = typeof obj.closed_by_identity === 'string' && obj.closed_by_identity.trim()
? obj.closed_by_identity.trim()
: null;
return {
...summaryData,
summary,
created_by_identity,
closed_at,
resolution,
closed_by_identity,
};
}
export function sanitizeIncidentNote(raw: unknown): import('../components/ops/types').SafeIncidentNote | null {
if (!raw || typeof raw !== 'object') return null;
const obj = raw as Record<string, unknown>;
if (typeof obj.created_at !== 'string' || !obj.created_at.trim()) return null;
const created_at = obj.created_at.trim();
if (typeof obj.created_by_identity !== 'string' || !obj.created_by_identity.trim()) return null;
const created_by_identity = obj.created_by_identity.trim();
if (typeof obj.body !== 'string' || !obj.body.trim()) return null;
const body = obj.body.trim();
return {
created_at,
created_by_identity,
body,
};
}

View File

@ -0,0 +1,16 @@
import { NextRequest } from 'next/server';
export function hasValidOrigin(request: NextRequest): boolean {
const origin = request.headers.get('origin');
const host = request.headers.get('x-forwarded-host')?.split(',')[0].trim()
?? request.headers.get('host');
const protocol = request.headers.get('x-forwarded-proto')?.split(',')[0].trim()
?? request.nextUrl.protocol.slice(0, -1);
if (!origin || !host) return false;
try {
const parsed = new URL(origin);
return parsed.protocol === `${protocol}:` && parsed.host === host;
} catch {
return false;
}
}

View File

@ -38,7 +38,7 @@ function loadTsModule(relativePath) {
return m.exports;
}
console.log('--- STARTING OPS-3C, OPS-3C.1 & OPS-3C.2 OPERATIONS TEST SUITE ---');
console.log('--- STARTING OPS-3C, OPS-3C.1, OPS-3C.2, OPS-3C.3 & OPS-4 OPERATIONS TEST SUITE ---');
// Load modules
const opsData = loadTsModule('../lib/ops-data.ts');
@ -48,6 +48,12 @@ const {
isValidHealthStatus,
HEALTH_COMPONENT_METADATA,
APPROVED_AUDIT_METADATA_KEYS,
isValidIncidentSeverity,
isValidIncidentStatus,
isValidStatusTransition,
sanitizeIncidentSummary,
sanitizeIncidentDetail,
sanitizeIncidentNote,
} = opsData;
// ============================================================
@ -332,7 +338,7 @@ for (const file of allOpsFiles) {
assert.ok(!content.includes('/api/v1/admin'), `${file} must not reference /api/v1/admin`);
assert.ok(!content.includes('AdminConsole'), `${file} must not reference AdminConsole`);
assert.ok(!content.includes('rider_id'), `${file} must not reference rider_id`);
assert.ok(!content.includes('vin'), `${file} must not reference vin`);
assert.ok(!/\bvin\b/i.test(content), `${file} must not reference VIN`);
assert.ok(!content.includes('telemetry_points'), `${file} must not reference telemetry_points`);
}
console.log(' PASS: All Operations components free of retired admin APIs and rider data.');
@ -431,9 +437,9 @@ console.log(' PASS: Responsive grid and drawer navigation adapt across 1440px,
// ============================================================
// 20. SECURITY UX & RUNTIME AUDIT
// ============================================================
console.log('Testing 20: Security UX & Third-Party Runtime Audit...');
for (const file of allOpsFiles) {
const currentOpsFiles = fs.readdirSync(opsDir);
for (const file of currentOpsFiles) {
const content = fs.readFileSync(path.join(opsDir, file), 'utf8');
assert.ok(!content.includes('localStorage'), `${file} must not use localStorage`);
assert.ok(!content.includes('sessionStorage'), `${file} must not use sessionStorage`);
@ -443,4 +449,392 @@ for (const file of allOpsFiles) {
}
console.log(' PASS: Zero localStorage/sessionStorage auth, zero dangerouslySetInnerHTML, zero third-party CDNs.');
console.log('--- ALL OPS-3C, OPS-3C.1 & OPS-3C.2 TESTS PASSED (20/20) ---');
// ============================================================
// 21. INCIDENTS NAVIGATION PLACEMENT & ROLE VISIBILITY (OPS-4)
// ============================================================
console.log('Testing 21: Incidents Navigation Placement & Role Accessibility...');
const sidebarSource = fs.readFileSync(path.resolve(__dirname, '../components/ops/operations-sidebar.tsx'), 'utf8');
assert.ok(
sidebarSource.includes("id: 'incidents'") && sidebarSource.includes("label: 'Incidents'"),
'OperationsSidebar must include Incidents navigation item'
);
// Order check: Overview -> Incidents -> Audit -> Operators -> My Session
const overviewIdx = sidebarSource.indexOf("id: 'overview'");
const incidentsIdx = sidebarSource.indexOf("id: 'incidents'");
const auditIdx = sidebarSource.indexOf("id: 'audit'");
const operatorsIdx = sidebarSource.indexOf("id: 'operators'");
const sessionIdx = sidebarSource.indexOf("id: 'session'");
assert.ok(
overviewIdx < incidentsIdx && incidentsIdx < auditIdx && auditIdx < operatorsIdx && operatorsIdx < sessionIdx,
'Navigation items must be strictly ordered: Overview -> Incidents -> Audit -> Operators -> My Session'
);
// Incidents has no role gate badge in sidebar (accessible to both operator and owner)
const incidentsSection = sidebarSource.substring(incidentsIdx, auditIdx);
assert.ok(
!incidentsSection.includes("badge: isOwner ? 'OWNER' : 'RESTRICTED'"),
'Incidents must be accessible to both operator and owner without restricted badge'
);
console.log(' PASS: Incidents placed at position 2, accessible to operator and owner.');
// ============================================================
// 22. CASE REFERENCE FORMAT & IMMUTABILITY (OPS-4)
// ============================================================
console.log('Testing 22: Case Reference Server Generation & Pattern Validation...');
const sampleCaseRef = 'OPS-2026-000001';
const caseRefRegex = /^OPS-\d{4}-\d{6}$/;
assert.ok(caseRefRegex.test(sampleCaseRef), 'Case reference must match OPS-YYYY-NNNNNN');
assert.ok(!caseRefRegex.test('INC-1234'), 'Invalid format must be rejected');
assert.ok(!caseRefRegex.test('OPS-2026-1'), 'Unpadded sequence must be rejected');
// Ensure Create Incident form does not provide a case reference input field
const createIncidentSource = fs.readFileSync(path.resolve(__dirname, '../components/ops/operations-create-incident.tsx'), 'utf8');
assert.ok(
!createIncidentSource.includes('case_reference') || !createIncidentSource.includes('input type="text" name="case_reference"'),
'Create Incident form must NOT accept a client-generated case reference'
);
console.log(' PASS: Case references strictly conform to OPS-YYYY-NNNNNN immutable pattern.');
// ============================================================
// 23. INTERNAL UUID ISOLATION (OPS-4)
// ============================================================
console.log('Testing 23: Internal Database UUID Isolation in Frontend & Sanitizers...');
const rawBackendIncident = {
id: '3fa85f64-5717-4562-b3fc-2c963f66afa6',
case_reference: 'OPS-2026-000042',
title: 'Database connection pool saturation',
summary: 'Connection exhaustion under high concurrent load',
severity: 'high',
status: 'investigating',
created_at: '2026-09-10T08:00:00Z',
updated_at: '2026-09-10T08:15:00Z',
created_by_operator_id: 'e4d9b1c2-3f4a-5b6c-7d8e-9f0a1b2c3d4e',
created_by_identity: 'operator@motorsignal.com',
closed_at: null,
closed_by_operator_id: null,
closed_by_identity: null,
resolution: null,
};
const sanitizedSummary = sanitizeIncidentSummary(rawBackendIncident);
assert.ok(sanitizedSummary !== null);
assert.equal(sanitizedSummary.case_reference, 'OPS-2026-000042');
assert.equal(sanitizedSummary.title, 'Database connection pool saturation');
assert.equal(sanitizedSummary.severity, 'high');
assert.equal(sanitizedSummary.status, 'investigating');
assert.equal(sanitizedSummary.id, undefined, 'Summary DTO must not contain database UUID');
const sanitizedDetail = sanitizeIncidentDetail(rawBackendIncident);
assert.ok(sanitizedDetail !== null);
assert.equal(sanitizedDetail.case_reference, 'OPS-2026-000042');
assert.equal(sanitizedDetail.created_by_identity, 'operator@motorsignal.com');
assert.equal(sanitizedDetail.id, undefined, 'Detail DTO must not contain database UUID');
assert.equal(sanitizedDetail.created_by_operator_id, undefined, 'Detail DTO must not contain operator UUID');
console.log(' PASS: Database UUIDs strictly stripped from all Incident DTOs.');
// ============================================================
// 24. INCIDENT SEVERITY ENUMS (OPS-4)
// ============================================================
console.log('Testing 24: Incident Severity Enums...');
assert.equal(isValidIncidentSeverity('low'), true);
assert.equal(isValidIncidentSeverity('medium'), true);
assert.equal(isValidIncidentSeverity('high'), true);
assert.equal(isValidIncidentSeverity('critical'), true);
assert.equal(isValidIncidentSeverity('p0'), false);
assert.equal(isValidIncidentSeverity('urgent'), false);
assert.equal(isValidIncidentSeverity('blocker'), false);
console.log(' PASS: Incident severity strictly locked to low, medium, high, critical.');
// ============================================================
// 25. INCIDENT STATUS ENUMS (OPS-4)
// ============================================================
console.log('Testing 25: Incident Status Enums...');
assert.equal(isValidIncidentStatus('open'), true);
assert.equal(isValidIncidentStatus('investigating'), true);
assert.equal(isValidIncidentStatus('monitoring'), true);
assert.equal(isValidIncidentStatus('resolved'), true);
assert.equal(isValidIncidentStatus('closed'), true);
assert.equal(isValidIncidentStatus('archived'), true);
assert.equal(isValidIncidentStatus('pending'), false);
assert.equal(isValidIncidentStatus('in_progress'), false);
assert.equal(isValidIncidentStatus('deleted'), false);
console.log(' PASS: Incident status strictly locked to 6 approved lifecycle states.');
// ============================================================
// 26. STATUS TRANSITION STATE MACHINE & ROLE MATRIX (OPS-4)
// ============================================================
console.log('Testing 26: Status Transition State Machine & Role Restrictions...');
// Operator transitions
assert.equal(isValidStatusTransition('open', 'investigating', 'operator'), true);
assert.equal(isValidStatusTransition('open', 'resolved', 'operator'), true);
assert.equal(isValidStatusTransition('open', 'closed', 'operator'), true);
assert.equal(isValidStatusTransition('investigating', 'monitoring', 'operator'), true);
assert.equal(isValidStatusTransition('investigating', 'resolved', 'operator'), true);
assert.equal(isValidStatusTransition('investigating', 'closed', 'operator'), true);
assert.equal(isValidStatusTransition('monitoring', 'investigating', 'operator'), true);
assert.equal(isValidStatusTransition('monitoring', 'resolved', 'operator'), true);
assert.equal(isValidStatusTransition('monitoring', 'closed', 'operator'), true);
assert.equal(isValidStatusTransition('resolved', 'investigating', 'operator'), true);
assert.equal(isValidStatusTransition('resolved', 'closed', 'operator'), true);
// Forbidden operator transitions
assert.equal(isValidStatusTransition('closed', 'investigating', 'operator'), false, 'Operator cannot reopen closed incident');
assert.equal(isValidStatusTransition('open', 'archived', 'operator'), false, 'Operator cannot archive incident');
assert.equal(isValidStatusTransition('investigating', 'archived', 'operator'), false, 'Operator cannot archive incident');
assert.equal(isValidStatusTransition('closed', 'archived', 'operator'), false, 'Operator cannot archive incident');
// Owner capabilities
assert.equal(isValidStatusTransition('closed', 'investigating', 'owner'), true, 'Owner can reopen closed incident');
assert.equal(isValidStatusTransition('open', 'archived', 'owner'), true, 'Owner can archive incident');
assert.equal(isValidStatusTransition('investigating', 'archived', 'owner'), true, 'Owner can archive incident');
assert.equal(isValidStatusTransition('closed', 'archived', 'owner'), true, 'Owner can archive incident');
// Terminal archived state
assert.equal(isValidStatusTransition('archived', 'open', 'owner'), false, 'Archived incident cannot transition to open');
assert.equal(isValidStatusTransition('archived', 'investigating', 'owner'), false, 'Archived incident cannot transition to investigating');
assert.equal(isValidStatusTransition('archived', 'closed', 'owner'), false, 'Archived incident cannot transition to closed');
console.log(' PASS: State machine guarantees strict role boundaries (Reopen/Archive = Owner Only).');
// ============================================================
// 27. APPEND-ONLY NOTES VALIDATION (OPS-4)
// ============================================================
console.log('Testing 27: Append-Only Notes Sanitization & Plain Text Contract...');
const rawNote = {
id: '7b8c9d0e-1f2a-3b4c-5d6e-7f8a9b0c1d2e',
incident_id: '3fa85f64-5717-4562-b3fc-2c963f66afa6',
created_at: '2026-09-10T08:20:00Z',
created_by_operator_id: 'e4d9b1c2-3f4a-5b6c-7d8e-9f0a1b2c3d4e',
created_by_identity: 'operator@motorsignal.com',
body: 'Scaled up replica count from 2 to 6. Ingress latency normalized to 14ms.',
};
const sanitizedNote = sanitizeIncidentNote(rawNote);
assert.ok(sanitizedNote !== null);
assert.equal(sanitizedNote.created_by_identity, 'operator@motorsignal.com');
assert.equal(sanitizedNote.body, 'Scaled up replica count from 2 to 6. Ingress latency normalized to 14ms.');
assert.equal(sanitizedNote.id, undefined, 'Note DTO must not expose internal note UUID');
assert.equal(sanitizedNote.incident_id, undefined, 'Note DTO must not expose incident UUID');
assert.equal(sanitizedNote.created_by_operator_id, undefined, 'Note DTO must not expose operator UUID');
// Fail closed on invalid note
assert.equal(sanitizeIncidentNote(null), null);
assert.equal(sanitizeIncidentNote({ body: 'test' }), null, 'Missing created_by_identity must fail closed');
assert.equal(sanitizeIncidentNote({ created_by_identity: 'op', body: '' }), null, 'Empty body must fail closed');
console.log(' PASS: Notes DTOs strictly validated and internal UUIDs sanitized.');
// ============================================================
// 28. NO EDIT / NO DELETE FOR NOTES (OPS-4)
// ============================================================
console.log('Testing 28: No Note Edit or Deletion Endpoints in Codebase...');
const incidentNotesRoutePath = path.resolve(__dirname, '../app/api/ops/incidents/[caseRef]/notes/route.ts');
const incidentNotesRouteSource = fs.readFileSync(incidentNotesRoutePath, 'utf8');
assert.ok(
incidentNotesRouteSource.includes('export async function GET') &&
incidentNotesRouteSource.includes('export async function POST'),
'Notes route must implement GET and POST'
);
assert.ok(
!incidentNotesRouteSource.includes('export async function PUT') &&
!incidentNotesRouteSource.includes('export async function PATCH') &&
!incidentNotesRouteSource.includes('export async function DELETE'),
'Notes route must strictly FORBID PUT, PATCH, and DELETE (append-only contract)'
);
console.log(' PASS: Notes are strictly append-only with zero edit or delete endpoints.');
// ============================================================
// 29. NEXT.JS PROXY ROUTES AUTH BOUNDARY (OPS-4)
// ============================================================
console.log('Testing 29: Next.js API Incidents Proxy Routes Auth Enforcement...');
const incidentsRouteSource = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/incidents/route.ts'), 'utf8');
const incidentDetailRouteSource = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/incidents/[caseRef]/route.ts'), 'utf8');
assert.ok(
incidentsRouteSource.includes('__Host-motorsignal_ops') &&
incidentDetailRouteSource.includes('__Host-motorsignal_ops') &&
incidentNotesRouteSource.includes('__Host-motorsignal_ops'),
'All incidents API proxy routes must enforce __Host-motorsignal_ops session token'
);
assert.ok(
!incidentsRouteSource.includes('preview=') &&
!incidentDetailRouteSource.includes('preview='),
'Proxy routes must have zero preview query authentication bypass'
);
console.log(' PASS: All incident proxy routes require verified Operations host session.');
// ============================================================
// 30. ACCESSIBLE CONFIRMATION MODAL CONTRACT (OPS-4)
// ============================================================
console.log('Testing 30: Incident Modal Accessibility & Resolution Validation...');
const modalSource = fs.readFileSync(path.resolve(__dirname, '../components/ops/operations-incident-modal.tsx'), 'utf8');
assert.ok(modalSource.includes('aria-modal="true"'), 'Modal must declare aria-modal="true"');
assert.ok(modalSource.includes('role="dialog"'), 'Modal must declare role="dialog"');
assert.ok(modalSource.includes("e.key === 'Escape'"), 'Modal must handle Escape key');
assert.ok(
modalSource.includes('caseReference'),
'Modal must render case reference string'
);
assert.ok(
modalSource.includes('resolution.trim()'),
'Modal must validate that resolution text is non-empty before close'
);
console.log(' PASS: Modal provides accessible dialog semantics, Escape key handling, and resolution validation.');
// ============================================================
// 31. DENSE INCIDENT LIST UI CONTRACT (OPS-4)
// ============================================================
console.log('Testing 31: Dense Incident List Table & Mobile Stacked Cards...');
const listSource = fs.readFileSync(path.resolve(__dirname, '../components/ops/operations-incidents-list.tsx'), 'utf8');
assert.ok(listSource.includes('hidden md:block'), 'List must render table on desktop');
assert.ok(listSource.includes('md:hidden'), 'List must render stacked cards on mobile');
assert.ok(listSource.includes('CASE'), 'Table must contain CASE column');
assert.ok(listSource.includes('TITLE'), 'Table must contain TITLE column');
assert.ok(listSource.includes('SEVERITY'), 'Table must contain SEVERITY column');
assert.ok(listSource.includes('STATUS'), 'Table must contain STATUS column');
assert.ok(listSource.includes('UPDATED'), 'Table must contain UPDATED column');
assert.ok(listSource.includes('CREATED'), 'Table must contain CREATED column');
console.log(' PASS: List provides dense desktop table and mobile stacked card layout.');
// ============================================================
// 32. INCIDENT DETAIL UI SECTIONS (OPS-4)
// ============================================================
console.log('Testing 32: Incident Detail UI Structure & Plain-Text Notes...');
const detailSource = fs.readFileSync(path.resolve(__dirname, '../components/ops/operations-incident-detail.tsx'), 'utf8');
assert.ok(detailSource.includes('Operational Timeline'), 'Detail must include Operational Timeline');
assert.ok(detailSource.includes('Operator Notes'), 'Detail must include Operator Notes');
assert.ok(detailSource.includes('Incident Summary'), 'Detail must include Incident Summary');
assert.ok(detailSource.includes('Resolution Record') || detailSource.includes('resolution'), 'Detail must support resolution section');
assert.ok(detailSource.includes('whitespace-pre-wrap'), 'Notes and summaries must preserve plain text formatting');
console.log(' PASS: Incident Detail UI includes all required operational sections.');
// ============================================================
// 33. ZERO POLLING VERIFICATION (OPS-4)
// ============================================================
console.log('Testing 33: Zero Polling Verification Across Operations UI...');
for (const file of currentOpsFiles) {
const content = fs.readFileSync(path.join(opsDir, file), 'utf8');
assert.ok(!content.includes('setInterval'), `${file} must NOT contain setInterval polling`);
assert.ok(!content.includes('setTimeout(') || content.includes('setTimeout(() => setMutationSuccess') || content.includes('setTimeout(() =>'), `${file} must only use setTimeout for UI transitions, not polling`);
}
console.log(' PASS: Zero automatic background polling across all Operations components.');
// ============================================================
// 34. ZERO RIDER DATA CROSSOVER (OPS-4)
// ============================================================
console.log('Testing 34: Zero Rider Data Crossover in Operations Incidents...');
for (const file of currentOpsFiles) {
const content = fs.readFileSync(path.join(opsDir, file), 'utf8');
assert.ok(!content.includes('telemetry_points'), `${file} must not reference telemetry`);
assert.ok(!content.includes('user_id'), `${file} must not expose consumer user_id`);
assert.ok(!content.includes('vehicle_id'), `${file} must not reference vehicle_id`);
assert.ok(!/\bvin\b/i.test(content), `${file} must not reference VIN`);
assert.ok(!content.includes('/api/v1/rides') && !content.includes('/api/rides'), `${file} must not query consumer rides`);
}
console.log(' PASS: Operations Incident system strictly isolated from rider and vehicle data.');
// ============================================================
// ============================================================
// 36. CSRF & SAME-ORIGIN VALIDATION ON MUTATIONS (OPS-4.2)
// ============================================================
console.log('Testing 36: CSRF & Same-Origin Enforcement on State-Changing Incident Proxy Routes (OPS-4.2)...');
const incidentsRouteSrc = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/incidents/route.ts'), 'utf8');
const incidentDetailRouteSrc = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/incidents/[caseRef]/route.ts'), 'utf8');
const incidentNotesRouteSrc = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/incidents/[caseRef]/notes/route.ts'), 'utf8');
assert.ok(
incidentsRouteSrc.includes('hasValidOrigin(request)') && incidentsRouteSrc.includes("detail: 'Invalid request origin.'"),
'POST /api/ops/incidents must enforce hasValidOrigin and reject with 403 on invalid origin'
);
assert.ok(
incidentDetailRouteSrc.includes('hasValidOrigin(request)') && incidentDetailRouteSrc.includes("detail: 'Invalid request origin.'"),
'PATCH /api/ops/incidents/[caseRef] must enforce hasValidOrigin and reject with 403 on invalid origin'
);
assert.ok(
incidentNotesRouteSrc.includes('hasValidOrigin(request)') && incidentNotesRouteSrc.includes("detail: 'Invalid request origin.'"),
'POST /api/ops/incidents/[caseRef]/notes must enforce hasValidOrigin and reject with 403 on invalid origin'
);
console.log(' PASS: All 3 state-changing incident proxy routes enforce same-origin CSRF protection.');
// ============================================================
// 37. SAME-ORIGIN HELPER BEHAVIOR FOR OPS MUTATIONS (OPS-4.2)
// ============================================================
console.log('Testing 37: hasValidOrigin Security Matrix (OPS-4.2)...');
const requestOriginModule = loadTsModule('../lib/request-origin.ts');
const { hasValidOrigin } = requestOriginModule;
// Mock NextRequest objects
function createMockRequest(headers) {
return {
headers: {
get: (k) => headers[k.toLowerCase()] || null,
},
nextUrl: {
protocol: 'https:',
},
};
}
// Same origin
assert.equal(
hasValidOrigin(createMockRequest({
origin: 'https://motorsignal.app',
host: 'motorsignal.app',
'x-forwarded-proto': 'https',
})),
true,
'Valid same-origin request must pass'
);
// Cross origin attacker
assert.equal(
hasValidOrigin(createMockRequest({
origin: 'https://evil-attacker.com',
host: 'motorsignal.app',
'x-forwarded-proto': 'https',
})),
false,
'Cross-origin attacker origin must be rejected'
);
// Missing origin header
assert.equal(
hasValidOrigin(createMockRequest({
host: 'motorsignal.app',
'x-forwarded-proto': 'https',
})),
false,
'Missing origin header must be rejected on mutations'
);
// Protocol mismatch (http origin to https host)
assert.equal(
hasValidOrigin(createMockRequest({
origin: 'http://motorsignal.app',
host: 'motorsignal.app',
'x-forwarded-proto': 'https',
})),
false,
'Protocol downgrade/mismatch must be rejected'
);
console.log(' PASS: hasValidOrigin rejects cross-origin, missing-origin, and protocol-mismatched requests.');
// ============================================================
// 38. GET PROXY ROUTES UNAFFECTED (OPS-4.2)
// ============================================================
console.log('Testing 38: GET Proxy Routes Unaffected by Origin Barrier (OPS-4.2)...');
// Ensure GET handlers do not block on origin
const getIncidentMatch = incidentsRouteSrc.match(/export async function GET[\s\S]*?export async function POST/);
assert.ok(getIncidentMatch && !getIncidentMatch[0].includes('hasValidOrigin'), 'GET incidents must not require origin');
const getDetailMatch = incidentDetailRouteSrc.match(/export async function GET[\s\S]*?export async function PATCH/);
assert.ok(getDetailMatch && !getDetailMatch[0].includes('hasValidOrigin'), 'GET incident detail must not require origin');
const getNotesMatch = incidentNotesRouteSrc.match(/export async function GET[\s\S]*?export async function POST/);
assert.ok(getNotesMatch && !getNotesMatch[0].includes('hasValidOrigin'), 'GET notes must not require origin');
console.log(' PASS: Read-only GET operations are unaffected while mutations require valid origin.');
console.log('--- ALL OPS-3C, OPS-3C.1, OPS-3C.2, OPS-3C.3, OPS-4 & OPS-4.2 TESTS PASSED (38/38) ---');