feat: build core API, fraud engine, notifier, and frontend

Phase 1 — Core API (Go):
- Events, guests, tokens, RSVPs CRUD on PostgreSQL via pgx/v5
- HMAC-signed per-guest tokens with format validation
- Health endpoint with DB ping, slog JSON logging, graceful shutdown

Phase 2 — NATS + Fraud Engine:
- NATS JetStream pub/sub with explicit-ack consumers
- Python/FastAPI fraud engine with heuristic risk scoring
  (fingerprint mismatch, IP change, missing signals, repeated access)
- gRPC sync scoring with 250ms fail-open timeout
- Per-guest baseline tracking; risk bands low/medium/high/block

Phase 3 — Notifications + Frontend:
- Notification worker scaffolding (Twilio/SES stubs, retry/backoff)
- Nuxt 3 frontend with Tailwind dark theme + brand green
- Live monitor via WebSocket with auto-reconnect
- Activity history endpoint backfills monitor with RSVPs +
  scored access checks (including blocked attempts)

UX polish:
- Marketing-friendly landing page (hero mockup, how-it-works,
  features, use cases, testimonials, FAQ, final CTA)
- Animated layered card mockups on landing + new-event page
- Plus-ones stepper, RSVP status badges, filter buttons
- Friendly access-check labels (Verified/Review/Suspicious/Blocked)
- Dashboard hydration fix via ClientOnly wrapper

Infrastructure:
- docker-compose for full local dev (postgres, nats, api,
  fraud-engine, notifier, frontend)
