Commit Graph

6 Commits

Author SHA1 Message Date
Kwaku Danso 3629ab8c79 fix(tier2-g): rebrand Security → Gate with event-planner language
The original Block G UI was correct but felt like a firewall config panel.
The audience for GuestGuard is wedding couples, party hosts, and event
planners — not network admins. Three concrete problems:

1. "Fraud detection" / "Risk score" frames every guest as a suspect.
   "Gate" is the metaphor every host already uses ("gate the guest
   list", "doorman"). It's the same idea, friendlier.

2. Three threshold sliders (0–100) are abstract. Hosts can't reason
   about "block at 85" without context. Replaced by four presets —
   Relaxed / Balanced / Strict / Very strict — each with a sentence
   that explains the kind of event it fits. The sliders survive under
   an Advanced disclosure for the rare power user.

3. CIDR notation is gibberish to a layperson. "Trusted networks" is
   what they're actually doing. A "Use my current network" button
   detects the host's apparent IP, widens IPv4 to /24 (typical home
   block), and pre-fills the form. Manual entry stays for cases
   where the host knows what they're doing.

Plus two leaks fixed on the guest-facing RSVP page: removed the
"Risk score 72 · high" line from both the confirmation and the
blocked-attempt cards. Guests should never see internal scoring
detail — the blocked message now reads "Something about this
attempt looked off" instead of "suspicious access attempt".

Backend
- New GET /me/public-ip (authed) — echoes the caller's apparent IP
  so the frontend's auto-detect button doesn't need a third-party
  ipify call

Frontend
- New components/GateCard.vue with preset cards + advanced disclosure
  + trusted-networks UX with auto-detect
- Removed components/SecurityCard.vue
- Event tab nav: Security → Gate. Hash-link alias preserves any
  /events/<id>#security bookmarks
- RSVP page: leaked risk-score lines removed; blocked-attempt
  message reworded for non-technical guests

