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 @@
+
+
+
+
+
+
🔒
+
This scanner link can't be used
+
+ It may have expired, been revoked, or wasn't for this event.
+ Ask the host to send you a fresh link from their dashboard.
+
+
+
+
+
+
+
+
GuestGuard · Door scanner
+
+ Offline
+
+
+
+
+
+
+
+
+
+
Arrived
+
+ {{ summary.arrived_headcount }}
+ of {{ summary.expected_headcount }}
+
+
+
+
{{ arrivalPct }}%
+
+
+
+
+
+
+
+
+
+
+
+
+
+
📷
+
Tap "Start scanning" to use the camera.
+
+
{{ startError }}
+
+
+
+
+
+
+
+
+
+
+
Guest matched
+
{{ confirm.guestName }}
+
Already checked in earlier. Tapping a count below will re-record the latest party size.
+
How many in their party walked in?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Just arrived
+
+
+
+ Guest
+ walk-in
+
+ party of {{ c.arrival_count }}
+
+
+
+
+
+
+
+
+
+
+
Walk-in
+
Adds them to the guest list and marks them in.
+
+
+
+
+
+
+
+
+
{{ toast.text }}
+
+
+
+
{{ fatalError }}
+
+
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.
+
+
+ {{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}
+}