- Multi-stage Dockerfiles, non-root UID 1000
- Integration tests with testcontainers-go

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kwaku Danso
2026-05-11 21:08:56 +01:00
parent f760fc3e21
commit 3f8bc58ca9
89 changed files with 22729 additions and 0 deletions
View File
+28
View File
@@ -0,0 +1,28 @@
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="GG_", env_file=".env", extra="ignore")
env: str = Field(default="development")
http_addr: str = Field(default="0.0.0.0:8081")
grpc_addr: str = Field(default="0.0.0.0:9091")
nats_url: str = Field(default="nats://localhost:4222")
stream_name: str = Field(default="GUESTGUARD")
consumer_durable: str = Field(default="fraud-engine-access")
@property
def host(self) -> str:
return self.http_addr.split(":", 1)[0] or "0.0.0.0"
@property
def port(self) -> int:
parts = self.http_addr.rsplit(":", 1)
if len(parts) == 2 and parts[1].isdigit():
return int(parts[1])
return 8081
def load_settings() -> Settings:
return Settings()
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
import logging
from datetime import UTC, datetime
from nats.aio.msg import Msg
from app.nats_bus import NatsBus
from app.schemas import AccessAttempted, FraudScored
from app.scoring import HeuristicScorer, risk_band
logger = logging.getLogger(__name__)
SUBJECT_ACCESS_ATTEMPTED = "guest.access.attempted"
SUBJECT_FRAUD_SCORED = "fraud.scored"
class FraudConsumer:
def __init__(self, bus: NatsBus, durable: str, scorer: HeuristicScorer) -> None:
self._bus = bus
self._durable = durable
self._scorer = scorer
self._subscription = None
async def start(self) -> None:
self._subscription = await self._bus.subscribe(
subject=SUBJECT_ACCESS_ATTEMPTED,
durable=self._durable,
handler=self._handle,
manual_ack=True,
)
logger.info("subscribed", extra={"subject": SUBJECT_ACCESS_ATTEMPTED, "durable": self._durable})
async def stop(self) -> None:
if self._subscription is not None:
await self._subscription.unsubscribe()
self._subscription = None
async def _handle(self, msg: Msg) -> None:
try:
evt = AccessAttempted.model_validate_json(msg.data)
except Exception:
logger.exception("invalid access.attempted payload — terminating message")
await msg.term()
return
try:
result = self._scorer.score(evt)
scored = FraudScored(
event_id=evt.event_id,
guest_id=evt.guest_id,
token_id=evt.token_id,
access_log_id=evt.access_log_id,
score=result.score,
risk=risk_band(result.score),
reasons=result.reasons,
scored_at=datetime.now(UTC),
)
await self._bus.publish(
SUBJECT_FRAUD_SCORED,
scored.model_dump_json().encode("utf-8"),
)
logger.info(
"scored access",
extra={
"guest_id": str(evt.guest_id),
"score": result.score,
"risk": scored.risk,
},
)
await msg.ack()
except Exception:
logger.exception("failed to score access — nak")
await msg.nak(delay=2)
+69
View File
@@ -0,0 +1,69 @@
from __future__ import annotations
import asyncio
import logging
from datetime import UTC, datetime
from uuid import UUID
import grpc
from app.schemas import AccessAttempted
from app.scoring import BLOCK, HIGH, LOW, MEDIUM, HeuristicScorer, risk_band
from fraud.v1 import fraud_pb2, fraud_pb2_grpc
logger = logging.getLogger(__name__)
_RISK_TO_PROTO = {
LOW: fraud_pb2.RISK_LOW,
MEDIUM: fraud_pb2.RISK_MEDIUM,
HIGH: fraud_pb2.RISK_HIGH,
BLOCK: fraud_pb2.RISK_BLOCK,
}
class FraudServicer(fraud_pb2_grpc.FraudServiceServicer):
def __init__(self, scorer: HeuristicScorer) -> None:
self._scorer = scorer
async def Score( # noqa: N802 — gRPC method
self,
request: fraud_pb2.ScoreRequest,
context: grpc.aio.ServicerContext,
) -> fraud_pb2.ScoreResponse:
try:
evt = AccessAttempted(
event_id=UUID(request.event_id),
guest_id=UUID(request.guest_id),
token_id=UUID(request.token_id),
access_log_id=UUID(request.access_log_id) if request.access_log_id else UUID(int=0),
fingerprint=dict(request.fingerprint) if request.fingerprint else None,
ip_address=request.ip_address or None,
user_agent=request.user_agent or None,
referrer=request.referrer or None,
occurred_at=datetime.now(UTC),
)
except (ValueError, TypeError) as exc:
await context.abort(grpc.StatusCode.INVALID_ARGUMENT, f"bad request: {exc}")
raise # unreachable, abort raises
result = self._scorer.score(evt)
band = risk_band(result.score)
return fraud_pb2.ScoreResponse(
score=result.score,
risk=_RISK_TO_PROTO.get(band, fraud_pb2.RISK_UNSPECIFIED),
reasons=result.reasons,
)
async def serve_grpc(scorer: HeuristicScorer, addr: str) -> grpc.aio.Server:
server = grpc.aio.server()
fraud_pb2_grpc.add_FraudServiceServicer_to_server(FraudServicer(scorer), server)
server.add_insecure_port(addr)
await server.start()
logger.info("grpc server started", extra={"addr": addr})
return server
async def stop_grpc(server: grpc.aio.Server) -> None:
await server.stop(grace=2.0)
await asyncio.sleep(0)
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
import structlog
import uvicorn
from fastapi import FastAPI
from app.config import load_settings
from app.consumer import FraudConsumer
from app.grpc_server import serve_grpc, stop_grpc
from app.nats_bus import NatsBus
from app.scoring import HeuristicScorer
logger = structlog.get_logger()
def _configure_logging(env: str) -> None:
level = logging.DEBUG if env == "development" else logging.INFO
logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(name)s %(message)s")
@asynccontextmanager
async def lifespan(app: FastAPI):
settings = load_settings()
_configure_logging(settings.env)
bus = NatsBus(settings.nats_url, settings.stream_name)
await bus.connect()
scorer = HeuristicScorer()
consumer = FraudConsumer(bus, settings.consumer_durable, scorer)
await consumer.start()
grpc_server = await serve_grpc(scorer, settings.grpc_addr)
app.state.bus = bus
app.state.consumer = consumer
app.state.scorer = scorer
app.state.grpc = grpc_server
app.state.settings = settings
try:
yield
finally:
await stop_grpc(grpc_server)
await consumer.stop()
await bus.close()
app = FastAPI(title="GuestGuard Fraud Engine", lifespan=lifespan)
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}
@app.get("/health/ready")
async def ready() -> dict[str, str]:
bus = getattr(app.state, "bus", None)
if bus is None:
return {"status": "starting"}
return {"status": "ok", "nats": "up"}
def serve() -> None:
settings = load_settings()
uvicorn.run(
"app.main:app",
host=settings.host,
port=settings.port,
log_level="info",
access_log=True,
)
if __name__ == "__main__":
serve()
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
import asyncio
import logging
import nats
from nats.aio.client import Client as NATSClient
from nats.js import JetStreamContext
from nats.js.api import ConsumerConfig, DeliverPolicy, RetentionPolicy, StorageType, StreamConfig
from nats.js.errors import NotFoundError
logger = logging.getLogger(__name__)
STREAM_SUBJECTS = ["guest.>", "fraud.>", "rsvp.>", "invitation.>"]
class NatsBus:
def __init__(self, url: str, stream_name: str) -> None:
self._url = url
self._stream_name = stream_name
self._nc: NATSClient | None = None
self._js: JetStreamContext | None = None
async def connect(self) -> None:
self._nc = await nats.connect(
self._url,
name="guestguard-fraud-engine",
max_reconnect_attempts=-1,
reconnect_time_wait=2,
)
self._js = self._nc.jetstream()
await self._ensure_stream()
logger.info("connected to nats", extra={"url": self._url})
async def _ensure_stream(self) -> None:
assert self._js is not None
try:
await self._js.stream_info(self._stream_name)
return
except NotFoundError:
pass
cfg = StreamConfig(
name=self._stream_name,
subjects=STREAM_SUBJECTS,
retention=RetentionPolicy.LIMITS,
storage=StorageType.FILE,
max_age=14 * 24 * 60 * 60 * 1_000_000_000, # 14 days, ns
)
await self._js.add_stream(config=cfg)
@property
def js(self) -> JetStreamContext:
if self._js is None:
raise RuntimeError("nats not connected")
return self._js
async def subscribe(
self,
subject: str,
durable: str,
handler,
manual_ack: bool = True,
):
cfg = ConsumerConfig(
durable_name=durable,
ack_policy="explicit",
deliver_policy=DeliverPolicy.ALL,
max_deliver=5,
ack_wait=30,
filter_subject=subject,
)
return await self.js.subscribe(
subject=subject,
durable=durable,
cb=handler,
manual_ack=manual_ack,
config=cfg,
)
async def publish(self, subject: str, payload: bytes) -> None:
await self.js.publish(subject, payload)
async def close(self) -> None:
if self._nc is not None:
await self._nc.drain()
await asyncio.sleep(0)
self._nc = None
self._js = None
+30
View File
@@ -0,0 +1,30 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class AccessAttempted(BaseModel):
model_config = ConfigDict(extra="ignore")
event_id: UUID
guest_id: UUID
token_id: UUID
access_log_id: UUID
fingerprint: dict[str, Any] | None = None
ip_address: str | None = None
user_agent: str | None = None
referrer: str | None = None
occurred_at: datetime
class FraudScored(BaseModel):
event_id: UUID
guest_id: UUID
token_id: UUID
access_log_id: UUID
score: int = Field(ge=0, le=100)
risk: str
reasons: list[str]
scored_at: datetime
+138
View File
@@ -0,0 +1,138 @@
"""Heuristic risk scoring.
This is intentionally simple a weighted feature scorer. Each feature returns
a 0-100 sub-score; the overall score is a weighted sum. We keep memory of seen
fingerprints per guest so subsequent accesses can be compared against the
baseline established by the first one.
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass, field
from typing import Any
from uuid import UUID
from app.schemas import AccessAttempted
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
BLOCK = "block"
def risk_band(score: int) -> str:
if score <= 30:
return LOW
if score <= 60:
return MEDIUM
if score <= 85:
return HIGH
return BLOCK
@dataclass
class GuestBaseline:
fingerprint_digest: str | None = None
ip_prefix: str | None = None
accesses: int = 0
@dataclass
class ScoringResult:
score: int
reasons: list[str]
@dataclass
class HeuristicScorer:
weights: dict[str, float] = field(
default_factory=lambda: {
"fingerprint_mismatch": 0.40,
"ip_change": 0.25,
"missing_signals": 0.10,
"repeated_access": 0.10,
"no_user_agent": 0.15,
}
)
baselines: dict[UUID, GuestBaseline] = field(default_factory=dict)
def score(self, evt: AccessAttempted) -> ScoringResult:
reasons: list[str] = []
sub: dict[str, int] = {}
baseline = self.baselines.get(evt.guest_id, GuestBaseline())
current_digest = _fingerprint_digest(evt.fingerprint)
current_prefix = _ip_prefix(evt.ip_address)
if baseline.fingerprint_digest is None:
sub["fingerprint_mismatch"] = 0
elif baseline.fingerprint_digest == current_digest:
sub["fingerprint_mismatch"] = 0
else:
sub["fingerprint_mismatch"] = 100
reasons.append("fingerprint differs from baseline")
if baseline.ip_prefix is None:
sub["ip_change"] = 0
elif baseline.ip_prefix == current_prefix:
sub["ip_change"] = 0
else:
sub["ip_change"] = 80
reasons.append("ip address changed since first access")
if not evt.fingerprint:
sub["missing_signals"] = 70
reasons.append("no device fingerprint provided")
else:
sub["missing_signals"] = 0
sub["repeated_access"] = min(baseline.accesses * 10, 60)
if baseline.accesses >= 5:
reasons.append(f"token accessed {baseline.accesses + 1} times")
if not evt.user_agent:
sub["no_user_agent"] = 80
reasons.append("missing user agent")
else:
sub["no_user_agent"] = 0
weighted = sum(sub[k] * self.weights[k] for k in self.weights)
final = int(round(min(max(weighted, 0), 100)))
# Update baseline AFTER scoring so the first access sets it without
# being penalised against itself.
if baseline.fingerprint_digest is None:
baseline.fingerprint_digest = current_digest
if baseline.ip_prefix is None:
baseline.ip_prefix = current_prefix
baseline.accesses += 1
self.baselines[evt.guest_id] = baseline
return ScoringResult(score=final, reasons=reasons)
def _fingerprint_digest(fp: dict[str, Any] | None) -> str | None:
if not fp:
return None
items = sorted((str(k), str(v)) for k, v in fp.items())
h = hashlib.sha256()
for k, v in items:
h.update(k.encode())
h.update(b"=")
h.update(v.encode())
h.update(b";")
return h.hexdigest()
def _ip_prefix(ip: str | None) -> str | None:
if not ip:
return None
if ":" in ip:
# IPv6 — keep first 4 hextets
parts = ip.split(":")[:4]
return ":".join(parts)
parts = ip.split(".")
if len(parts) == 4:
return ".".join(parts[:3])
return ip