Files
Kwaku Danso 98678ff5a3 feat(tier2): finish the finish line — Block H follow-ups, Block G geolocation, cross-cutting
Three threads of work land here together to close out Tier 2.

### Block H follow-ups — day-of check-in
- Scanner is now an "open on your phone" magic-link flow. Hosts on
  desktop mint a scoped JWT via POST /events/{id}/scanner-ticket and
  render its URL into a QR; phone scans it and lands on /scanner with
  the ticket as bearer. The ticket carries Audience=scanner so it can
  never substitute for a session token.
- Plus-one confirmation at the door: scan → POST /check-in/preview to
  fetch guest + expected party size → confirm buttons ("Just them",
  "Party of N", custom) → POST /check-in. No more silent arrival_count=1.
- Offline scan queue: failed POSTs go into an IndexedDB store and drain
  on the 'online' event with poison-message protection.
- Day-of arrivals headline widget on the event overview, gated to the
  host's local calendar date so it doesn't dominate the page weeks out.
- Tab nav restyled with inline heroicons + scrollable segmented control;
  Check-in moves to the rightmost slot.
- PWA: manifest + service worker scoped to /scanner, generated 192/512
  icons (Go scripted renderer in scripts/gen-scanner-icons.go).
- Confirmation email QR was rendering broken because html/template
  rewrites data: URLs to #ZgotmplZ; mark the value as template.URL.
- Email "open your invitation" link 404'd because we had no token to
  put after /rsvp/. Threaded AccessLink through the RSVPConfirmed NATS
  event from the API at submit time.

### Block G remainder — geolocation + threshold preview
- Pluggable GeoResolver in the fraud engine (NullResolver, IPApiResolver
  for the free ip-api.com fallback, MaxMindResolver behind GG_GEOIP_DB_PATH).
  Wrapped in a Redis cache (30d TTL). Geo flows through both gRPC and
  NATS scoring paths.
