3f8bc58ca9
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>
137 lines
3.2 KiB
Go
137 lines
3.2 KiB
Go
package fraud
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"google.golang.org/grpc"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/credentials/insecure"
|
|
"google.golang.org/grpc/status"
|
|
|
|
pb "github.com/alchemistkay/guestguard/internal/fraudpb"
|
|
)
|
|
|
|
type Decision struct {
|
|
Score int `json:"score"`
|
|
Risk string `json:"risk"`
|
|
Reasons []string `json:"reasons"`
|
|
Used bool `json:"used"` // false means we returned the fallback (engine unavailable / timed out)
|
|
}
|
|
|
|
type ScoreInput struct {
|
|
EventID uuid.UUID
|
|
GuestID uuid.UUID
|
|
TokenID uuid.UUID
|
|
AccessLogID uuid.UUID
|
|
Fingerprint map[string]string
|
|
IPAddress string
|
|
UserAgent string
|
|
Referrer string
|
|
}
|
|
|
|
type Client struct {
|
|
conn *grpc.ClientConn
|
|
stub pb.FraudServiceClient
|
|
timeout time.Duration
|
|
logger *slog.Logger
|
|
}
|
|
|
|
func Dial(ctx context.Context, addr string, timeout time.Duration, logger *slog.Logger) (*Client, error) {
|
|
if addr == "" {
|
|
return nil, errors.New("fraud grpc addr is empty")
|
|
}
|
|
|
|
conn, err := grpc.NewClient(addr,
|
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dial fraud grpc: %w", err)
|
|
}
|
|
|
|
return &Client{
|
|
conn: conn,
|
|
stub: pb.NewFraudServiceClient(conn),
|
|
timeout: timeout,
|
|
logger: logger,
|
|
}, nil
|
|
}
|
|
|
|
func (c *Client) Close() error {
|
|
if c.conn == nil {
|
|
return nil
|
|
}
|
|
return c.conn.Close()
|
|
}
|
|
|
|
// Score is a synchronous fraud check. If the engine is unreachable or slow,
|
|
// it returns a permissive fallback (Used=false) so the API stays available.
|
|
// The caller can still decide what to do with that signal.
|
|
func (c *Client) Score(ctx context.Context, in ScoreInput) Decision {
|
|
callCtx, cancel := context.WithTimeout(ctx, c.timeout)
|
|
defer cancel()
|
|
|
|
resp, err := c.stub.Score(callCtx, &pb.ScoreRequest{
|
|
EventId: in.EventID.String(),
|
|
GuestId: in.GuestID.String(),
|
|
TokenId: in.TokenID.String(),
|
|
AccessLogId: in.AccessLogID.String(),
|
|
Fingerprint: in.Fingerprint,
|
|
IpAddress: in.IPAddress,
|
|
UserAgent: in.UserAgent,
|
|
Referrer: in.Referrer,
|
|
})
|
|
if err != nil {
|
|
c.logger.Warn("fraud sync score failed, falling back",
|
|
"err", err,
|
|
"code", status.Code(err),
|
|
"guest_id", in.GuestID,
|
|
)
|
|
return Decision{Score: 0, Risk: "low", Reasons: []string{"fraud_engine_unavailable"}, Used: false}
|
|
}
|
|
|
|
return Decision{
|
|
Score: int(resp.Score),
|
|
Risk: riskString(resp.Risk),
|
|
Reasons: append([]string{}, resp.Reasons...),
|
|
Used: true,
|
|
}
|
|
}
|
|
|
|
func riskString(r pb.Risk) string {
|
|
switch r {
|
|
case pb.Risk_RISK_LOW:
|
|
return "low"
|
|
case pb.Risk_RISK_MEDIUM:
|
|
return "medium"
|
|
case pb.Risk_RISK_HIGH:
|
|
return "high"
|
|
case pb.Risk_RISK_BLOCK:
|
|
return "block"
|
|
default:
|
|
return "unknown"
|
|
}
|
|
}
|
|
|
|
// IsBlock is a small helper so callers don't depend on the string contract.
|
|
func IsBlock(d Decision) bool {
|
|
return d.Risk == "block"
|
|
}
|
|
|
|
// IsRetryableErr distinguishes transient gRPC errors. Currently unused but
|
|
// kept for future retry middleware.
|
|
func IsRetryableErr(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
switch status.Code(err) {
|
|
case codes.Unavailable, codes.DeadlineExceeded, codes.ResourceExhausted:
|
|
return true
|
|
}
|
|
return false
|
|
}
|