98678ff5a3
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>
477 lines
14 KiB
Go
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
|
|
}
|