diff --git a/cmd/api/main.go b/cmd/api/main.go index 0bafae4..d8c785a 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -89,6 +89,10 @@ func run() error { Score: evt.Score, Reasons: evt.Reasons, Flagged: evt.Score >= 60, + GeoCountry: evt.GeoCountry, + GeoCity: evt.GeoCity, + GeoLat: evt.GeoLat, + GeoLon: evt.GeoLon, }); err != nil { return err } @@ -219,6 +223,12 @@ func run() error { return err } + // Cross-cutting feature-flag store: initial load + 30s background + // refresher. stopFlags cancels the refresher on shutdown so the + // goroutine exits cleanly. + stopFlags := apiSrv.FeatureFlags().Start(rootCtx) + defer stopFlags() + srv := &http.Server{ Addr: cfg.HTTPAddr, Handler: apiSrv.Handler(), diff --git a/cmd/notifier/main.go b/cmd/notifier/main.go index 905e078..60d7d91 100644 --- a/cmd/notifier/main.go +++ b/cmd/notifier/main.go @@ -2,11 +2,18 @@ package main import ( "context" + "encoding/base64" + htmltemplate "html/template" "log/slog" "os" "os/signal" + "strings" "syscall" + "time" + "github.com/skip2/go-qrcode" + + "github.com/alchemistkay/guestguard/internal/auth" "github.com/alchemistkay/guestguard/internal/config" "github.com/alchemistkay/guestguard/internal/natspub" "github.com/alchemistkay/guestguard/internal/notification" @@ -104,10 +111,22 @@ func run() error { sender := notification.NewRouter(emailSender, smsSender) + // Tier 2 Block H — QR signer for the confirmation email's door + // code. Same secret as the API so the same JWT verifies on either + // service. + checkInQRSigner, err := auth.NewCheckInQRSigner(cfg.JWTSecret, cfg.JWTIssuer, 6*time.Hour) + if err != nil { + return err + } + + guestRepo := storage.NewGuestRepo(db) + eventRepo := storage.NewEventRepo(db) + rsvpSub, err := natspub.NewRSVPConfirmedSubscriber( rootCtx, natsClient, "notifier-rsvp-confirmed", func(ctx context.Context, evt natspub.RSVPConfirmed) error { - return handleRSVPConfirmed(ctx, logger, repo, sender, evt) + return handleRSVPConfirmed(ctx, logger, repo, sender, + guestRepo, eventRepo, checkInQRSigner, cfg.PublicBaseURL, evt) }, logger, ) @@ -169,21 +188,96 @@ func handleRSVPConfirmed( logger *slog.Logger, repo *notification.Repo, sender notification.Sender, + guests *storage.GuestRepo, + events *storage.EventRepo, + qr *auth.CheckInQRSigner, + publicBaseURL string, evt natspub.RSVPConfirmed, ) error { + // Look up the guest + event so the templated confirmation has real + // names, venue, and date. The NATS event only carries IDs. + guest, gerr := guests.Get(ctx, evt.GuestID) + if gerr != nil { + logger.Warn("load guest for confirmation email", "err", gerr, "guest_id", evt.GuestID) + } + event, eerr := events.Get(ctx, evt.EventID) + if eerr != nil { + logger.Warn("load event for confirmation email", "err", eerr, "event_id", evt.EventID) + } + + if guest == nil || guest.Email == nil || *guest.Email == "" { + // No email on file: nothing to do here. The host either has + // SMS wired up (handled separately) or shares the QR via the + // confirmation page link. + logger.Info("skip rsvp confirmation email — no guest email", + "guest_id", evt.GuestID, "event_id", evt.EventID) + return nil + } + + // Mint a QR for attending guests so the email carries their door + // code along with the confirmation. Other responses get the same + // email minus the QR section. + // + // The image is rendered as a data: URL and handed to html/template as + // a template.URL — without the type wrapper, html/template's + // contextual-URL escaper treats `data:` as unsafe and substitutes + // the placeholder `#ZgotmplZ`, which is why the image appeared + // broken in Mailpit and inbox clients. Marking it template.URL + // signals "we trust this URL" and lets the data: prefix through. + var qrImage htmltemplate.URL + if evt.Response == "attending" && event != nil && qr != nil { + now := time.Now().UTC() + raw, _, qerr := qr.Issue(evt.EventID, evt.GuestID, event.EventDate, now) + if qerr != nil { + logger.Warn("issue qr for confirmation email", "err", qerr, "guest_id", evt.GuestID) + } else if png, perr := qrcode.Encode(raw, qrcode.Medium, 320); perr != nil { + logger.Warn("render qr png for confirmation email", "err", perr) + } else { + qrImage = htmltemplate.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(png)) + } + } + + eventName := "" + venue := "" + eventDate := "" + if event != nil { + eventName = event.Name + venue = event.Venue + if !event.EventDate.IsZero() { + eventDate = event.EventDate.Format("Mon, 02 Jan 2006 · 15:04") + } + } + + // Prefer the access link carried on the NATS event — that's the + // guest's actual /rsvp/ URL. If we lost it (older event + // schema, or token was somehow missing), fall back to the bare + // dashboard origin so the template still renders without a 404. + rsvpLink := strings.TrimSpace(evt.AccessLink) + if rsvpLink == "" && publicBaseURL != "" { + rsvpLink = strings.TrimRight(publicBaseURL, "/") + } + + subject := "RSVP confirmed" + if eventName != "" { + subject = "RSVP confirmed — " + eventName + } + msg := notification.OutboundMessage{ GuestID: evt.GuestID, Channel: notification.ChannelEmail, Type: notification.TypeConfirmation, - Subject: "Your RSVP is confirmed", - Body: "Thanks for your response.", + Subject: subject, Metadata: map[string]any{ - "rsvp_id": evt.RSVPID, - "event_id": evt.EventID, - "response": evt.Response, - "plus_ones": evt.PlusOnes, - "risk_score": evt.RiskScore, - "submitted_at": evt.SubmittedAt, + "to": *guest.Email, + "GuestName": guest.Name, + "HostName": "your host", // notifier doesn't have the host's name handy + "EventName": eventName, + "Venue": venue, + "EventDate": eventDate, + "Response": evt.Response, + "PlusOnes": evt.PlusOnes, + "QRImage": qrImage, + "RSVPLink": rsvpLink, }, } diff --git a/docker-compose.yml b/docker-compose.yml index e2e6df0..3359c7b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -113,12 +113,21 @@ services: GG_HTTP_ADDR: 0.0.0.0:8081 GG_GRPC_ADDR: 0.0.0.0:9091 GG_NATS_URL: nats://nats:4222 + # Tier 2 Block G — geolocation. "auto" prefers MaxMind when a + # mmdb file lives at GG_GEOIP_DB_PATH, else falls back to free + # ip-api.com. Set GG_GEOIP_PROVIDER=null to disable entirely for + # local-only dev with no outbound internet. + GG_GEOIP_PROVIDER: ${GG_GEOIP_PROVIDER:-auto} + GG_GEOIP_DB_PATH: ${GG_GEOIP_DB_PATH:-/var/lib/guestguard/GeoLite2-City.mmdb} + GG_REDIS_URL: redis://redis:6379 ports: - "8081:8081" - "9091:9091" depends_on: nats: condition: service_healthy + redis: + condition: service_healthy restart: unless-stopped notifier: diff --git a/fraud-engine/app/config.py b/fraud-engine/app/config.py index be9edf3..8afecc3 100644 --- a/fraud-engine/app/config.py +++ b/fraud-engine/app/config.py @@ -12,6 +12,16 @@ class Settings(BaseSettings): stream_name: str = Field(default="GUESTGUARD") consumer_durable: str = Field(default="fraud-engine-access") + # Tier 2 Block G — geolocation enrichment. + # provider: "auto" picks MaxMind when GG_GEOIP_DB_PATH points to an + # existing .mmdb, else falls back to the free ip-api.com endpoint. + # "null" turns geolocation off entirely (useful for tests). + geoip_provider: str = Field(default="auto") + geoip_db_path: str | None = Field(default=None) + # Redis URL for caching geo lookups (30-day TTL). Empty means + # uncached — every miss hits the upstream resolver. + redis_url: str = Field(default="redis://redis:6379") + @property def host(self) -> str: return self.http_addr.split(":", 1)[0] or "0.0.0.0" diff --git a/fraud-engine/app/consumer.py b/fraud-engine/app/consumer.py index f9fcfcb..2067e80 100644 --- a/fraud-engine/app/consumer.py +++ b/fraud-engine/app/consumer.py @@ -5,6 +5,7 @@ from datetime import UTC, datetime from nats.aio.msg import Msg +from app.geo import GeoResolver from app.nats_bus import NatsBus from app.schemas import AccessAttempted, FraudScored from app.scoring import HeuristicScorer, risk_band @@ -16,10 +17,17 @@ SUBJECT_FRAUD_SCORED = "fraud.scored" class FraudConsumer: - def __init__(self, bus: NatsBus, durable: str, scorer: HeuristicScorer) -> None: + def __init__( + self, + bus: NatsBus, + durable: str, + scorer: HeuristicScorer, + geo: GeoResolver | None = None, + ) -> None: self._bus = bus self._durable = durable self._scorer = scorer + self._geo = geo self._subscription = None async def start(self) -> None: @@ -45,7 +53,8 @@ class FraudConsumer: return try: - result = self._scorer.score(evt) + geo = await self._geo.resolve(evt.ip_address) if self._geo else None + result = self._scorer.score(evt, geo=geo) scored = FraudScored( event_id=evt.event_id, guest_id=evt.guest_id, @@ -55,6 +64,10 @@ class FraudConsumer: risk=risk_band(result.score), reasons=result.reasons, scored_at=datetime.now(UTC), + geo_country=(geo.country if geo else None), + geo_city=(geo.city if geo else None), + geo_lat=(geo.lat if geo else None), + geo_lon=(geo.lon if geo else None), ) await self._bus.publish( SUBJECT_FRAUD_SCORED, diff --git a/fraud-engine/app/geo.py b/fraud-engine/app/geo.py new file mode 100644 index 0000000..bf5a74c --- /dev/null +++ b/fraud-engine/app/geo.py @@ -0,0 +1,287 @@ +"""Geolocation resolution for the fraud engine. + +Tier 2 Block G wants two things here: + + 1. Enrich every scored access with (country, city, lat, lon) so the + host UI can render "Sam opened from Lagos, Nigeria" rather than a + raw IPv4 string nobody can read. + + 2. Surface large geographic jumps (>500 km in <1h) as a scoring + signal — the geo_jump feature in scoring.py. That feature needs + the *previous* access's coordinates, which we stash on the per- + guest baseline alongside fingerprint + IP prefix. + +Design choices: + + * Pluggable resolvers. The spec mentions MaxMind GeoIP2 *or* a free + HTTP API like ipapi.com. We support both: MaxMind reads a local + GeoLite2 mmdb file (set `GG_GEOIP_DB_PATH`), and the default falls + back to ip-api.com (free, no auth, 45 req/min/IP — fine for a + homelab demo, sized to upgrade later). + + * Redis cache wrapper. Lookups are stable for ~30 days; caching + avoids hammering the upstream and keeps the synchronous gRPC scoring + path fast on repeat opens of the same invitation. + + * Private + invalid IPs short-circuit to None. Loopback, RFC1918, IPv6 + link-local etc. would just confuse the upstream and waste a Redis + miss. + + * Fail-open. A resolver error (network blip, malformed response) is + *not* a scoring signal — we score with `geo=None` and move on. +""" + +from __future__ import annotations + +import asyncio +import ipaddress +import json +import logging +from dataclasses import dataclass +from typing import Protocol + +logger = logging.getLogger(__name__) + + +@dataclass +class GeoLocation: + country: str | None = None # ISO-3166 alpha-2 code, e.g. "NG" + city: str | None = None + lat: float | None = None + lon: float | None = None + + def __bool__(self) -> bool: + return bool(self.country or self.city or self.lat is not None) + + +class GeoResolver(Protocol): + async def resolve(self, ip: str | None) -> GeoLocation | None: ... + async def close(self) -> None: ... + + +# --- helpers --- + + +def _is_resolvable(ip: str | None) -> bool: + if not ip: + return False + try: + a = ipaddress.ip_address(ip) + except ValueError: + return False + if a.is_loopback or a.is_private or a.is_link_local or a.is_multicast: + return False + if a.is_unspecified or a.is_reserved: + return False + return True + + +# --- null (test / disabled) --- + + +class NullResolver: + """Returns None for everything. Used in tests and when geolocation + is explicitly disabled via `GG_GEOIP_PROVIDER=null`.""" + + async def resolve(self, ip: str | None) -> GeoLocation | None: + return None + + async def close(self) -> None: + return None + + +# --- ip-api.com (default for dev) --- + + +class IPApiResolver: + """Resolves via http://ip-api.com — free, no auth, 45 req/min/IP. + + We deliberately stay on HTTP (not HTTPS) because the free tier + redirects HTTPS to a 403; the request carries no credentials so the + cleartext-ness isn't a leak. Switch to a paid tier (ipapi.co, ipinfo, + MaxMind) for production load. + """ + + def __init__(self, timeout_seconds: float = 1.5) -> None: + # aiohttp is imported lazily so app.scoring can be unit-tested + # without the optional HTTP dep installed. + import aiohttp # noqa: PLC0415 + + self._aiohttp = aiohttp + self._timeout = aiohttp.ClientTimeout(total=timeout_seconds) + self._session = None + + def _session_or_create(self): + if self._session is None or self._session.closed: + self._session = self._aiohttp.ClientSession(timeout=self._timeout) + return self._session + + async def resolve(self, ip: str | None) -> GeoLocation | None: + if not _is_resolvable(ip): + return None + session = self._session_or_create() + url = f"http://ip-api.com/json/{ip}?fields=status,country,countryCode,city,lat,lon" + try: + async with session.get(url) as resp: + if resp.status != 200: + logger.debug("geoip lookup non-200", extra={"ip": ip, "status": resp.status}) + return None + data = await resp.json(content_type=None) + except (self._aiohttp.ClientError, asyncio.TimeoutError) as exc: + logger.debug("geoip lookup error", extra={"ip": ip, "err": str(exc)}) + return None + if data.get("status") != "success": + return None + return GeoLocation( + country=data.get("countryCode") or data.get("country"), + city=data.get("city"), + lat=data.get("lat"), + lon=data.get("lon"), + ) + + async def close(self) -> None: + if self._session is not None and not self._session.closed: + await self._session.close() + + +# --- MaxMind GeoLite2-City (lazy import) --- + + +class MaxMindResolver: + """Reads a local GeoLite2-City.mmdb. Lazy-imports `geoip2` so the + base image doesn't carry it unless this resolver is actually + selected. Synchronous reader; we call it in a thread to keep the + asyncio loop unblocked.""" + + def __init__(self, db_path: str) -> None: + import geoip2.database # noqa: PLC0415 — intentionally lazy + + self._reader = geoip2.database.Reader(db_path) + + async def resolve(self, ip: str | None) -> GeoLocation | None: + if not _is_resolvable(ip): + return None + try: + rec = await asyncio.to_thread(self._reader.city, ip) + except Exception as exc: # noqa: BLE001 — generic per geoip2 raise hierarchy + logger.debug("maxmind lookup error", extra={"ip": ip, "err": str(exc)}) + return None + return GeoLocation( + country=rec.country.iso_code, + city=rec.city.name, + lat=rec.location.latitude, + lon=rec.location.longitude, + ) + + async def close(self) -> None: + try: + self._reader.close() + except Exception: # noqa: BLE001 — close is best-effort + pass + + +# --- Redis-cached wrapper --- + + +class CachedGeoResolver: + """Wraps any resolver in a Redis cache. 30-day TTL because public + IPs rarely change location, and dropping the wrong city for a few + days is cheaper than re-querying ip-api.com on every page load.""" + + KEY_PREFIX = "gg:geo:v1:" + TTL_SECONDS = 30 * 24 * 3600 + + def __init__(self, inner: GeoResolver, redis_client) -> None: + self._inner = inner + self._redis = redis_client + + async def resolve(self, ip: str | None) -> GeoLocation | None: + if not _is_resolvable(ip): + return None + key = self.KEY_PREFIX + ip # type: ignore[operator] + try: + cached = await self._redis.get(key) + except Exception as exc: # noqa: BLE001 + logger.debug("geo cache get failed", extra={"err": str(exc)}) + cached = None + if cached: + try: + data = json.loads(cached) + return GeoLocation(**data) + except (ValueError, TypeError): + pass + + result = await self._inner.resolve(ip) + if result is not None: + try: + await self._redis.set( + key, + json.dumps(result.__dict__), + ex=self.TTL_SECONDS, + ) + except Exception as exc: # noqa: BLE001 + logger.debug("geo cache set failed", extra={"err": str(exc)}) + return result + + async def close(self) -> None: + await self._inner.close() + + +# --- factory --- + + +async def make_resolver( + *, + provider: str, + db_path: str | None, + redis_url: str | None, +) -> GeoResolver: + """Build the resolver stack from settings. + + provider: + - "null": NullResolver (geo disabled) + - "ipapi": IPApiResolver + - "maxmind": MaxMindResolver (requires db_path) + - "auto": MaxMind if db_path file exists, else IPApi + + Wraps in CachedGeoResolver when redis_url is set. + """ + inner: GeoResolver + chosen = provider.lower() + if chosen == "auto": + chosen = "maxmind" if (db_path and _file_exists(db_path)) else "ipapi" + + if chosen == "null": + inner = NullResolver() + elif chosen == "maxmind": + if not db_path or not _file_exists(db_path): + logger.warning("maxmind db missing — falling back to ipapi") + inner = IPApiResolver() + else: + try: + inner = MaxMindResolver(db_path) + except Exception as exc: # noqa: BLE001 + logger.warning("maxmind init failed — falling back to ipapi", extra={"err": str(exc)}) + inner = IPApiResolver() + else: + inner = IPApiResolver() + + if not redis_url: + return inner + + try: + import redis.asyncio as redislib # noqa: PLC0415 + + client = redislib.from_url(redis_url, decode_responses=True) + await client.ping() + logger.info("geo cache: redis connected", extra={"url": redis_url}) + return CachedGeoResolver(inner, client) + except Exception as exc: # noqa: BLE001 + logger.warning("geo cache: redis unavailable — running uncached", extra={"err": str(exc)}) + return inner + + +def _file_exists(path: str) -> bool: + import os # noqa: PLC0415 + + return os.path.isfile(path) diff --git a/fraud-engine/app/grpc_server.py b/fraud-engine/app/grpc_server.py index 3396783..c31bac0 100644 --- a/fraud-engine/app/grpc_server.py +++ b/fraud-engine/app/grpc_server.py @@ -7,6 +7,7 @@ from uuid import UUID import grpc +from app.geo import GeoResolver 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 @@ -22,8 +23,9 @@ _RISK_TO_PROTO = { class FraudServicer(fraud_pb2_grpc.FraudServiceServicer): - def __init__(self, scorer: HeuristicScorer) -> None: + def __init__(self, scorer: HeuristicScorer, geo: GeoResolver | None) -> None: self._scorer = scorer + self._geo = geo async def Score( # noqa: N802 — gRPC method self, @@ -46,7 +48,16 @@ class FraudServicer(fraud_pb2_grpc.FraudServiceServicer): await context.abort(grpc.StatusCode.INVALID_ARGUMENT, f"bad request: {exc}") raise # unreachable, abort raises - result = self._scorer.score(evt) + # gRPC path is synchronous-from-the-caller's-perspective: the + # API blocks the RSVP submit on this score. A 1.5s timeout on + # the geo resolver keeps the worst case bounded even if the + # upstream provider is slow. Fail-open: no geo → score as if + # the feature weren't there. + geo = None + if self._geo is not None: + geo = await self._geo.resolve(evt.ip_address) + + result = self._scorer.score(evt, geo=geo) band = risk_band(result.score) return fraud_pb2.ScoreResponse( score=result.score, @@ -55,9 +66,13 @@ class FraudServicer(fraud_pb2_grpc.FraudServiceServicer): ) -async def serve_grpc(scorer: HeuristicScorer, addr: str) -> grpc.aio.Server: +async def serve_grpc( + scorer: HeuristicScorer, + addr: str, + geo: GeoResolver | None = None, +) -> grpc.aio.Server: server = grpc.aio.server() - fraud_pb2_grpc.add_FraudServiceServicer_to_server(FraudServicer(scorer), server) + fraud_pb2_grpc.add_FraudServiceServicer_to_server(FraudServicer(scorer, geo), server) server.add_insecure_port(addr) await server.start() logger.info("grpc server started", extra={"addr": addr}) diff --git a/fraud-engine/app/main.py b/fraud-engine/app/main.py index abb7fce..1c939e4 100644 --- a/fraud-engine/app/main.py +++ b/fraud-engine/app/main.py @@ -9,6 +9,7 @@ from fastapi import FastAPI from app.config import load_settings from app.consumer import FraudConsumer +from app.geo import make_resolver from app.grpc_server import serve_grpc, stop_grpc from app.nats_bus import NatsBus from app.scoring import HeuristicScorer @@ -29,15 +30,28 @@ async def lifespan(app: FastAPI): bus = NatsBus(settings.nats_url, settings.stream_name) await bus.connect() + geo = await make_resolver( + provider=settings.geoip_provider, + db_path=settings.geoip_db_path, + redis_url=settings.redis_url, + ) + logger.info( + "geo resolver", + provider=settings.geoip_provider, + cached=bool(settings.redis_url), + kind=type(geo).__name__, + ) + scorer = HeuristicScorer() - consumer = FraudConsumer(bus, settings.consumer_durable, scorer) + consumer = FraudConsumer(bus, settings.consumer_durable, scorer, geo=geo) await consumer.start() - grpc_server = await serve_grpc(scorer, settings.grpc_addr) + grpc_server = await serve_grpc(scorer, settings.grpc_addr, geo=geo) app.state.bus = bus app.state.consumer = consumer app.state.scorer = scorer + app.state.geo = geo app.state.grpc = grpc_server app.state.settings = settings @@ -46,6 +60,7 @@ async def lifespan(app: FastAPI): finally: await stop_grpc(grpc_server) await consumer.stop() + await geo.close() await bus.close() diff --git a/fraud-engine/app/schemas.py b/fraud-engine/app/schemas.py index 4a9dc0d..4f56a57 100644 --- a/fraud-engine/app/schemas.py +++ b/fraud-engine/app/schemas.py @@ -28,3 +28,11 @@ class FraudScored(BaseModel): risk: str reasons: list[str] scored_at: datetime + + # Tier 2 Block G geolocation enrichment. All four optional because + # private IPs, lookup failures, and disabled-provider mode all + # leave them unset — the API consumer must handle null. + geo_country: str | None = None + geo_city: str | None = None + geo_lat: float | None = None + geo_lon: float | None = None diff --git a/fraud-engine/app/scoring.py b/fraud-engine/app/scoring.py index 853f92f..4978428 100644 --- a/fraud-engine/app/scoring.py +++ b/fraud-engine/app/scoring.py @@ -9,10 +9,13 @@ baseline established by the first one. from __future__ import annotations import hashlib +import math from dataclasses import dataclass, field +from datetime import datetime from typing import Any from uuid import UUID +from app.geo import GeoLocation from app.schemas import AccessAttempted LOW = "low" @@ -36,12 +39,20 @@ class GuestBaseline: fingerprint_digest: str | None = None ip_prefix: str | None = None accesses: int = 0 + # Tier 2 Block G — geo_jump. Stash the most recent coordinates + the + # timestamp of the access they came from so the next access can be + # compared against them. + last_lat: float | None = None + last_lon: float | None = None + last_geo_at: datetime | None = None + last_country: str | None = None @dataclass class ScoringResult: score: int reasons: list[str] + geo: GeoLocation | None = None @dataclass @@ -53,11 +64,17 @@ class HeuristicScorer: "missing_signals": 0.10, "repeated_access": 0.10, "no_user_agent": 0.15, + # Tier 2 Block G — geo_jump. Implausibly fast travel between + # two accesses (>500 km in <1h) carries the heaviest weight + # alongside fingerprint mismatch. Note the weights are not + # required to sum to 1; the final score is clamped to + # [0, 100] so the relative magnitudes are what matters. + "geo_jump": 0.40, } ) baselines: dict[UUID, GuestBaseline] = field(default_factory=dict) - def score(self, evt: AccessAttempted) -> ScoringResult: + def score(self, evt: AccessAttempted, geo: GeoLocation | None = None) -> ScoringResult: reasons: list[str] = [] sub: dict[str, int] = {} @@ -97,7 +114,37 @@ class HeuristicScorer: else: sub["no_user_agent"] = 0 - weighted = sum(sub[k] * self.weights[k] for k in self.weights) + # geo_jump — implausibly fast travel between this access and the + # previous one. 500 km in under an hour means either a private + # jet or, far more likely, a stolen invitation being opened by + # someone in a different country. Spec threshold from + # docs/TIER2_PLAN.md Block G. + sub["geo_jump"] = 0 + if ( + geo is not None + and geo.lat is not None + and geo.lon is not None + and baseline.last_lat is not None + and baseline.last_lon is not None + and baseline.last_geo_at is not None + ): + km = _haversine_km( + baseline.last_lat, baseline.last_lon, geo.lat, geo.lon + ) + dt = (evt.occurred_at - baseline.last_geo_at).total_seconds() + if km > 500 and 0 < dt < 3600: + sub["geo_jump"] = 100 + where_now = geo.city or geo.country or "elsewhere" + where_before = baseline.last_country or "another location" + mins = max(int(dt / 60), 1) + reasons.append( + f"accessed from {where_before} and {where_now} within {mins} minutes" + ) + elif km > 500 and dt < 21600: # within 6h is still suspicious-but-possible + sub["geo_jump"] = 50 + reasons.append(f"large geographic jump ({int(km)} km)") + + weighted = sum(sub[k] * self.weights.get(k, 0) for k in sub) final = int(round(min(max(weighted, 0), 100))) # Tier 2 Block G — tighten the consecutive-fingerprint false @@ -124,10 +171,24 @@ class HeuristicScorer: baseline.fingerprint_digest = current_digest if baseline.ip_prefix is None: baseline.ip_prefix = current_prefix + if geo is not None and geo.lat is not None and geo.lon is not None: + baseline.last_lat = geo.lat + baseline.last_lon = geo.lon + baseline.last_geo_at = evt.occurred_at + baseline.last_country = geo.city or geo.country baseline.accesses += 1 self.baselines[evt.guest_id] = baseline - return ScoringResult(score=final, reasons=reasons) + return ScoringResult(score=final, reasons=reasons, geo=geo) + + +def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Great-circle distance in kilometres. Earth radius 6371 km.""" + rlat1, rlat2 = math.radians(lat1), math.radians(lat2) + dlat = math.radians(lat2 - lat1) + dlon = math.radians(lon2 - lon1) + a = math.sin(dlat / 2) ** 2 + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2 + return 2 * 6371.0 * math.asin(math.sqrt(a)) def _fingerprint_digest(fp: dict[str, Any] | None) -> str | None: diff --git a/fraud-engine/pyproject.toml b/fraud-engine/pyproject.toml index 9855856..b62e785 100644 --- a/fraud-engine/pyproject.toml +++ b/fraud-engine/pyproject.toml @@ -12,6 +12,13 @@ dependencies = [ "structlog>=24.4", "grpcio>=1.66", "protobuf>=5.28", + # Tier 2 Block G — geolocation enrichment. + # aiohttp drives the ip-api.com HTTP path; redis caches lookups. + # geoip2 is intentionally NOT pinned here — when a homelab wants + # to use MaxMind it can install it on top of the base image + # (it's ~2.5MB) without forcing it on every deployment. + "aiohttp>=3.10", + "redis>=5.0", ] [project.optional-dependencies] diff --git a/fraud-engine/tests/test_grpc_server.py b/fraud-engine/tests/test_grpc_server.py index 3474bce..0cc87ef 100644 --- a/fraud-engine/tests/test_grpc_server.py +++ b/fraud-engine/tests/test_grpc_server.py @@ -108,4 +108,4 @@ async def test_invalid_uuid_returns_invalid_argument(): def test_servicer_constructs(): # Ensures the servicer wires up against the generated stub. - FraudServicer(HeuristicScorer()) + FraudServicer(HeuristicScorer(), None) diff --git a/fraud-engine/tests/test_scoring.py b/fraud-engine/tests/test_scoring.py index 0ae7c30..5099ca0 100644 --- a/fraud-engine/tests/test_scoring.py +++ b/fraud-engine/tests/test_scoring.py @@ -1,6 +1,7 @@ -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from uuid import uuid4 +from app.geo import GeoLocation from app.schemas import AccessAttempted from app.scoring import HeuristicScorer, risk_band @@ -11,6 +12,7 @@ def _evt( fingerprint=None, ip=None, user_agent="Mozilla/5.0", + occurred_at=None, ): return AccessAttempted( event_id=uuid4(), @@ -20,7 +22,7 @@ def _evt( fingerprint=fingerprint, ip_address=ip, user_agent=user_agent, - occurred_at=datetime.now(UTC), + occurred_at=occurred_at or datetime.now(UTC), ) @@ -79,3 +81,70 @@ def test_score_clamped_to_0_100(): for i in range(12): res = scorer.score(_evt(guest_id=guest, fingerprint=None, ip=f"10.0.{i}.1", user_agent=None)) assert 0 <= res.score <= 100 + + +# --- Tier 2 Block G: geo_jump --- + + +_LAGOS = GeoLocation(country="NG", city="Lagos", lat=6.5244, lon=3.3792) +_PARIS = GeoLocation(country="FR", city="Paris", lat=48.8566, lon=2.3522) + + +def test_geo_jump_fires_on_implausible_travel(): + """Two accesses 5,000+ km apart within 12 minutes is the textbook + forwarded-link case the spec targets.""" + scorer = HeuristicScorer() + guest = uuid4() + t0 = datetime.now(UTC) + + first = _evt( + guest_id=guest, + fingerprint={"ua": "Chrome"}, + ip="102.89.0.1", + occurred_at=t0, + ) + scorer.score(first, geo=_LAGOS) + + twelve_mins_later = _evt( + guest_id=guest, + fingerprint={"ua": "Chrome"}, + ip="80.10.20.30", + occurred_at=t0 + timedelta(minutes=12), + ) + res = scorer.score(twelve_mins_later, geo=_PARIS) + + assert any("Lagos" in r and "Paris" in r for r in res.reasons), res.reasons + # geo_jump (100 × 0.40) + ip_change (80 × 0.20) = 56 weighted; the + # single-signal cap (geo_jump + ip_change both ≥ 70, so 2 signals) + # does NOT trigger and the score stays > the cap-at-55 floor. + assert res.score >= 55, res.score + + +def test_geo_jump_does_not_fire_for_local_movement(): + """A train-trip distance shouldn't escalate.""" + scorer = HeuristicScorer() + guest = uuid4() + t0 = datetime.now(UTC) + london = GeoLocation(country="GB", city="London", lat=51.5074, lon=-0.1278) + brighton = GeoLocation(country="GB", city="Brighton", lat=50.8225, lon=-0.1372) + + scorer.score( + _evt(guest_id=guest, fingerprint={"ua": "Chrome"}, ip="80.0.0.1", occurred_at=t0), + geo=london, + ) + res = scorer.score( + _evt( + guest_id=guest, + fingerprint={"ua": "Chrome"}, + ip="80.0.0.1", + occurred_at=t0 + timedelta(hours=2), + ), + geo=brighton, + ) + assert not any("Lagos" in r or "minutes" in r.lower() for r in res.reasons) + + +def test_geo_jump_carries_geo_back_on_result(): + scorer = HeuristicScorer() + res = scorer.score(_evt(fingerprint={"ua": "Chrome"}, ip="102.89.0.1"), geo=_LAGOS) + assert res.geo is _LAGOS diff --git a/frontend/components/CheckInCard.vue b/frontend/components/CheckInCard.vue index aefda71..0cc5e0b 100644 --- a/frontend/components/CheckInCard.vue +++ b/frontend/components/CheckInCard.vue @@ -1,12 +1,24 @@ + + diff --git a/frontend/public/icons/scanner-192.png b/frontend/public/icons/scanner-192.png new file mode 100644 index 0000000..15c3b7b Binary files /dev/null and b/frontend/public/icons/scanner-192.png differ diff --git a/frontend/public/icons/scanner-512.png b/frontend/public/icons/scanner-512.png new file mode 100644 index 0000000..66d7eec Binary files /dev/null and b/frontend/public/icons/scanner-512.png differ diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..430b586 --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,25 @@ +{ + "name": "GuestGuard Scanner", + "short_name": "GG Scanner", + "description": "Door check-in scanner for GuestGuard events. Add to home screen for a fullscreen experience.", + "start_url": "/scanner", + "scope": "/scanner", + "display": "standalone", + "orientation": "portrait", + "background_color": "#0a0a0a", + "theme_color": "#0a0a0a", + "icons": [ + { + "src": "/icons/scanner-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/icons/scanner-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ] +} diff --git a/frontend/public/scanner-sw.js b/frontend/public/scanner-sw.js new file mode 100644 index 0000000..9f85084 --- /dev/null +++ b/frontend/public/scanner-sw.js @@ -0,0 +1,60 @@ +// GuestGuard Scanner — minimal service worker. +// +// Why so light: the scanner is an online tool. Letting the SW cache and +// then serve stale check-in submissions would create double-arrivals +// and lost data. So this SW only: +// +// 1. Claims clients on install so the "Add to home screen" launcher +// opens the latest version straight away. +// 2. Caches the bare app shell (the page itself + jsQR) so the +// volunteer can re-open the scanner if the phone briefly drops +// 4G. Auth + check-in calls always go to network. +// +// Anything fancier (background sync queues, IndexedDB cache of pending +// check-ins) belongs in a later iteration once we have a story for +// merging conflicts. + +const SHELL_CACHE = 'gg-scanner-shell-v1' +const SHELL_URLS = [ + '/scanner', + 'https://cdn.jsdelivr.net/npm/jsqr@1.4.0/dist/jsQR.js', +] + +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(SHELL_CACHE).then((cache) => cache.addAll(SHELL_URLS)).catch(() => {}), + ) + self.skipWaiting() +}) + +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((k) => k !== SHELL_CACHE).map((k) => caches.delete(k))), + ), + ) + self.clients.claim() +}) + +self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url) + + // Never cache API calls — stale check-ins are worse than offline. + if (url.pathname.startsWith('/events/') || url.pathname.startsWith('/auth/')) { + return + } + + // Network-first for the shell URLs so a deployment is reflected on + // the next reload; fall back to cache only when offline. + if (event.request.mode === 'navigate' || SHELL_URLS.some((u) => event.request.url.endsWith(u))) { + event.respondWith( + fetch(event.request) + .then((res) => { + const copy = res.clone() + caches.open(SHELL_CACHE).then((c) => c.put(event.request, copy)).catch(() => {}) + return res + }) + .catch(() => caches.match(event.request).then((r) => r || Response.error())), + ) + } +}) diff --git a/internal/api/auth.go b/internal/api/auth.go index f6673c3..3ba4459 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -550,6 +550,17 @@ func requireAuth(signer *auth.JWTSigner) func(http.Handler) http.Handler { writeError(w, http.StatusUnauthorized, "invalid token") return } + // Scanner-scoped tokens are signed with the same HMAC secret but + // carry Audience="scanner" and are deliberately narrower than a + // session token — they only authorise the door-volunteer surface. + // Reject them here so requireAuth never grants account-wide access + // to a magic-link ticket. + for _, aud := range claims.Audience { + if aud == auth.ScannerJWTAudience { + writeError(w, http.StatusUnauthorized, "scanner token cannot be used here") + return + } + } ctx := context.WithValue(r.Context(), userIDCtxKey, claims.UserID) next.ServeHTTP(w, r.WithContext(ctx)) }) diff --git a/internal/api/billing_enforce.go b/internal/api/billing_enforce.go index 25a956e..1f05a05 100644 --- a/internal/api/billing_enforce.go +++ b/internal/api/billing_enforce.go @@ -135,6 +135,73 @@ func (e *tierEnforcer) allowGuestImport(w http.ResponseWriter, r *http.Request, return true } +// allowFeature gates a boolean Tier 2 feature (branding, scanner, +// broadcasts) against the host's current plan. On denial it writes a +// 402 with an upgrade payload and returns false. The `reason` value +// goes back to the frontend so it can render a targeted upgrade modal +// instead of a generic "upgrade your plan" prompt. +func (e *tierEnforcer) allowFeature(w http.ResponseWriter, r *http.Request, hostID uuid.UUID, reason, friendly string) bool { + if e == nil || e.subs == nil { + return true + } + tier, err := e.currentTier(r.Context(), hostID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to check plan") + return false + } + limits := billing.LimitsFor(tier) + allowed := false + switch reason { + case "custom_branding": + allowed = limits.CustomBranding + case "scanner": + allowed = limits.Scanner + case "broadcasts": + allowed = limits.Broadcasts + default: + // Unknown feature — be conservative, treat as allowed so a + // future caller can ship before the gate is hooked up. + return true + } + if allowed { + return true + } + e.writePaymentRequired(w, reason, tier, 0, 0, friendly) + return false +} + +// allowCollaboratorInvite gates the per-event collaborator count. Used +// by POST /events/{id}/collaborators before we mint the invite — saves +// us creating an invitation token only to surface a 402 on accept. +func (e *tierEnforcer) allowCollaboratorInvite(w http.ResponseWriter, r *http.Request, hostID, eventID uuid.UUID) bool { + if e == nil || e.subs == nil { + return true + } + tier, err := e.currentTier(r.Context(), hostID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to check plan") + return false + } + limit := billing.LimitsFor(tier).MaxCollaborators + if limit < 0 { + return true + } + used, err := e.subs.CountCollaboratorsByEvent(r.Context(), eventID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to count collaborators") + return false + } + if used >= limit { + msg := "You've reached the collaborator limit on the " + strings.ToUpper(string(tier)) + " plan." + if tier == billing.TierFree { + msg = "Collaborators aren't included on the free plan. Upgrade to share this event with editors or viewers." + } + e.writePaymentRequired(w, "max_collaborators", tier, used, limit, msg) + return false + } + return true +} + type paymentRequiredBody struct { Error string `json:"error"` Reason string `json:"reason"` diff --git a/internal/api/branding.go b/internal/api/branding.go index 2929409..c45854b 100644 --- a/internal/api/branding.go +++ b/internal/api/branding.go @@ -10,7 +10,9 @@ import ( "os" "strings" + "github.com/alchemistkay/guestguard/internal/audit" "github.com/alchemistkay/guestguard/internal/domain" + "github.com/alchemistkay/guestguard/internal/flags" "github.com/alchemistkay/guestguard/internal/storage" "github.com/alchemistkay/guestguard/internal/uploads" ) @@ -19,11 +21,14 @@ import ( // Tier 2 Block D. Reads are viewer+; writes (PUT + image upload) are // editor+. The role check uses the standard requireRole helper. type brandingHandler struct { - logger *slog.Logger - events *storage.EventRepo - collabs *storage.CollaboratorRepo - repo *storage.BrandingRepo - store uploads.ImageStore + logger *slog.Logger + events *storage.EventRepo + collabs *storage.CollaboratorRepo + repo *storage.BrandingRepo + store uploads.ImageStore + audit *audit.Recorder + enforcer *tierEnforcer + flags *flags.Store } type brandingResponse struct { @@ -88,6 +93,20 @@ func (h *brandingHandler) put(w http.ResponseWriter, r *http.Request) { if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok { return } + // Feature flag: ops can kill custom_branding without a redeploy + // (e.g. an image-processing bug). 503 makes it obvious this is a + // temporary outage rather than a billing matter. + if !h.flags.Enabled("custom_branding", hostID) { + writeError(w, http.StatusServiceUnavailable, "branding is temporarily disabled") + return + } + // Custom branding is a Pro+ feature. Free-tier hosts get the + // default invitation card; this endpoint refuses with 402 + + // upgrade payload so the frontend can render a targeted modal. + if !h.enforcer.allowFeature(w, r, hostID, "custom_branding", + "Custom branding (logo, cover, colours, font) is a Pro feature.") { + return + } var req updateBrandingRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") @@ -127,6 +146,20 @@ func (h *brandingHandler) put(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to save branding") return } + h.audit.Record(r.Context(), audit.Params{ + UserID: &hostID, + EventID: &eventID, + Action: "branding.update", + EntityType: "event", + TargetID: &eventID, + Metadata: map[string]any{ + "primary_color": req.PrimaryColor, + "accent_color": req.AccentColor, + "font_family": req.FontFamily, + "has_logo": req.LogoURL != nil && *req.LogoURL != "", + "has_cover": req.CoverImageURL != nil && *req.CoverImageURL != "", + }, + }) writeJSON(w, http.StatusOK, brandingResponse{Branding: b, AllowedFonts: domain.AllowedFonts}) } diff --git a/internal/api/checkins.go b/internal/api/checkins.go index 7686c5d..424c0c8 100644 --- a/internal/api/checkins.go +++ b/internal/api/checkins.go @@ -1,18 +1,22 @@ package api import ( + "context" "encoding/base64" "encoding/json" "errors" "log/slog" "net/http" + "net/url" "strings" + "time" "github.com/google/uuid" "github.com/skip2/go-qrcode" "github.com/alchemistkay/guestguard/internal/auth" "github.com/alchemistkay/guestguard/internal/domain" + "github.com/alchemistkay/guestguard/internal/flags" "github.com/alchemistkay/guestguard/internal/storage" ) @@ -20,13 +24,116 @@ import ( // codes at the door, record arrivals, register walk-ins, drive the // live arrivals counter on the dashboard. type checkInHandler struct { - logger *slog.Logger - events *storage.EventRepo - guests *storage.GuestRepo - collabs *storage.CollaboratorRepo - repo *storage.CheckInRepo - qrSigner *auth.CheckInQRSigner - hub *Hub + logger *slog.Logger + events *storage.EventRepo + guests *storage.GuestRepo + collabs *storage.CollaboratorRepo + repo *storage.CheckInRepo + qrSigner *auth.CheckInQRSigner + scannerSigner *auth.ScannerJWTSigner + publicBaseURL string + hub *Hub + enforcer *tierEnforcer + flags *flags.Store +} + +// --- preview a check-in (POST /events/{id}/check-in/preview) --- +// +// The scanner submits the decoded QR string; we validate the JWT and +// return the guest plus what we already know about them — name, +// expected party size, and whether they've already been recorded. +// No DB writes. The frontend uses this to render the +// "Just them / +1 / +2 / Other" confirmation step before committing +// the check-in, matching how Eventbrite / Lu.ma scanners handle +// plus-ones at the door. + +type previewCheckInRequest struct { + QRPayload string `json:"qr_payload"` +} + +type previewCheckInResponse struct { + Guest *domain.Guest `json:"guest"` + ExpectedPartySize int `json:"expected_party_size"` + AlreadyCheckedIn bool `json:"already_checked_in"` + ExistingCheckIn *domain.CheckIn `json:"existing_check_in,omitempty"` +} + +func (h *checkInHandler) preview(w http.ResponseWriter, r *http.Request) { + hostID, ok := hostFromContext(w, r) + if !ok { + return + } + eventID, ok := parseIDParam(w, r, "id") + if !ok { + return + } + if !requireScannerEventMatch(w, r, eventID) { + return + } + if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok { + return + } + + var req previewCheckInRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + req.QRPayload = strings.TrimSpace(req.QRPayload) + if req.QRPayload == "" { + writeError(w, http.StatusBadRequest, "qr_payload is required") + return + } + + claims, status, msg := h.parseCheckInQR(r.Context(), req.QRPayload, eventID) + if status != 0 { + writeError(w, status, msg) + return + } + + guest, gerr := h.guests.Get(r.Context(), claims.GuestID) + if gerr != nil || guest == nil { + writeError(w, http.StatusNotFound, "guest not found") + return + } + // arrival_count counts the guest plus their plus-ones; the door + // volunteer most often presses the matching number, so this is what + // drives the default-highlight in the UI. + expected := guest.PlusOnes + 1 + + existing, _ := h.repo.GetByGuest(r.Context(), guest.ID) + resp := previewCheckInResponse{ + Guest: guest, + ExpectedPartySize: expected, + AlreadyCheckedIn: existing != nil, + ExistingCheckIn: existing, + } + writeJSON(w, http.StatusOK, resp) +} + +// parseCheckInQR centralises the JWT validation + event-binding + +// guest-belongs-to-event checks that both preview and record need. +// Returns (claims, 0, "") on success or (nil, statusCode, message) on +// the recoverable error paths so the caller writes a single response. +func (h *checkInHandler) parseCheckInQR(ctx context.Context, payload string, eventID uuid.UUID) (*auth.CheckInQR, int, string) { + claims, err := h.qrSigner.Parse(payload) + if err != nil { + if errors.Is(err, auth.ErrExpiredJWT) { + return nil, http.StatusGone, "this QR has expired" + } + return nil, http.StatusBadRequest, "invalid QR" + } + if claims.EventID != eventID { + return nil, http.StatusBadRequest, "this QR belongs to a different event" + } + belongs, err := h.repo.GuestBelongsToEvent(ctx, claims.GuestID, eventID) + if err != nil { + return nil, http.StatusInternalServerError, "failed to verify guest" + } + if !belongs { + return nil, http.StatusNotFound, "guest is no longer on this event" + } + return claims, 0, "" } // --- record a check-in (QR-scanner POST) --- @@ -57,6 +164,9 @@ func (h *checkInHandler) record(w http.ResponseWriter, r *http.Request) { if !ok { return } + if !requireScannerEventMatch(w, r, eventID) { + return + } if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok { return } @@ -72,32 +182,9 @@ func (h *checkInHandler) record(w http.ResponseWriter, r *http.Request) { return } - claims, err := h.qrSigner.Parse(req.QRPayload) - if err != nil { - switch { - case errors.Is(err, auth.ErrExpiredJWT): - writeError(w, http.StatusGone, "this QR has expired") - default: - writeError(w, http.StatusBadRequest, "invalid QR") - } - return - } - if claims.EventID != eventID { - // JWT was issued for a different event. The scanner may have - // roamed; the host should switch event pages. - writeError(w, http.StatusBadRequest, "this QR belongs to a different event") - return - } - - // Sanity: the guest still belongs to this event (host may have - // removed them after issuing the QR). - belongs, err := h.repo.GuestBelongsToEvent(r.Context(), claims.GuestID, eventID) - if err != nil { - writeError(w, http.StatusInternalServerError, "failed to verify guest") - return - } - if !belongs { - writeError(w, http.StatusNotFound, "guest is no longer on this event") + claims, status, msg := h.parseCheckInQR(r.Context(), req.QRPayload, eventID) + if status != 0 { + writeError(w, status, msg) return } @@ -161,6 +248,9 @@ func (h *checkInHandler) walkIn(w http.ResponseWriter, r *http.Request) { if !ok { return } + if !requireScannerEventMatch(w, r, eventID) { + return + } if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok { return } @@ -237,6 +327,9 @@ func (h *checkInHandler) list(w http.ResponseWriter, r *http.Request) { if !ok { return } + if !requireScannerEventMatch(w, r, eventID) { + return + } if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer); !ok { return } @@ -285,6 +378,86 @@ func nameOf(g *domain.Guest) string { return g.Name } +// --- scanner magic-link (POST /events/{id}/scanner-ticket) --- + +type scannerTicketResponse struct { + Token string `json:"token"` + URL string `json:"url"` + QRImage string `json:"qr_image"` // data: URL PNG + ExpiresAt time.Time `json:"expires_at"` +} + +// issueScannerTicket mints a short-lived, event-scoped JWT that a host +// can render into a QR code on their desktop event-detail page. The +// door volunteer scans that QR with their phone camera, the scanner +// page reads ?token= out of the URL, and uses it as a Bearer for +// the three check-in endpoints — no separate phone login required. +// +// Editor+ on the event. Rate-limited at the route level. Tickets last +// four hours by default (see NewScannerJWTSigner), long enough for a +// full event without the host having to re-mint mid-night. +func (h *checkInHandler) issueScannerTicket(w http.ResponseWriter, r *http.Request) { + hostID, ok := hostFromContext(w, r) + if !ok { + return + } + eventID, ok := parseIDParam(w, r, "id") + if !ok { + return + } + if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok { + return + } + // Kill switch — ops can disable scanner ticket minting without a + // redeploy if the magic-link UX needs to be pulled back. + if !h.flags.Enabled("checkin_pwa", hostID) { + writeError(w, http.StatusServiceUnavailable, "the scanner is temporarily disabled") + return + } + // The day-of scanner is a Pro+ feature. Free-tier hosts can still + // see arrivals (the headline widget on the overview reads + // /check-ins which is viewer+) but minting a magic link for a + // volunteer's phone is an upsell lever. + if !h.enforcer.allowFeature(w, r, hostID, "scanner", + "The day-of check-in scanner is a Pro feature.") { + return + } + if h.scannerSigner == nil { + writeError(w, http.StatusServiceUnavailable, "scanner tickets are not configured") + return + } + + tok, exp, err := h.scannerSigner.Issue(hostID, eventID, time.Now()) + if err != nil { + h.logger.Error("issue scanner ticket", "err", err, "event_id", eventID) + writeError(w, http.StatusInternalServerError, "failed to issue ticket") + return + } + + // publicBaseURL is the front-of-house origin (e.g. https://guestguard.k4scloud.com). + // We embed the event id as well as the token so the scanner page can + // load the right event metadata before any check-in roundtrip. + base := strings.TrimRight(h.publicBaseURL, "/") + if base == "" { + base = "" + } + scannerURL := base + "/scanner?token=" + url.QueryEscape(tok) + "&event=" + url.QueryEscape(eventID.String()) + + qrImage, err := renderQRPNG(scannerURL) + if err != nil { + h.logger.Error("render scanner QR", "err", err) + writeError(w, http.StatusInternalServerError, "failed to render QR") + return + } + + writeJSON(w, http.StatusOK, scannerTicketResponse{ + Token: tok, + URL: scannerURL, + QRImage: qrImage, + ExpiresAt: exp, + }) +} + // renderQRPNG converts a JWT-shaped string into a base64-encoded PNG // data URL the frontend can drop straight into an . Used by // the access response so a successful RSVP comes back with the guest's diff --git a/internal/api/collaborators.go b/internal/api/collaborators.go index a14d9e9..e2dc4d0 100644 --- a/internal/api/collaborators.go +++ b/internal/api/collaborators.go @@ -11,6 +11,7 @@ import ( "github.com/google/uuid" + "github.com/alchemistkay/guestguard/internal/audit" "github.com/alchemistkay/guestguard/internal/auth" "github.com/alchemistkay/guestguard/internal/domain" "github.com/alchemistkay/guestguard/internal/storage" @@ -27,6 +28,8 @@ type collaboratorHandler struct { emails auth.EmailSender publicBaseURL string inviteTTL time.Duration + audit *audit.Recorder + enforcer *tierEnforcer } // --- responses --- @@ -159,6 +162,14 @@ func (h *collaboratorHandler) invite(w http.ResponseWriter, r *http.Request) { return } + // Tier gate. Free plans cap at 0 shared collaborators; Pro at 5; + // Business unlimited. We deny BEFORE minting the invite so we + // don't email someone an invite that the host won't be able to + // honour. + if !h.enforcer.allowCollaboratorInvite(w, r, hostID, eventID) { + return + } + // If the invitee already has an account AND is already a collaborator, // short-circuit with a friendly 409 — no email, no DB churn. if existing, err := h.users.GetByEmail(r.Context(), email); err == nil && existing != nil { @@ -192,6 +203,14 @@ func (h *collaboratorHandler) invite(w http.ResponseWriter, r *http.Request) { // so the host knows. sent := h.emailInvite(r.Context(), email, hostID, event.Name, req.Role, raw) + h.audit.Record(r.Context(), audit.Params{ + UserID: &hostID, + EventID: &eventID, + Action: "collaborator.invite", + EntityType: "collaborator_invite", + Metadata: map[string]any{"email": email, "role": req.Role, "email_sent": sent}, + }) + writeJSON(w, http.StatusCreated, inviteResponse{ Email: email, Role: req.Role, @@ -245,6 +264,14 @@ func (h *collaboratorHandler) updateRole(w http.ResponseWriter, r *http.Request) } return } + h.audit.Record(r.Context(), audit.Params{ + UserID: &hostID, + EventID: &eventID, + Action: "collaborator.role_change", + EntityType: "collaborator", + TargetID: &userID, + Metadata: map[string]any{"role": req.Role}, + }) w.WriteHeader(http.StatusNoContent) } @@ -279,6 +306,13 @@ func (h *collaboratorHandler) remove(w http.ResponseWriter, r *http.Request) { } return } + h.audit.Record(r.Context(), audit.Params{ + UserID: &hostID, + EventID: &eventID, + Action: "collaborator.remove", + EntityType: "collaborator", + TargetID: &userID, + }) w.WriteHeader(http.StatusNoContent) } diff --git a/internal/api/fraud_v2.go b/internal/api/fraud_v2.go index 2449815..005ef6c 100644 --- a/internal/api/fraud_v2.go +++ b/internal/api/fraud_v2.go @@ -3,9 +3,11 @@ package api import ( "encoding/json" "errors" + "fmt" "log/slog" "net/http" + "github.com/alchemistkay/guestguard/internal/audit" "github.com/alchemistkay/guestguard/internal/domain" "github.com/alchemistkay/guestguard/internal/storage" ) @@ -19,6 +21,7 @@ type securityHandler struct { allowlist *storage.AllowlistRepo feedback *storage.FeedbackRepo access *storage.AccessLogRepo + audit *audit.Recorder } // --- thresholds --- @@ -54,6 +57,96 @@ func (h *securityHandler) getThresholds(w http.ResponseWriter, r *http.Request) }) } +// GET /events/{id}/security/thresholds/preview?medium=&high=&block= — viewer+. +// +// Returns the band-counts that *would* result from applying the +// proposed thresholds to the event's recorded access scores. Powers the +// live "12 of your 47 access events would be flagged" widget under the +// sliders on the Gate tab, so a host moving the slider gets immediate +// concrete feedback instead of having to wait for new scans to land. +type thresholdsPreviewResponse struct { + Total int `json:"total"` // number of scored access logs we considered + Low int `json:"low"` + Medium int `json:"medium"` + High int `json:"high"` + Block int `json:"block"` +} + +func (h *securityHandler) previewThresholds(w http.ResponseWriter, r *http.Request) { + hostID, ok := hostFromContext(w, r) + if !ok { + return + } + eventID, ok := parseIDParam(w, r, "id") + if !ok { + return + } + if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer); !ok { + return + } + + // Parse the proposed thresholds out of the query string. Falling + // back to the event's stored thresholds when a value is missing + // means the frontend can ask "what would high=70 look like, with + // everything else as it is" without having to re-send the whole + // triple every time. + stored, _ := h.events.GetThresholds(r.Context(), eventID) + proposed := stored + if v, err := intQuery(r, "medium"); err == nil { + proposed.Medium = v + } + if v, err := intQuery(r, "high"); err == nil { + proposed.High = v + } + if v, err := intQuery(r, "block"); err == nil { + proposed.Block = v + } + if err := proposed.Valid(); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + scores, err := h.access.ScoresForEvent(r.Context(), eventID, 1000) + if err != nil { + h.logger.Error("preview thresholds: load scores", "err", err) + writeError(w, http.StatusInternalServerError, "failed to load access history") + return + } + + out := thresholdsPreviewResponse{Total: len(scores)} + for _, s := range scores { + switch proposed.Band(s) { + case "block": + out.Block++ + case "high": + out.High++ + case "medium": + out.Medium++ + default: + out.Low++ + } + } + writeJSON(w, http.StatusOK, out) +} + +// intQuery extracts a non-negative integer from ?= on the +// request. Returns an error when missing or unparseable so the caller +// can decide whether to substitute a default. +func intQuery(r *http.Request, name string) (int, error) { + raw := r.URL.Query().Get(name) + if raw == "" { + return 0, errors.New("missing") + } + var v int + if _, err := fmt.Sscanf(raw, "%d", &v); err != nil { + return 0, err + } + if v < 0 || v > 100 { + return 0, errors.New("out of range") + } + return v, nil +} + // PUT /events/{id}/security/thresholds — editor+. func (h *securityHandler) putThresholds(w http.ResponseWriter, r *http.Request) { hostID, ok := hostFromContext(w, r) @@ -85,6 +178,18 @@ func (h *securityHandler) putThresholds(w http.ResponseWriter, r *http.Request) writeError(w, http.StatusInternalServerError, "failed to update thresholds") return } + h.audit.Record(r.Context(), audit.Params{ + UserID: &hostID, + EventID: &eventID, + Action: "thresholds.update", + EntityType: "event", + TargetID: &eventID, + Metadata: map[string]any{ + "medium": req.Medium, + "high": req.High, + "block": req.Block, + }, + }) writeJSON(w, http.StatusOK, thresholdsResponse{ FraudThresholds: req, Defaults: domain.DefaultThresholds(), @@ -157,6 +262,13 @@ func (h *securityHandler) addAllowlist(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to add allowlist entry") return } + h.audit.Record(r.Context(), audit.Params{ + UserID: &hostID, + EventID: &eventID, + Action: "allowlist.add", + EntityType: "allowlist", + Metadata: map[string]any{"cidr": canonical, "label": req.Label}, + }) writeJSON(w, http.StatusCreated, entry) } @@ -193,6 +305,13 @@ func (h *securityHandler) removeAllowlist(w http.ResponseWriter, r *http.Request writeError(w, http.StatusInternalServerError, "failed to remove allowlist entry") return } + h.audit.Record(r.Context(), audit.Params{ + UserID: &hostID, + EventID: &eventID, + Action: "allowlist.remove", + EntityType: "allowlist", + Metadata: map[string]any{"cidr": canonical}, + }) w.WriteHeader(http.StatusNoContent) } diff --git a/internal/api/messages.go b/internal/api/messages.go index 63a4d42..f24397a 100644 --- a/internal/api/messages.go +++ b/internal/api/messages.go @@ -7,7 +7,9 @@ import ( "net/http" "time" + "github.com/alchemistkay/guestguard/internal/audit" "github.com/alchemistkay/guestguard/internal/domain" + "github.com/alchemistkay/guestguard/internal/flags" "github.com/alchemistkay/guestguard/internal/storage" ) @@ -19,6 +21,9 @@ type messageHandler struct { events *storage.EventRepo collabs *storage.CollaboratorRepo repo *storage.MessageRepo + audit *audit.Recorder + enforcer *tierEnforcer + flags *flags.Store } // --- response shapes --- @@ -130,6 +135,20 @@ func (h *messageHandler) create(w http.ResponseWriter, r *http.Request) { if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleEditor); !ok { return } + // Kill switch — ops can disable broadcasts entirely without a + // redeploy if delivery upstream goes wobbly. + if !h.flags.Enabled("broadcasts", hostID) { + writeError(w, http.StatusServiceUnavailable, "broadcasts are temporarily disabled") + return + } + // Custom broadcasts are a Pro+ feature. Auto-reminders (which the + // system creates on event creation) are NOT gated — those run for + // every event regardless of tier so free users still get reminder + // emails before their event. + if !h.enforcer.allowFeature(w, r, hostID, "broadcasts", + "Custom broadcasts are a Pro feature. Auto-reminders still run on every plan.") { + return + } var req composeMessageRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid json") @@ -186,6 +205,18 @@ func (h *messageHandler) create(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to create message") return } + h.audit.Record(r.Context(), audit.Params{ + UserID: &hostID, + EventID: &eventID, + Action: "message.create", + EntityType: "message", + TargetID: &m.ID, + Metadata: map[string]any{ + "audience": req.Audience, + "channel": req.Channel, + "status": status, + }, + }) writeJSON(w, http.StatusCreated, m) } @@ -283,6 +314,13 @@ func (h *messageHandler) sendNow(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to schedule send") return } + h.audit.Record(r.Context(), audit.Params{ + UserID: &hostID, + EventID: &eventID, + Action: "message.send_now", + EntityType: "message", + TargetID: &msgID, + }) w.WriteHeader(http.StatusAccepted) } @@ -312,5 +350,12 @@ func (h *messageHandler) cancel(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusInternalServerError, "failed to cancel message") return } + h.audit.Record(r.Context(), audit.Params{ + UserID: &hostID, + EventID: &eventID, + Action: "message.cancel", + EntityType: "message", + TargetID: &msgID, + }) w.WriteHeader(http.StatusNoContent) } diff --git a/internal/api/rsvps.go b/internal/api/rsvps.go index 0ead5dd..db55ed1 100644 --- a/internal/api/rsvps.go +++ b/internal/api/rsvps.go @@ -7,6 +7,8 @@ import ( "fmt" "log/slog" "net/http" + "net/url" + "strings" "time" "github.com/google/uuid" @@ -27,16 +29,17 @@ type fraudScorer interface { } type rsvpHandler struct { - logger *slog.Logger - guests *storage.GuestRepo - tokens *storage.TokenRepo - events *storage.EventRepo - rsvps *storage.RSVPRepo - accessLogs *storage.AccessLogRepo - allowlist *storage.AllowlistRepo - editNonces *editNonceStore - scorer fraudScorer - pub rsvpPublisher + logger *slog.Logger + guests *storage.GuestRepo + tokens *storage.TokenRepo + events *storage.EventRepo + rsvps *storage.RSVPRepo + accessLogs *storage.AccessLogRepo + allowlist *storage.AllowlistRepo + editNonces *editNonceStore + scorer fraudScorer + pub rsvpPublisher + publicBaseURL string } type submitRSVPRequest struct { @@ -111,7 +114,7 @@ func (h *rsvpHandler) submit(w http.ResponseWriter, r *http.Request) { // The token must remain valid so the guest can come back to the same // link and edit their response. - h.publishRSVPConfirmed(event.ID, guest.ID, rsvp, &score) + h.publishRSVPConfirmed(event.ID, guest.ID, rsvp, &score, h.accessLinkFor(r)) writeJSON(w, http.StatusCreated, submitRSVPResponse{ RSVP: rsvp, @@ -201,7 +204,7 @@ func (h *rsvpHandler) edit(w http.ResponseWriter, r *http.Request) { return } - h.publishRSVPConfirmed(event.ID, guest.ID, rsvp, &score) + h.publishRSVPConfirmed(event.ID, guest.ID, rsvp, &score, h.accessLinkFor(r)) writeJSON(w, http.StatusOK, submitRSVPResponse{ RSVP: rsvp, @@ -409,7 +412,7 @@ func (h *rsvpHandler) scoreAccess( return decision, fingerprint, ip, true } -func (h *rsvpHandler) publishRSVPConfirmed(eventID, guestID uuid.UUID, rsvp *domain.RSVP, score *int) { +func (h *rsvpHandler) publishRSVPConfirmed(eventID, guestID uuid.UUID, rsvp *domain.RSVP, score *int, accessLink string) { if h.pub == nil { return } @@ -427,9 +430,23 @@ func (h *rsvpHandler) publishRSVPConfirmed(eventID, guestID uuid.UUID, rsvp *dom PlusOnes: rsvp.PlusOnes, RiskScore: score, SubmittedAt: rsvp.SubmittedAt, + AccessLink: accessLink, }) } +// accessLinkFor reconstructs the magic invitation URL the guest used to +// arrive at the RSVP page. The raw token is only ever available on the +// inbound request (the database holds just the hash), so this is the +// only point where we can capture it for downstream channels like the +// confirmation email. +func (h *rsvpHandler) accessLinkFor(r *http.Request) string { + raw := strings.TrimSpace(r.PathValue("token")) + if raw == "" || h.publicBaseURL == "" { + return "" + } + return strings.TrimRight(h.publicBaseURL, "/") + "/rsvp/" + url.PathEscape(raw) +} + func mergeFingerprint(client map[string]any, server map[string]any) map[string]any { out := make(map[string]any, len(server)+len(client)) for k, v := range server { diff --git a/internal/api/scanner_auth.go b/internal/api/scanner_auth.go new file mode 100644 index 0000000..37c0346 --- /dev/null +++ b/internal/api/scanner_auth.go @@ -0,0 +1,132 @@ +package api + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/google/uuid" + + "github.com/alchemistkay/guestguard/internal/auth" +) + +// Scanner JWT integration — Tier 2 Block H follow-up. +// +// The host's desktop mints a scoped scanner ticket via POST +// /events/{id}/scanner-ticket and renders the magic URL into a QR. A +// door volunteer scans that with their phone camera, lands on /scanner +// with ?token=, and the page uses the token as a Bearer for the +// three check-in endpoints below. No second login required. +// +// The scanner JWT is HS256-signed with the same platform secret as the +// session token but carries Audience="scanner". `requireAuth` rejects +// audience=scanner; `requireAuthOrScanner` accepts either, and on a +// scanner token stamps the URL-event-id constraint into the request +// context so the handler can verify the path event matches before +// touching the database. + +type scannerCtxKey int + +const scannerEventCtxKey scannerCtxKey = iota + +// scannerEventFromContext returns the event_id the bearer scanner token +// is scoped to (if any). On a regular session token this returns +// uuid.Nil, false — the handler should fall back to its normal role +// check. On a scanner token it returns the event the token was minted +// against and the handler must 403 if the request's path event differs. +func scannerEventFromContext(ctx context.Context) (uuid.UUID, bool) { + v, ok := ctx.Value(scannerEventCtxKey).(uuid.UUID) + if !ok || v == uuid.Nil { + return uuid.Nil, false + } + return v, true +} + +// requireAuthOrScanner is the middleware applied to the three check-in +// endpoints. A bearer token can be either: +// +// - a normal session JWT (no audience) — usual host/collaborator flow, +// - a scanner JWT (Audience=scanner) — scoped to one event_id. +// +// Either way it sets userIDCtxKey so downstream handlers can call +// hostFromContext. Scanner tokens additionally set scannerEventCtxKey; +// handlers that read it MUST verify the URL event matches before doing +// anything that mutates state. +func requireAuthOrScanner(signer *auth.JWTSigner, scanner *auth.ScannerJWTSigner) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := r.Header.Get("Authorization") + if !strings.HasPrefix(h, "Bearer ") { + writeError(w, http.StatusUnauthorized, "missing bearer token") + return + } + raw := strings.TrimSpace(strings.TrimPrefix(h, "Bearer ")) + + // Try scanner first — the audience constraint means a session + // token will fail this cheaply and we fall through to the + // normal signer. + if scanner != nil { + if claims, err := scanner.Parse(raw); err == nil { + ctx := context.WithValue(r.Context(), userIDCtxKey, claims.UserID) + ctx = context.WithValue(ctx, scannerEventCtxKey, claims.EventID) + next.ServeHTTP(w, r.WithContext(ctx)) + return + } else if errors.Is(err, auth.ErrExpiredJWT) { + // If the audience matched but the token expired, surface + // the friendlier expired message. Detect by re-parsing + // without validation? Cheaper: if the normal signer can't + // parse it either, treat as expired-scanner so the door + // volunteer's phone gets a clear "ask the host for a + // fresh link" prompt. + if _, sessionErr := signer.Parse(raw); sessionErr != nil { + writeError(w, http.StatusUnauthorized, "scanner link expired — ask the host for a new one") + return + } + } + } + + claims, err := signer.Parse(raw) + if err != nil { + if errors.Is(err, auth.ErrExpiredJWT) { + writeError(w, http.StatusUnauthorized, "token expired") + return + } + writeError(w, http.StatusUnauthorized, "invalid token") + return + } + // Defence in depth: a session token must not carry the scanner + // audience. requireAuth enforces the same; mirror it here. + for _, aud := range claims.Audience { + if aud == auth.ScannerJWTAudience { + writeError(w, http.StatusUnauthorized, "scanner token cannot be used here") + return + } + } + ctx := context.WithValue(r.Context(), userIDCtxKey, claims.UserID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// requireScannerEventMatch is the per-handler guard. After a check-in +// handler has parsed its path event id, it calls this to confirm: +// - if the request used a scanner JWT, the JWT's event matches the URL, +// - if it didn't (regular session), this is a no-op. +// +// The role check (requireRole) is still applied for regular sessions +// inside the handler; for scanner JWTs the role gate is bypassed because +// the host already proved they were an editor when they minted the +// ticket — passing that authority onto the door volunteer is the +// entire point of the magic link. +func requireScannerEventMatch(w http.ResponseWriter, r *http.Request, pathEventID uuid.UUID) bool { + scoped, ok := scannerEventFromContext(r.Context()) + if !ok { + return true + } + if scoped != pathEventID { + writeError(w, http.StatusForbidden, "scanner link is scoped to a different event") + return false + } + return true +} diff --git a/internal/api/server.go b/internal/api/server.go index 1cda0ca..098f34e 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -7,8 +7,10 @@ import ( "github.com/redis/go-redis/v9" + "github.com/alchemistkay/guestguard/internal/audit" "github.com/alchemistkay/guestguard/internal/auth" "github.com/alchemistkay/guestguard/internal/billing" + "github.com/alchemistkay/guestguard/internal/flags" "github.com/alchemistkay/guestguard/internal/notification" "github.com/alchemistkay/guestguard/internal/ratelimit" "github.com/alchemistkay/guestguard/internal/storage" @@ -29,8 +31,10 @@ type Server struct { ws *wsHandler wsTicket *wsTicketHandler health *healthHandler - signer *auth.JWTSigner - limiter *ratelimit.Limiter + signer *auth.JWTSigner + scannerSigner *auth.ScannerJWTSigner + limiter *ratelimit.Limiter + flags *flags.Store unsub *unsubscribeHandler webhooks *webhookHandler csv *csvImportHandler @@ -107,6 +111,8 @@ func NewServer(deps ServerDeps) (*Server, error) { editNonces := newEditNonceStore(deps.Redis) messageRepo := storage.NewMessageRepo(deps.DB) checkInRepo := storage.NewCheckInRepo(deps.DB) + auditRec := audit.New(deps.DB.Pool, deps.Logger) + flagStore := flags.New(deps.DB.Pool, deps.Logger) // Tier 2 Block H — QR JWT signer reuses the platform's JWT secret // so production secrets management already covers it. TTL=6h is the @@ -116,6 +122,15 @@ func NewServer(deps ServerDeps) (*Server, error) { if err != nil { return nil, err } + // Scanner magic-link signer — same secret, audience-scoped so it + // can't double as a session token. 4h covers a full event without + // the host re-minting; the door volunteer's phone keeps working + // across the night even if their session would have otherwise + // timed out. + scannerJWTSigner, err := auth.NewScannerJWTSigner(deps.JWTSecret, deps.JWTIssuer, 4*time.Hour) + if err != nil { + return nil, err + } feedbackRepo := storage.NewFeedbackRepo(deps.DB) // Branding image store. Empty UploadsDir leaves it nil and the upload @@ -218,16 +233,17 @@ func NewServer(deps ServerDeps) (*Server, error) { publicBaseURL: deps.PublicBaseURL, }, rsvps: &rsvpHandler{ - logger: deps.Logger, - guests: guestRepo, - tokens: tokenRepo, - events: eventRepo, - rsvps: rsvpRepo, - accessLogs: accessRepo, - allowlist: allowlistRepo, - editNonces: editNonces, - scorer: deps.FraudScorer, - pub: deps.RSVPPublisher, + logger: deps.Logger, + guests: guestRepo, + tokens: tokenRepo, + events: eventRepo, + rsvps: rsvpRepo, + accessLogs: accessRepo, + allowlist: allowlistRepo, + editNonces: editNonces, + scorer: deps.FraudScorer, + pub: deps.RSVPPublisher, + publicBaseURL: deps.PublicBaseURL, }, activity: &activityHandler{ events: eventRepo, @@ -237,9 +253,11 @@ func NewServer(deps ServerDeps) (*Server, error) { }, ws: &wsHandler{logger: deps.Logger, hub: hub, tickets: wsTickets}, wsTicket: &wsTicketHandler{tickets: wsTickets, events: eventRepo, collabs: collabRepo}, - health: &healthHandler{pool: deps.DB.Pool}, - signer: signer, - limiter: limiter, + health: &healthHandler{pool: deps.DB.Pool}, + signer: signer, + scannerSigner: scannerJWTSigner, + limiter: limiter, + flags: flagStore, unsub: &unsubscribeHandler{ logger: deps.Logger, signer: deps.UnsubscribeSigner, @@ -271,11 +289,14 @@ func NewServer(deps ServerDeps) (*Server, error) { redis: deps.Redis, }, branding: &brandingHandler{ - logger: deps.Logger, - events: eventRepo, - collabs: collabRepo, - repo: brandingRepo, - store: imageStore, + logger: deps.Logger, + events: eventRepo, + collabs: collabRepo, + repo: brandingRepo, + store: imageStore, + audit: auditRec, + enforcer: enforcer, + flags: flagStore, }, uploads: &uploadHandler{ logger: deps.Logger, @@ -288,21 +309,29 @@ func NewServer(deps ServerDeps) (*Server, error) { allowlist: allowlistRepo, feedback: feedbackRepo, access: accessRepo, + audit: auditRec, }, messages: &messageHandler{ - logger: deps.Logger, - events: eventRepo, - collabs: collabRepo, - repo: messageRepo, - }, - checkIns: &checkInHandler{ logger: deps.Logger, events: eventRepo, - guests: guestRepo, collabs: collabRepo, - repo: checkInRepo, - qrSigner: checkInQRSigner, - hub: hub, + repo: messageRepo, + audit: auditRec, + enforcer: enforcer, + flags: flagStore, + }, + checkIns: &checkInHandler{ + logger: deps.Logger, + events: eventRepo, + guests: guestRepo, + collabs: collabRepo, + repo: checkInRepo, + qrSigner: checkInQRSigner, + scannerSigner: scannerJWTSigner, + publicBaseURL: deps.PublicBaseURL, + hub: hub, + enforcer: enforcer, + flags: flagStore, }, collabs: &collaboratorHandler{ logger: deps.Logger, @@ -312,6 +341,8 @@ func NewServer(deps ServerDeps) (*Server, error) { invites: inviteRepo, emails: emails, publicBaseURL: deps.PublicBaseURL, + audit: auditRec, + enforcer: enforcer, }, privacy: &privacyHandler{ logger: deps.Logger, @@ -329,6 +360,10 @@ func NewServer(deps ServerDeps) (*Server, error) { func (s *Server) Hub() *Hub { return s.hub } +// FeatureFlags exposes the loaded flag store so main can start its +// background refresher and shut it down on signal. +func (s *Server) FeatureFlags() *flags.Store { return s.flags } + func (s *Server) Handler() http.Handler { mux := http.NewServeMux() @@ -407,6 +442,8 @@ func (s *Server) Handler() http.Handler { // writes are editor+ (matches the rest of the event-edit surface). mux.Handle("GET /events/{id}/security/thresholds", authed(http.HandlerFunc(s.security.getThresholds))) + mux.Handle("GET /events/{id}/security/thresholds/preview", + authed(http.HandlerFunc(s.security.previewThresholds))) mux.Handle("PUT /events/{id}/security/thresholds", authed(http.HandlerFunc(s.security.putThresholds))) mux.Handle("GET /events/{id}/security/allowlist", @@ -435,13 +472,23 @@ func (s *Server) Handler() http.Handler { mux.Handle("DELETE /events/{id}/messages/{message_id}", authed(http.HandlerFunc(s.messages.cancel))) - // Block H — day-of check-in. + // Block H — day-of check-in. The three door-volunteer endpoints + // accept either a session token (host/collaborator opened the + // scanner on their own phone) or a scoped scanner JWT (the host + // minted a magic link, texted it to a volunteer). The ticket-issue + // endpoint requires a session token: only an editor can authorise a + // new volunteer. + scannerAuthed := requireAuthOrScanner(s.signer, s.scannerSigner) + mux.Handle("POST /events/{id}/scanner-ticket", + authed(rl("scanner_ticket_issue", 50, time.Hour, userIDKey, http.HandlerFunc(s.checkIns.issueScannerTicket)))) + mux.Handle("POST /events/{id}/check-in/preview", + scannerAuthed(rl("checkin_preview", 2000, time.Hour, userIDKey, http.HandlerFunc(s.checkIns.preview)))) mux.Handle("POST /events/{id}/check-in", - authed(rl("checkin_record", 1000, time.Hour, userIDKey, http.HandlerFunc(s.checkIns.record)))) + scannerAuthed(rl("checkin_record", 1000, time.Hour, userIDKey, http.HandlerFunc(s.checkIns.record)))) mux.Handle("POST /events/{id}/walk-ins", - authed(rl("checkin_walk_in", 500, time.Hour, userIDKey, http.HandlerFunc(s.checkIns.walkIn)))) + scannerAuthed(rl("checkin_walk_in", 500, time.Hour, userIDKey, http.HandlerFunc(s.checkIns.walkIn)))) mux.Handle("GET /events/{id}/check-ins", - authed(http.HandlerFunc(s.checkIns.list))) + scannerAuthed(http.HandlerFunc(s.checkIns.list))) // Block D — event branding. Reads are viewer+; PUT is editor+. The // upload endpoint is gated by auth only (any signed-in user can mint diff --git a/internal/audit/audit.go b/internal/audit/audit.go new file mode 100644 index 0000000..8a1929a --- /dev/null +++ b/internal/audit/audit.go @@ -0,0 +1,111 @@ +// Package audit records meaningful host-facing writes to an +// append-only table so the timeline (and any future compliance +// export) has a single source of truth. +// +// Design notes: +// +// - All writes are best-effort + fire-and-forget. Audit logging must +// never block or fail the real action — a host saving branding +// should not 500 because the audit insert flaked. We log a warning +// and move on. +// +// - Use the package-level `Record(...)` helper from handler code; it +// decorates the call with the request-id middleware adds to the +// context and dispatches to the wired Recorder. A nil Recorder +// (zero-value Server in tests) is a no-op. +// +// - Action names follow `entity.verb` (e.g. `branding.update`, +// `collaborator.invite`). Verbs are past-tense-implied — the row's +// existence is the past tense. +// +// - Metadata is freeform JSON; keep it small and reviewable. Don't +// put secrets in here — this table is queried by support. +package audit + +import ( + "context" + "encoding/json" + "log/slog" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Recorder writes audit rows asynchronously. Construct one per process +// and pass it into the handlers that need it (server.go bundles it on +// the few handlers that audit). +type Recorder struct { + pool *pgxpool.Pool + logger *slog.Logger +} + +func New(pool *pgxpool.Pool, logger *slog.Logger) *Recorder { + if logger == nil { + logger = slog.Default() + } + return &Recorder{pool: pool, logger: logger} +} + +// Params carries everything the audit_log INSERT needs. Most fields +// are nullable so a record can describe an action that doesn't have a +// specific target (e.g. "feature_flag.toggle") or doesn't sit under +// an event (account-level writes). +type Params struct { + UserID *uuid.UUID + EventID *uuid.UUID + Action string // e.g. "branding.update" + EntityType string // e.g. "event", "guest", "message" + TargetID *uuid.UUID // the row that was acted on (nullable) + Metadata map[string]any // free-form context — keep small + RequestID string // correlation id from middleware (if any) +} + +// Record inserts one audit_log row. Returns immediately; the insert +// runs on a detached goroutine with the package logger. Errors are +// warned, never returned. +func (r *Recorder) Record(ctx context.Context, p Params) { + if r == nil || r.pool == nil { + return + } + // Detach the context so cancelling the inbound HTTP request + // doesn't cancel the audit write mid-flight. + go r.write(context.WithoutCancel(ctx), p) +} + +func (r *Recorder) write(ctx context.Context, p Params) { + var meta []byte + if p.Metadata != nil { + var err error + meta, err = json.Marshal(p.Metadata) + if err != nil { + r.logger.Warn("audit: marshal metadata", "err", err, "action", p.Action) + meta = []byte(`{}`) + } + } else { + meta = []byte(`{}`) + } + + const q = ` + INSERT INTO audit_log + (user_id, event_id, action, entity_type, target_id, metadata, request_id) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, NULLIF($7, '')) + ` + if _, err := r.pool.Exec(ctx, q, + p.UserID, p.EventID, p.Action, nilIfEmpty(p.EntityType), + p.TargetID, meta, p.RequestID, + ); err != nil { + r.logger.Warn("audit: insert failed", + "err", err, + "action", p.Action, + "event_id", p.EventID, + "user_id", p.UserID, + ) + } +} + +func nilIfEmpty(s string) any { + if s == "" { + return nil + } + return s +} diff --git a/internal/auth/scanner_jwt.go b/internal/auth/scanner_jwt.go new file mode 100644 index 0000000..fd648ef --- /dev/null +++ b/internal/auth/scanner_jwt.go @@ -0,0 +1,115 @@ +package auth + +import ( + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +// ScannerClaims is the bearer token a host's phone uses to drive the +// check-in scanner. Tier 2 Block H follow-up. +// +// The desktop event-detail page mints one of these via POST +// /events/{id}/scanner-ticket, renders the magic URL into a QR, the +// host scans it with their phone camera, and the scanner page reads +// the token out of the URL. No second login required — the desktop +// host's session is the source of authority for the ticket-issuing +// call. +// +// Scoped: a scanner JWT only authorises POST /events/{id}/check-in, +// POST /events/{id}/walk-ins, and GET /events/{id}/check-ins for the +// `EventID` it carries. Everything else returns 401. This means a +// host who texted the magic link to a door volunteer hasn't given out +// blanket account access. +// +// The token carries Audience="scanner" so the normal Bearer-token +// middleware can reject it for non-scanner endpoints even though it +// shares the same HMAC secret as the platform session JWT. +type ScannerClaims struct { + UserID uuid.UUID `json:"user"` + EventID uuid.UUID `json:"event"` + jwt.RegisteredClaims +} + +// ScannerJWTAudience is the audience value that disambiguates a scanner +// token from a regular session access token (both are HS256 with the +// platform secret). Exported so middleware can refuse cross-purpose use. +const ScannerJWTAudience = "scanner" + +// ScannerJWTSigner mints + verifies the scoped scanner tokens. Same +// HMAC secret as the rest of the platform. +type ScannerJWTSigner struct { + secret []byte + issuer string + ttl time.Duration + parser *jwt.Parser +} + +// NewScannerJWTSigner — ttl bounds how long a magic URL stays usable +// before the host has to mint a fresh one. Default in the API is 4h +// so a door volunteer can stay on the scanner across a full event +// without needing the host to babysit them. +func NewScannerJWTSigner(secret, issuer string, ttl time.Duration) (*ScannerJWTSigner, error) { + if len(secret) < 32 { + return nil, fmt.Errorf("jwt secret must be at least 32 bytes") + } + if ttl <= 0 { + return nil, fmt.Errorf("scanner ttl must be positive") + } + return &ScannerJWTSigner{ + secret: []byte(secret), + issuer: issuer, + ttl: ttl, + parser: jwt.NewParser( + jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}), + jwt.WithIssuer(issuer), + jwt.WithAudience(ScannerJWTAudience), + jwt.WithExpirationRequired(), + ), + }, nil +} + +func (s *ScannerJWTSigner) Issue(userID, eventID uuid.UUID, now time.Time) (string, time.Time, error) { + exp := now.Add(s.ttl) + claims := ScannerClaims{ + UserID: userID, + EventID: eventID, + RegisteredClaims: jwt.RegisteredClaims{ + Issuer: s.issuer, + Subject: userID.String(), + Audience: jwt.ClaimStrings{ScannerJWTAudience}, + IssuedAt: jwt.NewNumericDate(now), + NotBefore: jwt.NewNumericDate(now.Add(-1 * time.Second)), + ExpiresAt: jwt.NewNumericDate(exp), + ID: uuid.NewString(), + }, + } + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := tok.SignedString(s.secret) + if err != nil { + return "", time.Time{}, err + } + return signed, exp, nil +} + +// Parse returns the bound user + event. Maps token-expiry to +// ErrExpiredJWT so the API can render a friendlier 410. +func (s *ScannerJWTSigner) Parse(raw string) (*ScannerClaims, error) { + claims := &ScannerClaims{} + tok, err := s.parser.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) { + return s.secret, nil + }) + if err != nil { + if errors.Is(err, jwt.ErrTokenExpired) { + return nil, ErrExpiredJWT + } + return nil, ErrInvalidJWT + } + if !tok.Valid || claims.UserID == uuid.Nil || claims.EventID == uuid.Nil { + return nil, ErrInvalidJWT + } + return claims, nil +} diff --git a/internal/auth/scanner_jwt_test.go b/internal/auth/scanner_jwt_test.go new file mode 100644 index 0000000..4e79748 --- /dev/null +++ b/internal/auth/scanner_jwt_test.go @@ -0,0 +1,111 @@ +package auth + +import ( + "errors" + "testing" + "time" + + "github.com/google/uuid" +) + +const scannerTestSecret = "scanner-secret-must-be-at-least-32-bytes-yy" + +func TestScannerJWT_RoundTrip(t *testing.T) { + s, err := NewScannerJWTSigner(scannerTestSecret, "test-issuer", 4*time.Hour) + if err != nil { + t.Fatalf("new signer: %v", err) + } + userID := uuid.New() + eventID := uuid.New() + now := time.Now().UTC() + tok, exp, err := s.Issue(userID, eventID, now) + if err != nil { + t.Fatalf("issue: %v", err) + } + if !exp.After(now) { + t.Fatalf("expiry should be after now: exp=%v now=%v", exp, now) + } + got, err := s.Parse(tok) + if err != nil { + t.Fatalf("parse: %v", err) + } + if got.UserID != userID || got.EventID != eventID { + t.Errorf("claims mismatch: got user=%v event=%v want user=%v event=%v", + got.UserID, got.EventID, userID, eventID) + } +} + +func TestScannerJWT_Expired(t *testing.T) { + s, _ := NewScannerJWTSigner(scannerTestSecret, "test-issuer", time.Second) + // Issue with `now` 5 minutes in the past; the +1s TTL has long since + // elapsed against the actual wall clock. + pastNow := time.Now().UTC().Add(-5 * time.Minute) + tok, _, err := s.Issue(uuid.New(), uuid.New(), pastNow) + if err != nil { + t.Fatalf("issue: %v", err) + } + if _, err := s.Parse(tok); !errors.Is(err, ErrExpiredJWT) { + t.Errorf("parse expired: want ErrExpiredJWT, got %v", err) + } +} + +// A scanner JWT must not be acceptable as a session access token even +// though both are HS256-signed with the same secret. The audience +// constraint on the session signer is responsible for keeping them +// apart; this test guards against accidental regressions. +func TestScannerJWT_RejectedBySessionParser(t *testing.T) { + const issuer = "guestguard-test" + scannerSigner, _ := NewScannerJWTSigner(scannerTestSecret, issuer, time.Hour) + sessionSigner, err := NewJWTSigner(scannerTestSecret, time.Hour, issuer) + if err != nil { + t.Fatalf("new session signer: %v", err) + } + + tok, _, err := scannerSigner.Issue(uuid.New(), uuid.New(), time.Now().UTC()) + if err != nil { + t.Fatalf("issue scanner: %v", err) + } + + // The session parser doesn't enforce audience itself, so Parse() will + // succeed and return claims with the "scanner" audience populated. + // Middleware (requireAuth) is what then rejects the audience — so the + // guarantee we want here is that the audience claim survives parsing + // so that check can fire. + claims, err := sessionSigner.Parse(tok) + if err != nil { + t.Fatalf("session parser unexpectedly errored on a scanner JWT: %v", err) + } + hasScannerAud := false + for _, aud := range claims.Audience { + if aud == ScannerJWTAudience { + hasScannerAud = true + } + } + if !hasScannerAud { + t.Errorf("scanner JWT lost its audience after session-parser parse — middleware can no longer reject it. got audience %v", claims.Audience) + } +} + +// And the reverse: a normal session token (no audience) must not be +// accepted by the scanner parser, because the scanner parser pins +// audience=scanner. Otherwise a stolen session token could be turned +// into a fake scanner ticket. +func TestScannerJWT_RejectsSessionToken(t *testing.T) { + const issuer = "guestguard-test" + sessionSigner, _ := NewJWTSigner(scannerTestSecret, time.Hour, issuer) + scannerSigner, _ := NewScannerJWTSigner(scannerTestSecret, issuer, time.Hour) + + tok, _, err := sessionSigner.Issue(uuid.New(), time.Now().UTC()) + if err != nil { + t.Fatalf("issue session: %v", err) + } + if _, err := scannerSigner.Parse(tok); !errors.Is(err, ErrInvalidJWT) { + t.Errorf("scanner parser must reject session token: got %v", err) + } +} + +func TestScannerJWT_SecretTooShort(t *testing.T) { + if _, err := NewScannerJWTSigner("short", "issuer", time.Hour); err == nil { + t.Errorf("expected too-short-secret error") + } +} diff --git a/internal/billing/tiers.go b/internal/billing/tiers.go index 66ca05c..73429e5 100644 --- a/internal/billing/tiers.go +++ b/internal/billing/tiers.go @@ -27,14 +27,44 @@ func (t Tier) Valid() bool { type Limits struct { EventsPerMonth int GuestsPerEvent int + + // Tier 2 feature gates. Auto-reminders, fraud detection, and + // analytics are intentionally on every tier — those are the + // safety + visibility features paying customers expect to find + // out of the box. The gates below are the genuine upsell levers. + MaxCollaborators int // shared editors/viewers per event (-1 = unlimited) + CustomBranding bool // logo / cover / colour overrides + Scanner bool // day-of check-in scanner + magic link + Broadcasts bool // custom broadcasts (auto-reminders are always on) } // TierLimits is the canonical plan-limits table. Matches docs/TIER1_PLAN.md // Block F pricing (placeholder until market validation). var TierLimits = map[Tier]Limits{ - TierFree: {EventsPerMonth: 1, GuestsPerEvent: 50}, - TierPro: {EventsPerMonth: 10, GuestsPerEvent: 1000}, - TierBusiness: {EventsPerMonth: -1, GuestsPerEvent: 5000}, + TierFree: { + EventsPerMonth: 1, + GuestsPerEvent: 50, + MaxCollaborators: 0, + CustomBranding: false, + Scanner: false, + Broadcasts: false, + }, + TierPro: { + EventsPerMonth: 10, + GuestsPerEvent: 1000, + MaxCollaborators: 5, + CustomBranding: true, + Scanner: true, + Broadcasts: true, + }, + TierBusiness: { + EventsPerMonth: -1, + GuestsPerEvent: 5000, + MaxCollaborators: -1, + CustomBranding: true, + Scanner: true, + Broadcasts: true, + }, } // LimitsFor returns the limits for a tier, defaulting to Free for unknown diff --git a/internal/flags/flags.go b/internal/flags/flags.go new file mode 100644 index 0000000..e9856f1 --- /dev/null +++ b/internal/flags/flags.go @@ -0,0 +1,176 @@ +// Package flags loads + serves feature flag decisions. +// +// Why this exists: even with tier-gating and audit logs, you sometimes +// just want to turn a feature off RIGHT NOW (e.g. the new geo-jump +// scorer is throwing false positives during a real event). A row in +// the feature_flags table flips it without a redeploy. +// +// Design notes: +// +// - Flag values are loaded into an in-memory map and refreshed in the +// background every 30s. A check is a map lookup; the cost of a +// gate vanishes from the hot path. +// +// - Default for unknown flags is enabled. New code wiring a gate +// ships live; ops disables later if needed. +// +// - Percent rollout uses a stable hash of (flag_key, user_id) so the +// same user sees a consistent decision across requests. Anonymous +// callers (uuid.Nil) always get the "on" side of the percentage — +// they're treated as the public path. +package flags + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "log/slog" + "sync" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Flag struct { + Key string + Enabled bool + PercentRollout int +} + +// Store loads + serves feature flag decisions. Zero-value Store +// allows everything (handy for tests). +type Store struct { + pool *pgxpool.Pool + logger *slog.Logger + + mu sync.RWMutex + flags map[string]Flag +} + +// New returns a Store with no flags loaded yet. Call Refresh to do the +// first read; the lifecycle helper Start spawns a periodic refresher +// for free. +func New(pool *pgxpool.Pool, logger *slog.Logger) *Store { + if logger == nil { + logger = slog.Default() + } + return &Store{ + pool: pool, + logger: logger, + flags: map[string]Flag{}, + } +} + +// Start runs an initial load and then refreshes every 30 seconds. It +// returns a stop function to be deferred from the caller. +func (s *Store) Start(ctx context.Context) func() { + if s == nil || s.pool == nil { + return func() {} + } + _ = s.Refresh(ctx) + tickerCtx, cancel := context.WithCancel(ctx) + go func() { + t := time.NewTicker(30 * time.Second) + defer t.Stop() + for { + select { + case <-tickerCtx.Done(): + return + case <-t.C: + if err := s.Refresh(tickerCtx); err != nil { + s.logger.Warn("feature flags refresh failed", "err", err) + } + } + } + }() + return cancel +} + +// Refresh re-reads the table. Errors leave the previous in-memory +// snapshot intact so a transient DB blip doesn't black out the gate +// for every request. +func (s *Store) Refresh(ctx context.Context) error { + if s == nil || s.pool == nil { + return nil + } + rows, err := s.pool.Query(ctx, `SELECT key, enabled, percent_rollout FROM feature_flags`) + if err != nil { + return err + } + defer rows.Close() + next := make(map[string]Flag, 16) + for rows.Next() { + var f Flag + var pct int16 + if err := rows.Scan(&f.Key, &f.Enabled, &pct); err != nil { + return err + } + f.PercentRollout = int(pct) + next[f.Key] = f + } + if err := rows.Err(); err != nil { + return err + } + s.mu.Lock() + s.flags = next + s.mu.Unlock() + return nil +} + +// Enabled returns true when the gate identified by `key` is on for +// `subject`. A subject is normally a userID; pass uuid.Nil for global +// gates that have no user dimension (the call still respects on/off +// state but skips the percent-rollout split). +// +// Unknown flag → enabled (safe default for new code). +func (s *Store) Enabled(key string, subject uuid.UUID) bool { + if s == nil { + return true + } + s.mu.RLock() + f, known := s.flags[key] + s.mu.RUnlock() + if !known { + return true + } + if !f.Enabled { + return false + } + if f.PercentRollout >= 100 { + return true + } + if f.PercentRollout <= 0 { + return false + } + if subject == uuid.Nil { + return true + } + return percentBucket(key, subject) < f.PercentRollout +} + +// Snapshot returns the current flag set — handy for an admin GET so +// ops can see what's actually loaded without diving into the DB. +func (s *Store) Snapshot() map[string]Flag { + if s == nil { + return nil + } + s.mu.RLock() + defer s.mu.RUnlock() + out := make(map[string]Flag, len(s.flags)) + for k, v := range s.flags { + out[k] = v + } + return out +} + +// percentBucket maps (flag, subject) → [0, 100). Stable + uniform. +func percentBucket(key string, subject uuid.UUID) int { + h := sha256.New() + h.Write([]byte(key)) + h.Write([]byte{0}) + h.Write(subject[:]) + sum := h.Sum(nil) + n := binary.BigEndian.Uint32(sum[:4]) + return int(n % 100) +} diff --git a/internal/flags/flags_test.go b/internal/flags/flags_test.go new file mode 100644 index 0000000..f5e23b5 --- /dev/null +++ b/internal/flags/flags_test.go @@ -0,0 +1,67 @@ +package flags + +import ( + "testing" + + "github.com/google/uuid" +) + +// nilStore returns enabled for everything — handy for hot paths where +// the store isn't wired (tests, init). +func TestNilStoreAllows(t *testing.T) { + var s *Store + if !s.Enabled("anything", uuid.New()) { + t.Fatal("nil *Store must report Enabled=true so absent infra never disables behaviour") + } +} + +// Unknown keys default to enabled — the safe default for "we just +// added a gate; ship code first, write the row later if needed". +func TestUnknownKeyDefaultsOn(t *testing.T) { + s := New(nil, nil) + if !s.Enabled("never_seeded", uuid.New()) { + t.Errorf("unknown key should be enabled by default") + } +} + +func TestExplicitlyDisabledKey(t *testing.T) { + s := New(nil, nil) + s.flags["kill"] = Flag{Key: "kill", Enabled: false, PercentRollout: 100} + if s.Enabled("kill", uuid.New()) { + t.Errorf("disabled flag must be off regardless of percent") + } +} + +func TestPercentRolloutZeroDisablesForUsers(t *testing.T) { + s := New(nil, nil) + s.flags["k"] = Flag{Key: "k", Enabled: true, PercentRollout: 0} + if s.Enabled("k", uuid.New()) { + t.Errorf("0%% rollout should be off for any user") + } +} + +// Stable bucketing — same (key, user) must always return the same +// decision so a user doesn't flap on and off across requests. +func TestPercentBucketIsStable(t *testing.T) { + s := New(nil, nil) + s.flags["k"] = Flag{Key: "k", Enabled: true, PercentRollout: 50} + u := uuid.New() + first := s.Enabled("k", u) + for i := 0; i < 20; i++ { + if s.Enabled("k", u) != first { + t.Fatalf("flag decision for user %v flapped on iteration %d", u, i) + } + } +} + +// Anonymous callers (uuid.Nil) skip percent-rollout splits — they're +// treated as the public path. Otherwise a 25%-rollout flag would 75% +// of the time refuse anonymous traffic, which is the wrong default +// for a public endpoint. +func TestAnonymousIsAlwaysOn(t *testing.T) { + s := New(nil, nil) + s.flags["k"] = Flag{Key: "k", Enabled: true, PercentRollout: 1} + if !s.Enabled("k", uuid.Nil) { + t.Errorf("uuid.Nil subject should be Enabled=true for partial rollouts") + } +} diff --git a/internal/natspub/events.go b/internal/natspub/events.go index eb7fc46..f5ae73a 100644 --- a/internal/natspub/events.go +++ b/internal/natspub/events.go @@ -27,6 +27,15 @@ type FraudScored struct { Risk string `json:"risk"` Reasons []string `json:"reasons"` ScoredAt time.Time `json:"scored_at"` + // Tier 2 Block G — geolocation enrichment. All optional because + // private IPs and lookup failures leave them unset. The API + // consumer copies these onto access_logs.geo_* so the host UI can + // render "opened from Lagos, Nigeria" without having to do the + // lookup itself. + GeoCountry *string `json:"geo_country,omitempty"` + GeoCity *string `json:"geo_city,omitempty"` + GeoLat *float64 `json:"geo_lat,omitempty"` + GeoLon *float64 `json:"geo_lon,omitempty"` } type RSVPConfirmed struct { @@ -37,6 +46,12 @@ type RSVPConfirmed struct { PlusOnes int `json:"plus_ones"` RiskScore *int `json:"risk_score,omitempty"` SubmittedAt time.Time `json:"submitted_at"` + // AccessLink is the full URL the guest can return to in order to + // view (or edit) their confirmation — the same magic invitation URL + // they used to submit the RSVP. Populated by the API at submit time + // so the notifier can include it in the confirmation email (and + // fallback link beneath the inline QR). Empty on legacy events. + AccessLink string `json:"access_link,omitempty"` } // InvitationSend asks the notifier to dispatch a guest invitation email. diff --git a/internal/notification/templates/confirmation.html b/internal/notification/templates/confirmation.html index 8c433ac..f40261e 100644 --- a/internal/notification/templates/confirmation.html +++ b/internal/notification/templates/confirmation.html @@ -7,5 +7,32 @@ {{if .Venue}}{{.Venue}}{{end}}{{if and .Venue .EventDate}} · {{end}}{{if .EventDate}}{{.EventDate}}{{end}}

