Files
where_woof/openspec/changes/scan-limits-gating/design.md

2.4 KiB

Context

The alert path already has location-aware throttling + fingerprint blocking. This change adds cost protection and the 2014 paid-gating model using existing schema only (no Stripe). The 2014 code suspended SMS on is_paid/late-payment; our equivalent is orders.status != 'paid'.

Goals / Non-Goals

Goals:

  • Non-paid orders ⇒ no alert SMS (record scans only).
  • Per-tag daily alert cap + per-IP hourly rate limit (configurable, env-overridable).
  • Zero new schema, zero external services.

Non-Goals:

  • Payment gateway (Stripe/Paddle) — later; it will simply set orders.status.
  • Plan tiers/entitlements beyond the daily/hourly caps.
  • Admin UI changes (TagResource already shows the order relation).

Decisions

  1. Paid-gating in shouldAlert: load the tag's order (via tag.order_id + a GetOrderByID query); if order exists and status != 'paid' → false. No order → proceed (transitional — most tags aren't sales-linked yet).
  2. Per-tag daily cap: CountAlertsByTagSince(tagID, now()-24h); maxAlertsPerTagDay default 5, env ALERT_MAX_TAG_DAY.
  3. Per-IP hourly limit: need the scanner IP → CountAlertsByIPSince(ip, now()-1h); but scans has no IP column! Options: (a) add scans.ip TEXT (idempotent ALTER) and record r.RemoteAddr host; (b) skip IP limit. Adding the column is cheap and matches the 2014 model (they tracked ip_address). Decision: add scans.ip column (idempotent) + record the client IP; maxAlertsPerIPHour default 10, env ALERT_MAX_IP_HOUR.
  4. Ordering of checks in shouldAlert: sms_enabled → owner phone → fingerprint block → paid-gatingdaily capIP rate → 10-min window rules → send.
  5. Client IP: from X-Forwarded-For if present (behind Caddy), else RemoteAddr host.

Risks / Trade-offs

  • [Tags without orders keep alerting] → intentional transitional behaviour; documented.
  • [IP capture behind proxy] → X-Forwarded-For parsing (Caddy sets it); falls back to RemoteAddr.
  • [Cap constants too strict/loose] → env-overridable; no schema change to retune.

Migration Plan

  1. db/schema.sql: idempotent ALTER TABLE scans ADD COLUMN IF NOT EXISTS ip TEXT;make db-up.
  2. sqlc: GetOrderByID, CountAlertsByTagSince, CountAlertsByIPSincemake generate.
  3. Handlers: scan.go gating + IP capture; InsertScan gains IP.
  4. Verify with extended suite; commit.

Open Questions

  • None blocking.