Internal storage / API names stay (FraudThresholds, fraud_v2,
/security/* endpoints) — they're never user-visible and renaming
them would be a breaking change for no end-user benefit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 22:15:11 +01:00
Kwaku Danso b873012191 feat(tier2): smarter fraud detection — Block G
Per-event fraud tuning. Hosts can now dial the medium / high / block
boundaries, allowlist trusted networks, and feed verdicts back on
flagged accesses — the seed corpus for a future ML model.

Schema (migration 0011)
- events.fraud_{medium,high,block}_threshold default 30/60/85 so
  existing events behave identically until a host changes them
- access_logs.geo_{country,city,lat,lon} for future enrichment
- fraud_feedback table — verdict ('legitimate' | 'suspicious') + note,
  PK on access_log_id so re-mark is an upsert
- event_allowlists table — (event_id, ip_cidr) primary key, inet column
  so containment checks use the native >>= operator (indexed lookup)

Domain
- FraudThresholds with Valid() + Band() helpers; Default trio echoed
  through GET responses so the frontend doesn't duplicate constants
- ParseAllowlistCIDR accepts bare IPs (auto-widens to /32 or /128) and
  canonicalises the output (203.0.113.42 → 203.0.113.42/32)
- Event.Thresholds() falls back to defaults if columns weren't
  populated yet, so the API never wedges every score into "low"

Storage
- AllowlistRepo: List / Add / Remove + Matches() — the latter pushes
  CIDR containment into Postgres rather than streaming rows back
- FeedbackRepo: Record (upserts) + ListForEvent (joined through guests)
- EventRepo.GetThresholds + UpdateThresholds, plus the threshold
  columns baked into scanEvent so every event load carries them
- AccessLogRepo.BelongsToEvent — stops a hostile editor on event A
  from marking event B's access logs

API
- GET/PUT /events/{id}/security/thresholds (viewer/editor)
- GET/POST/DELETE /events/{id}/security/allowlist
- POST /events/{id}/access-logs/{log_id}/feedback (editor)
- GET /events/{id}/security/feedback
- RSVP scoring path: allowlist short-circuit fires before the fraud
  engine; the engine's score is then re-banded against the event's
  thresholds (engine.Risk becomes advisory — API is the source of
  truth for "what counts as block here")
- CORS Allow-Methods already includes PUT (Block D fix)

Fraud engine
- Single-signal cap: it now takes ≥2 sub-scores of ≥70 to push the
  final into HIGH. Fixes the well-known "second visit with a slightly
  shifted fingerprint scores 60+" false positive
- Engine band remains advisory; API re-bands using per-event
  thresholds before deciding to block

Frontend
- SecurityCard.vue: visual band ribbon (proportional to thresholds),
  three sliders with mutual clamping so dragging medium past high
  pushes high (not an invalid ordering), reset-to-defaults button,
  CIDR allowlist with inline add + per-row remove, verdict-history
  inbox. Toast feedback on save/add/remove
- "Security" tab added to the event-detail tab nav (5th tab,
  right of Analytics)
- Viewer role hides write affordances; server enforces too

Tests
- Domain: ThresholdsBand, ThresholdsValid, ParseAllowlistCIDR (bare
  IP widening + traversal/typo rejection), FraudFeedbackValid
- Integration: thresholds round-trip + invalid ordering rejection,
  allowlist CRUD + duplicate 409 + invalid CIDR 400 + IP auto-widen,
  feedback record + upsert + cross-tenant 404 + invalid verdict 400,
  viewer can read / editor can write / outsider gets 404
- Full integration suite green (315.8s, all 36 top-level tests pass)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 21:33:57 +01:00
Kwaku Danso e5b187c575 feat(tier2): event branding + UX polish — Block D
Backend
- Migration 0010 adds event_branding (one row per event; all fields
  nullable so a brand-new event renders with defaults)
- BrandingRepo with COALESCE/NULLIF upsert semantics: nil pointer
  preserves the existing value, "" clears the field to NULL
- internal/uploads package: ImageStore interface + LocalFSStore (dev),
  pure-stdlib decode + re-encode that strips EXIF and rejects anything
  that isn't valid JPEG/PNG. Size cap 2 MB, random 16-byte filenames
- GET /events/{id}/branding (viewer+) returns the row plus the
  AllowedFonts list so the frontend picker stays in sync
- PUT /events/{id}/branding (editor+) validates hex colours, font
  allowlist, and refuses image URLs whose path doesn't start with
  /uploads/ (blocks arbitrary-origin <img> smuggling on guest pages)
- POST /uploads/image (authed) → fresh CDN URL; GET /uploads/{file}
  serves with year-long cache (immutable random names)
- GET /access/{token} now embeds the host's branding so the RSVP page
  can render in their colours/font with their logo + cover
- docker-compose mounts a named volume for uploads
- Custom-domain sub-block deferred to Tier 3 per the plan

Frontend
- BrandingCard.vue: colour pickers, font dropdown, logo + cover upload
  with progressive disclosure, live preview pane that re-renders on
  every keystroke
- RSVP page applies branding via CSS vars at the section root, so
  primary colour theme + font cascade through every child card. Cover
  image renders as a banner above the form; logo lands in the header
- Submit button background switches to var(--brand-primary) when set
- Mounted on the event detail page below the guests block

Plus the small UX fixes from the e2e walkthrough:
- Nav: dropped the top-level "Events" link; the logo doubles as the
  home affordance (→ /dashboard when signed in, → / otherwise). Account
  + Billing + Sign out live under a profile dropdown (avatar with
  initials, opens on click, closes on outside-click / Esc / route nav)
- Renamed "Back to dashboard" → "Back to events" across event detail,
  billing, account, and new-event pages

Tests
- TestBrandingGetReturnsDefaults / TestBrandingPutPersists /
  TestBrandingPutRejectsBadInputs / TestUploadAndServeImage /
  TestUploadRejectsNonImage — all pass
- Domain tests for IsValidHexColor + IsAllowedFont
- Full integration suite green (176s)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 12:04:09 +01:00
Kwaku Danso 6803d700b4 feat(tier2): calendar integration — Block B
After confirming attendance (or revisiting an already-attending RSVP),
guests can add the event to Google, Outlook, Apple, or any iCalendar
client with one click.

- internal/calendar package builds an RFC 5545 VCALENDAR plus the three
  provider deep-links from a narrow Event projection. Times are emitted
  as Z-suffixed UTC; the UID is deterministic (event-uuid@guestguard)
  so re-downloads update the same calendar entry instead of duplicating
- GET /access/{token}/calendar.ics returns the .ics with a slugified
  filename in Content-Disposition; same rate-limit class as /access
- GET /access/{token} response now embeds google/outlook/yahoo/ics URLs
  so the frontend doesn't re-encode per provider
- AddToCalendar.vue renders the four buttons; shown only when the
  guest is attending (declined/maybe don't need a calendar entry)
- Unit tests cover ics field presence, RFC 5545 escaping (comma,
  semicolon), CRLF line endings, explicit-end handling, omitted
  optionals, provider URL shape, filename slugification
- Integration: ics download returns valid VCALENDAR with the event
  UID + name; unknown token returns 404; /access embeds the links

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 19:36:06 +01:00
Kwaku Danso 39533162bb feat(tier2): editable RSVPs — Block A
Guests can revisit their invitation link and change their response
or plus-ones up to 5 times. Each prior state is snapshotted into
`rsvp_revisions` and surfaced to the host via a per-guest history
modal on the event detail page.

- Migration 0007 adds rsvp_revisions + rsvps.edit_count (with down)
- RSVPRepo.Update wraps snapshot+update+counter in one transaction,
  FOR UPDATE-locking the row so concurrent edits can't bypass the cap
- PATCH /rsvp/{token} re-runs the fraud check on every edit attempt
  (different device on an edit is itself a signal)
- POST /rsvp no longer marks the token used — the link stays valid
  so the guest can come back to edit
- GET /access/{token} now embeds the existing RSVP so the frontend
  renders an edit form instead of a blank submit form on revisit
- New host endpoint GET /events/{id}/guests/{guest_id}/rsvp/history
- Frontend: rsvp/[token].vue toggles between summary + edit form,
  surfaces edits-remaining; dashboard adds a "History" action on
  responded guests opening a revision-trail modal

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 19:27:50 +01:00
Kwaku Danso 3f8bc58ca9 feat: build core API, fraud engine, notifier, and frontend
Phase 1 — Core API (Go):
- Events, guests, tokens, RSVPs CRUD on PostgreSQL via pgx/v5
- HMAC-signed per-guest tokens with format validation
- Health endpoint with DB ping, slog JSON logging, graceful shutdown

Phase 2 — NATS + Fraud Engine:
- NATS JetStream pub/sub with explicit-ack consumers
- Python/FastAPI fraud engine with heuristic risk scoring
  (fingerprint mismatch, IP change, missing signals, repeated access)
- gRPC sync scoring with 250ms fail-open timeout
- Per-guest baseline tracking; risk bands low/medium/high/block

Phase 3 — Notifications + Frontend:
- Notification worker scaffolding (Twilio/SES stubs, retry/backoff)
- Nuxt 3 frontend with Tailwind dark theme + brand green
- Live monitor via WebSocket with auto-reconnect
- Activity history endpoint backfills monitor with RSVPs +
  scored access checks (including blocked attempts)

UX polish:
- Marketing-friendly landing page (hero mockup, how-it-works,
  features, use cases, testimonials, FAQ, final CTA)
- Animated layered card mockups on landing + new-event page
- Plus-ones stepper, RSVP status badges, filter buttons
- Friendly access-check labels (Verified/Review/Suspicious/Blocked)
- Dashboard hydration fix via ClientOnly wrapper

Infrastructure:
- docker-compose for full local dev (postgres, nats, api,
  fraud-engine, notifier, frontend)
- Multi-stage Dockerfiles, non-root UID 1000
- Integration tests with testcontainers-go

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 21:08:56 +01:00