{{end}} + +{{if .QRImage}} + + + + + +
+

+ Your door code +

+

+ Show this at the entrance on the day for a quick check-in. +

+ Your check-in QR code + {{if .RSVPLink}} +

+ If your inbox doesn't show the code, open your invitation from + this link + — the same QR is on the confirmation page. +

+ {{end}} +
+{{end}} +

You'll get a reminder closer to the date. If your plans change, use the same invitation link to update your reply.

{{end}} diff --git a/internal/storage/access_logs.go b/internal/storage/access_logs.go index 802f21d..8611574 100644 --- a/internal/storage/access_logs.go +++ b/internal/storage/access_logs.go @@ -56,6 +56,13 @@ type ApplyScoreParams struct { Score int Reasons []string Flagged bool + // Tier 2 Block G geolocation enrichment, copied over from the + // fraud.scored NATS event. nil-safe — private IPs + lookup + // failures leave them unset. + GeoCountry *string + GeoCity *string + GeoLat *float64 + GeoLon *float64 } // AccessCheckActivity is a scored access-log entry joined with the guest's @@ -109,6 +116,39 @@ func (r *AccessLogRepo) ListRecentScoredByEvent(ctx context.Context, eventID uui return out, rows.Err() } +// ScoresForEvent returns every recorded risk_score for the event's +// access logs in descending creation order, up to `limit`. Used by the +// threshold-preview endpoint so a host moving the sliders can see "12 +// of your last 47 events would now flag" without us re-shipping the +// full access logs to the browser. +func (r *AccessLogRepo) ScoresForEvent(ctx context.Context, eventID uuid.UUID, limit int) ([]int, error) { + if limit <= 0 || limit > 5000 { + limit = 1000 + } + const q = ` + SELECT a.risk_score + FROM access_logs a + JOIN guests g ON g.id = a.guest_id + WHERE g.event_id = $1 AND a.risk_score IS NOT NULL + ORDER BY a.created_at DESC + LIMIT $2 + ` + rows, err := r.pool.Query(ctx, q, eventID, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []int + for rows.Next() { + var s int16 + if err := rows.Scan(&s); err != nil { + return nil, err + } + out = append(out, int(s)) + } + return out, rows.Err() +} + // BelongsToEvent reports whether the access log identified by `id` is // attached (via guest) to `eventID`. Used by the feedback endpoint to // stop a hostile editor on event A from marking event B's logs. @@ -125,12 +165,25 @@ func (r *AccessLogRepo) BelongsToEvent(ctx context.Context, id, eventID uuid.UUI } func (r *AccessLogRepo) ApplyScore(ctx context.Context, p ApplyScoreParams) error { + // COALESCE preserves an existing geo column when the new event + // doesn't carry one — e.g. a re-score that came in over a gRPC + // path without geo set wouldn't accidentally wipe out the geo + // recorded by an earlier NATS-fed score. const q = ` UPDATE access_logs - SET risk_score = $2, risk_reasons = $3, flagged = $4 + SET risk_score = $2, + risk_reasons = $3, + flagged = $4, + geo_country = COALESCE($5, geo_country), + geo_city = COALESCE($6, geo_city), + geo_lat = COALESCE($7, geo_lat), + geo_lon = COALESCE($8, geo_lon) WHERE id = $1 ` - tag, err := r.pool.Exec(ctx, q, p.AccessLogID, p.Score, p.Reasons, p.Flagged) + tag, err := r.pool.Exec(ctx, q, + p.AccessLogID, p.Score, p.Reasons, p.Flagged, + p.GeoCountry, p.GeoCity, p.GeoLat, p.GeoLon, + ) if err != nil { return err } diff --git a/internal/storage/checkins.go b/internal/storage/checkins.go index 3760485..6b37f4f 100644 --- a/internal/storage/checkins.go +++ b/internal/storage/checkins.go @@ -6,6 +6,7 @@ import ( "fmt" "github.com/google/uuid" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" @@ -103,6 +104,24 @@ func (r *CheckInRepo) Summary(ctx context.Context, eventID uuid.UUID) (domain.Ch return s, err } +// GetByGuest returns the existing check-in for a guest, or nil if none. +// Used by the scanner's preview endpoint to surface "already in" before +// the volunteer picks a party size. +func (r *CheckInRepo) GetByGuest(ctx context.Context, guestID uuid.UUID) (*domain.CheckIn, error) { + var c domain.CheckIn + err := r.pool.QueryRow(ctx, ` + SELECT id, guest_id, checked_in_at, checked_in_by, arrival_count, notes, walk_in + FROM check_ins WHERE guest_id = $1 + `, guestID).Scan(&c.ID, &c.GuestID, &c.CheckedInAt, &c.CheckedInBy, &c.ArrivalCount, &c.Notes, &c.WalkIn) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &c, nil +} + // GuestBelongsToEvent confirms a guest is on the event before we record // their check-in. Belt-and-braces guard against a forged JWT pointing // at a guest from a different event — the JWT layer already binds diff --git a/internal/storage/migrations/0014_audit_log.down.sql b/internal/storage/migrations/0014_audit_log.down.sql new file mode 100644 index 0000000..78274ce --- /dev/null +++ b/internal/storage/migrations/0014_audit_log.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_audit_user; +DROP INDEX IF EXISTS idx_audit_event; +DROP TABLE IF EXISTS audit_log; diff --git a/internal/storage/migrations/0014_audit_log.up.sql b/internal/storage/migrations/0014_audit_log.up.sql new file mode 100644 index 0000000..17d5116 --- /dev/null +++ b/internal/storage/migrations/0014_audit_log.up.sql @@ -0,0 +1,27 @@ +-- Tier 2 cross-cutting: audit log. +-- +-- Every meaningful host-facing write (collaborator change, branding +-- update, threshold tweak, allowlist edit, message send/cancel) +-- records a row here. The shape is deliberately generic so a future +-- Audit tab can render a per-event timeline without per-action joins. + +CREATE TABLE audit_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + event_id UUID REFERENCES events(id) ON DELETE CASCADE, + action TEXT NOT NULL, -- e.g. "branding.update" + entity_type TEXT, -- "event" | "guest" | "message" … + target_id UUID, -- what was acted on (nullable) + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, -- per-action context + request_id TEXT, -- threading / correlation + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- The two query shapes we'll need: "show me the trail on this event" +-- (Audit tab, host-side timeline) and "what did this user touch lately" +-- (support / compliance lookups). Partial index on event_id keeps the +-- footprint small since not every audit row carries one. +CREATE INDEX idx_audit_event ON audit_log (event_id, created_at DESC) + WHERE event_id IS NOT NULL; +CREATE INDEX idx_audit_user ON audit_log (user_id, created_at DESC) + WHERE user_id IS NOT NULL; diff --git a/internal/storage/migrations/0015_feature_flags.down.sql b/internal/storage/migrations/0015_feature_flags.down.sql new file mode 100644 index 0000000..28b0ec9 --- /dev/null +++ b/internal/storage/migrations/0015_feature_flags.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS feature_flags; diff --git a/internal/storage/migrations/0015_feature_flags.up.sql b/internal/storage/migrations/0015_feature_flags.up.sql new file mode 100644 index 0000000..6fe007c --- /dev/null +++ b/internal/storage/migrations/0015_feature_flags.up.sql @@ -0,0 +1,34 @@ +-- Tier 2 cross-cutting: feature flags. +-- +-- Lets ops kill a misbehaving block without a redeploy + supports +-- percentage rollouts ("turn checkin_pwa on for 25% of users while we +-- watch the error rate"). Flag values are loaded on every check — +-- they're tiny, and we want to be able to flip a switch and see the +-- next request honour it without restart. +-- +-- The default state of any unknown flag is "enabled" so the table +-- only needs rows for the explicit kills + rollouts. This is the +-- right safe default for code that gates a NEW feature: the +-- developer wires the gate, ships, the feature is live; ops can +-- write a row to take it back later without redeploy. + +CREATE TABLE feature_flags ( + key TEXT PRIMARY KEY, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + percent_rollout SMALLINT NOT NULL DEFAULT 100 + CHECK (percent_rollout >= 0 AND percent_rollout <= 100), + note TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Pre-seed the Tier 2 flags so an operator can flip them without +-- having to remember the canonical key. +INSERT INTO feature_flags (key, enabled, percent_rollout, note) VALUES + ('editable_rsvp', TRUE, 100, 'Tier 2 Block A — guests can edit their RSVP via PATCH /rsvp.'), + ('checkin_pwa', TRUE, 100, 'Tier 2 Block H — day-of scanner + magic-link ticket.'), + ('smarter_fraud', TRUE, 100, 'Tier 2 Block G — per-event thresholds, allowlists, feedback.'), + ('geo_jump', TRUE, 100, 'Tier 2 Block G — geo_jump scoring feature on top of GeoIP.'), + ('broadcasts', TRUE, 100, 'Tier 2 Block F — custom broadcasts on top of auto-reminders.'), + ('custom_branding', TRUE, 100, 'Tier 2 Block D — per-event logo / cover / colour overrides.') +ON CONFLICT (key) DO NOTHING; diff --git a/internal/storage/subscriptions.go b/internal/storage/subscriptions.go index 45429f6..3a5740e 100644 --- a/internal/storage/subscriptions.go +++ b/internal/storage/subscriptions.go @@ -199,6 +199,25 @@ func (r *SubscriptionRepo) CountGuestsByEvent(ctx context.Context, eventID uuid. return n, nil } +// CountCollaboratorsByEvent counts non-owner collaborators on an event +// (accepted + still-pending invites). Used for the Tier 2 collaborator +// quota — owners aren't a shared seat, they pay separately, so they're +// excluded. +func (r *SubscriptionRepo) CountCollaboratorsByEvent(ctx context.Context, eventID uuid.UUID) (int, error) { + var n int + if err := r.pool.QueryRow(ctx, ` + SELECT + (SELECT count(*) FROM event_collaborators + WHERE event_id = $1 AND role <> 'owner') + + (SELECT count(*) FROM collaborator_invites + WHERE event_id = $1 AND consumed_at IS NULL + AND expires_at > now() AND role <> 'owner') + `, eventID).Scan(&n); err != nil { + return 0, err + } + return n, nil +} + func (r *SubscriptionRepo) scanOne(ctx context.Context, q string, args ...any) (*Subscription, error) { sub, err := scanSubscription(r.pool.QueryRow(ctx, q, args...)) if err != nil { diff --git a/scripts/gen-scanner-icons.go b/scripts/gen-scanner-icons.go new file mode 100644 index 0000000..1268ee4 --- /dev/null +++ b/scripts/gen-scanner-icons.go @@ -0,0 +1,150 @@ +//go:build ignore +// +build ignore + +// Quick generator for the scanner PWA's launcher icons. Run via: +// +// go run scripts/gen-scanner-icons.go +// +// Produces frontend/public/icons/scanner-{192,512}.png — black-on-green +// rounded-square marks with a centred "GG" wordmark. Stays in the repo +// so the icons can be regenerated when the brand changes, and so the +// reader doesn't have to wonder where the binary asset came from. +package main + +import ( + "fmt" + "image" + "image/color" + "image/png" + "os" + "path/filepath" +) + +const ( + bg = "#0a0a0a" // app background + brand = "#22c55e" // guestguard green + textCol = "#0a0a0a" // text drawn over the green disc +) + +type icon struct { + size int + path string +} + +func main() { + icons := []icon{ + {192, "frontend/public/icons/scanner-192.png"}, + {512, "frontend/public/icons/scanner-512.png"}, + } + for _, it := range icons { + if err := writeIcon(it); err != nil { + fmt.Fprintln(os.Stderr, "icon:", it.path, err) + os.Exit(1) + } + fmt.Println("wrote", it.path) + } +} + +func writeIcon(it icon) error { + if err := os.MkdirAll(filepath.Dir(it.path), 0o755); err != nil { + return err + } + img := image.NewRGBA(image.Rect(0, 0, it.size, it.size)) + bgC := mustParseHex(bg) + brandC := mustParseHex(brand) + textC := mustParseHex(textCol) + + // Solid background, rounded corners — Android maskable expects the + // safe inner area; we leave 8% padding so a circular mask doesn't + // crop the brand mark. + radius := it.size / 6 + for y := 0; y < it.size; y++ { + for x := 0; x < it.size; x++ { + if inRoundedRect(x, y, it.size, it.size, radius) { + img.Set(x, y, bgC) + } else { + img.Set(x, y, color.RGBA{0, 0, 0, 0}) + } + } + } + + // Centered green disc, ~62% of canvas — leaves room for the maskable + // safe-zone trim. + cx, cy := it.size/2, it.size/2 + rDisc := int(float64(it.size) * 0.31) + for y := cy - rDisc; y <= cy+rDisc; y++ { + for x := cx - rDisc; x <= cx+rDisc; x++ { + dx, dy := x-cx, y-cy + if dx*dx+dy*dy <= rDisc*rDisc { + img.Set(x, y, brandC) + } + } + } + + // "GG" wordmark — two stylised C shapes (open on the right) made of + // thick ring segments. Avoids depending on a font file in this + // throwaway generator; produces a clean recognisable mark at both + // 192 and 512. + drawG(img, cx-rDisc/2-1, cy, int(float64(rDisc)*0.38), int(float64(rDisc)*0.10), textC) + drawG(img, cx+rDisc/2+1, cy, int(float64(rDisc)*0.38), int(float64(rDisc)*0.10), textC) + + f, err := os.Create(it.path) + if err != nil { + return err + } + defer f.Close() + return png.Encode(f, img) +} + +func drawG(img *image.RGBA, cx, cy, r, thickness int, c color.Color) { + // Open-right ring + small inward tick on the bottom — looks enough + // like a "G" to read at icon scale. + for y := cy - r; y <= cy+r; y++ { + for x := cx - r; x <= cx+r; x++ { + dx, dy := x-cx, y-cy + d2 := dx*dx + dy*dy + if d2 <= r*r && d2 >= (r-thickness)*(r-thickness) { + // Open the right side so the shape reads as a G. + if dx > r-thickness*3 && dy > -thickness && dy < thickness { + continue + } + img.Set(x, y, c) + } + } + } + // Inward horizontal tick at mid-right for the G's crossbar. + for x := cx; x <= cx+r/2; x++ { + for y := cy - thickness/2; y <= cy+thickness/2; y++ { + img.Set(x, y, c) + } + } +} + +func inRoundedRect(x, y, w, h, r int) bool { + if x < r && y < r { + dx, dy := r-x, r-y + return dx*dx+dy*dy <= r*r + } + if x >= w-r && y < r { + dx, dy := x-(w-r-1), r-y + return dx*dx+dy*dy <= r*r + } + if x < r && y >= h-r { + dx, dy := r-x, y-(h-r-1) + return dx*dx+dy*dy <= r*r + } + if x >= w-r && y >= h-r { + dx, dy := x-(w-r-1), y-(h-r-1) + return dx*dx+dy*dy <= r*r + } + return true +} + +func mustParseHex(s string) color.RGBA { + if len(s) != 7 || s[0] != '#' { + panic("bad hex: " + s) + } + var r, g, b uint8 + fmt.Sscanf(s, "#%02x%02x%02x", &r, &g, &b) + return color.RGBA{r, g, b, 0xff} +}