- geo_jump scoring feature: >500km in <1h flags ("accessed from Lagos
  and Paris within 12 minutes"); >500km in <6h is a softer signal. The
  existing single-signal cap keeps a lone geo_jump in MEDIUM.
- FraudScored event carries geo_country/city/lat/lon; ApplyScore uses
  COALESCE so a later re-score without geo doesn't wipe earlier data.
- Threshold-slider live preview: GET /events/{id}/security/thresholds/preview
  returns band counts the host's existing access events would have
  fallen into under the proposed thresholds. Debounced (250ms) widget
  under the Advanced sliders so the host gets concrete feedback instead
  of guessing.

### Cross-cutting — audit, tier-gating, feature flags
- audit_log table + internal/audit.Recorder (async fire-and-forget on
  detached context so an audit blip never fails the real action). Wired
  into branding update, thresholds update, allowlist add/remove,
  collaborator invite/role-change/remove, message create/send-now/cancel.
- Tier-gating: extended billing.Limits with MaxCollaborators,
  CustomBranding, Scanner, Broadcasts. Free = none; Pro = 5 + all;
  Business = unlimited. Gates the scanner-ticket, message create,
  branding put, and collaborator invite endpoints with 402 +
  structured upgrade payload. Auto-reminders, fraud detection, and
  analytics deliberately stay on every tier — those are safety + visibility
  features, not upsell levers.
- Feature flags: feature_flags table + internal/flags.Store with 30s
  in-memory refresh, stable sha256(key + user_id) percent bucketing,
  unknown-key-defaults-on. Six Tier 2 flags pre-seeded. Three handlers
  (branding, broadcasts, scanner) check the kill switch ahead of the
  tier gate so ops can pull a feature back without a redeploy.

### Verified
- go test ./... + fraud-engine pytest (12/12 incl. 3 new geo_jump tests + 5
  new flags tests).
- docker compose build + up across api, fraud-engine, notifier, frontend.
- /health endpoints 200; migrations 0014 + 0015 applied; 6 flags
  seeded; audit_log table + partial indexes confirmed.
- Fraud-engine logs confirm geo resolver kind=CachedGeoResolver provider=auto.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-21 20:30:02 +01:00

477 lines
14 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"github.com/google/uuid"
"github.com/alchemistkay/guestguard/internal/auth"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/fraud"
"github.com/alchemistkay/guestguard/internal/natspub"
"github.com/alchemistkay/guestguard/internal/storage"
)
type rsvpPublisher interface {
PublishRSVPConfirmed(ctx context.Context, evt natspub.RSVPConfirmed) error
}
type fraudScorer interface {
Score(ctx context.Context, in fraud.ScoreInput) fraud.Decision
}
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
publicBaseURL string
}
type submitRSVPRequest struct {
Response string `json:"response"`
PlusOnes int `json:"plus_ones"`
DietaryNotes *string `json:"dietary_notes"`
Fingerprint map[string]any `json:"fingerprint"`
// EditNonce is the short-lived value carried back from the magic
// edit link the guest received by email. It bypasses the
// same-device check on PATCH so a guest who jumped to a new phone
// can still edit their RSVP. Empty on POST and on edits from the
// original device.
EditNonce string `json:"edit_nonce,omitempty"`
}
type submitRSVPResponse struct {
RSVP *domain.RSVP `json:"rsvp"`
Decision fraud.Decision `json:"fraud"`
Blocked bool `json:"blocked"`
Edited bool `json:"edited"`
}
// POST /rsvp/{token} — synchronous fraud check + RSVP recording.
func (h *rsvpHandler) submit(w http.ResponseWriter, r *http.Request) {
tk, ok := h.loadValidToken(w, r)
if !ok {
return
}
req, ok := decodeSubmitRSVP(w, r)
if !ok {
return
}
guest, event, ok := h.loadGuestEvent(w, r, tk.GuestID)
if !ok {
return
}
if req.PlusOnes > guest.PlusOnes {
writeError(w, http.StatusBadRequest,
fmt.Sprintf("you may bring up to %d plus-one(s)", guest.PlusOnes))
return
}
decision, fingerprint, ip, ok := h.scoreAccess(w, r, event, guest, tk, req.Fingerprint)
if !ok {
return
}
score := decision.Score
rsvp, err := h.rsvps.Create(r.Context(), storage.CreateRSVPParams{
GuestID: guest.ID,
Response: req.Resp,
PlusOnes: req.PlusOnes,
DietaryNotes: req.DietaryNotes,
DeviceFingerprint: fingerprint,
IPAddress: ip,
RiskScore: &score,
})
if err != nil {
if errors.Is(err, domain.ErrRSVPAlreadySubmitted) {
// Tier 2 Block A: guests edit an existing RSVP via PATCH. Tell
// the client to switch endpoints rather than swallowing the
// retry silently.
writeError(w, http.StatusConflict, "rsvp already submitted — use PATCH to edit")
return
}
h.logger.Error("create rsvp", "err", err, "guest_id", guest.ID)
writeError(w, http.StatusInternalServerError, "failed to record rsvp")
return
}
// Block A: we intentionally do NOT mark the token used here any more.
// 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.accessLinkFor(r))
writeJSON(w, http.StatusCreated, submitRSVPResponse{
RSVP: rsvp,
Decision: decision,
Blocked: false,
Edited: false,
})
}
// PATCH /rsvp/{token} — revise an existing RSVP. Same fraud check as POST.
// The prior state is snapshotted into rsvp_revisions inside Update; edits
// past MaxRSVPEdits return 429.
//
// As of the forwarded-link defence: a PATCH is only accepted when either
// (a) the current device fingerprint matches the one stored on the
// existing RSVP, or (b) the caller presented a valid edit nonce in the
// request body. Otherwise we refuse with 403 + a hint to request an edit
// link — this stops anyone the original guest forwarded the link to from
// quietly altering the response.
func (h *rsvpHandler) edit(w http.ResponseWriter, r *http.Request) {
tk, ok := h.loadValidToken(w, r)
if !ok {
return
}
req, ok := decodeSubmitRSVP(w, r)
if !ok {
return
}
guest, event, ok := h.loadGuestEvent(w, r, tk.GuestID)
if !ok {
return
}
if req.PlusOnes > guest.PlusOnes {
writeError(w, http.StatusBadRequest,
fmt.Sprintf("you may bring up to %d plus-one(s)", guest.PlusOnes))
return
}
// Forwarded-link gate. Load the existing RSVP (if any) so we can
// compare device fingerprints; absent RSVP means this is a quirky
// PATCH-before-POST flow that Update will 404 below anyway.
if existing, err := h.rsvps.GetByGuest(r.Context(), guest.ID); err == nil && existing != nil {
current := mergeFingerprint(req.Fingerprint, collectFingerprint(r))
if !fingerprintsSimilar(existing.DeviceFingerprint, current) {
// Bypass via edit nonce delivered to the guest's email.
bypassed := false
if h.editNonces != nil && req.EditNonce != "" {
if ok, _ := h.editNonces.Verify(r.Context(), req.EditNonce, guest.ID); ok {
bypassed = true
}
}
if !bypassed {
writeError(w, http.StatusForbidden,
"this invitation looks like it's being opened from a different device; "+
"please request an edit link from the previous screen")
return
}
}
}
decision, fingerprint, ip, ok := h.scoreAccess(w, r, event, guest, tk, req.Fingerprint)
if !ok {
return
}
score := decision.Score
rsvp, err := h.rsvps.Update(r.Context(), storage.UpdateRSVPParams{
GuestID: guest.ID,
Response: req.Resp,
PlusOnes: req.PlusOnes,
DietaryNotes: req.DietaryNotes,
DeviceFingerprint: fingerprint,
IPAddress: ip,
RiskScore: &score,
})
if err != nil {
switch {
case errors.Is(err, domain.ErrRSVPNotFound):
writeError(w, http.StatusNotFound, "no rsvp to edit — submit first")
case errors.Is(err, domain.ErrRSVPEditLimitReached):
writeError(w, http.StatusTooManyRequests,
fmt.Sprintf("edit limit reached (%d edits)", domain.MaxRSVPEdits))
default:
h.logger.Error("update rsvp", "err", err, "guest_id", guest.ID)
writeError(w, http.StatusInternalServerError, "failed to update rsvp")
}
return
}
h.publishRSVPConfirmed(event.ID, guest.ID, rsvp, &score, h.accessLinkFor(r))
writeJSON(w, http.StatusOK, submitRSVPResponse{
RSVP: rsvp,
Decision: decision,
Blocked: false,
Edited: true,
})
}
type rsvpHistoryResponse struct {
RSVP *domain.RSVP `json:"rsvp"`
Revisions []domain.RSVPRevision `json:"revisions"`
}
// GET /events/{id}/guests/{guest_id}/rsvp/history — host view of the
// current RSVP plus every prior revision newest-first.
func (h *rsvpHandler) history(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
guestID, ok := parseIDParam(w, r, "guest_id")
if !ok {
return
}
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok {
return
}
guest, err := h.guests.Get(r.Context(), guestID)
if err != nil {
if errors.Is(err, domain.ErrGuestNotFound) {
writeError(w, http.StatusNotFound, "guest not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load guest")
return
}
if guest.EventID != eventID {
writeError(w, http.StatusNotFound, "guest not found in event")
return
}
rsvp, err := h.rsvps.GetByGuest(r.Context(), guestID)
if err != nil {
if errors.Is(err, domain.ErrRSVPNotFound) {
writeJSON(w, http.StatusOK, rsvpHistoryResponse{Revisions: []domain.RSVPRevision{}})
return
}
writeError(w, http.StatusInternalServerError, "failed to load rsvp")
return
}
revisions, err := h.rsvps.ListRevisions(r.Context(), rsvp.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load revisions")
return
}
writeJSON(w, http.StatusOK, rsvpHistoryResponse{RSVP: rsvp, Revisions: revisions})
}
// --- shared helpers ---
type decodedRSVPRequest struct {
Resp domain.RSVPResponse
PlusOnes int
DietaryNotes *string
Fingerprint map[string]any
EditNonce string
}
func decodeSubmitRSVP(w http.ResponseWriter, r *http.Request) (decodedRSVPRequest, bool) {
var req submitRSVPRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return decodedRSVPRequest{}, false
}
resp := domain.RSVPResponse(req.Response)
if !resp.Valid() {
writeError(w, http.StatusBadRequest, "response must be attending|declined|maybe")
return decodedRSVPRequest{}, false
}
if req.PlusOnes < 0 {
writeError(w, http.StatusBadRequest, "plus_ones must be >= 0")
return decodedRSVPRequest{}, false
}
return decodedRSVPRequest{
Resp: resp,
PlusOnes: req.PlusOnes,
DietaryNotes: req.DietaryNotes,
Fingerprint: req.Fingerprint,
EditNonce: req.EditNonce,
}, true
}
func (h *rsvpHandler) loadValidToken(w http.ResponseWriter, r *http.Request) (*domain.Token, bool) {
raw := r.PathValue("token")
if err := auth.ValidateFormat(raw); err != nil {
writeError(w, http.StatusBadRequest, "malformed token")
return nil, false
}
tk, err := h.tokens.GetByHash(r.Context(), auth.HashToken(raw))
if err != nil {
if errors.Is(err, domain.ErrTokenNotFound) {
writeError(w, http.StatusNotFound, "token not found")
return nil, false
}
writeError(w, http.StatusInternalServerError, "failed to load token")
return nil, false
}
if err := tk.IsValid(time.Now().UTC()); err != nil {
writeError(w, http.StatusGone, err.Error())
return nil, false
}
return tk, true
}
func (h *rsvpHandler) loadGuestEvent(w http.ResponseWriter, r *http.Request, guestID uuid.UUID) (*domain.Guest, *domain.Event, bool) {
guest, err := h.guests.Get(r.Context(), guestID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load guest")
return nil, nil, false
}
event, err := h.events.Get(r.Context(), guest.EventID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load event")
return nil, nil, false
}
return guest, event, true
}
// scoreAccess logs the access attempt, scores it, and short-circuits with a
// 403 on a BLOCK decision. Returns the decision and the merged fingerprint /
// IP so the caller can persist them on the RSVP row.
func (h *rsvpHandler) scoreAccess(
w http.ResponseWriter,
r *http.Request,
event *domain.Event,
guest *domain.Guest,
tk *domain.Token,
clientFP map[string]any,
) (fraud.Decision, map[string]any, string, bool) {
fingerprint := mergeFingerprint(clientFP, collectFingerprint(r))
ip := clientIP(r)
accessLogID, err := h.accessLogs.Create(r.Context(), storage.CreateAccessLogParams{
GuestID: guest.ID,
TokenID: tk.ID,
Fingerprint: fingerprint,
IPAddress: ip,
})
if err != nil {
h.logger.Error("create access log", "err", err)
}
// Block G: allowlist short-circuit. If the request IP matches a CIDR
// the host has explicitly trusted (office Wi-Fi, family network), we
// skip the fraud engine entirely — score 0, low band. Best-effort:
// any error reading the allowlist falls through to normal scoring so a
// dropped DB connection doesn't lock guests out of an event.
if h.allowlist != nil {
if matched, label, err := h.allowlist.Matches(r.Context(), event.ID, ip); err == nil && matched {
reason := "allowlisted"
if label != "" {
reason = "allowlisted: " + label
}
return fraud.Decision{
Score: 0,
Risk: "low",
Reasons: []string{reason},
Used: true,
}, fingerprint, ip, true
}
}
decision := h.scorer.Score(r.Context(), fraud.ScoreInput{
EventID: event.ID,
GuestID: guest.ID,
TokenID: tk.ID,
AccessLogID: accessLogID,
Fingerprint: stringifyFingerprint(fingerprint),
IPAddress: ip,
UserAgent: r.UserAgent(),
Referrer: r.Referer(),
})
// Block G: re-band the score using this event's thresholds. The
// engine's `Risk` field becomes advisory; the API is the source of
// truth for "what counts as block here". This lets a strict-event
// host set Block=70 while a casual-event host sets it to 95 without
// touching the engine.
decision.Risk = event.Thresholds().Band(decision.Score)
if fraud.IsBlock(decision) {
writeJSON(w, http.StatusForbidden, submitRSVPResponse{
Decision: decision,
Blocked: true,
})
return decision, nil, "", false
}
return decision, fingerprint, ip, true
}
func (h *rsvpHandler) publishRSVPConfirmed(eventID, guestID uuid.UUID, rsvp *domain.RSVP, score *int, accessLink string) {
if h.pub == nil {
return
}
go func(evt natspub.RSVPConfirmed) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := h.pub.PublishRSVPConfirmed(ctx, evt); err != nil {
h.logger.Error("publish rsvp.confirmed", "err", err, "rsvp_id", evt.RSVPID)
}
}(natspub.RSVPConfirmed{
EventID: eventID,
GuestID: guestID,
RSVPID: rsvp.ID,
Response: string(rsvp.Response),
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 {
out[k] = v
}
for k, v := range client {
out["client_"+k] = v
}
return out
}
func stringifyFingerprint(fp map[string]any) map[string]string {
if fp == nil {
return nil
}
out := make(map[string]string, len(fp))
for k, v := range fp {
switch tv := v.(type) {
case string:
out[k] = tv
default:
b, _ := json.Marshal(tv)
out[k] = string(b)
}
}
return out
}